gitshark

Clone repository

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

← Commits

⚡ (avatars): Cache avatar and repo image responses immutably

ffe007a61f276cbc4edbdcebcfcef74759177771 · Miggi · 2026-07-09T18:49:23Z

Changes

8 files changed, +102 -10

MODIFY docs/maintainers/avatars.md +6 -1
diff --git a/docs/maintainers/avatars.md b/docs/maintainers/avatars.md
index 560ccfe..c8629b3 100644
--- a/docs/maintainers/avatars.md
+++ b/docs/maintainers/avatars.md
@@ -77,7 +77,12 @@
77 77 This is what lets avatars embed on public repository pages for anonymous
78 78 visitors. It returns `404` when the user has no avatar (`hasAvatar()` false)
79 79 or the file is missing from disk, and otherwise streams the bytes with the
80 -stored `avatar_content_type`.
80 +stored `avatar_content_type` and
81 +`Cache-Control: public, max-age=31536000, immutable`. Immutable caching is
82 +safe because every rendered avatar URL carries the `?v=<epoch millis>`
83 +cache-buster — replacing the picture changes the URL, so browsers never
84 +serve a stale cached response for the new URL. This includes the settings
85 +preview on `/settings/profile`, which is versioned like the avatar tag.
81 86
82 87 Upload and delete are authenticated, under `/settings/profile/avatar`
83 88 (`POST`, multipart, field `avatar`) and `/settings/profile/avatar/delete`
MODIFY docs/maintainers/repo-images.md +6 -1
diff --git a/docs/maintainers/repo-images.md b/docs/maintainers/repo-images.md
index 280fb6d..a0a7c62 100644
--- a/docs/maintainers/repo-images.md
+++ b/docs/maintainers/repo-images.md
@@ -64,7 +64,12 @@
64 64 repository's image is `404` for anyone who can't read the repo — the image must
65 65 not leak repository existence or content. It returns `404` when the repo has no
66 66 image (`hasImage()` false) or the file is missing, otherwise streams the bytes
67 -with the stored `image_content_type`.
67 +with the stored `image_content_type` and a `Cache-Control` header of
68 +`max-age=31536000, immutable` — scoped `public` for public repositories but
69 +`private` for private ones, so a shared cache (reverse proxy, CDN) never
70 +stores a private repository's image and serves it to someone the visibility
71 +guard would have rejected. Immutable caching is safe because rendered image
72 +URLs carry the `?v=<epoch millis>` cache-buster.
68 73
69 74 Upload and delete are owner-only, guarded by `RepositoryResource.requireOwner`
70 75 (which returns `404` — not `403` — to non-owners, consistent with how private
MODIFY src/main/java/de/workaround/account/AvatarResource.java +6 -1
diff --git a/src/main/java/de/workaround/account/AvatarResource.java b/src/main/java/de/workaround/account/AvatarResource.java
index ee1f2ef..b27f14a 100644
--- a/src/main/java/de/workaround/account/AvatarResource.java
+++ b/src/main/java/de/workaround/account/AvatarResource.java
@@ -6,6 +6,7 @@
6 6 import jakarta.ws.rs.NotFoundException;
7 7 import jakarta.ws.rs.Path;
8 8 import jakarta.ws.rs.PathParam;
9 +import jakarta.ws.rs.core.HttpHeaders;
9 10 import jakarta.ws.rs.core.Response;
10 11
11 12 /**
@@ -29,7 +30,11 @@
29 30 .filter(User::hasAvatar)
30 31 .orElseThrow(NotFoundException::new);
31 32 byte[] bytes = avatars.read(user).orElseThrow(NotFoundException::new);
32 - return Response.ok(bytes).type(user.avatarContentType).build();
33 + // Immutable is safe: every rendered avatar URL carries ?v=<avatarUpdatedAt>, so a new
34 + // upload changes the URL and the old cached response is never served for it.
35 + return Response.ok(bytes).type(user.avatarContentType)
36 + .header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
37 + .build();
33 38 }
34 39
35 40 }
MODIFY src/main/java/de/workaround/account/SettingsResource.java +8 -5
diff --git a/src/main/java/de/workaround/account/SettingsResource.java b/src/main/java/de/workaround/account/SettingsResource.java
index 5d33f1d..333142e 100644
--- a/src/main/java/de/workaround/account/SettingsResource.java
+++ b/src/main/java/de/workaround/account/SettingsResource.java
@@ -4,6 +4,7 @@
4 4 import java.io.UncheckedIOException;
5 5 import java.net.URI;
6 6 import java.nio.file.Files;
7 +import java.time.Instant;
7 8 import java.util.List;
8 9 import java.util.UUID;
9 10
@@ -40,7 +41,7 @@
40 41 static native TemplateInstance tokenCreated(String plaintext);
41 42
42 43 static native TemplateInstance profile(String username, String displayName, boolean hasAvatar,
43 - String error);
44 + Instant avatarUpdatedAt, String error);
44 45 }
45 46
46 47 @Inject
@@ -63,7 +64,7 @@
63 64 public TemplateInstance profile()
64 65 {
65 66 de.workaround.model.User user = currentUser.require();
66 - return Templates.profile(user.username, user.displayName, user.hasAvatar(), null);
67 + return Templates.profile(user.username, user.displayName, user.hasAvatar(), user.avatarUpdatedAt, null);
67 68 }
68 69
69 70 @POST
@@ -81,7 +82,8 @@
81 82 catch (InvalidUsernameException | UsernameTakenException e)
82 83 {
83 84 return Response.status(Response.Status.BAD_REQUEST)
84 - .entity(Templates.profile(username, displayName, user.hasAvatar(), e.getMessage()))
85 + .entity(Templates.profile(username, displayName, user.hasAvatar(), user.avatarUpdatedAt,
86 + e.getMessage()))
85 87 .build();
86 88 }
87 89 }
@@ -96,7 +98,7 @@
96 98 {
97 99 return Response.status(Response.Status.BAD_REQUEST)
98 100 .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
99 - "No image was uploaded."))
101 + user.avatarUpdatedAt, "No image was uploaded."))
100 102 .build();
101 103 }
102 104 try
@@ -107,7 +109,8 @@
107 109 catch (InvalidImageException e)
108 110 {
109 111 return Response.status(Response.Status.BAD_REQUEST)
110 - .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(), e.getMessage()))
112 + .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
113 + user.avatarUpdatedAt, e.getMessage()))
111 114 .build();
112 115 }
113 116 catch (IOException e)
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +7 -1
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index e160788..6ba09ca 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -40,6 +40,7 @@
40 40 import jakarta.ws.rs.Produces;
41 41 import jakarta.ws.rs.QueryParam;
42 42 import jakarta.ws.rs.core.Context;
43 +import jakarta.ws.rs.core.HttpHeaders;
43 44 import jakarta.ws.rs.core.MediaType;
44 45 import jakarta.ws.rs.core.Response;
45 46 import jakarta.ws.rs.core.UriInfo;
@@ -314,7 +315,12 @@
314 315 throw new NotFoundException();
315 316 }
316 317 byte[] bytes = images.read(repo).orElseThrow(NotFoundException::new);
317 - return Response.ok(bytes).type(repo.imageContentType).build();
318 + // Immutable is safe: rendered image URLs carry ?v=<imageUpdatedAt>. Private repos must not
319 + // land in shared caches (the URL leaks to proxies even though the response is guarded).
320 + String cacheScope = repo.visibility == Repository.Visibility.PUBLIC ? "public" : "private";
321 + return Response.ok(bytes).type(repo.imageContentType)
322 + .header(HttpHeaders.CACHE_CONTROL, cacheScope + ", max-age=31536000, immutable")
323 + .build();
318 324 }
319 325
320 326 private static URI settingsUri(Repository repo)
MODIFY src/main/resources/templates/SettingsResource/profile.html +1 -1
diff --git a/src/main/resources/templates/SettingsResource/profile.html b/src/main/resources/templates/SettingsResource/profile.html
index a78ced6..a2b8864 100644
--- a/src/main/resources/templates/SettingsResource/profile.html
+++ b/src/main/resources/templates/SettingsResource/profile.html
@@ -14,7 +14,7 @@
14 14
15 15 <h2>Profile picture</h2>
16 16 {#if hasAvatar}
17 -<img class="avatar-preview" src="/users/{username}/avatar" alt="Current profile picture">
17 +<img class="avatar-preview" src="/users/{username}/avatar?v={avatarUpdatedAt.toEpochMilli}" alt="Current profile picture">
18 18 {/if}
19 19 <form method="post" action="/settings/profile/avatar" enctype="multipart/form-data">
20 20 <p><input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></p>
MODIFY src/test/java/de/workaround/account/SettingsAvatarTest.java +34 -0
diff --git a/src/test/java/de/workaround/account/SettingsAvatarTest.java b/src/test/java/de/workaround/account/SettingsAvatarTest.java
index 94fc323..f1b542a 100644
--- a/src/test/java/de/workaround/account/SettingsAvatarTest.java
+++ b/src/test/java/de/workaround/account/SettingsAvatarTest.java
@@ -21,6 +21,7 @@
21 21
22 22 import static io.restassured.RestAssured.given;
23 23 import static org.hamcrest.Matchers.anyOf;
24 +import static org.hamcrest.Matchers.containsString;
24 25 import static org.hamcrest.Matchers.is;
25 26 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
26 27 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -66,6 +67,39 @@
66 67 }
67 68
68 69 @Test
70 + @TestSecurity(user = "avatar-cache")
71 + void servesAvatarWithImmutableCacheHeader()
72 + {
73 + given().redirects().follow(false)
74 + .multiPart("avatar", "me.png", png(), "image/png")
75 + .when().post("/settings/profile/avatar")
76 + .then().statusCode(anyOf(is(302), is(303)));
77 +
78 + given()
79 + .when().get("/users/avatar-cache/avatar")
80 + .then().statusCode(200)
81 + .header("Cache-Control", "public, max-age=31536000, immutable");
82 + }
83 +
84 + @Test
85 + @TestSecurity(user = "avatar-prev")
86 + void settingsPreviewUrlIsVersioned()
87 + {
88 + given().redirects().follow(false)
89 + .multiPart("avatar", "me.png", png(), "image/png")
90 + .when().post("/settings/profile/avatar")
91 + .then().statusCode(anyOf(is(302), is(303)));
92 +
93 + long version = bySub("avatar-prev").avatarUpdatedAt.toEpochMilli();
94 + // Target the preview element specifically: the layout's nav avatar is versioned already,
95 + // so a bare URL match would pass even while the preview stays unversioned.
96 + given()
97 + .when().get("/settings/profile")
98 + .then().statusCode(200)
99 + .body(containsString("avatar-preview\" src=\"/users/avatar-prev/avatar?v=" + version));
100 + }
101 +
102 + @Test
69 103 @TestSecurity(user = "avatar-big")
70 104 void rejectsOversized()
71 105 {
MODIFY src/test/java/de/workaround/web/RepositoryImageTest.java +34 -0
diff --git a/src/test/java/de/workaround/web/RepositoryImageTest.java b/src/test/java/de/workaround/web/RepositoryImageTest.java
index b7e09c5..81df3c1 100644
--- a/src/test/java/de/workaround/web/RepositoryImageTest.java
+++ b/src/test/java/de/workaround/web/RepositoryImageTest.java
@@ -122,6 +122,40 @@
122 122 }
123 123
124 124 @Test
125 + @TestSecurity(user = "img-cache-pub")
126 + void publicRepoImageIsPubliclyCacheable()
127 + {
128 + User owner = persistUser("img-cache-pub");
129 + service.create(owner, "cached", Repository.Visibility.PUBLIC, null);
130 +
131 + given().redirects().follow(false)
132 + .multiPart("image", "logo.png", png(), "image/png")
133 + .when().post("/repos/img-cache-pub/cached/image")
134 + .then().statusCode(anyOf(is(302), is(303)));
135 +
136 + given().when().get("/repos/img-cache-pub/cached/image")
137 + .then().statusCode(200)
138 + .header("Cache-Control", "public, max-age=31536000, immutable");
139 + }
140 +
141 + @Test
142 + @TestSecurity(user = "img-cache-priv")
143 + void privateRepoImageIsOnlyPrivatelyCacheable()
144 + {
145 + User owner = persistUser("img-cache-priv");
146 + service.create(owner, "hidden", Repository.Visibility.PRIVATE, null);
147 +
148 + given().redirects().follow(false)
149 + .multiPart("image", "logo.png", png(), "image/png")
150 + .when().post("/repos/img-cache-priv/hidden/image")
151 + .then().statusCode(anyOf(is(302), is(303)));
152 +
153 + given().when().get("/repos/img-cache-priv/hidden/image")
154 + .then().statusCode(200)
155 + .header("Cache-Control", "private, max-age=31536000, immutable");
156 + }
157 +
158 + @Test
125 159 @TestSecurity(user = "img-stranger")
126 160 void nonOwnerCannotUpload()
127 161 {

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog