tenseleyflow/shithub / 0059e18

Browse files

S26: PAT-authenticated star endpoints under /api/v1/user/starred

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0059e1873ded17b04f66456b26140a9b2ee84d68
Parents
8cbd63b
Tree
5dea7bd

2 changed files

StatusFile+-
M internal/web/handlers/api/api.go 2 0
A internal/web/handlers/api/stars.go 144 0
internal/web/handlers/api/api.gomodified
@@ -75,6 +75,8 @@ func (h *Handlers) Mount(r chi.Router) {
7575
 		// inside the helper since reads need repo:read but writes need
7676
 		// repo:write.
7777
 		h.mountChecks(r)
78
+		// S26 stars: PUT/DELETE need user:write, GET needs user:read.
79
+		h.mountStars(r)
7880
 	})
7981
 }
8082
 
internal/web/handlers/api/stars.goadded
@@ -0,0 +1,144 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"net/http"
7
+	"strconv"
8
+	"time"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
14
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/social"
17
+	socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+// mountStars registers the S26 PAT-authenticated star endpoints.
23
+//
24
+//	PUT    /api/v1/user/starred/{owner}/{repo} — idempotent star
25
+//	DELETE /api/v1/user/starred/{owner}/{repo} — idempotent unstar
26
+//	GET    /api/v1/user/starred                 — list current user's stars
27
+//
28
+// Scopes: user:write for star/unstar, user:read for list. Same shape
29
+// as the existing /api/v1/user route (which uses user:read).
30
+func (h *Handlers) mountStars(r chi.Router) {
31
+	r.Group(func(r chi.Router) {
32
+		r.Use(middleware.RequireScope(pat.ScopeUserWrite))
33
+		r.Put("/api/v1/user/starred/{owner}/{repo}", h.starPut)
34
+		r.Delete("/api/v1/user/starred/{owner}/{repo}", h.starDelete)
35
+	})
36
+	r.Group(func(r chi.Router) {
37
+		r.Use(middleware.RequireScope(pat.ScopeUserRead))
38
+		r.Get("/api/v1/user/starred", h.starsList)
39
+	})
40
+}
41
+
42
+func (h *Handlers) starPut(w http.ResponseWriter, r *http.Request) {
43
+	repo, ok := h.resolveStarTargetRepo(w, r)
44
+	if !ok {
45
+		return
46
+	}
47
+	auth := middleware.PATAuthFromContext(r.Context())
48
+	if err := social.Star(r.Context(), social.Deps{Pool: h.d.Pool}, auth.UserID, repo.ID, repo.Visibility == reposdb.RepoVisibilityPublic); err != nil {
49
+		writeAPIError(w, http.StatusInternalServerError, "star failed")
50
+		return
51
+	}
52
+	w.WriteHeader(http.StatusNoContent)
53
+}
54
+
55
+func (h *Handlers) starDelete(w http.ResponseWriter, r *http.Request) {
56
+	repo, ok := h.resolveStarTargetRepo(w, r)
57
+	if !ok {
58
+		return
59
+	}
60
+	auth := middleware.PATAuthFromContext(r.Context())
61
+	if err := social.Unstar(r.Context(), social.Deps{Pool: h.d.Pool}, auth.UserID, repo.ID, repo.Visibility == reposdb.RepoVisibilityPublic); err != nil {
62
+		writeAPIError(w, http.StatusInternalServerError, "unstar failed")
63
+		return
64
+	}
65
+	w.WriteHeader(http.StatusNoContent)
66
+}
67
+
68
+// starsList paginates the caller's starred repos.
69
+func (h *Handlers) starsList(w http.ResponseWriter, r *http.Request) {
70
+	auth := middleware.PATAuthFromContext(r.Context())
71
+	if auth.UserID == 0 {
72
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
73
+		return
74
+	}
75
+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
76
+	if page < 1 {
77
+		page = 1
78
+	}
79
+	const pageSize = 50
80
+	rows, err := socialdb.New().ListStarsForUser(r.Context(), h.d.Pool, socialdb.ListStarsForUserParams{
81
+		UserID: auth.UserID, Limit: pageSize, Offset: int32((page - 1) * pageSize),
82
+	})
83
+	if err != nil {
84
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
85
+		return
86
+	}
87
+	out := make([]map[string]any, 0, len(rows))
88
+	for _, s := range rows {
89
+		out = append(out, map[string]any{
90
+			"repo_id":          s.RepoID,
91
+			"name":             s.RepoName,
92
+			"description":      s.Description,
93
+			"visibility":       string(s.Visibility),
94
+			"star_count":       s.StarCount,
95
+			"primary_language": pgTextString(s.PrimaryLanguage),
96
+			"starred_at":       s.StarredAt.Time.Format(time.RFC3339),
97
+		})
98
+	}
99
+	writeJSON(w, http.StatusOK, map[string]any{"page": page, "starred": out})
100
+}
101
+
102
+// resolveStarTargetRepo resolves /{owner}/{repo} into a repo row,
103
+// gates by policy.ActionStarCreate (which checks visibility +
104
+// suspension + login), and returns 404-shaped denials so private-
105
+// repo existence isn't leaked.
106
+func (h *Handlers) resolveStarTargetRepo(w http.ResponseWriter, r *http.Request) (reposdb.Repo, bool) {
107
+	auth := middleware.PATAuthFromContext(r.Context())
108
+	if auth.UserID == 0 {
109
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
110
+		return reposdb.Repo{}, false
111
+	}
112
+	owner, err := usersdb.New().GetUserByUsername(r.Context(), h.d.Pool, chi.URLParam(r, "owner"))
113
+	if err != nil {
114
+		writeAPIError(w, http.StatusNotFound, "repo not found")
115
+		return reposdb.Repo{}, false
116
+	}
117
+	repo, err := reposdb.New().GetRepoByOwnerUserAndName(r.Context(), h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
118
+		OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
119
+		Name:        chi.URLParam(r, "repo"),
120
+	})
121
+	if err != nil {
122
+		writeAPIError(w, http.StatusNotFound, "repo not found")
123
+		return reposdb.Repo{}, false
124
+	}
125
+	// PAT-auth path: the middleware already rejected suspended
126
+	// accounts; passing IsSuspended=false here is correct by
127
+	// construction (documented in docs/internal/permissions.md).
128
+	actor := policy.UserActor(auth.UserID, "", false, false)
129
+	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionStarCreate, policy.NewRepoRefFromRepo(repo)).Allow {
130
+		writeAPIError(w, http.StatusNotFound, "repo not found")
131
+		return reposdb.Repo{}, false
132
+	}
133
+	return repo, true
134
+}
135
+
136
+// pgTextString unwraps a nullable text column for JSON output. nil
137
+// when invalid so encoding/json emits `null`.
138
+func pgTextString(t pgtype.Text) any {
139
+	if !t.Valid {
140
+		return nil
141
+	}
142
+	return t.String
143
+}
144
+