✨ (account): Add user profile pictures stored on the filesystem
Changes
23 files changed, +680 -20
MODIFY
README.md
+3 -0
@@ -24,6 +24,7 @@
24
24
- The owner can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25
25
- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author or by the repository owner. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
26
26
- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
27
+- Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
27
28
- Single access policy on all paths: owner read/write, public world-readable, private owner-only
28
29
- **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
29
30
git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
@@ -130,6 +131,7 @@
130
131
| Property / Env var | Default | Purpose |
131
132
|---|---|---|
132
133
| `GITSHARK_STORAGE_ROOT` | `data/repositories` | Root directory for bare repositories (persistent volume) |
134
+| `GITSHARK_AVATAR_ROOT` | `data/avatars` | Root directory for uploaded profile pictures (persistent volume) |
133
135
| `GITSHARK_SSH_PORT` | `2222` | Embedded SSH server port |
134
136
| `GITSHARK_SSH_HOST_KEY` | `data/ssh/host-key` | Persisted SSH host key file |
135
137
| `QUARKUS_DATASOURCE_JDBC_URL` / `_USERNAME` / `_PASSWORD` | — (Dev Services in dev/test) | PostgreSQL connection |
@@ -185,6 +187,7 @@
185
187
|---|---|
186
188
| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
187
189
| Filesystem (`GITSHARK_STORAGE_ROOT`) | Bare Git repositories |
190
+| Filesystem (`GITSHARK_AVATAR_ROOT`) | Uploaded profile pictures, one file per user (UUID-named) |
188
191
| Filesystem (`GITSHARK_SSH_HOST_KEY`) | SSH host key |
189
192
190
193
## CI
MODIFY
docs/README.md
+4 -0
@@ -8,6 +8,8 @@
8
8
9
9
### For users
10
10
11
+- **[Profile settings](users/profile.md)** — change your username and display
12
+ name, upload or remove a profile picture.
11
13
- **[Federation](users/federation.md)** — follow public repositories on other
12
14
instances, the push feed, your federated identity.
13
15
@@ -21,6 +23,8 @@
21
23
22
24
### For maintainers
23
25
26
+- **[Avatars](maintainers/avatars.md)** — profile-picture storage, validation,
27
+ and rendering, plus what's covered and what's out of scope.
24
28
- **[ForgeFed architecture](maintainers/forgefed.md)** — how federation is
25
29
implemented, the decisions behind it, what works and what is still missing.
26
30
MODIFY
docs/admins/getting-started.md
+17 -9
@@ -165,12 +165,14 @@
165
165
QUARKUS_OIDC_TOKEN_STATE_ENCRYPTION_SECRET: ${OIDC_TOKEN_STATE_SECRET}
166
166
# --- Storage & SSH ---
167
167
GITSHARK_STORAGE_ROOT: /data/repositories
168
+ GITSHARK_AVATAR_ROOT: /data/avatars
168
169
GITSHARK_SSH_HOST_KEY: /data/ssh/host-key
169
170
GITSHARK_SSH_PORT: "2222"
170
171
ports:
171
172
- "2222:2222" # SSH git access, published directly
172
173
volumes:
173
174
- repos:/data/repositories # bare git repositories
175
+ - avatars:/data/avatars # user profile pictures
174
176
- ssh:/data/ssh # persistent SSH host key
175
177
healthcheck:
176
178
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080 && echo ok >&3"]
@@ -182,15 +184,16 @@
182
184
volumes:
183
185
db-data:
184
186
repos:
187
+ avatars:
185
188
ssh:
186
189
```
187
190
188
191
Notes:
189
192
190
-- **Separate volumes for `/data/repositories` and `/data/ssh`** so Docker creates both
191
- mount points with the right ownership — no init container or `mkdir` needed. The SSH
192
- host key is generated on first boot and persists across restarts (so client
193
- `known_hosts` entries stay valid).
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).
194
197
- **HTTP port 8080 is not published** — it's reached through the reverse proxy on the
195
198
Compose network (Step 6). Only SSH (2222) is exposed directly.
196
199
- **Single app replica.** git-shark keeps git state on a `ReadWriteOnce`-style filesystem
@@ -289,6 +292,7 @@
289
292
| `QUARKUS_OIDC_AUTHENTICATION_STATE_SECRET` | ✅ | — | Encrypts PKCE state cookie (≥ 32 chars) |
290
293
| `QUARKUS_OIDC_TOKEN_STATE_ENCRYPTION_SECRET` | ✅ | — | Encrypts session/token cookie (≥ 32 chars) |
291
294
| `GITSHARK_STORAGE_ROOT` | — | `data/repositories` | On-disk bare-repo root |
295
+| `GITSHARK_AVATAR_ROOT` | — | `data/avatars` | On-disk profile-picture (avatar) storage root |
292
296
| `GITSHARK_SSH_HOST_KEY` | — | `data/ssh/host-key` | Persistent SSH host key path |
293
297
| `GITSHARK_SSH_PORT` | — | `2222` | Embedded SSH server port |
294
298
| `GITSHARK_FEDERATION_ENABLED` | — | `false` | Turn on ForgeFed/ActivityPub |
@@ -317,18 +321,22 @@
317
321
318
322
## Operations
319
323
320
-**Backups** — two things hold state:
321
-- The `db-data` volume (metadata: users, repo records, issues, MRs, comments).
324
+**Backups** — three things hold state:
325
+- The `db-data` volume (metadata: users, repo records, issues, MRs, comments;
326
+ also each avatar's content type and update timestamp — the bytes are not
327
+ here).
322
328
- The `repos` volume (the actual git objects).
329
+- The `avatars` volume (uploaded profile-picture bytes, one file per user).
323
330
324
-Back both up together and consistently. A logical DB dump:
331
+Back all three up together and consistently. A logical DB dump:
325
332
326
333
```bash
327
334
docker compose exec db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > gitshark-db.sql
328
335
```
329
336
330
-Snapshot the `repos` volume with your host's volume/snapshot tooling while the app is
331
-quiesced (or accept crash-consistent snapshots — bare repos tolerate them well).
337
+Snapshot the `repos` and `avatars` volumes with your host's volume/snapshot tooling
338
+while the app is quiesced (or accept crash-consistent snapshots — bare repos and
339
+avatar files both tolerate them well).
332
340
333
341
**Upgrades** — pull the new image and recreate the app:
334
342
ADD
docs/maintainers/avatars.md
+101 -0
@@ -0,0 +1,101 @@
1
+# Avatars: implementation notes
2
+
3
+Maintainer-facing notes on the profile-picture (avatar) feature: where the
4
+bytes live, how uploads are validated, the single render point, and what's
5
+deliberately out of scope. For the user-facing behavior see the
6
+[user guide](../users/profile.md); for deployment/config see
7
+[Getting Started](../admins/getting-started.md).
8
+
9
+---
10
+
11
+## Storage: filesystem, not the database
12
+
13
+Avatar bytes live on the local filesystem under `gitshark.storage.avatars`
14
+(env `GITSHARK_AVATAR_ROOT`, default `data/avatars`), one file per user named
15
+by the user's UUID (`AvatarService.avatarPath`). This mirrors the existing
16
+convention for bare git repositories under `gitshark.storage.root` — large
17
+binary blobs go on disk, not in PostgreSQL.
18
+
19
+The `users` table only stores metadata, added in
20
+`db/migration/V10__user_avatar.sql`:
21
+
22
+- `avatar_content_type` (nullable) — the validated MIME type, needed to serve
23
+ the file with the right `Content-Type`. `NULL` means the user has no
24
+ avatar (`User.hasAvatar()`).
25
+- `avatar_updated_at` (nullable) — last upload timestamp, used only to
26
+ cache-bust the `<img>` URL (`?v=<epoch millis>`) so browsers pick up a
27
+ replaced picture immediately.
28
+
29
+`AvatarService` is the only component that touches the filesystem; upload
30
+(`store`) and removal (`remove`) are `@Transactional` so the DB row and the
31
+file move together as far as the request is concerned (a crash between the
32
+file write and the commit can still leave them inconsistent — no two-phase
33
+commit is attempted, consistent with how bare-repo writes are handled).
34
+
35
+## Validation
36
+
37
+All validation happens in `AvatarService.validate`, called from
38
+`SettingsResource.uploadAvatar`:
39
+
40
+- **Size cap**: 2 MB (`AvatarService.MAX_BYTES`).
41
+- **Type allowlist**: PNG, JPEG, GIF, WebP (`image/png`, `image/jpeg`,
42
+ `image/gif`, `image/webp`).
43
+- **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`
45
+ signature). This rejects a file that lies about its type — declaring
46
+ `image/png` but uploading something else fails validation rather than being
47
+ stored and served back with a wrong/dangerous content type.
48
+
49
+Validation failures throw `InvalidAvatarException`, caught in
50
+`SettingsResource` and re-rendered as a form error on `/settings/profile`.
51
+
52
+## Rendering: one Qute tag, one render point
53
+
54
+`templates/tags/avatar.html` is the single place that decides how to render a
55
+user:
56
+
57
+```html
58
+{#if user.hasAvatar}<img class="avatar" src="/users/{user.username}/avatar?v={user.avatarUpdatedAt.toEpochMilli}" alt="{user.username}">{#else}<span class="av av-fallback">{user.username.charAt(0)}</span>{/if}
59
+```
60
+
61
+Every template that shows a local user invokes it as `{#avatar user=... /}`
62
+(header nav, repo lists, repo sidebar, issue/MR/comment authors — see
63
+`templates/layout.html`, `templates/HomeResource/*.html`,
64
+`templates/RepositoryResource/sidebar.html`,
65
+`templates/IssueResource/issue.html`,
66
+`templates/MergeRequestResource/mergeRequest.html`). Keeping the fallback
67
+logic in one tag means there's no place in the UI that can show a stale or
68
+inconsistent avatar state — a page either has the tag or it doesn't render a
69
+user avatar at all.
70
+
71
+## Serving endpoint
72
+
73
+`GET /users/{username}/avatar` (`AvatarResource`) is deliberately **public** —
74
+no authentication — unlike the upload/delete endpoints under `/settings/*`.
75
+This is what lets avatars embed on public repository pages for anonymous
76
+visitors. It returns `404` when the user has no avatar (`hasAvatar()` false)
77
+or the file is missing from disk, and otherwise streams the bytes with the
78
+stored `avatar_content_type`.
79
+
80
+Upload and delete are authenticated, under `/settings/profile/avatar`
81
+(`POST`, multipart, field `avatar`) and `/settings/profile/avatar/delete`
82
+(`POST`) respectively, both in `SettingsResource`.
83
+
84
+## What's covered / not covered
85
+
86
+**Covered** — anywhere a local `User` is rendered: header nav, home/explore
87
+repository lists, repository sidebar owner, issue authors, merge-request
88
+authors, and merge-request review-comment authors.
89
+
90
+**Not covered, on purpose:**
91
+
92
+- **Git commit authors.** The repository overview's "latest commit" and the
93
+ commit log render an initials badge built directly from the commit's git
94
+ identity string (`RepositoryResource/overview.html`), not from a `User`
95
+ lookup. A commit's author name/email is free-form data from the git object,
96
+ not necessarily tied to (or even matching) a local account, so there's no
97
+ reliable way to resolve it to an uploaded avatar without guessing.
98
+- **Remote federation actors.** Entries on the Following page represent
99
+ remote ForgeFed actors (`RemoteActor`), which are handles/URLs from another
100
+ instance, not local `User` rows — the avatar tag and storage only apply to
101
+ accounts on this instance.
MODIFY
docs/users/federation.md
+4 -0
@@ -94,3 +94,7 @@
94
94
95
95
Signing keys for your actors are generated and managed by the server — there is
96
96
nothing for you to configure.
97
+
98
+Remote federated users and repositories are identified by their remote handle
99
+only — unlike local accounts, they don't carry a profile picture (see
100
+[Profile settings](profile.md)).
ADD
docs/users/profile.md
+54 -0
@@ -0,0 +1,54 @@
1
+# Profile settings: user guide
2
+
3
+Your profile settings live at `/settings/profile` (link: **Profile** in the
4
+header navigation). From there you can change your handle and display name,
5
+and upload a profile picture.
6
+
7
+---
8
+
9
+## Username and display name
10
+
11
+- **Username** — your URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`). It's what
12
+ appears in every repo, SSH, and federation URL, and it's chosen once during
13
+ [onboarding](../admins/getting-started.md#step-7--bring-it-up); you can
14
+ change it later here.
15
+- **Display name** — a freeform name shown alongside your username in the UI.
16
+ Pre-filled from your OIDC `name` claim on first login, editable anytime.
17
+
18
+---
19
+
20
+## Profile picture
21
+
22
+The **Profile picture** section lets you upload an avatar:
23
+
24
+- **Allowed formats**: PNG, JPEG, GIF, WebP.
25
+- **Max size**: 2 MB.
26
+- Pick a file and press **Upload**. Uploading again replaces the existing
27
+ picture.
28
+- If you already have a picture, a **Remove picture** button appears —
29
+ removing it falls back to an initials badge (the first letter of your
30
+ username) everywhere your avatar was shown.
31
+
32
+The server checks both the declared file type and the file's actual content
33
+before accepting it, so renaming a file to fake its type doesn't work — you'll
34
+get an error and nothing is saved.
35
+
36
+### Where your avatar shows up
37
+
38
+Once uploaded, your picture appears everywhere your account is rendered as a
39
+user:
40
+
41
+- The header navigation, next to the **Profile** link.
42
+- Repository lists on the home page and `/explore`.
43
+- The owner in a repository's left sidebar.
44
+- As the author of issues, merge requests, and merge-request review comments.
45
+
46
+**Not covered:** git commit authors (shown on a repository's overview page and
47
+in the commit log) come from the commit's git identity, not your account, so
48
+they always show an initials badge regardless of your uploaded picture.
49
+Remote federated users (shown on the [Following](federation.md) page) are also
50
+not covered — they're identified by their remote handle, not a local account.
51
+
52
+Your uploaded picture is served publicly at `/users/<your-username>/avatar` so
53
+it can be embedded on public pages without requiring the viewer to be logged
54
+in.
ADD
src/main/java/de/workaround/account/AvatarResource.java
+35 -0
@@ -0,0 +1,35 @@
1
+package de.workaround.account;
2
+
3
+import de.workaround.model.User;
4
+import jakarta.inject.Inject;
5
+import jakarta.ws.rs.GET;
6
+import jakarta.ws.rs.NotFoundException;
7
+import jakarta.ws.rs.Path;
8
+import jakarta.ws.rs.PathParam;
9
+import jakarta.ws.rs.core.Response;
10
+
11
+/**
12
+ * Serves user profile pictures. Public (unlike upload, which lives under /settings) so avatars can
13
+ * embed on any page, including public repositories. Returns 404 when the user has no avatar.
14
+ */
15
+@Path("/users")
16
+public class AvatarResource
17
+{
18
+ @Inject
19
+ User.Repo users;
20
+
21
+ @Inject
22
+ AvatarService avatars;
23
+
24
+ @GET
25
+ @Path("/{username}/avatar")
26
+ public Response avatar(@PathParam("username") String username)
27
+ {
28
+ User user = users.findByUsername(username)
29
+ .filter(User::hasAvatar)
30
+ .orElseThrow(NotFoundException::new);
31
+ byte[] bytes = avatars.read(user).orElseThrow(NotFoundException::new);
32
+ return Response.ok(bytes).type(user.avatarContentType).build();
33
+ }
34
+
35
+}
ADD
src/main/java/de/workaround/account/AvatarService.java
+145 -0
@@ -0,0 +1,145 @@
1
+package de.workaround.account;
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.Map;
9
+import java.util.Optional;
10
+
11
+import de.workaround.model.User;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+import org.eclipse.microprofile.config.inject.ConfigProperty;
16
+
17
+/**
18
+ * Stores user profile pictures on the local filesystem, keyed by user id. Only the content type
19
+ * and update timestamp are persisted on the {@link User} row (see AvatarService callers); the bytes
20
+ * live under {@code gitshark.storage.avatars}, mirroring how bare git repos live under
21
+ * {@code gitshark.storage.root} rather than in the database.
22
+ */
23
+@ApplicationScoped
24
+public class AvatarService
25
+{
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
+ @Inject
37
+ User.Repo users;
38
+
39
+ @ConfigProperty(name = "gitshark.storage.avatars")
40
+ Path avatarRoot;
41
+
42
+ @Transactional
43
+ public void store(User user, byte[] bytes, String declaredContentType)
44
+ {
45
+ String contentType = validate(bytes, declaredContentType);
46
+
47
+ Path path = avatarPath(user);
48
+ try
49
+ {
50
+ Files.createDirectories(avatarRoot);
51
+ Files.write(path, bytes);
52
+ }
53
+ catch (IOException e)
54
+ {
55
+ throw new UncheckedIOException("Failed to write avatar to " + path, e);
56
+ }
57
+
58
+ User managed = users.findById(user.id);
59
+ managed.avatarContentType = contentType;
60
+ managed.avatarUpdatedAt = Instant.now();
61
+ user.avatarContentType = managed.avatarContentType;
62
+ user.avatarUpdatedAt = managed.avatarUpdatedAt;
63
+ }
64
+
65
+ @Transactional
66
+ public void remove(User user)
67
+ {
68
+ try
69
+ {
70
+ Files.deleteIfExists(avatarPath(user));
71
+ }
72
+ catch (IOException e)
73
+ {
74
+ throw new UncheckedIOException("Failed to delete avatar for " + user.id, e);
75
+ }
76
+
77
+ User managed = users.findById(user.id);
78
+ managed.avatarContentType = null;
79
+ managed.avatarUpdatedAt = null;
80
+ user.avatarContentType = null;
81
+ user.avatarUpdatedAt = null;
82
+ }
83
+
84
+ public Optional<byte[]> read(User user)
85
+ {
86
+ Path path = avatarPath(user);
87
+ if (!Files.exists(path))
88
+ {
89
+ return Optional.empty();
90
+ }
91
+ try
92
+ {
93
+ return Optional.of(Files.readAllBytes(path));
94
+ }
95
+ catch (IOException e)
96
+ {
97
+ throw new UncheckedIOException("Failed to read avatar for " + user.id, e);
98
+ }
99
+ }
100
+
101
+ private Path avatarPath(User user)
102
+ {
103
+ return avatarRoot.resolve(user.id.toString());
104
+ }
105
+
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
+}
ADD
src/main/java/de/workaround/account/InvalidAvatarException.java
+10 -0
@@ -0,0 +1,10 @@
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
+}
MODIFY
src/main/java/de/workaround/account/SettingsResource.java
+51 -3
@@ -1,9 +1,15 @@
1
1
package de.workaround.account;
2
2
3
+import java.io.IOException;
4
+import java.io.UncheckedIOException;
3
5
import java.net.URI;
6
+import java.nio.file.Files;
4
7
import java.util.List;
5
8
import java.util.UUID;
6
9
10
+import org.jboss.resteasy.reactive.RestForm;
11
+import org.jboss.resteasy.reactive.multipart.FileUpload;
12
+
7
13
import de.workaround.http.AccessTokenService;
8
14
import de.workaround.model.AccessToken;
9
15
import de.workaround.model.SshKey;
@@ -33,7 +39,8 @@
33
39
34
40
static native TemplateInstance tokenCreated(String plaintext);
35
41
36
- static native TemplateInstance profile(String username, String displayName, String error);
42
+ static native TemplateInstance profile(String username, String displayName, boolean hasAvatar,
43
+ String error);
37
44
}
38
45
39
46
@Inject
@@ -48,12 +55,15 @@
48
55
@Inject
49
56
UsernameService usernames;
50
57
58
+ @Inject
59
+ AvatarService avatars;
60
+
51
61
@GET
52
62
@Path("/profile")
53
63
public TemplateInstance profile()
54
64
{
55
65
de.workaround.model.User user = currentUser.require();
56
- return Templates.profile(user.username, user.displayName, null);
66
+ return Templates.profile(user.username, user.displayName, user.hasAvatar(), null);
57
67
}
58
68
59
69
@POST
@@ -71,11 +81,49 @@
71
81
catch (InvalidUsernameException | UsernameTakenException e)
72
82
{
73
83
return Response.status(Response.Status.BAD_REQUEST)
74
- .entity(Templates.profile(username, displayName, e.getMessage()))
84
+ .entity(Templates.profile(username, displayName, user.hasAvatar(), e.getMessage()))
75
85
.build();
76
86
}
77
87
}
78
88
89
+ @POST
90
+ @Path("/profile/avatar")
91
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
92
+ public Response uploadAvatar(@RestForm("avatar") FileUpload avatar)
93
+ {
94
+ de.workaround.model.User user = currentUser.require();
95
+ if (avatar == null)
96
+ {
97
+ return Response.status(Response.Status.BAD_REQUEST)
98
+ .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
99
+ "No image was uploaded."))
100
+ .build();
101
+ }
102
+ try
103
+ {
104
+ avatars.store(user, Files.readAllBytes(avatar.uploadedFile()), avatar.contentType());
105
+ return Response.seeOther(URI.create("/settings/profile")).build();
106
+ }
107
+ catch (InvalidAvatarException e)
108
+ {
109
+ return Response.status(Response.Status.BAD_REQUEST)
110
+ .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(), e.getMessage()))
111
+ .build();
112
+ }
113
+ catch (IOException e)
114
+ {
115
+ throw new UncheckedIOException("Failed to read uploaded avatar", e);
116
+ }
117
+ }
118
+
119
+ @POST
120
+ @Path("/profile/avatar/delete")
121
+ public Response deleteAvatar()
122
+ {
123
+ avatars.remove(currentUser.require());
124
+ return Response.seeOther(URI.create("/settings/profile")).build();
125
+ }
126
+
79
127
@GET
80
128
@Path("/keys")
81
129
public TemplateInstance keys()
MODIFY
src/main/java/de/workaround/model/User.java
+11 -0
@@ -36,6 +36,17 @@
36
36
37
37
public Instant createdAt = Instant.now();
38
38
39
+ // Profile picture: the bytes live on the filesystem (see AvatarService); only the content type
40
+ // and last-update timestamp are stored here. Null content type means the user has no avatar.
41
+ public String avatarContentType;
42
+
43
+ public Instant avatarUpdatedAt;
44
+
45
+ public boolean hasAvatar()
46
+ {
47
+ return avatarContentType != null;
48
+ }
49
+
39
50
public interface Repo extends PanacheRepository.Managed<User, UUID>
40
51
{
41
52
@Find
MODIFY
src/main/resources/META-INF/resources/shark.css
+35 -0
@@ -1555,6 +1555,41 @@
1555
1555
white-space: nowrap;
1556
1556
}
1557
1557
1558
+/* Inline user avatars (tags/avatar.html): an uploaded image or an initials fallback. */
1559
+.avatar {
1560
+ width: 20px;
1561
+ height: 20px;
1562
+ border-radius: 6px;
1563
+ object-fit: cover;
1564
+ vertical-align: middle;
1565
+ flex: none;
1566
+}
1567
+
1568
+.av-fallback {
1569
+ display: inline-flex;
1570
+ width: 20px;
1571
+ height: 20px;
1572
+ border-radius: 6px;
1573
+ background: var(--accent);
1574
+ color: #fff;
1575
+ align-items: center;
1576
+ justify-content: center;
1577
+ font: 700 11px/1 var(--font);
1578
+ text-transform: uppercase;
1579
+ vertical-align: middle;
1580
+ flex: none;
1581
+}
1582
+
1583
+/* Settings profile: larger current-avatar preview. */
1584
+.avatar-preview {
1585
+ width: 96px;
1586
+ height: 96px;
1587
+ border-radius: 12px;
1588
+ object-fit: cover;
1589
+ display: block;
1590
+ margin-bottom: 8px;
1591
+}
1592
+
1558
1593
.files {
1559
1594
display: flex;
1560
1595
flex-direction: column;
MODIFY
src/main/resources/application.properties
+4 -0
@@ -65,6 +65,10 @@
65
65
gitshark.storage.root=${GITSHARK_STORAGE_ROOT:data/repositories}
66
66
%test.gitshark.storage.root=target/test-repositories
67
67
68
+# User profile pictures (avatars): bytes stored on the filesystem, keyed by user id
69
+gitshark.storage.avatars=${GITSHARK_AVATAR_ROOT:data/avatars}
70
+%test.gitshark.storage.avatars=target/test-avatars
71
+
68
72
# Embedded SSH server
69
73
gitshark.ssh.port=${GITSHARK_SSH_PORT:2222}
70
74
gitshark.ssh.host-key-path=${GITSHARK_SSH_HOST_KEY:data/ssh/host-key}
ADD
src/main/resources/db/migration/V10__user_avatar.sql
+6 -0
@@ -0,0 +1,6 @@
1
+-- Profile pictures: bytes live on the filesystem (gitshark.storage.avatars), the DB keeps only
2
+-- the content type (needed to serve with the right MIME type) and an update timestamp (used to
3
+-- cache-bust the <img> URL). NULL avatar_content_type means the user has no avatar.
4
+ALTER TABLE users
5
+ ADD COLUMN avatar_content_type varchar(64),
6
+ ADD COLUMN avatar_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><a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
14
+ <td>{#avatar user=repo.owner /} <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><a class="mono" href="/repos/{row.repo.owner.username}/{row.repo.name}">{row.repo.owner.username}/{row.repo.name}</a></td>
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>
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><a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
25
+ <td>{#avatar user=repo.owner /} <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>
MODIFY
src/main/resources/templates/IssueResource/issue.html
+1 -1
@@ -7,7 +7,7 @@
7
7
<h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
8
8
<p>
9
9
<span class="badge status-{issue.status}">{issue.status.label}</span>
10
- <span class="muted">opened by {issue.author.username}</span>
10
+ <span class="muted">opened by {#avatar user=issue.author /} {issue.author.username}</span>
11
11
</p>
12
12
{#if issue.description}
13
13
<pre class="issue-desc">{issue.description}</pre>
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequest.html
+2 -2
@@ -8,7 +8,7 @@
8
8
<p>
9
9
<span class="badge status-{mr.status}">{mr.status.label}</span>
10
10
<span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
11
- <span class="muted">opened by {mr.author.username}</span>
11
+ <span class="muted">opened by {#avatar user=mr.author /} {mr.author.username}</span>
12
12
</p>
13
13
{#if mr.description}
14
14
<pre class="issue-desc">{mr.description}</pre>
@@ -74,7 +74,7 @@
74
74
<div class="dl-comment-row">
75
75
<div class="comment">
76
76
<div class="comment-head">
77
- <span class="who">{c.author.username}</span>
77
+ <span class="who">{#avatar user=c.author /} {c.author.username}</span>
78
78
</div>
79
79
<div class="comment-body">{c.body}</div>
80
80
</div>
MODIFY
src/main/resources/templates/RepositoryResource/sidebar.html
+1 -1
@@ -1,5 +1,5 @@
1
1
<aside class="repo-side">
2
- <div class="owner">{nav.repo.owner.username} /</div>
2
+ <div class="owner">{#avatar user=nav.repo.owner /} {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}
MODIFY
src/main/resources/templates/SettingsResource/profile.html
+16 -0
@@ -11,4 +11,20 @@
11
11
<p><label>Display name <input name="displayName" value="{displayName ?: ''}"></label></p>
12
12
<button class="btn btn-primary">Save</button>
13
13
</form>
14
+
15
+<h2>Profile picture</h2>
16
+{#if hasAvatar}
17
+<img class="avatar-preview" src="/users/{username}/avatar" alt="Current profile picture">
18
+{/if}
19
+<form method="post" action="/settings/profile/avatar" enctype="multipart/form-data">
20
+ <p><input type="file" name="avatar" 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 hasAvatar}
25
+<form method="post" action="/settings/profile/avatar/delete"
26
+ onsubmit="return confirm('Remove your profile picture?')">
27
+ <button class="btn btn-danger">Remove picture</button>
28
+</form>
29
+{/if}
14
30
{/include}
MODIFY
src/main/resources/templates/layout.html
+1 -1
@@ -15,7 +15,7 @@
15
15
{#insert nav}
16
16
{#if cdi:currentUser.loggedIn}
17
17
<a href="/following">Following</a>
18
- <a href="/settings/profile">Profile</a>
18
+ <a href="/settings/profile">{#avatar user=cdi:currentUser.get /} Profile</a>
19
19
<a href="/settings/keys">SSH keys</a>
20
20
<a href="/settings/tokens">Access tokens</a>
21
21
<form class="logout" method="post" action="/logout"><button type="submit">Logout</button></form>
ADD
src/main/resources/templates/tags/avatar.html
+1 -0
@@ -0,0 +1 @@
1
+{#if user.hasAvatar}<img class="avatar" src="/users/{user.username}/avatar?v={user.avatarUpdatedAt.toEpochMilli}" alt="{user.username}">{#else}<span class="av-fallback">{user.username.charAt(0)}</span>{/if}
ADD
src/test/java/de/workaround/account/SettingsAvatarTest.java
+175 -0
@@ -0,0 +1,175 @@
1
+package de.workaround.account;
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
+
10
+import javax.imageio.ImageIO;
11
+
12
+import org.junit.jupiter.api.Test;
13
+
14
+import de.workaround.model.User;
15
+import io.quarkus.test.junit.QuarkusTest;
16
+import io.quarkus.test.security.TestSecurity;
17
+import jakarta.inject.Inject;
18
+import jakarta.persistence.EntityManager;
19
+import jakarta.transaction.Transactional;
20
+import org.eclipse.microprofile.config.inject.ConfigProperty;
21
+
22
+import static io.restassured.RestAssured.given;
23
+import static org.hamcrest.Matchers.anyOf;
24
+import static org.hamcrest.Matchers.is;
25
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
26
+import static org.junit.jupiter.api.Assertions.assertEquals;
27
+import static org.junit.jupiter.api.Assertions.assertFalse;
28
+import static org.junit.jupiter.api.Assertions.assertNotNull;
29
+import static org.junit.jupiter.api.Assertions.assertNull;
30
+import static org.junit.jupiter.api.Assertions.assertTrue;
31
+
32
+@QuarkusTest
33
+class SettingsAvatarTest
34
+{
35
+ @Inject
36
+ User.Repo users;
37
+
38
+ @Inject
39
+ EntityManager em;
40
+
41
+ @ConfigProperty(name = "gitshark.storage.avatars")
42
+ Path avatarRoot;
43
+
44
+ @Test
45
+ @TestSecurity(user = "avatar-up")
46
+ void uploadsAndServesAvatar()
47
+ {
48
+ byte[] png = png();
49
+
50
+ given().redirects().follow(false)
51
+ .multiPart("avatar", "me.png", png, "image/png")
52
+ .when().post("/settings/profile/avatar")
53
+ .then().statusCode(anyOf(is(302), is(303)));
54
+
55
+ User user = bySub("avatar-up");
56
+ assertEquals("image/png", user.avatarContentType);
57
+ assertNotNull(user.avatarUpdatedAt);
58
+ assertTrue(Files.exists(avatarRoot.resolve(user.id.toString())), "avatar file written to disk");
59
+
60
+ byte[] served = given()
61
+ .when().get("/users/avatar-up/avatar")
62
+ .then().statusCode(200)
63
+ .contentType("image/png")
64
+ .extract().asByteArray();
65
+ assertArrayEquals(png, served);
66
+ }
67
+
68
+ @Test
69
+ @TestSecurity(user = "avatar-big")
70
+ void rejectsOversized()
71
+ {
72
+ byte[] big = new byte[2 * 1024 * 1024 + 1];
73
+ byte[] magic = { (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A };
74
+ System.arraycopy(magic, 0, big, 0, magic.length);
75
+
76
+ given().redirects().follow(false)
77
+ .multiPart("avatar", "big.png", big, "image/png")
78
+ .when().post("/settings/profile/avatar")
79
+ .then().statusCode(400);
80
+
81
+ assertNull(bySub("avatar-big").avatarContentType, "oversized upload not persisted");
82
+ }
83
+
84
+ @Test
85
+ @TestSecurity(user = "avatar-bad")
86
+ void rejectsDisallowedType()
87
+ {
88
+ given().redirects().follow(false)
89
+ .multiPart("avatar", "note.txt", "not an image".getBytes(), "text/plain")
90
+ .when().post("/settings/profile/avatar")
91
+ .then().statusCode(400);
92
+
93
+ assertNull(bySub("avatar-bad").avatarContentType, "disallowed type not persisted");
94
+ }
95
+
96
+ @Test
97
+ @TestSecurity(user = "avatar-spoof")
98
+ void rejectsContentTypeMagicMismatch()
99
+ {
100
+ given().redirects().follow(false)
101
+ .multiPart("avatar", "fake.png", "not really a png".getBytes(), "image/png")
102
+ .when().post("/settings/profile/avatar")
103
+ .then().statusCode(400);
104
+
105
+ assertNull(bySub("avatar-spoof").avatarContentType, "spoofed magic bytes not persisted");
106
+ }
107
+
108
+ @Test
109
+ @TestSecurity(user = "avatar-del")
110
+ void removesAvatar()
111
+ {
112
+ given().redirects().follow(false)
113
+ .multiPart("avatar", "me.png", png(), "image/png")
114
+ .when().post("/settings/profile/avatar")
115
+ .then().statusCode(anyOf(is(302), is(303)));
116
+
117
+ User uploaded = bySub("avatar-del");
118
+ assertNotNull(uploaded.avatarContentType);
119
+
120
+ given().redirects().follow(false)
121
+ .when().post("/settings/profile/avatar/delete")
122
+ .then().statusCode(anyOf(is(302), is(303)));
123
+
124
+ User cleared = bySub("avatar-del");
125
+ assertNull(cleared.avatarContentType);
126
+ assertNull(cleared.avatarUpdatedAt);
127
+ assertFalse(Files.exists(avatarRoot.resolve(cleared.id.toString())), "avatar file deleted from disk");
128
+
129
+ given().when().get("/users/avatar-del/avatar").then().statusCode(404);
130
+ }
131
+
132
+ @Test
133
+ void missingAvatarIs404()
134
+ {
135
+ seedWithHandle("avatar-none-sub", "avatar-none");
136
+
137
+ given().when().get("/users/avatar-none/avatar").then().statusCode(404);
138
+ }
139
+
140
+ // Clear the persistence context first: each HTTP call commits in its own transaction, so a
141
+ // re-read of the same user within one test method must not return the stale L1-cached instance.
142
+ private User bySub(String sub)
143
+ {
144
+ em.clear();
145
+ return users.findByOidcSub(sub);
146
+ }
147
+
148
+ @Transactional
149
+ void seedWithHandle(String sub, String handle)
150
+ {
151
+ if (users.findByOidcSubOptional(sub).isPresent())
152
+ {
153
+ return;
154
+ }
155
+ User user = new User();
156
+ user.oidcSub = sub;
157
+ user.username = handle;
158
+ user.persist();
159
+ }
160
+
161
+ private static byte[] png()
162
+ {
163
+ try
164
+ {
165
+ BufferedImage image = new BufferedImage(4, 4, BufferedImage.TYPE_INT_ARGB);
166
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
167
+ ImageIO.write(image, "png", out);
168
+ return out.toByteArray();
169
+ }
170
+ catch (IOException e)
171
+ {
172
+ throw new UncheckedIOException(e);
173
+ }
174
+ }
175
+}