A self-hosted print studio: images at exact physical sizes, sheet layout with crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery, share links, mail, Telegram — is an optional module that is off by default. This is the neutral public twin of a private tool. Compared to that original: - optional modules behind KLARBILD_MODULES; the default install is print-only - English interface with German as a full second language (1,038 strings); the English sentence is the translation key - no built-in accounts — first boot creates one admin from ADMIN_USERNAME / ADMIN_PASSWORD, or generates a password and logs it once - delivery and backup targets are generic SFTP/FTPS endpoints, off by default - English routes, English identifiers, English comments throughout - two compose files: print-only by default, everything in the full one - AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY 124 tests green, type check clean, verified end to end against a live instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
164 files changed
+27190
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import sharp from 'sharp';
|
||||
import { one } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
import { resolveShare, shareHasItem, trackShare, proofValid, proofFromCookie } from '../../../../lib/shares';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/**
|
||||
* Serve an image behind a share link — **without a sign-in**.
|
||||
*
|
||||
* The access check hangs on the link alone, not on the image: only what is in
|
||||
* `share_items` comes out. So nobody can fetch someone else's images through a
|
||||
* valid link by substituting a different image id.
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
const q = url.searchParams;
|
||||
// No `?pw=` any more: for protected links only the proof counts that the page
|
||||
// set as a cookie after checking once. Otherwise argon2 (130 ms, 64 MiB) would
|
||||
// run again for every single image — with a gallery of 40 images that is enough
|
||||
// to drive the process into the memory limit.
|
||||
let { status, share } = await resolveShare(params.slug!, null);
|
||||
if (status === 'passphrase' && share
|
||||
&& proofValid(share.id, proofFromCookie(request.headers.get('cookie'), params.slug!))) {
|
||||
status = 'ok';
|
||||
}
|
||||
if (status !== 'ok' || !share) {
|
||||
return new Response(
|
||||
status === 'expired' ? 'This link has expired.'
|
||||
: status === 'revoked' ? 'This link has been switched off.'
|
||||
: status === 'passphrase' ? 'A passphrase is required.'
|
||||
: 'Not found.',
|
||||
{ status: status === 'passphrase' ? 401 : status === 'not-found' ? 404 : 410 });
|
||||
}
|
||||
|
||||
const itemId = q.get('item');
|
||||
if (!itemId || !/^[0-9a-f-]{36}$/i.test(itemId)) return new Response('Not found.', { status: 404 });
|
||||
if (!(await shareHasItem(share.id, itemId))) return new Response('Not found.', { status: 404 });
|
||||
|
||||
const item = await one<any>(
|
||||
'SELECT result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1', [itemId]);
|
||||
if (!item?.result_path) return new Response('Not found.', { status: 404 });
|
||||
|
||||
const download = q.get('download') === '1';
|
||||
const wantThumb = q.get('thumb') === '1';
|
||||
// Whoever switched the download off does not hand out the original file either —
|
||||
// otherwise the setting would be decoration.
|
||||
if (download && !share.allow_download) return new Response('Downloading is off for this link.', { status: 403 });
|
||||
|
||||
const path = wantThumb ? (item.thumb_path || item.result_path) : item.result_path;
|
||||
try {
|
||||
let buf = await getObject(path);
|
||||
const sniff = (b: Buffer): string =>
|
||||
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
|
||||
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
|
||||
: 'image/png';
|
||||
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
|
||||
|
||||
// Viewing take: smaller and faster, above all on a phone. The download still
|
||||
// delivers the untouched original file at full resolution.
|
||||
//
|
||||
// Important: this did **not** used to apply to cut-out motifs — they went
|
||||
// through unfiltered, at full resolution, saveable with a right click. That
|
||||
// made "downloading off" useless for exactly stickers and cut-outs. Alpha
|
||||
// images are therefore scaled down as PNG instead of being skipped.
|
||||
if (!download && !wantThumb) {
|
||||
try {
|
||||
const small = sharp(buf, { failOn: 'none', limitInputPixels: 400_000_000 })
|
||||
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true });
|
||||
if (item.has_alpha) { buf = await small.png({ compressionLevel: 8 }).toBuffer(); contentType = 'image/png'; }
|
||||
else { buf = await small.jpeg({ quality: 88, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
|
||||
} catch (e: any) {
|
||||
// Only when the download is blocked is the original off limits — then it
|
||||
// is better to serve nothing than to serve full resolution by accident.
|
||||
console.error('[share] viewing take failed:', e?.message || e);
|
||||
if (!share.allow_download) return new Response('Preview not available.', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
if (download) {
|
||||
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(buf.length),
|
||||
// Not into search engines and not into anyone else's cache.
|
||||
'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
};
|
||||
if (download) {
|
||||
const name = (item.filename || 'klarbild.jpg').replace(/[^\w.\-]/g, '_');
|
||||
headers['Content-Disposition'] = `attachment; filename="${name}"`;
|
||||
}
|
||||
return new Response(new Uint8Array(buf), { headers });
|
||||
} catch (e: any) {
|
||||
// The cause goes into the log — otherwise, during a storage outage, the
|
||||
// operator searches in vain while every recipient sees "image no longer there".
|
||||
console.error('[share] delivery failed', path, e?.message || e);
|
||||
return new Response('This image is no longer there.', { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import archiver from 'archiver';
|
||||
import { Readable } from 'node:stream';
|
||||
import { once } from 'node:events';
|
||||
import { query } from '../../../../lib/db';
|
||||
import { getObject } from '../../../../lib/storage';
|
||||
import { resolveShare, shareItems, trackShare, proofValid, proofFromCookie,
|
||||
archiveName, packageName } from '../../../../lib/shares';
|
||||
import { tooOften, realIp } from '../../../../lib/limit';
|
||||
import { has, moduleOff } from '../../../../lib/modules';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
/** Upper limits. A ZIP is the most expensive way to pull data out of this server. */
|
||||
const MAX_FILES = 200;
|
||||
const MAX_BYTES = 500 * 1024 * 1024; // 500 MB unpacked
|
||||
|
||||
/**
|
||||
* All images of a share link as a ZIP.
|
||||
*
|
||||
* Deliberately **GET**: that makes it a plain link on the page which works even
|
||||
* without JavaScript — and the recipient is often on a phone.
|
||||
*
|
||||
* Unlike `/api/items/zip` (for signed-in users), here **one file after another**
|
||||
* is read and passed on: after every `append` the code waits for the `entry`
|
||||
* event. Otherwise twenty posters would quickly put a few gigabytes into memory
|
||||
* at the same time — and `mem_limit` is 3 GB.
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, request, clientAddress }) => {
|
||||
if (!has('share')) return moduleOff('share');
|
||||
const slug = params.slug!;
|
||||
let { status, share } = await resolveShare(slug, null);
|
||||
if (status === 'passphrase' && share
|
||||
&& proofValid(share.id, proofFromCookie(request.headers.get('cookie'), slug))) {
|
||||
status = 'ok';
|
||||
}
|
||||
if (status !== 'ok' || !share) {
|
||||
return new Response(
|
||||
status === 'expired' ? 'This link has expired.'
|
||||
: status === 'revoked' ? 'This link has been switched off.'
|
||||
: status === 'passphrase' ? 'A passphrase is required.'
|
||||
: 'Not found.',
|
||||
{ status: status === 'passphrase' ? 401 : status === 'not-found' ? 404 : 410 });
|
||||
}
|
||||
|
||||
// Whoever blocked the download means the bundle too.
|
||||
if (!share.allow_download) {
|
||||
return new Response('Downloading is switched off for this link.', { status: 403 });
|
||||
}
|
||||
|
||||
// A ZIP costs processing time and bandwidth — not once a second.
|
||||
const ip = realIp(request, clientAddress);
|
||||
if (tooOften(`zip:${share.id}:${ip}`, 5, 10 * 60_000)) {
|
||||
return new Response('Just downloaded a moment ago. Please wait a little.', { status: 429 });
|
||||
}
|
||||
|
||||
const images = await shareItems(share.id);
|
||||
if (!images.length) return new Response('There are no images for this link.', { status: 404 });
|
||||
if (images.length > MAX_FILES) {
|
||||
return new Response(`This bundle would be too large (${images.length} images). Please download them one by one.`, { status: 413 });
|
||||
}
|
||||
|
||||
const paths = await query<{ id: string; result_path: string; filename: string | null }>(
|
||||
`SELECT id, result_path, filename FROM items
|
||||
WHERE id = ANY($1::uuid[]) AND result_path IS NOT NULL`,
|
||||
[images.map((b) => b.id)]);
|
||||
const byId = new Map(paths.map((p) => [p.id, p]));
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
// Errors must not take the process with them; the stream then breaks off.
|
||||
archive.on('error', (e: any) => console.error('[share/zip]', e?.message || e));
|
||||
archive.on('warning', (e: any) => console.error('[share/zip] warning:', e?.message || e));
|
||||
|
||||
// The handlers are in place **before** anything is written into it.
|
||||
const web = Readable.toWeb(archive) as ReadableStream<Uint8Array>;
|
||||
|
||||
(async () => {
|
||||
const taken = new Map<string, number>();
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (const b of images) {
|
||||
const p = byId.get(b.id);
|
||||
if (!p) continue;
|
||||
let buf: Buffer;
|
||||
try { buf = await getObject(p.result_path); }
|
||||
catch (e: any) { console.error('[share/zip] file missing', p.result_path, e?.message || e); continue; }
|
||||
|
||||
bytes += buf.length;
|
||||
if (bytes > MAX_BYTES) {
|
||||
archive.append(
|
||||
Buffer.from(
|
||||
'This bundle was cut off at 500 MB.\r\n'
|
||||
+ 'The remaining images can be downloaded one by one on the page.\r\n', 'utf8'),
|
||||
{ name: 'NOTE.txt' });
|
||||
break;
|
||||
}
|
||||
|
||||
archive.append(buf, { name: archiveName(p.filename, taken) });
|
||||
// One file after another: only read on once this one is in the stream.
|
||||
await once(archive, 'entry');
|
||||
}
|
||||
} finally {
|
||||
archive.finalize().catch(() => {});
|
||||
}
|
||||
})();
|
||||
|
||||
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
|
||||
|
||||
return new Response(web, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${packageName(share.title)}"`,
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user