Files
foundry-core/internal/platform/shares.go
T
codex-bot e0ba9c08e8
Foundry Go checks / check (push) Successful in 1m47s
Foundry Go checks / check (pull_request) Successful in 1m51s
Expose paginated share redirect events to workspace admins
2026-11-05 18:39:23 +08:00

357 lines
11 KiB
Go

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
}