Add configurable onboarding and scoped invitation links
This commit is contained in:
@@ -36,4 +36,5 @@ jobs:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
FOUNDRY_PROJECTS: ${{ vars.FOUNDRY_PROJECTS }}
|
||||
FOUNDRY_ONBOARDING: ${{ vars.FOUNDRY_ONBOARDING }}
|
||||
run: bash deploy/release.sh bin/foundry
|
||||
|
||||
+5
-1
@@ -54,7 +54,11 @@ func main() {
|
||||
if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
platform.Register(mux, db, callbacks)
|
||||
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")),
|
||||
|
||||
+1
-6
@@ -62,16 +62,11 @@ CREATE TABLE auth_rate_limits (
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE onboarding_configs (
|
||||
project text PRIMARY KEY,
|
||||
require_workspace boolean NOT NULL DEFAULT false,
|
||||
questions jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(questions) = 'array'),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
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
|
||||
|
||||
@@ -56,3 +56,9 @@ END $$;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
printf 'Migration preserved existing logins and enforced project isolation.\n'
|
||||
CGO_ENABLED=0 go test -mod=readonly -c -o bin/platform-tests ./internal/platform
|
||||
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/platform-tests,target=/opt/platform-tests,readonly" \
|
||||
-e PLATFORM_TEST_DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable \
|
||||
--entrypoint /opt/platform-tests postgres:17-alpine -test.v
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ fi
|
||||
|
||||
docker run -d --name "$candidate" "${codex_mounts[@]}" --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" \
|
||||
|
||||
@@ -16,7 +16,10 @@ import (
|
||||
)
|
||||
|
||||
type service struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
origin string
|
||||
callbacks map[string]string
|
||||
onboardingConfigs map[string]OnboardingConfig
|
||||
}
|
||||
|
||||
type response map[string]any
|
||||
@@ -98,8 +101,13 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
||||
}
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string) {
|
||||
s := &service{db: db}
|
||||
func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) {
|
||||
s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs}
|
||||
mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding))
|
||||
mux.HandleFunc("/s/{token}", s.shortLink)
|
||||
mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares", s.protected(callbacks, s.shares))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/shares/{link}", s.protected(callbacks, s.revokeShare))
|
||||
mux.HandleFunc("/projects/{project}/workspaces", s.protected(callbacks, s.workspaces))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}", s.protected(callbacks, s.workspace))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/members", s.protected(callbacks, s.members))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"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-" + base64.RawURLEncoding.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)
|
||||
}()
|
||||
callbacks := map[string]string{project: "https://app.example.com/auth/callback"}
|
||||
mux := http.NewServeMux()
|
||||
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) 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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("material grant missing %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("click accounting incorrect %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Question struct {
|
||||
Key string `json:"key"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
type OnboardingConfig struct {
|
||||
RequireWorkspace bool `json:"require_workspace"`
|
||||
Questions []Question `json:"questions"`
|
||||
}
|
||||
|
||||
var surveyTypes = map[string]string{
|
||||
"team_activity": "string", "job_role": "string", "referral": "string",
|
||||
"previous_use": "boolean", "purpose": "string", "company_size": "string",
|
||||
}
|
||||
|
||||
func ParseOnboarding(raw string, callbacks map[string]string) (map[string]OnboardingConfig, error) {
|
||||
configs := map[string]OnboardingConfig{}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return configs, nil
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&configs); err != nil {
|
||||
return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON")
|
||||
}
|
||||
if decoder.Decode(new(any)) != io.EOF {
|
||||
return nil, errors.New("invalid FOUNDRY_ONBOARDING JSON")
|
||||
}
|
||||
for project, config := range configs {
|
||||
if _, ok := callbacks[project]; !ok {
|
||||
return nil, errors.New("onboarding project is not configured")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, question := range config.Questions {
|
||||
if surveyTypes[question.Key] == "" || seen[question.Key] {
|
||||
return nil, errors.New("invalid or duplicate onboarding question")
|
||||
}
|
||||
seen[question.Key] = true
|
||||
}
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func validAnswers(config OnboardingConfig, answers map[string]json.RawMessage) bool {
|
||||
questions := map[string]Question{}
|
||||
for _, question := range config.Questions {
|
||||
questions[question.Key] = question
|
||||
}
|
||||
for key, value := range answers {
|
||||
if _, ok := questions[key]; !ok {
|
||||
return false
|
||||
}
|
||||
if surveyTypes[key] == "boolean" {
|
||||
var answer *bool
|
||||
if json.Unmarshal(value, &answer) != nil || answer == nil {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
var answer *string
|
||||
if json.Unmarshal(value, &answer) != nil || answer == nil || len(*answer) > 2000 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, question := range config.Questions {
|
||||
value, ok := answers[question.Key]
|
||||
if question.Required {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if surveyTypes[question.Key] == "string" {
|
||||
var answer string
|
||||
if json.Unmarshal(value, &answer) != nil || strings.TrimSpace(answer) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *service) onboarding(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
config := s.onboardingConfigs[project]
|
||||
if config.Questions == nil {
|
||||
config.Questions = []Question{}
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
var answers, result json.RawMessage
|
||||
err := s.db.QueryRowContext(r.Context(), "SELECT answers,result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&answers, &result)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, response{"config": config, "completed": err == nil, "answers": answers, "result": result})
|
||||
return nil
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
var input struct {
|
||||
Username *string `json:"username"`
|
||||
TeamName string `json:"team_name"`
|
||||
GenerateInvite bool `json:"generate_invite"`
|
||||
Answers map[string]json.RawMessage `json:"answers"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
input.TeamName = strings.TrimSpace(input.TeamName)
|
||||
if input.Answers == nil {
|
||||
input.Answers = map[string]json.RawMessage{}
|
||||
}
|
||||
if input.Username != nil {
|
||||
value := strings.ToLower(strings.TrimSpace(*input.Username))
|
||||
input.Username = &value
|
||||
if !usernamePattern.MatchString(value) {
|
||||
reject(w, 400, "invalid_username")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if !validAnswers(config, input.Answers) || len(input.TeamName) > 160 || (config.RequireWorkspace && input.TeamName == "") || (input.GenerateInvite && input.TeamName == "") {
|
||||
reject(w, 400, "invalid_onboarding")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var locked string
|
||||
if err = tx.QueryRowContext(r.Context(), "SELECT id::text FROM project_accounts WHERE project=$1 AND id=$2 FOR UPDATE", project, account).Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
var prior json.RawMessage
|
||||
err = tx.QueryRowContext(r.Context(), "SELECT result FROM onboarding_responses WHERE project=$1 AND account_id=$2", project, account).Scan(&prior)
|
||||
if err == nil {
|
||||
reply(w, 200, response{"completed": true, "result": prior, "already_completed": true})
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if input.Username != nil {
|
||||
_, err = tx.ExecContext(r.Context(), "UPDATE project_accounts SET username=$3,updated_at=now() WHERE project=$1 AND id=$2", project, account, *input.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result := response{}
|
||||
var invitation string
|
||||
if input.TeamName != "" {
|
||||
workspace, err := createWorkspace(r.Context(), tx, project, account, input.TeamName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["workspace_id"] = workspace
|
||||
if input.GenerateInvite {
|
||||
token, hash := newLinkToken()
|
||||
var id string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO share_links(project,workspace_id,token_hash,role,created_by,expires_at)
|
||||
VALUES($1,$2,$3,'viewer',$4,now()+interval '7 days') RETURNING id::text
|
||||
`, project, workspace, hash, account).Scan(&id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["invite_id"] = id
|
||||
invitation = s.origin + "/s/" + token
|
||||
}
|
||||
}
|
||||
answers, _ := json.Marshal(input.Answers)
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
_, err = tx.ExecContext(r.Context(), "INSERT INTO onboarding_responses(project,account_id,answers,result) VALUES($1,$2,$3,$4)", project, account, answers, resultJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 201, response{"completed": true, "result": result, "invite_url": invitation, "already_completed": false})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"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
|
||||
}
|
||||
Reference in New Issue
Block a user