Skip to content
Open
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ pnpm --filter @tracking-link/web build
}
```

キーワードはプロジェクトごとに管理画面で設定します(転送先URLから自動で提案されます)。**プロジェクトを作ったら、ここにも追記してください。** 忘れても壊れはせず `FALLBACK_URL` に落ちるだけで、平常運転中に `scan_fallback_not_configured` がWorkers Logsに出るので、障害が起きる前に気づけます。
キーワードはプロジェクトごとに管理画面で設定しますが、**選べるのはここに書いたキーワードだけです**(APIも設定にないキーワードを400で拒否します)。ここに未登録のキーワードは、D1が落ちた当日まで何も起きていないように見えてしまうためです。順序としては**先にここへ追記し、そのあとで管理画面から選んでください。** なおキーワードを設定しないプロジェクトは `FALLBACK_URL` に落ちるだけで壊れはせず、平常運転中に `scan_fallback_not_configured` がWorkers Logsに出るので、障害が起きる前に気づけます。

キーワードは短い半角英数字にしてください。印刷するQRコードに入るため、日本語のキーワードは1文字9文字分にパーセントエンコードされ、シンボルが 57×57 から 61×61 モジュールに大きくなります(この判断はここを書く人の責任です。APIは文字種を見ません)。

転送先URLそのものではなくキーワードを埋めているのは、QRから復元できるURLはQRに入っていなければならず、シンボルが 53×53 から 69×69 まで大きくなるためです(ハッシュは一方向で復元できず、暗号化は平文より長くなるので、暗号技術では回避できません)。キーワードなら +4モジュールで済み、副次的に`&p=`が設定済みの一覧からしか選べないため**オープンリダイレクトが構造的に不可能**になります。

Expand Down
8 changes: 7 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@
"**/worker-configuration.d.ts",
// Generated load-test fixtures. `useIgnoreFile` above only reads the root
// .gitignore, so the nested one under loadtest/ does not reach biome.
"**/loadtest/.out"
"**/loadtest/.out",
// Throwaway git worktrees created by tooling. They hold a second full
// checkout, so without this `pnpm lint` reports another copy of the repo —
// often with CRLF diagnostics that do not exist in the real tree, which
// reads as "lint is broken" when nothing is wrong. Excluded via
// .git/info/exclude rather than .gitignore, and biome reads neither.
"**/.claude/worktrees"
]
},
"linter": {
Expand Down
48 changes: 48 additions & 0 deletions packages/api/migrations/0004_add_qrcode_short_code.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- Adds QRCodes.short_code: a 7-character stand-in for the UUID `id` in the URL a
-- printed QR code carries.
--
-- Background: the scan URL was `/?id=<uuid>&p=<keyword>`. Measured against the
-- deployed host at error-correction level H:
--
-- 103 chars 57x57 /?id=550e8400-e29b-41d4-a716-446655440000&p=instagram
-- 74 chars 49x49 /?id=q7mfe3x&p=instagram
--
-- Fewer modules means physically larger modules at the same printed size, which
-- is what decides whether a poster still reads from across a corridor or in bad
-- light. Nothing else about the app changes.
--
-- A NEW COLUMN, not a shorter `id`. AccessLogs.qr_id is a foreign key into
-- QRCodes(id) with ON DELETE CASCADE, and the id is baked into flyers that are
-- already on walls — see the comment on the table in schema.sql. Shortening the
-- id in place would break both. The scan endpoint therefore resolves
-- `id = ? OR short_code = ?`, so a poster carrying either form keeps working
-- forever, including after a row has been backfilled with a short code.
--
-- THIS MIGRATION AND THE BACKFILL ARE A PAIR — run the backfill immediately
-- after this file. The column is nullable so that adding it to a populated
-- database cannot fail, which means every existing row lands here with no code,
-- and the admin UI shows only the short URL. Rows left un-backfilled fall back to
-- rendering the long UUID URL, i.e. exactly the symbol size this change exists to
-- remove:
--
-- node scripts/backfill-short-codes.mjs --local
-- node scripts/backfill-short-codes.mjs --remote
--
-- The unique index is what makes the create path's bounded collision retry
-- meaningful — without it a duplicate code would silently point two posters at
-- one row. SQLite treats NULLs as distinct in a UNIQUE index, so the rows this
-- file leaves empty do not collide with each other; that is what allows the
-- column to be added before the backfill fills it in.
--
-- Idempotent? No — re-running errors with "duplicate column name". A brand-new
-- database does not need this file at all; schema.sql already has the column and
-- the index.
--
-- Apply with:
-- wrangler d1 execute trackinglink-db --local --file=./migrations/0004_add_qrcode_short_code.sql
-- wrangler d1 execute trackinglink-db --remote --file=./migrations/0004_add_qrcode_short_code.sql
-- Then re-run schema.sql, then run the backfill above.

ALTER TABLE QRCodes ADD COLUMN short_code TEXT;

CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_short_code ON QRCodes(short_code);
31 changes: 28 additions & 3 deletions packages/api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,29 @@ CREATE TABLE IF NOT EXISTS Projects (

-- QR code *metadata*. The QR image itself is never stored: the web app renders
-- it client-side on every view (see useQRDataUrl in QRCodesPage.tsx). A row
-- exists because the id is baked into the printed URL (`/?id=<id>`), so it has
-- to be stable forever — regenerating it would break already-posted flyers —
-- and because AccessLogs joins on it to attribute scans to a medium/location.
-- exists because the identifier is baked into the printed URL (`/?id=<...>`), so
-- it has to be stable forever — regenerating it would break already-posted
-- flyers — and because AccessLogs joins on it to attribute scans to a
-- medium/location.
--
-- Two columns can address a row: `id` and `short_code`. New QR codes are printed
-- with the short code; flyers printed before it existed carry the id. The scan
-- endpoint accepts either, and neither is ever reassigned.
CREATE TABLE IF NOT EXISTS QRCodes (
id TEXT PRIMARY KEY,
-- What new QR codes put in the printed URL, in place of the 36-character id:
-- `/?id=q7mfe3x&p=instagram` is 74 characters and encodes as a 49x49 symbol,
-- against 103 characters and 57x57 for the id. Bigger modules at the same
-- printed size is the entire benefit. 7 characters from a 32-character
-- alphabet that omits 0, 1, l and o, so a code survives being read aloud, and
-- lowercase only because TEXT comparison here is case-sensitive.
--
-- Nullable only so migrations/0004 can add it to a populated database without
-- inventing values in SQL. Every row is expected to carry one — the migration
-- is paired with scripts/backfill-short-codes.mjs, which fills in the rows
-- that predate the column. Never overwrite one: it is as printed, and as
-- permanent, as the id.
short_code TEXT,
-- What the QR code is printed on, e.g. "造形大ポスター". Unique per project:
-- one QR code per physical item.
name TEXT NOT NULL,
Expand Down Expand Up @@ -74,6 +92,13 @@ CREATE INDEX IF NOT EXISTS idx_qrcodes_project_id ON QRCodes(project_id);
-- (built from the name) unique.
CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_name ON QRCodes(project_id, name);

-- A short code is what a scan resolves against, so a duplicate would attribute
-- two posters' scans to one row and send some visitors to the wrong project. Also
-- the backstop the create path's collision retry relies on. NULLs count as
-- distinct in a SQLite UNIQUE index, which is what lets migrations/0004 add the
-- column before the backfill populates it.
CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_short_code ON QRCodes(short_code);

-- Serves both the access-log list and the CSV export, which filter by
-- project_id and order by accessed_at DESC — the leading column also covers
-- plain project_id lookups and COUNT(*), so no separate index on project_id is
Expand Down
263 changes: 263 additions & 0 deletions packages/api/scripts/backfill-short-codes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// Assigns a short code to every QRCodes row that does not have one yet.
//
// Pairs with migrations/0004_add_qrcode_short_code.sql. That migration adds
// short_code as a nullable column — it has to be nullable, because SQL alone
// cannot invent a value that satisfies the alphabet and the unique index — so
// every row that existed before it lands with no code. This script fills them in.
//
// It is not optional. The admin UI renders only the short URL, so a row left
// without a code falls back to the 36-character UUID form and prints the 57x57
// symbol that the whole change exists to avoid. Real, already-printed QR codes are
// affected: production held 13 rows when this was written, and a local
// development database typically holds a couple of hundred.
//
// Backfilling does NOT invalidate anything already in print. The scan endpoint
// resolves `id = ? OR short_code = ?`, so a poster carrying the UUID keeps
// working after its row gains a short code — the two are additional handles on
// the same row, and neither is ever reassigned.
//
// Why codes are generated here in JavaScript rather than in SQL: SQLite's
// randomblob()/hex() emits hex, which contains 0 and 1 — characters the alphabet
// deliberately omits so a code can be read aloud — and it has no way to react to
// a unique-index collision. Generating them here uses the same alphabet and the
// same bounded retry as the create path.
//
// Usage:
// node scripts/backfill-short-codes.mjs --local
// node scripts/backfill-short-codes.mjs --remote
// node scripts/backfill-short-codes.mjs --local --dry-run
//
// Safe to run twice: it only ever touches rows WHERE short_code IS NULL, so a
// second run reports 0 updated and writes nothing.
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Kept identical to SHORT_CODE_ALPHABET / SHORT_CODE_LENGTH in src/short-code.ts.
*
* Duplicated rather than imported because this file is run by plain `node`, which
* cannot import a TypeScript module, and adding a build step for one 8-line
* function would cost more than it saves. src/short-code.test.ts imports both and
* asserts they agree, so the two cannot drift silently — if you change one, that
* test fails.
*/
export const SHORT_CODE_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
export const SHORT_CODE_LENGTH = 7;

/** @returns {string} */
export function generateShortCode() {
const bytes = new Uint8Array(SHORT_CODE_LENGTH);
crypto.getRandomValues(bytes);
let code = '';
for (const byte of bytes) {
// 256 is a whole multiple of 32, so the low 5 bits are uniform.
code += SHORT_CODE_ALPHABET[byte & 31];
}
return code;
}

/** Matches SHORT_CODE_ATTEMPTS in src/routes/projects.ts. */
const ATTEMPTS_PER_ROW = 5;

const DB_NAME = 'trackinglink-db';

function parseArgs(argv) {
const args = { target: '', db: DB_NAME, dryRun: false };
for (const arg of argv) {
const [key, value] = arg.replace(/^--/, '').split('=');
if (key === 'local' || key === 'remote') args.target = key;
else if (key === 'dry-run') args.dryRun = true;
else if (key === 'db') args.db = value ?? DB_NAME;
else {
console.error(`unknown argument: ${arg}`);
process.exit(1);
}
}
return args;
}

const wranglerBin = join(
'node_modules',
'.bin',
process.platform === 'win32' ? 'wrangler.CMD' : 'wrangler',
);

/**
* Windows needs `shell: true` to launch wrangler.CMD at all, and a shell splits
* arguments on spaces — so `--command=SELECT id FROM QRCodes` arrives as ten
* arguments and wrangler rejects it. Quoting has to be explicit here; the SQL
* built below only ever uses single quotes, so wrapping in double quotes is safe.
*/
const useShell = process.platform === 'win32';

function quoteArg(arg) {
if (!useShell || !/[\s"]/.test(arg)) return arg;
return `"${arg.replace(/"/g, '\\"')}"`;
}

/**
* Runs one `wrangler d1 execute` and returns its combined output.
*
* Failure detection copies loadtest/seed/run.mjs deliberately: a bare /error/i
* also matches wrangler's own "update to prevent critical errors" version notice,
* which makes every run look broken. `[ERROR]` is wrangler's actual marker.
*/
function d1(args, extra) {
const argv = ['d1', 'execute', args.db, `--${args.target}`, ...extra];
const result = spawnSync(wranglerBin, argv.map(quoteArg), {
encoding: 'utf8',
shell: useShell,
});
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
if (result.status !== 0 || /\[ERROR\]/.test(output)) {
throw new Error(`wrangler d1 execute failed:\n${output}`);
}
return output;
}

/**
* Pulls the result rows out of `wrangler d1 execute --json` output.
*
* Wrangler prints a version banner and other chatter around the JSON, so the
* payload is located rather than assumed to be the whole of stdout.
*/
function queryRows(args, sql) {
const output = d1(args, [`--command=${sql}`, '--json']);
const start = output.indexOf('[');
const end = output.lastIndexOf(']');
if (start === -1 || end <= start) {
throw new Error(`could not find JSON in wrangler output:\n${output}`);
}
const parsed = JSON.parse(output.slice(start, end + 1));
return parsed.flatMap((entry) => entry.results ?? []);
}

/** Doubles single quotes — ids are arbitrary TEXT, not necessarily UUIDs. */
function sqlQuote(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}

async function main() {
const args = parseArgs(process.argv.slice(2));

if (!args.target) {
console.error(
[
'Specify --local or --remote.',
'',
' node scripts/backfill-short-codes.mjs --local',
' node scripts/backfill-short-codes.mjs --remote',
].join('\n'),
);
process.exit(1);
}

// Same hazard as the loadtest seeder: workerd holding the local database makes
// writes fail in a confusing way.
if (args.target === 'local') {
console.log(
'NOTE: stop `wrangler dev` first — workerd holds the local DB.\n',
);
}

const pending = queryRows(
args,
'SELECT id FROM QRCodes WHERE short_code IS NULL ORDER BY id',
);

if (pending.length === 0) {
console.log('Every QR code already has a short code — nothing to do.');
return;
}

// Read the codes already in use so collisions are resolved before writing
// rather than by absorbing a failed statement. The unique index remains the
// real arbiter; this just makes a collision cost nothing.
const taken = new Set(
queryRows(
args,
'SELECT short_code FROM QRCodes WHERE short_code IS NOT NULL',
).map((row) => row.short_code),
);

console.log(
`${pending.length} QR code(s) without a short code; ${taken.size} code(s) already in use.`,
);

const updates = [];
for (const row of pending) {
let code = '';
for (let attempt = 0; attempt < ATTEMPTS_PER_ROW; attempt++) {
const candidate = generateShortCode();
if (!taken.has(candidate)) {
code = candidate;
break;
}
}
if (!code) {
// Impossible at 32^7 codes against this many rows, so treat it as a broken
// generator rather than bad luck and stop before writing anything.
throw new Error(
`could not find a free short code for ${row.id} in ${ATTEMPTS_PER_ROW} attempts; check generateShortCode.`,
);
}
taken.add(code);
// `AND short_code IS NULL` makes each statement individually idempotent, so
// a partially-applied run can be repeated without overwriting a code that
// has already been printed.
updates.push(
`UPDATE QRCodes SET short_code = ${sqlQuote(code)} WHERE id = ${sqlQuote(row.id)} AND short_code IS NULL;`,
);
}

if (args.dryRun) {
console.log(`\n--dry-run: would apply ${updates.length} update(s).`);
for (const statement of updates.slice(0, 5)) console.log(` ${statement}`);
if (updates.length > 5) console.log(` … ${updates.length - 5} more`);
return;
}

// One wrangler invocation for the whole batch. Per-row invocations would be
// correct but take minutes for a few hundred rows, since each spawn pays
// wrangler's startup cost.
const dir = mkdtempSync(join(tmpdir(), 'tl-shortcode-'));
const sqlPath = join(dir, 'backfill.sql');
try {
writeFileSync(sqlPath, `${updates.join('\n')}\n`, 'utf8');
d1(args, [`--file=${sqlPath}`]);
} finally {
rmSync(dir, { recursive: true, force: true });
}

const [remaining] = queryRows(
args,
'SELECT COUNT(*) AS n FROM QRCodes WHERE short_code IS NULL',
);
const left = Number(remaining?.n ?? 0);

console.log(
`\nUpdated ${updates.length} row(s) in ${args.db} (${args.target}).`,
);
if (left > 0) {
console.error(
`${left} row(s) still have no short code — re-run this script; it only touches NULLs.`,
);
process.exit(1);
}
console.log('All QR codes now have a short code.');
}

// Importable for tests without running the backfill.
const invokedDirectly =
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));

if (invokedDirectly) {
main().catch((error) => {
console.error(`\n${error.message}`);
process.exit(1);
});
}
Loading
Loading