gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (releases): Attach and download prebuilt release assets

31f49278c7b875837f2065f508e3ecff58ad6325 · Phillip Souza Furtner · 2026-09-02T12:00:40Z

Changes

31 files changed, +1518 -72

MODIFY README.md +17 -2
diff --git a/README.md b/README.md
index c0e85c0..ab24a17 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 29f2fc5..fb7b476 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index 1e06657..16d0e28 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -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
diff --git a/docs/admins/persistent-data.md b/docs/admins/persistent-data.md
index 06f1fde..51ffeb7 100644
--- a/docs/admins/persistent-data.md
+++ b/docs/admins/persistent-data.md
@@ -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
diff --git a/docs/admins/releases.md b/docs/admins/releases.md
index 41b9e95..e41cd40 100644
--- a/docs/admins/releases.md
+++ b/docs/admins/releases.md
@@ -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
diff --git a/docs/maintainers/avatars.md b/docs/maintainers/avatars.md
index c8629b3..adb7242 100644
--- a/docs/maintainers/avatars.md
+++ b/docs/maintainers/avatars.md
@@ -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
diff --git a/docs/maintainers/releases.md b/docs/maintainers/releases.md
index acfb3f1..4dccb0f 100644
--- a/docs/maintainers/releases.md
+++ b/docs/maintainers/releases.md
@@ -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
diff --git a/docs/maintainers/repo-images.md b/docs/maintainers/repo-images.md
index a0a7c62..d856148 100644
--- a/docs/maintainers/repo-images.md
+++ b/docs/maintainers/repo-images.md
@@ -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
diff --git a/docs/users/releases.md b/docs/users/releases.md
index 2461d3e..2f5faec 100644
--- a/docs/users/releases.md
+++ b/docs/users/releases.md
@@ -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
diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm
index 3b48ea4..02ac061 100644
--- a/src/main/docker/Dockerfile.jvm
+++ b/src/main/docker/Dockerfile.jvm
@@ -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
diff --git a/src/main/docker/Dockerfile.legacy-jar b/src/main/docker/Dockerfile.legacy-jar
index 5cd55c6..0891694 100644
--- a/src/main/docker/Dockerfile.legacy-jar
+++ b/src/main/docker/Dockerfile.legacy-jar
@@ -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
diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native
index 9f9628a..8f72614 100644
--- a/src/main/docker/Dockerfile.native
+++ b/src/main/docker/Dockerfile.native
@@ -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
diff --git a/src/main/docker/Dockerfile.native-micro b/src/main/docker/Dockerfile.native-micro
index 72dc21c..5d487eb 100644
--- a/src/main/docker/Dockerfile.native-micro
+++ b/src/main/docker/Dockerfile.native-micro
@@ -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
diff --git a/src/main/docker/smoke-test-volumes.sh b/src/main/docker/smoke-test-volumes.sh
index 0bfe025..5313cfc 100755
--- a/src/main/docker/smoke-test-volumes.sh
+++ b/src/main/docker/smoke-test-volumes.sh
@@ -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
diff --git a/src/main/java/de/workaround/account/ImageValidation.java b/src/main/java/de/workaround/account/ImageValidation.java
index 5491696..26c9289 100644
--- a/src/main/java/de/workaround/account/ImageValidation.java
+++ b/src/main/java/de/workaround/account/ImageValidation.java
@@ -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
diff --git a/src/main/java/de/workaround/account/SettingsResource.java b/src/main/java/de/workaround/account/SettingsResource.java
index a7becb0..dee781a 100644
--- a/src/main/java/de/workaround/account/SettingsResource.java
+++ b/src/main/java/de/workaround/account/SettingsResource.java
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index d7133ad..10fcb8a 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/api/ReleaseApiResource.java b/src/main/java/de/workaround/api/ReleaseApiResource.java
index aeee78c..ca47377 100644
--- a/src/main/java/de/workaround/api/ReleaseApiResource.java
+++ b/src/main/java/de/workaround/api/ReleaseApiResource.java
@@ -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
diff --git a/src/main/java/de/workaround/git/GitRepositoryService.java b/src/main/java/de/workaround/git/GitRepositoryService.java
index 9ad0d11..b8aae7a 100644
--- a/src/main/java/de/workaround/git/GitRepositoryService.java
+++ b/src/main/java/de/workaround/git/GitRepositoryService.java
@@ -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
diff --git a/src/main/java/de/workaround/git/ReleaseService.java b/src/main/java/de/workaround/git/ReleaseService.java
index 361bdbd..23d152c 100644
--- a/src/main/java/de/workaround/git/ReleaseService.java
+++ b/src/main/java/de/workaround/git/ReleaseService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/ReleaseAsset.java b/src/main/java/de/workaround/model/ReleaseAsset.java
new file mode 100644
index 0000000..113224c
--- /dev/null
+++ b/src/main/java/de/workaround/model/ReleaseAsset.java
@@ -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
diff --git a/src/main/java/de/workaround/web/ReleaseResource.java b/src/main/java/de/workaround/web/ReleaseResource.java
index ef6d7b5..6d36ed4 100644
--- a/src/main/java/de/workaround/web/ReleaseResource.java
+++ b/src/main/java/de/workaround/web/ReleaseResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index ae16aa9..bb356da 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 0c295a8..b89f88c 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 9120ae0..b2fb403 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V33__release_assets.sql b/src/main/resources/db/migration/V33__release_assets.sql
new file mode 100644
index 0000000..004467b
--- /dev/null
+++ b/src/main/resources/db/migration/V33__release_assets.sql
@@ -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
diff --git a/src/main/resources/templates/ReleaseResource/release.html b/src/main/resources/templates/ReleaseResource/release.html
index 9d39bfb..40428cb 100644
--- a/src/main/resources/templates/ReleaseResource/release.html
+++ b/src/main/resources/templates/ReleaseResource/release.html
@@ -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
diff --git a/src/test/java/de/workaround/api/ReleaseAssetApiTest.java b/src/test/java/de/workaround/api/ReleaseAssetApiTest.java
new file mode 100644
index 0000000..714ed26
--- /dev/null
+++ b/src/test/java/de/workaround/api/ReleaseAssetApiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/ReleaseAssetServiceTest.java b/src/test/java/de/workaround/git/ReleaseAssetServiceTest.java
new file mode 100644
index 0000000..fe0a86f
--- /dev/null
+++ b/src/test/java/de/workaround/git/ReleaseAssetServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/model/ReleaseAssetTest.java b/src/test/java/de/workaround/model/ReleaseAssetTest.java
new file mode 100644
index 0000000..56bf84b
--- /dev/null
+++ b/src/test/java/de/workaround/model/ReleaseAssetTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/ReleaseAssetUiTest.java b/src/test/java/de/workaround/web/ReleaseAssetUiTest.java
new file mode 100644
index 0000000..1bd2195
--- /dev/null
+++ b/src/test/java/de/workaround/web/ReleaseAssetUiTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog