Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Normalize to LF in the repository and check out LF in the working tree, on
# every platform. Without this, a Windows clone with core.autocrlf=true gets
# CRLF working files, and `pnpm lint` fails on every file with a wall of
# line-ending diffs — while CI (ubuntu, LF) passes, so the failure looks like a
# local mystery. Biome formats to LF, so the working tree has to be LF too.
* text=auto eol=lf
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ worker-configuration.d.ts
*.log
*.tsbuildinfo

# Editor / IDE
.idea

# Local machine-specific / internal-only files
.claude/settings.local.json
/DS MTG_Tracking-Link要件定義.md
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ QRコード/リンクのアクセスを記録し、リダイレクトするた
## できること

- **プロジェクト**は遷移先(Instagramアカウント、特定のWebページなど)を表し、同じ遷移先URLを持つQRコードをグループ化する
- 各**QRコード**は遷移"元"のソース情報を持つ: **名前**(総称、例:「造形大ポスター」)、**媒体**(例:「Instagram」「ポスター」)、**場所**(貼ってある場所や渡された場所、例:「1F掲示板」)。媒体+場所はプロジェクト内で重複登録できない
- スキャンのたびにアクセスログ(日時・User-Agent・IP)を記録し、301リダイレクトで指定URLへ遷移
- 管理画面でプロジェクトごとのスキャン数、QRコード管理(作成/編集/印刷/削除)、アクセスログのCSVダウンロード(現在は無効化中)を確認可能
- 各**QRコード**は遷移"元"のソース情報を持つ: **名前**(貼る物の名前、例:「造形大ポスター」)、**媒体**(例:「Instagram」「ポスター」)、**場所**(貼ってある場所や渡された場所、例:「1F掲示板」。任意)。**物ごとに1つのQRコードを発行する**運用なので、名前はプロジェクト内で重複登録できない
- スキャンのたびにアクセスログ(日時・User-Agent・IP)を記録し、302リダイレクトで指定URLへ遷移。SNSのリンクプレビュー用クローラーからのアクセスは `is_bot` フラグを立てて記録する(捨てないので、判定を後から改良して過去分を再集計できる)
- 管理画面でプロジェクトごとのスキャン数、QRコード管理(作成/編集/削除)、アクセスログのCSVダウンロード(現在は無効化中)を確認可能

```
QRコードをスキャン ──▶ GET /?id={qrId} (packages/api) ──▶ ログ記録 ──▶ 301リダイレクト
QRコードをスキャン ──▶ GET /?id={qrId} (packages/api) ──▶ ログ記録 ──▶ 302リダイレクト
D1データベース (Projects, QRCodes, AccessLogs)
Expand Down
9 changes: 9 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
// Respect .gitignore so that locally-generated, untracked files are not
// linted. Without this, `pnpm lint` fails on machine-specific files such as
// .claude/settings.local.json, which CI never sees — so the failure only ever
// happens on a developer's machine.
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignore": [
"**/dist",
Expand Down
283 changes: 283 additions & 0 deletions docs/load-testing.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"description": "Self-hosted QR code link tracker on Cloudflare Workers + D1.",
"license": "MIT",
"packageManager": "pnpm@9.15.0",
"engines": { "node": ">=20" },
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "concurrently --names \"api,web\" --prefix \"[{name}]\" \"pnpm --filter @tracking-link/api dev\" \"pnpm --filter @tracking-link/web dev\"",
"dev:api": "pnpm --filter @tracking-link/api dev",
Expand All @@ -14,6 +16,7 @@
"deploy:api": "pnpm --filter @tracking-link/api run deploy",
"deploy:web": "pnpm --filter @tracking-link/web run deploy",
"typecheck": "pnpm -r run typecheck",
"test": "pnpm -r run test",
"lint": "biome check .",
"lint:fix": "biome check --write ."
},
Expand Down
9 changes: 4 additions & 5 deletions packages/api/migrations/0001_add_qrcode_name_medium.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
-- error ("duplicate column name"). A brand-new database doesn't need this at
-- all: schema.sql's CREATE TABLE already includes these columns.
--
-- Before running, make sure no project has more than one QRCodes row sharing
-- the same (now-blank) medium+location — schema.sql's unique index creation
-- will otherwise fail. Give any such duplicates distinct location values
-- first.
-- The columns are added blank. QRCodes is unique on (project_id, name) as of
-- migration 0002, so before creating that index you have to give the blank
-- names distinct values — run 0002 next and follow its instructions.
--
-- Apply with:
-- wrangler d1 execute trackinglink-db --local --file=./migrations/0001_add_qrcode_name_medium.sql
-- wrangler d1 execute trackinglink-db --remote --file=./migrations/0001_add_qrcode_name_medium.sql
-- Then re-run schema.sql to create the unique index.
-- Then run 0002, then re-run schema.sql.

ALTER TABLE QRCodes ADD COLUMN name TEXT NOT NULL DEFAULT '';
ALTER TABLE QRCodes ADD COLUMN medium TEXT NOT NULL DEFAULT '';
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- One-time migration for databases created before:
-- * QRCodes was unique on (project_id, name) instead of
-- (project_id, medium, location)
-- * AccessLogs had an id column exposed, an is_bot column, a foreign key to
-- QRCodes, and a (project_id, accessed_at) index
--
-- Not idempotent. A brand-new database does not need this at all — schema.sql
-- already creates everything below.
--
-- BEFORE RUNNING:
--
-- 1. Take a backup. This rebuilds AccessLogs, which is the only step here that
-- is hard to undo:
-- wrangler d1 export trackinglink-db --remote --output=backup-YYYYMMDD.sql
--
-- 2. Check for duplicate names within a project. The new unique index will fail
-- to create if this returns any rows — rename the duplicates first:
-- SELECT project_id, name, COUNT(*) FROM QRCodes
-- GROUP BY 1, 2 HAVING COUNT(*) > 1;
--
-- 3. Note that the AccessLogs rebuild DROPS rows whose qr_id no longer exists
-- in QRCodes. Those are logs orphaned by earlier project deletions: the old
-- schema had no foreign key, so deleting a project left its scan history
-- behind with nothing in the UI able to reach it. Count them first if you
-- want to know what you are losing:
-- SELECT COUNT(*) FROM AccessLogs
-- WHERE qr_id NOT IN (SELECT id FROM QRCodes);
--
-- Apply with:
-- wrangler d1 execute trackinglink-db --local --file=./migrations/0002_qrcode_unique_name_and_access_logs.sql
-- wrangler d1 execute trackinglink-db --remote --file=./migrations/0002_qrcode_unique_name_and_access_logs.sql
-- Then re-run schema.sql.

-- 1. QRCodes: one QR code per named item, instead of one per medium+location.
-- The old constraint made the intended workflow impossible: with location
-- optional, a second poster with a blank location collided with the first.
DROP INDEX IF EXISTS idx_qrcodes_project_medium_location;
CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_name ON QRCodes(project_id, name);

-- 2. AccessLogs: rebuild to add the foreign key and is_bot.
-- SQLite cannot ALTER TABLE ... ADD CONSTRAINT, so this is the standard
-- create-copy-drop-rename. Do it while the table is small.
CREATE TABLE AccessLogs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qr_id TEXT NOT NULL,
project_id TEXT NOT NULL,
accessed_at TEXT NOT NULL,
user_agent TEXT,
ip_address TEXT,
is_bot INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (qr_id) REFERENCES QRCodes(id) ON DELETE CASCADE
);

-- The WHERE clause is required, not optional: rows referencing a deleted QR
-- code would fail the new foreign key.
INSERT INTO AccessLogs_new (qr_id, project_id, accessed_at, user_agent, ip_address)
SELECT qr_id, project_id, accessed_at, user_agent, ip_address
FROM AccessLogs
WHERE qr_id IN (SELECT id FROM QRCodes);

DROP TABLE AccessLogs;
ALTER TABLE AccessLogs_new RENAME TO AccessLogs;

-- 3. Indexes. The composite index serves the log list and the CSV export
-- (WHERE project_id = ? ORDER BY accessed_at DESC) without a sort step, and
-- its leading column covers what idx_access_logs_project_id used to do —
-- dropping it removes one index write per scan on the hot path.
DROP INDEX IF EXISTS idx_access_logs_project_id;
CREATE INDEX IF NOT EXISTS idx_access_logs_project_accessed_at ON AccessLogs(project_id, accessed_at DESC);
CREATE INDEX IF NOT EXISTS idx_access_logs_qr_id ON AccessLogs(qr_id);
30 changes: 30 additions & 0 deletions packages/api/migrations/0003_add_project_fallback_key.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Adds Projects.fallback_key: a short keyword identifying where a project's QR
-- codes should send people when D1 cannot be reached.
--
-- Background: the scan endpoint reads the destination URL out of D1 on every
-- request. If D1 is unreachable — most plausibly because a Free-plan daily quota
-- ran out, which leaves the Worker running while every D1 call fails — the
-- redirect used to answer with a bare 500. Printed posters would all go dead at
-- once. QR codes now carry `&p=<fallback_key>`, and the Worker maps that keyword
-- to a URL from the FALLBACK_DESTINATIONS var, with no database involved.
--
-- A keyword rather than the destination URL itself: putting a recoverable URL in
-- the QR costs roughly its own length in payload (no amount of hashing or
-- encryption avoids that — a hash is one-way, and ciphertext is longer than
-- plaintext), which grew the symbol from 53x53 to 69x69 modules. A keyword costs
-- 4 modules. It also makes an open redirect structurally impossible, because the
-- parameter only ever selects from a list the operator configured.
--
-- Existing rows get ''. Nothing needs backfilling: the admin UI derives a keyword
-- from the destination host when the column is blank, so QR codes generated for
-- older projects still carry a usable one.
--
-- 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.
--
-- Apply with:
-- wrangler d1 execute trackinglink-db --local --file=./migrations/0003_add_project_fallback_key.sql
-- wrangler d1 execute trackinglink-db --remote --file=./migrations/0003_add_project_fallback_key.sql
-- Then re-run schema.sql.

ALTER TABLE Projects ADD COLUMN fallback_key TEXT NOT NULL DEFAULT '';
9 changes: 7 additions & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
"version": "0.1.0",
"description": "QR redirect/logging Worker and admin API for TrackingLink.",
"license": "MIT",
"engines": { "node": ">=20" },
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify",
"typecheck": "tsc --noEmit",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"db:apply:local": "wrangler d1 execute trackinglink-db --local --file=./schema.sql",
"db:apply:remote": "wrangler d1 execute trackinglink-db --remote --file=./schema.sql"
"db:apply:remote": "wrangler d1 execute trackinglink-db --remote --file=./schema.sql",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"drizzle-orm": "^0.36.4",
Expand All @@ -23,6 +27,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0",
"typescript": "^5.7.2",
"vitest": "2.1.8",
"wrangler": "^3.90.0"
}
}
63 changes: 51 additions & 12 deletions packages/api/schema.sql
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
-- TrackingLink schema
-- TrackingLink schema — source of truth. Safe to re-run any number of times.
--
-- Apply with:
-- wrangler d1 execute trackinglink-db --local --file=./schema.sql
-- wrangler d1 execute trackinglink-db --remote --file=./schema.sql
--
-- Upgrading a database created before a schema change? Run the matching file in
-- migrations/ once first (in numeric order), then re-run this file.

CREATE TABLE IF NOT EXISTS Projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
destination_url TEXT NOT NULL,
-- Short ASCII keyword baked into this project's QR codes as `&p=<key>`, used
-- to pick a destination from the FALLBACK_DESTINATIONS var when D1 cannot be
-- reached. Deliberately not the destination URL itself: a recoverable URL in
-- the QR costs its own length in payload and grows the symbol from 53x53 to
-- 69x69 modules, while a keyword costs 4 — and because the parameter only
-- selects from an operator-configured list, an open redirect is impossible.
--
-- Optional; '' means "no keyword". The admin UI then derives one from the
-- destination host, so no backfill is needed for older projects.
--
-- Changing it does not update QR codes that are already printed — those keep
-- the keyword they were generated with.
fallback_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
admin_user_id TEXT
);

-- 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.
CREATE TABLE IF NOT EXISTS QRCodes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
-- What the QR code is printed on, e.g. "造形大ポスター". Unique per project:
-- one QR code per physical item.
name TEXT NOT NULL,
project_id TEXT NOT NULL,
-- Source channel/type, e.g. "ポスター", "チラシ", "Instagram".
medium TEXT NOT NULL,
location TEXT NOT NULL,
-- Where the item is posted/handed out. Optional — staff record it only when
-- it is useful. Stored as '' rather than NULL when unset, so that reads,
-- CSV export and the unique index all see a single representation.
location TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
creator_id TEXT,
FOREIGN KEY (project_id) REFERENCES Projects(project_id) ON DELETE CASCADE
creator_id TEXT
);

CREATE TABLE IF NOT EXISTS AccessLogs (
Expand All @@ -30,14 +56,27 @@ CREATE TABLE IF NOT EXISTS AccessLogs (
project_id TEXT NOT NULL,
accessed_at TEXT NOT NULL,
user_agent TEXT,
ip_address TEXT
ip_address TEXT,
-- 1 when the request came from a link-preview crawler rather than a person.
-- Bot hits are recorded and flagged rather than dropped, so the classifier
-- can be refined later and historical counts recomputed.
is_bot INTEGER NOT NULL DEFAULT 0,
-- Deleting a QR code (or a project, which cascades to its QR codes) takes
-- its scan history with it. Without this, deletes left unreachable rows
-- behind.
FOREIGN KEY (qr_id) REFERENCES QRCodes(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_qrcodes_project_id ON QRCodes(project_id);
CREATE INDEX IF NOT EXISTS idx_access_logs_project_id ON AccessLogs(project_id);
CREATE INDEX IF NOT EXISTS idx_access_logs_qr_id ON AccessLogs(qr_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_medium_location ON QRCodes(project_id, medium, location);

-- Upgrading a database created before the name/medium columns existed?
-- Run migrations/0001_add_qrcode_name_medium.sql once first, then re-run this
-- file (it's safe to re-run any number of times after that).
-- One QR code per named item within a project. Guards against issuing a second
-- QR code for a poster that already has one, and keeps the download filename
-- (built from the name) unique.
CREATE UNIQUE INDEX IF NOT EXISTS idx_qrcodes_project_name ON QRCodes(project_id, name);

-- 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
-- needed (one less index to write on every scan).
CREATE INDEX IF NOT EXISTS idx_access_logs_project_accessed_at ON AccessLogs(project_id, accessed_at DESC);
CREATE INDEX IF NOT EXISTS idx_access_logs_qr_id ON AccessLogs(qr_id);
5 changes: 4 additions & 1 deletion packages/api/src/auth/local.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { SignJWT, jwtVerify } from 'jose';
import type { AuthUser, Verifier } from './types';

const SESSION_TTL_SECONDS = 60 * 60 * 24; // 24h
// One event day. The token is an all-permissions bearer credential living in
// localStorage, so a shorter life is the cheapest reduction of its blast radius;
// 24h meant a token stolen on day one still worked on day two.
const SESSION_TTL_SECONDS = 60 * 60 * 8; // 8h

/** Issues a session token for the built-in single-admin-password login (see `../routes/auth.ts`). */
export async function signLocalSession(
Expand Down
13 changes: 11 additions & 2 deletions packages/api/src/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MiddlewareHandler } from 'hono';
import { ErrorCodes, fail } from '../errors';
import { verifyLocalSession } from './local';
import type { HonoEnv, Verifier } from './types';

Expand All @@ -23,14 +24,22 @@ export function createAuthMiddleware(
verify: Verifier,
): MiddlewareHandler<HonoEnv> {
return async (c, next) => {
// Both failures share one code: the web app turns UNAUTHORIZED into "your
// session expired, sign in again" and sends the user to /login, and the
// distinction between "no header" and "bad token" is not something a user
// can act on differently.
const token = extractBearerToken(c.req.header('Authorization'));
if (!token) {
return c.json({ error: 'Authorization header required' }, 401);
return fail(c, 401, ErrorCodes.UNAUTHORIZED, {
message: 'Authorization header required',
});
}

const user = await verify(token, c.env);
if (!user) {
return c.json({ error: 'Invalid or expired token' }, 401);
return fail(c, 401, ErrorCodes.UNAUTHORIZED, {
message: 'Invalid or expired token',
});
}

c.set('user', user);
Expand Down
Loading
Loading