gitshark

Clone repository

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

← Commits

✨ (account): Add user profile pictures stored on the filesystem

26e21bd90498cb76eb1e15b8b3e5c6918714f5a6 · Phillip Souza Furtner · 2026-07-09T08:23:07Z

Changes

23 files changed, +680 -20

MODIFY README.md +3 -0
diff --git a/README.md b/README.md
index 9fde82a..c7c1e5f 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 0ffa259..8363194 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index 7ba95c7..a41bf5d 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -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
diff --git a/docs/maintainers/avatars.md b/docs/maintainers/avatars.md
new file mode 100644
index 0000000..ace06f6
--- /dev/null
+++ b/docs/maintainers/avatars.md
@@ -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
diff --git a/docs/users/federation.md b/docs/users/federation.md
index 52aa729..0fe0619 100644
--- a/docs/users/federation.md
+++ b/docs/users/federation.md
@@ -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
diff --git a/docs/users/profile.md b/docs/users/profile.md
new file mode 100644
index 0000000..328b318
--- /dev/null
+++ b/docs/users/profile.md
@@ -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
diff --git a/src/main/java/de/workaround/account/AvatarResource.java b/src/main/java/de/workaround/account/AvatarResource.java
new file mode 100644
index 0000000..ee1f2ef
--- /dev/null
+++ b/src/main/java/de/workaround/account/AvatarResource.java
@@ -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
diff --git a/src/main/java/de/workaround/account/AvatarService.java b/src/main/java/de/workaround/account/AvatarService.java
new file mode 100644
index 0000000..9ea0945
--- /dev/null
+++ b/src/main/java/de/workaround/account/AvatarService.java
@@ -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
diff --git a/src/main/java/de/workaround/account/InvalidAvatarException.java b/src/main/java/de/workaround/account/InvalidAvatarException.java
new file mode 100644
index 0000000..f475bd2
--- /dev/null
+++ b/src/main/java/de/workaround/account/InvalidAvatarException.java
@@ -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
diff --git a/src/main/java/de/workaround/account/SettingsResource.java b/src/main/java/de/workaround/account/SettingsResource.java
index b2439bd..7cb3889 100644
--- a/src/main/java/de/workaround/account/SettingsResource.java
+++ b/src/main/java/de/workaround/account/SettingsResource.java
@@ -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
diff --git a/src/main/java/de/workaround/model/User.java b/src/main/java/de/workaround/model/User.java
index 6d8395e..ed01d6c 100644
--- a/src/main/java/de/workaround/model/User.java
+++ b/src/main/java/de/workaround/model/User.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 57c20a0..99eec3a 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 1f31ce2..8a90e33 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V10__user_avatar.sql b/src/main/resources/db/migration/V10__user_avatar.sql
new file mode 100644
index 0000000..6ee9c52
--- /dev/null
+++ b/src/main/resources/db/migration/V10__user_avatar.sql
@@ -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
diff --git a/src/main/resources/templates/HomeResource/dashboard.html b/src/main/resources/templates/HomeResource/dashboard.html
index 1fdedad..66598ed 100644
--- a/src/main/resources/templates/HomeResource/dashboard.html
+++ b/src/main/resources/templates/HomeResource/dashboard.html
@@ -11,7 +11,7 @@
11 11 <tr><th>Repository</th><th>Visibility</th><th></th></tr>
12 12 {#for repo in pinned}
13 13 <tr>
14 - <td><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
diff --git a/src/main/resources/templates/HomeResource/home.html b/src/main/resources/templates/HomeResource/home.html
index 7b7c8ec..b9caf53 100644
--- a/src/main/resources/templates/HomeResource/home.html
+++ b/src/main/resources/templates/HomeResource/home.html
@@ -22,7 +22,7 @@
22 22 <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
23 23 {#for repo in repositories}
24 24 <tr>
25 - <td><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
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index a52adb7..6f9b10d 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -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
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequest.html b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
index 58e7cbe..aa2046b 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
@@ -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> &rarr; <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
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index f4316ab..6e6e3cf 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -1,5 +1,5 @@
1 1 <aside class="repo-side">
2 - <div class="owner">{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
diff --git a/src/main/resources/templates/SettingsResource/profile.html b/src/main/resources/templates/SettingsResource/profile.html
index 50ec8a4..a78ced6 100644
--- a/src/main/resources/templates/SettingsResource/profile.html
+++ b/src/main/resources/templates/SettingsResource/profile.html
@@ -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
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index 20878f1..0577d08 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -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
diff --git a/src/main/resources/templates/tags/avatar.html b/src/main/resources/templates/tags/avatar.html
new file mode 100644
index 0000000..1d3e187
--- /dev/null
+++ b/src/main/resources/templates/tags/avatar.html
@@ -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
diff --git a/src/test/java/de/workaround/account/SettingsAvatarTest.java b/src/test/java/de/workaround/account/SettingsAvatarTest.java
new file mode 100644
index 0000000..94fc323
--- /dev/null
+++ b/src/test/java/de/workaround/account/SettingsAvatarTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog