1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/url"
11 "slices"
12 "strconv"
13 "strings"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// ReleasesGitHub builds the GitHub REST representations of releases. Authorization
20// and all mutations remain in Releases; it owns no metadata or contents.
21type ReleasesGitHub struct {
22 rel *Releases
23 owner, publicURL string
24}
25
26var _ ports.GitHubReleaseAPI = (*ReleasesGitHub)(nil)
27
28// NewReleasesGitHub wraps the shared release service. publicURL is the external
29// server URL (without the API prefix), used in upload and browser download URLs.
30func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub {
31 return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")}
32}
33
34func (g *ReleasesGitHub) repo(owner, name string) (domain.RepoName, error) {
35 if !domain.OwnerMatches(g.owner, owner) {
36 return domain.RepoName{}, domain.ErrNotFound
37 }
38 rn, err := domain.ParseRepoName(strings.ToLower(name))
39 if err != nil {
40 return domain.RepoName{}, domain.ErrNotFound
41 }
42 return rn, nil
43}
44func (g *ReleasesGitHub) base(repo domain.RepoName) string {
45 return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String())
46}
47func (g *ReleasesGitHub) user(label string) map[string]any {
48 if label == "" || label == "root" || label == "owner" {
49 label = g.owner
50 }
51 u := ghURLs{server: g.publicURL, api: g.publicURL + apiPath}
52 return u.userObj(ghIdentity{login: label, id: stableID("user", label)}, false)
53}
54
55// escapeReleaseTag preserves tag separators: the web routes join the tag path.
56func escapeReleaseTag(tag string) string {
57 parts := strings.Split(tag, "/")
58 for i := range parts {
59 parts[i] = url.PathEscape(parts[i])
60 }
61 return strings.Join(parts, "/")
62}
63func (g *ReleasesGitHub) assetObj(a domain.ReleaseAsset, tag string) ordered {
64 return ordered{
65 {"url", g.base(a.Repo) + "/releases/assets/" + strconv.FormatInt(a.ID, 10)},
66 {"id", a.ID}, {"node_id", ""}, {"name", a.Name}, {"label", a.Label},
67 {"uploader", g.user(a.Uploader)}, {"content_type", a.ContentType}, {"state", "uploaded"},
68 {"size", a.Size}, {"digest", a.Digest}, {"download_count", 0},
69 {"created_at", a.CreatedAt.UTC().Format(timeLayout)}, {"updated_at", a.UpdatedAt.UTC().Format(timeLayout)},
70 {"browser_download_url", g.publicURL + "/" + a.Repo.String() + "/releases/download/" + url.PathEscape(tag) + "/" + url.PathEscape(a.Name)},
71 }
72}
73func (g *ReleasesGitHub) releaseObj(ctx context.Context, p domain.Principal, r domain.Release) (ordered, error) {
74 assets, err := g.rel.ListAssets(ctx, p, r.Repo, r.ID)
75 if err != nil {
76 return nil, err
77 }
78 objects := make([]ordered, 0, len(assets))
79 for _, a := range assets {
80 objects = append(objects, g.assetObj(a, r.Tag))
81 }
82 base := g.base(r.Repo) + "/releases/" + strconv.FormatInt(r.ID, 10)
83 var published any
84 if !r.Draft && !r.PublishedAt.IsZero() {
85 published = r.PublishedAt.UTC().Format(timeLayout)
86 }
87 return ordered{
88 {"url", base}, {"assets_url", base + "/assets"},
89 {"upload_url", g.publicURL + apiPath + "/uploads/repos/" + url.PathEscape(g.owner) + "/" + r.Repo.String() + "/releases/" + strconv.FormatInt(r.ID, 10) + "/assets{?name,label}"},
90 {"html_url", g.publicURL + "/" + r.Repo.String() + "/releases/tag/" + escapeReleaseTag(r.Tag)},
91 {"id", r.ID}, {"author", g.user(r.Author)}, {"node_id", ""}, {"tag_name", r.Tag}, {"target_commitish", r.TargetCommitish},
92 {"name", r.Title}, {"draft", r.Draft}, {"prerelease", r.Prerelease}, {"created_at", r.CreatedAt.UTC().Format(timeLayout)},
93 {"published_at", published}, {"assets", objects}, {"tarball_url", g.base(r.Repo) + "/tarball/" + escapeReleaseTag(r.Tag)},
94 {"zipball_url", nil}, {"body", r.Notes},
95 }, nil
96}
97func (g *ReleasesGitHub) marshalRelease(ctx context.Context, p domain.Principal, r domain.Release) ([]byte, error) {
98 o, err := g.releaseObj(ctx, p, r)
99 if err != nil {
100 return nil, err
101 }
102 return json.Marshal(o)
103}
104func releaseFields(body []byte) (map[string]json.RawMessage, error) {
105 var fields map[string]json.RawMessage
106 if err := json.Unmarshal(body, &fields); err != nil || fields == nil {
107 return nil, fmt.Errorf("%w: JSON object required", domain.ErrInvalid)
108 }
109 return fields, nil
110}
111func releaseField[T any](fields map[string]json.RawMessage, key string) (*T, error) {
112 b, ok := fields[key]
113 if !ok {
114 return nil, nil
115 }
116 var v T
117 if string(b) == "null" || json.Unmarshal(b, &v) != nil {
118 return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, key)
119 }
120 return &v, nil
121}
122func releaseParams(body []byte) (ports.UpdateReleaseParams, error) {
123 var p ports.UpdateReleaseParams
124 f, err := releaseFields(body)
125 if err != nil {
126 return p, err
127 }
128 for key, dst := range map[string]**string{"tag_name": &p.Tag, "target_commitish": &p.TargetCommitish, "name": &p.Title, "body": &p.Notes} {
129 *dst, err = releaseField[string](f, key)
130 if err != nil {
131 return p, err
132 }
133 }
134 for key, dst := range map[string]**bool{"draft": &p.Draft, "prerelease": &p.Prerelease} {
135 *dst, err = releaseField[bool](f, key)
136 if err != nil {
137 return p, err
138 }
139 }
140 latest, err := releaseField[string](f, "make_latest")
141 if err != nil {
142 return p, err
143 }
144 if latest != nil {
145 switch *latest {
146 case "true":
147 v := true
148 p.MakeLatest = &v
149 case "false":
150 v := false
151 p.MakeLatest = &v
152 case "legacy":
153 default:
154 return p, fmt.Errorf("%w: make_latest", domain.ErrInvalid)
155 }
156 }
157 return p, nil
158}
159func value[T any](p *T) (v T) {
160 if p != nil {
161 return *p
162 }
163 return v
164}
165
166// CreateRelease accepts GitHub's create body and returns its representation.
167func (g *ReleasesGitHub) CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) {
168 rn, err := g.repo(owner, repo)
169 if err != nil {
170 return nil, err
171 }
172 params, err := releaseParams(body)
173 if err != nil {
174 return nil, err
175 }
176 if params.Tag == nil || *params.Tag == "" {
177 return nil, fmt.Errorf("%w: tag_name", domain.ErrInvalid)
178 }
179 r, err := g.rel.CreateRelease(ctx, p, rn, ports.CreateReleaseParams{Tag: *params.Tag, TargetCommitish: value(params.TargetCommitish), Title: value(params.Title), Notes: value(params.Notes), Draft: value(params.Draft), Prerelease: value(params.Prerelease), MakeLatest: value(params.MakeLatest)})
180 if err != nil {
181 return nil, err
182 }
183 ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", r.ID).Msg("github release created")
184 return g.marshalRelease(ctx, p, r)
185}
186
187// GetRelease resolves an id, tag or latest selector.
188func (g *ReleasesGitHub) GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ports.ReleaseRef) ([]byte, error) {
189 rn, err := g.repo(owner, repo)
190 if err != nil {
191 return nil, err
192 }
193 r, err := g.rel.GetRelease(ctx, p, rn, ref)
194 if err != nil {
195 return nil, err
196 }
197 return g.marshalRelease(ctx, p, r)
198}
199
200// UpdateRelease changes only present fields, including explicit false and empty strings.
201func (g *ReleasesGitHub) UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) {
202 rn, err := g.repo(owner, repo)
203 if err != nil {
204 return nil, err
205 }
206 params, err := releaseParams(body)
207 if err != nil {
208 return nil, err
209 }
210 r, err := g.rel.UpdateRelease(ctx, p, rn, id, params)
211 if err != nil {
212 return nil, err
213 }
214 ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release updated")
215 return g.marshalRelease(ctx, p, r)
216}
217
218// DeleteRelease removes a release and all its assets.
219func (g *ReleasesGitHub) DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error {
220 rn, err := g.repo(owner, repo)
221 if err != nil {
222 return err
223 }
224 if err := g.rel.DeleteRelease(ctx, p, rn, id); err != nil {
225 return err
226 }
227 ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release deleted")
228 return nil
229}
230func releasePagination(page, perPage int) (int, int) {
231 if page < 1 {
232 page = 1
233 }
234 if perPage < 1 {
235 perPage = 30
236 }
237 return page, min(perPage, 100)
238}
239func releasePageBounds(page, perPage, total int) (int, int) {
240 // Avoid overflow on arbitrary query parameters.
241 if page-1 > total/perPage {
242 return total, total
243 }
244 start := min((page-1)*perPage, total)
245 return start, min(start+perPage, total)
246}
247
248// ListReleases returns visible releases newest first and GitHub Link pagination.
249func (g *ReleasesGitHub) ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) ([]byte, string, error) {
250 rn, err := g.repo(owner, repo)
251 if err != nil {
252 return nil, "", err
253 }
254 if _, err = g.rel.readable(ctx, p, rn); err != nil {
255 return nil, "", err
256 }
257 all, err := g.rel.store.ListReleases(ctx, rn)
258 if err != nil {
259 return nil, "", err
260 }
261 all = slices.DeleteFunc(all, func(r domain.Release) bool { return r.Draft && !releaseWriter(p, rn) })
262 slices.SortFunc(all, func(a, b domain.Release) int {
263 if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
264 return c
265 }
266 if a.ID > b.ID {
267 return -1
268 }
269 if a.ID < b.ID {
270 return 1
271 }
272 return 0
273 })
274 page, perPage = releasePagination(page, perPage)
275 start, end := releasePageBounds(page, perPage, len(all))
276 objects := make([]ordered, 0, end-start)
277 for _, r := range all[start:end] {
278 o, e := g.releaseObj(ctx, p, r)
279 if e != nil {
280 return nil, "", e
281 }
282 objects = append(objects, o)
283 }
284 b, err := json.Marshal(objects)
285 return b, pullFilesLink(g.base(rn)+"/releases", page, perPage, len(all)), err
286}
287
288// ListAssets lists uploaded assets with the same pagination conventions.
289func (g *ReleasesGitHub) ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) ([]byte, string, error) {
290 rn, err := g.repo(owner, repo)
291 if err != nil {
292 return nil, "", err
293 }
294 r, err := g.rel.GetRelease(ctx, p, rn, ports.ReleaseRef{ID: id})
295 if err != nil {
296 return nil, "", err
297 }
298 all, err := g.rel.ListAssets(ctx, p, rn, id)
299 if err != nil {
300 return nil, "", err
301 }
302 page, perPage = releasePagination(page, perPage)
303 start, end := releasePageBounds(page, perPage, len(all))
304 objects := make([]ordered, 0, end-start)
305 for _, a := range all[start:end] {
306 objects = append(objects, g.assetObj(a, r.Tag))
307 }
308 b, err := json.Marshal(objects)
309 return b, pullFilesLink(g.base(rn)+"/releases/"+strconv.FormatInt(id, 10)+"/assets", page, perPage, len(all)), err
310}
311func (g *ReleasesGitHub) marshalAsset(ctx context.Context, p domain.Principal, a domain.ReleaseAsset) ([]byte, error) {
312 r, err := g.rel.GetRelease(ctx, p, a.Repo, ports.ReleaseRef{ID: a.ReleaseID})
313 if err != nil {
314 return nil, err
315 }
316 return json.Marshal(g.assetObj(a, r.Tag))
317}
318
319// UploadAsset streams an exact-sized raw body; duplicate names are not replaced.
320func (g *ReleasesGitHub) UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, body io.Reader) ([]byte, error) {
321 rn, err := g.repo(owner, repo)
322 if err != nil {
323 return nil, err
324 }
325 a, err := g.rel.UploadAsset(ctx, p, rn, ports.UploadAssetParams{ReleaseID: id, Name: name, Label: label, ContentType: contentType, Size: size}, body)
326 if err != nil {
327 return nil, err
328 }
329 return g.marshalAsset(ctx, p, a)
330}
331
332// AssetByID resolves a visible asset, including its repository ownership.
333func (g *ReleasesGitHub) AssetByID(ctx context.Context, p domain.Principal, owner, repo string, id int64) (domain.ReleaseAsset, error) {
334 rn, err := g.repo(owner, repo)
335 if err != nil {
336 return domain.ReleaseAsset{}, err
337 }
338 return g.rel.GetAsset(ctx, p, rn, ports.AssetRef{ID: id})
339}
340
341// GetAsset returns the JSON representation, not the contents.
342func (g *ReleasesGitHub) GetAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) ([]byte, error) {
343 a, err := g.AssetByID(ctx, p, owner, repo, id)
344 if err != nil {
345 return nil, err
346 }
347 return g.marshalAsset(ctx, p, a)
348}
349
350// UpdateAsset changes the name and label fields that are present.
351func (g *ReleasesGitHub) UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) {
352 rn, err := g.repo(owner, repo)
353 if err != nil {
354 return nil, err
355 }
356 f, err := releaseFields(body)
357 if err != nil {
358 return nil, err
359 }
360 name, err := releaseField[string](f, "name")
361 if err != nil {
362 return nil, err
363 }
364 label, err := releaseField[string](f, "label")
365 if err != nil {
366 return nil, err
367 }
368 a, err := g.rel.UpdateAsset(ctx, p, rn, id, ports.UpdateAssetParams{Name: name, Label: label})
369 if err != nil {
370 return nil, err
371 }
372 ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset updated")
373 return g.marshalAsset(ctx, p, a)
374}
375
376// DeleteAsset removes the asset record and its blob.
377func (g *ReleasesGitHub) DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) error {
378 rn, err := g.repo(owner, repo)
379 if err != nil {
380 return err
381 }
382 if err := g.rel.DeleteAsset(ctx, p, rn, id); err != nil {
383 return err
384 }
385 ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset deleted")
386 return nil
387}