✨ (repos): Add per-repository custom images
Changes
25 files changed, +864 -90
MODIFY
README.md
+2 -0
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}