merge: resolve conflicts with main
- Take main's router.go, rich-text-editor.tsx, comment-card.tsx - Remove deleted daemon_pairing.go - Keep issue mention card feature
This commit is contained in:
commit
b8c784dda3
68 changed files with 2359 additions and 1139 deletions
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
|
|
@ -13,15 +12,12 @@ import (
|
|||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// mention represents a parsed @mention from markdown content.
|
||||
// mention represents a parsed @mention from markdown content (local alias).
|
||||
type mention struct {
|
||||
Type string // "member" or "agent"
|
||||
ID string // user_id or agent_id
|
||||
}
|
||||
|
||||
// mentionRe matches [@Label](mention://type/id) in markdown.
|
||||
var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
|
||||
|
||||
// statusLabels maps DB status values to human-readable labels for notifications.
|
||||
var statusLabels = map[string]string{
|
||||
"backlog": "Backlog",
|
||||
|
|
@ -59,17 +55,12 @@ func priorityLabel(p string) string {
|
|||
var emptyDetails = []byte("{}")
|
||||
|
||||
// parseMentions extracts mentions from markdown content.
|
||||
// Delegates to the shared util.ParseMentions and converts to the local type.
|
||||
func parseMentions(content string) []mention {
|
||||
matches := mentionRe.FindAllStringSubmatch(content, -1)
|
||||
seen := make(map[string]bool)
|
||||
var result []mention
|
||||
for _, m := range matches {
|
||||
key := m[1] + ":" + m[2]
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result = append(result, mention{Type: m[1], ID: m[2]})
|
||||
parsed := util.ParseMentions(content)
|
||||
result := make([]mention, len(parsed))
|
||||
for i, m := range parsed {
|
||||
result[i] = mention{Type: m.Type, ID: m.ID}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/storage"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
|
|
@ -47,7 +49,9 @@ func allowedOrigins() []string {
|
|||
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
||||
queries := db.New(pool)
|
||||
emailSvc := service.NewEmailService()
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc)
|
||||
s3 := storage.NewS3StorageFromEnv()
|
||||
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
|
|
@ -79,43 +83,37 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Post("/auth/send-code", h.SendCode)
|
||||
r.Post("/auth/verify-code", h.VerifyCode)
|
||||
|
||||
// Daemon API routes
|
||||
// Daemon API routes (all require a valid token)
|
||||
r.Route("/api/daemon", func(r chi.Router) {
|
||||
// Pairing routes — no auth required (daemon doesn't have a token yet).
|
||||
r.Post("/pairing-sessions", h.CreateDaemonPairingSession)
|
||||
r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession)
|
||||
r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession)
|
||||
r.Use(middleware.Auth(queries))
|
||||
|
||||
// Authenticated daemon routes — require daemon token (mdt_) or user JWT/PAT.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.DaemonAuth(queries))
|
||||
r.Post("/register", h.DaemonRegister)
|
||||
r.Post("/deregister", h.DaemonDeregister)
|
||||
r.Post("/heartbeat", h.DaemonHeartbeat)
|
||||
|
||||
r.Post("/register", h.DaemonRegister)
|
||||
r.Post("/deregister", h.DaemonDeregister)
|
||||
r.Post("/heartbeat", h.DaemonHeartbeat)
|
||||
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
|
||||
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
|
||||
r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage)
|
||||
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
|
||||
|
||||
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
|
||||
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
|
||||
r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage)
|
||||
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
|
||||
|
||||
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
|
||||
r.Post("/tasks/{taskId}/start", h.StartTask)
|
||||
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
|
||||
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
|
||||
r.Post("/tasks/{taskId}/fail", h.FailTask)
|
||||
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
|
||||
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
|
||||
})
|
||||
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
|
||||
r.Post("/tasks/{taskId}/start", h.StartTask)
|
||||
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
|
||||
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
|
||||
r.Post("/tasks/{taskId}/fail", h.FailTask)
|
||||
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
|
||||
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
|
||||
})
|
||||
|
||||
// Protected API routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth(queries))
|
||||
r.Use(middleware.RefreshCloudFrontCookies(cfSigner))
|
||||
|
||||
// --- User-scoped routes (no workspace context required) ---
|
||||
r.Get("/api/me", h.GetMe)
|
||||
r.Patch("/api/me", h.UpdateMe)
|
||||
r.Post("/api/upload-file", h.UploadFile)
|
||||
|
||||
r.Route("/api/workspaces", func(r chi.Router) {
|
||||
r.Get("/", h.ListWorkspaces)
|
||||
|
|
@ -150,8 +148,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Delete("/{id}", h.RevokePersonalAccessToken)
|
||||
})
|
||||
|
||||
r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession)
|
||||
|
||||
// --- Workspace-scoped routes (all require workspace membership) ---
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceMember(queries))
|
||||
|
|
@ -176,9 +172,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Get("/task-runs", h.ListTasksByIssue)
|
||||
r.Post("/reactions", h.AddIssueReaction)
|
||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||
r.Get("/attachments", h.ListAttachments)
|
||||
})
|
||||
})
|
||||
|
||||
// Attachments
|
||||
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
||||
|
||||
// Comments
|
||||
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
||||
r.Put("/", h.UpdateComment)
|
||||
|
|
|
|||
|
|
@ -3,21 +3,41 @@ module github.com/multica-ai/multica/server
|
|||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.13
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/resend/resend-go/v2 v2.28.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/lmittmann/tint v1.1.3 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
|
|
|||
|
|
@ -1,3 +1,43 @@
|
|||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
|
|
|||
203
server/internal/auth/cloudfront.go
Normal file
203
server/internal/auth/cloudfront.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||
)
|
||||
|
||||
// CloudFrontSigner generates signed cookies for CloudFront private distributions.
|
||||
type CloudFrontSigner struct {
|
||||
keyPairID string
|
||||
privateKey *rsa.PrivateKey
|
||||
domain string // CDN domain, e.g. "static.multica.ai"
|
||||
cookieDomain string // cookie scope, e.g. ".multica.ai"
|
||||
}
|
||||
|
||||
// NewCloudFrontSignerFromEnv creates a signer from environment variables.
|
||||
// Returns nil if CLOUDFRONT_KEY_PAIR_ID is not set (disables signed cookies).
|
||||
//
|
||||
// Private key resolution order:
|
||||
// 1. AWS Secrets Manager (CLOUDFRONT_PRIVATE_KEY_SECRET — secret name/ARN)
|
||||
// 2. Environment variable fallback (CLOUDFRONT_PRIVATE_KEY — base64-encoded PEM, for local dev only)
|
||||
//
|
||||
// Other required environment variables:
|
||||
// - CLOUDFRONT_KEY_PAIR_ID
|
||||
// - CLOUDFRONT_DOMAIN (e.g. "static.multica.ai")
|
||||
// - COOKIE_DOMAIN (e.g. ".multica.ai")
|
||||
func NewCloudFrontSignerFromEnv() *CloudFrontSigner {
|
||||
keyPairID := os.Getenv("CLOUDFRONT_KEY_PAIR_ID")
|
||||
if keyPairID == "" {
|
||||
slog.Info("CLOUDFRONT_KEY_PAIR_ID not set, signed cookies disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
domain := os.Getenv("CLOUDFRONT_DOMAIN")
|
||||
if domain == "" {
|
||||
slog.Error("CLOUDFRONT_DOMAIN not set")
|
||||
return nil
|
||||
}
|
||||
|
||||
cookieDomain := os.Getenv("COOKIE_DOMAIN")
|
||||
if cookieDomain == "" {
|
||||
slog.Error("COOKIE_DOMAIN not set")
|
||||
return nil
|
||||
}
|
||||
|
||||
rsaKey, err := loadPrivateKey()
|
||||
if err != nil {
|
||||
slog.Error("failed to load CloudFront private key", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("CloudFront cookie signer initialized", "key_pair_id", keyPairID, "domain", domain)
|
||||
return &CloudFrontSigner{
|
||||
keyPairID: keyPairID,
|
||||
privateKey: rsaKey,
|
||||
domain: domain,
|
||||
cookieDomain: cookieDomain,
|
||||
}
|
||||
}
|
||||
|
||||
// loadPrivateKey loads the RSA private key from Secrets Manager or env var fallback.
|
||||
func loadPrivateKey() (*rsa.PrivateKey, error) {
|
||||
// 1. Try Secrets Manager
|
||||
if secretName := os.Getenv("CLOUDFRONT_PRIVATE_KEY_SECRET"); secretName != "" {
|
||||
slog.Info("loading CloudFront private key from Secrets Manager", "secret", secretName)
|
||||
return loadKeyFromSecretsManager(secretName)
|
||||
}
|
||||
|
||||
// 2. Fallback: base64-encoded env var (local dev)
|
||||
if pkB64 := os.Getenv("CLOUDFRONT_PRIVATE_KEY"); pkB64 != "" {
|
||||
slog.Info("loading CloudFront private key from environment variable (local dev)")
|
||||
pemBytes, err := base64.StdEncoding.DecodeString(pkB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
return parseRSAPrivateKey(pemBytes)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("neither CLOUDFRONT_PRIVATE_KEY_SECRET nor CLOUDFRONT_PRIVATE_KEY is set")
|
||||
}
|
||||
|
||||
func loadKeyFromSecretsManager(secretName string) (*rsa.PrivateKey, error) {
|
||||
cfg, err := awsconfig.LoadDefaultConfig(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load AWS config: %w", err)
|
||||
}
|
||||
|
||||
client := secretsmanager.NewFromConfig(cfg)
|
||||
result, err := client.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{
|
||||
SecretId: aws.String(secretName),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get secret %q: %w", secretName, err)
|
||||
}
|
||||
|
||||
if result.SecretString == nil {
|
||||
return nil, fmt.Errorf("secret %q has no string value", secretName)
|
||||
}
|
||||
|
||||
return parseRSAPrivateKey([]byte(*result.SecretString))
|
||||
}
|
||||
|
||||
func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
|
||||
// Try PKCS8 first, then PKCS1
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||
return rsaKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("PKCS8 key is not RSA")
|
||||
}
|
||||
|
||||
rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
// SignedCookies generates the three CloudFront signed cookies with the given expiry.
|
||||
func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie {
|
||||
policy := fmt.Sprintf(`{"Statement":[{"Resource":"https://%s/*","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, s.domain, expiry.Unix())
|
||||
|
||||
encodedPolicy := cfBase64Encode([]byte(policy))
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(policy))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil))
|
||||
if err != nil {
|
||||
slog.Error("failed to sign CloudFront policy", "error", err)
|
||||
return nil
|
||||
}
|
||||
encodedSig := cfBase64Encode(sig)
|
||||
|
||||
cookieAttrs := func(name, value string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Domain: s.cookieDomain,
|
||||
Path: "/",
|
||||
Expires: expiry,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
}
|
||||
}
|
||||
|
||||
return []*http.Cookie{
|
||||
cookieAttrs("CloudFront-Policy", encodedPolicy),
|
||||
cookieAttrs("CloudFront-Signature", encodedSig),
|
||||
cookieAttrs("CloudFront-Key-Pair-Id", s.keyPairID),
|
||||
}
|
||||
}
|
||||
|
||||
// SignedURL generates a CloudFront signed URL for the given resource URL.
|
||||
// Used by CLI/API clients that don't have browser cookies.
|
||||
func (s *CloudFrontSigner) SignedURL(rawURL string, expiry time.Time) string {
|
||||
policy := fmt.Sprintf(`{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, rawURL, expiry.Unix())
|
||||
|
||||
encodedPolicy := cfBase64Encode([]byte(policy))
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(policy))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil))
|
||||
if err != nil {
|
||||
slog.Error("failed to sign CloudFront URL", "error", err)
|
||||
return rawURL
|
||||
}
|
||||
encodedSig := cfBase64Encode(sig)
|
||||
|
||||
separator := "?"
|
||||
if strings.Contains(rawURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return fmt.Sprintf("%s%sPolicy=%s&Signature=%s&Key-Pair-Id=%s", rawURL, separator, encodedPolicy, encodedSig, s.keyPairID)
|
||||
}
|
||||
|
||||
// cfBase64Encode applies CloudFront's URL-safe base64 encoding.
|
||||
func cfBase64Encode(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
r := strings.NewReplacer("+", "-", "=", "_", "/", "~")
|
||||
return r.Replace(encoded)
|
||||
}
|
||||
|
|
@ -25,11 +25,12 @@ type TimelineEntry struct {
|
|||
Details json.RawMessage `json:"details,omitempty"`
|
||||
|
||||
// Comment-only fields
|
||||
Content *string `json:"content,omitempty"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty"`
|
||||
CommentType *string `json:"comment_type,omitempty"`
|
||||
Reactions []ReactionResponse `json:"reactions,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty"`
|
||||
CommentType *string `json:"comment_type,omitempty"`
|
||||
Reactions []ReactionResponse `json:"reactions,omitempty"`
|
||||
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// ListTimeline returns a merged, chronologically-sorted timeline of activities
|
||||
|
|
@ -79,20 +80,22 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
// Fetch reactions for all comments in one batch.
|
||||
// Fetch reactions and attachments for all comments in one batch.
|
||||
commentIDs := make([]pgtype.UUID, len(comments))
|
||||
for i, c := range comments {
|
||||
commentIDs[i] = c.ID
|
||||
}
|
||||
grouped := h.groupReactions(r, commentIDs)
|
||||
groupedAtt := h.groupAttachments(r, commentIDs)
|
||||
|
||||
for _, c := range comments {
|
||||
content := c.Content
|
||||
commentType := c.Type
|
||||
updatedAt := timestampToString(c.UpdatedAt)
|
||||
cid := uuidToString(c.ID)
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
Type: "comment",
|
||||
ID: uuidToString(c.ID),
|
||||
ID: cid,
|
||||
ActorType: c.AuthorType,
|
||||
ActorID: uuidToString(c.AuthorID),
|
||||
Content: &content,
|
||||
|
|
@ -100,7 +103,8 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
|||
ParentID: uuidToPtr(c.ParentID),
|
||||
CreatedAt: timestampToString(c.CreatedAt),
|
||||
UpdatedAt: &updatedAt,
|
||||
Reactions: grouped[uuidToString(c.ID)],
|
||||
Reactions: grouped[cid],
|
||||
Attachments: groupedAtt[cid],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -328,24 +328,23 @@ type UpdateAgentRequest struct {
|
|||
}
|
||||
|
||||
// canManageAgent checks whether the current user can update or delete an agent.
|
||||
// Workspace-visible agents require owner/admin role. Private agents additionally
|
||||
// require the user to be the agent's owner (or a workspace owner/admin).
|
||||
// Workspace-visible agents can be managed by any workspace member.
|
||||
// Private agents can only be managed by their owner or workspace owner/admin.
|
||||
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
|
||||
wsID := uuidToString(agent.WorkspaceID)
|
||||
member, ok := h.requireWorkspaceRole(w, r, wsID, "agent not found", "owner", "admin", "member")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if agent.Visibility != "private" {
|
||||
return true
|
||||
}
|
||||
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
||||
isAgentOwner := uuidToString(agent.OwnerID) == requestUserID(r)
|
||||
if agent.Visibility == "private" && !isAdmin && !isAgentOwner {
|
||||
if !isAdmin && !isAgentOwner {
|
||||
writeError(w, http.StatusForbidden, "only the agent owner can manage this private agent")
|
||||
return false
|
||||
}
|
||||
if agent.Visibility != "private" && !isAdmin && !isAgentOwner {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,13 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Set CloudFront signed cookies for CDN access.
|
||||
if h.CFSigner != nil {
|
||||
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
||||
writeJSON(w, http.StatusOK, LoginResponse{
|
||||
Token: tokenString,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
|
@ -8,38 +9,44 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type CommentResponse struct {
|
||||
ID string `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Reactions []ReactionResponse `json:"reactions"`
|
||||
ID string `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Reactions []ReactionResponse `json:"reactions"`
|
||||
Attachments []AttachmentResponse `json:"attachments"`
|
||||
}
|
||||
|
||||
func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentResponse {
|
||||
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
|
||||
if reactions == nil {
|
||||
reactions = []ReactionResponse{}
|
||||
}
|
||||
if attachments == nil {
|
||||
attachments = []AttachmentResponse{}
|
||||
}
|
||||
return CommentResponse{
|
||||
ID: uuidToString(c.ID),
|
||||
IssueID: uuidToString(c.IssueID),
|
||||
AuthorType: c.AuthorType,
|
||||
AuthorID: uuidToString(c.AuthorID),
|
||||
Content: c.Content,
|
||||
Type: c.Type,
|
||||
ParentID: uuidToPtr(c.ParentID),
|
||||
CreatedAt: timestampToString(c.CreatedAt),
|
||||
UpdatedAt: timestampToString(c.UpdatedAt),
|
||||
Reactions: reactions,
|
||||
ID: uuidToString(c.ID),
|
||||
IssueID: uuidToString(c.IssueID),
|
||||
AuthorType: c.AuthorType,
|
||||
AuthorID: uuidToString(c.AuthorID),
|
||||
Content: c.Content,
|
||||
Type: c.Type,
|
||||
ParentID: uuidToPtr(c.ParentID),
|
||||
CreatedAt: timestampToString(c.CreatedAt),
|
||||
UpdatedAt: timestampToString(c.UpdatedAt),
|
||||
Reactions: reactions,
|
||||
Attachments: attachments,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,10 +71,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
|||
commentIDs[i] = c.ID
|
||||
}
|
||||
grouped := h.groupReactions(r, commentIDs)
|
||||
groupedAtt := h.groupAttachments(r, commentIDs)
|
||||
|
||||
resp := make([]CommentResponse, len(comments))
|
||||
for i, c := range comments {
|
||||
resp[i] = commentToResponse(c, grouped[uuidToString(c.ID)])
|
||||
cid := uuidToString(c.ID)
|
||||
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
|
@ -133,7 +142,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
resp := commentToResponse(comment, nil)
|
||||
resp := commentToResponse(comment, nil, nil)
|
||||
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
||||
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
||||
"comment": resp,
|
||||
|
|
@ -145,7 +154,10 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
|
||||
// Skip when the comment comes from the assigned agent itself to avoid loops.
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) {
|
||||
// Also skip when the comment @mentions others but not the assignee agent —
|
||||
// the user is talking to someone else, not requesting work from the assignee.
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) {
|
||||
// Resolve thread root: if the comment is a reply, agent should reply
|
||||
// to the thread root (matching frontend behavior where all replies
|
||||
// in a thread share the same top-level parent).
|
||||
|
|
@ -158,9 +170,82 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
|
||||
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, authorType, authorID)
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
|
||||
// anyone but does NOT @mention the issue's assignee agent. This is used to
|
||||
// suppress the on_comment trigger when the user is directing their comment at
|
||||
// someone else (e.g. sharing results with a colleague, asking another agent).
|
||||
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
|
||||
mentions := util.ParseMentions(content)
|
||||
if len(mentions) == 0 {
|
||||
return false // No mentions — normal on_comment behavior
|
||||
}
|
||||
if !issue.AssigneeID.Valid {
|
||||
return true // No assignee — mentions target others
|
||||
}
|
||||
assigneeID := uuidToString(issue.AssigneeID)
|
||||
for _, m := range mentions {
|
||||
if m.ID == assigneeID {
|
||||
return false // Assignee is mentioned — allow trigger
|
||||
}
|
||||
}
|
||||
return true // Others mentioned but not assignee — suppress trigger
|
||||
}
|
||||
|
||||
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
||||
// enqueues a task for each mentioned agent. Skips self-mentions, agents that
|
||||
// are already the issue's assignee (handled by on_comment), and agents with
|
||||
// on_mention trigger disabled.
|
||||
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, authorType, authorID string) {
|
||||
// Don't trigger on terminal statuses.
|
||||
if issue.Status == "done" || issue.Status == "cancelled" {
|
||||
return
|
||||
}
|
||||
|
||||
mentions := util.ParseMentions(comment.Content)
|
||||
for _, m := range mentions {
|
||||
if m.Type != "agent" {
|
||||
continue
|
||||
}
|
||||
// Prevent self-trigger: skip if the comment author is this agent.
|
||||
if authorType == "agent" && authorID == m.ID {
|
||||
continue
|
||||
}
|
||||
agentUUID := parseUUID(m.ID)
|
||||
// Prevent duplicate: skip if this agent is the issue's assignee
|
||||
// (already handled by the on_comment trigger above).
|
||||
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
|
||||
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
||||
continue
|
||||
}
|
||||
// Check if the agent has on_mention trigger enabled.
|
||||
if !h.isAgentMentionTriggerEnabled(ctx, agentUUID) {
|
||||
continue
|
||||
}
|
||||
// Dedup: skip if this agent already has a pending task for this issue.
|
||||
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
||||
IssueID: issue.ID,
|
||||
AgentID: agentUUID,
|
||||
})
|
||||
if err != nil || hasPending {
|
||||
continue
|
||||
}
|
||||
// Resolve thread root for reply threading.
|
||||
replyTo := comment.ID
|
||||
if comment.ParentID.Valid {
|
||||
replyTo = comment.ParentID
|
||||
}
|
||||
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, replyTo); err != nil {
|
||||
slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
|
|
@ -215,9 +300,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Fetch reactions for the updated comment.
|
||||
// Fetch reactions and attachments for the updated comment.
|
||||
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
|
||||
resp := commentToResponse(comment, grouped[uuidToString(comment.ID)])
|
||||
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
||||
cid := uuidToString(comment.ID)
|
||||
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
|
||||
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
||||
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "at least one runtime is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the caller is a member of the target workspace.
|
||||
if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "workspace not found")
|
||||
|
|
|
|||
|
|
@ -1,424 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
const daemonPairingTTL = 10 * time.Minute
|
||||
|
||||
type daemonPairingSessionRecord struct {
|
||||
Token string
|
||||
DaemonID string
|
||||
DeviceName string
|
||||
RuntimeName string
|
||||
RuntimeType string
|
||||
RuntimeVersion string
|
||||
WorkspaceID pgtype.UUID
|
||||
ApprovedBy pgtype.UUID
|
||||
Status string
|
||||
ApprovedAt pgtype.Timestamptz
|
||||
ClaimedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type DaemonPairingSessionResponse struct {
|
||||
Token string `json:"token"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeVersion string `json:"runtime_version"`
|
||||
WorkspaceID *string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt *string `json:"approved_at"`
|
||||
ClaimedAt *string `json:"claimed_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LinkURL *string `json:"link_url,omitempty"`
|
||||
DaemonToken *string `json:"daemon_token,omitempty"`
|
||||
}
|
||||
|
||||
type CreateDaemonPairingSessionRequest struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
RuntimeName string `json:"runtime_name"`
|
||||
RuntimeType string `json:"runtime_type"`
|
||||
RuntimeVersion string `json:"runtime_version"`
|
||||
}
|
||||
|
||||
type ApproveDaemonPairingSessionRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func daemonAppBaseURL() string {
|
||||
for _, key := range []string{"MULTICA_APP_URL", "FRONTEND_ORIGIN"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return strings.TrimRight(value, "/")
|
||||
}
|
||||
}
|
||||
return "http://localhost:3000"
|
||||
}
|
||||
|
||||
func daemonPairingLinkURL(token string) string {
|
||||
base := daemonAppBaseURL()
|
||||
return base + "/pair/local?token=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
func daemonPairingSessionToResponse(rec daemonPairingSessionRecord, includeLink bool) DaemonPairingSessionResponse {
|
||||
resp := DaemonPairingSessionResponse{
|
||||
Token: rec.Token,
|
||||
DaemonID: rec.DaemonID,
|
||||
DeviceName: rec.DeviceName,
|
||||
RuntimeName: rec.RuntimeName,
|
||||
RuntimeType: rec.RuntimeType,
|
||||
RuntimeVersion: rec.RuntimeVersion,
|
||||
WorkspaceID: uuidToPtr(rec.WorkspaceID),
|
||||
Status: rec.Status,
|
||||
ApprovedAt: timestampToPtr(rec.ApprovedAt),
|
||||
ClaimedAt: timestampToPtr(rec.ClaimedAt),
|
||||
ExpiresAt: timestampToString(rec.ExpiresAt),
|
||||
CreatedAt: timestampToString(rec.CreatedAt),
|
||||
UpdatedAt: timestampToString(rec.UpdatedAt),
|
||||
}
|
||||
if includeLink {
|
||||
link := daemonPairingLinkURL(rec.Token)
|
||||
resp.LinkURL = &link
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func randomDaemonPairingToken() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (h *Handler) getDaemonPairingSession(ctx context.Context, token string) (daemonPairingSessionRecord, error) {
|
||||
if h.DB == nil {
|
||||
return daemonPairingSessionRecord{}, fmt.Errorf("database executor is not configured")
|
||||
}
|
||||
|
||||
var rec daemonPairingSessionRecord
|
||||
err := h.DB.QueryRow(ctx, `
|
||||
SELECT
|
||||
token,
|
||||
daemon_id,
|
||||
device_name,
|
||||
runtime_name,
|
||||
runtime_type,
|
||||
runtime_version,
|
||||
workspace_id,
|
||||
approved_by,
|
||||
status,
|
||||
approved_at,
|
||||
claimed_at,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM daemon_pairing_session
|
||||
WHERE token = $1
|
||||
`, token).Scan(
|
||||
&rec.Token,
|
||||
&rec.DaemonID,
|
||||
&rec.DeviceName,
|
||||
&rec.RuntimeName,
|
||||
&rec.RuntimeType,
|
||||
&rec.RuntimeVersion,
|
||||
&rec.WorkspaceID,
|
||||
&rec.ApprovedBy,
|
||||
&rec.Status,
|
||||
&rec.ApprovedAt,
|
||||
&rec.ClaimedAt,
|
||||
&rec.ExpiresAt,
|
||||
&rec.CreatedAt,
|
||||
&rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return daemonPairingSessionRecord{}, err
|
||||
}
|
||||
|
||||
if rec.Status == "pending" && rec.ExpiresAt.Valid && rec.ExpiresAt.Time.Before(time.Now()) {
|
||||
if _, err := h.DB.Exec(ctx, `
|
||||
UPDATE daemon_pairing_session
|
||||
SET status = 'expired', updated_at = now()
|
||||
WHERE token = $1 AND status = 'pending'
|
||||
`, token); err == nil {
|
||||
rec.Status = "expired"
|
||||
rec.UpdatedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreateDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
||||
if h.DB == nil {
|
||||
writeError(w, http.StatusInternalServerError, "database executor is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateDaemonPairingSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
req.DaemonID = strings.TrimSpace(req.DaemonID)
|
||||
req.DeviceName = strings.TrimSpace(req.DeviceName)
|
||||
req.RuntimeName = strings.TrimSpace(req.RuntimeName)
|
||||
req.RuntimeType = strings.TrimSpace(req.RuntimeType)
|
||||
req.RuntimeVersion = strings.TrimSpace(req.RuntimeVersion)
|
||||
|
||||
if req.DaemonID == "" {
|
||||
writeError(w, http.StatusBadRequest, "daemon_id is required")
|
||||
return
|
||||
}
|
||||
if req.DeviceName == "" {
|
||||
writeError(w, http.StatusBadRequest, "device_name is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeName == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_name is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeType == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_type is required")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := randomDaemonPairingToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create pairing token")
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(daemonPairingTTL)
|
||||
var rec daemonPairingSessionRecord
|
||||
err = h.DB.QueryRow(r.Context(), `
|
||||
INSERT INTO daemon_pairing_session (
|
||||
token,
|
||||
daemon_id,
|
||||
device_name,
|
||||
runtime_name,
|
||||
runtime_type,
|
||||
runtime_version,
|
||||
expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING
|
||||
token,
|
||||
daemon_id,
|
||||
device_name,
|
||||
runtime_name,
|
||||
runtime_type,
|
||||
runtime_version,
|
||||
workspace_id,
|
||||
approved_by,
|
||||
status,
|
||||
approved_at,
|
||||
claimed_at,
|
||||
expires_at,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
token,
|
||||
req.DaemonID,
|
||||
req.DeviceName,
|
||||
req.RuntimeName,
|
||||
req.RuntimeType,
|
||||
req.RuntimeVersion,
|
||||
expiresAt,
|
||||
).Scan(
|
||||
&rec.Token,
|
||||
&rec.DaemonID,
|
||||
&rec.DeviceName,
|
||||
&rec.RuntimeName,
|
||||
&rec.RuntimeType,
|
||||
&rec.RuntimeVersion,
|
||||
&rec.WorkspaceID,
|
||||
&rec.ApprovedBy,
|
||||
&rec.Status,
|
||||
&rec.ApprovedAt,
|
||||
&rec.ClaimedAt,
|
||||
&rec.ExpiresAt,
|
||||
&rec.CreatedAt,
|
||||
&rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create pairing session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, daemonPairingSessionToResponse(rec, true))
|
||||
}
|
||||
|
||||
func (h *Handler) GetDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
rec, err := h.getDaemonPairingSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pairing session not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
||||
}
|
||||
|
||||
func (h *Handler) ApproveDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
rec, err := h.getDaemonPairingSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pairing session not found")
|
||||
return
|
||||
}
|
||||
if rec.Status == "expired" {
|
||||
writeError(w, http.StatusBadRequest, "pairing session expired")
|
||||
return
|
||||
}
|
||||
if rec.Status == "claimed" {
|
||||
writeError(w, http.StatusBadRequest, "pairing session already claimed")
|
||||
return
|
||||
}
|
||||
if rec.Status == "approved" {
|
||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
||||
return
|
||||
}
|
||||
|
||||
var req ApproveDaemonPairingSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.WorkspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if h.DB == nil {
|
||||
writeError(w, http.StatusInternalServerError, "database executor is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.DB.Exec(r.Context(), `
|
||||
UPDATE daemon_pairing_session
|
||||
SET
|
||||
workspace_id = $2,
|
||||
approved_by = $3,
|
||||
status = 'approved',
|
||||
approved_at = now(),
|
||||
updated_at = now()
|
||||
WHERE token = $1 AND status = 'pending'
|
||||
`, token, parseUUID(req.WorkspaceID), parseUUID(userID)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to approve pairing session")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err = h.getDaemonPairingSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to reload pairing session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
||||
}
|
||||
|
||||
func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
rec, err := h.getDaemonPairingSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pairing session not found")
|
||||
return
|
||||
}
|
||||
if rec.Status == "claimed" {
|
||||
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
|
||||
return
|
||||
}
|
||||
if rec.Status != "approved" {
|
||||
writeError(w, http.StatusBadRequest, "pairing session is not approved")
|
||||
return
|
||||
}
|
||||
|
||||
if h.DB == nil {
|
||||
writeError(w, http.StatusInternalServerError, "database executor is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.DB.Exec(r.Context(), `
|
||||
UPDATE daemon_pairing_session
|
||||
SET
|
||||
status = 'claimed',
|
||||
claimed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE token = $1 AND status = 'approved'
|
||||
`, token); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to claim pairing session")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err = h.getDaemonPairingSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to reload pairing session")
|
||||
return
|
||||
}
|
||||
|
||||
resp := daemonPairingSessionToResponse(rec, true)
|
||||
|
||||
// Issue a daemon auth token bound to the workspace and daemon.
|
||||
if rec.WorkspaceID.Valid {
|
||||
plainToken, err := auth.GenerateDaemonToken()
|
||||
if err != nil {
|
||||
slog.Error("failed to generate daemon token", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate daemon token")
|
||||
return
|
||||
}
|
||||
hash := auth.HashToken(plainToken)
|
||||
|
||||
// Revoke any existing tokens for this workspace+daemon pair.
|
||||
_ = h.Queries.DeleteDaemonTokensByWorkspaceAndDaemon(r.Context(), db.DeleteDaemonTokensByWorkspaceAndDaemonParams{
|
||||
WorkspaceID: rec.WorkspaceID,
|
||||
DaemonID: rec.DaemonID,
|
||||
})
|
||||
|
||||
_, err = h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{
|
||||
TokenHash: hash,
|
||||
WorkspaceID: rec.WorkspaceID,
|
||||
DaemonID: rec.DaemonID,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(365 * 24 * time.Hour), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to store daemon token", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to store daemon token")
|
||||
return
|
||||
}
|
||||
|
||||
resp.DaemonToken = &plainToken
|
||||
slog.Info("daemon token issued", "daemon_id", rec.DaemonID, "workspace_id", uuidToPtr(rec.WorkspaceID))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
296
server/internal/handler/file.go
Normal file
296
server/internal/handler/file.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
const maxUploadSize = 10 << 20 // 10 MB
|
||||
|
||||
// Allowed MIME type prefixes and exact types for uploads.
|
||||
var allowedContentTypes = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/svg+xml": true,
|
||||
"application/pdf": true,
|
||||
"text/plain": true,
|
||||
"text/csv": true,
|
||||
"application/json": true,
|
||||
"video/mp4": true,
|
||||
"video/webm": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/wav": true,
|
||||
"application/zip": true,
|
||||
}
|
||||
|
||||
func isContentTypeAllowed(ct string) bool {
|
||||
// Normalize: take only the media type, strip parameters like charset.
|
||||
ct = strings.TrimSpace(strings.SplitN(ct, ";", 2)[0])
|
||||
ct = strings.ToLower(ct)
|
||||
return allowedContentTypes[ct]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AttachmentResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
IssueID *string `json:"issue_id"`
|
||||
CommentID *string `json:"comment_id"`
|
||||
UploaderType string `json:"uploader_type"`
|
||||
UploaderID string `json:"uploader_id"`
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse {
|
||||
resp := AttachmentResponse{
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
UploaderType: a.UploaderType,
|
||||
UploaderID: uuidToString(a.UploaderID),
|
||||
Filename: a.Filename,
|
||||
URL: a.Url,
|
||||
DownloadURL: a.Url,
|
||||
ContentType: a.ContentType,
|
||||
SizeBytes: a.SizeBytes,
|
||||
CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if h.CFSigner != nil {
|
||||
resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(5*time.Minute))
|
||||
}
|
||||
if a.IssueID.Valid {
|
||||
s := uuidToString(a.IssueID)
|
||||
resp.IssueID = &s
|
||||
}
|
||||
if a.CommentID.Valid {
|
||||
s := uuidToString(a.CommentID)
|
||||
resp.CommentID = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// groupAttachments loads attachments for multiple comments and groups them by comment ID.
|
||||
func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse {
|
||||
if len(commentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs)
|
||||
if err != nil {
|
||||
slog.Error("failed to load attachments for comments", "error", err)
|
||||
return nil
|
||||
}
|
||||
grouped := make(map[string][]AttachmentResponse, len(commentIDs))
|
||||
for _, a := range attachments {
|
||||
cid := uuidToString(a.CommentID)
|
||||
grouped[cid] = append(grouped[cid], h.attachmentToResponse(a))
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UploadFile — POST /api/upload-file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Storage == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing file field: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Sniff actual content type from file bytes instead of trusting the client header.
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
contentType := http.DetectContentType(buf[:n])
|
||||
if !isContentTypeAllowed(contentType) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("file type not allowed: %s", contentType))
|
||||
return
|
||||
}
|
||||
// Seek back so the full file is uploaded.
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to read file")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
slog.Error("failed to generate file key", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
key := hex.EncodeToString(b) + path.Ext(header.Filename)
|
||||
|
||||
link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename)
|
||||
if err != nil {
|
||||
slog.Error("file upload failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "upload failed")
|
||||
return
|
||||
}
|
||||
|
||||
// If workspace context is available, create an attachment record.
|
||||
if workspaceID != "" {
|
||||
uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID)
|
||||
|
||||
params := db.CreateAttachmentParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UploaderType: uploaderType,
|
||||
UploaderID: parseUUID(uploaderID),
|
||||
Filename: header.Filename,
|
||||
Url: link,
|
||||
ContentType: contentType,
|
||||
SizeBytes: int64(len(data)),
|
||||
}
|
||||
|
||||
// Optional issue_id / comment_id from form fields
|
||||
if issueID := r.FormValue("issue_id"); issueID != "" {
|
||||
params.IssueID = parseUUID(issueID)
|
||||
}
|
||||
if commentID := r.FormValue("comment_id"); commentID != "" {
|
||||
params.CommentID = parseUUID(commentID)
|
||||
}
|
||||
|
||||
att, err := h.Queries.CreateAttachment(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Error("failed to create attachment record", "error", err)
|
||||
// S3 upload succeeded but DB record failed — still return the link
|
||||
// so the file is usable. Log the error for investigation.
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, h.attachmentToResponse(att))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback response (no workspace context, e.g. avatar upload)
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"filename": header.Filename,
|
||||
"link": link,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListAttachments — GET /api/issues/{id}/attachments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
issueID := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to list attachments", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to list attachments")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AttachmentResponse, len(attachments))
|
||||
for i, a := range attachments {
|
||||
resp[i] = h.attachmentToResponse(a)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteAttachment — DELETE /api/attachments/{id}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "id")
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{
|
||||
ID: parseUUID(attachmentID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Only the uploader (or workspace admin) can delete
|
||||
uploaderID := uuidToString(att.UploaderID)
|
||||
isUploader := att.UploaderType == "member" && uploaderID == userID
|
||||
member, hasMember := ctxMember(r.Context())
|
||||
isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner")
|
||||
|
||||
if !isUploader && !isAdmin {
|
||||
writeError(w, http.StatusForbidden, "not authorized to delete this attachment")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{
|
||||
ID: att.ID,
|
||||
WorkspaceID: att.WorkspaceID,
|
||||
}); err != nil {
|
||||
slog.Error("failed to delete attachment", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete attachment")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
@ -12,10 +12,12 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/storage"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
)
|
||||
|
||||
|
|
@ -38,9 +40,11 @@ type Handler struct {
|
|||
TaskService *service.TaskService
|
||||
EmailService *service.EmailService
|
||||
PingStore *PingStore
|
||||
Storage *storage.S3Storage
|
||||
CFSigner *auth.CloudFrontSigner
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService) *Handler {
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
|
||||
var executor dbExecutor
|
||||
if candidate, ok := txStarter.(dbExecutor); ok {
|
||||
executor = candidate
|
||||
|
|
@ -55,6 +59,8 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
|
|||
TaskService: service.NewTaskService(queries, hub, bus),
|
||||
EmailService: emailService,
|
||||
PingStore: NewPingStore(),
|
||||
Storage: s3,
|
||||
CFSigner: cfSigner,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func TestMain(m *testing.M) {
|
|||
go hub.Run()
|
||||
bus := events.New()
|
||||
emailSvc := service.NewEmailService()
|
||||
testHandler = New(queries, pool, hub, bus, emailSvc)
|
||||
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil)
|
||||
testPool = pool
|
||||
|
||||
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
||||
|
|
@ -729,6 +729,7 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
|
|||
"runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}]
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", testUserID)
|
||||
|
||||
testHandler.DaemonRegister(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
|
|
|
|||
|
|
@ -515,6 +515,30 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri
|
|||
return false
|
||||
}
|
||||
|
||||
// isAgentMentionTriggerEnabled checks if a specific agent has the on_mention
|
||||
// trigger enabled. Unlike isAgentTriggerEnabled, this takes an explicit agent
|
||||
// ID rather than deriving it from the issue assignee.
|
||||
func (h *Handler) isAgentMentionTriggerEnabled(ctx context.Context, agentID pgtype.UUID) bool {
|
||||
agent, err := h.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil || !agent.RuntimeID.Valid {
|
||||
return false
|
||||
}
|
||||
if agent.Triggers == nil || len(agent.Triggers) == 0 {
|
||||
return true // No config = all triggers enabled by default
|
||||
}
|
||||
|
||||
var triggers []agentTriggerSnapshot
|
||||
if err := json.Unmarshal(agent.Triggers, &triggers); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Type == "on_mention" {
|
||||
return trigger.Enabled
|
||||
}
|
||||
}
|
||||
return true // on_mention not configured = enabled by default
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
|
|
|
|||
28
server/internal/middleware/cloudfront.go
Normal file
28
server/internal/middleware/cloudfront.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
)
|
||||
|
||||
// RefreshCloudFrontCookies is middleware that refreshes CloudFront signed cookies
|
||||
// on authenticated requests when the cookie is missing (expired or first request
|
||||
// after login). This prevents 403s from the CDN when cookies expire before the
|
||||
// user's session does.
|
||||
func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if signer == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := r.Cookie("CloudFront-Policy"); err != nil {
|
||||
for _, cookie := range signer.SignedCookies(time.Now().Add(72 * time.Hour)) {
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,36 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
|
|||
return task, nil
|
||||
}
|
||||
|
||||
// EnqueueTaskForMention creates a queued task for a mentioned agent on an issue.
|
||||
// Unlike EnqueueTaskForIssue, this takes an explicit agent ID rather than
|
||||
// deriving it from the issue assignee.
|
||||
func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
}
|
||||
if !agent.RuntimeID.Valid {
|
||||
slog.Error("mention task enqueue failed: agent has no runtime", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||
}
|
||||
|
||||
task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{
|
||||
AgentID: agentID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
IssueID: issue.ID,
|
||||
Priority: priorityToInt(issue.Priority),
|
||||
TriggerCommentID: triggerCommentID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("mention task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CancelTasksForIssue cancels all active tasks for an issue.
|
||||
func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error {
|
||||
return s.Queries.CancelAgentTasksByIssue(ctx, issueID)
|
||||
|
|
|
|||
107
server/internal/storage/s3.go
Normal file
107
server/internal/storage/s3.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
cdnDomain string // if set, returned URLs use this instead of bucket name
|
||||
}
|
||||
|
||||
// NewS3StorageFromEnv creates an S3Storage from environment variables.
|
||||
// Returns nil if S3_BUCKET is not set.
|
||||
//
|
||||
// Environment variables:
|
||||
// - S3_BUCKET (required)
|
||||
// - S3_REGION (default: us-west-2)
|
||||
// - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (optional; falls back to default credential chain)
|
||||
func NewS3StorageFromEnv() *S3Storage {
|
||||
bucket := os.Getenv("S3_BUCKET")
|
||||
if bucket == "" {
|
||||
slog.Info("S3_BUCKET not set, file upload disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
region := os.Getenv("S3_REGION")
|
||||
if region == "" {
|
||||
region = "us-west-2"
|
||||
}
|
||||
|
||||
opts := []func(*config.LoadOptions) error{
|
||||
config.WithRegion(region),
|
||||
}
|
||||
|
||||
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
|
||||
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
|
||||
if accessKey != "" && secretKey != "" {
|
||||
opts = append(opts, config.WithCredentialsProvider(
|
||||
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
|
||||
))
|
||||
}
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(context.Background(), opts...)
|
||||
if err != nil {
|
||||
slog.Error("failed to load AWS config", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
cdnDomain := os.Getenv("CLOUDFRONT_DOMAIN")
|
||||
|
||||
slog.Info("S3 storage initialized", "bucket", bucket, "region", region, "cdn_domain", cdnDomain)
|
||||
return &S3Storage{
|
||||
client: s3.NewFromConfig(cfg),
|
||||
bucket: bucket,
|
||||
cdnDomain: cdnDomain,
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(name))
|
||||
for _, r := range name {
|
||||
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
|
||||
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
|
||||
b.WriteRune('_')
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
|
||||
safe := sanitizeFilename(filename)
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(data),
|
||||
ContentType: aws.String(contentType),
|
||||
ContentDisposition: aws.String(fmt.Sprintf(`inline; filename="%s"`, safe)),
|
||||
CacheControl: aws.String("max-age=432000,public"),
|
||||
StorageClass: types.StorageClassIntelligentTiering,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("s3 PutObject: %w", err)
|
||||
}
|
||||
|
||||
domain := s.bucket
|
||||
if s.cdnDomain != "" {
|
||||
domain = s.cdnDomain
|
||||
}
|
||||
link := fmt.Sprintf("https://%s/%s", domain, key)
|
||||
return link, nil
|
||||
}
|
||||
28
server/internal/util/mention.go
Normal file
28
server/internal/util/mention.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package util
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Mention represents a parsed @mention from markdown content.
|
||||
type Mention struct {
|
||||
Type string // "member" or "agent"
|
||||
ID string // user_id or agent_id
|
||||
}
|
||||
|
||||
// MentionRe matches [@Label](mention://type/id) in markdown.
|
||||
var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
|
||||
|
||||
// ParseMentions extracts deduplicated mentions from markdown content.
|
||||
func ParseMentions(content string) []Mention {
|
||||
matches := MentionRe.FindAllStringSubmatch(content, -1)
|
||||
seen := make(map[string]bool)
|
||||
var result []Mention
|
||||
for _, m := range matches {
|
||||
key := m[1] + ":" + m[2]
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result = append(result, Mention{Type: m[1], ID: m[2]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
1
server/migrations/029_attachment.down.sql
Normal file
1
server/migrations/029_attachment.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS attachment;
|
||||
17
server/migrations/029_attachment.up.sql
Normal file
17
server/migrations/029_attachment.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
CREATE TABLE attachment (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
issue_id UUID REFERENCES issue(id) ON DELETE CASCADE,
|
||||
comment_id UUID REFERENCES comment(id) ON DELETE CASCADE,
|
||||
uploader_type TEXT NOT NULL CHECK (uploader_type IN ('member', 'agent')),
|
||||
uploader_id UUID NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attachment_issue ON attachment(issue_id) WHERE issue_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachment_comment ON attachment(comment_id) WHERE comment_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachment_workspace ON attachment(workspace_id);
|
||||
21
server/migrations/029_drop_daemon_pairing.down.sql
Normal file
21
server/migrations/029_drop_daemon_pairing.down.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Re-create the daemon_pairing_session table (from migration 005).
|
||||
CREATE TABLE IF NOT EXISTS daemon_pairing_session (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
daemon_id TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
runtime_name TEXT NOT NULL DEFAULT '',
|
||||
runtime_type TEXT NOT NULL DEFAULT '',
|
||||
runtime_version TEXT NOT NULL DEFAULT '',
|
||||
workspace_id UUID REFERENCES workspace(id),
|
||||
approved_by UUID REFERENCES "user"(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
approved_at TIMESTAMPTZ,
|
||||
claimed_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_token ON daemon_pairing_session(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_daemon_pairing_session_status ON daemon_pairing_session(status, expires_at);
|
||||
1
server/migrations/029_drop_daemon_pairing.up.sql
Normal file
1
server/migrations/029_drop_daemon_pairing.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS daemon_pairing_session;
|
||||
1
server/migrations/030_agent_default_private.down.sql
Normal file
1
server/migrations/030_agent_default_private.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'workspace';
|
||||
1
server/migrations/030_agent_default_private.up.sql
Normal file
1
server/migrations/030_agent_default_private.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE agent ALTER COLUMN visibility SET DEFAULT 'private';
|
||||
|
|
@ -458,6 +458,25 @@ func (q *Queries) HasPendingTaskForIssue(ctx context.Context, issueID pgtype.UUI
|
|||
return has_pending, err
|
||||
}
|
||||
|
||||
const hasPendingTaskForIssueAndAgent = `-- name: HasPendingTaskForIssueAndAgent :one
|
||||
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched')
|
||||
`
|
||||
|
||||
type HasPendingTaskForIssueAndAgentParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
}
|
||||
|
||||
// Returns true if a specific agent already has a queued or dispatched task
|
||||
// for the given issue. Used by @mention trigger dedup.
|
||||
func (q *Queries) HasPendingTaskForIssueAndAgent(ctx context.Context, arg HasPendingTaskForIssueAndAgentParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, hasPendingTaskForIssueAndAgent, arg.IssueID, arg.AgentID)
|
||||
var has_pending bool
|
||||
err := row.Scan(&has_pending)
|
||||
return has_pending, err
|
||||
}
|
||||
|
||||
const listActiveTasksByIssue = `-- name: ListActiveTasksByIssue :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
||||
|
|
|
|||
226
server/pkg/db/generated/attachment.sql.go
Normal file
226
server/pkg/db/generated/attachment.sql.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: attachment.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createAttachment = `-- name: CreateAttachment :one
|
||||
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
||||
VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at
|
||||
`
|
||||
|
||||
type CreateAttachmentParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UploaderType string `json:"uploader_type"`
|
||||
UploaderID pgtype.UUID `json:"uploader_id"`
|
||||
Filename string `json:"filename"`
|
||||
Url string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
CommentID pgtype.UUID `json:"comment_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) {
|
||||
row := q.db.QueryRow(ctx, createAttachment,
|
||||
arg.WorkspaceID,
|
||||
arg.UploaderType,
|
||||
arg.UploaderID,
|
||||
arg.Filename,
|
||||
arg.Url,
|
||||
arg.ContentType,
|
||||
arg.SizeBytes,
|
||||
arg.IssueID,
|
||||
arg.CommentID,
|
||||
)
|
||||
var i Attachment
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.CommentID,
|
||||
&i.UploaderType,
|
||||
&i.UploaderID,
|
||||
&i.Filename,
|
||||
&i.Url,
|
||||
&i.ContentType,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAttachment = `-- name: DeleteAttachment :exec
|
||||
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
type DeleteAttachmentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.WorkspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAttachment = `-- name: GetAttachment :one
|
||||
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
type GetAttachmentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (Attachment, error) {
|
||||
row := q.db.QueryRow(ctx, getAttachment, arg.ID, arg.WorkspaceID)
|
||||
var i Attachment
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.CommentID,
|
||||
&i.UploaderType,
|
||||
&i.UploaderID,
|
||||
&i.Filename,
|
||||
&i.Url,
|
||||
&i.ContentType,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAttachmentsByComment = `-- name: ListAttachmentsByComment :many
|
||||
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||
WHERE comment_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListAttachmentsByCommentParams struct {
|
||||
CommentID pgtype.UUID `json:"comment_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachmentsByCommentParams) ([]Attachment, error) {
|
||||
rows, err := q.db.Query(ctx, listAttachmentsByComment, arg.CommentID, arg.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Attachment{}
|
||||
for rows.Next() {
|
||||
var i Attachment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.CommentID,
|
||||
&i.UploaderType,
|
||||
&i.UploaderID,
|
||||
&i.Filename,
|
||||
&i.Url,
|
||||
&i.ContentType,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAttachmentsByCommentIDs = `-- name: ListAttachmentsByCommentIDs :many
|
||||
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||
WHERE comment_id = ANY($1::uuid[])
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Attachment, error) {
|
||||
rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Attachment{}
|
||||
for rows.Next() {
|
||||
var i Attachment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.CommentID,
|
||||
&i.UploaderType,
|
||||
&i.UploaderID,
|
||||
&i.Filename,
|
||||
&i.Url,
|
||||
&i.ContentType,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many
|
||||
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListAttachmentsByIssueParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachmentsByIssueParams) ([]Attachment, error) {
|
||||
rows, err := q.db.Query(ctx, listAttachmentsByIssue, arg.IssueID, arg.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Attachment{}
|
||||
for rows.Next() {
|
||||
var i Attachment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.CommentID,
|
||||
&i.UploaderType,
|
||||
&i.UploaderID,
|
||||
&i.Filename,
|
||||
&i.Url,
|
||||
&i.ContentType,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
|
@ -79,6 +79,20 @@ type AgentTaskQueue struct {
|
|||
TriggerCommentID pgtype.UUID `json:"trigger_comment_id"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
CommentID pgtype.UUID `json:"comment_id"`
|
||||
UploaderType string `json:"uploader_type"`
|
||||
UploaderID pgtype.UUID `json:"uploader_id"`
|
||||
Filename string `json:"filename"`
|
||||
Url string `json:"url"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@ WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
|||
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched');
|
||||
|
||||
-- name: HasPendingTaskForIssueAndAgent :one
|
||||
-- Returns true if a specific agent already has a queued or dispatched task
|
||||
-- for the given issue. Used by @mention trigger dedup.
|
||||
SELECT count(*) > 0 AS has_pending FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND agent_id = $2 AND status IN ('queued', 'dispatched');
|
||||
|
||||
-- name: ListPendingTasksByRuntime :many
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE runtime_id = $1 AND status IN ('queued', 'dispatched')
|
||||
|
|
|
|||
26
server/pkg/db/queries/attachment.sql
Normal file
26
server/pkg/db/queries/attachment.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- name: CreateAttachment :one
|
||||
INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
||||
VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListAttachmentsByIssue :many
|
||||
SELECT * FROM attachment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListAttachmentsByComment :many
|
||||
SELECT * FROM attachment
|
||||
WHERE comment_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: GetAttachment :one
|
||||
SELECT * FROM attachment
|
||||
WHERE id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: ListAttachmentsByCommentIDs :many
|
||||
SELECT * FROM attachment
|
||||
WHERE comment_id = ANY($1::uuid[])
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: DeleteAttachment :exec
|
||||
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;
|
||||
Loading…
Add table
Add a link
Reference in a new issue