✨ (releases): Attach and download prebuilt release assets
Changes
31 files changed, +1518 -72
MODIFY
README.md
+17 -2
@@ -36,6 +36,18 @@
36
36
release links source archives, served for any ref at `…/archive/{ref}.zip` and `…/archive/{ref}.tar.gz`
37
37
(streamed from the object database, JDK-only zip/ustar writer, nested under a `<repo>-<ref>/` prefix).
38
38
Guide: [releases](docs/users/releases.md)
39
+- **Release assets** — a release also carries **uploaded prebuilt files**: APKs, JARs, installers,
40
+ checksum lists — the artifacts that cannot be derived from the repository, unlike the generated
41
+ source archives. Attached from the release page or over REST
42
+ (`POST …/releases/tags/{tag}/assets`, so a CI pipeline can publish its build output with a personal
43
+ access token), listed with size and per-asset download count, and downloaded at
44
+ `…/releases/assets/{id}/{fileName}`. Bytes stream to and from the filesystem keyed by asset UUID —
45
+ never by the uploader's file name — capped by `GITSHARK_MAX_ASSET_SIZE` (200 MB default), and always
46
+ served as an attachment with `nosniff` so an uploaded content type can never render on the instance's
47
+ origin. Downloads follow the repository's read rule, so a private repo's artifacts stay private
48
+ (`404`, not `403`); deleting an asset, a release, or the repository removes the stored bytes. Guides:
49
+ [for users](docs/users/releases.md), [for admins](docs/admins/releases.md),
50
+ [architecture](docs/maintainers/releases.md)
39
51
- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), an author, and an optional assignee and reviewer (any local user, set by username from a GitHub-style picker that suggests the repo owner, its collaborators, and the repository's top commit authors; blank clears); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches. Merge-request pages are addressed by number (`…/merge-requests/1`, matching the issue URL scheme; the REST API exposes the same objects as Gitea pull requests under `…/pulls/1`); old UUID URLs redirect permanently to the number form
40
52
- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
41
53
- Dashboard notifications: the signed-in home page (`/`) surfaces the open issues and merge requests you are involved in — ones you authored, are assigned to, or (for merge requests) are asked to review — each linking straight to the item and labelled with its repository; items in a repository you can no longer read never appear. Built on a pluggable `NotificationSource` aggregation, so future item types can contribute without touching the dashboard
@@ -77,7 +89,7 @@
77
89
(Renovate, `tea`) can drive it (see below)
78
90
- **MCP server** at `/mcp` (Streamable HTTP), exposing most of the REST API surface as
79
91
MCP tools so an AI client can manage repositories, issues, merge requests, and MR line-comments
80
- (releases are REST-only so far; see below)
92
+ (releases and release assets are REST-only so far; see below)
81
93
- **Push mirrors** — the repository owner can replicate a repository to external remotes on
82
94
every push (`git push --mirror` semantics, all refs including deletions), over HTTPS with
83
95
stored credentials or over SSH with a server-generated Ed25519 deploy key. Syncs run
@@ -183,6 +195,8 @@
183
195
| GET, POST | `/api/v1/repos/{owner}/{name}/releases` | List / publish releases (`{tag_name, target_commitish, name, body, prerelease}`) |
184
196
| GET | `/api/v1/repos/{owner}/{name}/releases/latest` | Newest non-prerelease release (`404` when there is none) |
185
197
| GET, PATCH, DELETE | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}` | Get / edit / delete a release by tag (the git tag is kept) |
198
+| GET, POST | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets` | List / upload release assets (`multipart/form-data`, field `attachment`) |
199
+| GET, DELETE | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets/{fileName}` | Download / delete a release asset (addressed by name; the numeric `id` is a one-way surrogate) |
186
200
| GET | `/api/v1/repos/{owner}/{name}/labels` | Labels (empty — no label model yet) |
187
201
| GET | `/api/v1/repos/{owner}/{name}/commits/{ref}/status` | Combined commit status (all-clear stub — no status store yet) |
188
202
| GET, POST | `/api/v1/repos/{owner}/{name}/commits/{ref}/statuses` · `/statuses/{sha}` | List statuses (empty) / post a status (echoed, not persisted; needs write) |
@@ -289,8 +303,9 @@
289
303
290
304
| Store | What |
291
305
|---|---|
292
-| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), `releases` (title, notes, pre-release flag and published commit per tag), push-mirror tables (`push_mirror` with AES-GCM-encrypted secrets, `mirror_sync` queue), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `remote_follows`, `remote_user_follows`, `received_pushes`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
306
+| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), `releases` (title, notes, pre-release flag and published commit per tag), `release_assets` (asset name, content type, size and download count — not the bytes), push-mirror tables (`push_mirror` with AES-GCM-encrypted secrets, `mirror_sync` queue), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `remote_follows`, `remote_user_follows`, `received_pushes`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
293
307
| Filesystem (`GITSHARK_STORAGE_ROOT`) | Bare Git repositories |
308
+| Filesystem (`GITSHARK_RELEASE_ASSET_ROOT`) | Uploaded release assets, one file per asset keyed by asset UUID |
294
309
| Filesystem (`GITSHARK_AVATAR_ROOT`) | Uploaded profile pictures, one file per user (UUID-named) |
295
310
| Filesystem (`GITSHARK_SSH_HOST_KEY`) | SSH host key |
296
311
MODIFY
docs/README.md
+8 -7
@@ -16,7 +16,8 @@
16
16
- **[Forking](users/forking.md)** — make your own copy of any repository you can
17
17
read, what a fork carries, and the one-fork-per-name rule.
18
18
- **[Releases](users/releases.md)** — publish a tag with title, Markdown notes
19
- and source archives, the Latest badge, pre-releases, and today's limits.
19
+ and source archives, attach prebuilt binaries (APKs, JARs, installers) for
20
+ people to download, the Latest badge, pre-releases, publishing from CI.
20
21
- **[Search](users/search.md)** — the header search box: case-insensitive
21
22
substring search over repositories and people, and how visibility is honored.
22
23
- **[Profile settings](users/profile.md)** — change your username and display
@@ -51,14 +52,14 @@
51
52
access.
52
53
- **[Forks](admins/forking.md)** — the fork endpoints, the `parent_repo_id`
53
54
column and its `ON DELETE SET NULL` semantics, and storage implications.
54
-- **[Releases](admins/releases.md)** — the release and archive endpoints, tag
55
- creation on publish, the `releases` table, and archive streaming behavior
56
- (no configuration needed).
55
+- **[Releases](admins/releases.md)** — the release, archive and asset endpoints,
56
+ tag creation on publish, asset storage with its two upload size limits and why
57
+ both matter, the `releases`/`release_assets` tables, and troubleshooting.
57
58
- **[Search](admins/search.md)** — the `/search` and `/api/v1/search` endpoints,
58
59
JSON shape, matching semantics, visibility enforcement (no configuration).
59
60
- **[Persistent data](admins/persistent-data.md)** — every store that must survive
60
- container recreation (database, repositories, avatars, SSH host key), what breaks
61
- when each is lost, and how to retrofit older deployments.
61
+ container recreation (database, repositories, avatars, release assets, SSH host
62
+ key), what breaks when each is lost, and how to retrofit older deployments.
62
63
- **[Federation](admins/federation.md)** — enable and operate ForgeFed federation:
63
64
configuration, allowlist, reverse-proxy requirements, delivery queue, monitoring.
64
65
- **[Push mirrors](admins/mirrors.md)** — the secret key, outbound network and
@@ -95,7 +96,7 @@
95
96
visibility-guarded serving endpoint.
96
97
- **[Releases architecture](maintainers/releases.md)** — releases as an
97
98
annotation over tags, in-core tag creation, the dependency-free archive
98
- writer, and the works/gaps list.
99
+ writer, generated archives versus uploaded assets, and the works/gaps list.
99
100
- **[ForgeFed architecture](maintainers/forgefed.md)** — how federation is
100
101
implemented, the decisions behind it, what works and what is still missing.
101
102
- **[Federated collaboration roadmap](maintainers/federation-roadmap.md)** — the
MODIFY
docs/admins/getting-started.md
+18 -7
@@ -201,6 +201,7 @@
201
201
GITSHARK_STORAGE_ROOT: /data/repositories
202
202
GITSHARK_AVATAR_ROOT: /data/avatars
203
203
GITSHARK_REPO_IMAGE_ROOT: /data/repo-images
204
+ GITSHARK_RELEASE_ASSET_ROOT: /data/release-assets
204
205
GITSHARK_SSH_HOST_KEY: /data/ssh/host-key
205
206
GITSHARK_SSH_PORT: "2222" # bind port INSIDE the container (>1024 → no root needed)
206
207
GITSHARK_SSH_EXTERNAL_PORT: "22" # port shown in clone URLs; MUST match the published host port below
@@ -210,6 +211,7 @@
210
211
- repos:/data/repositories # bare git repositories
211
212
- avatars:/data/avatars # user profile pictures
212
213
- repo-images:/data/repo-images # per-repository images
214
+ - release-assets:/data/release-assets # uploaded release binaries
213
215
- ssh:/data/ssh # persistent SSH host key
214
216
healthcheck:
215
217
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080 && echo ok >&3"]
@@ -223,20 +225,22 @@
223
225
repos:
224
226
avatars:
225
227
repo-images:
228
+ release-assets:
226
229
ssh:
227
230
```
228
231
229
232
Notes:
230
233
231
234
- **Separate volumes for `/data/repositories`, `/data/avatars`, `/data/repo-images`,
232
- and `/data/ssh`** so Docker creates each mount point with the right ownership — no
235
+ `/data/release-assets`, and `/data/ssh`** so Docker creates each mount point with
236
+ the right ownership — no
233
237
init container or `mkdir` needed. This works because the image ships those
234
238
directories pre-owned by the runtime user, and Docker copies that ownership into a
235
239
freshly created named volume. Volumes first created with an image from before this
236
240
fix are root-owned and stay that way — see
237
241
[Persistent data](persistent-data.md#fixing-root-owned-data-volumes) for the
238
242
one-time repair. The SSH host key is generated on first boot and
239
- persists across restarts (so client `known_hosts` entries stay valid). All four mounts
243
+ persists across restarts (so client `known_hosts` entries stay valid). All five mounts
240
244
(plus the database) are mandatory for a stateful deployment — see
241
245
[Persistent data](persistent-data.md) for what each holds and what breaks without it.
242
246
- **HTTP port 8080 is not published** — it's reached through the reverse proxy on the
@@ -354,6 +358,9 @@
354
358
| `GITSHARK_STORAGE_ROOT` | — | `data/repositories` | On-disk bare-repo root |
355
359
| `GITSHARK_AVATAR_ROOT` | — | `data/avatars` | On-disk profile-picture (avatar) storage root |
356
360
| `GITSHARK_REPO_IMAGE_ROOT` | — | `data/repo-images` | On-disk per-repository image storage root |
361
+| `GITSHARK_RELEASE_ASSET_ROOT` | — | `data/release-assets` | On-disk release-asset storage root (see [Releases](releases.md)) |
362
+| `GITSHARK_MAX_ASSET_SIZE` | — | `200M` | Largest single release-asset upload |
363
+| `GITSHARK_MAX_BODY_SIZE` | — | `210M` | HTTP request-body cap; must stay above `GITSHARK_MAX_ASSET_SIZE` |
357
364
| `GITSHARK_SSH_HOST_KEY` | — | `data/ssh/host-key` | Persistent SSH host key path |
358
365
| `GITSHARK_SSH_PORT` | — | `2222` | Port the embedded SSH server **binds inside the container**; keep >1024 so it needs no root |
359
366
| `GITSHARK_SSH_EXTERNAL_PORT` | — | `22` | Port advertised in clone/push URLs (display only, no runtime effect). Must match the published host port; `22` is omitted from the printed URL |
@@ -414,7 +421,7 @@
414
421
415
422
## Operations
416
423
417
-**Backups** — four things hold state (full inventory, including what breaks when each
424
+**Backups** — five things hold state (full inventory, including what breaks when each
418
425
is lost, in [Persistent data](persistent-data.md)):
419
426
- The `db-data` volume (metadata: users, repo records, issues, MRs, comments;
420
427
also each avatar's and repository image's content type and update timestamp —
@@ -422,16 +429,20 @@
422
429
- The `repos` volume (the actual git objects).
423
430
- The `avatars` volume (uploaded profile-picture bytes, one file per user).
424
431
- The `repo-images` volume (uploaded per-repository image bytes, one file per repo).
432
+- The `release-assets` volume (uploaded release binaries, one file per asset).
433
+ These are build outputs and **cannot be recovered from git** — if you lose them,
434
+ whoever built them must re-upload.
425
435
426
-Back all four up together and consistently. A logical DB dump:
436
+Back all five up together and consistently. A logical DB dump:
427
437
428
438
```bash
429
439
docker compose exec db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > gitshark-db.sql
430
440
```
431
441
432
-Snapshot the `repos`, `avatars`, and `repo-images` volumes with your host's
433
-volume/snapshot tooling while the app is quiesced (or accept crash-consistent
434
-snapshots — bare repos, avatar, and repository-image files all tolerate them well).
442
+Snapshot the `repos`, `avatars`, `repo-images`, and `release-assets` volumes with
443
+your host's volume/snapshot tooling while the app is quiesced (or accept
444
+crash-consistent snapshots — bare repos, images, and release assets all tolerate
445
+them well).
435
446
436
447
**Upgrades** — pull the new image and recreate the app:
437
448
MODIFY
docs/admins/persistent-data.md
+7 -6
@@ -1,6 +1,6 @@
1
1
# Persistent data: what your deployment must store
2
2
3
-git-shark keeps state in **one database and four filesystem locations**. Every one of
3
+git-shark keeps state in **one database and five filesystem locations**. Every one of
4
4
them must live on a persistent volume — anything written to the container's own
5
5
filesystem is lost when the container is recreated (every `docker compose up` after an
6
6
image update, config change, or host reboot).
@@ -8,14 +8,15 @@
8
8
Use this page as the checklist when setting up volumes, writing backup jobs, or
9
9
migrating a deployment to a new host.
10
10
11
-## The five stores
11
+## The six stores
12
12
13
13
| Store | Configured by | Default (in container) | Contents | If you lose it |
14
14
|---|---|---|---|---|
15
-| PostgreSQL | `QUARKUS_DATASOURCE_*` | external service | Users, repository records, issues, merge requests, comments (issue discussion in `issue_comments`; merge-request discussion and per-line review comments share `merge_request_comments`), releases (title, notes and pre-release flag per tag — the tags themselves live in git), SSH public keys, access-token hashes, push-mirror and federation state, CI runner registration-token and runner records (hashed secrets). Also each avatar's and repository image's content type and update timestamp — but **not** the image bytes. | Everything except the raw git objects and images. Total loss. |
15
+| PostgreSQL | `QUARKUS_DATASOURCE_*` | external service | Users, repository records, issues, merge requests, comments (issue discussion in `issue_comments`; merge-request discussion and per-line review comments share `merge_request_comments`), releases (title, notes and pre-release flag per tag — the tags themselves live in git) and their asset metadata (name, size, download count — **not** the asset bytes), SSH public keys, access-token hashes, push-mirror and federation state, CI runner registration-token and runner records (hashed secrets). Also each avatar's and repository image's content type and update timestamp — but **not** the image bytes. | Everything except the raw git objects, images, and release-asset bytes. Total loss. |
16
16
| Repositories | `GITSHARK_STORAGE_ROOT` | `data/repositories` | The bare git repositories (all commits, branches, tags). | All hosted code. The DB rows survive but point at nothing. |
17
17
| Avatars | `GITSHARK_AVATAR_ROOT` | `data/avatars` | Uploaded profile pictures, one file per user, named by user UUID. | Profile pictures render as broken images (the DB still says the user has one, but `GET /users/{username}/avatar` returns 404). Users must re-upload. |
18
18
| Repository images | `GITSHARK_REPO_IMAGE_ROOT` | `data/repo-images` | Uploaded per-repository images, one file per repository, named by repository UUID. | Repository images render as broken images (the DB still says the repo has one, but `GET /repos/{owner}/{name}/image` returns 404); repos fall back to the owner's avatar once the DB row is also cleared. Owners must re-upload. |
19
+| Release assets | `GITSHARK_RELEASE_ASSET_ROOT` | `data/release-assets` | Uploaded release binaries (APKs, JARs, installers), one file per asset, named by asset UUID. | Every release download 404s (the DB still lists the asset with its name, size and download count). These are build outputs and cannot be recovered from git — whoever built them must re-upload. |
19
20
| SSH host key | `GITSHARK_SSH_HOST_KEY` | `data/ssh/host-key` | The server's SSH host key, generated on first boot. | A new key is generated; every git client sees a host-key-changed warning and refuses to connect until `known_hosts` is fixed. |
20
21
21
22
If you front git-shark with Caddy as in the [Getting Started](getting-started.md) guide,
@@ -23,7 +24,7 @@
23
24
Let's Encrypt account.
24
25
25
26
In the reference Compose file all of these are named volumes: `db-data`, `repos`,
26
-`avatars`, `repo-images`, and `ssh`. The defaults above are *relative* paths — inside a container they
27
+`avatars`, `repo-images`, `release-assets`, and `ssh`. The defaults above are *relative* paths — inside a container they
27
28
resolve to a directory that vanishes with the container, so production deployments must
28
29
set the `GITSHARK_*` variables to absolute paths on mounted volumes, exactly as the
29
30
reference Compose file does.
@@ -31,7 +32,7 @@
31
32
## Checking a running deployment
32
33
33
34
```bash
34
-docker compose exec app sh -c 'ls /data/repositories /data/avatars /data/repo-images /data/ssh'
35
+docker compose exec app sh -c 'ls /data/repositories /data/avatars /data/repo-images /data/release-assets /data/ssh'
35
36
docker inspect --format '{{range .Mounts}}{{.Destination}} <- {{.Source}}{{println}}{{end}}' \
36
37
"$(docker compose ps -q app)"
37
38
```
@@ -92,6 +93,6 @@
92
93
93
94
## Backups
94
95
95
-Back up all five stores together and consistently — a DB dump that references git
96
+Back up all six stores together and consistently — a DB dump that references git
96
97
objects, avatar, or repository-image files from a different point in time is only crash-consistent. See
97
98
[Getting Started → Operations](getting-started.md#operations) for the commands.
MODIFY
docs/admins/releases.md
+88 -4
@@ -1,8 +1,12 @@
1
1
# Releases
2
2
3
3
Releases annotate git tags with a title, Markdown notes and a pre-release flag,
4
-and expose source-code archives of any ref. **No configuration is needed** — the
5
-feature is always on and adds no `GITSHARK_*` property.
4
+expose source-code archives of any ref, and carry **uploaded prebuilt files**
5
+(APKs, JARs, installers) as release assets.
6
+
7
+Notes, archives and tag creation need **no configuration**. Asset uploads add
8
+three properties and one filesystem store — see [Asset storage and
9
+limits](#asset-storage-and-limits) below.
6
10
7
11
## Endpoints
8
12
@@ -20,6 +24,12 @@
20
24
| `POST` | `/api/v1/repos/{owner}/{name}/releases` | Token + repository write |
21
25
| `GET` | `/api/v1/repos/{owner}/{name}/releases/latest` | Repository read |
22
26
| `GET`/`PATCH`/`DELETE` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}` | Read / token + write |
27
+| `POST` | `/repos/{owner}/{name}/releases/tag/{tag}/assets` | Repository write (multipart, field `asset`) |
28
+| `GET` | `/repos/{owner}/{name}/releases/assets/{id}/{fileName}` | Repository read |
29
+| `POST` | `/repos/{owner}/{name}/releases/assets/{id}/delete` | Repository write |
30
+| `GET` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets` | Repository read |
31
+| `POST` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets` | Token + write (multipart, field `attachment`) |
32
+| `GET`/`DELETE` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets/{fileName}` | Read / token + write |
23
33
24
34
Write access follows the single access policy: personal-repo owner and
25
35
collaborators, organisation MEMBER and above. A private repository's release and
@@ -45,6 +55,54 @@
45
55
Responses carry `Content-Disposition: attachment` and `application/zip` /
46
56
`application/gzip`. They are generated per request and are not cached.
47
57
58
+## Asset storage and limits
59
+
60
+| Variable | Default | Purpose |
61
+|---|---|---|
62
+| `GITSHARK_RELEASE_ASSET_ROOT` | `data/release-assets` | Directory holding the uploaded asset files |
63
+| `GITSHARK_MAX_ASSET_SIZE` | `200M` | Largest single asset upload; a larger file is rejected with `400` |
64
+| `GITSHARK_MAX_BODY_SIZE` | `210M` | Cap on any HTTP request body (`quarkus.http.limits.max-body-size`) |
65
+
66
+`GITSHARK_MAX_BODY_SIZE` must stay **above** `GITSHARK_MAX_ASSET_SIZE`: the
67
+request also carries the multipart envelope, and the body cap is enforced first —
68
+a request over it is rejected at the HTTP layer with **413**, before the
69
+application can produce a readable error. The 10 MB headroom in the defaults is
70
+there for that reason. Raising the asset limit means raising both together:
71
+
72
+```yaml
73
+ GITSHARK_MAX_ASSET_SIZE: 1G
74
+ GITSHARK_MAX_BODY_SIZE: 1100M
75
+```
76
+
77
+Note that the body cap replaces Quarkus's own default of 10 MB for **every**
78
+request body on the instance. The two uploads that read a body into memory
79
+(avatars, repository images) check the upload's reported length against their own
80
+2 MB limit before reading it (`ImageValidation.validateSize`), so the higher cap
81
+does not turn them into a memory-exhaustion vector.
82
+
83
+Asset bytes are written to `GITSHARK_RELEASE_ASSET_ROOT` in a **flat directory,
84
+one file per asset, named by the asset's UUID**. The uploader's file name is
85
+validated against `[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}`, stored in the database,
86
+and used only for the `Content-Disposition` header — it never reaches a
87
+filesystem path, so no upload can escape the store.
88
+
89
+The web download addresses an asset by its UUID; the REST API addresses it by
90
+**file name within its release**, because the numeric `id` in the JSON is a
91
+one-way surrogate (see `GiteaIds`) that nothing can be looked up by. The real id
92
+is also on the wire as `uuid`, and `browser_download_url` is the ready-made web
93
+link.
94
+
95
+Uploads are streamed: Quarkus buffers the multipart body to a temporary file and
96
+git-shark copies it into the store, so a large asset costs disk and not heap.
97
+Plan the volume for the sum of everything users attach — unlike git objects,
98
+release assets do not deduplicate. Downloads are always sent as
99
+`Content-Disposition: attachment` with `X-Content-Type-Options: nosniff`, so an
100
+uploaded file's content type can never be rendered on the instance's origin, and
101
+each download costs one indexed `UPDATE` on its counter.
102
+
103
+This directory must be on a persistent volume; it is part of the inventory in
104
+[Persistent data](persistent-data.md).
105
+
48
106
## Tag creation
49
107
50
108
Publishing a release whose tag does not exist yet creates an **annotated tag**
@@ -56,16 +114,36 @@
56
114
57
115
## Database
58
116
59
-One table, added in `V32__releases.sql`:
117
+Two tables, added in `V32__releases.sql` and `V33__release_assets.sql`:
60
118
61
119
| Table | Columns | Notes |
62
120
|---|---|---|
63
121
| `releases` | `id`, `repository_id`, `author_id`, `tag_name`, `title`, `body`, `commit_id`, `prerelease`, `created_at` | `unique (repository_id, tag_name)` — one release per tag; `ON DELETE CASCADE` from both `repositories` and `users` |
122
+| `release_assets` | `id`, `release_id`, `uploader_id`, `file_name`, `content_type`, `size_bytes`, `download_count`, `uploaded_at` | `unique (release_id, file_name)`; `ON DELETE CASCADE` from `releases` and `users`. Asset **bytes** are not here — they are on the filesystem, named by `id` |
64
123
65
124
Release notes live in the database; the tag and its objects live in the git
66
-repository. A restore therefore needs both stores in sync — see
125
+repository; asset bytes live in `GITSHARK_RELEASE_ASSET_ROOT`. A restore
126
+therefore needs all three stores in sync — see
67
127
[Persistent data](persistent-data.md).
68
128
129
+Deleting an asset, a release, or a whole repository removes the stored files in
130
+the same operation. There is no reconciliation job, and rows and files can drift
131
+apart in **both** directions if a process is killed mid-transaction:
132
+
133
+- **A row without its file.** Every delete path runs inside one transaction with
134
+ the file removal *before* the commit, so a crash in that window rolls the row
135
+ back while the file is already gone. The asset stays listed and its download
136
+ answers `404`. Find these by checking each `id` from `release_assets` against
137
+ the asset root.
138
+- **A file without its row.** An upload persists the row, copies the bytes, then
139
+ commits; a failure after the copy rolls the row back and leaves the file.
140
+ Find these by checking each file name in the asset root against
141
+ `select id from release_assets;`.
142
+
143
+Both need a literal process kill in a narrow window, so neither is expected in
144
+normal operation — but only the second one wastes space silently; the first one
145
+is visible to users as a broken download.
146
+
69
147
## Troubleshooting
70
148
71
149
| Symptom | Cause |
@@ -75,3 +153,9 @@
75
153
| `400` "is not a valid tag name" | git ref rules (no spaces, no `..`, no trailing `/`). |
76
154
| Archive returns `404` | The ref does not resolve, or the suffix is neither `.zip` nor `.tar.gz`. |
77
155
| Archive download stalls on a huge repository | Expected — it streams; put a proxy timeout/rate limit in front if this is abused. |
156
+| Upload fails with `413` and no error page | Over `GITSHARK_MAX_BODY_SIZE`, which is enforced before the application sees the request. Raise both size variables. |
157
+| `400` "File is too large" | Under the body cap but over `GITSHARK_MAX_ASSET_SIZE`. |
158
+| `400` "Use a file name of letters, digits, …" | The asset name has a space or a directory in it; the user must rename before uploading. |
159
+| `400` "already has a file named …" | One file name per release; delete the old one first. |
160
+| Asset download returns `404` although the page lists the file | The asset root is not the directory it was at upload time (missing or changed volume mount). Check `GITSHARK_RELEASE_ASSET_ROOT` against the actual mount. |
161
+| The asset root grows unexpectedly | Nothing prunes it. Compare the total against `select sum(size_bytes) from release_assets;` — a large gap means orphaned files from interrupted uploads. |
MODIFY
docs/maintainers/avatars.md
+7 -1
@@ -39,7 +39,13 @@
39
39
per-repository image feature (see [Repository images](repo-images.md)), so the
40
40
rules stay identical in one place:
41
41
42
-- **Size cap**: 2 MB (`ImageValidation.MAX_BYTES`).
42
+- **Size cap**: 2 MB (`ImageValidation.MAX_BYTES`), checked twice. Both callers
43
+ first call `ImageValidation.validateSize(upload.size())` on the multipart
44
+ upload's reported length, *before* `Files.readAllBytes`, and `validate` checks
45
+ the byte array again afterwards. The pre-read check exists because
46
+ `quarkus.http.limits.max-body-size` is raised well above Quarkus's 10 MB
47
+ default for [release assets](releases.md) — without it a 200 MB body would be
48
+ pulled onto the heap only to be rejected for exceeding 2 MB.
43
49
- **Type allowlist**: PNG, JPEG, GIF, WebP (`image/png`, `image/jpeg`,
44
50
`image/gif`, `image/webp`).
45
51
- **Magic-byte check**: the declared content type must match the file's
MODIFY
docs/maintainers/releases.md
+86 -12
@@ -1,22 +1,30 @@
1
1
# Releases architecture
2
2
3
-Releases are a thin database annotation over git tags, plus an archive writer.
4
-The tag stays the source of truth for *what* was released; the `releases` row only
5
-adds *how it is presented* (title, notes, pre-release flag) and remembers which
6
-commit the tag resolved to when it was published.
3
+Releases are a thin database annotation over git tags, plus an archive writer and
4
+an uploaded-file store. The tag stays the source of truth for *what* was released;
5
+the `releases` row only adds *how it is presented* (title, notes, pre-release flag)
6
+and remembers which commit the tag resolved to when it was published.
7
+
8
+Downloads come in two shapes, and the split is the central idea: **source archives
9
+are generated from the tag on demand**, while **assets are uploaded** because they
10
+cannot be derived from the repository at all.
7
11
8
12
## Component map
9
13
10
14
| Concern | Type | Notes |
11
15
|---|---|---|
16
+| Asset entity | `de.workaround.model.ReleaseAsset` | `release`, `uploader`, `fileName`, `contentType`, `sizeBytes`, `downloadCount`, `uploadedAt`; `humanSize()` renders the size with `Locale.ROOT`. `Repo` carries the per-release list, repository-scoped id lookup, the duplicate-name count and the id lists used for file cleanup |
17
+| Asset migration | `V33__release_assets.sql` | `release_assets`, cascading from `releases` and `users` |
12
18
| Entity | `de.workaround.model.Release` | `repository`, `author`, `tagName`, `title`, `body`, `commitId`, `prerelease`, `createdAt`; `Repo` carries the newest-first list, the stable-only list (for "Latest"), tag lookup and the nav count |
13
-| Domain service | `git.ReleaseService` | Publish/edit/delete with `AccessPolicy.canWrite`; cuts a missing tag from the submitted target; blank title falls back to the tag name |
19
+| Domain service | `git.ReleaseService` | Publish/edit/delete with `AccessPolicy.canWrite`; cuts a missing tag from the submitted target; blank title falls back to the tag name. Also owns the asset layer: name validation, size cap, streaming to and from the store, download counting, file cleanup |
14
20
| Tag writes | `git.GitTagService` | `resolveCommit` (peels annotated tags), `exists`, `createTag` — annotated tag written in-core with `TagBuilder` + `RefUpdate`, no working tree |
15
21
| Archives | `git.GitArchiveService` | `Format.ZIP`/`TAR_GZ`; streams a ref's tree into an `OutputStream` |
16
22
| Web UI | `web.ReleaseResource` + `templates/ReleaseResource/*` | List, detail (Markdown notes via the shared `Markdown` renderer), new/edit forms |
17
23
| Archive endpoint | `web.RepositoryResource#archive` | `GET …/archive/{ref}.zip|.tar.gz`, visibility-guarded, `StreamingOutput` |
18
24
| REST | `api.ReleaseApiResource` + `ApiModels.ReleaseView`/`NewRelease`/`ReleaseEdit` | Gitea-shaped release contract, addressed by tag |
19
25
| Navigation | `web.RepoNav#releaseCount` (built in `RepoNavService`) | Sidebar entry between Tags and Issues |
26
+| Asset cleanup hook | `git.GitRepositoryService#delete` | Calls `ReleaseService.deleteFilesFor` alongside `RepositoryImageService.deleteFileFor`, so deleting a repository takes its asset bytes with it |
27
+| Upload-size guard | `account.ImageValidation#validateSize` | Called by the avatar and repository-image uploads before `Files.readAllBytes`, because the raised HTTP body cap otherwise lets a 200 MB body onto the heap just to be rejected for exceeding 2 MB |
20
28
21
29
## Data flow
22
30
@@ -31,7 +39,20 @@
31
39
renders `body` through the shared XSS-safe `Markdown` renderer and links the two
32
40
archive URLs and the commit.
33
41
34
-**Download.** `RepositoryResource#archive` picks the format from the URL suffix,
42
+**Attach.** `ReleaseResource#upload` (or `ReleaseApiResource#uploadAsset`) takes a
43
+`FileUpload` that Quarkus has already buffered to a temporary file →
44
+`ReleaseService.addAsset` authorizes, validates the name, rejects a duplicate name,
45
+an empty file and anything over `gitshark.releases.max-asset-size`, persists the row,
46
+and only then copies the bytes to `<asset root>/<asset id>`. Persisting first is
47
+deliberate: the id names the storage path, so a failed copy rolls the row back.
48
+
49
+**Asset download.** `GET …/releases/assets/{id}/{fileName}` resolves the asset by
50
+**id scoped to the repository** (`findByRepositoryAndId`), so an id belonging to
51
+another repository cannot be read through one the caller can see. The file is handed
52
+over as a `File` entity and streamed, never read into memory, then the counter is
53
+incremented.
54
+
55
+**Archive download.** `RepositoryResource#archive` picks the format from the URL suffix,
35
56
resolves the ref (404 if unknown), and hands a `StreamingOutput` to
36
57
`GitArchiveService`, which walks the commit's tree recursively and copies each
37
58
blob straight from the object database.
@@ -68,6 +89,41 @@
68
89
reason the raw-blob endpoint streams.
69
90
- **No drafts.** Gitea's `draft` is reported as a constant `false`. A draft is a
70
91
second visibility rule over the same row, and nothing needs it yet.
92
+- **Assets are stored by UUID, never by name.** Same pattern as avatars and
93
+ repository images, for the same two reasons: bytes do not belong in PostgreSQL,
94
+ and a user-supplied name must not be able to influence a path. The name lives
95
+ only in the database and in the `Content-Disposition` header.
96
+- **Asset file names are restricted to `[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}`.** Real
97
+ artifacts (`app-release.apk`, `gitshark-1.0.0.jar`, `SHA256SUMS.txt`) fit it. The
98
+ restriction buys two things at once: nothing needs URL-escaping in the download
99
+ path, and no name can denote a directory or a traversal. Sanitising arbitrary
100
+ names instead would mean carrying an encoded and a decoded form through templates
101
+ and headers, for filenames nobody actually ships.
102
+- **The REST API addresses assets by file name, the web UI by UUID.** `GiteaIds` is
103
+ documented as one-way and lossy — a display surrogate that nothing is looked up
104
+ by — so an endpoint keyed on the numeric `id` it puts on the wire would be
105
+ unusable by a client reading that very field. Names are unique per release and
106
+ already restricted to a URL-safe charset, which makes them the natural key,
107
+ exactly as owner/name/number are elsewhere in the API. The web UI has the real
108
+ UUIDs in hand and uses them, and the JSON carries the UUID as `uuid` plus a
109
+ ready-made `browser_download_url`.
110
+- **The web asset route puts the id first and the name last** (`…/releases/assets/{id}/{fileName}`).
111
+ Hanging the name directly off the tag route instead would collide with the greedy
112
+ match that slash-bearing tags require. Keying on the id keeps both routes
113
+ unambiguous while `curl -O` still saves a sensibly named file.
114
+- **Every asset download is an attachment with `nosniff`.** The content type is
115
+ whatever the uploader's browser declared, so it can be `text/html`. Forcing
116
+ attachment disposition means it is never rendered on the instance's origin, which
117
+ closes stored XSS without having to police an allowlist of "safe" content types.
118
+- **Raising `quarkus.http.limits.max-body-size` is a whole-instance change, so the
119
+ two in-memory uploads were guarded in the same step.** Asset uploads need a body
120
+ cap far above Quarkus's 10 MB default; the avatar and repository-image handlers
121
+ read the full body with `Files.readAllBytes` before checking their 2 MB limit,
122
+ which would have turned the higher cap into a memory-exhaustion vector. They now
123
+ check `FileUpload.size()` first.
124
+- **Download counts cost one `UPDATE` per download.** Download counts are the main
125
+ thing people want to know about a published artifact; a single indexed row update
126
+ is accepted as the price.
71
127
72
128
## What works today
73
129
@@ -80,16 +136,34 @@
80
136
streamed, nested under a `<repo>-<ref>/` prefix.
81
137
- Sidebar entry with a release count on every repository page.
82
138
- Gitea-shaped REST: list, create, `latest`, get/PATCH/DELETE by tag, with
83
- `zipball_url`/`tarball_url` pointing at the archive endpoint.
139
+ `zipball_url`/`tarball_url` pointing at the archive endpoint, and an `assets`
140
+ array on every release object.
141
+- **Release assets**: attach a prebuilt file (one per request) from the release page
142
+ or over REST, list them with size and download count, download them, delete one or
143
+ all of them. Bytes stream to and from `gitshark.storage.release-assets`, capped by
144
+ `GITSHARK_MAX_ASSET_SIZE`. Deleting an asset, a release, or a repository removes
145
+ the stored files.
146
+- CI can publish end to end with a personal access token: create the release, then
147
+ `POST …/releases/tags/{tag}/assets` with the artifact.
84
148
85
149
## What still needs to be implemented
86
150
87
-- **Release assets** — uploaded files per release (`/releases/{id}/assets` in
88
- Gitea), with a storage root, a size cap and a download endpoint. The archive
89
- links are the only downloads today.
90
151
- **Draft releases**, and with them a `draft` flag that is more than a constant.
91
-- **MCP tools** for releases — the MCP surface mirrors the REST API elsewhere and
92
- currently has no release tools.
152
+- **Multi-file asset upload** — one file per request; the form has no multiple-file
153
+ input, and there is no batch REST call.
154
+- **Asset checksums** — nothing is computed or displayed; publishers upload their
155
+ own `SHA256SUMS` file.
156
+- **Orphaned-asset reconciliation** — nothing reconciles rows against files, and a
157
+ process killed mid-transaction can strand either side. Deletes remove the file
158
+ before the commit, so an interrupted delete leaves a **row whose file is gone**
159
+ (the download 404s, handled gracefully but visible to users); an upload copies
160
+ the bytes before the commit, so an interrupted upload leaves a **file with no
161
+ row** (silently wasted space). Both windows are narrow; neither is swept.
162
+- **Asset content-type allowlist** — the declared multipart content type is stored
163
+ after a `strip()` with no further validation. Harmless while every download is an
164
+ attachment with `nosniff`, but worth revisiting if that ever changes.
165
+- **MCP tools** for releases and their assets — the MCP surface mirrors the REST
166
+ API elsewhere and currently has no release tools.
93
167
- **Federation** — a release publishes no ActivityPub activity, so followers of a
94
168
repository learn about pushes but not about releases.
95
169
- **Auto-notes** — no "generate release notes from commits since the last tag".
MODIFY
docs/maintainers/repo-images.md
+5 -3
@@ -38,9 +38,11 @@
38
38
`ImageValidation.validate` (2 MB cap, PNG/JPEG/GIF/WebP allowlist, magic-byte
39
39
check — WebP additionally verifies the `WEBP` form type at offset 8, since a bare
40
40
`RIFF` prefix is also WAV/AVI) — the exact same helper `AvatarService` uses.
41
-Failures throw the shared `InvalidImageException`, caught in
42
-`RepositoryResource.uploadImage` and re-rendered as a form error on the
43
-repository settings page.
41
+`RepositoryResource.uploadImage` also calls the shared
42
+`ImageValidation.validateSize(image.size())` before reading the bytes, for the
43
+reason spelled out in [avatars](avatars.md#validation). Failures throw the shared
44
+`InvalidImageException`, caught in `RepositoryResource.uploadImage` and re-rendered
45
+as a form error on the repository settings page.
44
46
45
47
## Rendering: one Qute tag, owner-avatar fallback
46
48
MODIFY
docs/users/releases.md
+59 -6
@@ -1,9 +1,14 @@
1
1
# Releases
2
2
3
3
A **release** turns a git tag into something people can read and download: a
4
-title, Markdown release notes, and source-code archives of exactly that tag.
4
+title, Markdown release notes, source-code archives of exactly that tag, and any
5
+**prebuilt files you attach** — an APK, a JAR, an installer, a checksum list.
5
6
Every repository has a **Releases** section in its left sidebar, next to Tags.
6
7
8
+The archives are generated from the tag, so they always match the source. The
9
+attached files are the ones that *cannot* be derived from it: the artifacts you
10
+built.
11
+
7
12
Releases annotate tags — they never change history. Deleting a release keeps the
8
13
tag it was published from.
9
14
@@ -11,8 +16,8 @@
11
16
12
17
| Action | Who |
13
18
|---|---|
14
-| Browse releases, read notes, download archives | Anyone who can read the repository |
15
-| Publish, edit, delete a release | The repository owner, its collaborators, and organisation members |
19
+| Browse releases, read notes, download archives and attached files | Anyone who can read the repository |
20
+| Publish, edit, delete a release; attach and delete files | The repository owner, its collaborators, and organisation members |
16
21
17
22
A private repository's releases (and its archives) are invisible to everyone
18
23
without read access — the pages answer `404`, not `403`.
@@ -38,13 +43,33 @@
38
43
Pre-releases are listed normally but never become "latest".
39
44
- **Source code (zip)** and **Source code (tar.gz)** download the repository tree
40
45
at that tag. Both unpack into a single `<repo>-<tag>/` directory.
46
+- Attached files are listed above the archives, each with its size and how often
47
+ it has been downloaded.
41
48
- The commit link opens the exact revision the tag pointed at when the release
42
49
was published.
43
-- Owners and collaborators get **Edit** and **Delete** buttons.
50
+- Owners and collaborators get **Edit** and **Delete** buttons, an **Attach a
51
+ prebuilt file** form, and a **✕** next to each attached file.
44
52
45
53
Editing changes the title, the notes and the pre-release flag. The tag and its
46
54
commit stay fixed — publish a new release to ship a new version.
47
55
56
+## Attaching a prebuilt file
57
+
58
+Use **Attach a prebuilt file** on the release page. One file per upload; repeat
59
+for as many as you need.
60
+
61
+File names must be made up of letters, digits, and `.` `_` `-` `+` `~`, must
62
+start with a letter or digit, and cannot contain spaces or directories — so
63
+`app-release.apk`, `gitshark-1.0.0.jar` and `SHA256SUMS.txt` are all fine,
64
+`my app.apk` is not. Rename before uploading. Two files in the same release
65
+cannot share a name.
66
+
67
+Your admin sets the maximum upload size (200 MB unless they changed it). A larger
68
+file is rejected.
69
+
70
+Deleting a file is permanent — re-upload to restore one. Deleting the release
71
+deletes everything attached to it, but never the git tag.
72
+
48
73
## Downloading without the UI
49
74
50
75
The archive URLs work for any ref, not just released tags:
@@ -57,10 +82,38 @@
57
82
`<ref>` may be a tag, a branch, or a commit id. For a private repository, the
58
83
usual login (browser session) is required.
59
84
85
+Attached files have stable links too, shown on the release page:
86
+
87
+```bash
88
+curl -LO https://<host>/repos/<owner>/<repo>/releases/assets/<asset-id>/app-release.apk
89
+```
90
+
91
+## Publishing from CI
92
+
93
+A build pipeline can attach its artifact with a [personal access
94
+token](profile.md) — no browser needed:
95
+
96
+```bash
97
+BASE=https://<host>/api/v1/repos/<owner>/<repo>/releases
98
+
99
+# create the release (cuts the tag from main if it does not exist yet)
100
+curl -sS -X POST "$BASE" -H "Authorization: Bearer $TOKEN" \
101
+ -H 'Content-Type: application/json' \
102
+ -d '{"tag_name":"v1.0.0","target_commitish":"main","name":"1.0.0"}'
103
+
104
+# attach the build output
105
+curl -sS -X POST "$BASE/tags/v1.0.0/assets" -H "Authorization: Bearer $TOKEN" \
106
+ -F attachment=@build/app-release.apk
107
+
108
+# and later, fetch it back by name
109
+curl -sSLO "$BASE/tags/v1.0.0/assets/app-release.apk"
110
+```
111
+
60
112
## Limits today
61
113
62
-- **No file attachments.** A release carries notes and the source archives; you
63
- cannot upload binaries or checksums yet.
114
+- **One file per upload.** There is no multi-file picker; attach them one by one.
115
+- **No checksums are computed.** Upload your own `SHA256SUMS` file if you want
116
+ people to verify downloads.
64
117
- **No drafts.** A published release is immediately visible to everyone who can
65
118
read the repository.
66
119
- Releases are not announced to federation followers.
MODIFY
src/main/docker/Dockerfile.jvm
+1 -1
@@ -89,7 +89,7 @@
89
89
# freshly created volumes — otherwise every mount point is root-owned and the app
90
90
# cannot persist the SSH host key or write repositories, avatars, or repo images.
91
91
USER root
92
-RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/ssh \
92
+RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/release-assets /data/ssh \
93
93
&& chown -R 185:0 /data \
94
94
&& chmod -R g+rwX /data
95
95
USER 185
MODIFY
src/main/docker/Dockerfile.legacy-jar
+1 -1
@@ -89,7 +89,7 @@
89
89
# freshly created volumes — otherwise every mount point is root-owned and the app
90
90
# cannot persist the SSH host key or write repositories, avatars, or repo images.
91
91
USER root
92
-RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/ssh \
92
+RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/release-assets /data/ssh \
93
93
&& chown -R 185:0 /data \
94
94
&& chmod -R g+rwX /data
95
95
USER 185
MODIFY
src/main/docker/Dockerfile.native
+1 -1
@@ -25,7 +25,7 @@
25
25
# exist in the image owned by the runtime UID so Docker copies that ownership into
26
26
# freshly created volumes — otherwise every mount point is root-owned and the app
27
27
# cannot persist the SSH host key or write repositories, avatars, or repo images.
28
-RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/ssh \
28
+RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/release-assets /data/ssh \
29
29
&& chown -R 1001:root /data \
30
30
&& chmod -R g+rwX /data
31
31
COPY --chown=1001:root --chmod=0755 target/*-runner /work/application
MODIFY
src/main/docker/Dockerfile.native-micro
+1 -1
@@ -28,7 +28,7 @@
28
28
# exist in the image owned by the runtime UID so Docker copies that ownership into
29
29
# freshly created volumes — otherwise every mount point is root-owned and the app
30
30
# cannot persist the SSH host key or write repositories, avatars, or repo images.
31
-RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/ssh \
31
+RUN mkdir -p /data/repositories /data/avatars /data/repo-images /data/release-assets /data/ssh \
32
32
&& chown -R 1001:root /data \
33
33
&& chmod -R g+rwX /data
34
34
COPY --chown=1001:root --chmod=0755 target/*-runner /work/application
MODIFY
src/main/docker/smoke-test-volumes.sh
+3 -3
@@ -14,7 +14,7 @@
14
14
IMAGE="${1:?usage: smoke-test-volumes.sh <image> <runtime-uid>}"
15
15
UID_EXPECTED="${2:?usage: smoke-test-volumes.sh <image> <runtime-uid>}"
16
16
17
-DATA_DIRS="data/repositories data/avatars data/repo-images data/ssh"
17
+DATA_DIRS="data/repositories data/avatars data/repo-images data/release-assets data/ssh"
18
18
19
19
# --- 1. Static check: paths exist in the image, owned by the runtime UID. ---
20
20
# Works for every image, including the shell-less native-micro base, because
@@ -42,9 +42,9 @@
42
42
# in the documented docker-compose.yml. Skipped when the image has no shell.
43
43
if printf '%s\n' "$LISTING" | grep -qE '[[:space:]](bin|usr/bin)/sh( |$)'; then
44
44
docker run --rm \
45
- -v /data/repositories -v /data/avatars -v /data/repo-images -v /data/ssh \
45
+ -v /data/repositories -v /data/avatars -v /data/repo-images -v /data/release-assets -v /data/ssh \
46
46
--entrypoint "" "$IMAGE" \
47
- sh -c 'touch /data/repositories/.rw /data/avatars/.rw /data/repo-images/.rw /data/ssh/.rw'
47
+ sh -c 'touch /data/repositories/.rw /data/avatars/.rw /data/repo-images/.rw /data/release-assets/.rw /data/ssh/.rw'
48
48
echo "OK: runtime user can write all /data mount points"
49
49
else
50
50
echo "SKIP: image has no shell; static ownership check only"
MODIFY
src/main/java/de/workaround/account/ImageValidation.java
+14 -0
@@ -26,6 +26,20 @@
26
26
}
27
27
28
28
/**
29
+ * Rejects an oversized upload from its reported length, before the bytes are read into memory. The
30
+ * byte-level check in {@link #validate} still applies; this one exists so a multi-hundred megabyte body —
31
+ * which the HTTP layer permits for release assets — cannot be pulled onto the heap just to be rejected
32
+ * for exceeding 2 MB.
33
+ */
34
+ public static void validateSize(long length)
35
+ {
36
+ if (length > MAX_BYTES)
37
+ {
38
+ throw new InvalidImageException("Image is too large (max 2 MB).");
39
+ }
40
+ }
41
+
42
+ /**
29
43
* Validates the uploaded bytes against the declared content type and returns the normalised
30
44
* (trimmed, lower-cased) content type to persist. Throws {@link InvalidImageException} on any
31
45
* violation.
MODIFY
src/main/java/de/workaround/account/SettingsResource.java
+1 -0
@@ -128,6 +128,7 @@
128
128
}
129
129
try
130
130
{
131
+ ImageValidation.validateSize(avatar.size());
131
132
avatars.store(user, Files.readAllBytes(avatar.uploadedFile()), avatar.contentType());
132
133
return Response.seeOther(URI.create("/settings/profile")).build();
133
134
}
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+20 -3
@@ -10,6 +10,7 @@
10
10
import de.workaround.model.MergeRequest;
11
11
import de.workaround.model.MergeRequestComment;
12
12
import de.workaround.model.Release;
13
+import de.workaround.model.ReleaseAsset;
13
14
import de.workaround.model.Repository;
14
15
import de.workaround.model.User;
15
16
@@ -199,14 +200,30 @@
199
200
@JsonProperty("target_commitish") String targetCommitish, String name, String body, boolean draft,
200
201
boolean prerelease, @JsonProperty("created_at") Instant createdAt,
201
202
@JsonProperty("published_at") Instant publishedAt, PersonView author,
202
- @JsonProperty("zipball_url") String zipballUrl, @JsonProperty("tarball_url") String tarballUrl)
203
+ @JsonProperty("zipball_url") String zipballUrl, @JsonProperty("tarball_url") String tarballUrl,
204
+ List<ReleaseAssetView> assets)
203
205
{
204
- public static ReleaseView of(Release release, String archiveBase)
206
+ public static ReleaseView of(Release release, String archiveBase, List<ReleaseAsset> assets,
207
+ String assetBase)
205
208
{
206
209
return new ReleaseView(GiteaIds.of(release.id), release.tagName, release.commitId, release.title,
207
210
release.body, false, release.prerelease, release.createdAt, release.createdAt,
208
211
PersonView.of(release.author), archiveBase + release.tagName + ".zip",
209
- archiveBase + release.tagName + ".tar.gz");
212
+ archiveBase + release.tagName + ".tar.gz",
213
+ assets.stream().map(asset -> ReleaseAssetView.of(asset, assetBase)).toList());
214
+ }
215
+ }
216
+
217
+ /** Gitea release-attachment shape. {@code uuid} is the asset's real id; {@code id} is its numeric form. */
218
+ public record ReleaseAssetView(long id, String uuid, String name, long size,
219
+ @JsonProperty("download_count") long downloadCount, @JsonProperty("created_at") Instant createdAt,
220
+ @JsonProperty("browser_download_url") String browserDownloadUrl)
221
+ {
222
+ public static ReleaseAssetView of(ReleaseAsset asset, String assetBase)
223
+ {
224
+ return new ReleaseAssetView(GiteaIds.of(asset.id), asset.id.toString(), asset.fileName,
225
+ asset.sizeBytes, asset.downloadCount, asset.uploadedAt,
226
+ assetBase + asset.id + "/" + asset.fileName);
210
227
}
211
228
}
212
229
MODIFY
src/main/java/de/workaround/api/ReleaseApiResource.java
+99 -10
@@ -1,11 +1,13 @@
1
1
package de.workaround.api;
2
2
3
+import java.nio.file.Files;
3
4
import java.util.List;
4
5
5
6
import de.workaround.git.AccessPolicy;
6
7
import de.workaround.git.GitRepositoryService;
7
8
import de.workaround.git.ReleaseService;
8
9
import de.workaround.model.Release;
10
+import de.workaround.model.ReleaseAsset;
9
11
import de.workaround.model.Repository;
10
12
import de.workaround.model.User;
11
13
import jakarta.inject.Inject;
@@ -23,11 +25,15 @@
23
25
import jakarta.ws.rs.core.MediaType;
24
26
import jakarta.ws.rs.core.Response;
25
27
import jakarta.ws.rs.core.UriInfo;
28
+import org.jboss.resteasy.reactive.RestForm;
29
+import org.jboss.resteasy.reactive.multipart.FileUpload;
26
30
27
31
/**
28
32
* JSON REST access to a repository's releases under {@code /api/v1/repos/{owner}/{name}/releases}, shaped
29
- * like Gitea's release API. Releases are addressed by their tag. Listing/reading follow repository
30
- * read-visibility; creating, editing and deleting require a token and write access (enforced by
33
+ * like Gitea's release API. Releases are addressed by their tag, their uploaded assets by file name within
34
+ * that release — the numeric {@code id} on the wire is a one-way {@link GiteaIds} surrogate. Attaching a
35
+ * prebuilt binary here is how a CI pipeline publishes a build artifact. Listing/reading follow repository
36
+ * read-visibility; creating, editing, uploading and deleting require a token and write access (enforced by
31
37
* {@link ReleaseService}). Draft releases do not exist in git-shark.
32
38
*/
33
39
@Path("/api/v1/repos/{owner}/{name}/releases")
@@ -53,8 +59,7 @@
53
59
public List<ApiModels.ReleaseView> list(@PathParam("owner") String owner, @PathParam("name") String name)
54
60
{
55
61
Repository repo = requireReadable(owner, name);
56
- String archiveBase = archiveBase(repo);
57
- return releases.list(repo).stream().map(release -> ApiModels.ReleaseView.of(release, archiveBase)).toList();
62
+ return releases.list(repo).stream().map(release -> view(repo, release)).toList();
58
63
}
59
64
60
65
@POST
@@ -66,9 +71,7 @@
66
71
Repository repo = requireReadable(owner, name);
67
72
Release release = releases.create(user, repo, request.tagName(), request.targetCommitish(), request.name(),
68
73
request.body(), request.prerelease());
69
- return Response.status(Response.Status.CREATED)
70
- .entity(ApiModels.ReleaseView.of(release, archiveBase(repo)))
71
- .build();
74
+ return Response.status(Response.Status.CREATED).entity(view(repo, release)).build();
72
75
}
73
76
74
77
/** The newest non-prerelease release, or 404 when the repository has none. */
@@ -78,7 +81,7 @@
78
81
{
79
82
Repository repo = requireReadable(owner, name);
80
83
Release release = releases.findLatest(repo).orElseThrow(NotFoundException::new);
81
- return ApiModels.ReleaseView.of(release, archiveBase(repo));
84
+ return view(repo, release);
82
85
}
83
86
84
87
@GET
@@ -87,7 +90,7 @@
87
90
@PathParam("tag") String tag)
88
91
{
89
92
Repository repo = requireReadable(owner, name);
90
- return ApiModels.ReleaseView.of(require(repo, tag), archiveBase(repo));
93
+ return view(repo, require(repo, tag));
91
94
}
92
95
93
96
@PATCH
@@ -104,7 +107,7 @@
104
107
releases.update(user, release, request.name() == null ? release.title : request.name(),
105
108
request.body() == null ? release.body : request.body(),
106
109
request.prerelease() == null ? release.prerelease : request.prerelease());
107
- return ApiModels.ReleaseView.of(require(repo, tag), archiveBase(repo));
110
+ return view(repo, require(repo, tag));
108
111
}
109
112
110
113
@DELETE
@@ -118,6 +121,92 @@
118
121
return Response.noContent().build();
119
122
}
120
123
124
+ @GET
125
+ @Path("tags/{tag:.+}/assets")
126
+ public List<ApiModels.ReleaseAssetView> listAssets(@PathParam("owner") String owner,
127
+ @PathParam("name") String name, @PathParam("tag") String tag)
128
+ {
129
+ Repository repo = requireReadable(owner, name);
130
+ String assetBase = assetBase(repo);
131
+ return releases.assets(require(repo, tag)).stream()
132
+ .map(asset -> ApiModels.ReleaseAssetView.of(asset, assetBase))
133
+ .toList();
134
+ }
135
+
136
+ /** Attaches a prebuilt file to the release — the endpoint a build pipeline posts its artifact to. */
137
+ @POST
138
+ @Path("tags/{tag:.+}/assets")
139
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
140
+ public Response uploadAsset(@PathParam("owner") String owner, @PathParam("name") String name,
141
+ @PathParam("tag") String tag, @RestForm("attachment") FileUpload attachment)
142
+ {
143
+ User user = principal.require();
144
+ Repository repo = requireReadable(owner, name);
145
+ Release release = require(repo, tag);
146
+ if (attachment == null)
147
+ {
148
+ throw new de.workaround.git.InvalidReleaseException("No file was uploaded");
149
+ }
150
+ ReleaseAsset asset = releases.addAsset(user, release, attachment.fileName(), attachment.contentType(),
151
+ attachment.uploadedFile());
152
+ return Response.status(Response.Status.CREATED)
153
+ .entity(ApiModels.ReleaseAssetView.of(asset, assetBase(repo)))
154
+ .build();
155
+ }
156
+
157
+ /**
158
+ * Streams an asset's bytes. Always an attachment with sniffing disabled, matching the web download, so an
159
+ * uploaded file's content type can never be rendered on this origin.
160
+ */
161
+ @GET
162
+ @Path("tags/{tag:.+}/assets/{fileName}")
163
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
164
+ public Response downloadAsset(@PathParam("owner") String owner, @PathParam("name") String name,
165
+ @PathParam("tag") String tag, @PathParam("fileName") String fileName)
166
+ {
167
+ Repository repo = requireReadable(owner, name);
168
+ ReleaseAsset asset = requireAsset(repo, tag, fileName);
169
+ java.nio.file.Path stored = releases.assetPath(asset);
170
+ if (!Files.exists(stored))
171
+ {
172
+ throw new NotFoundException();
173
+ }
174
+ releases.recordDownload(asset);
175
+ return Response.ok(stored.toFile()).type(asset.contentType)
176
+ .header("Content-Disposition", "attachment; filename=\"" + asset.fileName + "\"")
177
+ .header("X-Content-Type-Options", "nosniff")
178
+ .build();
179
+ }
180
+
181
+ @DELETE
182
+ @Path("tags/{tag:.+}/assets/{fileName}")
183
+ public Response deleteAsset(@PathParam("owner") String owner, @PathParam("name") String name,
184
+ @PathParam("tag") String tag, @PathParam("fileName") String fileName)
185
+ {
186
+ User user = principal.require();
187
+ Repository repo = requireReadable(owner, name);
188
+ releases.deleteAsset(user, requireAsset(repo, tag, fileName));
189
+ return Response.noContent().build();
190
+ }
191
+
192
+ private ReleaseAsset requireAsset(Repository repo, String tag, String fileName)
193
+ {
194
+ return releases.findAsset(require(repo, tag), fileName).orElseThrow(NotFoundException::new);
195
+ }
196
+
197
+ private ApiModels.ReleaseView view(Repository repo, Release release)
198
+ {
199
+ return ApiModels.ReleaseView.of(release, archiveBase(repo), releases.assets(release), assetBase(repo));
200
+ }
201
+
202
+ /** Absolute prefix asset download URLs are built from, ending in {@code /releases/assets/}. */
203
+ private String assetBase(Repository repo)
204
+ {
205
+ return uriInfo.getBaseUri()
206
+ .resolve("/repos/" + repo.ownerHandle() + "/" + repo.name + "/releases/assets/")
207
+ .toString();
208
+ }
209
+
121
210
private Release require(Repository repo, String tag)
122
211
{
123
212
return releases.find(repo, tag).orElseThrow(NotFoundException::new);
MODIFY
src/main/java/de/workaround/git/GitRepositoryService.java
+4 -0
@@ -46,6 +46,9 @@
46
46
@Inject
47
47
RepositoryImageService images;
48
48
49
+ @Inject
50
+ ReleaseService releases;
51
+
49
52
@ConfigProperty(name = "gitshark.storage.root")
50
53
Path storageRoot;
51
54
@@ -177,6 +180,7 @@
177
180
}
178
181
Path path = repositoryPath(repository);
179
182
images.deleteFileFor(repository);
183
+ releases.deleteFilesFor(repository);
180
184
repositories.deleteById(repository.id);
181
185
try
182
186
{
MODIFY
src/main/java/de/workaround/git/ReleaseService.java
+177 -0
@@ -1,17 +1,26 @@
1
1
package de.workaround.git;
2
2
3
+import java.io.IOException;
4
+import java.io.UncheckedIOException;
5
+import java.nio.file.Files;
3
6
import java.nio.file.Path;
7
+import java.nio.file.StandardCopyOption;
4
8
import java.util.List;
5
9
import java.util.Optional;
10
+import java.util.UUID;
11
+import java.util.regex.Pattern;
6
12
7
13
import org.eclipse.jgit.lib.PersonIdent;
8
14
9
15
import de.workaround.model.Release;
16
+import de.workaround.model.ReleaseAsset;
10
17
import de.workaround.model.Repository;
11
18
import de.workaround.model.User;
19
+import io.quarkus.runtime.configuration.MemorySize;
12
20
import jakarta.enterprise.context.ApplicationScoped;
13
21
import jakarta.inject.Inject;
14
22
import jakarta.transaction.Transactional;
23
+import org.eclipse.microprofile.config.inject.ConfigProperty;
15
24
16
25
/**
17
26
* Publishes and manages a repository's releases. A release annotates exactly one git tag with a title and
@@ -19,12 +28,24 @@
19
28
* annotated tag is cut from the given target (a branch, tag or commit) as part of publishing — the way the
20
29
* "New release" page and Gitea's {@code target_commitish} work.
21
30
*
31
+ * <p>A release also carries uploaded {@link ReleaseAsset} files — prebuilt binaries that cannot be derived
32
+ * from the repository, unlike the source archives its page links. Asset bytes are streamed to
33
+ * {@code gitshark.storage.release-assets} into a file named by the asset's UUID; the uploader's file name is
34
+ * validated and kept in the database only, so it can never influence a filesystem path.
35
+ *
22
36
* <p>Reading follows the repository's read-visibility rule (enforced by callers); publishing, editing and
23
37
* deleting require write access (owner, collaborator or org member). Deleting a release never deletes its tag.
24
38
*/
25
39
@ApplicationScoped
26
40
public class ReleaseService
27
41
{
42
+ /**
43
+ * Asset file names are deliberately restricted to characters that need no escaping in a URL path and
44
+ * cannot denote a directory, which covers the shapes real artifacts have ({@code app-release.apk},
45
+ * {@code gitshark-1.0.0.jar}, {@code SHA256SUMS.txt}).
46
+ */
47
+ private static final Pattern VALID_FILE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}");
48
+
28
49
@Inject
29
50
Release.Repo releases;
30
51
@@ -37,6 +58,15 @@
37
58
@Inject
38
59
AccessPolicy accessPolicy;
39
60
61
+ @Inject
62
+ ReleaseAsset.Repo assets;
63
+
64
+ @ConfigProperty(name = "gitshark.storage.release-assets")
65
+ Path assetRoot;
66
+
67
+ @ConfigProperty(name = "gitshark.releases.max-asset-size")
68
+ MemorySize maxAssetSize;
69
+
40
70
@Transactional
41
71
public Release create(User actor, Repository repository, String tagName, String target, String title,
42
72
String body, boolean prerelease)
@@ -110,7 +140,154 @@
110
140
public void delete(User actor, Release release)
111
141
{
112
142
requireWrite(actor, release.repository);
143
+ // collect the paths before the cascade removes the rows that name them
144
+ List<Path> stored = assets.findIdsByRelease(release).stream().map(this::assetPath).toList();
113
145
releases.deleteById(release.id);
146
+ stored.forEach(this::deleteFile);
147
+ }
148
+
149
+ // -- assets --
150
+
151
+ public List<ReleaseAsset> assets(Release release)
152
+ {
153
+ return assets.findByRelease(release);
154
+ }
155
+
156
+ public Optional<ReleaseAsset> findAsset(Repository repository, UUID id)
157
+ {
158
+ return assets.findByRepositoryAndId(repository, id);
159
+ }
160
+
161
+ /**
162
+ * Looks an asset up by its name within one release — how the REST API addresses assets, since the
163
+ * Gitea-shaped numeric {@code id} on the wire is a one-way surrogate that nothing can be looked up by.
164
+ */
165
+ public Optional<ReleaseAsset> findAsset(Release release, String fileName)
166
+ {
167
+ return assets.findByReleaseAndFileName(release, fileName);
168
+ }
169
+
170
+ /** Absolute path of the stored bytes; the file exists for every asset row. */
171
+ public Path assetPath(ReleaseAsset asset)
172
+ {
173
+ return assetPath(asset.id);
174
+ }
175
+
176
+ /**
177
+ * Attaches an uploaded file to the release. {@code source} is the upload's temporary file; its bytes are
178
+ * copied into the asset store, never read into memory, so a multi-hundred-megabyte binary costs no heap.
179
+ */
180
+ @Transactional
181
+ public ReleaseAsset addAsset(User actor, Release release, String fileName, String contentType, Path source)
182
+ {
183
+ requireWrite(actor, release.repository);
184
+ String name = validateFileName(fileName);
185
+ if (assets.countByReleaseAndFileName(release, name) > 0)
186
+ {
187
+ throw new InvalidReleaseException("This release already has a file named " + name);
188
+ }
189
+ long size = sizeOf(source);
190
+ if (size == 0)
191
+ {
192
+ throw new InvalidReleaseException("The uploaded file is empty");
193
+ }
194
+ if (size > maxAssetSize.asLongValue())
195
+ {
196
+ throw new InvalidReleaseException("File is too large (max " + maxAssetSize.asBigInteger() + " bytes)");
197
+ }
198
+
199
+ ReleaseAsset asset = new ReleaseAsset();
200
+ asset.release = release;
201
+ asset.uploader = actor;
202
+ asset.fileName = name;
203
+ asset.contentType = blankTo(contentType, "application/octet-stream");
204
+ asset.sizeBytes = size;
205
+ asset.persist();
206
+ // persist first so the id (and with it the storage path) exists; a failed copy rolls the row back
207
+ store(source, assetPath(asset));
208
+ return asset;
209
+ }
210
+
211
+ @Transactional
212
+ public void recordDownload(ReleaseAsset asset)
213
+ {
214
+ ReleaseAsset managed = assets.findById(asset.id);
215
+ if (managed != null)
216
+ {
217
+ managed.downloadCount++;
218
+ }
219
+ }
220
+
221
+ @Transactional
222
+ public void deleteAsset(User actor, ReleaseAsset asset)
223
+ {
224
+ requireWrite(actor, asset.release.repository);
225
+ Path stored = assetPath(asset);
226
+ assets.deleteById(asset.id);
227
+ deleteFile(stored);
228
+ }
229
+
230
+ /**
231
+ * Removes only the asset files of a repository, leaving the rows to the DB-level cascade. Called while the
232
+ * whole repository is being deleted ({@link GitRepositoryService#delete}); without this the bytes would be
233
+ * orphaned on disk under now-unreachable UUIDs.
234
+ */
235
+ public void deleteFilesFor(Repository repository)
236
+ {
237
+ assets.findIdsByRepository(repository).stream().map(this::assetPath).forEach(this::deleteFile);
238
+ }
239
+
240
+ private Path assetPath(UUID assetId)
241
+ {
242
+ return assetRoot.resolve(assetId.toString());
243
+ }
244
+
245
+ private static String validateFileName(String fileName)
246
+ {
247
+ String name = fileName == null ? "" : fileName.strip();
248
+ if (!VALID_FILE_NAME.matcher(name).matches())
249
+ {
250
+ throw new InvalidReleaseException("Use a file name of letters, digits, dots, dashes, underscores,"
251
+ + " plus or tilde — no spaces and no directories");
252
+ }
253
+ return name;
254
+ }
255
+
256
+ private void store(Path source, Path target)
257
+ {
258
+ try
259
+ {
260
+ Files.createDirectories(assetRoot);
261
+ Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
262
+ }
263
+ catch (IOException e)
264
+ {
265
+ throw new UncheckedIOException("Failed to write release asset to " + target, e);
266
+ }
267
+ }
268
+
269
+ private void deleteFile(Path path)
270
+ {
271
+ try
272
+ {
273
+ Files.deleteIfExists(path);
274
+ }
275
+ catch (IOException e)
276
+ {
277
+ throw new UncheckedIOException("Failed to delete release asset " + path, e);
278
+ }
279
+ }
280
+
281
+ private static long sizeOf(Path source)
282
+ {
283
+ try
284
+ {
285
+ return Files.size(source);
286
+ }
287
+ catch (IOException e)
288
+ {
289
+ throw new UncheckedIOException("Failed to read the uploaded release asset", e);
290
+ }
114
291
}
115
292
116
293
private String cutTag(User actor, Path barePath, String tag, String target)
ADD
src/main/java/de/workaround/model/ReleaseAsset.java
+94 -0
@@ -0,0 +1,94 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Locale;
6
+import java.util.Optional;
7
+import java.util.UUID;
8
+
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.GeneratedValue;
15
+import jakarta.persistence.GenerationType;
16
+import jakarta.persistence.Id;
17
+import jakarta.persistence.JoinColumn;
18
+import jakarta.persistence.ManyToOne;
19
+import jakarta.persistence.Table;
20
+
21
+/**
22
+ * One prebuilt file attached to a {@link Release} — an APK, a JAR, an installer, a checksum list. Unlike the
23
+ * source archives on a release page, which are generated from its tag on demand, these are uploaded and
24
+ * cannot be derived from the repository.
25
+ * <p>
26
+ * The bytes live on the filesystem under {@code gitshark.storage.release-assets}, in a file named by this
27
+ * row's id, so the uploader's file name never reaches a filesystem path. Owned by the release and removed
28
+ * with it (DB-level ON DELETE CASCADE); the stored file is deleted by the service.
29
+ */
30
+@Entity
31
+@Table(name = "release_assets")
32
+public class ReleaseAsset implements PanacheEntity.Managed
33
+{
34
+ @Id
35
+ @GeneratedValue(strategy = GenerationType.UUID)
36
+ public UUID id;
37
+
38
+ @ManyToOne(optional = false)
39
+ @JoinColumn(name = "release_id")
40
+ public Release release;
41
+
42
+ @ManyToOne(optional = false)
43
+ public User uploader;
44
+
45
+ /** The name the file is served back as; unique within the release. */
46
+ public String fileName;
47
+
48
+ public String contentType;
49
+
50
+ public long sizeBytes;
51
+
52
+ public long downloadCount;
53
+
54
+ public Instant uploadedAt = Instant.now();
55
+
56
+ /** Size rendered for the UI, e.g. {@code 12.4 MB}. */
57
+ public String humanSize()
58
+ {
59
+ String[] units = { "B", "KB", "MB", "GB" };
60
+ double size = sizeBytes;
61
+ int unit = 0;
62
+ while (size >= 1024 && unit < units.length - 1)
63
+ {
64
+ size /= 1024;
65
+ unit++;
66
+ }
67
+ // Locale.ROOT: the rendered size must not depend on the server's default locale
68
+ return unit == 0 ? sizeBytes + " B" : String.format(Locale.ROOT, "%.1f %s", size, units[unit]);
69
+ }
70
+
71
+ public interface Repo extends PanacheRepository.Managed<ReleaseAsset, UUID>
72
+ {
73
+ @HQL("select a from ReleaseAsset a join fetch a.uploader where a.release = :release order by a.fileName")
74
+ List<ReleaseAsset> findByRelease(Release release);
75
+
76
+ @HQL("select a from ReleaseAsset a join fetch a.release rel join fetch rel.repository"
77
+ + " where a.id = :id and rel.repository = :repository")
78
+ Optional<ReleaseAsset> findByRepositoryAndId(Repository repository, UUID id);
79
+
80
+ @HQL("select count(a) from ReleaseAsset a where a.release = :release and a.fileName = :fileName")
81
+ long countByReleaseAndFileName(Release release, String fileName);
82
+
83
+ @HQL("select a from ReleaseAsset a join fetch a.release rel join fetch rel.repository"
84
+ + " where a.release = :release and a.fileName = :fileName")
85
+ Optional<ReleaseAsset> findByReleaseAndFileName(Release release, String fileName);
86
+
87
+ @HQL("select a.id from ReleaseAsset a where a.release.repository = :repository")
88
+ List<UUID> findIdsByRepository(Repository repository);
89
+
90
+ @HQL("select a.id from ReleaseAsset a where a.release = :release")
91
+ List<UUID> findIdsByRelease(Release release);
92
+ }
93
+
94
+}
MODIFY
src/main/java/de/workaround/web/ReleaseResource.java
+65 -4
@@ -3,8 +3,10 @@
3
3
import java.net.URI;
4
4
import java.net.URLEncoder;
5
5
import java.nio.charset.StandardCharsets;
6
+import java.nio.file.Files;
6
7
import java.nio.file.Path;
7
8
import java.util.List;
9
+import java.util.UUID;
8
10
9
11
import de.workaround.account.CurrentUser;
10
12
import de.workaround.git.AccessPolicy;
@@ -13,6 +15,7 @@
13
15
import de.workaround.git.GitRepositoryService;
14
16
import de.workaround.git.ReleaseService;
15
17
import de.workaround.model.Release;
18
+import de.workaround.model.ReleaseAsset;
16
19
import de.workaround.model.Repository;
17
20
import io.quarkus.qute.CheckedTemplate;
18
21
import io.quarkus.qute.TemplateInstance;
@@ -29,11 +32,13 @@
29
32
import jakarta.ws.rs.core.MediaType;
30
33
import jakarta.ws.rs.core.Response;
31
34
import jakarta.ws.rs.core.UriInfo;
35
+import org.jboss.resteasy.reactive.RestForm;
36
+import org.jboss.resteasy.reactive.multipart.FileUpload;
32
37
33
38
/**
34
39
* The repository's Releases pages: the list with its "Latest" badge, the per-tag detail page with rendered
35
- * notes and source-archive links, and the publish/edit/delete forms. Reading follows repository visibility;
36
- * every mutation is authorized in {@link ReleaseService}.
40
+ * notes, uploaded assets and source-archive links, and the publish/edit/delete/upload forms. Reading follows
41
+ * repository visibility; every mutation is authorized in {@link ReleaseService}.
37
42
*/
38
43
@jakarta.ws.rs.Path("/repos/{owner}/{name}/releases")
39
44
@Produces(MediaType.TEXT_HTML)
@@ -49,7 +54,7 @@
49
54
List<String> untaggedTags);
50
55
51
56
static native TemplateInstance release(Repository repo, RepoNav nav, boolean canWrite, Release release,
52
- String bodyHtml, boolean latest);
57
+ String bodyHtml, boolean latest, List<ReleaseAsset> assets);
53
58
54
59
static native TemplateInstance editRelease(Repository repo, RepoNav nav, Release release);
55
60
}
@@ -120,7 +125,63 @@
120
125
Release release = require(repo, tag);
121
126
String bodyHtml = release.body == null ? null : Markdown.render(release.body);
122
127
boolean latest = releaseService.findLatest(repo).filter(r -> r.id.equals(release.id)).isPresent();
123
- return Templates.release(repo, repoNav.build(repo, uriInfo), canWrite(repo), release, bodyHtml, latest);
128
+ return Templates.release(repo, repoNav.build(repo, uriInfo), canWrite(repo), release, bodyHtml, latest,
129
+ releaseService.assets(release));
130
+ }
131
+
132
+ @POST
133
+ @jakarta.ws.rs.Path("tag/{tag:.+}/assets")
134
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
135
+ public Response upload(@PathParam("owner") String owner, @PathParam("name") String name,
136
+ @PathParam("tag") String tag, @RestForm("asset") FileUpload asset)
137
+ {
138
+ Repository repo = requireWritable(owner, name);
139
+ Release release = require(repo, tag);
140
+ if (asset == null)
141
+ {
142
+ throw new de.workaround.git.InvalidReleaseException("No file was uploaded");
143
+ }
144
+ // file-name and size validation live in ReleaseService (InvalidReleaseException -> 400 via its mapper)
145
+ releaseService.addAsset(currentUser.require(), release, asset.fileName(), asset.contentType(),
146
+ asset.uploadedFile());
147
+ return Response.seeOther(releaseUri(repo, release.tagName)).build();
148
+ }
149
+
150
+ /**
151
+ * Serves an uploaded asset. The id alone identifies the file; the trailing name segment only gives
152
+ * {@code curl -O} and {@code wget} a sensible file name. Always sent as an attachment with sniffing
153
+ * disabled, so an uploaded file's content type can never be rendered on this origin.
154
+ */
155
+ @GET
156
+ @jakarta.ws.rs.Path("assets/{id}/{fileName}")
157
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
158
+ public Response download(@PathParam("owner") String owner, @PathParam("name") String name,
159
+ @PathParam("id") UUID id, @PathParam("fileName") String fileName)
160
+ {
161
+ Repository repo = requireReadable(owner, name);
162
+ ReleaseAsset asset = releaseService.findAsset(repo, id).orElseThrow(NotFoundException::new);
163
+ Path stored = releaseService.assetPath(asset);
164
+ if (!Files.exists(stored))
165
+ {
166
+ throw new NotFoundException();
167
+ }
168
+ releaseService.recordDownload(asset);
169
+ return Response.ok(stored.toFile()).type(asset.contentType)
170
+ .header("Content-Disposition", "attachment; filename=\"" + asset.fileName + "\"")
171
+ .header("X-Content-Type-Options", "nosniff")
172
+ .build();
173
+ }
174
+
175
+ @POST
176
+ @jakarta.ws.rs.Path("assets/{id}/delete")
177
+ public Response deleteAsset(@PathParam("owner") String owner, @PathParam("name") String name,
178
+ @PathParam("id") UUID id)
179
+ {
180
+ Repository repo = requireWritable(owner, name);
181
+ ReleaseAsset asset = releaseService.findAsset(repo, id).orElseThrow(NotFoundException::new);
182
+ String tag = asset.release.tagName;
183
+ releaseService.deleteAsset(currentUser.require(), asset);
184
+ return Response.seeOther(releaseUri(repo, tag)).build();
124
185
}
125
186
126
187
@GET
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+2 -0
@@ -19,6 +19,7 @@
19
19
import org.jboss.resteasy.reactive.multipart.FileUpload;
20
20
21
21
import de.workaround.account.CurrentUser;
22
+import de.workaround.account.ImageValidation;
22
23
import de.workaround.account.InvalidImageException;
23
24
import de.workaround.git.AccessPolicy;
24
25
import de.workaround.git.GitArchiveService;
@@ -355,6 +356,7 @@
355
356
}
356
357
try
357
358
{
359
+ ImageValidation.validateSize(image.size());
358
360
images.store(currentUser.require(), repo, Files.readAllBytes(image.uploadedFile()), image.contentType());
359
361
return Response.seeOther(settingsUri(repo)).build();
360
362
}
MODIFY
src/main/resources/META-INF/resources/shark.css
+40 -0
@@ -2244,6 +2244,46 @@
2244
2244
flex-direction: column;
2245
2245
}
2246
2246
2247
+/* release assets: an uploaded file's row carries its size, download count and a delete control,
2248
+ so unlike the generated source-archive rows it is not one single link */
2249
+.asset-row .fname {
2250
+ display: flex;
2251
+ align-items: center;
2252
+ gap: var(--s2);
2253
+ min-width: 0;
2254
+ flex: 1;
2255
+ color: inherit;
2256
+}
2257
+
2258
+.asset-row .fname .n {
2259
+ overflow: hidden;
2260
+ text-overflow: ellipsis;
2261
+ white-space: nowrap;
2262
+}
2263
+
2264
+.asset-row .fname:hover .n {
2265
+ color: var(--accent-deep);
2266
+}
2267
+
2268
+.asset-meta {
2269
+ display: flex;
2270
+ align-items: center;
2271
+ gap: var(--s4);
2272
+ flex: none;
2273
+ font: 500 12.5px/1 var(--mono);
2274
+ margin-left: var(--s4);
2275
+}
2276
+
2277
+.asset-upload {
2278
+ margin-top: var(--s5);
2279
+}
2280
+
2281
+.asset-upload .check-hint {
2282
+ margin: 0;
2283
+ font: 400 12.5px/1.45 var(--font);
2284
+ color: var(--muted);
2285
+}
2286
+
2247
2287
.frow {
2248
2288
display: flex;
2249
2289
align-items: center;
MODIFY
src/main/resources/application.properties
+11 -0
@@ -106,6 +106,17 @@
106
106
gitshark.storage.repo-images=${GITSHARK_REPO_IMAGE_ROOT:data/repo-images}
107
107
%test.gitshark.storage.repo-images=target/test-repo-images
108
108
109
+# Release assets: prebuilt files (APKs, JARs, installers) attached to a release, stored on the
110
+# filesystem one file per asset keyed by asset id. max-asset-size caps a single upload; the HTTP
111
+# body limit sits above it to leave room for the multipart envelope, and raises Quarkus's own 10M
112
+# default for every request body (the two in-memory image uploads guard their own 2M limit before
113
+# reading, see ImageValidation.validateSize).
114
+gitshark.storage.release-assets=${GITSHARK_RELEASE_ASSET_ROOT:data/release-assets}
115
+%test.gitshark.storage.release-assets=target/test-release-assets
116
+gitshark.releases.max-asset-size=${GITSHARK_MAX_ASSET_SIZE:200M}
117
+%test.gitshark.releases.max-asset-size=4K
118
+quarkus.http.limits.max-body-size=${GITSHARK_MAX_BODY_SIZE:210M}
119
+
109
120
# Embedded SSH server
110
121
# port the embedded SSH server binds inside the container; keep it >1024 so the process needs no root
111
122
gitshark.ssh.port=${GITSHARK_SSH_PORT:2222}
ADD
src/main/resources/db/migration/V33__release_assets.sql
+20 -0
@@ -0,0 +1,20 @@
1
+-- Release assets: prebuilt files (APKs, JARs, installers) attached to a release, alongside the
2
+-- source archives that are generated from its tag on the fly.
3
+
4
+-- The bytes live on the filesystem (gitshark.storage.release-assets), one file per asset named by
5
+-- its UUID, so an uploader-supplied file name can never influence the storage path. file_name is
6
+-- unique per release and is what the download serves the file back as.
7
+create table release_assets
8
+(
9
+ id uuid primary key,
10
+ release_id uuid not null references releases (id) on delete cascade,
11
+ uploader_id uuid not null references users (id) on delete cascade,
12
+ file_name text not null,
13
+ content_type varchar(255) not null,
14
+ size_bytes bigint not null,
15
+ download_count bigint not null default 0,
16
+ uploaded_at timestamptz not null default now(),
17
+ unique (release_id, file_name)
18
+);
19
+
20
+create index release_assets_release_idx on release_assets (release_id, file_name);
MODIFY
src/main/resources/templates/ReleaseResource/release.html
+28 -0
@@ -33,6 +33,23 @@
33
33
{/if}
34
34
<h3 class="comments-title">Assets</h3>
35
35
<div class="panel">
36
+ {#for asset in assets}
37
+ <div class="frow asset-row">
38
+ <a class="fname" href="/repos/{repo.ownerHandle}/{repo.name}/releases/assets/{asset.id}/{asset.fileName}">
39
+ <span class="g">⤓</span> <span class="n">{asset.fileName}</span>
40
+ </a>
41
+ <span class="asset-meta muted">
42
+ <span>{asset.humanSize}</span>
43
+ <span>{#if asset.downloadCount == 1}1 download{#else}{asset.downloadCount} downloads{/if}</span>
44
+ </span>
45
+ {#if canWrite}
46
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/releases/assets/{asset.id}/delete"
47
+ onsubmit="return confirm('Delete {asset.fileName}? This cannot be undone.')">
48
+ <button type="submit" class="btn-icon" title="Delete {asset.fileName}" aria-label="Delete {asset.fileName}">✕</button>
49
+ </form>
50
+ {/if}
51
+ </div>
52
+ {/for}
36
53
<a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/archive/{release.tagName}.zip">
37
54
<span class="fname"><span class="g">⤓</span> <span class="n">Source code (zip)</span></span>
38
55
</a>
@@ -43,6 +60,17 @@
43
60
<span class="fname"><span class="g">◴</span> <span class="n">Commit {release.shortCommitId}</span></span>
44
61
</a>
45
62
</div>
63
+ {#if canWrite}
64
+ <form class="issue-form asset-upload" method="post" enctype="multipart/form-data"
65
+ action="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}/assets">
66
+ <label for="release-asset">Attach a prebuilt file</label>
67
+ <p class="check-hint">A binary people should download instead of building it themselves — an APK, a JAR, an installer. Source archives above are generated from the tag and need no upload.</p>
68
+ <input id="release-asset" type="file" name="asset" required>
69
+ <div class="form-actions">
70
+ <button type="submit" class="btn btn-primary">Upload</button>
71
+ </div>
72
+ </form>
73
+ {/if}
46
74
</div>
47
75
</div>
48
76
</section>
ADD
src/test/java/de/workaround/api/ReleaseAssetApiTest.java
+152 -0
@@ -0,0 +1,152 @@
1
+package de.workaround.api;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.util.Map;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.git.GitTestSeeder;
10
+import de.workaround.http.AccessTokenService;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import jakarta.inject.Inject;
15
+import jakarta.transaction.Transactional;
16
+
17
+import static io.restassured.RestAssured.given;
18
+import static org.hamcrest.CoreMatchers.equalTo;
19
+import static org.hamcrest.Matchers.containsString;
20
+import static org.hamcrest.Matchers.emptyOrNullString;
21
+import static org.hamcrest.Matchers.hasItem;
22
+import static org.hamcrest.Matchers.not;
23
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
24
+
25
+@QuarkusTest
26
+class ReleaseAssetApiTest
27
+{
28
+ private static final byte[] APK = "not-really-an-apk".getBytes(StandardCharsets.UTF_8);
29
+
30
+ @Inject
31
+ GitRepositoryService service;
32
+
33
+ @Inject
34
+ AccessTokenService tokenService;
35
+
36
+ @Inject
37
+ User.Repo userRepo;
38
+
39
+ @Test
40
+ void uploadListDownloadAndDeleteAReleaseAsset() throws Exception
41
+ {
42
+ User owner = persistUser("api-asset-owner");
43
+ String token = mintToken(owner);
44
+ releasedRepo(owner, token, "shipped", "v1.0.0");
45
+ String base = "/api/v1/repos/" + owner.username + "/shipped/releases";
46
+
47
+ // upload — this is the endpoint a CI pipeline uses to publish a build artifact
48
+ given().header("Authorization", "Bearer " + token)
49
+ .multiPart("attachment", "app-release.apk", APK, "application/vnd.android.package-archive")
50
+ .when().post(base + "/tags/v1.0.0/assets")
51
+ .then().statusCode(201)
52
+ .body("name", equalTo("app-release.apk"))
53
+ .body("size", equalTo(APK.length))
54
+ .body("download_count", equalTo(0))
55
+ .body("uuid", not(emptyOrNullString()))
56
+ .body("browser_download_url",
57
+ containsString("/repos/" + owner.username + "/shipped/releases/assets/"));
58
+
59
+ // list (anonymous read on a public repo)
60
+ given().when().get(base + "/tags/v1.0.0/assets")
61
+ .then().statusCode(200)
62
+ .body("name", hasItem("app-release.apk"));
63
+
64
+ // the release object itself carries its assets
65
+ given().when().get(base + "/tags/v1.0.0")
66
+ .then().statusCode(200)
67
+ .body("assets.name", hasItem("app-release.apk"));
68
+
69
+ // download the bytes — assets are addressed by name within their release, because the numeric
70
+ // id on the wire is a one-way GiteaIds surrogate that nothing can be looked up by
71
+ byte[] body = given().when().get(base + "/tags/v1.0.0/assets/app-release.apk")
72
+ .then().statusCode(200)
73
+ .extract().asByteArray();
74
+ assertArrayEquals(APK, body);
75
+
76
+ // the download was counted
77
+ given().when().get(base + "/tags/v1.0.0/assets")
78
+ .then().statusCode(200)
79
+ .body("[0].download_count", equalTo(1));
80
+
81
+ given().header("Authorization", "Bearer " + token)
82
+ .when().delete(base + "/tags/v1.0.0/assets/app-release.apk")
83
+ .then().statusCode(204);
84
+ given().when().get(base + "/tags/v1.0.0/assets/app-release.apk").then().statusCode(404);
85
+ }
86
+
87
+ @Test
88
+ void uploadingWithoutWriteAccessIsRejected() throws Exception
89
+ {
90
+ User owner = persistUser("api-asset-victim");
91
+ User stranger = persistUser("api-asset-stranger");
92
+ String ownerToken = mintToken(owner);
93
+ releasedRepo(owner, ownerToken, "guarded", "v1.0.0");
94
+ String base = "/api/v1/repos/" + owner.username + "/guarded/releases";
95
+
96
+ given().header("Authorization", "Bearer " + mintToken(stranger))
97
+ .multiPart("attachment", "evil.apk", APK, "application/octet-stream")
98
+ .when().post(base + "/tags/v1.0.0/assets")
99
+ .then().statusCode(403);
100
+
101
+ // and anonymously, without any token at all
102
+ given().multiPart("attachment", "evil.apk", APK, "application/octet-stream")
103
+ .when().post(base + "/tags/v1.0.0/assets")
104
+ .then().statusCode(401);
105
+ }
106
+
107
+ @Test
108
+ void anUnusableFileNameIsRejected() throws Exception
109
+ {
110
+ User owner = persistUser("api-asset-badname");
111
+ String token = mintToken(owner);
112
+ releasedRepo(owner, token, "picky", "v1.0.0");
113
+
114
+ given().header("Authorization", "Bearer " + token)
115
+ .multiPart("attachment", "../escape.apk", APK, "application/octet-stream")
116
+ .when().post("/api/v1/repos/" + owner.username + "/picky/releases/tags/v1.0.0/assets")
117
+ .then().statusCode(400);
118
+ }
119
+
120
+ private void releasedRepo(User owner, String token, String name, String tag) throws Exception
121
+ {
122
+ Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
123
+ GitTestSeeder.seed(service.repositoryPath(repo),
124
+ Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
125
+ given().header("Authorization", "Bearer " + token)
126
+ .contentType("application/json")
127
+ .body(Map.of("tag_name", tag, "target_commitish", "main", "name", tag))
128
+ .when().post("/api/v1/repos/" + owner.username + "/" + name + "/releases")
129
+ .then().statusCode(201);
130
+ }
131
+
132
+ private String mintToken(User owner)
133
+ {
134
+ return tokenService.create(owner, "release-asset-api").plaintext();
135
+ }
136
+
137
+ @Transactional
138
+ User persistUser(String name)
139
+ {
140
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
141
+ if (existing != null)
142
+ {
143
+ return existing;
144
+ }
145
+ User user = new User();
146
+ user.oidcSub = name;
147
+ user.username = name;
148
+ user.persist();
149
+ return user;
150
+ }
151
+
152
+}
ADD
src/test/java/de/workaround/git/ReleaseAssetServiceTest.java
+247 -0
@@ -0,0 +1,247 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.List;
7
+import java.util.Map;
8
+
9
+import org.junit.jupiter.api.Test;
10
+
11
+import de.workaround.model.Release;
12
+import de.workaround.model.ReleaseAsset;
13
+import de.workaround.model.Repository;
14
+import de.workaround.model.User;
15
+import io.quarkus.test.junit.QuarkusTest;
16
+import jakarta.inject.Inject;
17
+import jakarta.transaction.Transactional;
18
+
19
+import static org.junit.jupiter.api.Assertions.assertEquals;
20
+import static org.junit.jupiter.api.Assertions.assertFalse;
21
+import static org.junit.jupiter.api.Assertions.assertThrows;
22
+import static org.junit.jupiter.api.Assertions.assertTrue;
23
+
24
+@QuarkusTest
25
+class ReleaseAssetServiceTest
26
+{
27
+ @Inject
28
+ GitRepositoryService service;
29
+
30
+ @Inject
31
+ ReleaseService releases;
32
+
33
+ @Inject
34
+ CollaboratorService collaborators;
35
+
36
+ @Inject
37
+ User.Repo users;
38
+
39
+ @Test
40
+ void addAssetStoresTheBytesOnDiskWithItsMetadata() throws Exception
41
+ {
42
+ User owner = persistUser("asset-iris");
43
+ Release release = releasedRepo(owner, "iris-app", "v1.0.0");
44
+
45
+ ReleaseAsset asset = releases.addAsset(owner, release, "app-release.apk",
46
+ "application/vnd.android.package-archive", tempFile("android-binary"));
47
+
48
+ assertEquals("app-release.apk", asset.fileName);
49
+ assertEquals("application/vnd.android.package-archive", asset.contentType);
50
+ assertEquals("android-binary".length(), asset.sizeBytes);
51
+ assertEquals(0, asset.downloadCount);
52
+ assertEquals(owner.id, asset.uploader.id);
53
+ assertEquals("android-binary", Files.readString(releases.assetPath(asset)));
54
+ assertEquals(List.of("app-release.apk"), releases.assets(release).stream().map(a -> a.fileName).toList());
55
+ }
56
+
57
+ @Test
58
+ void anAssetWithNoDeclaredContentTypeFallsBackToOctetStream() throws Exception
59
+ {
60
+ User owner = persistUser("asset-tom");
61
+ Release release = releasedRepo(owner, "tom-app", "v1.0.0");
62
+
63
+ ReleaseAsset asset = releases.addAsset(owner, release, "tool.bin", null, tempFile("bits"));
64
+
65
+ assertEquals("application/octet-stream", asset.contentType);
66
+ }
67
+
68
+ @Test
69
+ void addAssetRejectsAFileNameThatCouldEscapeTheStoreOrTheUrl() throws Exception
70
+ {
71
+ User owner = persistUser("asset-jane");
72
+ Release release = releasedRepo(owner, "jane-app", "v1.0.0");
73
+ Path file = tempFile("payload");
74
+
75
+ for (String bad : List.of("../escape.apk", "dir/app.apk", "dir\\app.apk", "..", "", " ",
76
+ "we ird.apk", ".hidden", "quote\".apk"))
77
+ {
78
+ assertThrows(InvalidReleaseException.class,
79
+ () -> releases.addAsset(owner, release, bad, "application/octet-stream", file),
80
+ "expected rejection of asset file name '" + bad + "'");
81
+ }
82
+ }
83
+
84
+ @Test
85
+ void addAssetRejectsASecondAssetWithTheSameFileName() throws Exception
86
+ {
87
+ User owner = persistUser("asset-karl");
88
+ Release release = releasedRepo(owner, "karl-app", "v1.0.0");
89
+ releases.addAsset(owner, release, "app.apk", "application/octet-stream", tempFile("one"));
90
+
91
+ assertThrows(InvalidReleaseException.class,
92
+ () -> releases.addAsset(owner, release, "app.apk", "application/octet-stream", tempFile("two")));
93
+ }
94
+
95
+ @Test
96
+ void addAssetRejectsAnEmptyUpload() throws Exception
97
+ {
98
+ User owner = persistUser("asset-void");
99
+ Release release = releasedRepo(owner, "void-app", "v1.0.0");
100
+
101
+ assertThrows(InvalidReleaseException.class,
102
+ () -> releases.addAsset(owner, release, "empty.bin", "application/octet-stream", tempFile("")));
103
+ }
104
+
105
+ @Test
106
+ void addAssetRejectsAnUploadOverTheConfiguredSizeLimit() throws Exception
107
+ {
108
+ User owner = persistUser("asset-lena");
109
+ Release release = releasedRepo(owner, "lena-app", "v1.0.0");
110
+ // %test caps gitshark.releases.max-asset-size at 4K
111
+ Path oversized = tempFile("x".repeat(5 * 1024));
112
+
113
+ assertThrows(InvalidReleaseException.class,
114
+ () -> releases.addAsset(owner, release, "big.bin", "application/octet-stream", oversized));
115
+ }
116
+
117
+ @Test
118
+ void uploadingAnAssetRequiresWriteAccess() throws Exception
119
+ {
120
+ User owner = persistUser("asset-mia");
121
+ User stranger = persistUser("asset-nils");
122
+ Release release = releasedRepo(owner, "mia-app", "v1.0.0");
123
+
124
+ assertThrows(ForbiddenOperationException.class, () -> releases.addAsset(stranger, release, "app.apk",
125
+ "application/octet-stream", tempFile("nope")));
126
+ }
127
+
128
+ @Test
129
+ void collaboratorsMayUploadAndDeleteAssets() throws Exception
130
+ {
131
+ User owner = persistUser("asset-gina");
132
+ User mate = persistUser("asset-hugo");
133
+ Release release = releasedRepo(owner, "gina-app", "v1.0.0");
134
+ collaborators.add(owner, release.repository, "asset-hugo");
135
+
136
+ ReleaseAsset asset = releases.addAsset(mate, release, "app.apk", "application/octet-stream",
137
+ tempFile("dex"));
138
+ assertEquals(mate.id, asset.uploader.id);
139
+
140
+ releases.deleteAsset(mate, asset);
141
+ assertTrue(releases.assets(release).isEmpty());
142
+ }
143
+
144
+ @Test
145
+ void recordDownloadCountsEachDownload() throws Exception
146
+ {
147
+ User owner = persistUser("asset-olaf");
148
+ Release release = releasedRepo(owner, "olaf-app", "v1.0.0");
149
+ ReleaseAsset asset = releases.addAsset(owner, release, "app.apk", "application/octet-stream",
150
+ tempFile("bits"));
151
+
152
+ releases.recordDownload(asset);
153
+ releases.recordDownload(asset);
154
+
155
+ assertEquals(2, releases.findAsset(release.repository, asset.id).orElseThrow().downloadCount);
156
+ }
157
+
158
+ @Test
159
+ void findAssetIsScopedToItsOwnRepository() throws Exception
160
+ {
161
+ User owner = persistUser("asset-scope");
162
+ Release mine = releasedRepo(owner, "scope-a", "v1.0.0");
163
+ Release other = releasedRepo(owner, "scope-b", "v1.0.0");
164
+ ReleaseAsset asset = releases.addAsset(owner, mine, "app.apk", "application/octet-stream",
165
+ tempFile("bits"));
166
+
167
+ assertTrue(releases.findAsset(mine.repository, asset.id).isPresent());
168
+ assertTrue(releases.findAsset(other.repository, asset.id).isEmpty(),
169
+ "an asset id from another repository must not resolve");
170
+ }
171
+
172
+ @Test
173
+ void deletingAnAssetRemovesTheRowAndTheFile() throws Exception
174
+ {
175
+ User owner = persistUser("asset-pia");
176
+ Release release = releasedRepo(owner, "pia-app", "v1.0.0");
177
+ ReleaseAsset asset = releases.addAsset(owner, release, "app.apk", "application/octet-stream",
178
+ tempFile("bits"));
179
+ Path stored = releases.assetPath(asset);
180
+
181
+ releases.deleteAsset(owner, asset);
182
+
183
+ assertFalse(Files.exists(stored), "asset bytes must not be orphaned on disk");
184
+ assertTrue(releases.findAsset(release.repository, asset.id).isEmpty());
185
+ }
186
+
187
+ @Test
188
+ void deletingAReleaseRemovesItsAssetsAndTheirFiles() throws Exception
189
+ {
190
+ User owner = persistUser("asset-quinn");
191
+ Release release = releasedRepo(owner, "quinn-app", "v1.0.0");
192
+ Repository repo = release.repository;
193
+ Path stored = releases.assetPath(releases.addAsset(owner, release, "app.apk",
194
+ "application/octet-stream", tempFile("bits")));
195
+
196
+ releases.delete(owner, release);
197
+
198
+ assertFalse(Files.exists(stored), "asset bytes must not be orphaned on disk");
199
+ assertTrue(releases.find(repo, "v1.0.0").isEmpty());
200
+ }
201
+
202
+ @Test
203
+ void deletingTheRepositoryRemovesEveryAssetFile() throws Exception
204
+ {
205
+ User owner = persistUser("asset-rosa");
206
+ Release release = releasedRepo(owner, "rosa-app", "v1.0.0");
207
+ Repository repo = release.repository;
208
+ Path stored = releases.assetPath(releases.addAsset(owner, release, "app.apk",
209
+ "application/octet-stream", tempFile("bits")));
210
+
211
+ service.delete(owner, repo);
212
+
213
+ assertFalse(Files.exists(stored), "asset bytes must not survive their repository");
214
+ }
215
+
216
+ private Release releasedRepo(User owner, String name, String tag) throws Exception
217
+ {
218
+ Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
219
+ GitTestSeeder.seed(service.repositoryPath(repo),
220
+ Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
221
+ GitTestSeeder.seedTag(service.repositoryPath(repo), tag);
222
+ return releases.create(owner, repo, tag, "main", tag, null, false);
223
+ }
224
+
225
+ private static Path tempFile(String content) throws Exception
226
+ {
227
+ Path file = Files.createTempFile("asset", ".bin");
228
+ Files.writeString(file, content);
229
+ return file;
230
+ }
231
+
232
+ @Transactional
233
+ User persistUser(String name)
234
+ {
235
+ User existing = users.findByOidcSubOptional(name).orElse(null);
236
+ if (existing != null)
237
+ {
238
+ return existing;
239
+ }
240
+ User user = new User();
241
+ user.oidcSub = name;
242
+ user.username = name;
243
+ user.persist();
244
+ return user;
245
+ }
246
+
247
+}
ADD
src/test/java/de/workaround/model/ReleaseAssetTest.java
+48 -0
@@ -0,0 +1,48 @@
1
+package de.workaround.model;
2
+
3
+import java.util.Locale;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import static org.junit.jupiter.api.Assertions.assertEquals;
8
+
9
+class ReleaseAssetTest
10
+{
11
+ @Test
12
+ void humanSizeStepsThroughTheUnits()
13
+ {
14
+ assertEquals("0 B", sized(0).humanSize());
15
+ assertEquals("512 B", sized(512).humanSize());
16
+ assertEquals("1023 B", sized(1023).humanSize(), "bytes stay bytes right up to the KB boundary");
17
+ assertEquals("1.0 KB", sized(1024).humanSize());
18
+ assertEquals("293.0 KB", sized(300_000).humanSize());
19
+ assertEquals("1.0 MB", sized(1024L * 1024).humanSize());
20
+ assertEquals("1.5 GB", sized(1536L * 1024 * 1024).humanSize());
21
+ assertEquals("1024.0 GB", sized(1024L * 1024 * 1024 * 1024).humanSize(),
22
+ "GB is the last unit, so a terabyte is rendered in GB rather than an unknown unit");
23
+ }
24
+
25
+ @Test
26
+ void humanSizeDoesNotDependOnTheServersDefaultLocale()
27
+ {
28
+ Locale original = Locale.getDefault();
29
+ try
30
+ {
31
+ // a locale whose decimal separator is a comma would otherwise render "293,0 KB"
32
+ Locale.setDefault(Locale.GERMANY);
33
+ assertEquals("293.0 KB", sized(300_000).humanSize());
34
+ }
35
+ finally
36
+ {
37
+ Locale.setDefault(original);
38
+ }
39
+ }
40
+
41
+ private static ReleaseAsset sized(long bytes)
42
+ {
43
+ ReleaseAsset asset = new ReleaseAsset();
44
+ asset.sizeBytes = bytes;
45
+ return asset;
46
+ }
47
+
48
+}
ADD
src/test/java/de/workaround/web/ReleaseAssetUiTest.java
+194 -0
@@ -0,0 +1,194 @@
1
+package de.workaround.web;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.Map;
7
+
8
+import org.junit.jupiter.api.Test;
9
+
10
+import de.workaround.git.GitRepositoryService;
11
+import de.workaround.git.GitTestSeeder;
12
+import de.workaround.git.ReleaseService;
13
+import de.workaround.model.Release;
14
+import de.workaround.model.ReleaseAsset;
15
+import de.workaround.model.Repository;
16
+import de.workaround.model.User;
17
+import io.quarkus.test.junit.QuarkusTest;
18
+import io.quarkus.test.security.TestSecurity;
19
+import jakarta.inject.Inject;
20
+import jakarta.transaction.Transactional;
21
+
22
+import static io.restassured.RestAssured.given;
23
+import static org.hamcrest.CoreMatchers.containsString;
24
+import static org.hamcrest.CoreMatchers.not;
25
+import static org.hamcrest.Matchers.equalTo;
26
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
27
+
28
+@QuarkusTest
29
+class ReleaseAssetUiTest
30
+{
31
+ private static final byte[] APK = "not-really-an-apk".getBytes(StandardCharsets.UTF_8);
32
+
33
+ @Inject
34
+ GitRepositoryService service;
35
+
36
+ @Inject
37
+ ReleaseService releases;
38
+
39
+ @Inject
40
+ User.Repo users;
41
+
42
+ @Test
43
+ @TestSecurity(user = "relasset-owner")
44
+ void ownerUploadsABinaryAndAnyoneCanDownloadIt() throws Exception
45
+ {
46
+ User owner = persistUser("relasset-owner");
47
+ Release release = releasedRepo(owner, "app", "v1.0.0");
48
+ String base = "/repos/relasset-owner/app/releases";
49
+
50
+ given().redirects().follow(false)
51
+ .multiPart("asset", "app-release.apk", APK, "application/vnd.android.package-archive")
52
+ .when().post(base + "/tag/v1.0.0/assets")
53
+ .then().statusCode(303);
54
+
55
+ // the uploaded file is listed alongside the generated source archives
56
+ given().when().get(base + "/tag/v1.0.0")
57
+ .then().statusCode(200)
58
+ .body(containsString("app-release.apk"))
59
+ .body(containsString("Source code (zip)"));
60
+
61
+ ReleaseAsset asset = releases.assets(releases.find(release.repository, "v1.0.0").orElseThrow()).getFirst();
62
+ byte[] body = given().when().get(base + "/assets/" + asset.id + "/app-release.apk")
63
+ .then().statusCode(200)
64
+ .header("Content-Disposition", containsString("attachment; filename=\"app-release.apk\""))
65
+ .header("Content-Type", equalTo("application/vnd.android.package-archive"))
66
+ .header("X-Content-Type-Options", equalTo("nosniff"))
67
+ .extract().asByteArray();
68
+ assertArrayEquals(APK, body, "the download must return the uploaded bytes verbatim");
69
+ }
70
+
71
+ @Test
72
+ @TestSecurity(user = "relasset-counter")
73
+ void theReleasePageShowsSizeAndDownloadCount() throws Exception
74
+ {
75
+ User owner = persistUser("relasset-counter");
76
+ Release release = releasedRepo(owner, "counted", "v1.0.0");
77
+ ReleaseAsset asset = releases.addAsset(owner, release, "tool.jar", "application/java-archive",
78
+ tempFile());
79
+ String base = "/repos/relasset-counter/counted/releases";
80
+
81
+ given().when().get(base + "/assets/" + asset.id + "/tool.jar").then().statusCode(200);
82
+
83
+ given().when().get(base + "/tag/v1.0.0")
84
+ .then().statusCode(200)
85
+ .body(containsString("17 B"))
86
+ .body(containsString("1 download"));
87
+ }
88
+
89
+ @Test
90
+ @TestSecurity(user = "relasset-stranger")
91
+ void aUserWithoutWriteAccessCannotUploadOrDeleteAnAsset() throws Exception
92
+ {
93
+ User owner = persistUser("relasset-victim");
94
+ persistUser("relasset-stranger");
95
+ Release release = releasedRepo(owner, "locked", "v1.0.0");
96
+ ReleaseAsset asset = releases.addAsset(owner, release, "owner.jar", "application/java-archive",
97
+ tempFile());
98
+ String base = "/repos/relasset-victim/locked/releases";
99
+
100
+ given().redirects().follow(false)
101
+ .multiPart("asset", "evil.apk", APK, "application/octet-stream")
102
+ .when().post(base + "/tag/v1.0.0/assets")
103
+ .then().statusCode(403);
104
+
105
+ given().redirects().follow(false)
106
+ .when().post(base + "/assets/" + asset.id + "/delete")
107
+ .then().statusCode(403);
108
+
109
+ // the asset itself stays readable, but without an upload form or a delete control
110
+ given().when().get(base + "/tag/v1.0.0")
111
+ .then().statusCode(200)
112
+ .body(containsString("owner.jar"))
113
+ .body(not(containsString("Attach a prebuilt file")));
114
+ }
115
+
116
+ @Test
117
+ void aPrivateRepositorysAssetsAreHiddenFromAnonymousVisitors() throws Exception
118
+ {
119
+ User owner = persistUser("relasset-private");
120
+ Repository repo = service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
121
+ GitTestSeeder.seed(service.repositoryPath(repo),
122
+ Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
123
+ GitTestSeeder.seedTag(service.repositoryPath(repo), "v1.0.0");
124
+ Release release = releases.create(owner, repo, "v1.0.0", "main", "Internal", null, false);
125
+ ReleaseAsset asset = releases.addAsset(owner, release, "internal.jar", "application/java-archive",
126
+ tempFile());
127
+
128
+ given().when().get("/repos/relasset-private/secret/releases/assets/" + asset.id + "/internal.jar")
129
+ .then().statusCode(404);
130
+ }
131
+
132
+ @Test
133
+ @TestSecurity(user = "relasset-deleter")
134
+ void ownerDeletesAnAsset() throws Exception
135
+ {
136
+ User owner = persistUser("relasset-deleter");
137
+ Release release = releasedRepo(owner, "temporary", "v1.0.0");
138
+ ReleaseAsset asset = releases.addAsset(owner, release, "gone.jar", "application/java-archive",
139
+ tempFile());
140
+ String base = "/repos/relasset-deleter/temporary/releases";
141
+
142
+ given().redirects().follow(false)
143
+ .when().post(base + "/assets/" + asset.id + "/delete")
144
+ .then().statusCode(303);
145
+
146
+ given().when().get(base + "/assets/" + asset.id + "/gone.jar").then().statusCode(404);
147
+ given().when().get(base + "/tag/v1.0.0").then().statusCode(200).body(not(containsString("gone.jar")));
148
+ }
149
+
150
+ @Test
151
+ @TestSecurity(user = "relasset-badname")
152
+ void anUnusableFileNameIsRejectedWithABadRequest() throws Exception
153
+ {
154
+ User owner = persistUser("relasset-badname");
155
+ releasedRepo(owner, "picky", "v1.0.0");
156
+
157
+ given().redirects().follow(false)
158
+ .multiPart("asset", "bad name.apk", APK, "application/octet-stream")
159
+ .when().post("/repos/relasset-badname/picky/releases/tag/v1.0.0/assets")
160
+ .then().statusCode(400);
161
+ }
162
+
163
+ private Release releasedRepo(User owner, String name, String tag) throws Exception
164
+ {
165
+ Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
166
+ GitTestSeeder.seed(service.repositoryPath(repo),
167
+ Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
168
+ GitTestSeeder.seedTag(service.repositoryPath(repo), tag);
169
+ return releases.create(owner, repo, tag, "main", tag, null, false);
170
+ }
171
+
172
+ private static Path tempFile() throws Exception
173
+ {
174
+ Path file = Files.createTempFile("asset", ".bin");
175
+ Files.write(file, APK);
176
+ return file;
177
+ }
178
+
179
+ @Transactional
180
+ User persistUser(String name)
181
+ {
182
+ User existing = users.findByOidcSubOptional(name).orElse(null);
183
+ if (existing != null)
184
+ {
185
+ return existing;
186
+ }
187
+ User user = new User();
188
+ user.oidcSub = name;
189
+ user.username = name;
190
+ user.persist();
191
+ return user;
192
+ }
193
+
194
+}