web: a film sim is colour and tone only
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME, CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID. Grain, clarity, saturation and light moves were dropped from their `adjustments`, so a sim is a clean starting point and the general knobs read their defaults while the look still lands on the pixels. LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in where the Exposure slider applies, so the picture gets the lift and the parameter stays at 0. Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the WATERMARK chip column, the real admin stats, and the fix that stopped presets from doubling and a frame from refusing to come off when a photo was reopened (/file is the finished render, /base the editable pixels).
This commit is contained in:
@@ -88,6 +88,11 @@ CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
|
||||
@@ -711,6 +716,14 @@ export function photoFile(id: number): { file: string; mime: string } | undefine
|
||||
| undefined;
|
||||
}
|
||||
|
||||
// The same row, owner-scoped: the routes that serve or write the editable base
|
||||
// behind a saved render answer only to the account that made the photo.
|
||||
export function photoFileOwned(userId: number, id: number): { file: string; mime: string } | undefined {
|
||||
return db.prepare('SELECT file, mime FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as
|
||||
| { file: string; mime: string }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
// The QR card's payload: the stored look, as raw JSON, and where the photo is
|
||||
// allowed to show. The route decides who may read it (a curated `qr` slot), so
|
||||
// this returns the row as stored, recipe included.
|
||||
@@ -761,6 +774,22 @@ export function deleteAllPhotos(): string[] {
|
||||
// look, so every frame on the strip is rated the same way. `visitor` is the
|
||||
// salted-address hash the counter already uses, which is what makes a vote
|
||||
// one-per-visitor without an account and without storing anything identifying.
|
||||
// Reverse-geocode cache, keyed by the coordinate rounded to ~11m. An empty
|
||||
// string is a coordinate the geocoder answered about and had no name for, which
|
||||
// is a real answer and is cached like any other; `undefined` means "never
|
||||
// asked" — the caller then makes the call.
|
||||
export function findPlace(key: string): string | undefined {
|
||||
const row = db.prepare('SELECT name FROM places WHERE key = ?').get(key) as { name: string } | undefined;
|
||||
return row?.name;
|
||||
}
|
||||
|
||||
export function savePlace(key: string, name: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO places (key, name, at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET name = excluded.name, at = excluded.at`,
|
||||
).run(key, name, new Date().toISOString());
|
||||
}
|
||||
|
||||
export type Rating = { avg: number; n: number; mine: number };
|
||||
|
||||
export function rateLook(key: string, visitor: string, stars: number): void {
|
||||
@@ -860,11 +889,15 @@ const BUCKET_COLUMN = {
|
||||
|
||||
export function eventStats(days: number, limit = 12): EventStats {
|
||||
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||
// Real visits only: the crawler and headless-reading rows the counter used to
|
||||
// keep are filtered here as well as at the door, so the numbers are traffic
|
||||
// and not a scan of the page by something with no one behind it.
|
||||
const grouped = (column: string, kind: EventKind | null): EventBucket[] =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT ${column} AS key, COUNT(*) AS n FROM events
|
||||
WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> ''
|
||||
AND COALESCE(device, '') <> 'bot'
|
||||
AND (? IS NULL OR kind = ?)
|
||||
GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`,
|
||||
)
|
||||
@@ -876,7 +909,7 @@ export function eventStats(days: number, limit = 12): EventStats {
|
||||
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
|
||||
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks,
|
||||
COUNT(DISTINCT visitor) AS visitors
|
||||
FROM events WHERE at >= ?`,
|
||||
FROM events WHERE at >= ? AND COALESCE(device, '') <> 'bot'`,
|
||||
)
|
||||
.get(since) as { views: number | null; clicks: number | null; visitors: number };
|
||||
|
||||
@@ -892,7 +925,7 @@ export function eventStats(days: number, limit = 12): EventStats {
|
||||
`SELECT substr(at, 1, 10) AS date,
|
||||
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
|
||||
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks
|
||||
FROM events WHERE at >= ? GROUP BY date`,
|
||||
FROM events WHERE at >= ? AND COALESCE(device, '') <> 'bot' GROUP BY date`,
|
||||
)
|
||||
.all(since) as { date: string; views: number; clicks: number }[];
|
||||
for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Place names for a coordinate. The phone app asks the OS geocoder, and a
|
||||
// browser has no twin of that, so the lookup lives here: one HTTP call behind a
|
||||
// cache, and the same naming rule the app uses, so a stamp reads the same on
|
||||
// both.
|
||||
import { findPlace, savePlace } from './db';
|
||||
|
||||
// ~11m at the equator: fixes from the same spot share a cache row.
|
||||
const key = (lat: number, lng: number) => `${lat.toFixed(4)},${lng.toFixed(4)}`;
|
||||
|
||||
// Nominatim (OpenStreetMap). Its usage policy asks for one request per second,
|
||||
// a caller that identifies itself, and answers that are kept — hence the queue
|
||||
// below and the SQLite row every answer leaves behind.
|
||||
const ENDPOINT = 'https://nominatim.openstreetmap.org/reverse';
|
||||
const AGENT = 'RecipesCam/1.2 (https://recipescam.labz.io.vn)';
|
||||
const GAP_MS = 1100;
|
||||
const TIMEOUT_MS = 8000;
|
||||
|
||||
// One lookup at a time, never closer together than GAP_MS.
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// Android answers with the commune in `subregion` and the city in `region`;
|
||||
// Nominatim says the same thing with `suburb` and `city`. Same rule as the app's
|
||||
// localityName: "COMMUNE, CITY", and a name that fills both slots prints once.
|
||||
export function nameOf(address: Record<string, string | undefined>): string | null {
|
||||
const district =
|
||||
address.suburb || address.city_district || address.district || address.quarter || address.neighbourhood || address.county;
|
||||
const city = address.city || address.town || address.municipality || address.village || address.state;
|
||||
const parts = district && district !== city ? [district, city] : [district || city];
|
||||
const name = parts.filter(Boolean).join(', ');
|
||||
return name ? name.toUpperCase() : null;
|
||||
}
|
||||
|
||||
export async function placeName(lat: number, lng: number): Promise<string | null> {
|
||||
const cached = findPlace(key(lat, lng));
|
||||
if (cached !== undefined) return cached || null;
|
||||
|
||||
const call = queue.then(async () => {
|
||||
await new Promise((r) => setTimeout(r, GAP_MS));
|
||||
const url = `${ENDPOINT}?format=jsonv2&zoom=14&addressdetails=1&lat=${lat}&lon=${lng}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'user-agent': AGENT, accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`geocoder HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { address?: Record<string, string> };
|
||||
return body.address ? nameOf(body.address) : null;
|
||||
});
|
||||
// The queue must not inherit a rejection, or every later lookup would fail
|
||||
// with it; the caller gets the failure through `call` itself.
|
||||
queue = call.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const name = await call;
|
||||
// Only an answer that came back is worth keeping — a timeout or an outage
|
||||
// must not pin "no name here" on the coordinate for good.
|
||||
savePlace(key(lat, lng), name ?? '');
|
||||
return name;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { recipeFile } from './recipeFile';
|
||||
import { sendVerificationMail } from './mailer';
|
||||
import { placeName } from './place';
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
replacePhoto,
|
||||
avatarPath,
|
||||
photoFile,
|
||||
photoFileOwned,
|
||||
photoPath,
|
||||
photoPreset,
|
||||
rateLook,
|
||||
@@ -316,7 +318,11 @@ const SYSTEMS: [string, RegExp][] = [
|
||||
|
||||
function parseUa(ua: string): { browser: string | null; os: string | null; device: string } {
|
||||
const u = ua.toLowerCase();
|
||||
const device = /bot|crawler|spider|httpclient|curl|wget|python-requests/.test(u)
|
||||
// Crawlers and headless browsers never count as traffic: a bot that runs the
|
||||
// beacon would otherwise land in the stats page as a visitor — see the guard
|
||||
// in /api/events and the `device <> 'bot'` filter on every stats query.
|
||||
// No UA at all is a script, not a browser: every real one sends a string.
|
||||
const device = !u.trim() || /bot|crawler|spider|crawl|slurp|headless|puppeteer|playwright|phantomjs|lighthouse|httpclient|curl|wget|python-requests/.test(u)
|
||||
? 'bot'
|
||||
: /ipad|tablet|android(?!.*mobile)/.test(u)
|
||||
? 'tablet'
|
||||
@@ -379,6 +385,10 @@ app.post('/api/events', async (req, reply) => {
|
||||
if (!b || !isEventKind(b.kind) || !allowTrack(ip || 'unknown')) return reply.status(204).send();
|
||||
const text = (v: unknown, max: number) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
|
||||
const ua = parseUa(typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '');
|
||||
// A bot's visit is not traffic: it is answered, never stored, and never
|
||||
// looked up against the geo service either. The stats queries filter `bot`
|
||||
// out as well, so rows written before this guard stay out of the numbers.
|
||||
if (ua.device === 'bot') return reply.status(204).send();
|
||||
const geo = await lookupGeo(ip);
|
||||
createEvent({
|
||||
kind: b.kind,
|
||||
@@ -630,6 +640,20 @@ app.get('/api/photos/mine', async (req, reply) => {
|
||||
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
|
||||
});
|
||||
|
||||
// The name of a coordinate, for the stamp. A browser cannot ask the OS the way
|
||||
// the phone app does, so the lookup happens here — which is also why it is a
|
||||
// pro route: every miss takes a call out to the public geocoder.
|
||||
app.get('/api/place', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
if (!user) return;
|
||||
const q = req.query as { lat?: string; lng?: string };
|
||||
const lat = Number(q.lat);
|
||||
const lng = Number(q.lng);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180)
|
||||
return reply.status(400).send({ error: 'invalid coordinates' });
|
||||
return reply.status(200).send({ place: await placeName(lat, lng) });
|
||||
});
|
||||
|
||||
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
if (!user) return;
|
||||
@@ -757,6 +781,66 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
|
||||
.send(data);
|
||||
});
|
||||
|
||||
// The editable base behind a saved render: the pixels as they went INTO the
|
||||
// look, before the frame, the grade and the stamp were applied. `/file` is what
|
||||
// the folder and the landing strip show — the finished frame — and this is the
|
||||
// layer underneath it. The studio loads it when a saved photo is opened again,
|
||||
// so the stored look lands on the original instead of a second time on its own
|
||||
// output (which doubled the frame and stacked the grade). Owner only: the
|
||||
// render may be public on the strip, the base never is.
|
||||
app.put<{ Params: { id: string } }>(
|
||||
'/api/photos/:id/base',
|
||||
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
|
||||
async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
if (!user) return;
|
||||
if (!allowUpload(String(user.id))) return tooMany(reply);
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
const row = photoFileOwned(user.id, id);
|
||||
if (!row) return reply.status(404).send({ error: 'photo not found' });
|
||||
|
||||
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' });
|
||||
|
||||
// One base per photo, beside the render and overwritten in place by a
|
||||
// re-save: a replace throws the old file away, and `unlink` takes this
|
||||
// with it.
|
||||
writeFileSync(photoPath(`${row.file}.b`), body);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
|
||||
const user = requirePro(req, reply);
|
||||
if (!user) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
|
||||
const row = photoFileOwned(user.id, id);
|
||||
if (!row || basename(row.file) !== row.file) return reply.status(404).send({ error: 'not_found' });
|
||||
let data: Buffer;
|
||||
let mime: ReturnType<typeof sniffImage> = null;
|
||||
try {
|
||||
data = readFileSync(photoPath(`${row.file}.b`));
|
||||
mime = sniffImage(data);
|
||||
} catch {
|
||||
return reply.status(404).send({ error: 'not_found' });
|
||||
}
|
||||
// A row saved before the base existed has no `.b` beside it, and the studio
|
||||
// falls back to the render (the old, look-already-baked behaviour).
|
||||
if (!mime) return reply.status(404).send({ error: 'not_found' });
|
||||
return reply
|
||||
.header('content-type', mime)
|
||||
.header('x-content-type-options', 'nosniff')
|
||||
.header('cache-control', 'private, max-age=60')
|
||||
.send(data);
|
||||
});
|
||||
|
||||
// The QR card's payload: the `.recipe` file the app reads back on IMPORT, built
|
||||
// from the look the photo was uploaded with. Public like the strip, but only
|
||||
// for a row the curator ticked into the `qr` section — that checkbox is the
|
||||
@@ -816,10 +900,14 @@ function admin(req: FastifyRequest): User | { status: number } {
|
||||
}
|
||||
|
||||
function unlink(file: string): void {
|
||||
try {
|
||||
unlinkSync(photoPath(file));
|
||||
} catch {
|
||||
// Already gone; the row is what matters.
|
||||
// The editable base lives beside the render as `<file>.b`; the row is gone,
|
||||
// so it goes too.
|
||||
for (const name of [file, `${file}.b`]) {
|
||||
try {
|
||||
unlinkSync(photoPath(name));
|
||||
} catch {
|
||||
// Already gone; the row is what matters.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user