Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions src/app/routes/ProjectDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,22 +175,48 @@ export function ProjectDetailsPage() {
</p>
) : (
<ul className="mediaList">
{project.data.project.media.map((media) => (
<li key={media.id}>
<a href={`/api/media/${media.id}/content`}>
<strong>{media.originalName}</strong>
<span>{formatBytes(media.sizeBytes)}</span>
</a>
{project.data.project.permissions.canManageMedia && (
<button
onClick={() => removeMedia.mutate(media.id)}
disabled={removeMedia.isPending}
{project.data.project.media.map((media) => {
const isImage = isImageMediaType(media.mediaType);
const contentUrl = `/api/media/${encodeURIComponent(media.id)}/content`;
const href = isImage ? `${contentUrl}?preview=1` : contentUrl;
return (
<li
key={media.id}
className={isImage ? 'imageAttachment' : undefined}
>
<a
className="mediaLink"
href={href}
{...(isImage
? {
target: '_blank',
rel: 'noreferrer',
'aria-label': `Open ${media.originalName} full size`,
}
: {})}
>
Delete
</button>
)}
</li>
))}
{isImage && (
<span className="mediaPreview">
<img src={href} alt="" loading="lazy" />
<span>open full size ↗</span>
</span>
)}
<span className="mediaMeta">
<strong>{media.originalName}</strong>
<small>{formatBytes(media.sizeBytes)}</small>
</span>
</a>
{project.data.project.permissions.canManageMedia && (
<button
onClick={() => removeMedia.mutate(media.id)}
disabled={removeMedia.isPending}
>
Delete
</button>
)}
</li>
);
})}
</ul>
)}
</section>
Expand All @@ -210,6 +236,10 @@ function initials(value: string) {
.toUpperCase();
}

function isImageMediaType(mediaType: string | null) {
return mediaType?.toLowerCase().startsWith('image/') ?? false;
}

function formatBytes(value: number | null) {
if (value === null) return 'Size unknown';
if (value < 1024) return `${value} B`;
Expand Down
79 changes: 75 additions & 4 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1215,17 +1215,74 @@ main {
align-items: center;
border-bottom: 1px solid var(--line);
}
.mediaList a {
.mediaLink {
display: flex;
flex: 1;
justify-content: space-between;
min-width: 0;
padding: 1rem 0;
flex: 1;
gap: 1.25rem;
align-items: center;
text-decoration: none;
}
.mediaList a span {
.mediaMeta {
display: flex;
min-width: 0;
flex: 1;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
.mediaMeta strong {
overflow-wrap: anywhere;
}
.mediaMeta small {
flex: 0 0 auto;
color: var(--muted);
font-size: 0.75rem;
}
.mediaPreview {
position: relative;
display: block;
width: 10rem;
aspect-ratio: 8 / 5;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--lavender);
}
.mediaPreview img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 160ms ease;
}
.mediaPreview > span {
position: absolute;
right: 0.45rem;
bottom: 0.45rem;
padding: 0.32rem 0.45rem;
color: #fff;
font-size: 0.62rem;
font-weight: 600;
border-radius: 0.35rem;
background: rgba(29, 17, 39, 0.8);
opacity: 0;
transform: translateY(0.2rem);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.mediaLink:hover .mediaPreview img,
.mediaLink:focus-visible .mediaPreview img {
transform: scale(1.035);
}
.mediaLink:hover .mediaPreview > span,
.mediaLink:focus-visible .mediaPreview > span {
opacity: 1;
transform: translateY(0);
}
.mediaList button {
margin-left: 1.5rem;
color: var(--danger);
Expand Down Expand Up @@ -2737,6 +2794,20 @@ kbd {
align-items: flex-start;
flex-direction: column;
}
.imageAttachment {
align-items: flex-start;
}
.imageAttachment .mediaLink,
.imageAttachment .mediaMeta {
align-items: flex-start;
flex-direction: column;
}
.mediaPreview {
width: min(12rem, 55vw);
}
.mediaList button {
margin-top: 1rem;
}
.operationsBar input {
width: 100%;
min-width: 0;
Expand Down
26 changes: 22 additions & 4 deletions src/worker/repositories/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,28 @@ export async function deleteMedia(
await db.prepare('DELETE FROM media WHERE id = ?').bind(mediaId).run();
}

export function attachmentHeaders(media: MediaRecord, object: R2ObjectBody) {
export function attachmentHeaders(
media: MediaRecord,
object: R2ObjectBody,
preview = false,
) {
const headers = new Headers();
const isImagePreview = preview && isImageMediaType(media.media_type);
object.writeHttpMetadata(headers);
headers.set('Content-Type', media.media_type || 'application/octet-stream');
headers.set('Content-Length', String(object.size));
headers.set('Content-Disposition', contentDisposition(media.original_name));
headers.set(
'Content-Disposition',
contentDisposition(media.original_name, isImagePreview ? 'inline' : 'attachment'),
);
headers.set('Cache-Control', 'private, max-age=300');
headers.set('X-Content-Type-Options', 'nosniff');
if (isImagePreview) {
headers.set(
'Content-Security-Policy',
"sandbox; script-src 'none'; object-src 'none'; base-uri 'none'",
);
}
if (object.httpEtag) headers.set('ETag', object.httpEtag);
return headers;
}
Expand Down Expand Up @@ -189,7 +203,11 @@ function mediaKey(projectId: string, mediaId: string, originalName: string) {
return `projects/${projectId}/media/${mediaId}/${safeName || 'attachment'}`;
}

function contentDisposition(filename: string) {
function isImageMediaType(mediaType: string | null) {
return mediaType?.toLowerCase().startsWith('image/') ?? false;
}

function contentDisposition(filename: string, disposition: 'attachment' | 'inline') {
const ascii = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '_');
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
}
4 changes: 3 additions & 1 deletion src/worker/routes/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ mediaRoutes.get('/:mediaId/content', async (c) => {
c.env.ATTACHMENTS,
c.req.param('mediaId'),
);
return new Response(object.body, {headers: attachmentHeaders(media, object)});
return new Response(object.body, {
headers: attachmentHeaders(media, object, c.req.query('preview') === '1'),
});
} catch (error) {
const result = errorResponse(error);
return c.json(result.response, result.status);
Expand Down
51 changes: 51 additions & 0 deletions test/app/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,57 @@ describe('clickable project routes', () => {
expect(screen.getByText('<img src=x onerror=alert(1)>')).toBeTruthy();
});

it('previews image attachments and opens the original in a new tab', async () => {
fetchMock.mockResolvedValue(
json({
project: {
...projectFixture,
media: [
{
id: 'screenshot',
originalName: 'Launch screenshot.PNG',
mediaType: 'IMAGE/PNG',
sizeBytes: 2048,
status: 'available',
createdAt: '2026-01-02',
},
{
id: 'notes',
originalName: 'Notes.txt',
mediaType: 'text/plain',
sizeBytes: 9,
status: 'available',
createdAt: '2026-01-03',
},
],
},
}),
);

const rendered = renderRoute(
<ProjectDetailsPage />,
'/years/2026/projects/project',
'/years/:yearId/projects/:projectId',
);

const imageLink = await screen.findByRole('link', {
name: 'Open Launch screenshot.PNG full size',
});
expect(imageLink.getAttribute('href')).toBe(
'/api/media/screenshot/content?preview=1',
);
expect(imageLink.getAttribute('target')).toBe('_blank');
expect(imageLink.getAttribute('rel')).toBe('noreferrer');
expect(imageLink.querySelector('img')?.getAttribute('src')).toBe(
'/api/media/screenshot/content?preview=1',
);
expect(rendered.container.querySelectorAll('.mediaPreview')).toHaveLength(1);

const notesLink = screen.getByRole('link', {name: /Notes\.txt/});
expect(notesLink.getAttribute('href')).toBe('/api/media/notes/content');
expect(notesLink.getAttribute('target')).toBeNull();
});

it('renders an idea with no video and exposes the server claim permission', async () => {
fetchMock.mockResolvedValue(
json({
Expand Down
28 changes: 26 additions & 2 deletions test/media/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,25 @@ describe('private project media', () => {
expect(await download.text()).toBe('private bytes');
});

it('serves image previews inline while keeping other media as downloads', async () => {
const image = await upload(memberToken, 'Screenshot.png', 'image bytes', 'image/png');
const text = await upload(memberToken, 'Notes.txt', 'text bytes');

const [imagePreview, imageDownload, textPreview] = await Promise.all([
request(`/api/media/${image.body.media.id}/content?preview=1`, memberToken),
request(`/api/media/${image.body.media.id}/content`, memberToken),
request(`/api/media/${text.body.media.id}/content?preview=1`, memberToken),
]);

expect(imagePreview.headers.get('Content-Type')).toBe('image/png');
expect(imagePreview.headers.get('Content-Disposition')).toContain('inline');
expect(imagePreview.headers.get('Content-Security-Policy')).toContain(
"script-src 'none'",
);
expect(imageDownload.headers.get('Content-Disposition')).toContain('attachment');
expect(textPreview.headers.get('Content-Disposition')).toContain('attachment');
});

it('rejects media writes from non-members and when submissions close', async () => {
const outsider = await upload(outsiderToken, 'nope.txt', 'blocked');
await env.DB.prepare('UPDATE years SET submissions_closed = 1 WHERE id = ?')
Expand Down Expand Up @@ -116,9 +135,14 @@ describe('private project media', () => {
});
});

async function upload(token: string, name: string, contents: string) {
async function upload(
token: string,
name: string,
contents: string,
mediaType = 'text/plain',
) {
const data = new FormData();
data.set('file', new File([contents], name, {type: 'text/plain'}));
data.set('file', new File([contents], name, {type: mediaType}));
const response = await request(`/api/media/projects/${projectId}`, token, {
method: 'POST',
body: data,
Expand Down
Loading