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_countnow(), (max_uses IS NULL OR use_count 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 }