package platform import ( "database/sql" "encoding/json" "errors" "io" "log" "net/http" "net/url" "regexp" "strings" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "github.com/jackc/pgx/v5/pgconn" ) type service struct { 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) } 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") reply(w, 204, nil) return } account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization")) if errors.Is(err, auth.ErrInvalidSession) { reject(w, 401, "invalid_session") return } if err == nil { err = handle(w, r, project, account) } if err != nil { var pg *pgconn.PgError switch { 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) { s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} 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 }