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:
2026-09-18 14:47:30 +07:00
parent 27099c2877
commit 72d5ce1c3e
7 changed files with 249 additions and 51 deletions
+37
View File
@@ -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) => {