⚡ (avatars): Cache avatar and repo image responses immutably
Changes
8 files changed, +102 -10
MODIFY
docs/maintainers/avatars.md
+6 -1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
{