Compare commits

...

25 Commits

Author SHA1 Message Date
codex-bot 34c461a61c feat(auth): renew sessions with 30-day idle and 90-day absolute limits
Foundry Go checks / check (push) Successful in 2m53s
Foundry Go checks / check (pull_request) Successful in 3m9s
2026-11-06 12:32:04 +08:00
Zayden-Jung 022086beb8 Merge pull request '支持 Canvas 框选区、成员归属与名称显示模式' (#9) from codex/canvas-frames into main
Foundry Go checks / check (push) Successful in 3m28s
Deploy Foundry Core / deploy (push) Successful in 3m4s
Reviewed-on: #9
2026-11-06 03:21:48 +00:00
codex-bot 6f504ebe15 Support canvas frame nodes and explicit membership snapshots
Foundry Go checks / check (push) Successful in 3m13s
Foundry Go checks / check (pull_request) Successful in 4m24s
2026-11-06 11:20:05 +08:00
Zayden-Jung 57aab71d45 Merge pull request 'Canvas 快照持久化与 workspace/board SEO slug' (#7) from codex/canvas-persistence into main
Foundry Go checks / check (push) Successful in 2m38s
Deploy Foundry Core / deploy (push) Successful in 3m45s
Foundry Go checks / check (pull_request) Successful in 3m20s
Reviewed-on: #7
2026-11-05 18:57:43 +00:00
codex-bot 6f36ef27bb Add versioned board canvas persistence and stable SEO slugs
Foundry Go checks / check (push) Successful in 7m30s
Foundry Go checks / check (pull_request) Successful in 8m43s
2026-11-06 02:47:27 +08:00
Zayden-Jung e8ed5e4f9f Merge pull request '修复 Google/Slack OAuth 回调的系统 CA 证书缺失' (#6) from codex/fix-oauth-ca into main
Foundry Go checks / check (push) Successful in 2m47s
Deploy Foundry Core / deploy (push) Successful in 2m30s
Reviewed-on: #6
2026-11-05 15:16:11 +00:00
codex-bot b1e0d783e6 Mount system CA roots for OAuth HTTPS requests
Foundry Go checks / check (push) Successful in 3m22s
Foundry Go checks / check (pull_request) Successful in 3m16s
2026-11-05 21:39:11 +08:00
Zayden-Jung 4043c89520 Merge pull request 'Foundry 用户认证、workspace 分享与结构化物料 API' (#5) from codex/foundry-platform into main
Foundry Go checks / check (push) Successful in 2m3s
Deploy Foundry Core / deploy (push) Successful in 2m15s
Reviewed-on: #5
2026-11-05 12:30:25 +00:00
codex-bot 155ce28c37 Report inconclusive Slack placeholder-code checks accurately
Foundry Go checks / check (push) Successful in 2m9s
Foundry Go checks / check (pull_request) Successful in 1m57s
2026-11-05 20:22:51 +08:00
codex-bot 6c69fcbaaa Inject Slack Actions secrets and verify live client credentials
Foundry Go checks / check (push) Failing after 1m37s
Foundry Go checks / check (pull_request) Successful in 2m3s
2026-11-05 20:19:49 +08:00
codex-bot 5b9af931c7 Wait for final PostgreSQL startup before migration checks
Foundry Go checks / check (push) Successful in 2m3s
Foundry Go checks / check (pull_request) Successful in 1m57s
2026-11-05 19:03:55 +08:00
codex-bot 9d5ab9e215 Fix shared OAuth exchange SQL by avoiding a reserved identifier
Foundry Go checks / check (push) Successful in 2m10s
Foundry Go checks / check (pull_request) Successful in 2m11s
2026-11-05 18:55:29 +08:00
codex-bot f69575d48f Serialize email verification and resend to preserve registration state
Foundry Go checks / check (push) Failing after 2m1s
Foundry Go checks / check (pull_request) Failing after 1m50s
2026-11-05 18:53:50 +08:00
codex-bot fbe0910638 Guard legacy Google accounts through provider login expansion
Foundry Go checks / check (push) Failing after 1m59s
Foundry Go checks / check (pull_request) Failing after 1m56s
2026-11-05 18:51:56 +08:00
codex-bot 5c0ad30a7a Add Slack OpenID login with verified identities and callback replay protection
Foundry Go checks / check (push) Failing after 2m2s
Foundry Go checks / check (pull_request) Failing after 2m4s
2026-11-05 18:49:41 +08:00
codex-bot 206cd45098 Apply tracked schema migrations before deployment and test runtime grants
Foundry Go checks / check (push) Successful in 1m48s
Foundry Go checks / check (pull_request) Failing after 1m38s
2026-11-05 18:43:45 +08:00
codex-bot 4a06944fec Verify multimodal content boundaries and every material sort
Foundry Go checks / check (push) Successful in 2m6s
Foundry Go checks / check (pull_request) Successful in 1m49s
2026-11-05 18:40:41 +08:00
codex-bot e0ba9c08e8 Expose paginated share redirect events to workspace admins
Foundry Go checks / check (push) Successful in 1m47s
Foundry Go checks / check (pull_request) Successful in 1m51s
2026-11-05 18:39:23 +08:00
codex-bot bfcea1b3af Add structured materials, canvas layouts, tags and content permissions
Foundry Go checks / check (push) Successful in 1m51s
Foundry Go checks / check (pull_request) Successful in 1m56s
2026-11-05 18:36:53 +08:00
codex-bot 0d0521f13d Implement verified email registration and queued TLS delivery
Foundry Go checks / check (push) Successful in 1m52s
Foundry Go checks / check (pull_request) Successful in 1m53s
2026-11-05 18:31:09 +08:00
codex-bot 1b756d36ce Add configurable onboarding and scoped invitation links
Foundry Go checks / check (push) Successful in 1m47s
Foundry Go checks / check (pull_request) Successful in 1m57s
2026-11-05 18:24:30 +08:00
codex-bot 53c4dbee8e Add workspace membership roles with serialized permission changes
Foundry Go checks / check (push) Successful in 1m38s
Foundry Go checks / check (pull_request) Successful in 1m43s
2026-11-05 18:19:19 +08:00
codex-bot e626a2359e Add authenticated profiles and prefill Google account names
Foundry Go checks / check (push) Successful in 1m42s
Foundry Go checks / check (pull_request) Successful in 1m51s
2026-11-05 18:17:44 +08:00
codex-bot 0fd84f0f6e Establish project-scoped account and collaboration schema
Foundry Go checks / check (push) Successful in 1m43s
Foundry Go checks / check (pull_request) Successful in 1m40s
2026-11-05 18:14:44 +08:00
Zayden-Jung c30a3cd197 Merge pull request '为 Foundry 添加项目级 Codex HTTP 接口' (#4) from codex/foundry-codex-http into main
Foundry Go checks / check (push) Successful in 1m27s
Deploy Foundry Core / deploy (push) Successful in 2m22s
Reviewed-on: #4
2026-11-05 07:33:05 +00:00
35 changed files with 4380 additions and 31 deletions
+11
View File
@@ -5,3 +5,14 @@ GOOGLE_CLIENT_SECRET=replace-me
FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}' FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}'
HOST=127.0.0.1 HOST=127.0.0.1
PORT=8080 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
+53
View File
@@ -17,6 +17,59 @@ jobs:
test -z "$(gofmt -l cmd internal)" test -z "$(gofmt -l cmd internal)"
go test -mod=readonly ./... go test -mod=readonly ./...
go build -mod=readonly -o bin/foundry ./cmd/foundry 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 - name: Smoke test HTTP routes
env: env:
PUBLIC_ORIGIN: https://api.example.com PUBLIC_ORIGIN: https://api.example.com
+4
View File
@@ -8,6 +8,7 @@ on:
- 'go.mod' - 'go.mod'
- 'go.sum' - 'go.sum'
- 'deploy/**' - 'deploy/**'
- 'db/**'
- '.gitea/workflows/deploy.yml' - '.gitea/workflows/deploy.yml'
concurrency: concurrency:
@@ -35,5 +36,8 @@ jobs:
env: env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} 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_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }}
FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }}
run: bash deploy/release.sh bin/foundry run: bash deploy/release.sh bin/foundry
+1
View File
@@ -1,2 +1,3 @@
.env .env
/bin/ /bin/
/api-docs/
+76
View File
@@ -29,6 +29,17 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。 用同一个 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 ## Codex execution
When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object
@@ -61,3 +72,68 @@ disables command execution tools. It receives prompts via stdin and returns
the final agent message. The worker has no published port and Foundry receives 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 neither the Codex home nor the Docker socket. Keep both containers on the same
host for Unix socket access. 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: <full snapshot>}`
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.
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
or belong to other frames. Any non-frame node can specify `data.frameId`, which
must reference a frame in the same snapshot; forward references are supported.
Membership is explicit and independent of geometry: moving a frame over existing
content does not add members. The frontend assigns membership on content drop
and implements label zoom behavior. Core preserves the supplied membership and
label mode without inferring them from positions. Renderer-only `zIndex` is
ignored alongside transient state; restore frame layering in the frontend.
This extends existing JSONB snapshots and requires no additional migration.
### Browser session lifetime
Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth
logins have a 30-day idle lifetime; authenticated requests atomically renew that
idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired,
revoked and wrong-project tokens are never revived. Logout removes the session.
Apply `db/004-session-lifetime.sql` before running this version. Existing sessions
keep their prior idle expiry, so expired logins remain expired. For legacy rows,
the absolute 90-day cap starts at migration time because issuance was not recorded;
an unexpired legacy session gains the new idle lifetime on its next authenticated
request. New rows record their actual creation time and absolute deadline.
Login and exchange responses return `expires_in: 2592000`. Protected platform
responses and `/auth/me` include `X-Foundry-Session-Expires-In` with the remaining
seconds after renewal, shortened near the absolute deadline. The frontend proxy
must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must
not extend a cookie beyond the backend-provided remaining lifetime, or expose the
session token to client JavaScript. CORS exposes the lifetime header alongside
ETag where applicable. The absolute deadline stays server-authoritative.
+17 -1
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"log" "log"
"net" "net"
@@ -11,6 +12,7 @@ import (
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "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/codex"
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/stdlib"
) )
@@ -45,7 +47,16 @@ func main() {
} }
db := stdlib.OpenDB(*config) db := stdlib.OpenDB(*config)
defer db.Close() 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -53,6 +64,11 @@ func main() {
if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil {
log.Fatal(err) 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) mux.Handle("/", handler)
server := &http.Server{ server := &http.Server{
Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")),
+224
View File
@@ -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;
+29
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
BEGIN;
-- Existing sessions keep their current idle deadline: expired sessions stay expired.
-- Their original issuance time was not recorded, so the new absolute cap starts here.
ALTER TABLE project_sessions
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN absolute_expires_at timestamptz NOT NULL DEFAULT (now() + interval '90 days');
COMMIT;
+77
View File
@@ -0,0 +1,77 @@
#!/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'),
('expired-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;
IF EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='expired-session' AND expires_at>now()) THEN
RAISE EXCEPTION 'Migration revived expired session';
END IF;
IF NOT EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='existing-session' AND absolute_expires_at>now()+interval '89 days') THEN
RAISE EXCEPTION 'Missing absolute session deadline';
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
+76
View File
@@ -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.")
+29 -2
View File
@@ -39,11 +39,34 @@ PYCONFIG
export FOUNDRY_PROJECTS export FOUNDRY_PROJECTS
test -s "$foundry/database.env" 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 docker network inspect "$network" >/dev/null
python3 "$(dirname "$0")/migrate.py"
release=$(mktemp -d "$foundry/release.XXXXXXXX") release=$(mktemp -d "$foundry/release.XXXXXXXX")
install -m 755 "$1" "$release/foundry" install -m 755 "$1" "$release/foundry"
export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" 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_mounts=()
codex_dir=$foundry/codex codex_dir=$foundry/codex
if [[ -d "$codex_dir" ]]; then if [[ -d "$codex_dir" ]]; then
@@ -64,11 +87,13 @@ if [[ -d "$codex_dir" ]]; then
) )
fi fi
docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ 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 \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \
-e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ -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 \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \
--read-only --memory 128m \ --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" \ --mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \
node:24-bookworm-slim /opt/foundry >/dev/null node:24-bookworm-slim /opt/foundry >/dev/null
@@ -79,7 +104,9 @@ trap cleanup_candidate EXIT
docker exec "$candidate" node -e ' docker exec "$candidate" node -e '
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const fs = require("node:fs");
async function verify() { async function verify() {
assert.ok(fs.readFileSync(process.env.SSL_CERT_FILE, "utf8").includes("-----BEGIN CERTIFICATE-----"));
const base = "http://127.0.0.1:8080"; const base = "http://127.0.0.1:8080";
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
try { try {
+39 -28
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"golang.org/x/crypto/bcrypt"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -26,10 +27,18 @@ type service struct {
projects map[string]*url.URL projects map[string]*url.URL
redirectURI, clientID, secret string redirectURI, clientID, secret string
google *http.Client 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) { func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig, slack *SlackConfig) (http.Handler, error) {
s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret, 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 }}} google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
for name, raw := range callbacks { for name, raw := range callbacks {
u, err := url.Parse(raw) 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"}) reply(w, 200, payload{"status": "ready"})
return nil return nil
})) }))
mux.HandleFunc("/auth/slack/callback", s.wrap(s.slackCallback))
mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback)) mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback))
mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project)) mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 { if frontend == nil {
return reject(w, 404, "not_found") 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" { if r.Method == http.MethodGet && action == "google" {
return s.start(w, r, project) return s.start(w, r, project)
} }
@@ -116,12 +129,15 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
return reject(w, 403, "origin_denied") return reject(w, 403, "origin_denied")
} }
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", SessionExpiryHeader)
} }
switch { switch {
case r.Method == http.MethodOptions: case r.Method == http.MethodOptions:
w.Header().Set("Access-Control-Allow-Methods", "GET, POST") 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) 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": case r.Method == http.MethodPost && action == "exchange":
return s.exchange(w, r, project) return s.exchange(w, r, project)
case r.Method == http.MethodGet && action == "me": case r.Method == http.MethodGet && action == "me":
@@ -146,14 +162,14 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string)
} }
state, browser, verifier := token(), token(), token() state, browser, verifier := token(), token(), token()
_, err := s.db.ExecContext(r.Context(), ` _, 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) `, digest(state), digest(browser), project, challenge, verifier)
if err != nil { if err != nil {
return err return err
} }
query := url.Values{ query := url.Values{
"client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"}, "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"}, "code_challenge_method": {"S256"}, "prompt": {"select_account"},
} }
http.SetCookie(w, oauthCookie(state, browser, 600)) http.SetCookie(w, oauthCookie(state, browser, 600))
@@ -201,7 +217,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
var project, challenge, verifier string var project, challenge, verifier string
err = s.db.QueryRowContext(ctx, ` err = s.db.QueryRowContext(ctx, `
DELETE FROM oauth_attempts 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 RETURNING project, frontend_challenge, google_verifier
`, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier) `, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -240,6 +256,8 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
Subject string `json:"sub"` Subject string `json:"sub"`
Email *string `json:"email"` Email *string `json:"email"`
Verified bool `json:"email_verified"` 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 { if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil {
return err return err
@@ -251,16 +269,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
user.Email = nil user.Email = nil
} }
grant := token() grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge)
_, 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)
if err != nil { if err != nil {
return err return err
} }
@@ -276,13 +285,13 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
} }
session := token() session := token()
result, err := s.db.ExecContext(r.Context(), ` result, err := s.db.ExecContext(r.Context(), `
WITH grant AS ( WITH redeemed_grant AS (
DELETE FROM login_grants DELETE FROM login_grants
WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now()
RETURNING account_id RETURNING account_id
) )
INSERT INTO project_sessions INSERT INTO project_sessions(token_hash,project,account_id,expires_at)
SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant SELECT $4, $2, account_id, now() + interval '30 days' FROM redeemed_grant
`, digest(code), project, digest(verifier), digest(session)) `, digest(code), project, digest(verifier), digest(session))
if err != nil { if err != nil {
return err return err
@@ -294,7 +303,7 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
if count == 0 { if count == 0 {
return reject(w, 401, "invalid_grant") return reject(w, 401, "invalid_grant")
} }
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
return nil return nil
} }
@@ -311,19 +320,21 @@ func (s *service) account(w http.ResponseWriter, r *http.Request, project string
reply(w, 204, nil) reply(w, 204, nil)
return nil return nil
} }
var id string session, err := AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
var email *string if errors.Is(err, ErrInvalidSession) {
err := s.db.QueryRowContext(r.Context(), `
SELECT a.id::text, a.email FROM project_sessions s
JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id
WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now()
`, digest(token), project).Scan(&id, &email)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 401, "invalid_session") return reject(w, 401, "invalid_session")
} }
if err != nil { if err != nil {
return err return err
} }
var email *string
err = s.db.QueryRowContext(r.Context(), "SELECT email FROM project_accounts WHERE project=$1 AND id=$2", project, session.AccountID).Scan(&email)
if err != nil {
return err
}
WriteSessionHeaders(w, session)
id := session.AccountID
reply(w, 200, payload{"project": project, "id": id, "email": email}) reply(w, 200, payload{"project": project, "id": id, "email": email})
return nil return nil
} }
+217
View File
@@ -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(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account)
if err != nil {
return err
}
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
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(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", 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": SessionIdleSeconds})
return nil
}
+72
View File
@@ -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)
}
}
+67
View File
@@ -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
}
+113
View File
@@ -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
}
+50
View File
@@ -0,0 +1,50 @@
package auth
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
)
const SessionIdleSeconds = 30 * 24 * 60 * 60
const SessionExpiryHeader = "X-Foundry-Session-Expires-In"
var ErrInvalidSession = errors.New("invalid session")
type Session struct {
AccountID string
ExpiresIn int64
}
// AuthenticateSession atomically validates and renews a live session, bounded by
// its absolute deadline. Expired, revoked and wrong-project tokens never renew.
func AuthenticateSession(ctx context.Context, db *sql.DB, project, authorization string) (Session, error) {
value, ok := strings.CutPrefix(authorization, "Bearer ")
if !ok || !tokenPattern.MatchString(value) {
return Session{}, ErrInvalidSession
}
var session Session
err := db.QueryRowContext(ctx, `
UPDATE project_sessions
SET expires_at=LEAST(now()+interval '30 days',absolute_expires_at)
WHERE project=$1 AND token_hash=$2 AND expires_at>now() AND absolute_expires_at>now()
RETURNING account_id::text,GREATEST(0,FLOOR(EXTRACT(EPOCH FROM (expires_at-now()))))::bigint
`, project, digest(value)).Scan(&session.AccountID, &session.ExpiresIn)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrInvalidSession
}
return session, err
}
func WriteSessionHeaders(w http.ResponseWriter, session Session) {
w.Header().Set(SessionExpiryHeader, strconv.FormatInt(session.ExpiresIn, 10))
}
// Authenticate preserves the existing API for clients that only need an account ID.
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) {
session, err := AuthenticateSession(ctx, db, project, authorization)
return session.AccountID, err
}
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func sessionFixture(t *testing.T) (*sql.DB, string, string) {
t.Helper()
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)
}
project := "session-" + token()[:12]
var account string
if err = db.QueryRow("INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&account); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project); db.Close() })
return db, project, account
}
func TestSessionIdleAndAbsoluteExpiry(t *testing.T) {
db, project, account := sessionFixture(t)
for _, test := range []struct {
name, idle, absolute string
valid bool
}{
{"active", "1 hour", "90 days", true},
{"idle-expired", "-1 second", "90 days", false},
{"absolute-cap", "1 hour", "2 days", true},
{"absolute-expired", "1 hour", "-1 second", false},
} {
t.Run(test.name, func(t *testing.T) {
value := token()
_, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+$4::interval,now()+$5::interval)", digest(value), project, account, test.idle, test.absolute)
if err != nil {
t.Fatal(err)
}
var before time.Time
if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&before); err != nil {
t.Fatal(err)
}
session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value)
if !test.valid {
if err != ErrInvalidSession {
t.Fatalf("expired session accepted: %v", err)
}
var after time.Time
if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&after); err != nil {
t.Fatal(err)
}
if !after.Equal(before) {
t.Fatal("expired session revived")
}
return
}
if err != nil || session.AccountID != account || session.ExpiresIn <= 0 || session.ExpiresIn > SessionIdleSeconds {
t.Fatalf("invalid renewal result: %+v %v", session, err)
}
var expiry, cap time.Time
if err := db.QueryRow("SELECT expires_at,absolute_expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&expiry, &cap); err != nil {
t.Fatal(err)
}
if expiry.After(cap) || (test.name == "absolute-cap" && !expiry.Equal(cap)) {
t.Fatal("absolute cap not enforced")
}
if test.name == "active" && session.ExpiresIn < SessionIdleSeconds-2 {
t.Fatal("active session did not receive 30 idle days")
}
})
}
}
func TestSessionIsolationRevocationAndConcurrentRenewal(t *testing.T) {
db, project, account := sessionFixture(t)
value := token()
if _, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+interval '1 hour',now()+interval '2 days')", digest(value), project, account); err != nil {
t.Fatal(err)
}
for _, input := range []struct{ project, authorization string }{{"other", "Bearer " + value}, {project, "Bearer bad-token"}, {project, ""}} {
if _, err := AuthenticateSession(context.Background(), db, input.project, input.authorization); err != ErrInvalidSession {
t.Fatalf("invalid or cross-project token accepted: %v", err)
}
}
var group sync.WaitGroup
for range 4 {
group.Add(1)
go func() {
defer group.Done()
session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value)
if err != nil || session.ExpiresIn > 2*86400 {
t.Errorf("concurrent renewal escaped cap: %+v %v", session, err)
}
}()
}
group.Wait()
if _, err := db.Exec("DELETE FROM project_sessions WHERE token_hash=$1", digest(value)); err != nil {
t.Fatal(err)
}
if _, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value); err != ErrInvalidSession {
t.Fatal("revoked session renewed")
}
}
func TestOAuthExchangeIssuesLongLivedSessionAndMeReturnsRemainingLifetime(t *testing.T) {
db, project, account := sessionFixture(t)
code, verifier := token(), token()
_, err := db.Exec("INSERT INTO login_grants(token_hash,project,account_id,frontend_challenge,expires_at) VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(code), project, account, digest(verifier))
if err != nil {
t.Fatal(err)
}
s := &service{db: db}
r := httptest.NewRequest("POST", "/exchange", nil)
r.Header.Set("X-Foundry-Code", code)
r.Header.Set("X-Foundry-Verifier", verifier)
out := httptest.NewRecorder()
if err = s.exchange(out, r, project); err != nil || out.Code != 200 {
t.Fatalf("exchange: %d %v", out.Code, err)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err = json.Unmarshal(out.Body.Bytes(), &result); err != nil || result.ExpiresIn != SessionIdleSeconds {
t.Fatal("incorrect login lifetime")
}
var idle, absolute float64
if err = db.QueryRow("SELECT EXTRACT(EPOCH FROM(expires_at-created_at)),EXTRACT(EPOCH FROM(absolute_expires_at-created_at)) FROM project_sessions WHERE token_hash=$1", digest(result.AccessToken)).Scan(&idle, &absolute); err != nil || idle < SessionIdleSeconds-2 || absolute != 90*86400 {
t.Fatalf("wrong session deadlines: %f %f %v", idle, absolute, err)
}
r = httptest.NewRequest("GET", "/me", strings.NewReader(""))
r.Header.Set("Authorization", "Bearer "+result.AccessToken)
out = httptest.NewRecorder()
if err = s.account(out, r, project, false); err != nil || out.Header().Get(SessionExpiryHeader) == "" {
t.Fatal("missing renewal header")
}
}
+226
View File
@@ -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
}
+222
View File
@@ -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)
}
}
+323
View File
@@ -0,0 +1,323 @@
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", "zIndex"} {
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", "frameId", "labelMode") {
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 kind == "frame" {
typ = "frame"
} 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
}
if frameID, exists := d["frameId"]; exists {
value, ok := frameID.(string)
if kind == "frame" || !ok || !canvasID.MatchString(value) {
return nil, false
}
}
if mode, exists := d["labelMode"]; exists && (kind != "frame" || !canvasChoice(mode, "hidden", "fixed", "scaled")) {
return nil, false
}
if kind == "frame" {
if rotation, exists := d["rotation"]; exists && rotation != float64(0) {
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
}
}
}
}
}
// Resolve memberships after all nodes so forward references are valid.
// Geometry never creates membership: only explicit frameId is persisted.
for _, value := range nodes {
data := value.(map[string]any)["data"].(map[string]any)
if id, exists := data["frameId"]; exists && kinds[id.(string)] != "frame" {
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
}
+90
View File
@@ -0,0 +1,90 @@
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)
}
}
}
// Frame nodes follow the frontend contract: membership is data, not containment.
const frameFixture = `{"schema_version":1,"nodes":[{"id":"text","type":"material","position":{"x":100,"y":100},"data":{"kind":"text","title":"成员","text":"正文","frameId":"frame"}},{"id":"plain","type":"plainText","position":{"x":0,"y":0},"data":{"kind":"plain-text","title":"纯文本","text":"文字","frameId":"frame"}},{"id":"icon","type":"icon","position":{"x":10,"y":10},"data":{"kind":"icon","title":"图标","text":"","icon":"Star","frameId":"frame"}},{"id":"frame","type":"frame","position":{"x":0,"y":0},"width":600,"height":400,"zIndex":-1,"style":{"width":600,"height":400,"pointerEvents":"none"},"data":{"kind":"frame","title":"框选区","text":"","rotation":0,"labelMode":"fixed"}},{"id":"outside","type":"material","position":{"x":100,"y":100},"data":{"kind":"script","title":"重叠但未加入","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasFrames(t *testing.T) {
for _, mode := range []string{"hidden", "fixed", "scaled"} {
t.Run(mode, func(t *testing.T) {
out, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)))
if !ok {
t.Fatal("valid frame rejected")
}
var doc map[string]any
json.Unmarshal(out, &doc)
nodes := doc["nodes"].([]any)
if nodes[0].(map[string]any)["data"].(map[string]any)["frameId"] != "frame" || nodes[3].(map[string]any)["data"].(map[string]any)["labelMode"] != mode {
t.Fatal("frame data lost")
}
if _, exists := nodes[4].(map[string]any)["data"].(map[string]any)["frameId"]; exists {
t.Fatal("position inferred membership")
}
if _, exists := nodes[3].(map[string]any)["zIndex"]; exists {
t.Fatal("rendering state persisted")
}
})
}
cases := [][2]string{
{`"labelMode":"fixed"`, `"labelMode":"invalid"`},
{`"labelMode":"fixed"`, `"labelMode":null`},
{`"rotation":0`, `"rotation":1`},
{`"frameId":"frame"`, `"frameId":"missing"`},
{`"frameId":"frame"`, `"frameId":"text"`},
{`"frameId":"frame"`, `"frameId":null`},
{`"frameId":"frame"`, `"frameId":""`},
{`"labelMode":"fixed"`, `"labelMode":"fixed","frameId":"frame"`},
{`"type":"frame"`, `"type":"material"`},
{`"edges":[]`, `"edges":[{"id":"e","source":"text","target":"frame"}]`},
}
for _, c := range cases {
t.Run(c[1], func(t *testing.T) {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, c[0], c[1], 1))); ok {
t.Fatal("invalid frame accepted")
}
})
}
withoutMode := strings.Replace(frameFixture, `,"labelMode":"fixed"`, "", 1)
if _, ok := normalizeCanvas(json.RawMessage(withoutMode)); !ok {
t.Fatal("optional labelMode rejected")
}
}
+129
View File
@@ -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
}
+39
View File
@@ -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")
}
})
}
}
+96
View File
@@ -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
}
+187
View File
@@ -0,0 +1,187 @@
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, "+auth.SessionExpiryHeader)
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match")
reply(w, 204, nil)
return
}
session, err := auth.AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
if errors.Is(err, auth.ErrInvalidSession) {
reject(w, 401, "invalid_session")
return
}
account := session.AccountID
if err == nil {
auth.WriteSessionHeaders(w, session)
}
if err == nil && r.PathValue("workspace") != "" {
var id string
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id)
if err == nil {
r.SetPathValue("workspace", id)
}
}
if err == nil && r.PathValue("material") != "" {
var id string
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM materials WHERE project=$1 AND workspace_id=$2 AND (id::text=$3 OR slug=$3)", project, r.PathValue("workspace"), r.PathValue("material")).Scan(&id)
if err == nil {
r.SetPathValue("material", id)
}
}
if err == nil {
err = handle(w, r, project, account)
}
if err != nil {
var pg *pgconn.PgError
switch {
case errors.Is(err, 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
}
+316
View File
@@ -0,0 +1,316 @@
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"
"strconv"
"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")
}
// Frames round-trip through PostgreSQL with all label modes and explicit memberships.
for index, mode := range []string{"hidden", "fixed", "scaled"} {
version := strconv.Itoa(4 + index)
raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)
call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`)
persisted := call("GET", canvasPath, tokens[0], nil, 200)
normalized, _ := normalizeCanvas(json.RawMessage(raw))
want, _ := json.Marshal(json.RawMessage(normalized))
got, _ := json.Marshal(persisted["document"])
if !bytes.Equal(want, got) {
t.Fatal("frame snapshot not preserved")
}
}
invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1)
rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`)
if rejected["error"] != "invalid_canvas" {
t.Fatal("unexpected frame error")
}
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) {
t.Fatal("invalid frame changed saved version")
}
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)
}
}
+158
View File
@@ -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
}
+299
View File
@@ -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
}
+192
View File
@@ -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
}
+356
View File
@@ -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_count<max_uses)
), click AS(
INSERT INTO link_clicks(project,workspace_id,link_id,referrer_origin)
SELECT project,workspace_id,id,$2 FROM valid RETURNING project
)
SELECT project FROM click
`, linkDigest(token), ref).Scan(&project)
if err != nil {
if err == sql.ErrNoRows {
reject(w, 404, "not_found")
} else {
reject(w, 503, "service_unavailable")
}
return
}
callback, ok := s.callbacks[project]
if !ok {
reject(w, 404, "not_found")
return
}
destination, err := url.Parse(callback)
if err != nil {
reject(w, 503, "service_unavailable")
return
}
destination.Fragment = "invite=" + token
http.Redirect(w, r, destination.String(), 302)
}
func (s *service) redeemShare(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 {
Token string `json:"token"`
}
if !decode(w, r, &input) {
return nil
}
if !linkTokenPattern.MatchString(input.Token) {
reject(w, 404, "not_found")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
var workspace string
// Lock workspace before the link, matching revocation and role updates.
err = tx.QueryRowContext(r.Context(), `
SELECT w.id::text FROM workspaces w JOIN share_links l ON (w.project,w.id)=(l.project,l.workspace_id)
WHERE l.project=$1 AND l.token_hash=$2 FOR UPDATE OF w
`, project, linkDigest(input.Token)).Scan(&workspace)
if err != nil {
return err
}
var id, role string
var material *string
var allowed, capacity, redeemed bool
err = tx.QueryRowContext(r.Context(), `
SELECT id::text,role,material_id::text,
revoked_at IS NULL AND expires_at>now(), (max_uses IS NULL OR use_count<max_uses),
EXISTS(SELECT 1 FROM share_redemptions d WHERE (d.project,d.workspace_id,d.link_id)=(share_links.project,share_links.workspace_id,share_links.id) AND d.account_id=$4)
FROM share_links WHERE project=$1 AND workspace_id=$2 AND token_hash=$3 FOR UPDATE
`, project, workspace, linkDigest(input.Token), account).Scan(&id, &role, &material, &allowed, &capacity, &redeemed)
if err != nil {
return err
}
if !allowed || (!capacity && !redeemed) {
reject(w, 410, "share_unavailable")
return nil
}
result, err := tx.ExecContext(r.Context(), `
INSERT INTO share_redemptions(project,workspace_id,link_id,account_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING
`, project, workspace, id, account)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n > 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
}
+12
View File
@@ -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))
}
+169
View File
@@ -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
}
+251
View File
@@ -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
}