Foundry 用户认证、workspace 分享与结构化物料 API #5

Merged
Zayden-Jung merged 16 commits from codex/foundry-platform into main 2026-11-05 12:30:25 +00:00
28 changed files with 3503 additions and 21 deletions
+11
View File
@@ -5,3 +5,14 @@ GOOGLE_CLIENT_SECRET=replace-me
FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}'
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
+53
View File
@@ -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
+4
View File
@@ -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
View File
@@ -1,2 +1,3 @@
.env
/bin/
/api-docs/
+17 -1
View File
@@ -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")),
+224
View File
@@ -0,0 +1,224 @@
BEGIN;
-- Keep existing Google accounts and sessions valid during the expansion.
ALTER TABLE project_accounts ALTER COLUMN google_subject DROP NOT NULL;
ALTER TABLE project_accounts
ADD COLUMN username text,
ADD COLUMN display_name text NOT NULL DEFAULT '',
ADD COLUMN avatar_url text,
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
CREATE UNIQUE INDEX project_account_username ON project_accounts(project, lower(username))
WHERE username IS NOT NULL;
ALTER TABLE oauth_attempts
ADD COLUMN provider text NOT NULL DEFAULT 'google' CHECK (provider IN ('google','slack')),
ADD COLUMN oidc_nonce text;
CREATE TABLE account_identities (
project text NOT NULL,
provider text NOT NULL CHECK (provider IN ('google', 'slack')),
subject text NOT NULL,
account_id uuid NOT NULL,
PRIMARY KEY (project, provider, subject),
UNIQUE (project, provider, account_id),
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
INSERT INTO account_identities(project, provider, subject, account_id)
SELECT project, 'google', google_subject, id FROM project_accounts WHERE google_subject IS NOT NULL;
CREATE TABLE email_credentials (
project text NOT NULL,
email text NOT NULL CHECK (email = lower(email)),
account_id uuid NOT NULL,
password_hash text NOT NULL,
verified_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, email),
UNIQUE (project, account_id),
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
-- Registration is promoted to an account only after the single-use email check.
CREATE TABLE email_registrations (
project text NOT NULL,
email text NOT NULL,
password_hash text NOT NULL,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, email)
);
CREATE TABLE email_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project text NOT NULL,
recipient text NOT NULL,
subject text NOT NULL,
body text NOT NULL,
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
lease_until timestamptz,
lease_id uuid,
expires_at timestamptz NOT NULL,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX email_outbox_pending ON email_outbox(available_at) WHERE sent_at IS NULL;
CREATE TABLE auth_rate_limits (
key_hash text PRIMARY KEY,
attempts integer NOT NULL CHECK (attempts > 0),
expires_at timestamptz NOT NULL
);
CREATE TABLE onboarding_responses (
project text NOT NULL,
account_id uuid NOT NULL,
answers jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(answers) = 'object'),
result jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(result) = 'object'),
completed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, account_id),
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
CREATE TABLE workspaces (
project text NOT NULL,
id uuid NOT NULL DEFAULT gen_random_uuid(),
name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 160),
owner_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, id),
FOREIGN KEY (project, owner_id) REFERENCES project_accounts
);
CREATE TABLE workspace_members (
project text NOT NULL,
workspace_id uuid NOT NULL,
account_id uuid NOT NULL,
role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, workspace_id, account_id),
FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE,
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
CREATE INDEX workspace_members_account ON workspace_members(project, account_id);
CREATE TABLE materials (
project text NOT NULL,
workspace_id uuid NOT NULL,
id uuid NOT NULL DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('text', 'article', 'image', 'board', 'card')),
title text NOT NULL CHECK (length(title) <= 300),
description text NOT NULL DEFAULT '' CHECK (length(description) <= 10000),
content jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(content) = 'object'),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
modified_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, workspace_id, id),
FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE,
FOREIGN KEY (project, created_by) REFERENCES project_accounts
);
CREATE INDEX material_modified ON materials(project, workspace_id, modified_at DESC, id);
CREATE INDEX material_created ON materials(project, workspace_id, created_at DESC, id);
CREATE INDEX material_alphabetical ON materials(project, workspace_id, lower(title), id);
CREATE TABLE material_opens (
project text NOT NULL,
workspace_id uuid NOT NULL,
material_id uuid NOT NULL,
account_id uuid NOT NULL,
opened_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, workspace_id, material_id, account_id),
FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE,
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
CREATE INDEX material_opens_account ON material_opens(project, workspace_id, account_id, opened_at DESC);
CREATE TABLE tags (
project text NOT NULL,
workspace_id uuid NOT NULL,
id uuid NOT NULL DEFAULT gen_random_uuid(),
name text NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 80),
color text NOT NULL DEFAULT '',
PRIMARY KEY (project, workspace_id, id),
FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE
);
CREATE UNIQUE INDEX tag_name ON tags(project, workspace_id, lower(name));
CREATE TABLE material_tags (
project text NOT NULL,
workspace_id uuid NOT NULL,
material_id uuid NOT NULL,
tag_id uuid NOT NULL,
PRIMARY KEY (project, workspace_id, material_id, tag_id),
FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE,
FOREIGN KEY (project, workspace_id, tag_id) REFERENCES tags ON DELETE CASCADE
);
CREATE INDEX material_tags_filter ON material_tags(project, workspace_id, tag_id, material_id);
-- A board placement references a reusable material, independently of its content.
CREATE TABLE board_layouts (
project text NOT NULL,
workspace_id uuid NOT NULL,
board_id uuid NOT NULL,
id uuid NOT NULL DEFAULT gen_random_uuid(),
material_id uuid NOT NULL,
x double precision NOT NULL CHECK (x BETWEEN -1000000 AND 1000000),
y double precision NOT NULL CHECK (y BETWEEN -1000000 AND 1000000),
width double precision NOT NULL CHECK (width BETWEEN 1 AND 100000),
height double precision NOT NULL CHECK (height BETWEEN 1 AND 100000),
z_index integer NOT NULL DEFAULT 0,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
PRIMARY KEY (project, workspace_id, board_id, id),
FOREIGN KEY (project, workspace_id, board_id) REFERENCES materials ON DELETE CASCADE,
FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE,
CHECK (board_id <> material_id)
);
CREATE TABLE material_grants (
project text NOT NULL,
workspace_id uuid NOT NULL,
material_id uuid NOT NULL,
account_id uuid NOT NULL,
role text NOT NULL CHECK (role IN ('editor', 'viewer')),
PRIMARY KEY (project, workspace_id, material_id, account_id),
FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE,
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
CREATE TABLE share_links (
project text NOT NULL,
workspace_id uuid NOT NULL,
id uuid NOT NULL DEFAULT gen_random_uuid(),
material_id uuid,
token_hash text NOT NULL UNIQUE,
role text NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')),
created_by uuid NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
max_uses integer CHECK (max_uses BETWEEN 1 AND 100000),
use_count integer NOT NULL DEFAULT 0 CHECK (use_count >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, workspace_id, id),
FOREIGN KEY (project, workspace_id) REFERENCES workspaces ON DELETE CASCADE,
FOREIGN KEY (project, workspace_id, material_id) REFERENCES materials ON DELETE CASCADE,
FOREIGN KEY (project, created_by) REFERENCES project_accounts,
CHECK (material_id IS NULL OR role <> 'admin')
);
CREATE TABLE share_redemptions (
project text NOT NULL,
workspace_id uuid NOT NULL,
link_id uuid NOT NULL,
account_id uuid NOT NULL,
redeemed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (project, workspace_id, link_id, account_id),
FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE,
FOREIGN KEY (project, account_id) REFERENCES project_accounts ON DELETE CASCADE
);
CREATE TABLE link_clicks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project text NOT NULL,
workspace_id uuid NOT NULL,
link_id uuid NOT NULL,
clicked_at timestamptz NOT NULL DEFAULT now(),
-- Store the referring origin only, without URL paths, queries or user tokens.
referrer_origin text NOT NULL DEFAULT '',
FOREIGN KEY (project, workspace_id, link_id) REFERENCES share_links ON DELETE CASCADE
);
CREATE INDEX link_clicks_summary ON link_clicks(project, workspace_id, link_id, clicked_at);
COMMIT;
+70
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Apply checked migrations atomically from the trusted deployment runner."""
import argparse
import hashlib
from pathlib import Path
import re
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--container", default="endoleap-dev-postgres-1")
parser.add_argument("--database", default="foundry_auth")
parser.add_argument("--role", default="foundry")
args = parser.parse_args()
for value in (args.database, args.role):
if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", value):
raise SystemExit("Invalid deployment database or role")
migrations = sorted((Path(__file__).resolve().parent.parent / "db").glob("[0-9]*.sql"))
if not migrations:
raise SystemExit("No migrations found")
script = [
"\\set ON_ERROR_STOP on",
"BEGIN;",
"SET LOCAL lock_timeout='30s';",
"SELECT pg_advisory_xact_lock(731920164);",
"CREATE TABLE IF NOT EXISTS foundry_schema_migrations "
"(name text PRIMARY KEY, checksum text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now());",
]
tables = set()
for index, path in enumerate(migrations):
source = path.read_text()
name = path.name
if not re.fullmatch(r"[0-9]+-[a-z0-9-]+\.sql", name):
raise SystemExit("Invalid migration name")
checksum = hashlib.sha256(source.encode()).hexdigest()
tables.update(re.findall(r"CREATE TABLE ([a-z_]+)", source))
# The first deployed version predates migration tracking.
if index == 0:
script.extend([
"SELECT bool_and(to_regclass('public.' || name) IS NOT NULL) AS baseline FROM "
"unnest(ARRAY['project_accounts','oauth_attempts','login_grants','project_sessions']) name \\gset",
"\\if :baseline",
f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}') ON CONFLICT DO NOTHING;",
"\\endif",
])
script.extend([
f"SELECT EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}') AS applied, "
f"EXISTS(SELECT 1 FROM foundry_schema_migrations WHERE name='{name}' AND checksum='{checksum}') AS valid \\gset",
"\\if :applied",
"\\if :valid",
"\\else",
"\\echo Migration checksum mismatch",
"\\quit 1",
"\\endif",
"\\else",
])
source = source.strip()
if not source.startswith("BEGIN;") or not source.endswith("COMMIT;"):
raise SystemExit("Migration must have explicit transaction boundaries")
script.append(source[len("BEGIN;"):-len("COMMIT;")])
script.extend([
f"INSERT INTO foundry_schema_migrations(name,checksum) VALUES('{name}','{checksum}');",
"\\endif",
])
for table in sorted(tables):
script.append(f'GRANT SELECT,INSERT,UPDATE,DELETE ON TABLE "{table}" TO "{args.role}";')
script.extend([
f'GRANT USAGE,SELECT ON SEQUENCE link_clicks_id_seq TO "{args.role}";',
"COMMIT;",
])
subprocess.run(
["docker", "exec", "-i", args.container, "sh", "-c",
'exec psql -X -U "${POSTGRES_USER:-postgres}" -d "$1" -v ON_ERROR_STOP=1', "sh", args.database],
input=("\n".join(script) + "\n").encode(), check=True,
)
print("Schema migrations and application grants verified.")
+21 -2
View File
@@ -40,10 +40,29 @@ export FOUNDRY_PROJECTS
test -s "$foundry/database.env"
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,9 +83,9 @@ 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=$release/foundry,target=/opt/foundry,readonly" \
+26 -18
View File
@@ -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
+217
View File
@@ -0,0 +1,217 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"net/mail"
"strings"
"golang.org/x/crypto/bcrypt"
)
func emailAddress(value string) (string, bool) {
value = strings.ToLower(strings.TrimSpace(value))
address, err := mail.ParseAddress(value)
return value, err == nil && address.Address == value && len(value) <= 254
}
func emailBody(w http.ResponseWriter, r *http.Request, input any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
decoder.DisallowUnknownFields()
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
_ = reject(w, 400, "invalid_request")
return false
}
return true
}
func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `
INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes')
ON CONFLICT(key_hash) DO UPDATE SET
attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END,
expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END
RETURNING attempts
`, digest(key)).Scan(&count)
return count <= limit, err
}
func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error {
if action == "verify-email" {
return s.verifyEmail(w, r, project)
}
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if !emailBody(w, r, &input) {
return nil
}
email, valid := emailAddress(input.Email)
if !valid || len(input.Password) < 12 || len(input.Password) > 72 {
return reject(w, 400, "invalid_credentials_format")
}
limit := 10
if action == "register" {
if s.email == nil {
return reject(w, 503, "email_not_configured")
}
limit = 3
}
allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit)
if err != nil {
return err
}
if !allowed {
w.Header().Set("Retry-After", "900")
return reject(w, 429, "rate_limited")
}
select {
case s.passwordSlots <- struct{}{}:
defer func() { <-s.passwordSlots }()
default:
w.Header().Set("Retry-After", "2")
return reject(w, 429, "auth_busy")
}
if action == "register" {
return s.registerEmail(w, r, project, email, input.Password)
}
var account, hash string
err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash)
missing := errors.Is(err, sql.ErrNoRows)
if err != nil && !missing {
return err
}
passwordHash := []byte(hash)
if missing {
passwordHash = s.dummyPassword
}
if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing {
return reject(w, 401, "invalid_credentials")
}
session := token()
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions 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
}
+72
View File
@@ -0,0 +1,72 @@
package auth
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"net/http/httptest"
"net/url"
"os"
"regexp"
"strings"
"testing"
)
func TestConcurrentRegistrationAndVerification(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var raw [12]byte
rand.Read(raw[:])
project := "email-" + hex.EncodeToString(raw[:])
frontend, _ := url.Parse("https://app.example.com/auth/callback")
s := &service{db: db, projects: map[string]*url.URL{project: frontend}}
defer func() {
_, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project)
_, _ = db.Exec("DELETE FROM email_registrations WHERE project=$1", project)
_, _ = db.Exec("DELETE FROM email_outbox WHERE project=$1", project)
}()
request := httptest.NewRequest("POST", "https://api.example.com/register", nil)
out := httptest.NewRecorder()
if err = s.registerEmail(out, request, project, "fixture@example.invalid", "long-fixture-password"); err != nil {
t.Fatal(err)
}
var body string
if err = db.QueryRow("SELECT body FROM email_outbox WHERE project=$1", project).Scan(&body); err != nil {
t.Fatal(err)
}
match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body)
if len(match) != 2 {
t.Fatal("missing verification link")
}
done := make(chan error, 2)
go func() {
r := httptest.NewRequest("POST", "https://api.example.com/register", nil)
done <- s.registerEmail(httptest.NewRecorder(), r, project, "fixture@example.invalid", "another-fixture-password")
}()
go func() {
r := httptest.NewRequest("POST", "https://api.example.com/verify-email", strings.NewReader(`{"token":"`+match[1]+`"}`))
done <- s.verifyEmail(httptest.NewRecorder(), r, project)
}()
for range 2 {
if err := <-done; err != nil {
t.Fatal(err)
}
}
var accounts, pending int
if err = db.QueryRow("SELECT count(*) FROM email_credentials WHERE project=$1", project).Scan(&accounts); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT count(*) FROM email_registrations WHERE project=$1", project).Scan(&pending); err != nil {
t.Fatal(err)
}
if accounts+pending != 1 {
t.Fatalf("concurrent registration left accounts=%d pending=%d", accounts, pending)
}
}
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"context"
"database/sql"
"errors"
"net/url"
"strings"
)
func (s *service) identityGrant(ctx context.Context, project, provider, subject string, email *string, name, picture, challenge string) (string, error) {
name = strings.TrimSpace(name)
if len(name) > 160 {
name = ""
}
if u, err := url.Parse(picture); err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(picture) > 2048 {
picture = ""
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
// Serialize first registration for the same provider identity.
if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", project+":"+provider+":"+subject); err != nil {
return "", err
}
var account string
err = tx.QueryRowContext(ctx, "SELECT account_id::text FROM account_identities WHERE project=$1 AND provider=$2 AND subject=$3", project, provider, subject).Scan(&account)
if errors.Is(err, sql.ErrNoRows) {
var googleSubject *string
if provider == "google" {
googleSubject = &subject
}
err = tx.QueryRowContext(ctx, `
WITH identity AS(SELECT gen_random_uuid() AS id)
INSERT INTO project_accounts(project,id,google_subject,email,username,display_name,avatar_url)
SELECT $1,id,$2,$3,
left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-',''),
$4,NULLIF($5,'') FROM identity RETURNING id::text
`, project, googleSubject, email, name, picture).Scan(&account)
if err != nil {
return "", err
}
_, err = tx.ExecContext(ctx, "INSERT INTO account_identities(project,provider,subject,account_id) VALUES($1,$2,$3,$4)", project, provider, subject, account)
} else if err == nil {
_, err = tx.ExecContext(ctx, `
UPDATE project_accounts SET email=COALESCE($3,email),
display_name=CASE WHEN display_name='' THEN $4 ELSE display_name END,
avatar_url=COALESCE(avatar_url,NULLIF($5,'')),
username=COALESCE(username,left(COALESCE(NULLIF(regexp_replace(lower($4),'[^a-z0-9]','','g'),''),'user'),7)||'_'||replace(id::text,'-','')),
updated_at=now() WHERE project=$1 AND id=$2
`, project, account, email, name, picture)
}
if err != nil {
return "", err
}
grant := token()
_, err = tx.ExecContext(ctx, "INSERT INTO login_grants VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(grant), project, account, challenge)
if err != nil {
return "", err
}
if err = tx.Commit(); err != nil {
return "", err
}
return grant, nil
}
+113
View File
@@ -0,0 +1,113 @@
package auth
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"log"
"net"
"net/mail"
"net/smtp"
"os"
"strings"
"time"
)
type EmailConfig struct {
Host, Port, Username, Password, From string
}
func EmailConfigFromEnv() (*EmailConfig, error) {
c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")}
if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" {
return nil, nil
}
address, err := mail.ParseAddress(c.From)
if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") {
return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required")
}
return c, nil
}
func (c *EmailConfig) send(recipient, subject, body string) error {
if strings.ContainsAny(recipient+subject, "\r\n") {
return errors.New("invalid mail headers")
}
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12})
if err != nil {
return err
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(30 * time.Second))
client, err := smtp.NewClient(connection, c.Host)
if err != nil {
return err
}
defer client.Close()
if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
return err
}
if err = client.Mail(c.From); err != nil {
return err
}
if err = client.Rcpt(recipient); err != nil {
return err
}
writer, err := client.Data()
if err != nil {
return err
}
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body)
if _, err = writer.Write([]byte(message)); err != nil {
return err
}
if err = writer.Close(); err != nil {
return err
}
return client.Quit()
}
func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) {
if c == nil {
return
}
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("email delivery failed (%T)", err)
}
}
}
}
func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error {
var id, lease, recipient, subject, body string
var attempts int
err := db.QueryRowContext(ctx, `
UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1
WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10
AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now())
ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id::text,lease_id::text,recipient,subject,body,attempts
`).Scan(&id, &lease, &recipient, &subject, &body, &attempts)
if err != nil {
return err
}
sendErr := c.send(recipient, subject, body)
if sendErr == nil {
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease)
return err
}
delay := 30 * (1 << min(attempts, 7))
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay)
if err != nil {
return err
}
return sendErr
}
+27
View File
@@ -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
}
+226
View File
@@ -0,0 +1,226 @@
package auth
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
type SlackConfig struct {
ClientID, Secret, RedirectURI string
mu sync.Mutex
keys map[string]*rsa.PublicKey
fetchedAt time.Time
}
func SlackConfigFromEnv(redirect string) (*SlackConfig, error) {
id, secret := os.Getenv("SLACK_CLIENT_ID"), os.Getenv("SLACK_CLIENT_SECRET")
if id == "" && secret == "" {
return nil, nil
}
if id == "" || secret == "" || strings.ContainsAny(id+secret, "\r\n") {
return nil, errors.New("Slack requires client ID and secret")
}
return &SlackConfig{ClientID: id, Secret: secret, RedirectURI: redirect}, nil
}
type slackClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Expires int64 `json:"exp"`
Issued int64 `json:"iat"`
Nonce string `json:"nonce"`
Team string `json:"https://slack.com/team_id"`
Email *string `json:"email"`
Verified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (s *service) slackKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
c := s.slack
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.fetchedAt) < time.Minute {
if key := c.keys[kid]; key != nil {
return key, nil
}
return nil, errors.New("unknown Slack signing key")
}
var document struct {
Keys []struct {
Type string `json:"kty"`
ID string `json:"kid"`
Algorithm string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
}
if err := s.googleJSON(ctx, "GET", "https://slack.com/openid/connect/keys", nil, "", &document); err != nil {
return nil, err
}
keys := map[string]*rsa.PublicKey{}
for _, key := range document.Keys {
n, nErr := base64.RawURLEncoding.DecodeString(key.N)
e, eErr := base64.RawURLEncoding.DecodeString(key.E)
if key.Type != "RSA" || (key.Algorithm != "" && key.Algorithm != "RS256") || (key.Use != "" && key.Use != "sig") || nErr != nil || eErr != nil || len(e) > 4 {
continue
}
modulus := new(big.Int).SetBytes(n)
exponent := new(big.Int).SetBytes(e).Int64()
if modulus.BitLen() < 2048 || modulus.BitLen() > 8192 || exponent < 3 || exponent > 2147483647 || exponent%2 == 0 {
continue
}
keys[key.ID] = &rsa.PublicKey{N: modulus, E: int(exponent)}
}
c.keys, c.fetchedAt = keys, time.Now()
if key := keys[kid]; key != nil {
return key, nil
}
return nil, errors.New("unknown Slack signing key")
}
func (s *service) verifySlack(ctx context.Context, value, nonce string) (slackClaims, error) {
var claims slackClaims
invalid := errors.New("invalid Slack identity token")
if len(value) > 32768 {
return claims, invalid
}
parts := strings.Split(value, ".")
if len(parts) != 3 {
return claims, invalid
}
header, err := base64.RawURLEncoding.DecodeString(parts[0])
var h struct {
Algorithm string `json:"alg"`
ID string `json:"kid"`
}
if err != nil || json.Unmarshal(header, &h) != nil || h.Algorithm != "RS256" || h.ID == "" {
return claims, invalid
}
key, err := s.slackKey(ctx, h.ID)
if err != nil {
return claims, err
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err != nil || rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature) != nil {
return claims, invalid
}
body, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || json.Unmarshal(body, &claims) != nil {
return claims, invalid
}
now := time.Now().Unix()
if claims.Issuer != "https://slack.com" || claims.Audience != s.slack.ClientID || claims.Expires <= now ||
claims.Issued <= 0 || claims.Issued > now+60 || claims.Expires <= claims.Issued || claims.Nonce != nonce ||
claims.Subject == "" || len(claims.Subject) > 128 || claims.Team == "" || len(claims.Team) > 128 ||
strings.ContainsAny(claims.Subject+claims.Team, ":\r\n") {
return claims, invalid
}
if !claims.Verified {
claims.Email = nil
}
return claims, nil
}
func (s *service) startSlack(w http.ResponseWriter, r *http.Request, project string) error {
if s.slack == nil {
return reject(w, 503, "slack_not_configured")
}
challenge := r.URL.Query().Get("challenge")
if !tokenPattern.MatchString(challenge) {
return reject(w, 400, "invalid_challenge")
}
state, browser, nonce := token(), token(), token()
_, err := s.db.ExecContext(r.Context(), `
INSERT INTO oauth_attempts(state_hash,browser_hash,project,frontend_challenge,google_verifier,expires_at,provider,oidc_nonce)
VALUES($1,$2,$3,$4,'',now()+interval '10 minutes','slack',$5)
`, digest(state), digest(browser), project, challenge, nonce)
if err != nil {
return err
}
cookie := oauthCookie(state, browser, 600)
cookie.SameSite = http.SameSiteNoneMode
http.SetCookie(w, cookie)
query := url.Values{"response_type": {"code"}, "scope": {"openid profile email"}, "client_id": {s.slack.ClientID}, "state": {state}, "nonce": {nonce}, "redirect_uri": {s.slack.RedirectURI}}
http.Redirect(w, r, "https://slack.com/openid/connect/authorize?"+query.Encode(), 302)
return nil
}
func (s *service) slackCallback(w http.ResponseWriter, r *http.Request) error {
if s.slack == nil {
return reject(w, 503, "slack_not_configured")
}
if r.Method != http.MethodGet && r.Method != http.MethodPost {
return reject(w, 405, "method_not_allowed")
}
r.Body = http.MaxBytesReader(w, r.Body, 16384)
if r.ParseForm() != nil {
return reject(w, 400, "invalid_callback")
}
state := r.Form.Get("state")
cookie, err := r.Cookie(oauthCookie(state, "", 0).Name)
if !tokenPattern.MatchString(state) || err != nil || !tokenPattern.MatchString(cookie.Value) {
return reject(w, 400, "invalid_state")
}
var project, challenge, nonce string
err = s.db.QueryRowContext(r.Context(), `
DELETE FROM oauth_attempts WHERE state_hash=$1 AND browser_hash=$2 AND expires_at>now() AND provider='slack'
RETURNING project,frontend_challenge,oidc_nonce
`, digest(state), digest(cookie.Value)).Scan(&project, &challenge, &nonce)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 400, "invalid_state")
}
if err != nil {
return err
}
frontend := s.projects[project]
if frontend == nil {
return reject(w, 400, "invalid_state")
}
expired := oauthCookie(state, "", -1)
expired.SameSite = http.SameSiteNoneMode
http.SetCookie(w, expired)
destination := *frontend
if r.Form.Get("code") == "" || r.Form.Has("error") {
destination.Fragment = "error=slack_login_cancelled"
http.Redirect(w, r, destination.String(), 302)
return nil
}
var tokens struct {
OK bool `json:"ok"`
IDToken string `json:"id_token"`
}
err = s.googleJSON(r.Context(), "POST", "https://slack.com/api/openid.connect.token", url.Values{
"code": {r.Form.Get("code")}, "client_id": {s.slack.ClientID}, "client_secret": {s.slack.Secret}, "redirect_uri": {s.slack.RedirectURI}, "grant_type": {"authorization_code"},
}, "", &tokens)
if err != nil {
return err
}
if !tokens.OK || tokens.IDToken == "" {
return errors.New("Slack token exchange failed")
}
claims, err := s.verifySlack(r.Context(), tokens.IDToken, nonce)
if err != nil {
return err
}
grant, err := s.identityGrant(r.Context(), project, "slack", claims.Team+":"+claims.Subject, claims.Email, claims.Name, claims.Picture, challenge)
if err != nil {
return err
}
destination.Fragment = "code=" + grant
http.Redirect(w, r, destination.String(), 302)
return nil
}
+222
View File
@@ -0,0 +1,222 @@
package auth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func signIdentity(t *testing.T, key *rsa.PrivateKey, claims slackClaims) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"fixture"}`))
body, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
value := header + "." + base64.RawURLEncoding.EncodeToString(body)
hash := sha256.Sum256([]byte(value))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:])
if err != nil {
t.Fatal(err)
}
return value + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func TestSlackIdentityVerification(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
s := &service{slack: &SlackConfig{ClientID: "fixture", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}}
good := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: "nonce"}
for _, name := range []string{"valid", "issuer", "audience", "expired", "nonce", "signature", "algorithm"} {
t.Run(name, func(t *testing.T) {
claims := good
switch name {
case "issuer":
claims.Issuer = "https://attacker.invalid"
case "audience":
claims.Audience = "other-client"
case "expired":
claims.Expires = time.Now().Add(-time.Minute).Unix()
case "nonce":
claims.Nonce = "wrong"
}
value := signIdentity(t, key, claims)
if name == "signature" {
parts := strings.Split(value, ".")
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sig[0] ^= 1
parts[2] = base64.RawURLEncoding.EncodeToString(sig)
value = strings.Join(parts, ".")
}
if name == "algorithm" {
parts := strings.Split(value, ".")
parts[0] = base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","kid":"fixture"}`))
value = strings.Join(parts, ".")
}
_, err := s.verifySlack(context.Background(), value, "nonce")
if (err == nil) != (name == "valid") {
t.Fatalf("unexpected verification result %v", err)
}
})
}
}
func TestSlackCallbackAndExchange(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
var value [12]byte
rand.Read(value[:])
project := "slack-" + hex.EncodeToString(value[:])
defer db.Exec("DELETE FROM project_accounts WHERE project=$1", project)
frontend, _ := url.Parse("https://app.example.com/auth/callback")
config := &SlackConfig{ClientID: "fixture", Secret: "fixture-secret", RedirectURI: "https://api.example.com/auth/slack/callback", keys: map[string]*rsa.PublicKey{"fixture": &key.PublicKey}, fetchedAt: time.Now()}
verifier := token()
var nonce string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Error("unexpected Slack request method")
}
_ = r.ParseForm()
if r.Form.Get("client_id") != "fixture" || r.Form.Get("client_secret") != "fixture-secret" {
t.Error("incorrect Slack client credentials")
}
claims := slackClaims{Issuer: "https://slack.com", Subject: "U123", Team: "T123", Audience: "fixture", Expires: time.Now().Add(time.Minute).Unix(), Issued: time.Now().Unix(), Nonce: nonce, Name: "Slack User"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id_token": signIdentity(t, key, claims)})
}))
defer upstream.Close()
s := &service{db: db, projects: map[string]*url.URL{project: frontend}, slack: config, google: &http.Client{Transport: rewriteTransport{destination: upstream.URL}}}
start := httptest.NewRequest("GET", "https://api.example.com/projects/"+project+"/auth/slack?challenge="+digest(verifier), nil)
out := httptest.NewRecorder()
if err = s.startSlack(out, start, project); err != nil {
t.Fatal(err)
}
if out.Code != 302 {
t.Fatalf("start status %d", out.Code)
}
location, _ := url.Parse(out.Header().Get("Location"))
nonce = location.Query().Get("nonce")
state := location.Query().Get("state")
if nonce == "" || state == "" {
t.Fatal("missing state or nonce")
}
cookies := out.Result().Cookies()
if len(cookies) != 1 || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly {
t.Fatal("incorrect Slack browser binding cookie")
}
body := url.Values{"state": {state}, "code": {"fixture-code"}}
callback := func() *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", config.RedirectURI, strings.NewReader(body.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookies[0])
w := httptest.NewRecorder()
if err := s.slackCallback(w, r); err != nil {
t.Fatal(err)
}
return w
}
success := callback()
if success.Code != 302 {
t.Fatalf("callback status %d %s", success.Code, success.Body.String())
}
destination, _ := url.Parse(success.Header().Get("Location"))
fragment, _ := url.ParseQuery(destination.Fragment)
grant := fragment.Get("code")
if !tokenPattern.MatchString(grant) {
t.Fatal("callback missing one-time exchange code")
}
if replay := callback(); replay.Code != 400 {
t.Fatal("Slack callback replay accepted")
}
request := httptest.NewRequest("POST", "https://api.example.com/projects/"+project+"/auth/exchange", nil)
request.Header.Set("X-Foundry-Code", grant)
request.Header.Set("X-Foundry-Verifier", verifier)
exchange := httptest.NewRecorder()
if err = s.exchange(exchange, request, project); err != nil {
t.Fatal(err)
}
if exchange.Code != 200 {
t.Fatalf("exchange status %d", exchange.Code)
}
var account, username, name string
err = db.QueryRow("SELECT a.id::text,a.username,a.display_name FROM project_accounts a JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) WHERE a.project=$1 AND i.provider='slack' AND i.subject='T123:U123'", project).Scan(&account, &username, &name)
if err != nil || username == "" || name != "Slack User" {
t.Fatalf("Slack profile prefill failed %v", err)
}
_, err = db.Exec("UPDATE project_accounts SET username='chosen-name',display_name='Chosen name' WHERE project=$1 AND id=$2", project, account)
if err != nil {
t.Fatal(err)
}
if _, err = s.identityGrant(context.Background(), project, "slack", "T123:U123", nil, "New Slack Name", "", digest(verifier)); err != nil {
t.Fatal(err)
}
if err = db.QueryRow("SELECT username,display_name FROM project_accounts WHERE project=$1 AND id=$2", project, account).Scan(&username, &name); err != nil {
t.Fatal(err)
}
if username != "chosen-name" || name != "Chosen name" {
t.Fatal("provider login overwrote chosen profile")
}
}
type rewriteTransport struct{ destination string }
func (t rewriteTransport) RoundTrip(r *http.Request) (*http.Response, error) {
cloned := r.Clone(r.Context())
target, _ := url.Parse(t.destination)
cloned.URL.Scheme, cloned.URL.Host = target.Scheme, target.Host
return http.DefaultTransport.RoundTrip(cloned)
}
func TestGoogleIdentityKeepsLegacyAccount(t *testing.T) {
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := &service{db: db}
grant, err := s.identityGrant(context.Background(), "fixture", "google", "existing-google", nil, "Existing Google User", "", digest(token()))
if err != nil {
t.Fatal(err)
}
var account string
if err = db.QueryRow("SELECT account_id::text FROM login_grants WHERE project='fixture' AND token_hash=$1", digest(grant)).Scan(&account); err != nil {
t.Fatal(err)
}
if account != "00000000-0000-0000-0000-000000000001" {
t.Fatal("legacy Google identity changed account ID")
}
var count int
if err = db.QueryRow("SELECT count(*) FROM project_sessions WHERE project='fixture' AND token_hash='existing-session'").Scan(&count); err != nil || count != 1 {
t.Fatalf("existing Google session was lost %v", err)
}
}
+129
View File
@@ -0,0 +1,129 @@
package platform
import (
"bytes"
"encoding/json"
"io"
"net/url"
"regexp"
)
type Paragraph struct {
ID string `json:"id"`
Text string `json:"text"`
Description string `json:"description,omitempty"`
}
type Section struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Paragraphs []Paragraph `json:"paragraphs"`
}
type Article struct {
Sections []Section `json:"sections"`
}
type Image struct {
URL string `json:"url"`
Alt string `json:"alt,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type CardPart struct {
Kind string `json:"kind"`
Text *string `json:"text,omitempty"`
Article *Article `json:"article,omitempty"`
Image *Image `json:"image,omitempty"`
}
type Content struct {
SchemaVersion int `json:"schema_version"`
Text *string `json:"text,omitempty"`
Article *Article `json:"article,omitempty"`
Image *Image `json:"image,omitempty"`
Parts []CardPart `json:"parts,omitempty"`
}
var blockID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func validArticle(article *Article) bool {
if article == nil || len(article.Sections) > 200 {
return false
}
seen := map[string]bool{}
total := 0
for _, section := range article.Sections {
if !blockID.MatchString(section.ID) || seen[section.ID] || len(section.Title) > 300 || len(section.Description) > 10000 {
return false
}
seen[section.ID] = true
total += len(section.Paragraphs)
if total > 2000 {
return false
}
for _, paragraph := range section.Paragraphs {
if !blockID.MatchString(paragraph.ID) || seen[paragraph.ID] || len(paragraph.Text) > 100000 || len(paragraph.Description) > 10000 {
return false
}
seen[paragraph.ID] = true
}
}
return true
}
func validImage(image *Image) bool {
if image == nil || len(image.URL) > 2048 || len(image.Alt) > 2000 || image.Width < 0 || image.Width > 100000 || image.Height < 0 || image.Height > 100000 {
return false
}
u, err := url.Parse(image.URL)
return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == ""
}
func validPart(part CardPart) bool {
switch part.Kind {
case "text":
return part.Text != nil && len(*part.Text) <= 100000 && part.Article == nil && part.Image == nil
case "article":
return part.Text == nil && part.Image == nil && validArticle(part.Article)
case "image":
return part.Text == nil && part.Article == nil && validImage(part.Image)
default:
return false
}
}
func normalizeContent(kind string, raw json.RawMessage) (json.RawMessage, bool) {
if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' {
return nil, false
}
var content Content
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(&content) != nil || d.Decode(new(any)) != io.EOF {
return nil, false
}
if content.SchemaVersion == 0 {
content.SchemaVersion = 1
}
if content.SchemaVersion != 1 {
return nil, false
}
switch kind {
case "text", "article", "image":
if len(content.Parts) > 0 || !validPart(CardPart{Kind: kind, Text: content.Text, Article: content.Article, Image: content.Image}) {
return nil, false
}
case "board":
if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) > 0 {
return nil, false
}
case "card":
if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) < 1 || len(content.Parts) > 20 {
return nil, false
}
for _, part := range content.Parts {
if !validPart(part) {
return nil, false
}
}
default:
return nil, false
}
out, err := json.Marshal(content)
return out, err == nil
}
+39
View File
@@ -0,0 +1,39 @@
package platform
import (
"encoding/json"
"testing"
)
func TestContentValidation(t *testing.T) {
cases := []struct {
name, kind, body string
valid bool
}{
{"plain text", "text", `{"text":""}`, true},
{"empty article draft", "article", `{"article":{"sections":[]}}`, true},
{"paragraph descriptions", "article", `{"article":{"sections":[{"id":"s","title":"Section","description":"Summary","paragraphs":[{"id":"p","text":"Body","description":"Notes"}]}]}}`, true},
{"duplicate paragraph IDs", "article", `{"article":{"sections":[{"id":"s","paragraphs":[{"id":"p","text":"One"},{"id":"p","text":"Two"}]}]}}`, false},
{"markdown field", "article", `{"markdown":"# Heading"}`, false},
{"unknown schema", "text", `{"schema_version":2,"text":"Text"}`, false},
{"null content", "board", `null`, false},
{"nested unknown field", "image", `{"image":{"url":"https://example.com/image.png","secret":"unexpected"}}`, false},
{"insecure image", "image", `{"image":{"url":"http://example.com/image.png"}}`, false},
{"URL credentials", "image", `{"image":{"url":"https://user:password@example.com/image.png"}}`, false},
{"card and text", "card", `{"parts":[{"kind":"text","text":"Caption"}]}`, true},
{"empty card", "card", `{"parts":[]}`, false},
{"mismatched part", "card", `{"parts":[{"kind":"text","image":{"url":"https://example.com/image.png"}}]}`, false},
{"board payload", "board", `{"text":"Wrong type"}`, false},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
out, ok := normalizeContent(test.kind, json.RawMessage(test.body))
if ok != test.valid {
t.Fatalf("valid=%v, want %v", ok, test.valid)
}
if ok && !json.Valid(out) {
t.Fatal("normalized content is invalid JSON")
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
package platform
import "net/http"
func (s *service) materialGrants(w http.ResponseWriter, r *http.Request, project, account string) error {
workspace, material := r.PathValue("workspace"), r.PathValue("material")
if r.Method != http.MethodGet && r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account)
if err != nil {
return err
}
if role != "admin" {
reject(w, 403, "admin_required")
return nil
}
if r.Method == http.MethodGet {
rows, err := tx.QueryContext(r.Context(), "SELECT account_id::text,role FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 ORDER BY account_id LIMIT 1000", project, workspace, material)
if err != nil {
return err
}
defer rows.Close()
items := []response{}
for rows.Next() {
var id, role string
if err := rows.Scan(&id, &role); err != nil {
return err
}
items = append(items, response{"account_id": id, "role": role})
}
if err := rows.Err(); err != nil {
return err
}
reply(w, 200, response{"items": items})
return nil
}
var input struct {
AccountID string `json:"account_id"`
Role string `json:"role"`
}
if !decode(w, r, &input) {
return nil
}
if input.Role != "editor" && input.Role != "viewer" {
reject(w, 400, "invalid_role")
return nil
}
_, err = tx.ExecContext(r.Context(), `
INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5)
ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=EXCLUDED.role
`, project, workspace, material, input.AccountID, input.Role)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 204, nil)
return nil
}
func (s *service) deleteMaterialGrant(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodDelete {
reject(w, 405, "method_not_allowed")
return nil
}
workspace, material := r.PathValue("workspace"), r.PathValue("material")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account)
if err != nil {
return err
}
if role != "admin" {
reject(w, 403, "admin_required")
return nil
}
_, err = tx.ExecContext(r.Context(), "DELETE FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 AND account_id=$4", project, workspace, material, r.PathValue("account"))
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 204, nil)
return nil
}
+168
View File
@@ -0,0 +1,168 @@
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 {
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}/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
}
+213
View File
@@ -0,0 +1,213 @@
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"
"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)
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"1"`)
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, `"2"`)
call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204)
call("GET", textPath, tokens[2], nil, 404)
call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204)
call("PATCH", textPath, tokens[0], changed, 412, `"3"`)
imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}}
call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201)
call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{
response{"kind": "text", "text": "Caption"},
response{"kind": "article", "article": articleContent["article"]},
response{"kind": "image", "image": imageContent["image"]},
}}}, 201)
for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} {
call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200)
}
images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200)
if len(images["items"].([]any)) != 1 {
t.Fatal("kind/title filter returned incorrect materials")
}
email := "fixture@example.invalid"
password := "a-long-fixture-password"
call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202)
call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401)
var body string
if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil {
t.Fatal(err)
}
match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body)
if len(match) != 2 {
t.Fatal("missing email verification link")
}
verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201)
call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400)
call("GET", base+"/profile", verified["access_token"].(string), nil, 200)
call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401)
loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200)
call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204)
call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401)
var count int
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 {
t.Fatalf("material grant missing %v", err)
}
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 {
t.Fatalf("click accounting incorrect %v", err)
}
}
+158
View File
@@ -0,0 +1,158 @@
package platform
import (
"database/sql"
"encoding/json"
"math"
"net/http"
)
type placement struct {
MaterialID string `json:"material_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ZIndex int `json:"z_index"`
}
func (p placement) valid() bool {
return !math.IsNaN(p.X) && !math.IsNaN(p.Y) && !math.IsNaN(p.Width) && !math.IsNaN(p.Height) &&
p.X >= -1000000 && p.X <= 1000000 && p.Y >= -1000000 && p.Y <= 1000000 &&
p.Width >= 1 && p.Width <= 100000 && p.Height >= 1 && p.Height <= 100000 &&
p.ZIndex >= -1000000 && p.ZIndex <= 1000000
}
func (s *service) layouts(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete {
reject(w, 405, "method_not_allowed")
return nil
}
workspace, board, layout := r.PathValue("workspace"), r.PathValue("material"), r.PathValue("layout")
if (layout == "" && (r.Method == http.MethodPatch || r.Method == http.MethodDelete)) || (layout != "" && (r.Method == http.MethodGet || r.Method == http.MethodPost)) {
reject(w, 405, "method_not_allowed")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err != nil {
return err
}
if kind != "board" {
reject(w, 400, "not_a_board")
return nil
}
var version int64
if err = tx.QueryRowContext(r.Context(), "SELECT version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board).Scan(&version); err != nil {
return err
}
if r.Method == http.MethodGet {
rows, err := tx.QueryContext(r.Context(), `
SELECT jsonb_build_object('id',l.id,'material_id',l.material_id,'x',l.x,'y',l.y,'width',l.width,'height',l.height,'z_index',l.z_index,'version',l.version)
FROM board_layouts l WHERE l.project=$1 AND l.workspace_id=$2 AND l.board_id=$3
AND (EXISTS(SELECT 1 FROM workspace_members m WHERE m.project=l.project AND m.workspace_id=l.workspace_id AND m.account_id=$4)
OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(l.project,l.workspace_id,l.material_id) AND g.account_id=$4))
ORDER BY l.z_index,l.id LIMIT 2000
`, project, workspace, board, account)
if err != nil {
return err
}
defer rows.Close()
items := []json.RawMessage{}
for rows.Next() {
var item json.RawMessage
if err := rows.Scan(&item); err != nil {
return err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return err
}
setVersion(w, version)
reply(w, 200, response{"items": items, "board_version": version})
return nil
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
expected, ok := expectedVersion(w, r)
if !ok {
return nil
}
if expected != version {
reject(w, 412, "version_conflict")
return nil
}
var input placement
if r.Method != http.MethodDelete {
if !decode(w, r, &input) {
return nil
}
if !input.valid() || input.MaterialID == board {
reject(w, 400, "invalid_layout")
return nil
}
refKind, _, err := lockMaterial(r.Context(), tx, project, workspace, input.MaterialID, account)
if err != nil {
return err
}
if refKind == "board" {
reject(w, 400, "nested_board_not_supported")
return nil
}
}
var result sql.Result
switch r.Method {
case http.MethodPost:
var count int
if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3", project, workspace, board).Scan(&count); err != nil {
return err
}
if count >= 2000 {
reject(w, 409, "board_full")
return nil
}
err = tx.QueryRowContext(r.Context(), `
INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height,z_index)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text
`, project, workspace, board, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex).Scan(&layout)
case http.MethodPatch:
result, err = tx.ExecContext(r.Context(), `
UPDATE board_layouts SET material_id=$5,x=$6,y=$7,width=$8,height=$9,z_index=$10,version=version+1
WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4
`, project, workspace, board, layout, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex)
case http.MethodDelete:
result, err = tx.ExecContext(r.Context(), "DELETE FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4", project, workspace, board, layout)
}
if err != nil {
return err
}
if result != nil {
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
}
_, err = tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, version+1)
if r.Method == http.MethodPost {
reply(w, 201, response{"id": layout, "board_version": version + 1})
} else {
reply(w, 204, nil)
}
return nil
}
+297
View File
@@ -0,0 +1,297 @@
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"`
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 || 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 string
err = tx.QueryRowContext(r.Context(), `
INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by)
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text
`, project, workspace, input.Kind, input.Title, input.Description, content, account).Scan(&id)
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})
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,'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 {
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 || len(input.Title) > 300 || len(input.Description) > 10000 {
reject(w, 400, "invalid_material")
return nil
}
result, err = tx.ExecContext(r.Context(), `
UPDATE materials SET 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)
} 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,'title',m.title,
'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at,
'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id)
FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id)
WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),'[]'::jsonb))
FROM materials m LEFT JOIN material_opens opened ON (opened.project,opened.workspace_id,opened.material_id)=(m.project,m.workspace_id,m.id) AND opened.account_id=$3
WHERE m.project=$1 AND m.workspace_id=$2
AND (EXISTS(SELECT 1 FROM workspace_members member WHERE (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$3)
OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(m.project,m.workspace_id,m.id) AND g.account_id=$3))
AND ($4='' OR m.title ILIKE '%'||$4||'%') AND ($5='' OR m.kind=$5)
AND ($6='' OR EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id::text=$6))
ORDER BY `+order+` LIMIT $7 OFFSET $8`, project, r.PathValue("workspace"), account, query, kind, tag, limit+1, offset)
if err != nil {
return err
}
defer rows.Close()
items := []json.RawMessage{}
for rows.Next() {
var value json.RawMessage
if err := rows.Scan(&value); err != nil {
return err
}
items = append(items, value)
}
if err := rows.Err(); err != nil {
return err
}
var next *int
if len(items) > limit {
items = items[:limit]
value := offset + limit
next = &value
}
reply(w, 200, response{"items": items, "next_offset": next})
return nil
}
+192
View File
@@ -0,0 +1,192 @@
package platform
import (
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)
type Question struct {
Key string `json:"key"`
Required bool `json:"required"`
}
type OnboardingConfig struct {
RequireWorkspace bool `json:"require_workspace"`
Questions []Question `json:"questions"`
}
var surveyTypes = map[string]string{
"team_activity": "string", "job_role": "string", "referral": "string",
"previous_use": "boolean", "purpose": "string", "company_size": "string",
}
func ParseOnboarding(raw string, callbacks map[string]string) (map[string]OnboardingConfig, error) {
configs := map[string]OnboardingConfig{}
if strings.TrimSpace(raw) == "" {
return configs, nil
}
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&configs); err != nil {
return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON")
}
if decoder.Decode(new(any)) != io.EOF {
return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON")
}
for project, config := range configs {
if _, ok := callbacks[project]; !ok {
return nil, errors.New("onboarding project is not configured")
}
seen := map[string]bool{}
for _, question := range config.Questions {
if surveyTypes[question.Key] == "" || seen[question.Key] {
return nil, errors.New("invalid or duplicate onboarding question")
}
seen[question.Key] = true
}
}
return configs, nil
}
func validAnswers(config OnboardingConfig, answers map[string]json.RawMessage) bool {
questions := map[string]Question{}
for _, question := range config.Questions {
questions[question.Key] = question
}
for key, value := range answers {
if _, ok := questions[key]; !ok {
return false
}
if surveyTypes[key] == "boolean" {
var answer *bool
if json.Unmarshal(value, &answer) != nil || answer == nil {
return false
}
} else {
var answer *string
if json.Unmarshal(value, &answer) != nil || answer == nil || len(*answer) > 2000 {
return false
}
}
}
for _, question := range config.Questions {
value, ok := answers[question.Key]
if question.Required {
if !ok {
return false
}
if surveyTypes[question.Key] == "string" {
var answer string
if json.Unmarshal(value, &answer) != nil || strings.TrimSpace(answer) == "" {
return false
}
}
}
}
return true
}
func (s *service) onboarding(w http.ResponseWriter, r *http.Request, project, account string) error {
config := s.onboardingConfigs[project]
if config.Questions == nil {
config.Questions = []Question{}
}
if r.Method == http.MethodGet {
var answers, result json.RawMessage
err := s.db.QueryRowContext(r.Context(), "SELECT answers,result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&answers, &result)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
reply(w, 200, response{"config": config, "completed": err == nil, "answers": answers, "result": result})
return nil
}
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
var input struct {
Username *string `json:"username"`
TeamName string `json:"team_name"`
GenerateInvite bool `json:"generate_invite"`
Answers map[string]json.RawMessage `json:"answers"`
}
if !decode(w, r, &input) {
return nil
}
input.TeamName = strings.TrimSpace(input.TeamName)
if input.Answers == nil {
input.Answers = map[string]json.RawMessage{}
}
if input.Username != nil {
value := strings.ToLower(strings.TrimSpace(*input.Username))
input.Username = &value
if !usernamePattern.MatchString(value) {
reject(w, 400, "invalid_username")
return nil
}
}
if !validAnswers(config, input.Answers) || len(input.TeamName) > 160 || (config.RequireWorkspace && input.TeamName == "") || (input.GenerateInvite && input.TeamName == "") {
reject(w, 400, "invalid_onboarding")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
var locked string
if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM project_accounts WHERE project=$1 AND id=$2 FOR UPDATE", project, account).Scan(&locked); err != nil {
return err
}
var prior json.RawMessage
err = tx.QueryRowContext(r.Context(), "SELECT result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&prior)
if err == nil {
reply(w, 200, response{"completed": true, "result": prior, "already_completed": true})
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
if input.Username != nil {
_, err = tx.ExecContext(r.Context(), "UPDATE project_accounts SET username=$3,updated_at=now() WHERE project=$1 AND id=$2", project, account, *input.Username)
if err != nil {
return err
}
}
result := response{}
var invitation string
if input.TeamName != "" {
workspace, err := createWorkspace(r.Context(), tx, project, account, input.TeamName)
if err != nil {
return err
}
result["workspace_id"] = workspace
if input.GenerateInvite {
token, hash := newLinkToken()
var id string
err = tx.QueryRowContext(r.Context(), `
INSERT INTO share_links(project,workspace_id,token_hash,role,created_by,expires_at)
VALUES($1,$2,$3,'viewer',$4,now()+interval '7 days') RETURNING id::text
`, project, workspace, hash, account).Scan(&id)
if err != nil {
return err
}
result["invite_id"] = id
invitation = s.origin + "/s/" + token
}
}
answers, _ := json.Marshal(input.Answers)
resultJSON, _ := json.Marshal(result)
_, err = tx.ExecContext(r.Context(), "INSERT INTO onboarding_responses(project,account_id,answers,result) VALUES($1,$2,$3,$4)", project, account, answers, resultJSON)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 201, response{"completed": true, "result": result, "invite_url": invitation, "already_completed": false})
return nil
}
+356
View File
@@ -0,0 +1,356 @@
package platform
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"net/http"
"net/url"
"regexp"
"strconv"
"time"
)
var linkTokenPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
func newLinkToken() (string, string) {
var raw [16]byte
rand.Read(raw[:])
value := base64.RawURLEncoding.EncodeToString(raw[:])
return value, linkDigest(value)
}
func linkDigest(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
type shareInput struct {
Role string `json:"role"`
MaterialID *string `json:"material_id"`
ExpiresIn int `json:"expires_in"`
MaxUses *int `json:"max_uses"`
}
func (s *service) shares(w http.ResponseWriter, r *http.Request, project, account string) error {
workspace := r.PathValue("workspace")
if r.Method != http.MethodPost && r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockWorkspace(r.Context(), tx, project, workspace, account)
if err != nil {
return err
}
if role != "admin" {
reject(w, 403, "forbidden")
return nil
}
if r.Method == http.MethodGet {
rows, err := tx.QueryContext(r.Context(), `
SELECT l.id::text,l.material_id::text,l.role,l.expires_at,l.revoked_at,l.max_uses,l.use_count,
(SELECT count(*) FROM link_clicks c WHERE (c.project,c.workspace_id,c.link_id)=(l.project,l.workspace_id,l.id))
FROM share_links l WHERE project=$1 AND workspace_id=$2 ORDER BY created_at DESC,id LIMIT 200
`, project, workspace)
if err != nil {
return err
}
defer rows.Close()
items := []response{}
for rows.Next() {
var id, role string
var material *string
var expires time.Time
var revoked *time.Time
var max *int
var uses, clicks int64
if err := rows.Scan(&id, &material, &role, &expires, &revoked, &max, &uses, &clicks); err != nil {
return err
}
items = append(items, response{"id": id, "material_id": material, "role": role, "expires_at": expires, "revoked_at": revoked, "max_uses": max, "use_count": uses, "click_count": clicks})
}
if err := rows.Err(); err != nil {
return err
}
reply(w, 200, response{"items": items})
return nil
}
var input shareInput
if !decode(w, r, &input) {
return nil
}
if input.ExpiresIn == 0 {
input.ExpiresIn = 7 * 86400
}
if (input.Role != "admin" && input.Role != "editor" && input.Role != "viewer") ||
(input.MaterialID != nil && input.Role == "admin") || input.ExpiresIn < 60 || input.ExpiresIn > 30*86400 ||
(input.MaxUses != nil && (*input.MaxUses < 1 || *input.MaxUses > 100000)) {
reject(w, 400, "invalid_share")
return nil
}
token, hash := newLinkToken()
var id string
var expires time.Time
err = tx.QueryRowContext(r.Context(), `
INSERT INTO share_links(project,workspace_id,material_id,token_hash,role,created_by,expires_at,max_uses)
VALUES($1,$2,$3,$4,$5,$6,now()+$7*interval '1 second',$8) RETURNING id::text,expires_at
`, project, workspace, input.MaterialID, hash, input.Role, account, input.ExpiresIn, input.MaxUses).Scan(&id, &expires)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 201, response{"id": id, "url": s.origin + "/s/" + token, "token": token, "role": input.Role, "expires_at": expires})
return nil
}
func (s *service) revokeShare(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodDelete {
reject(w, 405, "method_not_allowed")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account)
if err != nil {
return err
}
if role != "admin" {
reject(w, 403, "forbidden")
return nil
}
result, err := tx.ExecContext(r.Context(), `
UPDATE share_links SET revoked_at=COALESCE(revoked_at,now()) WHERE project=$1 AND workspace_id=$2 AND id=$3
`, project, r.PathValue("workspace"), r.PathValue("link"))
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 204, nil)
return nil
}
// A public short link records only a referring origin and redirects to a fixed
// frontend. The fragment carries the invitation, never a login session.
func (s *service) shortLink(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return
}
token := r.PathValue("token")
if !linkTokenPattern.MatchString(token) {
reject(w, 404, "not_found")
return
}
ref := ""
if u, err := url.Parse(r.Referer()); err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil {
ref = u.Scheme + "://" + u.Host
if len(ref) > 300 {
ref = ""
}
}
var project string
err := s.db.QueryRowContext(r.Context(), `
WITH valid AS(
SELECT project,workspace_id,id FROM share_links
WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now() AND (max_uses IS NULL OR use_count<max_uses)
), click AS(
INSERT INTO link_clicks(project,workspace_id,link_id,referrer_origin)
SELECT project,workspace_id,id,$2 FROM valid RETURNING project
)
SELECT project FROM click
`, linkDigest(token), ref).Scan(&project)
if err != nil {
if err == sql.ErrNoRows {
reject(w, 404, "not_found")
} else {
reject(w, 503, "service_unavailable")
}
return
}
callback, ok := s.callbacks[project]
if !ok {
reject(w, 404, "not_found")
return
}
destination, err := url.Parse(callback)
if err != nil {
reject(w, 503, "service_unavailable")
return
}
destination.Fragment = "invite=" + token
http.Redirect(w, r, destination.String(), 302)
}
func (s *service) redeemShare(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
var input struct {
Token string `json:"token"`
}
if !decode(w, r, &input) {
return nil
}
if !linkTokenPattern.MatchString(input.Token) {
reject(w, 404, "not_found")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
var workspace string
// Lock workspace before the link, matching revocation and role updates.
err = tx.QueryRowContext(r.Context(), `
SELECT w.id::text FROM workspaces w JOIN share_links l ON (w.project,w.id)=(l.project,l.workspace_id)
WHERE l.project=$1 AND l.token_hash=$2 FOR UPDATE OF w
`, project, linkDigest(input.Token)).Scan(&workspace)
if err != nil {
return err
}
var id, role string
var material *string
var allowed, capacity, redeemed bool
err = tx.QueryRowContext(r.Context(), `
SELECT id::text,role,material_id::text,
revoked_at IS NULL AND expires_at>now(), (max_uses IS NULL OR use_count<max_uses),
EXISTS(SELECT 1 FROM share_redemptions d WHERE (d.project,d.workspace_id,d.link_id)=(share_links.project,share_links.workspace_id,share_links.id) AND d.account_id=$4)
FROM share_links WHERE project=$1 AND workspace_id=$2 AND token_hash=$3 FOR UPDATE
`, project, workspace, linkDigest(input.Token), account).Scan(&id, &role, &material, &allowed, &capacity, &redeemed)
if err != nil {
return err
}
if !allowed || (!capacity && !redeemed) {
reject(w, 410, "share_unavailable")
return nil
}
result, err := tx.ExecContext(r.Context(), `
INSERT INTO share_redemptions(project,workspace_id,link_id,account_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING
`, project, workspace, id, account)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n > 0 {
if material == nil {
_, err = tx.ExecContext(r.Context(), `
INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,$4)
ON CONFLICT(project,workspace_id,account_id) DO UPDATE SET role=CASE
WHEN workspace_members.role='admin' OR EXCLUDED.role='admin' THEN 'admin'
WHEN workspace_members.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END
`, project, workspace, account, role)
} else {
_, err = tx.ExecContext(r.Context(), `
INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5)
ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE
WHEN material_grants.role='editor' OR EXCLUDED.role='editor' THEN 'editor' ELSE 'viewer' END
`, project, workspace, *material, account, role)
}
if err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), "UPDATE share_links SET use_count=use_count+1 WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, id)
if err != nil {
return err
}
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 200, response{"workspace_id": workspace, "material_id": material, "role": role, "already_redeemed": n == 0})
return nil
}
func (s *service) shareClicks(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
before := r.URL.Query().Get("before")
var cursor int64
if before != "" {
value, err := strconv.ParseInt(before, 10, 64)
if err != nil || value < 1 {
reject(w, 400, "invalid_cursor")
return nil
}
cursor = value
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
workspace, link := r.PathValue("workspace"), r.PathValue("link")
_, role, err := lockWorkspace(r.Context(), tx, project, workspace, account)
if err != nil {
return err
}
if role != "admin" {
reject(w, 403, "forbidden")
return nil
}
var found string
if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM share_links WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, link).Scan(&found); err != nil {
return err
}
rows, err := tx.QueryContext(r.Context(), `
SELECT id,clicked_at,referrer_origin FROM link_clicks WHERE project=$1 AND workspace_id=$2 AND link_id=$3 AND ($4::bigint=0 OR id<$4)
ORDER BY id DESC LIMIT 101
`, project, workspace, link, cursor)
if err != nil {
return err
}
defer rows.Close()
items := []response{}
var last int64
for rows.Next() {
var id int64
var at time.Time
var ref string
if err := rows.Scan(&id, &at, &ref); err != nil {
return err
}
items = append(items, response{"id": strconv.FormatInt(id, 10), "clicked_at": at, "referrer_origin": ref})
if len(items) <= 100 {
last = id
}
}
if err := rows.Err(); err != nil {
return err
}
var next *string
if len(items) > 100 {
items = items[:100]
value := strconv.FormatInt(last, 10)
next = &value
}
reply(w, 200, response{"items": items, "next_before": next})
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package platform
import (
"net/http"
"regexp"
"strings"
)
var colorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
func (s *service) tags(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
workspace := r.PathValue("workspace")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockWorkspace(r.Context(), tx, project, workspace, account)
if err != nil {
return err
}
if r.Method == http.MethodGet {
rows, err := tx.QueryContext(r.Context(), "SELECT id::text,name,color FROM tags WHERE project=$1 AND workspace_id=$2 ORDER BY lower(name),id LIMIT 1000", project, workspace)
if err != nil {
return err
}
defer rows.Close()
items := []response{}
for rows.Next() {
var id, name, color string
if err := rows.Scan(&id, &name, &color); err != nil {
return err
}
items = append(items, response{"id": id, "name": name, "color": color})
}
if err := rows.Err(); err != nil {
return err
}
reply(w, 200, response{"items": items})
return nil
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
var input struct {
Name string `json:"name"`
Color string `json:"color"`
}
if !decode(w, r, &input) {
return nil
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 80 || (input.Color != "" && !colorPattern.MatchString(input.Color)) {
reject(w, 400, "invalid_tag")
return nil
}
var id string
err = tx.QueryRowContext(r.Context(), "INSERT INTO tags(project,workspace_id,name,color) VALUES($1,$2,$3,$4) RETURNING id::text", project, workspace, input.Name, input.Color).Scan(&id)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 201, response{"id": id, "name": input.Name, "color": input.Color})
return nil
}
func (s *service) deleteTag(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodDelete {
reject(w, 405, "method_not_allowed")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
_, err = tx.ExecContext(r.Context(), `
UPDATE materials m SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2
AND EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id=$3)
`, project, r.PathValue("workspace"), r.PathValue("tag"))
if err != nil {
return err
}
_, err = tx.ExecContext(r.Context(), "DELETE FROM tags WHERE project=$1 AND workspace_id=$2 AND id=$3", project, r.PathValue("workspace"), r.PathValue("tag"))
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 204, nil)
return nil
}
func (s *service) materialTags(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
var input struct {
Tags []string `json:"tags"`
}
if !decode(w, r, &input) {
return nil
}
if len(input.Tags) > 100 {
reject(w, 400, "too_many_tags")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
workspace, material := r.PathValue("workspace"), r.PathValue("material")
_, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
version, ok := expectedVersion(w, r)
if !ok {
return nil
}
result, err := tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
reject(w, 412, "version_conflict")
return nil
}
_, err = tx.ExecContext(r.Context(), "DELETE FROM material_tags WHERE project=$1 AND workspace_id=$2 AND material_id=$3", project, workspace, material)
if err != nil {
return err
}
for _, tag := range input.Tags {
_, err = tx.ExecContext(r.Context(), "INSERT INTO material_tags(project,workspace_id,material_id,tag_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", project, workspace, material, tag)
if err != nil {
return err
}
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, version+1)
reply(w, 204, nil)
return nil
}
+239
View File
@@ -0,0 +1,239 @@
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 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 string
if err := rows.Scan(&id, &name, &role, &owner); err != nil {
return err
}
items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner})
}
if err := rows.Err(); err != nil {
return err
}
reply(w, 200, response{"items": items})
case http.MethodPost:
var input struct {
Name string `json:"name"`
}
if !decode(w, r, &input) {
return nil
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 160 {
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
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account})
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 string
if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil {
return err
}
reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner})
return nil
case http.MethodPatch:
if role != "admin" {
reject(w, 403, "forbidden")
return nil
}
var input struct {
Name string `json:"name"`
}
if !decode(w, r, &input) {
return nil
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 160 {
reject(w, 400, "invalid_workspace")
return nil
}
_, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name)
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
}