diff --git a/internal/platform/http.go b/internal/platform/http.go index 4067bdf..f9872c7 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -114,6 +114,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi 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)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index 14cff61..3f2496d 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -106,6 +106,11 @@ func TestOnboardingAndSharing(t *testing.T) { 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) diff --git a/internal/platform/shares.go b/internal/platform/shares.go index f660f99..6d5159a 100644 --- a/internal/platform/shares.go +++ b/internal/platform/shares.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "time" ) @@ -285,3 +286,71 @@ ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=CASE 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 +}