Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5070ed0460 | |||
| 57aab71d45 | |||
| 6f36ef27bb | |||
| e8ed5e4f9f | |||
| b1e0d783e6 | |||
| 4043c89520 | |||
| 155ce28c37 | |||
| 6c69fcbaaa | |||
| 5b9af931c7 | |||
| 9d5ab9e215 | |||
| f69575d48f | |||
| fbe0910638 | |||
| 5c0ad30a7a | |||
| 206cd45098 | |||
| 4a06944fec | |||
| e0ba9c08e8 | |||
| bfcea1b3af | |||
| 0d0521f13d | |||
| 1b756d36ce | |||
| 53c4dbee8e | |||
| e626a2359e | |||
| 0fd84f0f6e | |||
| c30a3cd197 |
@@ -5,3 +5,14 @@ GOOGLE_CLIENT_SECRET=replace-me
|
||||
FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}'
|
||||
HOST=127.0.0.1
|
||||
PORT=8080
|
||||
|
||||
# Optional email verification through TLS SMTP.
|
||||
SMTP_HOST=smtpdm.aliyun.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=noreply@notice.entroia.com
|
||||
SMTP_PASSWORD=replace-me
|
||||
SMTP_FROM=noreply@notice.entroia.com
|
||||
|
||||
# Optional Sign in with Slack.
|
||||
SLACK_CLIENT_ID=replace-me
|
||||
SLACK_CLIENT_SECRET=replace-me
|
||||
|
||||
@@ -17,6 +17,59 @@ jobs:
|
||||
test -z "$(gofmt -l cmd internal)"
|
||||
go test -mod=readonly ./...
|
||||
go build -mod=readonly -o bin/foundry ./cmd/foundry
|
||||
- name: Check Slack OAuth configuration and endpoint
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
|
||||
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
|
||||
run: |
|
||||
python3 - <<'PYSLACK'
|
||||
import json, os, secrets, sys, urllib.parse, urllib.request
|
||||
client = os.environ.get("SLACK_CLIENT_ID", "")
|
||||
secret = os.environ.get("SLACK_CLIENT_SECRET", "")
|
||||
if not client and not secret:
|
||||
print("Slack client verification skipped because credentials are not configured")
|
||||
sys.exit(0)
|
||||
if not client or not secret:
|
||||
sys.exit("Both Slack client secrets must be configured")
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
code = "foundry-invalid-preflight-" + secrets.token_hex(16)
|
||||
def probe(password):
|
||||
body = urllib.parse.urlencode({
|
||||
"client_id": client, "client_secret": password,
|
||||
"code": code, "grant_type": "authorization_code",
|
||||
"redirect_uri": "https://api.foundry.entroia.com/auth/slack/callback"
|
||||
}).encode()
|
||||
request = urllib.request.Request(
|
||||
"https://slack.com/api/openid.connect.token", data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
with opener.open(request, timeout=20) as response:
|
||||
result = json.load(response)
|
||||
return result.get("error", "")
|
||||
try:
|
||||
actual = probe(secret)
|
||||
control = probe(secrets.token_hex(32))
|
||||
except Exception as error:
|
||||
sys.exit("Slack preflight request failed (" + type(error).__name__ + ")")
|
||||
if actual in ("invalid_code", "invalid_grant") and control in ("bad_client_secret", "invalid_client"):
|
||||
print("Slack client credentials accepted; invalid-secret negative control rejected")
|
||||
print("A real user authorization is still required to verify the complete login")
|
||||
elif actual in ("invalid_code", "invalid_grant") and control in ("invalid_code", "invalid_grant"):
|
||||
print("Both Slack Actions secrets are available and the OAuth endpoint is reachable")
|
||||
print("Slack rejected the placeholder code before validating the client secret")
|
||||
print("Client-secret validity requires a real user authorization; this is not a complete login test")
|
||||
else:
|
||||
known = {"invalid_client_id", "bad_client_secret", "invalid_client", "invalid_code",
|
||||
"invalid_grant", "bad_redirect_uri", "accesslimited", "ratelimited"}
|
||||
print("Configured credential result:", actual if actual in known else "unclassified")
|
||||
print("Negative control result:", control if control in known else "unclassified")
|
||||
sys.exit("Slack client preflight could not verify the configured credentials")
|
||||
PYSLACK
|
||||
- name: Verify schema migration in an isolated database
|
||||
run: bash deploy/check-schema.sh
|
||||
- name: Smoke test HTTP routes
|
||||
env:
|
||||
PUBLIC_ORIGIN: https://api.example.com
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'deploy/**'
|
||||
- 'db/**'
|
||||
- '.gitea/workflows/deploy.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -35,5 +36,8 @@ jobs:
|
||||
env:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
|
||||
SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }}
|
||||
FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }}
|
||||
FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }}
|
||||
run: bash deploy/release.sh bin/foundry
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
.env
|
||||
/bin/
|
||||
/api-docs/
|
||||
|
||||
@@ -29,6 +29,17 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog
|
||||
|
||||
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。
|
||||
|
||||
## Runtime HTTPS trust
|
||||
|
||||
The Go backend needs an operating-system CA bundle for Google and Slack HTTPS
|
||||
requests. The `node:24-bookworm-slim` runtime does not include that trust store;
|
||||
Node's own successful HTTPS checks do not validate Go's trust configuration.
|
||||
`deploy/release.sh` mounts the deployment host's
|
||||
`/etc/ssl/certs/ca-certificates.crt` read-only and sets `SSL_CERT_FILE` to that
|
||||
path. The deployment fails before switching traffic if the host bundle is
|
||||
missing or unreadable in the candidate. Keep TLS certificate verification
|
||||
enabled and maintain the deployment host's trusted CA package.
|
||||
|
||||
## Codex execution
|
||||
|
||||
When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object
|
||||
@@ -61,3 +72,35 @@ disables command execution tools. It receives prompts via stdin and returns
|
||||
the final agent message. The worker has no published port and Foundry receives
|
||||
neither the Codex home nor the Docker socket. Keep both containers on the same
|
||||
host for Unix socket access.
|
||||
|
||||
### Board Canvas snapshots and slugs
|
||||
|
||||
Apply `db/003-canvas.sql` through the normal migration runner before starting this
|
||||
version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas`
|
||||
returns `{version, document}` and a quoted `ETag`. An untouched board returns
|
||||
version 1 and an empty schema-1 graph. `PATCH` takes `{document: <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.
|
||||
|
||||
+17
-1
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/platform"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
@@ -45,7 +47,16 @@ func main() {
|
||||
}
|
||||
db := stdlib.OpenDB(*config)
|
||||
defer db.Close()
|
||||
handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret)
|
||||
email, err := auth.EmailConfigFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go email.Run(context.Background(), db)
|
||||
slack, err := auth.SlackConfigFromEnv(origin.ResolveReference(&url.URL{Path: "/auth/slack/callback"}).String())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email, slack)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -53,6 +64,11 @@ func main() {
|
||||
if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
onboarding, err := platform.ParseOnboarding(os.Getenv("FOUNDRY_ONBOARDING"), callbacks)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
platform.Register(mux, db, callbacks, origin.Scheme+"://"+origin.Host, onboarding)
|
||||
mux.Handle("/", handler)
|
||||
server := &http.Server{
|
||||
Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")),
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
container=foundry-schema-$(openssl rand -hex 8)
|
||||
cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
# No host port or shared network. The database is discarded after this check.
|
||||
docker run -d --name "$container" --network none \
|
||||
-e POSTGRES_HOST_AUTH_METHOD=trust postgres:17-alpine >/dev/null
|
||||
for attempt in {1..30}; do
|
||||
if docker exec "$container" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
psql() { docker exec -i "$container" psql -X -U postgres -v ON_ERROR_STOP=1; }
|
||||
psql < db/001-auth.sql
|
||||
psql <<'SQL'
|
||||
INSERT INTO project_accounts(project,id,google_subject,email)
|
||||
VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid');
|
||||
INSERT INTO project_sessions VALUES
|
||||
('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour');
|
||||
SQL
|
||||
psql <<'SQLROLE'
|
||||
CREATE ROLE foundry_fixture LOGIN;
|
||||
SQLROLE
|
||||
python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture
|
||||
python3 deploy/migrate.py --container "$container" --database postgres --role foundry_fixture
|
||||
psql <<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM project_sessions s JOIN project_accounts a
|
||||
ON (s.project,s.account_id)=(a.project,a.id)
|
||||
JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id)
|
||||
WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google'
|
||||
) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF;
|
||||
END $$;
|
||||
BEGIN;
|
||||
INSERT INTO project_accounts(project,id,email) VALUES
|
||||
('fixture','00000000-0000-0000-0000-000000000002','email@example.invalid');
|
||||
INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES
|
||||
('fixture','email@example.invalid','00000000-0000-0000-0000-000000000002','fixture-hash');
|
||||
INSERT INTO workspaces(project,id,name,owner_id) VALUES
|
||||
('fixture','00000000-0000-0000-0000-000000000010','Fixture','00000000-0000-0000-0000-000000000001');
|
||||
INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES
|
||||
('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin');
|
||||
INSERT INTO materials(project,workspace_id,id,kind,title,created_by) VALUES
|
||||
('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000020','article','Article','00000000-0000-0000-0000-000000000001'),
|
||||
('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','board','Board','00000000-0000-0000-0000-000000000001');
|
||||
INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height) VALUES
|
||||
('fixture','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000021','00000000-0000-0000-0000-000000000020',0,0,320,200);
|
||||
DO $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES
|
||||
('other','00000000-0000-0000-0000-000000000010','00000000-0000-0000-0000-000000000001','admin');
|
||||
RAISE EXCEPTION 'Cross-project membership unexpectedly accepted';
|
||||
EXCEPTION WHEN foreign_key_violation THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
printf 'Migration preserved existing logins and enforced project isolation.\n'
|
||||
for package in platform auth; do
|
||||
CGO_ENABLED=0 go test -mod=readonly -c -o "bin/$package-tests" "./internal/$package"
|
||||
docker run --rm --network "container:$container" --user 1000:1000 \
|
||||
--read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--mount "type=bind,source=$(pwd)/bin/$package-tests,target=/opt/integration-tests,readonly" \
|
||||
-e PLATFORM_TEST_DATABASE_URL=postgres://foundry_fixture@127.0.0.1:5432/postgres?sslmode=disable \
|
||||
--entrypoint /opt/integration-tests postgres:17-alpine -test.v
|
||||
done
|
||||
@@ -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
@@ -39,11 +39,34 @@ PYCONFIG
|
||||
export FOUNDRY_PROJECTS
|
||||
|
||||
test -s "$foundry/database.env"
|
||||
# Go's HTTPS client uses the OS trust store, not Node's bundled roots.
|
||||
# The slim Node runtime has no system CA bundle of its own.
|
||||
ca_bundle=/etc/ssl/certs/ca-certificates.crt
|
||||
test -s "$ca_bundle"
|
||||
docker network inspect "$network" >/dev/null
|
||||
python3 "$(dirname "$0")/migrate.py"
|
||||
|
||||
release=$(mktemp -d "$foundry/release.XXXXXXXX")
|
||||
install -m 755 "$1" "$release/foundry"
|
||||
export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin"
|
||||
mail_env=()
|
||||
if [[ -s "$foundry/mail.env" ]]; then
|
||||
mail_env=(--env-file "$foundry/mail.env")
|
||||
fi
|
||||
slack_env=()
|
||||
if [[ -n "${SLACK_CLIENT_ID:-}" || -n "${SLACK_CLIENT_SECRET:-}" ]]; then
|
||||
for name in SLACK_CLIENT_ID SLACK_CLIENT_SECRET; do
|
||||
value=${!name-}
|
||||
if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then
|
||||
printf 'Missing or invalid %s\n' "$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
export SLACK_CLIENT_ID SLACK_CLIENT_SECRET
|
||||
slack_env=(-e SLACK_CLIENT_ID -e SLACK_CLIENT_SECRET)
|
||||
elif [[ -s "$foundry/slack.env" ]]; then
|
||||
slack_env=(--env-file "$foundry/slack.env")
|
||||
fi
|
||||
codex_mounts=()
|
||||
codex_dir=$foundry/codex
|
||||
if [[ -d "$codex_dir" ]]; then
|
||||
@@ -64,11 +87,13 @@ if [[ -d "$codex_dir" ]]; then
|
||||
)
|
||||
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 \
|
||||
-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 \
|
||||
--read-only --memory 128m \
|
||||
--mount "type=bind,source=$ca_bundle,target=/etc/ssl/certs/ca-certificates.crt,readonly" \
|
||||
-e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
--mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \
|
||||
node:24-bookworm-slim /opt/foundry >/dev/null
|
||||
|
||||
@@ -79,7 +104,9 @@ trap cleanup_candidate EXIT
|
||||
|
||||
docker exec "$candidate" node -e '
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
async function verify() {
|
||||
assert.ok(fs.readFileSync(process.env.SSL_CERT_FILE, "utf8").includes("-----BEGIN CERTIFICATE-----"));
|
||||
const base = "http://127.0.0.1:8080";
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
|
||||
+26
-18
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -26,10 +27,18 @@ type service struct {
|
||||
projects map[string]*url.URL
|
||||
redirectURI, clientID, secret string
|
||||
google *http.Client
|
||||
email *EmailConfig
|
||||
passwordSlots chan struct{}
|
||||
dummyPassword []byte
|
||||
slack *SlackConfig
|
||||
}
|
||||
|
||||
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) {
|
||||
s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
|
||||
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig, slack *SlackConfig) (http.Handler, error) {
|
||||
dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &service{db: db, email: email, slack: slack, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
|
||||
google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
for name, raw := range callbacks {
|
||||
u, err := url.Parse(raw)
|
||||
@@ -52,6 +61,7 @@ func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string
|
||||
reply(w, 200, payload{"status": "ready"})
|
||||
return nil
|
||||
}))
|
||||
mux.HandleFunc("/auth/slack/callback", s.wrap(s.slackCallback))
|
||||
mux.HandleFunc("GET /auth/google/callback", s.wrap(s.callback))
|
||||
mux.HandleFunc("/projects/{project}/auth/{action}", s.wrap(s.project))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -106,6 +116,9 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
|
||||
if frontend == nil {
|
||||
return reject(w, 404, "not_found")
|
||||
}
|
||||
if r.Method == http.MethodGet && action == "slack" {
|
||||
return s.startSlack(w, r, project)
|
||||
}
|
||||
if r.Method == http.MethodGet && action == "google" {
|
||||
return s.start(w, r, project)
|
||||
}
|
||||
@@ -120,8 +133,10 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
|
||||
switch {
|
||||
case r.Method == http.MethodOptions:
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Foundry-Code, X-Foundry-Verifier")
|
||||
reply(w, 204, nil)
|
||||
case r.Method == http.MethodPost && (action == "register" || action == "verify-email" || action == "login"):
|
||||
return s.emailAction(w, r, project, action)
|
||||
case r.Method == http.MethodPost && action == "exchange":
|
||||
return s.exchange(w, r, project)
|
||||
case r.Method == http.MethodGet && action == "me":
|
||||
@@ -146,14 +161,14 @@ func (s *service) start(w http.ResponseWriter, r *http.Request, project string)
|
||||
}
|
||||
state, browser, verifier := token(), token(), token()
|
||||
_, err := s.db.ExecContext(r.Context(), `
|
||||
INSERT INTO oauth_attempts VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes')
|
||||
INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at) VALUES ($1, $2, $3, $4, $5, now() + interval '10 minutes')
|
||||
`, digest(state), digest(browser), project, challenge, verifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := url.Values{
|
||||
"client_id": {s.clientID}, "redirect_uri": {s.redirectURI}, "response_type": {"code"},
|
||||
"scope": {"openid email"}, "state": {state}, "code_challenge": {digest(verifier)},
|
||||
"scope": {"openid email profile"}, "state": {state}, "code_challenge": {digest(verifier)},
|
||||
"code_challenge_method": {"S256"}, "prompt": {"select_account"},
|
||||
}
|
||||
http.SetCookie(w, oauthCookie(state, browser, 600))
|
||||
@@ -201,7 +216,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
|
||||
var project, challenge, verifier string
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
DELETE FROM oauth_attempts
|
||||
WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now()
|
||||
WHERE state_hash = $1 AND browser_hash = $2 AND expires_at > now() AND provider='google'
|
||||
RETURNING project, frontend_challenge, google_verifier
|
||||
`, digest(state), digest(browser.Value)).Scan(&project, &challenge, &verifier)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -240,6 +255,8 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
|
||||
Subject string `json:"sub"`
|
||||
Email *string `json:"email"`
|
||||
Verified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
if err := s.googleJSON(ctx, "GET", "https://openidconnect.googleapis.com/v1/userinfo", nil, tokens.AccessToken, &user); err != nil {
|
||||
return err
|
||||
@@ -251,16 +268,7 @@ func (s *service) callback(w http.ResponseWriter, r *http.Request) error {
|
||||
user.Email = nil
|
||||
}
|
||||
|
||||
grant := token()
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
WITH account AS (
|
||||
INSERT INTO project_accounts (project, id, google_subject, email) VALUES ($1, gen_random_uuid(), $2, $3)
|
||||
ON CONFLICT (project, google_subject) DO UPDATE SET email = EXCLUDED.email
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO login_grants
|
||||
SELECT $4, $1, id, $5, now() + interval '1 minute' FROM account
|
||||
`, project, user.Subject, user.Email, digest(grant), challenge)
|
||||
grant, err := s.identityGrant(ctx, project, "google", user.Subject, user.Email, user.Name, user.Picture, challenge)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -276,13 +284,13 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
|
||||
}
|
||||
session := token()
|
||||
result, err := s.db.ExecContext(r.Context(), `
|
||||
WITH grant AS (
|
||||
WITH redeemed_grant AS (
|
||||
DELETE FROM login_grants
|
||||
WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now()
|
||||
RETURNING account_id
|
||||
)
|
||||
INSERT INTO project_sessions
|
||||
SELECT $4, $2, account_id, now() + interval '8 hours' FROM grant
|
||||
SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant
|
||||
`, digest(code), project, digest(verifier), digest(session))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func emailAddress(value string) (string, bool) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
address, err := mail.ParseAddress(value)
|
||||
return value, err == nil && address.Address == value && len(value) <= 254
|
||||
}
|
||||
func emailBody(w http.ResponseWriter, r *http.Request, input any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
|
||||
_ = reject(w, 400, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes')
|
||||
ON CONFLICT(key_hash) DO UPDATE SET
|
||||
attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END,
|
||||
expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END
|
||||
RETURNING attempts
|
||||
`, digest(key)).Scan(&count)
|
||||
return count <= limit, err
|
||||
}
|
||||
|
||||
func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error {
|
||||
if action == "verify-email" {
|
||||
return s.verifyEmail(w, r, project)
|
||||
}
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !emailBody(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
email, valid := emailAddress(input.Email)
|
||||
if !valid || len(input.Password) < 12 || len(input.Password) > 72 {
|
||||
return reject(w, 400, "invalid_credentials_format")
|
||||
}
|
||||
limit := 10
|
||||
if action == "register" {
|
||||
if s.email == nil {
|
||||
return reject(w, 503, "email_not_configured")
|
||||
}
|
||||
limit = 3
|
||||
}
|
||||
allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
w.Header().Set("Retry-After", "900")
|
||||
return reject(w, 429, "rate_limited")
|
||||
}
|
||||
select {
|
||||
case s.passwordSlots <- struct{}{}:
|
||||
defer func() { <-s.passwordSlots }()
|
||||
default:
|
||||
w.Header().Set("Retry-After", "2")
|
||||
return reject(w, 429, "auth_busy")
|
||||
}
|
||||
if action == "register" {
|
||||
return s.registerEmail(w, r, project, email, input.Password)
|
||||
}
|
||||
var account, hash string
|
||||
err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash)
|
||||
missing := errors.Is(err, sql.ErrNoRows)
|
||||
if err != nil && !missing {
|
||||
return err
|
||||
}
|
||||
passwordHash := []byte(hash)
|
||||
if missing {
|
||||
passwordHash = s.dummyPassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing {
|
||||
return reject(w, 401, "invalid_credentials")
|
||||
}
|
||||
session := token()
|
||||
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil {
|
||||
return err
|
||||
}
|
||||
verification := token()
|
||||
result, err := tx.ExecContext(r.Context(), `
|
||||
INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at)
|
||||
SELECT $1,$2,$3,$4,now()+interval '30 minutes'
|
||||
WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2)
|
||||
ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at
|
||||
`, project, email, string(hash), digest(verification))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
destination := *s.projects[project]
|
||||
destination.Fragment = "verify_email=" + verification
|
||||
body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email."
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes')
|
||||
`, project, email, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 202, payload{"status": "verification_requested"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error {
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if !emailBody(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
if !tokenPattern.MatchString(input.Token) {
|
||||
return reject(w, 400, "invalid_verification")
|
||||
}
|
||||
allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return reject(w, 429, "rate_limited")
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var email, hash string
|
||||
err = tx.QueryRowContext(r.Context(), "SELECT email FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now()", project, digest(input.Token)).Scan(&email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return reject(w, 400, "invalid_verification")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", "email:"+project+":"+email); err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash
|
||||
`, project, digest(input.Token)).Scan(&email, &hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return reject(w, 400, "invalid_verification")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var account string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
WITH identity AS(SELECT gen_random_uuid() AS id)
|
||||
INSERT INTO project_accounts(project,id,email,username)
|
||||
SELECT $1,id,$2,
|
||||
left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7)
|
||||
|| '_' || replace(id::text,'-','') FROM identity RETURNING id::text
|
||||
`, project, email).Scan(&account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session := token()
|
||||
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrInvalidSession = errors.New("invalid session")
|
||||
|
||||
// Authenticate resolves only an unexpired session belonging to this project.
|
||||
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) {
|
||||
value, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||
if !ok || !tokenPattern.MatchString(value) {
|
||||
return "", ErrInvalidSession
|
||||
}
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT account_id::text FROM project_sessions
|
||||
WHERE project=$1 AND token_hash=$2 AND expires_at>now()
|
||||
`, project, digest(value)).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrInvalidSession
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const canvasLimit = 5 << 20
|
||||
|
||||
var canvasColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
var canvasID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||
var canvasIcon = regexp.MustCompile(`^[A-Z][A-Za-z0-9]{0,63}$`)
|
||||
|
||||
func canvasNumber(v any, low, high float64) bool {
|
||||
n, ok := v.(float64)
|
||||
return ok && !math.IsNaN(n) && !math.IsInf(n, 0) && n >= low && n <= high
|
||||
}
|
||||
func canvasString(v any, limit int) bool {
|
||||
s, ok := v.(string)
|
||||
return ok && utf8.RuneCountInString(s) <= limit
|
||||
}
|
||||
func canvasChoice(v any, choices ...string) bool {
|
||||
for _, s := range choices {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func canvasFields(m map[string]any, fields ...string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
for k := range m {
|
||||
found := false
|
||||
for _, f := range fields {
|
||||
if k == f {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func canvasImage(v any) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s, "https://") {
|
||||
u, e := url.Parse(s)
|
||||
return e == nil && u.Hostname() != "" && u.User == nil && len(s) < 2048
|
||||
}
|
||||
for _, mime := range []string{"png", "jpeg", "webp", "gif"} {
|
||||
prefix := "data:image/" + mime + ";base64,"
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
b, e := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, prefix))
|
||||
return e == nil && len(b) > 0 && len(b) <= 2<<20 && http.DetectContentType(b) == "image/"+mime
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
|
||||
var doc map[string]any
|
||||
if json.Unmarshal(raw, &doc) != nil || !canvasFields(doc, "schema_version", "nodes", "edges", "viewport") || doc["schema_version"] != float64(1) {
|
||||
return nil, false
|
||||
}
|
||||
nodes, ok := doc["nodes"].([]any)
|
||||
if !ok || len(nodes) > 500 {
|
||||
return nil, false
|
||||
}
|
||||
edges, ok := doc["edges"].([]any)
|
||||
if !ok || len(edges) > 2000 {
|
||||
return nil, false
|
||||
}
|
||||
viewport, ok := doc["viewport"].(map[string]any)
|
||||
if !ok || !canvasFields(viewport, "x", "y", "zoom") || !canvasNumber(viewport["x"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["y"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["zoom"], .1, 4) {
|
||||
return nil, false
|
||||
}
|
||||
kinds := map[string]string{}
|
||||
for _, v := range nodes {
|
||||
n, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if style, ok := n["style"].(map[string]any); ok {
|
||||
if _, exists := n["width"]; !exists {
|
||||
if v, exists := style["width"]; exists {
|
||||
n["width"] = v
|
||||
}
|
||||
}
|
||||
if _, exists := n["height"]; !exists {
|
||||
if v, exists := style["height"]; exists {
|
||||
n["height"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"selected", "dragging", "measured", "style"} {
|
||||
delete(n, key)
|
||||
}
|
||||
if !canvasFields(n, "id", "type", "position", "width", "height", "data") {
|
||||
return nil, false
|
||||
}
|
||||
id, ok := n["id"].(string)
|
||||
if !ok || !canvasID.MatchString(id) || kinds[id] != "" {
|
||||
return nil, false
|
||||
}
|
||||
p, ok := n["position"].(map[string]any)
|
||||
if !ok || !canvasFields(p, "x", "y") || !canvasNumber(p["x"], -1e6, 1e6) || !canvasNumber(p["y"], -1e6, 1e6) {
|
||||
return nil, false
|
||||
}
|
||||
_, w := n["width"]
|
||||
_, h := n["height"]
|
||||
if w != h || (w && (!canvasNumber(n["width"], 20, 3000) || !canvasNumber(n["height"], 20, 3000))) {
|
||||
return nil, false
|
||||
}
|
||||
d, ok := n["data"].(map[string]any)
|
||||
if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle") {
|
||||
return nil, false
|
||||
}
|
||||
kind, ok := d["kind"].(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
typ := "material"
|
||||
if kind == "plain-text" {
|
||||
typ = "plainText"
|
||||
} else if kind == "icon" {
|
||||
typ = "icon"
|
||||
} else if !canvasChoice(kind, "text", "image", "script") {
|
||||
return nil, false
|
||||
}
|
||||
if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) {
|
||||
return nil, false
|
||||
}
|
||||
kinds[id] = kind
|
||||
if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) {
|
||||
return nil, false
|
||||
}
|
||||
if f, exists := d["fontSize"]; exists && !canvasNumber(f, 8, 160) {
|
||||
return nil, false
|
||||
}
|
||||
if kind == "icon" {
|
||||
s, ok := d["icon"].(string)
|
||||
if !ok || !canvasIcon.MatchString(s) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if src, exists := d["src"]; (exists || kind == "image") && !canvasImage(src) {
|
||||
return nil, false
|
||||
}
|
||||
if value, exists := d["textStyle"]; exists {
|
||||
style, ok := value.(map[string]any)
|
||||
if !ok || !canvasFields(style, "color", "background", "bold", "italic", "underline", "align", "fontFamily", "lineHeight") {
|
||||
return nil, false
|
||||
}
|
||||
for k, v := range style {
|
||||
switch k {
|
||||
case "color", "background":
|
||||
s, ok := v.(string)
|
||||
if !ok || !(canvasColor.MatchString(s) || (k == "background" && s == "transparent")) {
|
||||
return nil, false
|
||||
}
|
||||
case "bold", "italic", "underline":
|
||||
if _, ok := v.(bool); !ok {
|
||||
return nil, false
|
||||
}
|
||||
case "align":
|
||||
if !canvasChoice(v, "left", "center", "right") {
|
||||
return nil, false
|
||||
}
|
||||
case "fontFamily":
|
||||
if !canvasChoice(v, "sans", "serif", "mono") {
|
||||
return nil, false
|
||||
}
|
||||
case "lineHeight":
|
||||
if !canvasNumber(v, 1, 3) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, v := range edges {
|
||||
e, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
delete(e, "selected")
|
||||
if !canvasFields(e, "id", "source", "target", "sourceHandle", "targetHandle", "type") {
|
||||
return nil, false
|
||||
}
|
||||
id, ok := e["id"].(string)
|
||||
if !ok || !canvasID.MatchString(id) || seen[id] {
|
||||
return nil, false
|
||||
}
|
||||
seen[id] = true
|
||||
for _, key := range []string{"source", "target"} {
|
||||
s, ok := e[key].(string)
|
||||
if !ok || !canvasChoice(kinds[s], "text", "image", "script") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sourceHandle", "targetHandle"} {
|
||||
if h, exists := e[key]; exists && h != nil && !canvasChoice(h, "", "top", "right", "bottom", "left") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if typ, exists := e["type"]; exists && !canvasChoice(typ, "default", "bezier") {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
out, err := json.Marshal(doc)
|
||||
return out, err == nil
|
||||
}
|
||||
|
||||
func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPatch {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
workspace, material := r.PathValue("workspace"), r.PathValue("material")
|
||||
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
var version int64
|
||||
var document json.RawMessage
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT canvas_version,COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material).Scan(&version, &document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
setVersion(w, version)
|
||||
reply(w, 200, response{"version": version, "document": document})
|
||||
return nil
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
expected, ok := expectedVersion(w, r)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasLimit))
|
||||
if err != nil {
|
||||
reject(w, 413, "canvas_too_large")
|
||||
return nil
|
||||
}
|
||||
var input struct {
|
||||
Document json.RawMessage `json:"document"`
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if !utf8.Valid(body) || decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF {
|
||||
reject(w, 400, "invalid_canvas")
|
||||
return nil
|
||||
}
|
||||
document, ok = normalizeCanvas(input.Document)
|
||||
if !ok {
|
||||
reject(w, 400, "invalid_canvas")
|
||||
return nil
|
||||
}
|
||||
if expected != version {
|
||||
reject(w, 412, "version_conflict")
|
||||
return nil
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=canvas_version+1 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material, document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
setVersion(w, version+1)
|
||||
reply(w, 200, response{"version": version + 1})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const canvasFixture = `{"schema_version":1,"nodes":[{"id":"a","type":"material","position":{"x":100,"y":200},"width":320,"height":220,"selected":true,"data":{"kind":"text","title":"灵感笔记","text":"正文","rotation":30,"fontSize":24,"textStyle":{"color":"#37352f","background":"transparent","bold":true,"italic":false,"underline":false,"align":"left","fontFamily":"sans","lineHeight":1.5}}},{"id":"b","type":"material","position":{"x":0,"y":0},"data":{"kind":"script","title":"Script","text":""}},{"id":"icon","type":"icon","position":{"x":500,"y":200},"width":80,"height":80,"data":{"kind":"icon","title":"火箭","text":"","icon":"Rocket","rotation":0}}],"edges":[{"id":"e","source":"a","target":"b","sourceHandle":"top","targetHandle":"bottom"}],"viewport":{"x":0,"y":0,"zoom":1}}`
|
||||
|
||||
func TestCanvasValidation(t *testing.T) {
|
||||
out, ok := normalizeCanvas(json.RawMessage(canvasFixture))
|
||||
if !ok || strings.Contains(string(out), "selected") {
|
||||
t.Fatal("valid snapshot rejected or UI state retained")
|
||||
}
|
||||
cases := [][2]string{{`"rotation":30`, `"rotation":360`}, {`"width":320`, `"width":19`}, {`"color":"#37352f"`, `"color":"red"`}, {`"bold":true`, `"bold":"true"`}, {`"target":"b"`, `"target":"missing"`}, {`"target":"b"`, `"target":"icon"`}, {`"zoom":1`, `"zoom":0`}, {`"sourceHandle":"top"`, `"sourceHandle":"unknown"`}, {`"fontSize":24`, `"fontSize":161`}, {`"lineHeight":1.5`, `"lineHeight":4`}, {`"id":"b"`, `"id":"a"`}, {`"schema_version":1`, `"schema_version":2`}}
|
||||
for _, c := range cases {
|
||||
t.Run(c[1], func(t *testing.T) {
|
||||
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(canvasFixture, c[0], c[1], 1))); ok {
|
||||
t.Fatal("accepted invalid snapshot")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, bad := range []string{`null`, `{}`, `{"schema_version":1,"nodes":null,"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`} {
|
||||
if _, ok := normalizeCanvas(json.RawMessage(bad)); ok {
|
||||
t.Fatal("accepted missing document fields")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestSlugValidation(t *testing.T) {
|
||||
for _, v := range []string{"my-board", "workspace-2026"} {
|
||||
if !validSlug(&v) {
|
||||
t.Fatal(v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"", "Bad Slug", "../board", strings.Repeat("a", 101)} {
|
||||
if validSlug(&v) {
|
||||
t.Fatal(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
db *sql.DB
|
||||
origin string
|
||||
callbacks map[string]string
|
||||
onboardingConfigs map[string]OnboardingConfig
|
||||
}
|
||||
|
||||
type response map[string]any
|
||||
|
||||
func reply(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
if value != nil {
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
}
|
||||
|
||||
func reject(w http.ResponseWriter, status int, code string) {
|
||||
reply(w, status, response{"error": code})
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
reject(w, 400, "invalid_request")
|
||||
return false
|
||||
}
|
||||
if decoder.Decode(new(any)) != io.EOF {
|
||||
reject(w, 400, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type endpoint func(http.ResponseWriter, *http.Request, string, string) error
|
||||
|
||||
func (s *service) protected(callbacks map[string]string, handle endpoint) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
project := r.PathValue("project")
|
||||
callback, exists := callbacks[project]
|
||||
if !exists {
|
||||
reject(w, 404, "not_found")
|
||||
return
|
||||
}
|
||||
frontend, _ := url.Parse(callback)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
if origin != frontend.Scheme+"://"+frontend.Host {
|
||||
reject(w, 403, "origin_denied")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Expose-Headers", "ETag")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match")
|
||||
reply(w, 204, nil)
|
||||
return
|
||||
}
|
||||
account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization"))
|
||||
if errors.Is(err, auth.ErrInvalidSession) {
|
||||
reject(w, 401, "invalid_session")
|
||||
return
|
||||
}
|
||||
if err == nil && r.PathValue("workspace") != "" {
|
||||
var id string
|
||||
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id)
|
||||
if err == nil {
|
||||
r.SetPathValue("workspace", id)
|
||||
}
|
||||
}
|
||||
if err == nil && r.PathValue("material") != "" {
|
||||
var id string
|
||||
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM materials WHERE project=$1 AND workspace_id=$2 AND (id::text=$3 OR slug=$3)", project, r.PathValue("workspace"), r.PathValue("material")).Scan(&id)
|
||||
if err == nil {
|
||||
r.SetPathValue("material", id)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = handle(w, r, project, account)
|
||||
}
|
||||
if err != nil {
|
||||
var pg *pgconn.PgError
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
reject(w, 404, "not_found")
|
||||
case errors.As(err, &pg) && (pg.Code == "22P02" || pg.Code == "23503" || pg.Code == "23514"):
|
||||
reject(w, 400, "invalid_reference")
|
||||
case errors.As(err, &pg) && pg.Code == "23505":
|
||||
reject(w, 409, "already_exists")
|
||||
default:
|
||||
log.Printf("platform request failed (%T)", err)
|
||||
reject(w, 503, "service_unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) {
|
||||
s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs}
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/open", s.protected(callbacks, s.openMaterial))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags", s.protected(callbacks, s.tags))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/tags", s.protected(callbacks, s.materialTags))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants", s.protected(callbacks, s.materialGrants))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}/clicks", s.protected(callbacks, s.shareClicks))
|
||||
mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding))
|
||||
mux.HandleFunc("/s/{token}", s.shortLink)
|
||||
mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares", s.protected(callbacks, s.shares))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}", s.protected(callbacks, s.revokeShare))
|
||||
mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members/{account}", s.protected(callbacks, s.member))
|
||||
mux.HandleFunc("/projects/{project}/profile", s.protected(callbacks, s.profile))
|
||||
}
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,39}$`)
|
||||
|
||||
func (s *service) profile(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method == http.MethodPatch {
|
||||
var input struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if !usernamePattern.MatchString(input.Username) || len(input.DisplayName) > 160 {
|
||||
reject(w, 400, "invalid_profile")
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(r.Context(), `
|
||||
UPDATE project_accounts SET username=$3, display_name=$4, updated_at=now()
|
||||
WHERE project=$1 AND id=$2
|
||||
`, project, account, input.Username, input.DisplayName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if r.Method != http.MethodGet {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
var username, email, avatar *string
|
||||
var name string
|
||||
err := s.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name, email, avatar_url FROM project_accounts WHERE project=$1 AND id=$2
|
||||
`, project, account).Scan(&username, &name, &email, &avatar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, response{"id": account, "project": project, "username": username, "display_name": name, "email": email, "avatar_url": avatar})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func TestOnboardingAndSharing(t *testing.T) {
|
||||
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires an isolated integration database")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
var raw [12]byte
|
||||
rand.Read(raw[:])
|
||||
project := "test-" + hex.EncodeToString(raw[:])
|
||||
accounts := []string{}
|
||||
tokens := []string{}
|
||||
for range 3 {
|
||||
var id string
|
||||
err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts = append(accounts, id)
|
||||
var value [32]byte
|
||||
rand.Read(value[:])
|
||||
token := base64.RawURLEncoding.EncodeToString(value[:])
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
_, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project)
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project)
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project)
|
||||
}()
|
||||
callbacks := map[string]string{project: "https://app.example.com/auth/callback"}
|
||||
mux := http.NewServeMux()
|
||||
authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux.Handle("/", authHandler)
|
||||
Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{
|
||||
project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}},
|
||||
})
|
||||
call := func(method, path, token string, body any, status int, versions ...string) response {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(method, path, bytes.NewReader(encoded))
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if len(versions) > 0 {
|
||||
r.Header.Set("If-Match", versions[0])
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
if w.Code != status {
|
||||
t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status)
|
||||
}
|
||||
out := response{}
|
||||
if w.Body.Len() > 0 && w.Code != 302 {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
base := "/projects/" + project
|
||||
call("GET", base+"/workspaces", "", nil, 401)
|
||||
call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400)
|
||||
first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201)
|
||||
workspace := first["result"].(map[string]any)["workspace_id"].(string)
|
||||
retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200)
|
||||
if retry["result"].(map[string]any)["workspace_id"] != workspace {
|
||||
t.Fatal("onboarding created a second workspace")
|
||||
}
|
||||
workspacePath := base + "/workspaces/" + workspace
|
||||
share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201)
|
||||
token := share["token"].(string)
|
||||
call("GET", "/s/"+token, "", nil, 302)
|
||||
clicks := call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[0], nil, 200)
|
||||
if len(clicks["items"].([]any)) != 1 {
|
||||
t.Fatal("click event API did not return the redirect")
|
||||
}
|
||||
call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[2], nil, 404)
|
||||
call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200)
|
||||
call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200)
|
||||
call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410)
|
||||
call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403)
|
||||
call("GET", workspacePath, tokens[2], nil, 404)
|
||||
call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409)
|
||||
call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204)
|
||||
call("GET", "/s/"+token, "", nil, 404)
|
||||
var material string
|
||||
err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201)
|
||||
call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200)
|
||||
call("GET", workspacePath, tokens[2], nil, 404)
|
||||
articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{
|
||||
"id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}},
|
||||
}}}}
|
||||
created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201)
|
||||
articleID := created["id"].(string)
|
||||
text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201)
|
||||
textPath := workspacePath + "/materials/" + text["id"].(string)
|
||||
call("GET", textPath, tokens[2], nil, 404)
|
||||
call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
|
||||
call("GET", textPath, tokens[2], nil, 200)
|
||||
changed := response{"title": "Beta", "content": response{"text": "Changed"}}
|
||||
call("PATCH", textPath, tokens[2], changed, 403, `"1"`)
|
||||
call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204)
|
||||
call("PATCH", textPath, tokens[2], changed, 428)
|
||||
call("PATCH", textPath, tokens[2], changed, 204, `"1"`)
|
||||
call("PATCH", textPath, tokens[0], changed, 412, `"1"`)
|
||||
call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400)
|
||||
tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201)
|
||||
call("GET", workspacePath+"/tags", tokens[2], nil, 404)
|
||||
call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`)
|
||||
call("POST", textPath+"/open", tokens[2], nil, 204)
|
||||
filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200)
|
||||
if len(filtered["items"].([]any)) != 1 {
|
||||
t.Fatal("tag-filtered material list leaked inaccessible materials")
|
||||
}
|
||||
board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201)
|
||||
boardPath := workspacePath + "/materials/" + board["id"].(string)
|
||||
|
||||
canvasPath := boardPath + "/canvas"
|
||||
empty := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
if empty["version"] != float64(1) {
|
||||
t.Fatal("old board not initialized at version 1")
|
||||
}
|
||||
snapshot := response{"document": json.RawMessage(canvasFixture)}
|
||||
call("PATCH", canvasPath, "", snapshot, 401, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[0], snapshot, 428)
|
||||
call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`)
|
||||
saved := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
expected, _ := normalizeCanvas(json.RawMessage(canvasFixture))
|
||||
expectedJSON, _ := json.Marshal(json.RawMessage(expected))
|
||||
actualJSON, _ := json.Marshal(saved["document"])
|
||||
if !bytes.Equal(expectedJSON, actualJSON) {
|
||||
t.Fatal("snapshot failed round trip")
|
||||
}
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`)
|
||||
call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`)
|
||||
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) {
|
||||
t.Fatal("rename changed canvas version")
|
||||
}
|
||||
slugPath := workspacePath + "/materials/my-canvas"
|
||||
if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" {
|
||||
t.Fatal("slug lookup failed")
|
||||
}
|
||||
// A PNG at the local upload limit must survive JSON and database storage.
|
||||
var imageDoc map[string]any
|
||||
json.Unmarshal([]byte(canvasFixture), &imageDoc)
|
||||
imageBytes := make([]byte, 2<<20)
|
||||
copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10})
|
||||
data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)
|
||||
data["kind"] = "image"
|
||||
data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`)
|
||||
restored := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] {
|
||||
t.Fatal("large image changed")
|
||||
}
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`)
|
||||
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) {
|
||||
t.Fatal("rejected snapshot changed version")
|
||||
}
|
||||
// Restore the material version expected by the pre-existing layout checks below.
|
||||
|
||||
// Exercise two independent HTTP transactions against the same expected version.
|
||||
encodedSnapshot, _ := json.Marshal(snapshot)
|
||||
statuses := make(chan int, 2)
|
||||
start := make(chan struct{})
|
||||
for range 2 {
|
||||
go func() {
|
||||
<-start
|
||||
request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot))
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("If-Match", `"3"`)
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
statuses <- recorder.Code
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
one, two := <-statuses, <-statuses
|
||||
if !((one == 200 && two == 412) || (one == 412 && two == 200)) {
|
||||
t.Fatalf("concurrent writes: %d %d", one, two)
|
||||
}
|
||||
if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" {
|
||||
t.Fatal("canvas save changed title")
|
||||
}
|
||||
call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204)
|
||||
call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200)
|
||||
call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403)
|
||||
call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409)
|
||||
call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204)
|
||||
if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" {
|
||||
t.Fatal("rename changed slug")
|
||||
}
|
||||
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`)
|
||||
call("GET", boardPath+"/layouts", tokens[1], nil, 200)
|
||||
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
|
||||
hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200)
|
||||
if len(hidden["items"].([]any)) != 0 {
|
||||
t.Fatal("board share disclosed private child material")
|
||||
}
|
||||
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`)
|
||||
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`)
|
||||
call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204)
|
||||
call("GET", textPath, tokens[2], nil, 404)
|
||||
call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204)
|
||||
call("PATCH", textPath, tokens[0], changed, 412, `"3"`)
|
||||
|
||||
imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}}
|
||||
call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201)
|
||||
call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{
|
||||
response{"kind": "text", "text": "Caption"},
|
||||
response{"kind": "article", "article": articleContent["article"]},
|
||||
response{"kind": "image", "image": imageContent["image"]},
|
||||
}}}, 201)
|
||||
for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} {
|
||||
call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200)
|
||||
}
|
||||
images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200)
|
||||
if len(images["items"].([]any)) != 1 {
|
||||
t.Fatal("kind/title filter returned incorrect materials")
|
||||
}
|
||||
|
||||
email := "fixture@example.invalid"
|
||||
password := "a-long-fixture-password"
|
||||
call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202)
|
||||
call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401)
|
||||
var body string
|
||||
if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body)
|
||||
if len(match) != 2 {
|
||||
t.Fatal("missing email verification link")
|
||||
}
|
||||
verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201)
|
||||
call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400)
|
||||
call("GET", base+"/profile", verified["access_token"].(string), nil, 200)
|
||||
call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401)
|
||||
loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200)
|
||||
call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204)
|
||||
call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401)
|
||||
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("material grant missing %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("click accounting incorrect %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user