216 lines
10 KiB
Go
216 lines
10 KiB
Go
package platform
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
|
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
type service struct {
|
|
canvasAI *codex.CanvasClient
|
|
db *sql.DB
|
|
origin string
|
|
callbacks map[string]string
|
|
onboardingConfigs map[string]OnboardingConfig
|
|
}
|
|
|
|
type response map[string]any
|
|
|
|
func reply(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
if value != nil {
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
}
|
|
|
|
func reject(w http.ResponseWriter, status int, code string) {
|
|
reply(w, status, response{"error": code})
|
|
}
|
|
|
|
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(value); err != nil {
|
|
reject(w, 400, "invalid_request")
|
|
return false
|
|
}
|
|
if decoder.Decode(new(any)) != io.EOF {
|
|
reject(w, 400, "invalid_request")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
type endpoint func(http.ResponseWriter, *http.Request, string, string) error
|
|
|
|
func (s *service) protected(callbacks map[string]string, handle endpoint) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
project := r.PathValue("project")
|
|
callback, exists := callbacks[project]
|
|
if !exists {
|
|
reject(w, 404, "not_found")
|
|
return
|
|
}
|
|
frontend, _ := url.Parse(callback)
|
|
w.Header().Set("Vary", "Origin")
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
if origin != frontend.Scheme+"://"+frontend.Host {
|
|
reject(w, 403, "origin_denied")
|
|
return
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Expose-Headers", "ETag, X-Canvas-Schema-Version, "+auth.SessionExpiryHeader)
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key")
|
|
reply(w, 204, nil)
|
|
return
|
|
}
|
|
session, err := auth.AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
|
|
if errors.Is(err, auth.ErrInvalidSession) {
|
|
reject(w, 401, "invalid_session")
|
|
return
|
|
}
|
|
account := session.AccountID
|
|
if err == nil {
|
|
auth.WriteSessionHeaders(w, session)
|
|
}
|
|
if err == nil && r.PathValue("workspace") != "" {
|
|
var id string
|
|
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id)
|
|
if err == nil {
|
|
r.SetPathValue("workspace", id)
|
|
}
|
|
}
|
|
if err == nil && r.PathValue("material") != "" {
|
|
var id string
|
|
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM materials WHERE project=$1 AND workspace_id=$2 AND (id::text=$3 OR slug=$3)", project, r.PathValue("workspace"), r.PathValue("material")).Scan(&id)
|
|
if err == nil {
|
|
r.SetPathValue("material", id)
|
|
}
|
|
}
|
|
if err == nil {
|
|
err = handle(w, r, project, account)
|
|
}
|
|
if err != nil {
|
|
var pg *pgconn.PgError
|
|
switch {
|
|
case errors.Is(err, errSubgraphCycle):
|
|
reject(w, 409, "subgraph_cycle")
|
|
case errors.Is(err, errSubgraphDepth):
|
|
reject(w, 413, "graph_limit_exceeded")
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
reject(w, 404, "not_found")
|
|
case errors.As(err, &pg) && (pg.Code == "22P02" || pg.Code == "23503" || pg.Code == "23514"):
|
|
reject(w, 400, "invalid_reference")
|
|
case errors.As(err, &pg) && pg.Code == "23505":
|
|
reject(w, 409, "already_exists")
|
|
default:
|
|
log.Printf("platform request failed (%T)", err)
|
|
reject(w, 503, "service_unavailable")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig, clients ...*codex.CanvasClient) {
|
|
s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs}
|
|
if len(clients) > 0 {
|
|
s.canvasAI = clients[0]
|
|
if s.canvasAI != nil {
|
|
go s.runCanvasQueue(context.Background())
|
|
}
|
|
}
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets", s.protected(callbacks, s.uploadAsset))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}", s.protected(callbacks, s.getAsset))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/models", s.protected(callbacks, s.canvasAIModels))
|
|
mux.HandleFunc("POST /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs", s.protected(callbacks, s.createCanvasAIRun))
|
|
mux.HandleFunc("GET /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs", s.protected(callbacks, s.listCanvasAIRuns))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}", s.protected(callbacks, s.getCanvasAIRun))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/capabilities", s.protected(callbacks, s.canvasCapabilities))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/connections", s.protected(callbacks, s.createCanvasConnection))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs", s.protected(callbacks, s.createSubgraph))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/migrate", s.protected(callbacks, s.migrateSubgraphs))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/{frame}/dissolve", s.protected(callbacks, s.dissolveSubgraph))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/{frame}/members", s.protected(callbacks, s.moveSubgraphMembers))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/transactions/{transaction}/undo", s.protected(callbacks, s.undoCanvasTransaction))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/open", s.protected(callbacks, s.openMaterial))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags", s.protected(callbacks, s.tags))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/tags", s.protected(callbacks, s.materialTags))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants", s.protected(callbacks, s.materialGrants))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}/clicks", s.protected(callbacks, s.shareClicks))
|
|
mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding))
|
|
mux.HandleFunc("/s/{token}", s.shortLink)
|
|
mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares", s.protected(callbacks, s.shares))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}", s.protected(callbacks, s.revokeShare))
|
|
mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members))
|
|
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members/{account}", s.protected(callbacks, s.member))
|
|
mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile))
|
|
}
|
|
|
|
var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,39}$`)
|
|
|
|
func (s *service) profile(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method == http.MethodPatch {
|
|
var input struct {
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
if !decode(w, r, &input) {
|
|
return nil
|
|
}
|
|
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
|
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
|
if !usernamePattern.MatchString(input.Username) || len(input.DisplayName) > 160 {
|
|
reject(w, 400, "invalid_profile")
|
|
return nil
|
|
}
|
|
_, err := s.db.ExecContext(r.Context(), `
|
|
UPDATE project_accounts SET username=$3, display_name=$4, updated_at=now()
|
|
WHERE project=$1 AND id=$2
|
|
`, project, account, input.Username, input.DisplayName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else if r.Method != http.MethodGet {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
var username, email, avatar *string
|
|
var name string
|
|
err := s.db.QueryRowContext(r.Context(), `
|
|
SELECT username, display_name, email, avatar_url FROM project_accounts WHERE project=$1 AND id=$2
|
|
`, project, account).Scan(&username, &name, &email, &avatar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reply(w, 200, response{"id": account, "project": project, "username": username, "display_name": name, "email": email, "avatar_url": avatar})
|
|
return nil
|
|
}
|