357 lines
11 KiB
Go
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
|
|
}
|