diff --git a/.env.example b/.env.example index c879d5b..c6447fe 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,14 @@ GOOGLE_CLIENT_SECRET=replace-me FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}' HOST=127.0.0.1 PORT=8080 + +# Optional email verification through TLS SMTP. +SMTP_HOST=smtpdm.aliyun.com +SMTP_PORT=465 +SMTP_USERNAME=noreply@notice.entroia.com +SMTP_PASSWORD=replace-me +SMTP_FROM=noreply@notice.entroia.com + +# Optional Sign in with Slack. +SLACK_CLIENT_ID=replace-me +SLACK_CLIENT_SECRET=replace-me diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index f278f2a..f3ad8e9 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -17,6 +17,59 @@ jobs: test -z "$(gofmt -l cmd internal)" go test -mod=readonly ./... go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Check Slack OAuth configuration and endpoint + if: gitea.event_name == 'push' + env: + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + run: | + python3 - <<'PYSLACK' + import json, os, secrets, sys, urllib.parse, urllib.request + client = os.environ.get("SLACK_CLIENT_ID", "") + secret = os.environ.get("SLACK_CLIENT_SECRET", "") + if not client and not secret: + print("Slack client verification skipped because credentials are not configured") + sys.exit(0) + if not client or not secret: + sys.exit("Both Slack client secrets must be configured") + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + opener = urllib.request.build_opener(NoRedirect) + code = "foundry-invalid-preflight-" + secrets.token_hex(16) + def probe(password): + body = urllib.parse.urlencode({ + "client_id": client, "client_secret": password, + "code": code, "grant_type": "authorization_code", + "redirect_uri": "https://api.foundry.entroia.com/auth/slack/callback" + }).encode() + request = urllib.request.Request( + "https://slack.com/api/openid.connect.token", data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + with opener.open(request, timeout=20) as response: + result = json.load(response) + return result.get("error", "") + try: + actual = probe(secret) + control = probe(secrets.token_hex(32)) + except Exception as error: + sys.exit("Slack preflight request failed (" + type(error).__name__ + ")") + if actual in ("invalid_code", "invalid_grant") and control in ("bad_client_secret", "invalid_client"): + print("Slack client credentials accepted; invalid-secret negative control rejected") + print("A real user authorization is still required to verify the complete login") + elif actual in ("invalid_code", "invalid_grant") and control in ("invalid_code", "invalid_grant"): + print("Both Slack Actions secrets are available and the OAuth endpoint is reachable") + print("Slack rejected the placeholder code before validating the client secret") + print("Client-secret validity requires a real user authorization; this is not a complete login test") + else: + known = {"invalid_client_id", "bad_client_secret", "invalid_client", "invalid_code", + "invalid_grant", "bad_redirect_uri", "accesslimited", "ratelimited"} + print("Configured credential result:", actual if actual in known else "unclassified") + print("Negative control result:", control if control in known else "unclassified") + sys.exit("Slack client preflight could not verify the configured credentials") + PYSLACK + - name: Verify schema migration in an isolated database + run: bash deploy/check-schema.sh - name: Smoke test HTTP routes env: PUBLIC_ORIGIN: https://api.example.com diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..928e4f3 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,43 @@ +name: Deploy Foundry Core +on: + push: + branches: [main] + paths: + - 'cmd/**' + - 'internal/**' + - 'go.mod' + - 'go.sum' + - 'deploy/**' + - 'db/**' + - '.gitea/workflows/deploy.yml' + +concurrency: + group: foundry-core-main-deploy + cancel-in-progress: false + +jobs: + deploy: + if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push' + runs-on: ces-control + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Verify and build + run: | + set -eu + test -z "$(gofmt -l cmd internal)" + go test -mod=readonly ./... + CGO_ENABLED=0 go build -mod=readonly -o bin/foundry ./cmd/foundry + - name: Deploy and verify backend readiness + env: + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} + SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} + FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }} + FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }} + run: bash deploy/release.sh bin/foundry diff --git a/.gitignore b/.gitignore index 579c657..db3643f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env /bin/ +/api-docs/ diff --git a/README.md b/README.md index 447473c..8f6a90d 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,15 @@ 1. 在 Google Cloud 创建一个 Web application OAuth 客户端。授权重定向 URI 填写 `https:///auth/google/callback`。所有项目信任同一个客户端,因此共享 Google 同意页面的应用名称。 2. 在服务器私网准备 PostgreSQL 13 或以上版本。在认证库执行一次 `db/001-auth.sql`(使用 `psql -v ON_ERROR_STOP=1`),给运行账号授予这四张表所需的查询及增删改权限。 3. 按 `.env.example` 注入运行环境变量。每个项目配置一个固定的前端回调地址,本地开发可用 localhost。将 Google 客户端密钥和数据库连接串存入服务器的密钥管理设施。 -4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署时运行 `foundry` 二进制,通过 HTTPS 反向代理转发至服务监听地址。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。 +4. 由 Gitea Actions 在 `ces-control` 上使用 Go 1.25 构建并检查。部署工作流仅处理 `main` 上影响后端或部署的改动,将二进制启动在服务器 Docker 私网,由 Traefik 提供 HTTPS。通过 `GET /healthz` 检查进程,通过 `GET /readyz` 检查认证库。 5. 代理避免记录 Google 回调的查询参数及认证请求头。设置入口限流,并定时删除三张临时表(`oauth_attempts`、`login_grants`、`project_sessions`)中 `expires_at <= now()` 的记录。 +## Actions 部署配置 + +在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。 + +Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401 和 HTTPS 就绪。前端接入后,可在浏览器用 Google 测试账号验证完整登录、回调、兑换和会话。 + ## 前端接入 1. 在浏览器用 Web Crypto 生成 32 字节随机值,编码为无填充 Base64URL,作为 verifier 暂存在当前 tab 的 sessionStorage。对 verifier 字符串做 SHA-256 并用相同编码得到 challenge。 @@ -22,3 +28,79 @@ ## 验收 用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。 + +## Runtime HTTPS trust + +The Go backend needs an operating-system CA bundle for Google and Slack HTTPS +requests. The `node:24-bookworm-slim` runtime does not include that trust store; +Node's own successful HTTPS checks do not validate Go's trust configuration. +`deploy/release.sh` mounts the deployment host's +`/etc/ssl/certs/ca-certificates.crt` read-only and sets `SSL_CERT_FILE` to that +path. The deployment fails before switching traffic if the host bundle is +missing or unreadable in the candidate. Keep TLS certificate verification +enabled and maintain the deployment host's trusted CA package. + +## Codex execution + +When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object +containing only a nonempty `prompt` string and returns `{"result":"…"}`. +Use `Authorization: Bearer ` with a dedicated 32-byte random token +encoded as 64 hex characters. Ordinary OAuth sessions cannot execute Codex. +Missing, unknown and revoked tokens return 401. A valid token for a different +project returns 403. Requests are limited to 32 KiB, CLI output to 512 KiB, +and each execution to 90 seconds. The worker accepts one execution at a time +and returns 429 when busy. + +On the deployment host, provision `/opt/endoleap-infra/foundry/codex` with: + +- `grants/projects.json` containing a JSON object mapping lowercase hex SHA-256 + digests of the **decoded** token bytes to configured project names. Use mode + 0700 for the directory and 0600 for the file, readable by UID 1000. Rotate or + revoke tokens by atomically replacing the file within the mounted directory. + A revocation applies to subsequent requests. +- `home` owned by UID 1000 with an isolated Codex login in `auth.json`. + Keep this home dedicated to the worker. Supply the login only and keep user + configuration, MCP servers, skills, and other workload files out of it. + +With that directory configured, the next normal Foundry deployment starts the +worker using the Linux binary built by ces-control and mounts the worker socket +and grants directory read-only. To replace an existing worker, stop and remove +its container, then run `bash deploy/codex-worker.sh /absolute/path/to/foundry` +with the desired ces-control build. The worker +runs the installed Codex CLI with fixed read-only, no-approval arguments and +disables command execution tools. It receives prompts via stdin and returns +the final agent message. The worker has no published port and Foundry receives +neither the Codex home nor the Docker socket. Keep both containers on the same +host for Unix socket access. + +### Board Canvas snapshots and slugs + +Apply `db/003-canvas.sql` through the normal migration runner before starting this +version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas` +returns `{version, document}` and a quoted `ETag`. An untouched board returns +version 1 and an empty schema-1 graph. `PATCH` takes `{document: }` +and the quoted `If-Match`; it atomically replaces the snapshot and returns +`{version}` with the new `ETag`. Canvas and material metadata have separate +versions. GET uses existing material read permissions; PATCH requires editor or +admin. Missing versions return 428, stale versions 412 `version_conflict`, invalid +graphs 400 `invalid_canvas`, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/ +GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary +node selection/drag/measurement state and mirrored styles are discarded. + +Workspace and material create/get/list responses include `slug`. Create and +metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits, +with single hyphens between segments; UUID-form custom values are reserved). +Workspace slugs are unique within a project, material slugs within a workspace; +duplicates return 409. Defaults use the ASCII name/title and UUID, falling back +to a generic prefix for names without ASCII letters/digits. Existing records are +backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace +and material path parameters accept either ID or slug, with unchanged permissions. +Canvas uses its board material's slug; there is no separate Canvas identity. +Slugs enable frontend SEO URLs but do not make private content public or add +search-engine metadata. Changing a custom slug does not retain an old alias. + +Frontend follow-up: allowlist the Canvas proxy route and set its body limit to +at least 5 MiB; load the server snapshot, offer migration of old local drafts, +serialize autosaves and handle 412 explicitly. Restore node style dimensions +from width/height and render edges as Bézier curves. These frontend changes are +outside this Core delivery; local drafts alone are not database persistence. diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index b354ce0..7feed0e 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "log" "net" @@ -10,6 +11,8 @@ import ( "time" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -22,6 +25,9 @@ func env(key, fallback string) string { } func main() { + if len(os.Args) == 2 && os.Args[1] == "codex-worker" { + log.Fatal(codex.ServeWorker("/run/foundry-codex/worker.sock")) + } origin, err := url.Parse(os.Getenv("PUBLIC_ORIGIN")) if err != nil || origin.Scheme != "https" || origin.Hostname() == "" || origin.User != nil || (origin.Path != "" && origin.Path != "/") || origin.RawQuery != "" || origin.Fragment != "" { @@ -41,14 +47,33 @@ func main() { } db := stdlib.OpenDB(*config) defer db.Close() - handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret) + email, err := auth.EmailConfigFromEnv() if err != nil { log.Fatal(err) } + go email.Run(context.Background(), db) + slack, err := auth.SlackConfigFromEnv(origin.ResolveReference(&url.URL{Path: "/auth/slack/callback"}).String()) + if err != nil { + log.Fatal(err) + } + handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email, slack) + if err != nil { + log.Fatal(err) + } + mux := http.NewServeMux() + if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { + log.Fatal(err) + } + onboarding, err := platform.ParseOnboarding(os.Getenv("FOUNDRY_ONBOARDING"), callbacks) + if err != nil { + log.Fatal(err) + } + platform.Register(mux, db, callbacks, origin.Scheme+"://"+origin.Host, onboarding) + mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), - Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, + WriteTimeout: 110 * time.Second, IdleTimeout: 60 * time.Second, } log.Fatal(server.ListenAndServe()) } diff --git a/db/002-platform.sql b/db/002-platform.sql new file mode 100644 index 0000000..87f9def --- /dev/null +++ b/db/002-platform.sql @@ -0,0 +1,224 @@ +BEGIN; + +-- Keep existing Google accounts and sessions valid during the expansion. +ALTER TABLE project_accounts ALTER COLUMN google_subject DROP NOT NULL; +ALTER TABLE project_accounts + ADD COLUMN username text, + ADD COLUMN display_name text NOT NULL DEFAULT '', + ADD COLUMN avatar_url text, + ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(); +CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username)) + WHERE username IS NOT NULL; + +ALTER TABLE oauth_attempts + ADD COLUMN provider text NOT NULL DEFAULT 'google' CHECK (provider IN ('google','slack')), + ADD COLUMN oidc_nonce text; + +CREATE TABLE account_identities ( + project text NOT NULL, + provider text NOT NULL CHECK (provider IN ('google', 'slack')), + subject text NOT NULL, + account_id uuid NOT NULL, + PRIMARY KEY (project, provider, subject), + UNIQUE (project, provider, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +INSERT INTO account_identities(project, provider, subject, account_id) +SELECT project, 'google', google_subject, id FROM project_accounts WHERE google_subject IS NOT NULL; + +CREATE TABLE email_credentials ( + project text NOT NULL, + email text NOT NULL CHECK (email = lower(email)), + account_id uuid NOT NULL, + password_hash text NOT NULL, + verified_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, email), + UNIQUE (project, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +-- Registration is promoted to an account only after the single-use email check. +CREATE TABLE email_registrations ( + project text NOT NULL, + email text NOT NULL, + password_hash text NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, email) +); +CREATE TABLE email_outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project text NOT NULL, + recipient text NOT NULL, + subject text NOT NULL, + body text NOT NULL, + attempts integer NOT NULL DEFAULT 0, + available_at timestamptz NOT NULL DEFAULT now(), + lease_until timestamptz, + lease_id uuid, + expires_at timestamptz NOT NULL, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX email_outbox_pending ON email_outbox(available_at) WHERE sent_at IS NULL; +CREATE TABLE auth_rate_limits ( + key_hash text PRIMARY KEY, + attempts integer NOT NULL CHECK (attempts > 0), + expires_at timestamptz NOT NULL +); + +CREATE TABLE onboarding_responses ( + project text NOT NULL, + account_id uuid NOT NULL, + answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'), + result jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(result) = 'object'), + completed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, account_id), + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); + +CREATE TABLE workspaces ( + project text NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 160), + owner_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, id), + FOREIGN KEY (project, owner_id) REFERENCES project_accounts +); +CREATE TABLE workspace_members ( + project text NOT NULL, + workspace_id uuid NOT NULL, + account_id uuid NOT NULL, + role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, account_id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE INDEX workspace_members_account ON workspace_members(project, account_id); + +CREATE TABLE materials ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + kind text NOT NULL CHECK (kind IN ('text', 'article', 'image', 'board', 'card')), + title text NOT NULL CHECK (length(title) <= 300), + description text NOT NULL DEFAULT '' CHECK (length(description) <= 10000), + content jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(content) = 'object'), + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + modified_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, created_by) REFERENCES project_accounts +); +CREATE INDEX material_modified ON materials(project, workspace_id, modified_at DESC, id); +CREATE INDEX material_created ON materials(project, workspace_id, created_at DESC, id); +CREATE INDEX material_alphabetical ON materials(project, workspace_id, lower(title), id); +CREATE TABLE material_opens ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + account_id uuid NOT NULL, + opened_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, material_id, account_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE INDEX material_opens_account ON material_opens(project, workspace_id, account_id, opened_at DESC); + +CREATE TABLE tags ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80), + color text NOT NULL DEFAULT '', + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE +); +CREATE UNIQUE INDEX tag_name ON tags(project, workspace_id, lower(name)); +CREATE TABLE material_tags ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + tag_id uuid NOT NULL, + PRIMARY KEY (project, workspace_id, material_id, tag_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, tag_id) REFERENCES tags ON DELETE CASCADE +); +CREATE INDEX material_tags_filter ON material_tags(project, workspace_id, tag_id, material_id); + +-- A board placement references a reusable material, independently of its content. +CREATE TABLE board_layouts ( + project text NOT NULL, + workspace_id uuid NOT NULL, + board_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + material_id uuid NOT NULL, + x double precision NOT NULL CHECK (x BETWEEN -1000000 AND 1000000), + y double precision NOT NULL CHECK (y BETWEEN -1000000 AND 1000000), + width double precision NOT NULL CHECK (width BETWEEN 1 AND 100000), + height double precision NOT NULL CHECK (height BETWEEN 1 AND 100000), + z_index integer NOT NULL DEFAULT 0, + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + PRIMARY KEY (project, workspace_id, board_id, id), + FOREIGN KEY (project, workspace_id, board_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + CHECK (board_id <> material_id) +); +CREATE TABLE material_grants ( + project text NOT NULL, + workspace_id uuid NOT NULL, + material_id uuid NOT NULL, + account_id uuid NOT NULL, + role text NOT NULL CHECK (role IN ('editor', 'viewer')), + PRIMARY KEY (project, workspace_id, material_id, account_id), + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE TABLE share_links ( + project text NOT NULL, + workspace_id uuid NOT NULL, + id uuid NOT NULL DEFAULT gen_random_uuid(), + material_id uuid, + token_hash text NOT NULL UNIQUE, + role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')), + created_by uuid NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + max_uses integer CHECK (max_uses BETWEEN 1 AND 100000), + use_count integer NOT NULL DEFAULT 0 CHECK (use_count >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, id), + FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE, + FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE, + FOREIGN KEY (project, created_by) REFERENCES project_accounts, + CHECK (material_id IS NULL OR role <> 'admin') +); +CREATE TABLE share_redemptions ( + project text NOT NULL, + workspace_id uuid NOT NULL, + link_id uuid NOT NULL, + account_id uuid NOT NULL, + redeemed_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project, workspace_id, link_id, account_id), + FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE, + FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE +); +CREATE TABLE link_clicks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project text NOT NULL, + workspace_id uuid NOT NULL, + link_id uuid NOT NULL, + clicked_at timestamptz NOT NULL DEFAULT now(), + -- Store the referring origin only, without URL paths, queries or user tokens. + referrer_origin text NOT NULL DEFAULT '', + FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE +); +CREATE INDEX link_clicks_summary ON link_clicks(project, workspace_id, link_id, clicked_at); + +COMMIT; diff --git a/db/003-canvas.sql b/db/003-canvas.sql new file mode 100644 index 0000000..73a3d1d --- /dev/null +++ b/db/003-canvas.sql @@ -0,0 +1,29 @@ +BEGIN; +ALTER TABLE materials ADD COLUMN canvas_version bigint NOT NULL DEFAULT 1 CHECK (canvas_version > 0); +ALTER TABLE materials ADD COLUMN canvas_document jsonb; +ALTER TABLE materials ADD CONSTRAINT canvas_board_only CHECK (canvas_document IS NULL OR kind='board'); +ALTER TABLE workspaces ADD COLUMN slug text; +UPDATE workspaces SET slug=COALESCE(NULLIF(trim(both '-' from left(trim(both '-' from regexp_replace(lower(name),'[^a-z0-9]+','-','g')),50)),''),'workspace') || '-' || id::text; +ALTER TABLE workspaces ALTER COLUMN slug SET NOT NULL; + +ALTER TABLE workspaces ADD CONSTRAINT workspace_slug_unique UNIQUE(project,slug); +ALTER TABLE workspaces ADD CONSTRAINT workspace_slug_valid CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(slug)<=100); +ALTER TABLE materials ADD COLUMN slug text; +UPDATE materials SET slug=COALESCE(NULLIF(trim(both '-' from left(trim(both '-' from regexp_replace(lower(title),'[^a-z0-9]+','-','g')),50)),''),'canvas') || '-' || id::text; +ALTER TABLE materials ALTER COLUMN slug SET NOT NULL; + +ALTER TABLE materials ADD CONSTRAINT material_slug_unique UNIQUE(project,workspace_id,slug); +ALTER TABLE materials ADD CONSTRAINT material_slug_valid CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(slug)<=100); +CREATE FUNCTION foundry_default_slug() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE label text; +BEGIN + IF NEW.slug IS NULL THEN + IF TG_TABLE_NAME='workspaces' THEN label=NEW.name; ELSE label=NEW.title; END IF; + label=left(trim(both '-' from regexp_replace(lower(label),'[^a-z0-9]+','-','g')),50); + NEW.slug=COALESCE(NULLIF(trim(both '-' from label),''),'canvas') || '-' || NEW.id::text; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER workspace_default_slug BEFORE INSERT ON workspaces FOR EACH ROW EXECUTE FUNCTION foundry_default_slug(); +CREATE TRIGGER material_default_slug BEFORE INSERT ON materials FOR EACH ROW EXECUTE FUNCTION foundry_default_slug(); +COMMIT; diff --git a/deploy/check-schema.sh b/deploy/check-schema.sh new file mode 100644 index 0000000..69de8a7 --- /dev/null +++ b/deploy/check-schema.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +container=foundry-schema-$(openssl rand -hex 8) +cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; } +trap cleanup EXIT +# No host port or shared network. The database is discarded after this check. +docker run -d --name "$container" --network none \ + -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine >/dev/null +for attempt in {1..30}; do + if docker exec "$container" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; then break; fi + sleep 1 +done +psql() { docker exec -i "$container" psql -X -U postgres -v ON_ERROR_STOP=1; } +psql < db/001-auth.sql +psql <<'SQL' +INSERT INTO project_accounts(project,id,google_subject,email) +VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid'); +INSERT INTO project_sessions VALUES +('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); +SQL +psql <<'SQLROLE' +CREATE ROLE foundry_fixture LOGIN; +SQLROLE +python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture +python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture +psql <<'SQL' +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM project_sessions s JOIN project_accounts a + ON (s.project,s.account_id)=(a.project,a.id) + JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) + WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google' + ) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF; +END $$; +BEGIN; +INSERT INTO project_accounts(project,id,email) VALUES +('fixture','00000000-0000-0000-0000-000000000002','email@example.invalid'); +INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES +('fixture','email@example.invalid','00000000-0000-0000-0000-000000000002','fixture-hash'); +INSERT INTO workspaces(project,id,name,owner_id) VALUES +('fixture','00000000-0000-0000-0000-000000000010','Fixture','00000000-0000-0000-0000-000000000001'); +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin'); +INSERT INTO materials(project,workspace_id,id,kind,title,created_by) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000020','article','Article','00000000-0000-0000-0000-000000000001'), +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','board','Board','00000000-0000-0000-0000-000000000001'); +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height) VALUES +('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','00000000-0000-0000-0000-000000000020',0,0,320,200); +DO $$ +BEGIN + BEGIN + INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES + ('other','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin'); + RAISE EXCEPTION 'Cross-project membership unexpectedly accepted'; + EXCEPTION WHEN foreign_key_violation THEN NULL; + END; +END $$; +ROLLBACK; +SQL +printf 'Migration preserved existing logins and enforced project isolation.\n' +for package in platform auth; do + CGO_ENABLED=0 go test -mod=readonly -c -o "bin/$package-tests" "./internal/$package" + docker run --rm --network "container:$container" --user 1000:1000 \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$(pwd)/bin/$package-tests,target=/opt/integration-tests,readonly" \ + -e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \ + --entrypoint /opt/integration-tests postgres:17-alpine -test.v +done diff --git a/deploy/codex-worker.sh b/deploy/codex-worker.sh new file mode 100644 index 0000000..2747092 --- /dev/null +++ b/deploy/codex-worker.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +base=/opt/endoleap-infra +dir=$base/foundry/codex +binary=$(realpath "$1") +test -x "$binary" +test -s "$dir/home/auth.json" +test -s "$dir/grants/projects.json" +install -d -m 700 "$dir/run" +chown 1000:1000 "$dir/run" + +# The host operator supplies an isolated Codex home and starts a single worker. +# The Docker default bridge supplies egress independently of Foundry's network. +docker run -d --name foundry-codex-worker --restart unless-stopped --init \ + --network bridge --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ + --read-only --pids-limit 128 --memory 384m --tmpfs /tmp:rw,nosuid,nodev \ + --tmpfs /work:ro,nosuid,nodev --workdir /work \ + --mount "type=bind,source=$binary,target=/opt/foundry,readonly" \ + --mount "type=bind,source=$base/codex-cli,target=/opt/codex,readonly" \ + --mount "type=bind,source=$dir/home,target=/home/node/.codex" \ + --mount "type=bind,source=$dir/run,target=/run/foundry-codex" \ + --mount "type=bind,source=/etc/ssl/certs/ca-certificates.crt,target=/etc/ssl/certs/ca-certificates.crt,readonly" \ + node:24-bookworm-slim /opt/foundry codex-worker diff --git a/deploy/migrate.py b/deploy/migrate.py new file mode 100644 index 0000000..7cc46ff --- /dev/null +++ b/deploy/migrate.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Apply checked migrations atomically from the trusted deployment runner.""" +import argparse +import hashlib +from pathlib import Path +import re +import subprocess + +parser = argparse.ArgumentParser() +parser.add_argument("--container", default="endoleap-dev-postgres-1") +parser.add_argument("--database", default="foundry_auth") +parser.add_argument("--role", default="foundry") +args = parser.parse_args() +for value in (args.database, args.role): + if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", value): + raise SystemExit("Invalid deployment database or role") +migrations = sorted((Path(__file__).resolve().parent.parent / "db").glob("[0-9]*.sql")) +if not migrations: + raise SystemExit("No migrations found") + +script = [ + "\\set ON_ERROR_STOP on", + "BEGIN;", + "SET LOCAL lock_timeout='30s';", + "SELECT pg_advisory_xact_lock(731920164);", + "CREATE TABLE IF NOT EXISTS foundry_schema_migrations " + "(name text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now());", +] +tables = set() +for index, path in enumerate(migrations): + source = path.read_text() + name = path.name + if not re.fullmatch(r"[0-9]+-[a-z0-9-]+\.sql", name): + raise SystemExit("Invalid migration name") + checksum = hashlib.sha256(source.encode()).hexdigest() + tables.update(re.findall(r"CREATE TABLE ([a-z_]+)", source)) + # The first deployed version predates migration tracking. + if index == 0: + script.extend([ + "SELECT bool_and(to_regclass('public.' || name) IS NOT NULL) AS baseline FROM " + "unnest(ARRAY['project_accounts','oauth_attempts','login_grants','project_sessions']) name \\gset", + "\\if :baseline", + f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}') ON CONFLICT DO NOTHING;", + "\\endif", + ]) + script.extend([ + f"SELECT EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}') AS applied, " + f"EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}' AND checksum='{checksum}') AS valid \\gset", + "\\if :applied", + "\\if :valid", + "\\else", + "\\echo Migration checksum mismatch", + "\\quit 1", + "\\endif", + "\\else", + ]) + source = source.strip() + if not source.startswith("BEGIN;") or not source.endswith("COMMIT;"): + raise SystemExit("Migration must have explicit transaction boundaries") + script.append(source[len("BEGIN;"):-len("COMMIT;")]) + script.extend([ + f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}');", + "\\endif", + ]) +for table in sorted(tables): + script.append(f'GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE "{table}" TO "{args.role}";') +script.extend([ + f'GRANT USAGE,SELECT ON SEQUENCE link_clicks_id_seq TO "{args.role}";', + "COMMIT;", +]) +subprocess.run( + ["docker", "exec", "-i", args.container, "sh", "-c", + 'exec psql -X -U "${POSTGRES_USER:-postgres}" -d "$1" -v ON_ERROR_STOP=1', "sh", args.database], + input=("\n".join(script) + "\n").encode(), check=True, +) +print("Schema migrations and application grants verified.") diff --git a/deploy/release.sh b/deploy/release.sh new file mode 100644 index 0000000..cace2aa --- /dev/null +++ b/deploy/release.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +base=/opt/endoleap-infra +foundry=$base/foundry +origin=https://api.foundry.entroia.com +candidate=foundry-core-candidate +network=endoleap-dev_default + +for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET; do + value=${!name-} + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'Missing or invalid %s\n' "$name" >&2 + exit 1 + fi +done +# Accept formatted JSON from Actions Variables and never print its contents. +FOUNDRY_PROJECTS=$(python3 - <<'PYCONFIG' +import json +import os +import sys + +raw = os.environ.get("FOUNDRY_PROJECTS", "") +if not raw.strip(): + sys.exit("FOUNDRY_PROJECTS was not injected from Actions Variables") +try: + projects = json.loads(raw) +except ValueError: + sys.exit("FOUNDRY_PROJECTS must be valid JSON") +if not isinstance(projects, dict) or not projects or any( + not isinstance(callback, str) or not callback.strip() + for callback in projects.values() +): + sys.exit("FOUNDRY_PROJECTS must map project names to callback URLs") +print(json.dumps(projects, separators=(",", ":"))) +PYCONFIG +) +export FOUNDRY_PROJECTS + +test -s "$foundry/database.env" +# Go's HTTPS client uses the OS trust store, not Node's bundled roots. +# The slim Node runtime has no system CA bundle of its own. +ca_bundle=/etc/ssl/certs/ca-certificates.crt +test -s "$ca_bundle" +docker network inspect "$network" >/dev/null +python3 "$(dirname "$0")/migrate.py" + +release=$(mktemp -d "$foundry/release.XXXXXXXX") +install -m 755 "$1" "$release/foundry" +export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" +mail_env=() +if [[ -s "$foundry/mail.env" ]]; then + mail_env=(--env-file "$foundry/mail.env") +fi +slack_env=() +if [[ -n "${SLACK_CLIENT_ID:-}" || -n "${SLACK_CLIENT_SECRET:-}" ]]; then + for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET; do + value=${!name-} + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'Missing or invalid %s\n' "$name" >&2 + exit 1 + fi + done + export SLACK_CLIENT_ID SLACK_CLIENT_SECRET + slack_env=(-e SLACK_CLIENT_ID -e SLACK_CLIENT_SECRET) +elif [[ -s "$foundry/slack.env" ]]; then + slack_env=(--env-file "$foundry/slack.env") +fi +codex_mounts=() +codex_dir=$foundry/codex +if [[ -d "$codex_dir" ]]; then + if ! docker inspect -f '{{.State.Running}}' foundry-codex-worker 2>/dev/null | grep -qx true; then + bash "$(dirname "$0")/codex-worker.sh" "$release/foundry" >/dev/null + fi + for attempt in {1..20}; do + [[ ! -S "$codex_dir/run/worker.sock" ]] || break + sleep 0.25 + done + test -S "$codex_dir/run/worker.sock" + test -s "$codex_dir/grants/projects.json" + codex_mounts=( + --mount "type=bind,source=$codex_dir/run,target=/run/foundry-codex,readonly" + --mount "type=bind,source=$codex_dir/grants,target=/etc/foundry-codex,readonly" + -e FOUNDRY_CODEX_SOCKET=/run/foundry-codex/worker.sock + -e FOUNDRY_CODEX_GRANTS=/etc/foundry-codex/projects.json + ) +fi + +docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack_env[@]}" --network "$network" \ + --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ + -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \ + --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 128m \ + --mount "type=bind,source=$ca_bundle,target=/etc/ssl/certs/ca-certificates.crt,readonly" \ + -e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \ + node:24-bookworm-slim /opt/foundry >/dev/null + +cleanup_candidate() { + docker rm -f "${live:-$candidate}" >/dev/null 2>&1 || true +} +trap cleanup_candidate EXIT + +docker exec "$candidate" node -e ' + const assert = require("node:assert/strict"); + const fs = require("node:fs"); + async function verify() { + assert.ok(fs.readFileSync(process.env.SSL_CERT_FILE, "utf8").includes("-----BEGIN CERTIFICATE-----")); + const base = "http://127.0.0.1:8080"; + for (let attempt = 0; ; attempt++) { + try { + const ready = await fetch(base + "/readyz", {signal: AbortSignal.timeout(3000)}); + if (ready.ok) break; + } catch (error) { + if (attempt === 9) throw error; + } + assert.ok(attempt < 9); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; + const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; + assert.equal((await fetch(route + "/me")).status, 401); + if (process.env.FOUNDRY_CODEX_SOCKET) { + const exec = base + "/projects/" + encodeURIComponent(project) + "/codex/exec"; + assert.equal((await fetch(exec, {method: "POST"})).status, 401); + } + } + verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); }); +' + +# Keep the previous running container until the new candidate has passed checks. +# Traefik reads the route atomically from its watched config directory. +id=$(docker inspect -f '{{.Id}}' "$candidate") +live=foundry-core-${id:0:12} +docker rename "$candidate" "$live" +docker update --restart unless-stopped "$live" >/dev/null +bash "$(dirname "$0")/route.sh" "$live" +trap - EXIT +printf 'Foundry readiness and unauthenticated rejection verified.\n' diff --git a/deploy/route.sh b/deploy/route.sh new file mode 100644 index 0000000..5a7c29b --- /dev/null +++ b/deploy/route.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +cd /opt/endoleap-infra +live=$1 +[[ "$live" =~ ^foundry-core-[a-f0-9]{12}$ ]] + +install -d -m 700 dev/foundry-edge +edge=dev/foundry-edge +route=$edge/foundry.yml +previous= +if [[ -f "$route" ]]; then + previous=$(sed -n 's@.*url: http://\(foundry-core-[a-f0-9]\{12\}\):8080.*@\1@p' "$route") +fi +backup=$(mktemp) +if [[ -f "$route" ]]; then cp "$route" "$backup"; fi +rollback() { + if [[ -s "$backup" ]]; then + cp "$backup" "$edge/.restore" + mv "$edge/.restore" "$route" + else + rm -f "$route" + fi + rm -f "$backup" +} +trap rollback EXIT + +touch "$edge/acme.json" +chmod 600 "$edge/acme.json" +cat > "$edge/.foundry.yml" < foundry-edge.yml <<'YAML' +services: + traefik: + command: + - --entrypoints.web.address=:8080 + - --entrypoints.websecure.address=:8443 + - --ping=true + - --ping.entrypoint=web + - --api.dashboard=false + - --providers.file.directory=/edge + - --certificatesresolvers.public.acme.storage=/edge/acme.json + - --certificatesresolvers.public.acme.tlschallenge=true + ports: + - "443:8443" + volumes: + - ./dev/foundry-edge:/edge +YAML +docker compose -p endoleap-dev --env-file dev/env -f compose.yml -f dev.yml -f foundry-edge.yml up -d --no-deps traefik + +curl --fail --silent --show-error --output /dev/null --max-time 6 \ + --retry 15 --retry-delay 4 --retry-all-errors \ + --resolve api.foundry.entroia.com:443:127.0.0.1 \ + https://api.foundry.entroia.com/readyz + +trap - EXIT +rm -f "$backup" +if [[ "$previous" =~ ^foundry-core-[a-f0-9]{12}$ && "$previous" != "$live" ]]; then + docker stop "$previous" >/dev/null || printf 'Previous container needs manual cleanup.\n' >&2 +fi diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e85fd71..f15c2c6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/crypto/bcrypt" "io" "log" "net/http" @@ -26,10 +27,18 @@ type service struct { projects map[string]*url.URL redirectURI, clientID, secret string google *http.Client + email *EmailConfig + passwordSlots chan struct{} + dummyPassword []byte + slack *SlackConfig } -func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) { - s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, +func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig, slack *SlackConfig) (http.Handler, error) { + dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + s := &service{db: db, email: email, slack: slack, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} for name, raw := range callbacks { u, err := url.Parse(raw) @@ -52,6 +61,7 @@ func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string reply(w, 200, payload{"status": "ready"}) return nil })) + mux.HandleFunc("/auth/slack/callback", s.wrap(s.slackCallback)) mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback)) mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -106,6 +116,9 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { if frontend == nil { return reject(w, 404, "not_found") } + if r.Method == http.MethodGet && action == "slack" { + return s.startSlack(w, r, project) + } if r.Method == http.MethodGet && action == "google" { return s.start(w, r, project) } @@ -120,8 +133,10 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error { switch { case r.Method == http.MethodOptions: w.Header().Set("Access-Control-Allow-Methods", "GET, POST") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Foundry-Code, X-Foundry-Verifier") reply(w, 204, nil) + case r.Method == http.MethodPost && (action == "register" || action == "verify-email" || action == "login"): + return s.emailAction(w, r, project, action) case r.Method == http.MethodPost && action == "exchange": return s.exchange(w, r, project) case r.Method == http.MethodGet && action == "me": @@ -146,14 +161,14 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string) } state, browser, verifier := token(), token(), token() _, err := s.db.ExecContext(r.Context(), ` - INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') + INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at) VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes') `, digest(state), digest(browser), project, challenge, verifier) if err != nil { return err } query := url.Values{ "client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"}, - "scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)}, + "scope": {"openid email profile"}, "state": {state}, "code_challenge": {digest(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"}, } http.SetCookie(w, oauthCookie(state, browser, 600)) @@ -201,7 +216,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { var project, challenge, verifier string err = s.db.QueryRowContext(ctx, ` DELETE FROM oauth_attempts - WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() + WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() AND provider='google' RETURNING project, frontend_challenge, google_verifier `, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier) if errors.Is(err, sql.ErrNoRows) { @@ -240,6 +255,8 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { Subject string `json:"sub"` Email *string `json:"email"` Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` } if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil { return err @@ -251,16 +268,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error { user.Email = nil } - grant := token() - _, err = s.db.ExecContext(ctx, ` - WITH account AS ( - INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, gen_random_uuid(), $2, $3) - ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email - RETURNING id - ) - INSERT INTO login_grants - SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account - `, project, user.Subject, user.Email, digest(grant), challenge) + grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge) if err != nil { return err } @@ -276,13 +284,13 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin } session := token() result, err := s.db.ExecContext(r.Context(), ` - WITH grant AS ( + WITH redeemed_grant AS ( DELETE FROM login_grants WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() RETURNING account_id ) INSERT INTO project_sessions - SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant + SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant `, digest(code), project, digest(verifier), digest(session)) if err != nil { return err diff --git a/internal/auth/email.go b/internal/auth/email.go new file mode 100644 index 0000000..c94749c --- /dev/null +++ b/internal/auth/email.go @@ -0,0 +1,217 @@ +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/mail" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func emailAddress(value string) (string, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + address, err := mail.ParseAddress(value) + return value, err == nil && address.Address == value && len(value) <= 254 +} +func emailBody(w http.ResponseWriter, r *http.Request, input any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384)) + decoder.DisallowUnknownFields() + if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF { + _ = reject(w, 400, "invalid_request") + return false + } + return true +} + +func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, ` +INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes') +ON CONFLICT(key_hash) DO UPDATE SET + attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END, + expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END +RETURNING attempts +`, digest(key)).Scan(&count) + return count <= limit, err +} + +func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error { + if action == "verify-email" { + return s.verifyEmail(w, r, project) + } + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !emailBody(w, r, &input) { + return nil + } + email, valid := emailAddress(input.Email) + if !valid || len(input.Password) < 12 || len(input.Password) > 72 { + return reject(w, 400, "invalid_credentials_format") + } + limit := 10 + if action == "register" { + if s.email == nil { + return reject(w, 503, "email_not_configured") + } + limit = 3 + } + allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit) + if err != nil { + return err + } + if !allowed { + w.Header().Set("Retry-After", "900") + return reject(w, 429, "rate_limited") + } + select { + case s.passwordSlots <- struct{}{}: + defer func() { <-s.passwordSlots }() + default: + w.Header().Set("Retry-After", "2") + return reject(w, 429, "auth_busy") + } + if action == "register" { + return s.registerEmail(w, r, project, email, input.Password) + } + var account, hash string + err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash) + missing := errors.Is(err, sql.ErrNoRows) + if err != nil && !missing { + return err + } + passwordHash := []byte(hash) + if missing { + passwordHash = s.dummyPassword + } + if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing { + return reject(w, 401, "invalid_credentials") + } + session := token() + _, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + if err != nil { + return err + } + reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + return nil +} + +func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil { + return err + } + verification := token() + result, err := tx.ExecContext(r.Context(), ` +INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at) +SELECT $1,$2,$3,$4,now()+interval '30 minutes' +WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2) +ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at +`, project, email, string(hash), digest(verification)) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n > 0 { + destination := *s.projects[project] + destination.Fragment = "verify_email=" + verification + body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email." + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes') +`, project, email, body) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 202, payload{"status": "verification_requested"}) + return nil +} + +func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error { + var input struct { + Token string `json:"token"` + } + if !emailBody(w, r, &input) { + return nil + } + if !tokenPattern.MatchString(input.Token) { + return reject(w, 400, "invalid_verification") + } + allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10) + if err != nil { + return err + } + if !allowed { + return reject(w, 429, "rate_limited") + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var email, hash string + err = tx.QueryRowContext(r.Context(), "SELECT email FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now()", project, digest(input.Token)).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_verification") + } + if err != nil { + return err + } + if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil { + return err + } + err = tx.QueryRowContext(r.Context(), ` +DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash +`, project, digest(input.Token)).Scan(&email, &hash) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_verification") + } + if err != nil { + return err + } + var account string + err = tx.QueryRowContext(r.Context(), ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,email,username) +SELECT $1,id,$2, + left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7) + || '_' || replace(id::text,'-','') FROM identity RETURNING id::text +`, project, email).Scan(&account) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash) + if err != nil { + return err + } + session := token() + _, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) + return nil +} diff --git a/internal/auth/email_test.go b/internal/auth/email_test.go new file mode 100644 index 0000000..b2b3227 --- /dev/null +++ b/internal/auth/email_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "net/http/httptest" + "net/url" + "os" + "regexp" + "strings" + "testing" +) + +func TestConcurrentRegistrationAndVerification(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var raw [12]byte + rand.Read(raw[:]) + project := "email-" + hex.EncodeToString(raw[:]) + frontend, _ := url.Parse("https://app.example.com/auth/callback") + s := &service{db: db, projects: map[string]*url.URL{project: frontend}} + defer func() { + _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.Exec("DELETE FROM email_registrations WHERE project=$1", project) + _, _ = db.Exec("DELETE FROM email_outbox WHERE project=$1", project) + }() + request := httptest.NewRequest("POST", "https://api.example.com/register", nil) + out := httptest.NewRecorder() + if err = s.registerEmail(out, request, project, "fixture@example.invalid", "long-fixture-password"); err != nil { + t.Fatal(err) + } + var body string + if err = db.QueryRow("SELECT body FROM email_outbox WHERE project=$1", project).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing verification link") + } + done := make(chan error, 2) + go func() { + r := httptest.NewRequest("POST", "https://api.example.com/register", nil) + done <- s.registerEmail(httptest.NewRecorder(), r, project, "fixture@example.invalid", "another-fixture-password") + }() + go func() { + r := httptest.NewRequest("POST", "https://api.example.com/verify-email", strings.NewReader(`{"token":"`+match[1]+`"}`)) + done <- s.verifyEmail(httptest.NewRecorder(), r, project) + }() + for range 2 { + if err := <-done; err != nil { + t.Fatal(err) + } + } + var accounts, pending int + if err = db.QueryRow("SELECT count(*) FROM email_credentials WHERE project=$1", project).Scan(&accounts); err != nil { + t.Fatal(err) + } + if err = db.QueryRow("SELECT count(*) FROM email_registrations WHERE project=$1", project).Scan(&pending); err != nil { + t.Fatal(err) + } + if accounts+pending != 1 { + t.Fatalf("concurrent registration left accounts=%d pending=%d", accounts, pending) + } +} diff --git a/internal/auth/identity.go b/internal/auth/identity.go new file mode 100644 index 0000000..d4ef1e9 --- /dev/null +++ b/internal/auth/identity.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "net/url" + "strings" +) + +func (s *service) identityGrant(ctx context.Context, project, provider, subject string, email *string, name, picture, challenge string) (string, error) { + name = strings.TrimSpace(name) + if len(name) > 160 { + name = "" + } + if u, err := url.Parse(picture); err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(picture) > 2048 { + picture = "" + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + // Serialize first registration for the same provider identity. + if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", project+":"+provider+":"+subject); err != nil { + return "", err + } + var account string + err = tx.QueryRowContext(ctx, "SELECT account_id::text FROM account_identities WHERE project=$1 AND provider=$2 AND subject=$3", project, provider, subject).Scan(&account) + if errors.Is(err, sql.ErrNoRows) { + var googleSubject *string + if provider == "google" { + googleSubject = &subject + } + err = tx.QueryRowContext(ctx, ` +WITH identity AS(SELECT gen_random_uuid() AS id) +INSERT INTO project_accounts(project,id,google_subject,email,username,display_name,avatar_url) +SELECT $1,id,$2,$3, + left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-',''), + $4,NULLIF($5,'') FROM identity RETURNING id::text +`, project, googleSubject, email, name, picture).Scan(&account) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, "INSERT INTO account_identities(project,provider,subject,account_id) VALUES($1,$2,$3,$4)", project, provider, subject, account) + } else if err == nil { + _, err = tx.ExecContext(ctx, ` +UPDATE project_accounts SET email=COALESCE($3,email), + display_name=CASE WHEN display_name='' THEN $4 ELSE display_name END, + avatar_url=COALESCE(avatar_url,NULLIF($5,'')), + username=COALESCE(username,left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-','')), + updated_at=now() WHERE project=$1 AND id=$2 +`, project, account, email, name, picture) + } + if err != nil { + return "", err + } + grant := token() + _, err = tx.ExecContext(ctx, "INSERT INTO login_grants VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(grant), project, account, challenge) + if err != nil { + return "", err + } + if err = tx.Commit(); err != nil { + return "", err + } + return grant, nil +} diff --git a/internal/auth/mailer.go b/internal/auth/mailer.go new file mode 100644 index 0000000..77efda5 --- /dev/null +++ b/internal/auth/mailer.go @@ -0,0 +1,113 @@ +package auth + +import ( + "context" + "crypto/tls" + "database/sql" + "errors" + "fmt" + "log" + "net" + "net/mail" + "net/smtp" + "os" + "strings" + "time" +) + +type EmailConfig struct { + Host, Port, Username, Password, From string +} + +func EmailConfigFromEnv() (*EmailConfig, error) { + c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")} + if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" { + return nil, nil + } + address, err := mail.ParseAddress(c.From) + if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") { + return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required") + } + return c, nil +} + +func (c *EmailConfig) send(recipient, subject, body string) error { + if strings.ContainsAny(recipient+subject, "\r\n") { + return errors.New("invalid mail headers") + } + connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return err + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(30 * time.Second)) + client, err := smtp.NewClient(connection, c.Host) + if err != nil { + return err + } + defer client.Close() + if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil { + return err + } + if err = client.Mail(c.From); err != nil { + return err + } + if err = client.Rcpt(recipient); err != nil { + return err + } + writer, err := client.Data() + if err != nil { + return err + } + message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body) + if _, err = writer.Write([]byte(message)); err != nil { + return err + } + if err = writer.Close(); err != nil { + return err + } + return client.Quit() +} + +func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) { + if c == nil { + return + } + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) { + log.Printf("email delivery failed (%T)", err) + } + } + } +} +func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error { + var id, lease, recipient, subject, body string + var attempts int + err := db.QueryRowContext(ctx, ` +UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1 +WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10 + AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now()) + ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1) +RETURNING id::text,lease_id::text,recipient,subject,body,attempts +`).Scan(&id, &lease, &recipient, &subject, &body, &attempts) + if err != nil { + return err + } + sendErr := c.send(recipient, subject, body) + if sendErr == nil { + _, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease) + return err + } + delay := 30 * (1 << min(attempts, 7)) + _, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay) + if err != nil { + return err + } + return sendErr +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..da62d70 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,27 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +var ErrInvalidSession = errors.New("invalid session") + +// Authenticate resolves only an unexpired session belonging to this project. +func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { + value, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || !tokenPattern.MatchString(value) { + return "", ErrInvalidSession + } + var id string + err := db.QueryRowContext(ctx, ` +SELECT account_id::text FROM project_sessions +WHERE project=$1 AND token_hash=$2 AND expires_at>now() +`, project, digest(value)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrInvalidSession + } + return id, err +} diff --git a/internal/auth/slack.go b/internal/auth/slack.go new file mode 100644 index 0000000..1a94da6 --- /dev/null +++ b/internal/auth/slack.go @@ -0,0 +1,226 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +type SlackConfig struct { + ClientID, Secret, RedirectURI string + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +func SlackConfigFromEnv(redirect string) (*SlackConfig, error) { + id, secret := os.Getenv("SLACK_CLIENT_ID"), os.Getenv("SLACK_CLIENT_SECRET") + if id == "" && secret == "" { + return nil, nil + } + if id == "" || secret == "" || strings.ContainsAny(id+secret, "\r\n") { + return nil, errors.New("Slack requires client ID and secret") + } + return &SlackConfig{ClientID: id, Secret: secret, RedirectURI: redirect}, nil +} + +type slackClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expires int64 `json:"exp"` + Issued int64 `json:"iat"` + Nonce string `json:"nonce"` + Team string `json:"https://slack.com/team_id"` + Email *string `json:"email"` + Verified bool `json:"email_verified"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (s *service) slackKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + c := s.slack + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.fetchedAt) < time.Minute { + if key := c.keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") + } + var document struct { + Keys []struct { + Type string `json:"kty"` + ID string `json:"kid"` + Algorithm string `json:"alg"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + } + } + if err := s.googleJSON(ctx, "GET", "https://slack.com/openid/connect/keys", nil, "", &document); err != nil { + return nil, err + } + keys := map[string]*rsa.PublicKey{} + for _, key := range document.Keys { + n, nErr := base64.RawURLEncoding.DecodeString(key.N) + e, eErr := base64.RawURLEncoding.DecodeString(key.E) + if key.Type != "RSA" || (key.Algorithm != "" && key.Algorithm != "RS256") || (key.Use != "" && key.Use != "sig") || nErr != nil || eErr != nil || len(e) > 4 { + continue + } + modulus := new(big.Int).SetBytes(n) + exponent := new(big.Int).SetBytes(e).Int64() + if modulus.BitLen() < 2048 || modulus.BitLen() > 8192 || exponent < 3 || exponent > 2147483647 || exponent%2 == 0 { + continue + } + keys[key.ID] = &rsa.PublicKey{N: modulus, E: int(exponent)} + } + c.keys, c.fetchedAt = keys, time.Now() + if key := keys[kid]; key != nil { + return key, nil + } + return nil, errors.New("unknown Slack signing key") +} +func (s *service) verifySlack(ctx context.Context, value, nonce string) (slackClaims, error) { + var claims slackClaims + invalid := errors.New("invalid Slack identity token") + if len(value) > 32768 { + return claims, invalid + } + parts := strings.Split(value, ".") + if len(parts) != 3 { + return claims, invalid + } + header, err := base64.RawURLEncoding.DecodeString(parts[0]) + var h struct { + Algorithm string `json:"alg"` + ID string `json:"kid"` + } + if err != nil || json.Unmarshal(header, &h) != nil || h.Algorithm != "RS256" || h.ID == "" { + return claims, invalid + } + key, err := s.slackKey(ctx, h.ID) + if err != nil { + return claims, err + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err != nil || rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) != nil { + return claims, invalid + } + body, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || json.Unmarshal(body, &claims) != nil { + return claims, invalid + } + now := time.Now().Unix() + if claims.Issuer != "https://slack.com" || claims.Audience != s.slack.ClientID || claims.Expires <= now || + claims.Issued <= 0 || claims.Issued > now+60 || claims.Expires <= claims.Issued || claims.Nonce != nonce || + claims.Subject == "" || len(claims.Subject) > 128 || claims.Team == "" || len(claims.Team) > 128 || + strings.ContainsAny(claims.Subject+claims.Team, ":\r\n") { + return claims, invalid + } + if !claims.Verified { + claims.Email = nil + } + return claims, nil +} +func (s *service) startSlack(w http.ResponseWriter, r *http.Request, project string) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + challenge := r.URL.Query().Get("challenge") + if !tokenPattern.MatchString(challenge) { + return reject(w, 400, "invalid_challenge") + } + state, browser, nonce := token(), token(), token() + _, err := s.db.ExecContext(r.Context(), ` +INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at,provider,oidc_nonce) +VALUES($1,$2,$3,$4,'',now()+interval '10 minutes','slack',$5) +`, digest(state), digest(browser), project, challenge, nonce) + if err != nil { + return err + } + cookie := oauthCookie(state, browser, 600) + cookie.SameSite = http.SameSiteNoneMode + http.SetCookie(w, cookie) + query := url.Values{"response_type": {"code"}, "scope": {"openid profile email"}, "client_id": {s.slack.ClientID}, "state": {state}, "nonce": {nonce}, "redirect_uri": {s.slack.RedirectURI}} + http.Redirect(w, r, "https://slack.com/openid/connect/authorize?"+query.Encode(), 302) + return nil +} +func (s *service) slackCallback(w http.ResponseWriter, r *http.Request) error { + if s.slack == nil { + return reject(w, 503, "slack_not_configured") + } + if r.Method != http.MethodGet && r.Method != http.MethodPost { + return reject(w, 405, "method_not_allowed") + } + r.Body = http.MaxBytesReader(w, r.Body, 16384) + if r.ParseForm() != nil { + return reject(w, 400, "invalid_callback") + } + state := r.Form.Get("state") + cookie, err := r.Cookie(oauthCookie(state, "", 0).Name) + if !tokenPattern.MatchString(state) || err != nil || !tokenPattern.MatchString(cookie.Value) { + return reject(w, 400, "invalid_state") + } + var project, challenge, nonce string + err = s.db.QueryRowContext(r.Context(), ` +DELETE FROM oauth_attempts WHERE state_hash=$1 AND browser_hash=$2 AND expires_at>now() AND provider='slack' +RETURNING project,frontend_challenge,oidc_nonce +`, digest(state), digest(cookie.Value)).Scan(&project, &challenge, &nonce) + if errors.Is(err, sql.ErrNoRows) { + return reject(w, 400, "invalid_state") + } + if err != nil { + return err + } + frontend := s.projects[project] + if frontend == nil { + return reject(w, 400, "invalid_state") + } + expired := oauthCookie(state, "", -1) + expired.SameSite = http.SameSiteNoneMode + http.SetCookie(w, expired) + destination := *frontend + if r.Form.Get("code") == "" || r.Form.Has("error") { + destination.Fragment = "error=slack_login_cancelled" + http.Redirect(w, r, destination.String(), 302) + return nil + } + var tokens struct { + OK bool `json:"ok"` + IDToken string `json:"id_token"` + } + err = s.googleJSON(r.Context(), "POST", "https://slack.com/api/openid.connect.token", url.Values{ + "code": {r.Form.Get("code")}, "client_id": {s.slack.ClientID}, "client_secret": {s.slack.Secret}, "redirect_uri": {s.slack.RedirectURI}, "grant_type": {"authorization_code"}, + }, "", &tokens) + if err != nil { + return err + } + if !tokens.OK || tokens.IDToken == "" { + return errors.New("Slack token exchange failed") + } + claims, err := s.verifySlack(r.Context(), tokens.IDToken, nonce) + if err != nil { + return err + } + grant, err := s.identityGrant(r.Context(), project, "slack", claims.Team+":"+claims.Subject, claims.Email, claims.Name, claims.Picture, challenge) + if err != nil { + return err + } + destination.Fragment = "code=" + grant + http.Redirect(w, r, destination.String(), 302) + return nil +} diff --git a/internal/auth/slack_test.go b/internal/auth/slack_test.go new file mode 100644 index 0000000..938f604 --- /dev/null +++ b/internal/auth/slack_test.go @@ -0,0 +1,222 @@ +package auth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func signIdentity(t *testing.T, key *rsa.PrivateKey, claims slackClaims) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"fixture"}`)) + body, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + value := header + "." + base64.RawURLEncoding.EncodeToString(body) + hash := sha256.Sum256([]byte(value)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + return value + "." + base64.RawURLEncoding.EncodeToString(sig) +} +func TestSlackIdentityVerification(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + s := &service{slack: &SlackConfig{ClientID: "fixture", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}} + good := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: "nonce"} + for _, name := range []string{"valid", "issuer", "audience", "expired", "nonce", "signature", "algorithm"} { + t.Run(name, func(t *testing.T) { + claims := good + switch name { + case "issuer": + claims.Issuer = "https://attacker.invalid" + case "audience": + claims.Audience = "other-client" + case "expired": + claims.Expires = time.Now().Add(-time.Minute).Unix() + case "nonce": + claims.Nonce = "wrong" + } + value := signIdentity(t, key, claims) + if name == "signature" { + parts := strings.Split(value, ".") + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + sig[0] ^= 1 + parts[2] = base64.RawURLEncoding.EncodeToString(sig) + value = strings.Join(parts, ".") + } + if name == "algorithm" { + parts := strings.Split(value, ".") + parts[0] = base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","kid":"fixture"}`)) + value = strings.Join(parts, ".") + } + _, err := s.verifySlack(context.Background(), value, "nonce") + if (err == nil) != (name == "valid") { + t.Fatalf("unexpected verification result %v", err) + } + }) + } +} +func TestSlackCallbackAndExchange(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + var value [12]byte + rand.Read(value[:]) + project := "slack-" + hex.EncodeToString(value[:]) + defer db.Exec("DELETE FROM project_accounts WHERE project=$1", project) + frontend, _ := url.Parse("https://app.example.com/auth/callback") + config := &SlackConfig{ClientID: "fixture", Secret: "fixture-secret", RedirectURI: "https://api.example.com/auth/slack/callback", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()} + verifier := token() + var nonce string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("unexpected Slack request method") + } + _ = r.ParseForm() + if r.Form.Get("client_id") != "fixture" || r.Form.Get("client_secret") != "fixture-secret" { + t.Error("incorrect Slack client credentials") + } + claims := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: nonce, Name: "Slack User"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id_token": signIdentity(t, key, claims)}) + })) + defer upstream.Close() + s := &service{db: db, projects: map[string]*url.URL{project: frontend}, slack: config, google: &http.Client{Transport: rewriteTransport{destination: upstream.URL}}} + start := httptest.NewRequest("GET", "https://api.example.com/projects/"+project+"/auth/slack?challenge="+digest(verifier), nil) + out := httptest.NewRecorder() + if err = s.startSlack(out, start, project); err != nil { + t.Fatal(err) + } + if out.Code != 302 { + t.Fatalf("start status %d", out.Code) + } + location, _ := url.Parse(out.Header().Get("Location")) + nonce = location.Query().Get("nonce") + state := location.Query().Get("state") + if nonce == "" || state == "" { + t.Fatal("missing state or nonce") + } + cookies := out.Result().Cookies() + if len(cookies) != 1 || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly { + t.Fatal("incorrect Slack browser binding cookie") + } + body := url.Values{"state": {state}, "code": {"fixture-code"}} + callback := func() *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", config.RedirectURI, strings.NewReader(body.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(cookies[0]) + w := httptest.NewRecorder() + if err := s.slackCallback(w, r); err != nil { + t.Fatal(err) + } + return w + } + success := callback() + if success.Code != 302 { + t.Fatalf("callback status %d %s", success.Code, success.Body.String()) + } + destination, _ := url.Parse(success.Header().Get("Location")) + fragment, _ := url.ParseQuery(destination.Fragment) + grant := fragment.Get("code") + if !tokenPattern.MatchString(grant) { + t.Fatal("callback missing one-time exchange code") + } + if replay := callback(); replay.Code != 400 { + t.Fatal("Slack callback replay accepted") + } + request := httptest.NewRequest("POST", "https://api.example.com/projects/"+project+"/auth/exchange", nil) + request.Header.Set("X-Foundry-Code", grant) + request.Header.Set("X-Foundry-Verifier", verifier) + exchange := httptest.NewRecorder() + if err = s.exchange(exchange, request, project); err != nil { + t.Fatal(err) + } + if exchange.Code != 200 { + t.Fatalf("exchange status %d", exchange.Code) + } + var account, username, name string + err = db.QueryRow("SELECT a.id::text,a.username,a.display_name FROM project_accounts a JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) WHERE a.project=$1 AND i.provider='slack' AND i.subject='T123:U123'", project).Scan(&account, &username, &name) + if err != nil || username == "" || name != "Slack User" { + t.Fatalf("Slack profile prefill failed %v", err) + } + _, err = db.Exec("UPDATE project_accounts SET username='chosen-name',display_name='Chosen name' WHERE project=$1 AND id=$2", project, account) + if err != nil { + t.Fatal(err) + } + if _, err = s.identityGrant(context.Background(), project, "slack", "T123:U123", nil, "New Slack Name", "", digest(verifier)); err != nil { + t.Fatal(err) + } + if err = db.QueryRow("SELECT username,display_name FROM project_accounts WHERE project=$1 AND id=$2", project, account).Scan(&username, &name); err != nil { + t.Fatal(err) + } + if username != "chosen-name" || name != "Chosen name" { + t.Fatal("provider login overwrote chosen profile") + } +} + +type rewriteTransport struct{ destination string } + +func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) { + cloned := r.Clone(r.Context()) + target, _ := url.Parse(t.destination) + cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host + return http.DefaultTransport.RoundTrip(cloned) +} + +func TestGoogleIdentityKeepsLegacyAccount(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + s := &service{db: db} + grant, err := s.identityGrant(context.Background(), "fixture", "google", "existing-google", nil, "Existing Google User", "", digest(token())) + if err != nil { + t.Fatal(err) + } + var account string + if err = db.QueryRow("SELECT account_id::text FROM login_grants WHERE project='fixture' AND token_hash=$1", digest(grant)).Scan(&account); err != nil { + t.Fatal(err) + } + if account != "00000000-0000-0000-0000-000000000001" { + t.Fatal("legacy Google identity changed account ID") + } + var count int + if err = db.QueryRow("SELECT count(*) FROM project_sessions WHERE project='fixture' AND token_hash='existing-session'").Scan(&count); err != nil || count != 1 { + t.Fatalf("existing Google session was lost %v", err) + } +} diff --git a/internal/codex/http.go b/internal/codex/http.go new file mode 100644 index 0000000..45c93a0 --- /dev/null +++ b/internal/codex/http.go @@ -0,0 +1,143 @@ +package codex + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "strings" + "time" +) + +const ( + maxRequest = 32 << 10 + maxOutput = 512 << 10 + timeout = 90 * time.Second +) + +type task struct { + Prompt string `json:"prompt"` +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func fail(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} + +func readTask(w http.ResponseWriter, r *http.Request, limit int64) (task, bool) { + var input task + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, limit)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&input) + if err == nil { + err = decoder.Decode(new(any)) + if err == io.EOF && strings.TrimSpace(input.Prompt) != "" { + return input, true + } + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + fail(w, 413, "request_too_large") + } else { + fail(w, 400, "invalid_task") + } + return task{}, false +} + +// Register enables the public endpoint only when both configuration files are supplied. +// Each credential digest belongs to exactly one project. +func Register(mux *http.ServeMux, projects map[string]string, socket, grantsFile string) error { + if socket == "" && grantsFile == "" { + return nil + } + if socket == "" || grantsFile == "" { + return errors.New("Codex requires both a socket and a grants file") + } + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + DisableKeepAlives: true, + } + client := &http.Client{ + Transport: transport, + Timeout: timeout + 2*time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + mux.HandleFunc("POST /projects/{project}/codex/exec", func(w http.ResponseWriter, r *http.Request) { + scheme, token, _ := strings.Cut(r.Header.Get("Authorization"), " ") + key, err := hex.DecodeString(token) + if !strings.EqualFold(scheme, "Bearer") || err != nil || len(key) != 32 { + w.Header().Set("WWW-Authenticate", "Bearer") + fail(w, 401, "unauthorized") + return + } + data, err := os.ReadFile(grantsFile) + var grants map[string]string + if err != nil || json.Unmarshal(data, &grants) != nil { + fail(w, 503, "codex_unavailable") + return + } + hash := sha256.Sum256(key) + var scope string + for digest, project := range grants { + value, err := hex.DecodeString(digest) + if err == nil && subtle.ConstantTimeCompare(hash[:], value) == 1 { + scope = project + } + } + if scope == "" { + w.Header().Set("WWW-Authenticate", "Bearer") + fail(w, 401, "unauthorized") + return + } + project := r.PathValue("project") + if _, exists := projects[project]; !exists || scope != project { + fail(w, 403, "forbidden") + return + } + input, ok := readTask(w, r, maxRequest) + if !ok { + return + } + body, _ := json.Marshal(input) + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "http://codex/exec", bytes.NewReader(body)) + if err != nil { + fail(w, 502, "codex_unavailable") + return + } + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + fail(w, 504, "codex_timeout") + } else { + fail(w, 502, "codex_unavailable") + } + return + } + defer resp.Body.Close() + data, err = io.ReadAll(io.LimitReader(resp.Body, maxOutput+1)) + if err != nil || len(data) > maxOutput || !json.Valid(data) { + fail(w, 502, "invalid_codex_response") + return + } + if retry := resp.Header.Get("Retry-After"); retry != "" { + w.Header().Set("Retry-After", retry) + } + writeJSON(w, resp.StatusCode, json.RawMessage(data)) + }) + return nil +} diff --git a/internal/codex/http_test.go b/internal/codex/http_test.go new file mode 100644 index 0000000..718a06c --- /dev/null +++ b/internal/codex/http_test.go @@ -0,0 +1,69 @@ +package codex + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectAuthorizationAndForwarding(t *testing.T) { + dir := t.TempDir() + socket := filepath.Join(dir, "worker.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + worker := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/exec" || r.Header.Get("Authorization") != "" { + fail(w, 500, "unexpected_worker_request") + return + } + input, ok := readTask(w, r, 6*maxRequest) + if ok { + writeJSON(w, 200, map[string]string{"result": input.Prompt}) + } + })} + go worker.Serve(listener) + t.Cleanup(func() { _ = worker.Close() }) + + key := make([]byte, 32) + token := hex.EncodeToString(key) + grants := filepath.Join(dir, "grants.json") + writeGrants := func(data string) { + t.Helper() + if err := os.WriteFile(grants, []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + writeGrants(fmt.Sprintf(`{"%x":"notes"}`, sha256.Sum256(key))) + mux := http.NewServeMux() + if err := Register(mux, map[string]string{"notes": "", "board": ""}, socket, grants); err != nil { + t.Fatal(err) + } + check := func(project, credential, body string, status int, response string) { + t.Helper() + r := httptest.NewRequest("POST", "/projects/"+project+"/codex/exec", strings.NewReader(body)) + if credential != "" { + r.Header.Set("Authorization", "Bearer "+credential) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status || !strings.Contains(w.Body.String(), response) { + t.Fatalf("got %d %s, want %d containing %q", w.Code, w.Body.String(), status, response) + } + } + check("notes", "", `{"prompt":"hello"}`, 401, "unauthorized") + check("board", token, `{"prompt":"hello"}`, 403, "forbidden") + check("notes", token, `{"prompt":"hello","sandbox":"danger"}`, 400, "invalid_task") + check("notes", token, `{"prompt":"`+strings.Repeat("x", maxRequest)+`"}`, 413, "request_too_large") + check("notes", token, `{"prompt":"hello"}`, 200, `"result":"hello"`) + writeGrants("{}") + check("notes", token, `{"prompt":"hello"}`, 401, "unauthorized") +} diff --git a/internal/codex/worker.go b/internal/codex/worker.go new file mode 100644 index 0000000..f4065d3 --- /dev/null +++ b/internal/codex/worker.go @@ -0,0 +1,134 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +const executable = "/opt/codex/node_modules/.bin/codex" + +var errOutputLimit = errors.New("Codex output limit exceeded") + +type outputBuffer struct { + buffer bytes.Buffer + exceeded bool +} + +func (b *outputBuffer) Write(p []byte) (int, error) { + if len(p) > maxOutput-b.buffer.Len() { + b.exceeded = true + return 0, errOutputLimit + } + return b.buffer.Write(p) +} + +func ServeWorker(socket string) error { + syscall.Umask(0077) + if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + defer listener.Close() + if err := os.Chmod(socket, 0600); err != nil { + return err + } + slot := make(chan struct{}, 1) + mux := http.NewServeMux() + mux.HandleFunc("POST /exec", func(w http.ResponseWriter, r *http.Request) { + select { + case slot <- struct{}{}: + defer func() { <-slot }() + default: + w.Header().Set("Retry-After", "5") + fail(w, 429, "codex_busy") + return + } + input, ok := readTask(w, r, 6*maxRequest) + if ok { + execute(w, r, input.Prompt) + } + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: timeout + 15*time.Second} + return server.Serve(listener) +} + +func execute(w http.ResponseWriter, r *http.Request, prompt string) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "--ask-for-approval", "never", + "--disable", "shell_tool", "--disable", "apps", "--disable", "plugins", + "--disable", "multi_agent", "--disable", "hooks", "--disable", "memories", + "-c", `web_search="disabled"`, "exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "--ephemeral", "-") + cmd.Dir = "/work" + cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/home/node", "CODEX_HOME=/home/node/.codex", "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"} + cmd.Stdin = strings.NewReader(prompt) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + cmd.WaitDelay = 2 * time.Second + var output outputBuffer + cmd.Stdout, cmd.Stderr = &output, io.Discard + err := cmd.Run() + switch { + case output.exceeded: + fail(w, 502, "codex_output_too_large") + case errors.Is(ctx.Err(), context.DeadlineExceeded): + fail(w, 504, "codex_timeout") + case err != nil: + fail(w, 502, "codex_failed") + default: + var result string + var completed bool + for _, line := range bytes.Split(bytes.TrimSpace(output.buffer.Bytes()), []byte("\n")) { + var event struct { + Type string `json:"type"` + Item struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if json.Unmarshal(line, &event) != nil { + fail(w, 502, "invalid_codex_response") + return + } + if event.Type == "item.completed" && event.Item.Type == "agent_message" { + result = event.Item.Text + } + if event.Type == "turn.failed" || event.Type == "error" { + fail(w, 502, "codex_failed") + return + } + if event.Type == "turn.completed" { + completed = true + } + } + if !completed || result == "" { + fail(w, 502, "codex_failed") + return + } + response, _ := json.Marshal(map[string]string{"result": result}) + if len(response) >= maxOutput { + fail(w, 502, "codex_output_too_large") + return + } + writeJSON(w, 200, json.RawMessage(response)) + } +} diff --git a/internal/platform/canvas.go b/internal/platform/canvas.go new file mode 100644 index 0000000..a0e02a4 --- /dev/null +++ b/internal/platform/canvas.go @@ -0,0 +1,299 @@ +package platform + +import ( + "encoding/base64" + "encoding/json" + "io" + "math" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +const canvasLimit = 5 << 20 + +var canvasColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) +var canvasID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) +var canvasIcon = regexp.MustCompile(`^[A-Z][A-Za-z0-9]{0,63}$`) + +func canvasNumber(v any, low, high float64) bool { + n, ok := v.(float64) + return ok && !math.IsNaN(n) && !math.IsInf(n, 0) && n >= low && n <= high +} +func canvasString(v any, limit int) bool { + s, ok := v.(string) + return ok && utf8.RuneCountInString(s) <= limit +} +func canvasChoice(v any, choices ...string) bool { + for _, s := range choices { + if v == s { + return true + } + } + return false +} +func canvasFields(m map[string]any, fields ...string) bool { + if m == nil { + return false + } + for k := range m { + found := false + for _, f := range fields { + if k == f { + found = true + break + } + } + if !found { + return false + } + } + return true +} +func canvasImage(v any) bool { + s, ok := v.(string) + if !ok { + return false + } + if strings.HasPrefix(s, "https://") { + u, e := url.Parse(s) + return e == nil && u.Hostname() != "" && u.User == nil && len(s) < 2048 + } + for _, mime := range []string{"png", "jpeg", "webp", "gif"} { + prefix := "data:image/" + mime + ";base64," + if strings.HasPrefix(s, prefix) { + b, e := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, prefix)) + return e == nil && len(b) > 0 && len(b) <= 2<<20 && http.DetectContentType(b) == "image/"+mime + } + } + return false +} +func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) { + var doc map[string]any + if json.Unmarshal(raw, &doc) != nil || !canvasFields(doc, "schema_version", "nodes", "edges", "viewport") || doc["schema_version"] != float64(1) { + return nil, false + } + nodes, ok := doc["nodes"].([]any) + if !ok || len(nodes) > 500 { + return nil, false + } + edges, ok := doc["edges"].([]any) + if !ok || len(edges) > 2000 { + return nil, false + } + viewport, ok := doc["viewport"].(map[string]any) + if !ok || !canvasFields(viewport, "x", "y", "zoom") || !canvasNumber(viewport["x"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["y"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["zoom"], .1, 4) { + return nil, false + } + kinds := map[string]string{} + for _, v := range nodes { + n, ok := v.(map[string]any) + if !ok { + return nil, false + } + if style, ok := n["style"].(map[string]any); ok { + if _, exists := n["width"]; !exists { + if v, exists := style["width"]; exists { + n["width"] = v + } + } + if _, exists := n["height"]; !exists { + if v, exists := style["height"]; exists { + n["height"] = v + } + } + } + for _, key := range []string{"selected", "dragging", "measured", "style"} { + delete(n, key) + } + if !canvasFields(n, "id", "type", "position", "width", "height", "data") { + return nil, false + } + id, ok := n["id"].(string) + if !ok || !canvasID.MatchString(id) || kinds[id] != "" { + return nil, false + } + p, ok := n["position"].(map[string]any) + if !ok || !canvasFields(p, "x", "y") || !canvasNumber(p["x"], -1e6, 1e6) || !canvasNumber(p["y"], -1e6, 1e6) { + return nil, false + } + _, w := n["width"] + _, h := n["height"] + if w != h || (w && (!canvasNumber(n["width"], 20, 3000) || !canvasNumber(n["height"], 20, 3000))) { + return nil, false + } + d, ok := n["data"].(map[string]any) + if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle") { + return nil, false + } + kind, ok := d["kind"].(string) + if !ok { + return nil, false + } + typ := "material" + if kind == "plain-text" { + typ = "plainText" + } else if kind == "icon" { + typ = "icon" + } else if !canvasChoice(kind, "text", "image", "script") { + return nil, false + } + if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) { + return nil, false + } + kinds[id] = kind + if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) { + return nil, false + } + if f, exists := d["fontSize"]; exists && !canvasNumber(f, 8, 160) { + return nil, false + } + if kind == "icon" { + s, ok := d["icon"].(string) + if !ok || !canvasIcon.MatchString(s) { + return nil, false + } + } + if src, exists := d["src"]; (exists || kind == "image") && !canvasImage(src) { + return nil, false + } + if value, exists := d["textStyle"]; exists { + style, ok := value.(map[string]any) + if !ok || !canvasFields(style, "color", "background", "bold", "italic", "underline", "align", "fontFamily", "lineHeight") { + return nil, false + } + for k, v := range style { + switch k { + case "color", "background": + s, ok := v.(string) + if !ok || !(canvasColor.MatchString(s) || (k == "background" && s == "transparent")) { + return nil, false + } + case "bold", "italic", "underline": + if _, ok := v.(bool); !ok { + return nil, false + } + case "align": + if !canvasChoice(v, "left", "center", "right") { + return nil, false + } + case "fontFamily": + if !canvasChoice(v, "sans", "serif", "mono") { + return nil, false + } + case "lineHeight": + if !canvasNumber(v, 1, 3) { + return nil, false + } + } + } + } + } + seen := map[string]bool{} + for _, v := range edges { + e, ok := v.(map[string]any) + if !ok { + return nil, false + } + delete(e, "selected") + if !canvasFields(e, "id", "source", "target", "sourceHandle", "targetHandle", "type") { + return nil, false + } + id, ok := e["id"].(string) + if !ok || !canvasID.MatchString(id) || seen[id] { + return nil, false + } + seen[id] = true + for _, key := range []string{"source", "target"} { + s, ok := e[key].(string) + if !ok || !canvasChoice(kinds[s], "text", "image", "script") { + return nil, false + } + } + for _, key := range []string{"sourceHandle", "targetHandle"} { + if h, exists := e[key]; exists && h != nil && !canvasChoice(h, "", "top", "right", "bottom", "left") { + return nil, false + } + } + if typ, exists := e["type"]; exists && !canvasChoice(typ, "default", "bezier") { + return nil, false + } + } + out, err := json.Marshal(doc) + return out, err == nil +} + +func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPatch { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, material := r.PathValue("workspace"), r.PathValue("material") + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if kind != "board" { + reject(w, 400, "invalid_material") + return nil + } + var version int64 + var document json.RawMessage + err = tx.QueryRowContext(r.Context(), `SELECT canvas_version,COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material).Scan(&version, &document) + if err != nil { + return err + } + if r.Method == http.MethodGet { + setVersion(w, version) + reply(w, 200, response{"version": version, "document": document}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + expected, ok := expectedVersion(w, r) + if !ok { + return nil + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasLimit)) + if err != nil { + reject(w, 413, "canvas_too_large") + return nil + } + var input struct { + Document json.RawMessage `json:"document"` + } + decoder := json.NewDecoder(strings.NewReader(string(body))) + decoder.DisallowUnknownFields() + if !utf8.Valid(body) || decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF { + reject(w, 400, "invalid_canvas") + return nil + } + document, ok = normalizeCanvas(input.Document) + if !ok { + reject(w, 400, "invalid_canvas") + return nil + } + if expected != version { + reject(w, 412, "version_conflict") + return nil + } + _, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=canvas_version+1 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material, document) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + reply(w, 200, response{"version": version + 1}) + return nil +} diff --git a/internal/platform/canvas_test.go b/internal/platform/canvas_test.go new file mode 100644 index 0000000..7cba1d9 --- /dev/null +++ b/internal/platform/canvas_test.go @@ -0,0 +1,41 @@ +package platform + +import ( + "encoding/json" + "strings" + "testing" +) + +const canvasFixture = `{"schema_version":1,"nodes":[{"id":"a","type":"material","position":{"x":100,"y":200},"width":320,"height":220,"selected":true,"data":{"kind":"text","title":"灵感笔记","text":"正文","rotation":30,"fontSize":24,"textStyle":{"color":"#37352f","background":"transparent","bold":true,"italic":false,"underline":false,"align":"left","fontFamily":"sans","lineHeight":1.5}}},{"id":"b","type":"material","position":{"x":0,"y":0},"data":{"kind":"script","title":"Script","text":""}},{"id":"icon","type":"icon","position":{"x":500,"y":200},"width":80,"height":80,"data":{"kind":"icon","title":"火箭","text":"","icon":"Rocket","rotation":0}}],"edges":[{"id":"e","source":"a","target":"b","sourceHandle":"top","targetHandle":"bottom"}],"viewport":{"x":0,"y":0,"zoom":1}}` + +func TestCanvasValidation(t *testing.T) { + out, ok := normalizeCanvas(json.RawMessage(canvasFixture)) + if !ok || strings.Contains(string(out), "selected") { + t.Fatal("valid snapshot rejected or UI state retained") + } + cases := [][2]string{{`"rotation":30`, `"rotation":360`}, {`"width":320`, `"width":19`}, {`"color":"#37352f"`, `"color":"red"`}, {`"bold":true`, `"bold":"true"`}, {`"target":"b"`, `"target":"missing"`}, {`"target":"b"`, `"target":"icon"`}, {`"zoom":1`, `"zoom":0`}, {`"sourceHandle":"top"`, `"sourceHandle":"unknown"`}, {`"fontSize":24`, `"fontSize":161`}, {`"lineHeight":1.5`, `"lineHeight":4`}, {`"id":"b"`, `"id":"a"`}, {`"schema_version":1`, `"schema_version":2`}} + for _, c := range cases { + t.Run(c[1], func(t *testing.T) { + if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(canvasFixture, c[0], c[1], 1))); ok { + t.Fatal("accepted invalid snapshot") + } + }) + } + for _, bad := range []string{`null`, `{}`, `{"schema_version":1,"nodes":null,"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`} { + if _, ok := normalizeCanvas(json.RawMessage(bad)); ok { + t.Fatal("accepted missing document fields") + } + } +} +func TestSlugValidation(t *testing.T) { + for _, v := range []string{"my-board", "workspace-2026"} { + if !validSlug(&v) { + t.Fatal(v) + } + } + for _, v := range []string{"", "Bad Slug", "../board", strings.Repeat("a", 101)} { + if validSlug(&v) { + t.Fatal(v) + } + } +} diff --git a/internal/platform/content.go b/internal/platform/content.go new file mode 100644 index 0000000..262f330 --- /dev/null +++ b/internal/platform/content.go @@ -0,0 +1,129 @@ +package platform + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + "regexp" +) + +type Paragraph struct { + ID string `json:"id"` + Text string `json:"text"` + Description string `json:"description,omitempty"` +} +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Paragraphs []Paragraph `json:"paragraphs"` +} +type Article struct { + Sections []Section `json:"sections"` +} +type Image struct { + URL string `json:"url"` + Alt string `json:"alt,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type CardPart struct { + Kind string `json:"kind"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` +} +type Content struct { + SchemaVersion int `json:"schema_version"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` + Parts []CardPart `json:"parts,omitempty"` +} + +var blockID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +func validArticle(article *Article) bool { + if article == nil || len(article.Sections) > 200 { + return false + } + seen := map[string]bool{} + total := 0 + for _, section := range article.Sections { + if !blockID.MatchString(section.ID) || seen[section.ID] || len(section.Title) > 300 || len(section.Description) > 10000 { + return false + } + seen[section.ID] = true + total += len(section.Paragraphs) + if total > 2000 { + return false + } + for _, paragraph := range section.Paragraphs { + if !blockID.MatchString(paragraph.ID) || seen[paragraph.ID] || len(paragraph.Text) > 100000 || len(paragraph.Description) > 10000 { + return false + } + seen[paragraph.ID] = true + } + } + return true +} +func validImage(image *Image) bool { + if image == nil || len(image.URL) > 2048 || len(image.Alt) > 2000 || image.Width < 0 || image.Width > 100000 || image.Height < 0 || image.Height > 100000 { + return false + } + u, err := url.Parse(image.URL) + return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == "" +} +func validPart(part CardPart) bool { + switch part.Kind { + case "text": + return part.Text != nil && len(*part.Text) <= 100000 && part.Article == nil && part.Image == nil + case "article": + return part.Text == nil && part.Image == nil && validArticle(part.Article) + case "image": + return part.Text == nil && part.Article == nil && validImage(part.Image) + default: + return false + } +} +func normalizeContent(kind string, raw json.RawMessage) (json.RawMessage, bool) { + if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' { + return nil, false + } + var content Content + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if d.Decode(&content) != nil || d.Decode(new(any)) != io.EOF { + return nil, false + } + if content.SchemaVersion == 0 { + content.SchemaVersion = 1 + } + if content.SchemaVersion != 1 { + return nil, false + } + switch kind { + case "text", "article", "image": + if len(content.Parts) > 0 || !validPart(CardPart{Kind: kind, Text: content.Text, Article: content.Article, Image: content.Image}) { + return nil, false + } + case "board": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) > 0 { + return nil, false + } + case "card": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) < 1 || len(content.Parts) > 20 { + return nil, false + } + for _, part := range content.Parts { + if !validPart(part) { + return nil, false + } + } + default: + return nil, false + } + out, err := json.Marshal(content) + return out, err == nil +} diff --git a/internal/platform/content_test.go b/internal/platform/content_test.go new file mode 100644 index 0000000..1cd761c --- /dev/null +++ b/internal/platform/content_test.go @@ -0,0 +1,39 @@ +package platform + +import ( + "encoding/json" + "testing" +) + +func TestContentValidation(t *testing.T) { + cases := []struct { + name, kind, body string + valid bool + }{ + {"plain text", "text", `{"text":""}`, true}, + {"empty article draft", "article", `{"article":{"sections":[]}}`, true}, + {"paragraph descriptions", "article", `{"article":{"sections":[{"id":"s","title":"Section","description":"Summary","paragraphs":[{"id":"p","text":"Body","description":"Notes"}]}]}}`, true}, + {"duplicate paragraph IDs", "article", `{"article":{"sections":[{"id":"s","paragraphs":[{"id":"p","text":"One"},{"id":"p","text":"Two"}]}]}}`, false}, + {"markdown field", "article", `{"markdown":"# Heading"}`, false}, + {"unknown schema", "text", `{"schema_version":2,"text":"Text"}`, false}, + {"null content", "board", `null`, false}, + {"nested unknown field", "image", `{"image":{"url":"https://example.com/image.png","secret":"unexpected"}}`, false}, + {"insecure image", "image", `{"image":{"url":"http://example.com/image.png"}}`, false}, + {"URL credentials", "image", `{"image":{"url":"https://user:password@example.com/image.png"}}`, false}, + {"card and text", "card", `{"parts":[{"kind":"text","text":"Caption"}]}`, true}, + {"empty card", "card", `{"parts":[]}`, false}, + {"mismatched part", "card", `{"parts":[{"kind":"text","image":{"url":"https://example.com/image.png"}}]}`, false}, + {"board payload", "board", `{"text":"Wrong type"}`, false}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out, ok := normalizeContent(test.kind, json.RawMessage(test.body)) + if ok != test.valid { + t.Fatalf("valid=%v, want %v", ok, test.valid) + } + if ok && !json.Valid(out) { + t.Fatal("normalized content is invalid JSON") + } + }) + } +} diff --git a/internal/platform/grants.go b/internal/platform/grants.go new file mode 100644 index 0000000..6dde717 --- /dev/null +++ b/internal/platform/grants.go @@ -0,0 +1,96 @@ +package platform + +import "net/http" + +func (s *service) materialGrants(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, material := r.PathValue("workspace"), r.PathValue("material") + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT account_id::text,role FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 ORDER BY account_id LIMIT 1000", project, workspace, material) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + if err := rows.Scan(&id, &role); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input struct { + AccountID string `json:"account_id"` + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=EXCLUDED.role +`, project, workspace, material, input.AccountID, input.Role) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) deleteMaterialGrant(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 AND account_id=$4", project, workspace, material, r.PathValue("account")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} diff --git a/internal/platform/http.go b/internal/platform/http.go new file mode 100644 index 0000000..87e9678 --- /dev/null +++ b/internal/platform/http.go @@ -0,0 +1,183 @@ +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) + w.Header().Set("Access-Control-Expose-Headers", "ETag") + } + 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 && 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, 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}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials)) + 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}/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 +} diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go new file mode 100644 index 0000000..5292294 --- /dev/null +++ b/internal/platform/integration_test.go @@ -0,0 +1,293 @@ +package platform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func TestOnboardingAndSharing(t *testing.T) { + dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("requires an isolated integration database") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + var raw [12]byte + rand.Read(raw[:]) + project := "test-" + hex.EncodeToString(raw[:]) + accounts := []string{} + tokens := []string{} + for range 3 { + var id string + err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id) + if err != nil { + t.Fatal(err) + } + accounts = append(accounts, id) + var value [32]byte + rand.Read(value[:]) + token := base64.RawURLEncoding.EncodeToString(value[:]) + hash := sha256.Sum256([]byte(token)) + _, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id) + if err != nil { + t.Fatal(err) + } + tokens = append(tokens, token) + } + defer func() { + _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) + _, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project) + }() + callbacks := map[string]string{project: "https://app.example.com/auth/callback"} + mux := http.NewServeMux() + authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil) + if err != nil { + t.Fatal(err) + } + mux.Handle("/", authHandler) + Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ + project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, + }) + call := func(method, path, token string, body any, status int, versions ...string) response { + t.Helper() + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(method, path, bytes.NewReader(encoded)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + if len(versions) > 0 { + r.Header.Set("If-Match", versions[0]) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status { + t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) + } + out := response{} + if w.Body.Len() > 0 && w.Code != 302 { + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return out + } + base := "/projects/" + project + call("GET", base+"/workspaces", "", nil, 401) + call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400) + first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201) + workspace := first["result"].(map[string]any)["workspace_id"].(string) + retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200) + if retry["result"].(map[string]any)["workspace_id"] != workspace { + t.Fatal("onboarding created a second workspace") + } + workspacePath := base + "/workspaces/" + workspace + share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) + token := share["token"].(string) + call("GET", "/s/"+token, "", nil, 302) + clicks := call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[0], nil, 200) + if len(clicks["items"].([]any)) != 1 { + t.Fatal("click event API did not return the redirect") + } + call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[2], nil, 404) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) + call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) + call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403) + call("GET", workspacePath, tokens[2], nil, 404) + call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409) + call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204) + call("GET", "/s/"+token, "", nil, 404) + var material string + err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material) + if err != nil { + t.Fatal(err) + } + contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) + call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) + call("GET", workspacePath, tokens[2], nil, 404) + articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{ + "id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}}, + }}}} + created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201) + articleID := created["id"].(string) + text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201) + textPath := workspacePath + "/materials/" + text["id"].(string) + call("GET", textPath, tokens[2], nil, 404) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + call("GET", textPath, tokens[2], nil, 200) + changed := response{"title": "Beta", "content": response{"text": "Changed"}} + call("PATCH", textPath, tokens[2], changed, 403, `"1"`) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) + call("PATCH", textPath, tokens[2], changed, 428) + call("PATCH", textPath, tokens[2], changed, 204, `"1"`) + call("PATCH", textPath, tokens[0], changed, 412, `"1"`) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400) + tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201) + call("GET", workspacePath+"/tags", tokens[2], nil, 404) + call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`) + call("POST", textPath+"/open", tokens[2], nil, 204) + filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200) + if len(filtered["items"].([]any)) != 1 { + t.Fatal("tag-filtered material list leaked inaccessible materials") + } + board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201) + boardPath := workspacePath + "/materials/" + board["id"].(string) + + canvasPath := boardPath + "/canvas" + empty := call("GET", canvasPath, tokens[0], nil, 200) + if empty["version"] != float64(1) { + t.Fatal("old board not initialized at version 1") + } + snapshot := response{"document": json.RawMessage(canvasFixture)} + call("PATCH", canvasPath, "", snapshot, 401, `"1"`) + call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`) + call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`) + call("PATCH", canvasPath, tokens[0], snapshot, 428) + call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`) + call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`) + saved := call("GET", canvasPath, tokens[0], nil, 200) + expected, _ := normalizeCanvas(json.RawMessage(canvasFixture)) + expectedJSON, _ := json.Marshal(json.RawMessage(expected)) + actualJSON, _ := json.Marshal(saved["document"]) + if !bytes.Equal(expectedJSON, actualJSON) { + t.Fatal("snapshot failed round trip") + } + call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`) + call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`) + if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) { + t.Fatal("rename changed canvas version") + } + slugPath := workspacePath + "/materials/my-canvas" + if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" { + t.Fatal("slug lookup failed") + } + // A PNG at the local upload limit must survive JSON and database storage. + var imageDoc map[string]any + json.Unmarshal([]byte(canvasFixture), &imageDoc) + imageBytes := make([]byte, 2<<20) + copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10}) + data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any) + data["kind"] = "image" + data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes) + call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`) + restored := call("GET", canvasPath, tokens[0], nil, 200) + if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] { + t.Fatal("large image changed") + } + call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`) + if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) { + t.Fatal("rejected snapshot changed version") + } + // Restore the material version expected by the pre-existing layout checks below. + + // Exercise two independent HTTP transactions against the same expected version. + encodedSnapshot, _ := json.Marshal(snapshot) + statuses := make(chan int, 2) + start := make(chan struct{}) + for range 2 { + go func() { + <-start + request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot)) + request.Header.Set("Authorization", "Bearer "+tokens[0]) + request.Header.Set("If-Match", `"3"`) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + statuses <- recorder.Code + }() + } + close(start) + one, two := <-statuses, <-statuses + if !((one == 200 && two == 412) || (one == 412 && two == 200)) { + t.Fatalf("concurrent writes: %d %d", one, two) + } + if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" { + t.Fatal("canvas save changed title") + } + call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204) + call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200) + call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403) + call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409) + call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204) + if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" { + t.Fatal("rename changed slug") + } + layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`) + call("GET", boardPath+"/layouts", tokens[1], nil, 200) + call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) + if len(hidden["items"].([]any)) != 0 { + t.Fatal("board share disclosed private child material") + } + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`) + call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`) + call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204) + call("GET", textPath, tokens[2], nil, 404) + call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) + call("PATCH", textPath, tokens[0], changed, 412, `"3"`) + + imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}} + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{ + response{"kind": "text", "text": "Caption"}, + response{"kind": "article", "article": articleContent["article"]}, + response{"kind": "image", "image": imageContent["image"]}, + }}}, 201) + for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} { + call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200) + } + images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200) + if len(images["items"].([]any)) != 1 { + t.Fatal("kind/title filter returned incorrect materials") + } + + email := "fixture@example.invalid" + password := "a-long-fixture-password" + call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) + call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401) + var body string + if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("missing email verification link") + } + verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201) + call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400) + call("GET", base+"/profile", verified["access_token"].(string), nil, 200) + call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401) + loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200) + call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204) + call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401) + + var count int + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { + t.Fatalf("material grant missing %v", err) + } + if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 { + t.Fatalf("click accounting incorrect %v", err) + } +} diff --git a/internal/platform/layouts.go b/internal/platform/layouts.go new file mode 100644 index 0000000..4674f1b --- /dev/null +++ b/internal/platform/layouts.go @@ -0,0 +1,158 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "math" + "net/http" +) + +type placement struct { + MaterialID string `json:"material_id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + ZIndex int `json:"z_index"` +} + +func (p placement) valid() bool { + return !math.IsNaN(p.X) && !math.IsNaN(p.Y) && !math.IsNaN(p.Width) && !math.IsNaN(p.Height) && + p.X >= -1000000 && p.X <= 1000000 && p.Y >= -1000000 && p.Y <= 1000000 && + p.Width >= 1 && p.Width <= 100000 && p.Height >= 1 && p.Height <= 100000 && + p.ZIndex >= -1000000 && p.ZIndex <= 1000000 +} +func (s *service) layouts(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, board, layout := r.PathValue("workspace"), r.PathValue("material"), r.PathValue("layout") + if (layout == "" && (r.Method == http.MethodPatch || r.Method == http.MethodDelete)) || (layout != "" && (r.Method == http.MethodGet || r.Method == http.MethodPost)) { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) + if err != nil { + return err + } + if kind != "board" { + reject(w, 400, "not_a_board") + return nil + } + var version int64 + if err = tx.QueryRowContext(r.Context(), "SELECT version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board).Scan(&version); err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',l.id,'material_id',l.material_id,'x',l.x,'y',l.y,'width',l.width,'height',l.height,'z_index',l.z_index,'version',l.version) +FROM board_layouts l WHERE l.project=$1 AND l.workspace_id=$2 AND l.board_id=$3 +AND (EXISTS(SELECT 1 FROM workspace_members m WHERE m.project=l.project AND m.workspace_id=l.workspace_id AND m.account_id=$4) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(l.project,l.workspace_id,l.material_id) AND g.account_id=$4)) +ORDER BY l.z_index,l.id LIMIT 2000 +`, project, workspace, board, account) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var item json.RawMessage + if err := rows.Scan(&item); err != nil { + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return err + } + setVersion(w, version) + reply(w, 200, response{"items": items, "board_version": version}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + expected, ok := expectedVersion(w, r) + if !ok { + return nil + } + if expected != version { + reject(w, 412, "version_conflict") + return nil + } + var input placement + if r.Method != http.MethodDelete { + if !decode(w, r, &input) { + return nil + } + if !input.valid() || input.MaterialID == board { + reject(w, 400, "invalid_layout") + return nil + } + refKind, _, err := lockMaterial(r.Context(), tx, project, workspace, input.MaterialID, account) + if err != nil { + return err + } + if refKind == "board" { + reject(w, 400, "nested_board_not_supported") + return nil + } + } + var result sql.Result + switch r.Method { + case http.MethodPost: + var count int + if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3", project, workspace, board).Scan(&count); err != nil { + return err + } + if count >= 2000 { + reject(w, 409, "board_full") + return nil + } + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height,z_index) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text +`, project, workspace, board, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex).Scan(&layout) + case http.MethodPatch: + result, err = tx.ExecContext(r.Context(), ` +UPDATE board_layouts SET material_id=$5,x=$6,y=$7,width=$8,height=$9,z_index=$10,version=version+1 +WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 +`, project, workspace, board, layout, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex) + case http.MethodDelete: + result, err = tx.ExecContext(r.Context(), "DELETE FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4", project, workspace, board, layout) + } + if err != nil { + return err + } + if result != nil { + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + } + _, err = tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + if r.Method == http.MethodPost { + reply(w, 201, response{"id": layout, "board_version": version + 1}) + } else { + reply(w, 204, nil) + } + return nil +} diff --git a/internal/platform/materials.go b/internal/platform/materials.go new file mode 100644 index 0000000..bd41eda --- /dev/null +++ b/internal/platform/materials.go @@ -0,0 +1,299 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "strconv" +) + +func lockMaterial(ctx context.Context, tx *sql.Tx, project, workspace, material, account string) (string, string, error) { + var locked, kind, role string + err := tx.QueryRowContext(ctx, "SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE", project, workspace).Scan(&locked) + if err != nil { + return "", "", err + } + err = tx.QueryRowContext(ctx, ` +SELECT m.kind,CASE + WHEN member.role='admin' THEN 'admin' + WHEN member.role='editor' OR access.role='editor' THEN 'editor' + ELSE 'viewer' END +FROM materials m +LEFT JOIN workspace_members member ON (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$4 +LEFT JOIN material_grants access ON (access.project,access.workspace_id,access.material_id)=(m.project,m.workspace_id,m.id) AND access.account_id=$4 +WHERE m.project=$1 AND m.workspace_id=$2 AND m.id=$3 AND (member.role IS NOT NULL OR access.role IS NOT NULL) +FOR UPDATE OF m +`, project, workspace, material, account).Scan(&kind, &role) + return kind, role, err +} + +func expectedVersion(w http.ResponseWriter, r *http.Request) (int64, bool) { + header := r.Header.Get("If-Match") + if header == "" { + reject(w, 428, "version_required") + return 0, false + } + if len(header) < 3 || header[0] != '"' || header[len(header)-1] != '"' { + reject(w, 400, "invalid_version") + return 0, false + } + version, err := strconv.ParseInt(header[1:len(header)-1], 10, 64) + if err != nil || version < 1 { + reject(w, 400, "invalid_version") + return 0, false + } + return version, true +} +func setVersion(w http.ResponseWriter, version int64) { + w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(version, 10))) +} + +func (s *service) materials(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodGet { + return s.listMaterials(w, r, project, account) + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Kind string `json:"kind"` + Slug *string `json:"slug,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(input.Kind, input.Content) + if !ok || !validSlug(input.Slug) || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace := r.PathValue("workspace") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var id, slug string + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by,slug) +VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id::text,slug +`, project, workspace, input.Kind, input.Title, input.Description, content, account, input.Slug).Scan(&id, &slug) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, 1) + reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1, "slug": slug}) + return nil +} + +func (s *service) material(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + var result json.RawMessage + var version int64 + err = tx.QueryRowContext(r.Context(), ` +SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'slug',slug,'title',title,'description',description, +'content',content,'version',version,'created_at',created_at,'modified_at',modified_at,'created_by',created_by),version +FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, workspace, material).Scan(&result, &version) + if err != nil { + return err + } + setVersion(w, version) + reply(w, 200, result) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Slug *string `json:"slug,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(kind, input.Content) + if !ok || !validSlug(input.Slug) || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + result, err = tx.ExecContext(r.Context(), ` +UPDATE materials SET slug=COALESCE($8,slug),title=$4,description=$5,content=$6,version=version+1,modified_at=now() +WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$7 +`, project, workspace, material, input.Title, input.Description, content, version, input.Slug) + } else { + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + result, err = tx.ExecContext(r.Context(), "DELETE FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + if err = tx.Commit(); err != nil { + return err + } + if r.Method == http.MethodPatch { + setVersion(w, version+1) + } + reply(w, 204, nil) + return nil +} + +func (s *service) openMaterial(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, _, err = lockMaterial(r.Context(), tx, project, workspace, material, account); err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_opens(project,workspace_id,material_id,account_id) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET opened_at=now() +`, project, workspace, material, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) listMaterials(w http.ResponseWriter, r *http.Request, project, account string) error { + sort := r.URL.Query().Get("sort") + orders := map[string]string{ + "": "m.modified_at DESC,m.id", + "modified": "m.modified_at DESC,m.id", + "created": "m.created_at DESC,m.id", + "alphabetical": "lower(m.title),m.id", + "opened": "opened.opened_at DESC NULLS LAST,m.id", + "tag": "COALESCE((SELECT min(lower(t.name)) FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),''),lower(m.title),m.id", + } + order, ok := orders[sort] + if !ok { + reject(w, 400, "invalid_sort") + return nil + } + limit, offset := 50, 0 + var err error + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + } + if err != nil || limit < 1 || limit > 100 { + reject(w, 400, "invalid_pagination") + return nil + } + if raw := r.URL.Query().Get("offset"); raw != "" { + offset, err = strconv.Atoi(raw) + } + if err != nil || offset < 0 || offset > 1000000 { + reject(w, 400, "invalid_pagination") + return nil + } + query := r.URL.Query().Get("q") + kind := r.URL.Query().Get("kind") + if len(query) > 256 { + reject(w, 400, "invalid_filter") + return nil + } + switch kind { + case "", "text", "article", "image", "board", "card": + default: + reject(w, 400, "invalid_filter") + return nil + } + tag := r.URL.Query().Get("tag") + rows, err := s.db.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'slug',m.slug,'title',m.title, +'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at, +'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id) +FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) +WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),'[]'::jsonb)) +FROM materials m LEFT JOIN material_opens opened ON (opened.project,opened.workspace_id,opened.material_id)=(m.project,m.workspace_id,m.id) AND opened.account_id=$3 +WHERE m.project=$1 AND m.workspace_id=$2 +AND (EXISTS(SELECT 1 FROM workspace_members member WHERE (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$3) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(m.project,m.workspace_id,m.id) AND g.account_id=$3)) +AND ($4='' OR m.title ILIKE '%'||$4||'%') AND ($5='' OR m.kind=$5) +AND ($6='' OR EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id::text=$6)) +ORDER BY `+order+` LIMIT $7 OFFSET $8`, project, r.PathValue("workspace"), account, query, kind, tag, limit+1, offset) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var value json.RawMessage + if err := rows.Scan(&value); err != nil { + return err + } + items = append(items, value) + } + if err := rows.Err(); err != nil { + return err + } + var next *int + if len(items) > limit { + items = items[:limit] + value := offset + limit + next = &value + } + reply(w, 200, response{"items": items, "next_offset": next}) + return nil +} diff --git a/internal/platform/onboarding.go b/internal/platform/onboarding.go new file mode 100644 index 0000000..c770c46 --- /dev/null +++ b/internal/platform/onboarding.go @@ -0,0 +1,192 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +type Question struct { + Key string `json:"key"` + Required bool `json:"required"` +} +type OnboardingConfig struct { + RequireWorkspace bool `json:"require_workspace"` + Questions []Question `json:"questions"` +} + +var surveyTypes = map[string]string{ + "team_activity": "string", "job_role": "string", "referral": "string", + "previous_use": "boolean", "purpose": "string", "company_size": "string", +} + +func ParseOnboarding(raw string, callbacks map[string]string) (map[string]OnboardingConfig, error) { + configs := map[string]OnboardingConfig{} + if strings.TrimSpace(raw) == "" { + return configs, nil + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&configs); err != nil { + return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") + } + if decoder.Decode(new(any)) != io.EOF { + return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON") + } + for project, config := range configs { + if _, ok := callbacks[project]; !ok { + return nil, errors.New("onboarding project is not configured") + } + seen := map[string]bool{} + for _, question := range config.Questions { + if surveyTypes[question.Key] == "" || seen[question.Key] { + return nil, errors.New("invalid or duplicate onboarding question") + } + seen[question.Key] = true + } + } + return configs, nil +} + +func validAnswers(config OnboardingConfig, answers map[string]json.RawMessage) bool { + questions := map[string]Question{} + for _, question := range config.Questions { + questions[question.Key] = question + } + for key, value := range answers { + if _, ok := questions[key]; !ok { + return false + } + if surveyTypes[key] == "boolean" { + var answer *bool + if json.Unmarshal(value, &answer) != nil || answer == nil { + return false + } + } else { + var answer *string + if json.Unmarshal(value, &answer) != nil || answer == nil || len(*answer) > 2000 { + return false + } + } + } + for _, question := range config.Questions { + value, ok := answers[question.Key] + if question.Required { + if !ok { + return false + } + if surveyTypes[question.Key] == "string" { + var answer string + if json.Unmarshal(value, &answer) != nil || strings.TrimSpace(answer) == "" { + return false + } + } + } + } + return true +} + +func (s *service) onboarding(w http.ResponseWriter, r *http.Request, project, account string) error { + config := s.onboardingConfigs[project] + if config.Questions == nil { + config.Questions = []Question{} + } + if r.Method == http.MethodGet { + var answers, result json.RawMessage + err := s.db.QueryRowContext(r.Context(), "SELECT answers,result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&answers, &result) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + reply(w, 200, response{"config": config, "completed": err == nil, "answers": answers, "result": result}) + return nil + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Username *string `json:"username"` + TeamName string `json:"team_name"` + GenerateInvite bool `json:"generate_invite"` + Answers map[string]json.RawMessage `json:"answers"` + } + if !decode(w, r, &input) { + return nil + } + input.TeamName = strings.TrimSpace(input.TeamName) + if input.Answers == nil { + input.Answers = map[string]json.RawMessage{} + } + if input.Username != nil { + value := strings.ToLower(strings.TrimSpace(*input.Username)) + input.Username = &value + if !usernamePattern.MatchString(value) { + reject(w, 400, "invalid_username") + return nil + } + } + if !validAnswers(config, input.Answers) || len(input.TeamName) > 160 || (config.RequireWorkspace && input.TeamName == "") || (input.GenerateInvite && input.TeamName == "") { + reject(w, 400, "invalid_onboarding") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + var locked string + if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM project_accounts WHERE project=$1 AND id=$2 FOR UPDATE", project, account).Scan(&locked); err != nil { + return err + } + var prior json.RawMessage + err = tx.QueryRowContext(r.Context(), "SELECT result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&prior) + if err == nil { + reply(w, 200, response{"completed": true, "result": prior, "already_completed": true}) + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if input.Username != nil { + _, err = tx.ExecContext(r.Context(), "UPDATE project_accounts SET username=$3,updated_at=now() WHERE project=$1 AND id=$2", project, account, *input.Username) + if err != nil { + return err + } + } + result := response{} + var invitation string + if input.TeamName != "" { + workspace, err := createWorkspace(r.Context(), tx, project, account, input.TeamName) + if err != nil { + return err + } + result["workspace_id"] = workspace + if input.GenerateInvite { + token, hash := newLinkToken() + var id string + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,token_hash,role,created_by,expires_at) +VALUES($1,$2,$3,'viewer',$4,now()+interval '7 days') RETURNING id::text +`, project, workspace, hash, account).Scan(&id) + if err != nil { + return err + } + result["invite_id"] = id + invitation = s.origin + "/s/" + token + } + } + answers, _ := json.Marshal(input.Answers) + resultJSON, _ := json.Marshal(result) + _, err = tx.ExecContext(r.Context(), "INSERT INTO onboarding_responses(project,account_id,answers,result) VALUES($1,$2,$3,$4)", project, account, answers, resultJSON) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"completed": true, "result": result, "invite_url": invitation, "already_completed": false}) + return nil +} diff --git a/internal/platform/shares.go b/internal/platform/shares.go new file mode 100644 index 0000000..6d5159a --- /dev/null +++ b/internal/platform/shares.go @@ -0,0 +1,356 @@ +package platform + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "net/http" + "net/url" + "regexp" + "strconv" + "time" +) + +var linkTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) + +func newLinkToken() (string, string) { + var raw [16]byte + rand.Read(raw[:]) + value := base64.RawURLEncoding.EncodeToString(raw[:]) + return value, linkDigest(value) +} +func linkDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +type shareInput struct { + Role string `json:"role"` + MaterialID *string `json:"material_id"` + ExpiresIn int `json:"expires_in"` + MaxUses *int `json:"max_uses"` +} + +func (s *service) shares(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + if r.Method != http.MethodPost && r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT l.id::text,l.material_id::text,l.role,l.expires_at,l.revoked_at,l.max_uses,l.use_count, + (SELECT count(*) FROM link_clicks c WHERE (c.project,c.workspace_id,c.link_id)=(l.project,l.workspace_id,l.id)) +FROM share_links l WHERE project=$1 AND workspace_id=$2 ORDER BY created_at DESC,id LIMIT 200 +`, project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + var material *string + var expires time.Time + var revoked *time.Time + var max *int + var uses, clicks int64 + if err := rows.Scan(&id, &material, &role, &expires, &revoked, &max, &uses, &clicks); err != nil { + return err + } + items = append(items, response{"id": id, "material_id": material, "role": role, "expires_at": expires, "revoked_at": revoked, "max_uses": max, "use_count": uses, "click_count": clicks}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input shareInput + if !decode(w, r, &input) { + return nil + } + if input.ExpiresIn == 0 { + input.ExpiresIn = 7 * 86400 + } + if (input.Role != "admin" && input.Role != "editor" && input.Role != "viewer") || + (input.MaterialID != nil && input.Role == "admin") || input.ExpiresIn < 60 || input.ExpiresIn > 30*86400 || + (input.MaxUses != nil && (*input.MaxUses < 1 || *input.MaxUses > 100000)) { + reject(w, 400, "invalid_share") + return nil + } + token, hash := newLinkToken() + var id string + var expires time.Time + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO share_links(project,workspace_id,material_id,token_hash,role,created_by,expires_at,max_uses) +VALUES($1,$2,$3,$4,$5,$6,now()+$7*interval '1 second',$8) RETURNING id::text,expires_at +`, project, workspace, input.MaterialID, hash, input.Role, account, input.ExpiresIn, input.MaxUses).Scan(&id, &expires) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "url": s.origin + "/s/" + token, "token": token, "role": input.Role, "expires_at": expires}) + return nil +} + +func (s *service) revokeShare(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + result, err := tx.ExecContext(r.Context(), ` +UPDATE share_links SET revoked_at=COALESCE(revoked_at,now()) WHERE project=$1 AND workspace_id=$2 AND id=$3 +`, project, r.PathValue("workspace"), r.PathValue("link")) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +// A public short link records only a referring origin and redirects to a fixed +// frontend. The fragment carries the invitation, never a login session. +func (s *service) shortLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return + } + token := r.PathValue("token") + if !linkTokenPattern.MatchString(token) { + reject(w, 404, "not_found") + return + } + ref := "" + if u, err := url.Parse(r.Referer()); err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil { + ref = u.Scheme + "://" + u.Host + if len(ref) > 300 { + ref = "" + } + } + var project string + err := s.db.QueryRowContext(r.Context(), ` +WITH valid AS( + SELECT project,workspace_id,id FROM share_links + WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now() AND (max_uses IS NULL OR use_countnow(), (max_uses IS NULL OR use_count 0 { + if material == nil { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,account_id) DO UPDATE SET role=CASE + WHEN workspace_members.role='admin' OR EXCLUDED.role='admin' THEN 'admin' + WHEN workspace_members.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, account, role) + } else { + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE + WHEN material_grants.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END +`, project, workspace, *material, account, role) + } + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "UPDATE share_links SET use_count=use_count+1 WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, id) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0}) + return nil +} + +func (s *service) shareClicks(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + before := r.URL.Query().Get("before") + var cursor int64 + if before != "" { + value, err := strconv.ParseInt(before, 10, 64) + if err != nil || value < 1 { + reject(w, 400, "invalid_cursor") + return nil + } + cursor = value + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, link := r.PathValue("workspace"), r.PathValue("link") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var found string + if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM share_links WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, link).Scan(&found); err != nil { + return err + } + rows, err := tx.QueryContext(r.Context(), ` +SELECT id,clicked_at,referrer_origin FROM link_clicks WHERE project=$1 AND workspace_id=$2 AND link_id=$3 AND ($4::bigint=0 OR id<$4) +ORDER BY id DESC LIMIT 101 +`, project, workspace, link, cursor) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + var last int64 + for rows.Next() { + var id int64 + var at time.Time + var ref string + if err := rows.Scan(&id, &at, &ref); err != nil { + return err + } + items = append(items, response{"id": strconv.FormatInt(id, 10), "clicked_at": at, "referrer_origin": ref}) + if len(items) <= 100 { + last = id + } + } + if err := rows.Err(); err != nil { + return err + } + var next *string + if len(items) > 100 { + items = items[:100] + value := strconv.FormatInt(last, 10) + next = &value + } + reply(w, 200, response{"items": items, "next_before": next}) + return nil +} diff --git a/internal/platform/slug.go b/internal/platform/slug.go new file mode 100644 index 0000000..eec8d55 --- /dev/null +++ b/internal/platform/slug.go @@ -0,0 +1,12 @@ +package platform + +import "regexp" + +var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +var uuidSlugPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// Slugs remain stable across renames unless explicitly changed by an authorized edit. +func validSlug(slug *string) bool { + return slug == nil || (len(*slug) <= 100 && slugPattern.MatchString(*slug) && !uuidSlugPattern.MatchString(*slug)) +} diff --git a/internal/platform/tags.go b/internal/platform/tags.go new file mode 100644 index 0000000..32c95ea --- /dev/null +++ b/internal/platform/tags.go @@ -0,0 +1,169 @@ +package platform + +import ( + "net/http" + "regexp" + "strings" +) + +var colorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +func (s *service) tags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT id::text,name,color FROM tags WHERE project=$1 AND workspace_id=$2 ORDER BY lower(name),id LIMIT 1000", project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, color string + if err := rows.Scan(&id, &name, &color); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "color": color}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + Color string `json:"color"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 80 || (input.Color != "" && !colorPattern.MatchString(input.Color)) { + reject(w, 400, "invalid_tag") + return nil + } + var id string + err = tx.QueryRowContext(r.Context(), "INSERT INTO tags(project,workspace_id,name,color) VALUES($1,$2,$3,$4) RETURNING id::text", project, workspace, input.Name, input.Color).Scan(&id) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "color": input.Color}) + return nil +} +func (s *service) deleteTag(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +UPDATE materials m SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 + AND EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id=$3) +`, project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM tags WHERE project=$1 AND workspace_id=$2 AND id=$3", project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) materialTags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Tags []string `json:"tags"` + } + if !decode(w, r, &input) { + return nil + } + if len(input.Tags) > 100 { + reject(w, 400, "too_many_tags") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace, material := r.PathValue("workspace"), r.PathValue("material") + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + result, err := tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_tags WHERE project=$1 AND workspace_id=$2 AND material_id=$3", project, workspace, material) + if err != nil { + return err + } + for _, tag := range input.Tags { + _, err = tx.ExecContext(r.Context(), "INSERT INTO material_tags(project,workspace_id,material_id,tag_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", project, workspace, material, tag) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + reply(w, 204, nil) + return nil +} diff --git a/internal/platform/workspaces.go b/internal/platform/workspaces.go new file mode 100644 index 0000000..2062efe --- /dev/null +++ b/internal/platform/workspaces.go @@ -0,0 +1,251 @@ +package platform + +import ( + "context" + "database/sql" + "net/http" + "strings" +) + +func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, account string) error { + switch r.Method { + case http.MethodGet: + rows, err := s.db.QueryContext(r.Context(), ` +SELECT w.id::text,w.name,m.role,w.owner_id::text,w.slug FROM workspaces w +JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id) +WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200 +`, project, account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, role, owner, slug string + if err := rows.Scan(&id, &name, &role, &owner, &slug); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner, "slug": slug}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + case http.MethodPost: + var input struct { + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 || !validSlug(input.Slug) { + reject(w, 400, "invalid_workspace") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + id, err := createWorkspace(r.Context(), tx, project, account, input.Name) + if err != nil { + return err + } + var slug string + if input.Slug != nil { + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET slug=$3 WHERE project=$1 AND id=$2", project, id, *input.Slug) + if err != nil { + return err + } + } + if err = tx.QueryRowContext(r.Context(), "SELECT slug FROM workspaces WHERE project=$1 AND id=$2", project, id).Scan(&slug); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account, "slug": slug}) + default: + reject(w, 405, "method_not_allowed") + } + return nil +} + +func createWorkspace(ctx context.Context, tx *sql.Tx, project, account, name string) (string, error) { + var id string + err := tx.QueryRowContext(ctx, ` +INSERT INTO workspaces(project,name,owner_id) VALUES($1,$2,$3) RETURNING id::text +`, project, name, account).Scan(&id) + if err != nil { + return "", err + } + _, err = tx.ExecContext(ctx, ` +INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin') +`, project, id, account) + return id, err +} + +func lockWorkspace(ctx context.Context, tx *sql.Tx, project, workspace, account string) (string, string, error) { + var owner, role string + err := tx.QueryRowContext(ctx, ` +SELECT owner_id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE +`, project, workspace).Scan(&owner) + if err != nil { + return "", "", err + } + // Read membership after the workspace lock, so concurrent revocation is visible. + err = tx.QueryRowContext(ctx, ` +SELECT role FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 +`, project, workspace, account).Scan(&role) + return owner, role, err +} + +func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + switch r.Method { + case http.MethodGet: + var name, slug string + if err := tx.QueryRowContext(r.Context(), "SELECT name,slug FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name, &slug); err != nil { + return err + } + reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner, "slug": slug}) + return nil + case http.MethodPatch: + if role != "admin" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + Slug *string `json:"slug,omitempty"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 160 || !validSlug(input.Slug) { + reject(w, 400, "invalid_workspace") + return nil + } + _, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,slug=COALESCE($4,slug),updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name, input.Slug) + case http.MethodDelete: + if owner != account { + reject(w, 403, "owner_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM workspaces WHERE project=$1 AND id=$2", project, workspace) + default: + reject(w, 405, "method_not_allowed") + return nil + } + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) members(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet { + reject(w, 405, "method_not_allowed") + return nil + } + rows, err := s.db.QueryContext(r.Context(), ` +SELECT m.account_id::text,m.role,a.username,a.display_name FROM workspace_members m +JOIN project_accounts a ON (a.project,a.id)=(m.project,m.account_id) +WHERE m.project=$1 AND m.workspace_id=$2 AND EXISTS( + SELECT 1 FROM workspace_members caller WHERE caller.project=$1 AND caller.workspace_id=$2 AND caller.account_id=$3) +ORDER BY m.joined_at,m.account_id LIMIT 200 +`, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role, name string + var username *string + if err := rows.Scan(&id, &role, &username, &name); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role, "username": username, "display_name": name}) + } + if err := rows.Err(); err != nil { + return err + } + if len(items) == 0 { + return sql.ErrNoRows + } + reply(w, 200, response{"items": items}) + return nil +} + +func (s *service) member(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, target := r.PathValue("workspace"), r.PathValue("account") + if r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + owner, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if owner == target { + reject(w, 409, "owner_membership_required") + return nil + } + if role != "admin" && (r.Method != http.MethodDelete || target != account) { + reject(w, 403, "forbidden") + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "admin" && input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + result, err = tx.ExecContext(r.Context(), "UPDATE workspace_members SET role=$4 WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target, input.Role) + } else { + result, err = tx.ExecContext(r.Context(), "DELETE FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3", project, workspace, target) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +}