gitshark

Clone repository

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

← Commits

✨ (repos): Add per-repository custom images

9168bf3d8d69160cd8398bcf6471f7f442eca334 · Phillip Souza Furtner · 2026-07-09T12:08:58Z

Changes

25 files changed, +864 -90

MODIFY README.md +2 -0
diff --git a/README.md b/README.md
index 1b0e382..f39d3ce 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@
31 31 on private repositories, and manage issues and merge requests; deleting the repository,
32 32 managing mirrors, and managing collaborators stay owner-only. Guides:
33 33 [for users](docs/users/collaborators.md), [for admins](docs/admins/collaborators.md)
34 +- Per-repository images: the repo owner can upload a custom image (same PNG/JPEG/GIF/WebP, ≤ 2 MB, validated rules as avatars) on a dedicated owner-only repository **Settings** page (`/repos/{owner}/{name}/settings`), stored on the filesystem keyed by repo UUID. It replaces the owner's avatar wherever the repository is shown (repo lists, repo sidebar); a repository with no custom image falls back to its owner's avatar. Served at `GET /repos/{owner}/{name}/image`, visibility-guarded so a private repo's image never leaks (`404` for non-viewers), and removable back to the fallback
34 35 - Single access policy on all paths: owner read/write, collaborators read/write, public
35 36 world-readable, private repositories visible to the owner and collaborators only
36 37 - **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
@@ -148,6 +149,7 @@
148 149 |---|---|---|
149 150 | `GITSHARK_STORAGE_ROOT` | `data/repositories` | Root directory for bare repositories (persistent volume) |
150 151 | `GITSHARK_AVATAR_ROOT` | `data/avatars` | Root directory for uploaded profile pictures (persistent volume) |
152 +| `GITSHARK_REPO_IMAGE_ROOT` | `data/repo-images` | Root directory for uploaded per-repository images (persistent volume) |
151 153 | `GITSHARK_SSH_PORT` | `2222` | Embedded SSH server port |
152 154 | `GITSHARK_SSH_HOST_KEY` | `data/ssh/host-key` | Persisted SSH host key file |
153 155 | `QUARKUS_DATASOURCE_JDBC_URL` / `_USERNAME` / `_PASSWORD` | — (Dev Services in dev/test) | PostgreSQL connection |
MODIFY docs/README.md +5 -0
diff --git a/docs/README.md b/docs/README.md
index 94d6bed..2d90fc5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,6 +10,8 @@
10 10
11 11 - **[Profile settings](users/profile.md)** — change your username and display
12 12 name, upload or remove a profile picture.
13 +- **[Repository image](users/repository-image.md)** — give a repository its own
14 + picture instead of your avatar, from its owner-only Settings page.
13 15 - **[Federation](users/federation.md)** — follow public repositories on other
14 16 instances, the push feed, your federated identity.
15 17 - **[Push mirrors](users/mirrors.md)** — replicate a repository to an external
@@ -37,6 +39,9 @@
37 39
38 40 - **[Avatars](maintainers/avatars.md)** — profile-picture storage, validation,
39 41 and rendering, plus what's covered and what's out of scope.
42 +- **[Repository images](maintainers/repo-images.md)** — per-repository image
43 + storage, how it reuses the avatar machinery, owner-avatar fallback, and the
44 + visibility-guarded serving endpoint.
40 45 - **[ForgeFed architecture](maintainers/forgefed.md)** — how federation is
41 46 implemented, the decisions behind it, what works and what is still missing.
42 47 - **[Push mirrors architecture](maintainers/push-mirrors.md)** — trigger flow,
MODIFY docs/admins/getting-started.md +17 -12
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index dae35ba..5309790 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -166,6 +166,7 @@
166 166 # --- Storage & SSH ---
167 167 GITSHARK_STORAGE_ROOT: /data/repositories
168 168 GITSHARK_AVATAR_ROOT: /data/avatars
169 + GITSHARK_REPO_IMAGE_ROOT: /data/repo-images
169 170 GITSHARK_SSH_HOST_KEY: /data/ssh/host-key
170 171 GITSHARK_SSH_PORT: "2222"
171 172 ports:
@@ -173,6 +174,7 @@
173 174 volumes:
174 175 - repos:/data/repositories # bare git repositories
175 176 - avatars:/data/avatars # user profile pictures
177 + - repo-images:/data/repo-images # per-repository images
176 178 - ssh:/data/ssh # persistent SSH host key
177 179 healthcheck:
178 180 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080 && echo ok >&3"]
@@ -185,16 +187,17 @@
185 187 db-data:
186 188 repos:
187 189 avatars:
190 + repo-images:
188 191 ssh:
189 192 ```
190 193
191 194 Notes:
192 195
193 -- **Separate volumes for `/data/repositories`, `/data/avatars`, and `/data/ssh`** so
194 - Docker creates each mount point with the right ownership — no init container or
195 - `mkdir` needed. The SSH host key is generated on first boot and persists across
196 - restarts (so client `known_hosts` entries stay valid). All three mounts (plus the
197 - database) are mandatory for a stateful deployment — see
196 +- **Separate volumes for `/data/repositories`, `/data/avatars`, `/data/repo-images`,
197 + and `/data/ssh`** so Docker creates each mount point with the right ownership — no
198 + init container or `mkdir` needed. The SSH host key is generated on first boot and
199 + persists across restarts (so client `known_hosts` entries stay valid). All four mounts
200 + (plus the database) are mandatory for a stateful deployment — see
198 201 [Persistent data](persistent-data.md) for what each holds and what breaks without it.
199 202 - **HTTP port 8080 is not published** — it's reached through the reverse proxy on the
200 203 Compose network (Step 6). Only SSH (2222) is exposed directly.
@@ -295,6 +298,7 @@
295 298 | `QUARKUS_OIDC_TOKEN_STATE_ENCRYPTION_SECRET` | ✅ | — | Encrypts session/token cookie (≥ 32 chars) |
296 299 | `GITSHARK_STORAGE_ROOT` | — | `data/repositories` | On-disk bare-repo root |
297 300 | `GITSHARK_AVATAR_ROOT` | — | `data/avatars` | On-disk profile-picture (avatar) storage root |
301 +| `GITSHARK_REPO_IMAGE_ROOT` | — | `data/repo-images` | On-disk per-repository image storage root |
298 302 | `GITSHARK_SSH_HOST_KEY` | — | `data/ssh/host-key` | Persistent SSH host key path |
299 303 | `GITSHARK_SSH_PORT` | — | `2222` | Embedded SSH server port |
300 304 | `GITSHARK_SECRET_KEY` | — | — | Encrypts push-mirror secrets at rest; required to create mirrors (see [Push mirrors](mirrors.md)) |
@@ -339,23 +343,24 @@
339 343
340 344 ## Operations
341 345
342 -**Backups** — three things hold state (full inventory, including what breaks when each
346 +**Backups** — four things hold state (full inventory, including what breaks when each
343 347 is lost, in [Persistent data](persistent-data.md)):
344 348 - The `db-data` volume (metadata: users, repo records, issues, MRs, comments;
345 - also each avatar's content type and update timestamp — the bytes are not
346 - here).
349 + also each avatar's and repository image's content type and update timestamp —
350 + the bytes are not here).
347 351 - The `repos` volume (the actual git objects).
348 352 - The `avatars` volume (uploaded profile-picture bytes, one file per user).
353 +- The `repo-images` volume (uploaded per-repository image bytes, one file per repo).
349 354
350 -Back all three up together and consistently. A logical DB dump:
355 +Back all four up together and consistently. A logical DB dump:
351 356
352 357 ```bash
353 358 docker compose exec db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > gitshark-db.sql
354 359 ```
355 360
356 -Snapshot the `repos` and `avatars` volumes with your host's volume/snapshot tooling
357 -while the app is quiesced (or accept crash-consistent snapshots — bare repos and
358 -avatar files both tolerate them well).
361 +Snapshot the `repos`, `avatars`, and `repo-images` volumes with your host's
362 +volume/snapshot tooling while the app is quiesced (or accept crash-consistent
363 +snapshots — bare repos, avatar, and repository-image files all tolerate them well).
359 364
360 365 **Upgrades** — pull the new image and recreate the app:
361 366
MODIFY docs/admins/persistent-data.md +8 -7
diff --git a/docs/admins/persistent-data.md b/docs/admins/persistent-data.md
index 88346a1..33a8283 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 three filesystem locations**. Every one of
3 +git-shark keeps state in **one database and four 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,13 +8,14 @@
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 four stores
11 +## The five 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, SSH public keys, access-token hashes, push-mirror and federation state. Also each avatar'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, SSH public keys, access-token hashes, push-mirror and federation state. 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. |
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 +| 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. |
18 19 | 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. |
19 20
20 21 If you front git-shark with Caddy as in the [Getting Started](getting-started.md) guide,
@@ -22,7 +23,7 @@
22 23 Let's Encrypt account.
23 24
24 25 In the reference Compose file all of these are named volumes: `db-data`, `repos`,
25 -`avatars`, and `ssh`. The defaults above are *relative* paths — inside a container they
26 +`avatars`, `repo-images`, and `ssh`. The defaults above are *relative* paths — inside a container they
26 27 resolve to a directory that vanishes with the container, so production deployments must
27 28 set the `GITSHARK_*` variables to absolute paths on mounted volumes, exactly as the
28 29 reference Compose file does.
@@ -30,7 +31,7 @@
30 31 ## Checking a running deployment
31 32
32 33 ```bash
33 -docker compose exec app sh -c 'ls /data/repositories /data/avatars /data/ssh'
34 +docker compose exec app sh -c 'ls /data/repositories /data/avatars /data/repo-images /data/ssh'
34 35 docker inspect --format '{{range .Mounts}}{{.Destination}} <- {{.Source}}{{println}}{{end}}' \
35 36 "$(docker compose ps -q app)"
36 37 ```
@@ -69,6 +70,6 @@
69 70
70 71 ## Backups
71 72
72 -Back up all four stores together and consistently — a DB dump that references git
73 -objects or avatar files from a different point in time is only crash-consistent. See
73 +Back up all five stores together and consistently — a DB dump that references git
74 +objects, avatar, or repository-image files from a different point in time is only crash-consistent. See
74 75 [Getting Started → Operations](getting-started.md#operations) for the commands.
MODIFY docs/maintainers/avatars.md +7 -5
diff --git a/docs/maintainers/avatars.md b/docs/maintainers/avatars.md
index ace06f6..560ccfe 100644
--- a/docs/maintainers/avatars.md
+++ b/docs/maintainers/avatars.md
@@ -34,19 +34,21 @@
34 34
35 35 ## Validation
36 36
37 -All validation happens in `AvatarService.validate`, called from
38 -`SettingsResource.uploadAvatar`:
37 +All validation happens in `ImageValidation.validate` — a shared helper used by
38 +both `AvatarService` (called from `SettingsResource.uploadAvatar`) and the
39 +per-repository image feature (see [Repository images](repo-images.md)), so the
40 +rules stay identical in one place:
39 41
40 -- **Size cap**: 2 MB (`AvatarService.MAX_BYTES`).
42 +- **Size cap**: 2 MB (`ImageValidation.MAX_BYTES`).
41 43 - **Type allowlist**: PNG, JPEG, GIF, WebP (`image/png`, `image/jpeg`,
42 44 `image/gif`, `image/webp`).
43 45 - **Magic-byte check**: the declared content type must match the file's
44 - actual leading bytes (`AvatarService.ALLOWED`, e.g. PNG's `\x89PNG\r\n\x1a\n`
46 + actual leading bytes (`ImageValidation.ALLOWED`, e.g. PNG's `\x89PNG\r\n\x1a\n`
45 47 signature). This rejects a file that lies about its type — declaring
46 48 `image/png` but uploading something else fails validation rather than being
47 49 stored and served back with a wrong/dangerous content type.
48 50
49 -Validation failures throw `InvalidAvatarException`, caught in
51 +Validation failures throw `InvalidImageException`, caught in
50 52 `SettingsResource` and re-rendered as a form error on `/settings/profile`.
51 53
52 54 ## Rendering: one Qute tag, one render point
ADD docs/maintainers/repo-images.md +101 -0
diff --git a/docs/maintainers/repo-images.md b/docs/maintainers/repo-images.md
new file mode 100644
index 0000000..280fb6d
--- /dev/null
+++ b/docs/maintainers/repo-images.md
@@ -0,0 +1,101 @@
1 +# Repository images: implementation notes
2 +
3 +Maintainer-facing notes on the per-repository image feature: where the bytes
4 +live, how it reuses the avatar machinery, the render point with owner-avatar
5 +fallback, and the visibility-guarded serving endpoint. For the user-facing
6 +behavior see the [user guide](../users/repository-image.md); for
7 +deployment/config see [Getting Started](../admins/getting-started.md). This
8 +feature deliberately mirrors [avatars](avatars.md) — read that first.
9 +
10 +---
11 +
12 +## Storage: filesystem, not the database
13 +
14 +Repository image bytes live on the local filesystem under
15 +`gitshark.storage.repo-images` (env `GITSHARK_REPO_IMAGE_ROOT`, default
16 +`data/repo-images`), one file per repository named by the repository's UUID
17 +(`RepositoryImageService.imagePath`). Same convention as avatars and bare git
18 +repos — large binary blobs go on disk, not in PostgreSQL.
19 +
20 +The `repositories` table only stores metadata, added in
21 +`db/migration/V13__repository_image.sql`:
22 +
23 +- `image_content_type` (nullable) — the validated MIME type, needed to serve the
24 + file with the right `Content-Type`. `NULL` means the repository has no custom
25 + image (`Repository.hasImage()`) and falls back to its owner's avatar.
26 +- `image_updated_at` (nullable) — last upload timestamp, used only to cache-bust
27 + the `<img>` URL (`?v=<epoch millis>`).
28 +
29 +`RepositoryImageService` is the only component that touches the filesystem;
30 +`store` and `remove` are `@Transactional`, same consistency caveat as
31 +`AvatarService`. When a whole repository is deleted, `GitRepositoryService.delete`
32 +calls `RepositoryImageService.deleteFileFor` so the image bytes are removed too —
33 +otherwise they would be orphaned on disk under a UUID no row points at anymore.
34 +
35 +## Validation: shared with avatars
36 +
37 +Validation is **not** duplicated. `RepositoryImageService.store` calls the shared
38 +`ImageValidation.validate` (2 MB cap, PNG/JPEG/GIF/WebP allowlist, magic-byte
39 +check — WebP additionally verifies the `WEBP` form type at offset 8, since a bare
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.
44 +
45 +## Rendering: one Qute tag, owner-avatar fallback
46 +
47 +`templates/tags/repoAvatar.html` is the single place that decides how to render a
48 +repository, delegating to the user avatar tag when there is no custom image:
49 +
50 +```html
51 +{#if repo.hasImage}<img class="avatar" src="/repos/{repo.owner.username}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="{repo.name}">{#else}{#avatar user=repo.owner /}{/if}
52 +```
53 +
54 +Every template that shows a repository invokes it as `{#repoAvatar repo=... /}`
55 +instead of the previous `{#avatar user=repo.owner /}` (repository sidebar,
56 +home/explore repo lists, dashboard). Because the fallback branch reuses the
57 +avatar tag, repositories keep exactly their previous look until an image is
58 +uploaded.
59 +
60 +## Serving endpoint: visibility-guarded
61 +
62 +`GET /repos/{owner}/{name}/image` (`RepositoryResource.image`) is **not** public,
63 +unlike the user avatar endpoint. It goes through `requireReadable`, so a private
64 +repository's image is `404` for anyone who can't read the repo — the image must
65 +not leak repository existence or content. It returns `404` when the repo has no
66 +image (`hasImage()` false) or the file is missing, otherwise streams the bytes
67 +with the stored `image_content_type`.
68 +
69 +Upload and delete are owner-only, guarded by `RepositoryResource.requireOwner`
70 +(which returns `404` — not `403` — to non-owners, consistent with how private
71 +repos are hidden): `POST /repos/{owner}/{name}/image` (multipart, field `image`)
72 +and `POST /repos/{owner}/{name}/image/delete`. As defense-in-depth,
73 +`RepositoryImageService.store`/`remove` also take the acting `User` and assert
74 +`AccessPolicy.canWrite` themselves (mirroring `GitRepositoryService.delete` and
75 +`RepositoryPinService`), so a future non-REST caller can't bypass the check. The upload UI lives on the
76 +owner-only settings page `GET /repos/{owner}/{name}/settings`
77 +(`templates/RepositoryResource/settings.html`), linked from the repo sidebar only
78 +when `RepoNav.owner` is true.
79 +
80 +## Decisions
81 +
82 +- **Owner-avatar fallback, not an initials badge.** The request was to let a repo
83 + *override* the owner picture it already showed; keeping the owner avatar as the
84 + fallback means no repository's appearance changes until someone opts in.
85 +- **Shared `ImageValidation`/`InvalidImageException`.** Extracted from
86 + `AvatarService` rather than copied so the size cap, type allowlist, and
87 + magic-byte rules can never drift between the two upload paths.
88 +- **Dedicated settings page, not the overview.** Repository-owner actions get
89 + their own page (`/settings`) with room to grow, surfaced via an owner-only
90 + sidebar link.
91 +
92 +## What's covered / not covered
93 +
94 +**Covered** — anywhere a repository is rendered with an avatar: the repository
95 +sidebar, the home/explore repository lists, and the dashboard (pinned + all
96 +repositories).
97 +
98 +**Not covered, on purpose:**
99 +
100 +- **Federation actor documents.** A repository's ForgeFed `Repository` actor does
101 + not expose the custom image as an `icon`; this is a UI-only feature for now.
MODIFY docs/users/profile.md +4 -0
diff --git a/docs/users/profile.md b/docs/users/profile.md
index 328b318..2b9f2b3 100644
--- a/docs/users/profile.md
+++ b/docs/users/profile.md
@@ -43,6 +43,10 @@
43 43 - The owner in a repository's left sidebar.
44 44 - As the author of issues, merge requests, and merge-request review comments.
45 45
46 +On repository surfaces (the lists and the sidebar), a repository that has its own
47 +[repository image](repository-image.md) shows that image instead of your avatar;
48 +your avatar is the fallback when the repository has none.
49 +
46 50 **Not covered:** git commit authors (shown on a repository's overview page and
47 51 in the commit log) come from the commit's git identity, not your account, so
48 52 they always show an initials badge regardless of your uploaded picture.
ADD docs/users/repository-image.md +46 -0
diff --git a/docs/users/repository-image.md b/docs/users/repository-image.md
new file mode 100644
index 0000000..e8cb157
--- /dev/null
+++ b/docs/users/repository-image.md
@@ -0,0 +1,46 @@
1 +# Repository image: user guide
2 +
3 +Give a repository its own picture instead of showing your profile picture next
4 +to it. Only the repository's owner can set this, from the repository's
5 +**Settings** page.
6 +
7 +---
8 +
9 +## Setting a repository image
10 +
11 +1. Open the repository and click **Settings** in the left sidebar (only the
12 + owner sees this link), or go to `/repos/<owner>/<name>/settings`.
13 +2. Under **Repository image**, pick a file and press **Upload**.
14 +
15 +- **Allowed formats**: PNG, JPEG, GIF, WebP.
16 +- **Max size**: 2 MB.
17 +- Uploading again replaces the existing image.
18 +- If the repository already has an image, a **Remove image** button appears.
19 +
20 +The server checks both the declared file type and the file's actual content
21 +before accepting it, so renaming a file to fake its type doesn't work — you'll
22 +get an error and nothing is saved. (These are the same rules as your
23 +[profile picture](profile.md).)
24 +
25 +## Fallback: the owner's avatar
26 +
27 +A repository with no custom image shows its **owner's profile picture** — the
28 +same as before this feature existed. Uploading an image overrides that; removing
29 +it falls back to the owner's avatar again. So a repository's look never changes
30 +until someone deliberately sets an image.
31 +
32 +## Where the repository image shows up
33 +
34 +Once set, the image replaces the owner's avatar wherever the repository is
35 +listed:
36 +
37 +- The repository's left sidebar.
38 +- Repository lists on the home page and `/explore`.
39 +- Your dashboard (pinned and all repositories).
40 +
41 +## Visibility
42 +
43 +The image is served at `/repos/<owner>/<name>/image`. For a **private**
44 +repository it is only visible to people who can already see the repository —
45 +anyone else gets a "not found", so the image never reveals a private repo's
46 +existence.
MODIFY src/main/java/de/workaround/account/AvatarService.java +1 -51
diff --git a/src/main/java/de/workaround/account/AvatarService.java b/src/main/java/de/workaround/account/AvatarService.java
index 9ea0945..f411450 100644
--- a/src/main/java/de/workaround/account/AvatarService.java
+++ b/src/main/java/de/workaround/account/AvatarService.java
@@ -5,7 +5,6 @@
5 5 import java.nio.file.Files;
6 6 import java.nio.file.Path;
7 7 import java.time.Instant;
8 -import java.util.Map;
9 8 import java.util.Optional;
10 9
11 10 import de.workaround.model.User;
@@ -23,16 +22,6 @@
23 22 @ApplicationScoped
24 23 public class AvatarService
25 24 {
26 - static final long MAX_BYTES = 2L * 1024 * 1024;
27 -
28 - // Allowed content types mapped to the leading magic bytes we require the upload to actually start
29 - // with, so a mislabelled or spoofed file is rejected rather than served back with a wrong type.
30 - private static final Map<String, byte[]> ALLOWED = Map.of(
31 - "image/png", new byte[] { (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A },
32 - "image/jpeg", new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF },
33 - "image/gif", new byte[] { 'G', 'I', 'F', '8' },
34 - "image/webp", new byte[] { 'R', 'I', 'F', 'F' });
35 -
36 25 @Inject
37 26 User.Repo users;
38 27
@@ -42,7 +31,7 @@
42 31 @Transactional
43 32 public void store(User user, byte[] bytes, String declaredContentType)
44 33 {
45 - String contentType = validate(bytes, declaredContentType);
34 + String contentType = ImageValidation.validate(bytes, declaredContentType);
46 35
47 36 Path path = avatarPath(user);
48 37 try
@@ -103,43 +92,4 @@
103 92 return avatarRoot.resolve(user.id.toString());
104 93 }
105 94
106 - private static String validate(byte[] bytes, String declaredContentType)
107 - {
108 - if (bytes == null || bytes.length == 0)
109 - {
110 - throw new InvalidAvatarException("No image was uploaded.");
111 - }
112 - if (bytes.length > MAX_BYTES)
113 - {
114 - throw new InvalidAvatarException("Image is too large (max 2 MB).");
115 - }
116 - String contentType = declaredContentType == null ? "" : declaredContentType.trim().toLowerCase();
117 - byte[] magic = ALLOWED.get(contentType);
118 - if (magic == null)
119 - {
120 - throw new InvalidAvatarException("Unsupported image type. Use PNG, JPEG, GIF or WebP.");
121 - }
122 - if (!startsWith(bytes, magic))
123 - {
124 - throw new InvalidAvatarException("File content does not match its image type.");
125 - }
126 - return contentType;
127 - }
128 -
129 - private static boolean startsWith(byte[] bytes, byte[] prefix)
130 - {
131 - if (bytes.length < prefix.length)
132 - {
133 - return false;
134 - }
135 - for (int i = 0; i < prefix.length; i++)
136 - {
137 - if (bytes[i] != prefix[i])
138 - {
139 - return false;
140 - }
141 - }
142 - return true;
143 - }
144 -
145 95 }
ADD src/main/java/de/workaround/account/ImageValidation.java +83 -0
diff --git a/src/main/java/de/workaround/account/ImageValidation.java b/src/main/java/de/workaround/account/ImageValidation.java
new file mode 100644
index 0000000..5491696
--- /dev/null
+++ b/src/main/java/de/workaround/account/ImageValidation.java
@@ -0,0 +1,83 @@
1 +package de.workaround.account;
2 +
3 +import java.util.Map;
4 +
5 +/**
6 + * Shared validation for uploaded images (user avatars and repository images). Enforces a 2 MB limit
7 + * and an allowlist of content types, each mapped to the leading magic bytes we require the upload to
8 + * actually start with, so a mislabelled or spoofed file is rejected rather than stored and served
9 + * back with a wrong type.
10 + */
11 +public final class ImageValidation
12 +{
13 + static final long MAX_BYTES = 2L * 1024 * 1024;
14 +
15 + private static final Map<String, byte[]> ALLOWED = Map.of(
16 + "image/png", new byte[] { (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A },
17 + "image/jpeg", new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF },
18 + "image/gif", new byte[] { 'G', 'I', 'F', '8' },
19 + "image/webp", new byte[] { 'R', 'I', 'F', 'F' });
20 +
21 + // The WebP form type follows the 4-byte "RIFF" tag and a 4-byte size field, i.e. at offset 8.
22 + private static final byte[] WEBP_FORM_TYPE = new byte[] { 'W', 'E', 'B', 'P' };
23 +
24 + private ImageValidation()
25 + {
26 + }
27 +
28 + /**
29 + * Validates the uploaded bytes against the declared content type and returns the normalised
30 + * (trimmed, lower-cased) content type to persist. Throws {@link InvalidImageException} on any
31 + * violation.
32 + */
33 + public static String validate(byte[] bytes, String declaredContentType)
34 + {
35 + if (bytes == null || bytes.length == 0)
36 + {
37 + throw new InvalidImageException("No image was uploaded.");
38 + }
39 + if (bytes.length > MAX_BYTES)
40 + {
41 + throw new InvalidImageException("Image is too large (max 2 MB).");
42 + }
43 + String contentType = declaredContentType == null ? "" : declaredContentType.trim().toLowerCase();
44 + byte[] magic = ALLOWED.get(contentType);
45 + if (magic == null)
46 + {
47 + throw new InvalidImageException("Unsupported image type. Use PNG, JPEG, GIF or WebP.");
48 + }
49 + if (!startsWith(bytes, magic))
50 + {
51 + throw new InvalidImageException("File content does not match its image type.");
52 + }
53 + // "RIFF" is a generic container shared by WAV/AVI/…; a real WebP additionally carries the
54 + // "WEBP" form type at offset 8, so verify it rather than accept any RIFF file as an image.
55 + if (contentType.equals("image/webp") && !regionMatches(bytes, 8, WEBP_FORM_TYPE))
56 + {
57 + throw new InvalidImageException("File content does not match its image type.");
58 + }
59 + return contentType;
60 + }
61 +
62 + private static boolean startsWith(byte[] bytes, byte[] prefix)
63 + {
64 + return regionMatches(bytes, 0, prefix);
65 + }
66 +
67 + private static boolean regionMatches(byte[] bytes, int offset, byte[] expected)
68 + {
69 + if (bytes.length < offset + expected.length)
70 + {
71 + return false;
72 + }
73 + for (int i = 0; i < expected.length; i++)
74 + {
75 + if (bytes[offset + i] != expected[i])
76 + {
77 + return false;
78 + }
79 + }
80 + return true;
81 + }
82 +
83 +}
DELETE src/main/java/de/workaround/account/InvalidAvatarException.java +0 -10
diff --git a/src/main/java/de/workaround/account/InvalidAvatarException.java b/src/main/java/de/workaround/account/InvalidAvatarException.java
deleted file mode 100644
index f475bd2..0000000
--- a/src/main/java/de/workaround/account/InvalidAvatarException.java
+++ /dev/null
@@ -1,10 +0,0 @@
1 -package de.workaround.account;
2 -
3 -public class InvalidAvatarException extends RuntimeException
4 -{
5 - public InvalidAvatarException(String message)
6 - {
7 - super(message);
8 - }
9 -
10 -}
ADD src/main/java/de/workaround/account/InvalidImageException.java +14 -0
diff --git a/src/main/java/de/workaround/account/InvalidImageException.java b/src/main/java/de/workaround/account/InvalidImageException.java
new file mode 100644
index 0000000..a82d30c
--- /dev/null
+++ b/src/main/java/de/workaround/account/InvalidImageException.java
@@ -0,0 +1,14 @@
1 +package de.workaround.account;
2 +
3 +/**
4 + * Thrown when an uploaded image (a user avatar or a repository image) fails validation: empty,
5 + * too large, an unsupported content type, or content whose magic bytes do not match its declared type.
6 + */
7 +public class InvalidImageException extends RuntimeException
8 +{
9 + public InvalidImageException(String message)
10 + {
11 + super(message);
12 + }
13 +
14 +}
MODIFY src/main/java/de/workaround/account/SettingsResource.java +1 -1
diff --git a/src/main/java/de/workaround/account/SettingsResource.java b/src/main/java/de/workaround/account/SettingsResource.java
index 7cb3889..5d33f1d 100644
--- a/src/main/java/de/workaround/account/SettingsResource.java
+++ b/src/main/java/de/workaround/account/SettingsResource.java
@@ -104,7 +104,7 @@
104 104 avatars.store(user, Files.readAllBytes(avatar.uploadedFile()), avatar.contentType());
105 105 return Response.seeOther(URI.create("/settings/profile")).build();
106 106 }
107 - catch (InvalidAvatarException e)
107 + catch (InvalidImageException e)
108 108 {
109 109 return Response.status(Response.Status.BAD_REQUEST)
110 110 .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(), e.getMessage()))
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 0def8a0..692eeea 100644
--- a/src/main/java/de/workaround/git/GitRepositoryService.java
+++ b/src/main/java/de/workaround/git/GitRepositoryService.java
@@ -34,6 +34,9 @@
34 34 @Inject
35 35 User.Repo users;
36 36
37 + @Inject
38 + RepositoryImageService images;
39 +
37 40 @ConfigProperty(name = "gitshark.storage.root")
38 41 Path storageRoot;
39 42
@@ -84,6 +87,7 @@
84 87 throw new ForbiddenOperationException("Only the owner may delete a repository");
85 88 }
86 89 Path path = repositoryPath(repository);
90 + images.deleteFileFor(repository);
87 91 repositories.deleteById(repository.id);
88 92 try
89 93 {
ADD src/main/java/de/workaround/git/RepositoryImageService.java +125 -0
diff --git a/src/main/java/de/workaround/git/RepositoryImageService.java b/src/main/java/de/workaround/git/RepositoryImageService.java
new file mode 100644
index 0000000..3a0e31c
--- /dev/null
+++ b/src/main/java/de/workaround/git/RepositoryImageService.java
@@ -0,0 +1,125 @@
1 +package de.workaround.git;
2 +
3 +import java.io.IOException;
4 +import java.io.UncheckedIOException;
5 +import java.nio.file.Files;
6 +import java.nio.file.Path;
7 +import java.time.Instant;
8 +import java.util.Optional;
9 +
10 +import de.workaround.account.ImageValidation;
11 +import de.workaround.model.Repository;
12 +import de.workaround.model.User;
13 +import jakarta.enterprise.context.ApplicationScoped;
14 +import jakarta.inject.Inject;
15 +import jakarta.transaction.Transactional;
16 +import org.eclipse.microprofile.config.inject.ConfigProperty;
17 +
18 +/**
19 + * Stores custom repository images on the local filesystem, keyed by repository id. Only the content
20 + * type and update timestamp are persisted on the {@link Repository} row; the bytes live under
21 + * {@code gitshark.storage.repo-images}. Mirrors the user-avatar subsystem (AvatarService); a repository
22 + * with no stored image falls back to its owner's avatar when rendered.
23 + */
24 +@ApplicationScoped
25 +public class RepositoryImageService
26 +{
27 + @Inject
28 + Repository.Repo repositories;
29 +
30 + @Inject
31 + AccessPolicy accessPolicy;
32 +
33 + @ConfigProperty(name = "gitshark.storage.repo-images")
34 + Path imageRoot;
35 +
36 + @Transactional
37 + public void store(User actor, Repository repository, byte[] bytes, String declaredContentType)
38 + {
39 + requireWrite(actor, repository);
40 + String contentType = ImageValidation.validate(bytes, declaredContentType);
41 +
42 + Path path = imagePath(repository);
43 + try
44 + {
45 + Files.createDirectories(imageRoot);
46 + Files.write(path, bytes);
47 + }
48 + catch (IOException e)
49 + {
50 + throw new UncheckedIOException("Failed to write repository image to " + path, e);
51 + }
52 +
53 + Repository managed = repositories.findById(repository.id);
54 + managed.imageContentType = contentType;
55 + managed.imageUpdatedAt = Instant.now();
56 + repository.imageContentType = managed.imageContentType;
57 + repository.imageUpdatedAt = managed.imageUpdatedAt;
58 + }
59 +
60 + @Transactional
61 + public void remove(User actor, Repository repository)
62 + {
63 + requireWrite(actor, repository);
64 + deleteFile(repository);
65 +
66 + Repository managed = repositories.findById(repository.id);
67 + managed.imageContentType = null;
68 + managed.imageUpdatedAt = null;
69 + repository.imageContentType = null;
70 + repository.imageUpdatedAt = null;
71 + }
72 +
73 + /**
74 + * Removes only the image file, leaving no DB update. Called when the whole repository is being
75 + * deleted (its row and authorization are handled by {@link GitRepositoryService#delete}); without
76 + * this the image bytes would be orphaned on disk under a now-unreachable UUID.
77 + */
78 + public void deleteFileFor(Repository repository)
79 + {
80 + deleteFile(repository);
81 + }
82 +
83 + private void deleteFile(Repository repository)
84 + {
85 + try
86 + {
87 + Files.deleteIfExists(imagePath(repository));
88 + }
89 + catch (IOException e)
90 + {
91 + throw new UncheckedIOException("Failed to delete repository image for " + repository.id, e);
92 + }
93 + }
94 +
95 + private void requireWrite(User actor, Repository repository)
96 + {
97 + if (!accessPolicy.canWrite(actor, repository))
98 + {
99 + throw new ForbiddenOperationException("Only the owner may change a repository's image");
100 + }
101 + }
102 +
103 + public Optional<byte[]> read(Repository repository)
104 + {
105 + Path path = imagePath(repository);
106 + if (!Files.exists(path))
107 + {
108 + return Optional.empty();
109 + }
110 + try
111 + {
112 + return Optional.of(Files.readAllBytes(path));
113 + }
114 + catch (IOException e)
115 + {
116 + throw new UncheckedIOException("Failed to read repository image for " + repository.id, e);
117 + }
118 + }
119 +
120 + private Path imagePath(Repository repository)
121 + {
122 + return imageRoot.resolve(repository.id.toString());
123 + }
124 +
125 +}
MODIFY src/main/java/de/workaround/model/Repository.java +11 -0
diff --git a/src/main/java/de/workaround/model/Repository.java b/src/main/java/de/workaround/model/Repository.java
index 90909ea..55b408b 100644
--- a/src/main/java/de/workaround/model/Repository.java
+++ b/src/main/java/de/workaround/model/Repository.java
@@ -37,8 +37,19 @@
37 37
38 38 public String description;
39 39
40 + // Custom repository image: bytes live on the filesystem (gitshark.storage.repo-images) keyed by id.
41 + // A null content type means no custom image, in which case the repo falls back to the owner's avatar.
42 + public String imageContentType;
43 +
44 + public Instant imageUpdatedAt;
45 +
40 46 public Instant createdAt = Instant.now();
41 47
48 + public boolean hasImage()
49 + {
50 + return imageContentType != null;
51 + }
52 +
42 53 public enum Visibility
43 54 {
44 55 PUBLIC,
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +90 -0
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 5a353f1..96cac13 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -1,7 +1,10 @@
1 1 package de.workaround.web;
2 2
3 +import java.io.IOException;
4 +import java.io.UncheckedIOException;
3 5 import java.net.URI;
4 6 import java.nio.charset.StandardCharsets;
7 +import java.nio.file.Files;
5 8 import java.nio.file.Path;
6 9 import java.time.Duration;
7 10 import java.time.Instant;
@@ -12,10 +15,15 @@
12 15 import java.util.Set;
13 16 import java.util.TreeSet;
14 17
18 +import org.jboss.resteasy.reactive.RestForm;
19 +import org.jboss.resteasy.reactive.multipart.FileUpload;
20 +
15 21 import de.workaround.account.CurrentUser;
22 +import de.workaround.account.InvalidImageException;
16 23 import de.workaround.git.AccessPolicy;
17 24 import de.workaround.git.GitBrowseService;
18 25 import de.workaround.git.GitRepositoryService;
26 +import de.workaround.git.RepositoryImageService;
19 27 import de.workaround.git.RepositoryPinService;
20 28 import de.workaround.model.Repository;
21 29 import de.workaround.model.User;
@@ -63,6 +71,8 @@
63 71 List<GitBrowseService.BranchInfo> branches);
64 72
65 73 static native TemplateInstance tags(Repository repo, RepoNav nav, List<String> tags);
74 +
75 + static native TemplateInstance settings(Repository repo, RepoNav nav, String error);
66 76 }
67 77
68 78 @Inject
@@ -86,6 +96,9 @@
86 96 @Inject
87 97 de.workaround.mirror.MirrorService mirrorService;
88 98
99 + @Inject
100 + RepositoryImageService images;
101 +
89 102 @Context
90 103 UriInfo uriInfo;
91 104
@@ -260,6 +273,71 @@
260 273 return Templates.tags(repo, nav, browse.tags(path));
261 274 }
262 275
276 + @GET
277 + @jakarta.ws.rs.Path("settings")
278 + public TemplateInstance settings(@PathParam("owner") String owner, @PathParam("name") String name)
279 + {
280 + Repository repo = requireOwner(owner, name);
281 + return Templates.settings(repo, repoNav.build(repo, uriInfo), null);
282 + }
283 +
284 + @POST
285 + @jakarta.ws.rs.Path("image")
286 + @Consumes(MediaType.MULTIPART_FORM_DATA)
287 + public Response uploadImage(@PathParam("owner") String owner, @PathParam("name") String name,
288 + @RestForm("image") FileUpload image)
289 + {
290 + Repository repo = requireOwner(owner, name);
291 + if (image == null)
292 + {
293 + return Response.status(Response.Status.BAD_REQUEST)
294 + .entity(Templates.settings(repo, repoNav.build(repo, uriInfo), "No image was uploaded.")).build();
295 + }
296 + try
297 + {
298 + images.store(currentUser.require(), repo, Files.readAllBytes(image.uploadedFile()), image.contentType());
299 + return Response.seeOther(settingsUri(repo)).build();
300 + }
301 + catch (InvalidImageException e)
302 + {
303 + return Response.status(Response.Status.BAD_REQUEST)
304 + .entity(Templates.settings(repo, repoNav.build(repo, uriInfo), e.getMessage())).build();
305 + }
306 + catch (IOException e)
307 + {
308 + throw new UncheckedIOException("Failed to read uploaded repository image", e);
309 + }
310 + }
311 +
312 + @POST
313 + @jakarta.ws.rs.Path("image/delete")
314 + public Response deleteImage(@PathParam("owner") String owner, @PathParam("name") String name)
315 + {
316 + Repository repo = requireOwner(owner, name);
317 + images.remove(currentUser.require(), repo);
318 + return Response.seeOther(settingsUri(repo)).build();
319 + }
320 +
321 + @GET
322 + @jakarta.ws.rs.Path("image")
323 + @Produces(MediaType.APPLICATION_OCTET_STREAM)
324 + public Response image(@PathParam("owner") String owner, @PathParam("name") String name)
325 + {
326 + // Visibility-guarded (unlike the public user avatar): a private repo's image must not leak.
327 + Repository repo = requireReadable(owner, name);
328 + if (!repo.hasImage())
329 + {
330 + throw new NotFoundException();
331 + }
332 + byte[] bytes = images.read(repo).orElseThrow(NotFoundException::new);
333 + return Response.ok(bytes).type(repo.imageContentType).build();
334 + }
335 +
336 + private static URI settingsUri(Repository repo)
337 + {
338 + return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/settings");
339 + }
340 +
263 341 @POST
264 342 @jakarta.ws.rs.Path("delete")
265 343 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@@ -428,4 +506,16 @@
428 506 return repo;
429 507 }
430 508
509 + private Repository requireOwner(String owner, String name)
510 + {
511 + Repository repo = requireReadable(owner, name);
512 + User user = currentUser.get();
513 + if (user == null || !user.id.equals(repo.owner.id))
514 + {
515 + // hide existence from non-owners rather than signalling 403
516 + throw new NotFoundException();
517 + }
518 + return repo;
519 + }
520 +
431 521 }
MODIFY src/main/resources/application.properties +4 -0
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 1e75422..9a94fe2 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -69,6 +69,10 @@
69 69 gitshark.storage.avatars=${GITSHARK_AVATAR_ROOT:data/avatars}
70 70 %test.gitshark.storage.avatars=target/test-avatars
71 71
72 +# Repository images: bytes stored on the filesystem, keyed by repository id
73 +gitshark.storage.repo-images=${GITSHARK_REPO_IMAGE_ROOT:data/repo-images}
74 +%test.gitshark.storage.repo-images=target/test-repo-images
75 +
72 76 # Embedded SSH server
73 77 gitshark.ssh.port=${GITSHARK_SSH_PORT:2222}
74 78 gitshark.ssh.host-key-path=${GITSHARK_SSH_HOST_KEY:data/ssh/host-key}
ADD src/main/resources/db/migration/V13__repository_image.sql +7 -0
diff --git a/src/main/resources/db/migration/V13__repository_image.sql b/src/main/resources/db/migration/V13__repository_image.sql
new file mode 100644
index 0000000..e5d62b5
--- /dev/null
+++ b/src/main/resources/db/migration/V13__repository_image.sql
@@ -0,0 +1,7 @@
1 +-- Per-repository images: like user avatars (V10), the bytes live on the filesystem
2 +-- (gitshark.storage.repo-images) and the DB keeps only the content type (to serve with the right
3 +-- MIME type) and an update timestamp (to cache-bust the <img> URL). NULL image_content_type means
4 +-- the repository has no custom image and falls back to its owner's avatar.
5 +ALTER TABLE repositories
6 + ADD COLUMN image_content_type varchar(64),
7 + ADD COLUMN image_updated_at timestamptz;
MODIFY src/main/resources/templates/HomeResource/dashboard.html +2 -2
diff --git a/src/main/resources/templates/HomeResource/dashboard.html b/src/main/resources/templates/HomeResource/dashboard.html
index 66598ed..7aa7dc7 100644
--- a/src/main/resources/templates/HomeResource/dashboard.html
+++ b/src/main/resources/templates/HomeResource/dashboard.html
@@ -11,7 +11,7 @@
11 11 <tr><th>Repository</th><th>Visibility</th><th></th></tr>
12 12 {#for repo in pinned}
13 13 <tr>
14 - <td>{#avatar user=repo.owner /} <a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
14 + <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
15 15 <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
16 16 <td class="actions">
17 17 <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/unpin">
@@ -50,7 +50,7 @@
50 50 <tr><th>Repository</th><th>Visibility</th><th>Description</th><th></th></tr>
51 51 {#for row in repositories}
52 52 <tr>
53 - <td>{#avatar user=row.repo.owner /} <a class="mono" href="/repos/{row.repo.owner.username}/{row.repo.name}">{row.repo.owner.username}/{row.repo.name}</a></td>
53 + <td>{#repoAvatar repo=row.repo /} <a class="mono" href="/repos/{row.repo.owner.username}/{row.repo.name}">{row.repo.owner.username}/{row.repo.name}</a></td>
54 54 <td><span class="badge badge-{row.repo.visibility.name().toLowerCase()}">{row.repo.visibility.name().toLowerCase()}</span></td>
55 55 <td class="muted">{row.repo.description ?: ''}</td>
56 56 <td class="actions">
MODIFY src/main/resources/templates/HomeResource/home.html +1 -1
diff --git a/src/main/resources/templates/HomeResource/home.html b/src/main/resources/templates/HomeResource/home.html
index b9caf53..a65cc26 100644
--- a/src/main/resources/templates/HomeResource/home.html
+++ b/src/main/resources/templates/HomeResource/home.html
@@ -22,7 +22,7 @@
22 22 <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
23 23 {#for repo in repositories}
24 24 <tr>
25 - <td>{#avatar user=repo.owner /} <a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
25 + <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
26 26 <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
27 27 <td class="muted">{repo.description ?: ''}</td>
28 28 </tr>
ADD src/main/resources/templates/RepositoryResource/settings.html +33 -0
diff --git a/src/main/resources/templates/RepositoryResource/settings.html b/src/main/resources/templates/RepositoryResource/settings.html
new file mode 100644
index 0000000..b274845
--- /dev/null
+++ b/src/main/resources/templates/RepositoryResource/settings.html
@@ -0,0 +1,33 @@
1 +{#include layout}
2 +{#title}Settings · {repo.owner.username}/{repo.name} – git-shark{/title}
3 +
4 +<div class="repo-layout">
5 +
6 + {#include RepositoryResource/sidebar nav=nav active='settings' /}
7 +
8 + <section class="repo-main">
9 + <h1>Settings</h1>
10 + {#if error}
11 + <p class="error">{error}</p>
12 + {/if}
13 +
14 + <h2>Repository image</h2>
15 + <p class="muted">Shown next to this repository across git-shark. Without one, the owner's profile picture is used.</p>
16 + {#if repo.hasImage}
17 + <img class="avatar-preview" src="/repos/{repo.owner.username}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="Current repository image">
18 + {/if}
19 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/image" enctype="multipart/form-data">
20 + <p><input type="file" name="image" accept="image/png,image/jpeg,image/gif,image/webp" required></p>
21 + <p class="muted">PNG, JPEG, GIF or WebP, up to 2 MB.</p>
22 + <button class="btn btn-primary">Upload</button>
23 + </form>
24 + {#if repo.hasImage}
25 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/image/delete"
26 + onsubmit="return confirm('Remove this repository image?')">
27 + <button class="btn btn-danger">Remove image</button>
28 + </form>
29 + {/if}
30 + </section>
31 +
32 +</div>
33 +{/include}
MODIFY src/main/resources/templates/RepositoryResource/sidebar.html +2 -1
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index 1bed5c7..3b24bec 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -1,5 +1,5 @@
1 1 <aside class="repo-side">
2 - <div class="owner">{#avatar user=nav.repo.owner /} {nav.repo.owner.username} /</div>
2 + <div class="owner">{#repoAvatar repo=nav.repo /} {nav.repo.owner.username} /</div>
3 3 <div class="repo-name"><a href="/repos/{nav.repo.owner.username}/{nav.repo.name}">{nav.repo.name}</a></div>
4 4 <span class="tag{#if nav.repo.visibility.name() == 'PRIVATE'} tag-private{/if}"><span class="dot"></span> {nav.repo.visibility.name()}</span>
5 5 {#if nav.repo.description}
@@ -29,6 +29,7 @@
29 29 <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
30 30 <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
31 31 {#if nav.isOwner}
32 + <a class="{#if active == 'settings'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/settings"><span class="g">⚙</span> Settings</a>
32 33 <a class="{#if active == 'collaborators'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/settings/collaborators"><span class="g">⚇</span> Collaborators</a>
33 34 {/if}
34 35 </nav>
ADD src/main/resources/templates/tags/repoAvatar.html +1 -0
diff --git a/src/main/resources/templates/tags/repoAvatar.html b/src/main/resources/templates/tags/repoAvatar.html
new file mode 100644
index 0000000..21ada92
--- /dev/null
+++ b/src/main/resources/templates/tags/repoAvatar.html
@@ -0,0 +1 @@
1 +{#if repo.hasImage}<img class="avatar" src="/repos/{repo.owner.username}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="{repo.name}">{#else}{#avatar user=repo.owner /}{/if}
ADD src/test/java/de/workaround/web/RepositoryImageTest.java +295 -0
diff --git a/src/test/java/de/workaround/web/RepositoryImageTest.java b/src/test/java/de/workaround/web/RepositoryImageTest.java
new file mode 100644
index 0000000..b7e09c5
--- /dev/null
+++ b/src/test/java/de/workaround/web/RepositoryImageTest.java
@@ -0,0 +1,295 @@
1 +package de.workaround.web;
2 +
3 +import java.awt.image.BufferedImage;
4 +import java.io.ByteArrayOutputStream;
5 +import java.io.IOException;
6 +import java.io.UncheckedIOException;
7 +import java.nio.file.Files;
8 +import java.nio.file.Path;
9 +import java.util.UUID;
10 +
11 +import javax.imageio.ImageIO;
12 +
13 +import org.junit.jupiter.api.Test;
14 +
15 +import de.workaround.account.AvatarService;
16 +import de.workaround.git.ForbiddenOperationException;
17 +import de.workaround.git.GitRepositoryService;
18 +import de.workaround.git.RepositoryImageService;
19 +import de.workaround.model.Repository;
20 +import de.workaround.model.User;
21 +import io.quarkus.test.junit.QuarkusTest;
22 +import io.quarkus.test.security.TestSecurity;
23 +import jakarta.inject.Inject;
24 +import jakarta.persistence.EntityManager;
25 +import jakarta.transaction.Transactional;
26 +import org.eclipse.microprofile.config.inject.ConfigProperty;
27 +
28 +import static io.restassured.RestAssured.given;
29 +import static org.hamcrest.CoreMatchers.containsString;
30 +import static org.hamcrest.CoreMatchers.not;
31 +import static org.hamcrest.Matchers.anyOf;
32 +import static org.hamcrest.Matchers.is;
33 +import static org.junit.jupiter.api.Assertions.assertArrayEquals;
34 +import static org.junit.jupiter.api.Assertions.assertEquals;
35 +import static org.junit.jupiter.api.Assertions.assertFalse;
36 +import static org.junit.jupiter.api.Assertions.assertNotNull;
37 +import static org.junit.jupiter.api.Assertions.assertNull;
38 +import static org.junit.jupiter.api.Assertions.assertThrows;
39 +import static org.junit.jupiter.api.Assertions.assertTrue;
40 +
41 +@QuarkusTest
42 +class RepositoryImageTest
43 +{
44 + @Inject
45 + GitRepositoryService service;
46 +
47 + @Inject
48 + AvatarService avatars;
49 +
50 + @Inject
51 + RepositoryImageService imageService;
52 +
53 + @Inject
54 + Repository.Repo repositories;
55 +
56 + @Inject
57 + User.Repo users;
58 +
59 + @Inject
60 + EntityManager em;
61 +
62 + @ConfigProperty(name = "gitshark.storage.repo-images")
63 + Path imageRoot;
64 +
65 + @Test
66 + @TestSecurity(user = "img-owner")
67 + void ownerUploadsAndServesImage()
68 + {
69 + User owner = persistUser("img-owner");
70 + Repository repo = service.create(owner, "shark", Repository.Visibility.PUBLIC, null);
71 + byte[] png = png();
72 +
73 + given().redirects().follow(false)
74 + .multiPart("image", "logo.png", png, "image/png")
75 + .when().post("/repos/img-owner/shark/image")
76 + .then().statusCode(anyOf(is(302), is(303)));
77 +
78 + Repository stored = byId(repo.id);
79 + assertEquals("image/png", stored.imageContentType);
80 + assertNotNull(stored.imageUpdatedAt);
81 + assertTrue(Files.exists(imageRoot.resolve(repo.id.toString())), "image file written to disk");
82 +
83 + byte[] served = given()
84 + .when().get("/repos/img-owner/shark/image")
85 + .then().statusCode(200)
86 + .contentType("image/png")
87 + .extract().asByteArray();
88 + assertArrayEquals(png, served);
89 + }
90 +
91 + @Test
92 + @TestSecurity(user = "img-fallback")
93 + void withoutImageRepoFallsBackToOwnerAvatar()
94 + {
95 + User owner = persistUser("img-fallback");
96 + avatars.store(owner, png(), "image/png");
97 + service.create(owner, "plain", Repository.Visibility.PUBLIC, null);
98 +
99 + // no custom image => serving 404s and the repo renders the owner's avatar, not a repo image
100 + given().when().get("/repos/img-fallback/plain/image").then().statusCode(404);
101 + given().when().get("/repos/img-fallback/plain")
102 + .then().statusCode(200)
103 + .body(containsString("/users/img-fallback/avatar"))
104 + .body(not(containsString("/repos/img-fallback/plain/image")));
105 + }
106 +
107 + @Test
108 + @TestSecurity(user = "img-owner-set")
109 + void repoWithImageRendersItsOwnUrl()
110 + {
111 + User owner = persistUser("img-owner-set");
112 + service.create(owner, "branded", Repository.Visibility.PUBLIC, null);
113 +
114 + given().redirects().follow(false)
115 + .multiPart("image", "logo.png", png(), "image/png")
116 + .when().post("/repos/img-owner-set/branded/image")
117 + .then().statusCode(anyOf(is(302), is(303)));
118 +
119 + given().when().get("/repos/img-owner-set/branded")
120 + .then().statusCode(200)
121 + .body(containsString("/repos/img-owner-set/branded/image"));
122 + }
123 +
124 + @Test
125 + @TestSecurity(user = "img-stranger")
126 + void nonOwnerCannotUpload()
127 + {
128 + persistUser("img-stranger");
129 + User owner = persistUser("img-real-owner-" + UUID.randomUUID().toString().substring(0, 8));
130 + Repository repo = service.create(owner, "guarded", Repository.Visibility.PUBLIC, null);
131 +
132 + given().redirects().follow(false)
133 + .multiPart("image", "logo.png", png(), "image/png")
134 + .when().post("/repos/" + owner.username + "/guarded/image")
135 + .then().statusCode(404);
136 +
137 + assertNull(byId(repo.id).imageContentType, "non-owner upload not persisted");
138 + }
139 +
140 + @Test
141 + @TestSecurity(user = "img-outsider")
142 + void privateRepoImageIsHiddenFromStrangers()
143 + {
144 + persistUser("img-outsider");
145 + User owner = persistUser("img-secret-owner-" + UUID.randomUUID().toString().substring(0, 8));
146 + service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
147 +
148 + given().when().get("/repos/" + owner.username + "/secret/image").then().statusCode(404);
149 + }
150 +
151 + @Test
152 + @TestSecurity(user = "img-big")
153 + void rejectsOversized()
154 + {
155 + User owner = persistUser("img-big");
156 + Repository repo = service.create(owner, "big", Repository.Visibility.PUBLIC, null);
157 + byte[] big = new byte[2 * 1024 * 1024 + 1];
158 + byte[] magic = { (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A };
159 + System.arraycopy(magic, 0, big, 0, magic.length);
160 +
161 + given().redirects().follow(false)
162 + .multiPart("image", "big.png", big, "image/png")
163 + .when().post("/repos/img-big/big/image")
164 + .then().statusCode(400);
165 +
166 + assertNull(byId(repo.id).imageContentType, "oversized upload not persisted");
167 + }
168 +
169 + @Test
170 + @TestSecurity(user = "img-spoof")
171 + void rejectsContentTypeMagicMismatch()
172 + {
173 + User owner = persistUser("img-spoof");
174 + Repository repo = service.create(owner, "spoof", Repository.Visibility.PUBLIC, null);
175 +
176 + given().redirects().follow(false)
177 + .multiPart("image", "fake.png", "not really a png".getBytes(), "image/png")
178 + .when().post("/repos/img-spoof/spoof/image")
179 + .then().statusCode(400);
180 +
181 + assertNull(byId(repo.id).imageContentType, "spoofed magic bytes not persisted");
182 + }
183 +
184 + @Test
185 + @TestSecurity(user = "img-del")
186 + void removesImage()
187 + {
188 + User owner = persistUser("img-del");
189 + Repository repo = service.create(owner, "temp", Repository.Visibility.PUBLIC, null);
190 +
191 + given().redirects().follow(false)
192 + .multiPart("image", "logo.png", png(), "image/png")
193 + .when().post("/repos/img-del/temp/image")
194 + .then().statusCode(anyOf(is(302), is(303)));
195 + assertNotNull(byId(repo.id).imageContentType);
196 +
197 + given().redirects().follow(false)
198 + .when().post("/repos/img-del/temp/image/delete")
199 + .then().statusCode(anyOf(is(302), is(303)));
200 +
201 + Repository cleared = byId(repo.id);
202 + assertNull(cleared.imageContentType);
203 + assertNull(cleared.imageUpdatedAt);
204 + assertFalse(Files.exists(imageRoot.resolve(repo.id.toString())), "image file deleted from disk");
205 +
206 + given().when().get("/repos/img-del/temp/image").then().statusCode(404);
207 + }
208 +
209 + @Test
210 + @TestSecurity(user = "img-del-repo")
211 + void deletingRepositoryAlsoRemovesImageFile()
212 + {
213 + User owner = persistUser("img-del-repo");
214 + Repository repo = service.create(owner, "gone", Repository.Visibility.PUBLIC, null);
215 +
216 + given().redirects().follow(false)
217 + .multiPart("image", "logo.png", png(), "image/png")
218 + .when().post("/repos/img-del-repo/gone/image")
219 + .then().statusCode(anyOf(is(302), is(303)));
220 + assertTrue(Files.exists(imageRoot.resolve(repo.id.toString())));
221 +
222 + service.delete(owner, repo);
223 +
224 + assertFalse(Files.exists(imageRoot.resolve(repo.id.toString())), "image file removed with the repository");
225 + }
226 +
227 + @Test
228 + @TestSecurity(user = "img-webp")
229 + void rejectsRiffThatIsNotWebp()
230 + {
231 + User owner = persistUser("img-webp");
232 + Repository repo = service.create(owner, "webp", Repository.Visibility.PUBLIC, null);
233 +
234 + // "RIFF" container but the format tag at offset 8 is "AVI ", not "WEBP" — must be rejected
235 + byte[] riff = { 'R', 'I', 'F', 'F', 0, 0, 0, 0, 'A', 'V', 'I', ' ', 0, 0, 0, 0 };
236 +
237 + given().redirects().follow(false)
238 + .multiPart("image", "fake.webp", riff, "image/webp")
239 + .when().post("/repos/img-webp/webp/image")
240 + .then().statusCode(400);
241 +
242 + assertNull(byId(repo.id).imageContentType, "non-WebP RIFF file not persisted");
243 + }
244 +
245 + @Test
246 + @TestSecurity(user = "img-svc")
247 + void serviceRejectsNonOwnerActor()
248 + {
249 + User owner = persistUser("img-svc-owner-" + UUID.randomUUID().toString().substring(0, 8));
250 + User stranger = persistUser("img-svc-stranger-" + UUID.randomUUID().toString().substring(0, 8));
251 + Repository repo = service.create(owner, "svc", Repository.Visibility.PUBLIC, null);
252 +
253 + assertThrows(ForbiddenOperationException.class,
254 + () -> imageService.store(stranger, repo, png(), "image/png"));
255 + assertFalse(Files.exists(imageRoot.resolve(repo.id.toString())), "rejected upload writes no file");
256 + }
257 +
258 + // Clear the persistence context first: each HTTP call commits in its own transaction, so a
259 + // re-read of the same repository within one test method must not return the stale L1-cached instance.
260 + private Repository byId(UUID id)
261 + {
262 + em.clear();
263 + return repositories.findById(id);
264 + }
265 +
266 + @Transactional
267 + User persistUser(String name)
268 + {
269 + User existing = users.findByOidcSubOptional(name).orElse(null);
270 + if (existing != null)
271 + {
272 + return existing;
273 + }
274 + User user = new User();
275 + user.oidcSub = name;
276 + user.username = name;
277 + user.persist();
278 + return user;
279 + }
280 +
281 + private static byte[] png()
282 + {
283 + try
284 + {
285 + BufferedImage image = new BufferedImage(4, 4, BufferedImage.TYPE_INT_ARGB);
286 + ByteArrayOutputStream out = new ByteArrayOutputStream();
287 + ImageIO.write(image, "png", out);
288 + return out.toByteArray();
289 + }
290 + catch (IOException e)
291 + {
292 + throw new UncheckedIOException(e);
293 + }
294 + }
295 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog