web: re-save over the open frame, keeping its last three looks
Opening one of the folder's own photos and hitting SAVE PHOTO used to make a second copy of it. Now it replaces that row — same id, same place — and the look the row carried steps into its history, newest first and capped at three, because the pixels it described are gone. The frame's own column in MY PHOTOS lists those looks (click one to put its settings back on the stage) and carries the landing-page consent as a plain tick, which answers the click at once. A file from the disk clears the open id, so a fresh frame still adds one.
This commit is contained in:
+86
-16
@@ -148,6 +148,18 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
||||
}
|
||||
}
|
||||
|
||||
// The photo's own edit history: the looks it carried before the last few saves,
|
||||
// newest first, capped at PHOTO_HISTORY_MAX. Re-saving an open photo replaces
|
||||
// its pixels and its recipe, so the previous recipe is the only way back — a
|
||||
// short JSON array is enough of a log for that.
|
||||
export const PHOTO_HISTORY_MAX = 3;
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'history')) {
|
||||
db.exec(`ALTER TABLE photos ADD COLUMN history TEXT`);
|
||||
}
|
||||
}
|
||||
|
||||
// `avatar` is the stored file name, or null for "no picture".
|
||||
export type User = {
|
||||
id: number;
|
||||
@@ -313,8 +325,9 @@ export type Photo = {
|
||||
// The owner's own folder reads it back to draw the toggle.
|
||||
consent: boolean;
|
||||
};
|
||||
// The owner's own row adds the look that made it, so it can be opened again.
|
||||
export type MyPhoto = Photo & { recipe: unknown | null };
|
||||
// The owner's own row adds the look that made it, so it can be opened again,
|
||||
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
|
||||
export type MyPhoto = Photo & { recipe: unknown | null; history: unknown[] };
|
||||
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
|
||||
export type PhotoMeta = {
|
||||
tag?: string | null;
|
||||
@@ -331,20 +344,26 @@ const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.s
|
||||
|
||||
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
|
||||
type PhotoRow = Omit<Photo, 'consent'> & { consent: number };
|
||||
type MyPhotoRow = PhotoRow & { recipe: string | null };
|
||||
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
|
||||
const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 });
|
||||
const toMyPhoto = (row: MyPhotoRow): MyPhoto => ({
|
||||
...toPhoto(row),
|
||||
// A row whose JSON will not parse is still a photo: its settings are simply
|
||||
// gone, not worth failing the whole folder over.
|
||||
recipe: (() => {
|
||||
try {
|
||||
return row.recipe ? JSON.parse(row.recipe) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
// A row whose JSON will not parse is still a photo: its settings are simply
|
||||
// gone, not worth failing the whole folder over. Same for one bad entry in the
|
||||
// history — the rest of the list still stands.
|
||||
const parseJson = (raw: string | null): unknown => {
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const toMyPhoto = (row: MyPhotoRow): MyPhoto => {
|
||||
const history = parseJson(row.history);
|
||||
return {
|
||||
...toPhoto(row),
|
||||
recipe: parseJson(row.recipe),
|
||||
history: Array.isArray(history) ? history : [],
|
||||
};
|
||||
};
|
||||
|
||||
export function listPhotos(): Photo[] {
|
||||
return (
|
||||
@@ -379,7 +398,7 @@ export function listPhotosByUser(userId: number): MyPhoto[] {
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe
|
||||
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history
|
||||
FROM photos WHERE user_id = ? ORDER BY photos.id DESC`,
|
||||
)
|
||||
.all(userId) as MyPhotoRow[]
|
||||
@@ -503,6 +522,57 @@ export function createPhoto(
|
||||
};
|
||||
}
|
||||
|
||||
// Re-saving a photo the caller already owns: the pixels are replaced in place
|
||||
// and the look the row carried steps into its history, newest first and capped
|
||||
// — the only way back to it, since the bytes it described are gone. A row that
|
||||
// is not theirs, or not there, comes back undefined.
|
||||
export function replacePhoto(
|
||||
userId: number,
|
||||
id: number,
|
||||
file: string,
|
||||
mime: string,
|
||||
bytes: number,
|
||||
meta?: PhotoMeta,
|
||||
): MyPhoto | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history
|
||||
FROM photos WHERE id = ? AND user_id = ?`,
|
||||
)
|
||||
.get(id, userId) as MyPhotoRow | undefined;
|
||||
if (!row) return undefined;
|
||||
const before = toMyPhoto(row);
|
||||
// Only a save that carried a new look is a version of anything, and the log
|
||||
// is capped on the way in so the column can never grow past it.
|
||||
const history =
|
||||
meta?.recipe === undefined || before.recipe === null
|
||||
? before.history
|
||||
: [before.recipe, ...before.history].slice(0, PHOTO_HISTORY_MAX);
|
||||
db.prepare(
|
||||
`UPDATE photos
|
||||
SET file = ?, mime = ?, bytes = ?, tag = ?, title = ?, meta = ?, consent = ?, recipe = ?, history = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
file,
|
||||
mime,
|
||||
bytes,
|
||||
meta?.tag ?? null,
|
||||
meta?.title ?? null,
|
||||
meta?.meta ?? null,
|
||||
meta?.consent === false ? 0 : 1,
|
||||
meta?.recipe === undefined ? JSON.stringify(before.recipe) : JSON.stringify(meta.recipe),
|
||||
JSON.stringify(history),
|
||||
id,
|
||||
);
|
||||
return toMyPhoto(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE id = ?`,
|
||||
)
|
||||
.get(id) as MyPhotoRow,
|
||||
);
|
||||
}
|
||||
|
||||
// The uploader's own toggle: may this photo show on the landing strip?
|
||||
export function setPhotoConsent(userId: number, id: number, consent: boolean): boolean {
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
listPhotosWithOwner,
|
||||
listRecipes,
|
||||
listUsersWithCounts,
|
||||
replacePhoto,
|
||||
avatarPath,
|
||||
photoFile,
|
||||
photoPath,
|
||||
@@ -538,6 +539,42 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply
|
||||
return reply.status(201).send({ photo });
|
||||
});
|
||||
|
||||
// Re-saving one of the caller's own photos: the same bytes-in, meta-on-the-
|
||||
// query shape as the upload, but it lands on the row they named instead of
|
||||
// making a new one. The pixels it displaces are gone for good, so the look they
|
||||
// carried steps into the row's history (see replacePhoto) and the old file is
|
||||
// unlinked. Owner only — the user_id in the WHERE is the authorisation.
|
||||
app.put<{ Params: { id: string } }>(
|
||||
'/api/photos/:id',
|
||||
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
|
||||
async (req, reply) => {
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
if (!allowUpload(String(user.id))) return tooMany(reply);
|
||||
|
||||
const body = req.body;
|
||||
if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' });
|
||||
if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' });
|
||||
|
||||
const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
||||
const mime = sniffImage(body);
|
||||
if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' });
|
||||
|
||||
const previous = photoFile(id);
|
||||
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
|
||||
writeFileSync(photoPath(file), body, { flag: 'wx' });
|
||||
const photo = replacePhoto(user.id, id, file, mime, body.length, photoMeta(req));
|
||||
if (!photo) {
|
||||
unlink(file);
|
||||
return reply.status(404).send({ error: 'photo not found' });
|
||||
}
|
||||
if (previous && previous.file !== file) unlink(previous.file);
|
||||
return reply.status(200).send({ photo });
|
||||
},
|
||||
);
|
||||
|
||||
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
|
||||
// under a server-generated name. The picture it replaces goes with it.
|
||||
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
|
||||
|
||||
Reference in New Issue
Block a user