Skip to content

Commit c434bba

Browse files
Merge pull request #93 from CodeForPhilly/feat/legacy-url-redirects
feat(api): legacy laddr URL redirects (closes #78)
2 parents c9c8ccb + 046e80a commit c434bba

6 files changed

Lines changed: 618 additions & 0 deletions

File tree

apps/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import pushDaemonPlugin from './plugins/push-daemon.js';
3939
import servicesPlugin from './plugins/services.js';
4040
import markdownPlugin from './plugins/markdown.js';
4141
import slugRedirectPlugin from './plugins/slug-redirect.js';
42+
import legacyRedirectPlugin from './plugins/legacy-redirect.js';
4243
import rateLimitPlugin from './plugins/rate-limit.js';
4344
import idempotencyPlugin from './plugins/idempotency.js';
4445
import sessionMiddlewarePlugin from './auth/middleware.js';
@@ -133,6 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
133134
await fastify.register(servicesPlugin);
134135
await fastify.register(markdownPlugin);
135136
await fastify.register(slugRedirectPlugin);
137+
await fastify.register(legacyRedirectPlugin);
136138

137139
// ----- 7. Rate limiting -----
138140
await fastify.register(rateLimitPlugin);
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* Legacy laddr URL redirect plugin.
3+
*
4+
* Catches the URL shapes the old `codeforphilly.org` site served and
5+
* 301s them to the current canonical equivalents, per
6+
* specs/behaviors/legacy-id-mapping.md → "Legacy URL forms we accept".
7+
*
8+
* /projects?ID=<n> → /projects/<slug>
9+
* /people/:username[/...] → /members/:username[/...]
10+
* /project-updates?ProjectID=<n> → /projects/<slug>
11+
* /project-buzz/<slug>[/...] → /projects/<projectSlug>/buzz/<slug>[/...]
12+
* /tags/<namespace>.<slug>[/...] → /tags/<namespace>/<slug>[/...]
13+
*
14+
* Plus `410 Gone` for explicitly-deferred patterns (`/checkin`,
15+
* `/bigscreen`) — see specs/deferred.md for why.
16+
*
17+
* Companion to slug-redirect.ts (renames *within* the new site). The two
18+
* hooks pattern-match disjoint URL shapes — they coexist without
19+
* coordination, both bypass /api/*, and both register before the
20+
* static-web SPA fallthrough.
21+
*/
22+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
23+
import fp from 'fastify-plugin';
24+
25+
import type { InMemoryState } from '../store/memory/state.js';
26+
27+
/** Long cache — legacy URL shapes are permanent. */
28+
const REDIRECT_CACHE = 'public, max-age=86400';
29+
30+
/** 410 body — minimal explanation page. */
31+
const GONE_HTML = `<!DOCTYPE html>
32+
<html lang="en">
33+
<head>
34+
<meta charset="UTF-8">
35+
<title>This page is no longer available</title>
36+
<meta name="viewport" content="width=device-width, initial-scale=1">
37+
<style>
38+
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1rem; color: #111; }
39+
h1 { font-size: 1.5rem; }
40+
p { line-height: 1.5; }
41+
a { color: #0366d6; }
42+
</style>
43+
</head>
44+
<body>
45+
<h1>This page is no longer available</h1>
46+
<p>
47+
The page you're looking for was part of an older version of
48+
<a href="https://codeforphilly.org/">codeforphilly.org</a> that's been
49+
retired. The feature isn't coming back in its old form, but you can
50+
still find current Code for Philly projects, events, and people from
51+
<a href="/">the home page</a>.
52+
</p>
53+
</body>
54+
</html>`;
55+
56+
const GONE_PATHS = new Set(['/checkin', '/bigscreen']);
57+
58+
/** Strip the query off a URL string, returning { path, query } (query keeps the leading ?). */
59+
function splitUrl(url: string): { path: string; query: string } {
60+
const idx = url.indexOf('?');
61+
if (idx === -1) return { path: url, query: '' };
62+
return { path: url.slice(0, idx), query: url.slice(idx) };
63+
}
64+
65+
/**
66+
* Remove a single query-string parameter while preserving the rest. Returns
67+
* the query suffix including the leading `?`, or '' if no params remain.
68+
*/
69+
function dropQueryParam(query: string, param: string): string {
70+
if (!query) return '';
71+
const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
72+
params.delete(param);
73+
const remaining = params.toString();
74+
return remaining ? `?${remaining}` : '';
75+
}
76+
77+
async function legacyRedirectPlugin(fastify: FastifyInstance): Promise<void> {
78+
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
79+
if (request.method !== 'GET' && request.method !== 'HEAD') return;
80+
if (request.url.startsWith('/api/')) return;
81+
82+
const { path, query } = splitUrl(request.url);
83+
const state = fastify.inMemoryState;
84+
85+
// /checkin, /bigscreen → 410 Gone -----------------------------------------
86+
if (GONE_PATHS.has(path)) {
87+
await reply
88+
.code(410)
89+
.type('text/html; charset=utf-8')
90+
.header('Cache-Control', 'public, max-age=86400')
91+
.send(GONE_HTML);
92+
return;
93+
}
94+
95+
// /projects?ID=<n> → /projects/<slug> -------------------------------------
96+
if (path === '/projects' && query) {
97+
const id = legacyIdFromQuery(query, 'ID');
98+
if (id !== null) {
99+
const slug = projectSlugByLegacyId(state, id);
100+
if (slug) {
101+
const remainingQuery = dropQueryParam(query, 'ID');
102+
await sendRedirect(reply, `/projects/${slug}${remainingQuery}`);
103+
return;
104+
}
105+
// Unknown legacyId — fall through to SPA; nothing useful to redirect to.
106+
}
107+
}
108+
109+
// /project-updates?ProjectID=<n> → /projects/<slug> -----------------------
110+
if (path === '/project-updates' && query) {
111+
const id = legacyIdFromQuery(query, 'ProjectID');
112+
if (id !== null) {
113+
const slug = projectSlugByLegacyId(state, id);
114+
if (slug) {
115+
const remainingQuery = dropQueryParam(query, 'ProjectID');
116+
await sendRedirect(reply, `/projects/${slug}${remainingQuery}`);
117+
return;
118+
}
119+
}
120+
}
121+
122+
// /people/<username>[/...] → /members/<username>[/...] --------------------
123+
// Pure prefix rewrite — laddr's Username was copied verbatim into slug
124+
// per behaviors/slug-handles.md#migration-from-laddr, so no lookup needed.
125+
const peopleMatch = /^\/people\/([^/]+)(\/.*)?$/.exec(path);
126+
if (peopleMatch) {
127+
const username = peopleMatch[1] as string;
128+
const suffix = peopleMatch[2] ?? '';
129+
await sendRedirect(reply, `/members/${username}${suffix}${query}`);
130+
return;
131+
}
132+
133+
// /project-buzz/<slug>[/...] → /projects/<projectSlug>/buzz/<slug>[/...] --
134+
const buzzMatch = /^\/project-buzz\/([^/]+)(\/.*)?$/.exec(path);
135+
if (buzzMatch) {
136+
const buzzSlug = buzzMatch[1] as string;
137+
const suffix = buzzMatch[2] ?? '';
138+
const buzzId = state.buzzIdBySlug.get(buzzSlug);
139+
if (buzzId !== undefined) {
140+
const buzz = state.projectBuzz.get(buzzId);
141+
if (buzz) {
142+
const projectSlug = state.projectSlugById.get(buzz.projectId);
143+
if (projectSlug) {
144+
await sendRedirect(
145+
reply,
146+
`/projects/${projectSlug}/buzz/${buzzSlug}${suffix}${query}`,
147+
);
148+
return;
149+
}
150+
}
151+
}
152+
// Unknown buzz slug — fall through (SPA serves 404 or its own handling).
153+
}
154+
155+
// /tags/<namespace>.<slug>[/...] → /tags/<namespace>/<slug>[/...] ---------
156+
// Pure URL transform; no lookup. The legacy dot-form was laddr's tag
157+
// handle shape; the new site uses path-form for routing distinction.
158+
const dotTagMatch = /^\/tags\/([a-z]+)\.([^/]+)(\/.*)?$/.exec(path);
159+
if (dotTagMatch) {
160+
const namespace = dotTagMatch[1] as string;
161+
const slug = dotTagMatch[2] as string;
162+
const suffix = dotTagMatch[3] ?? '';
163+
await sendRedirect(reply, `/tags/${namespace}/${slug}${suffix}${query}`);
164+
return;
165+
}
166+
});
167+
}
168+
169+
async function sendRedirect(reply: FastifyReply, target: string): Promise<void> {
170+
await reply
171+
.code(301)
172+
.header('Location', target)
173+
.header('Cache-Control', REDIRECT_CACHE)
174+
.send();
175+
}
176+
177+
/**
178+
* Parse an integer legacy-id from a query string. Returns null for absent,
179+
* non-numeric, negative, or NaN values — those fall through to the SPA
180+
* rather than triggering a redirect to an invalid target.
181+
*/
182+
function legacyIdFromQuery(query: string, param: string): number | null {
183+
const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
184+
const raw = params.get(param);
185+
if (raw === null) return null;
186+
if (!/^\d+$/.test(raw)) return null;
187+
const n = Number(raw);
188+
if (!Number.isInteger(n) || n <= 0) return null;
189+
return n;
190+
}
191+
192+
function projectSlugByLegacyId(state: InMemoryState, legacyId: number): string | null {
193+
const projectId = state.projectIdByLegacyId.get(legacyId);
194+
if (!projectId) return null;
195+
return state.projectSlugById.get(projectId) ?? null;
196+
}
197+
198+
export default fp(legacyRedirectPlugin, {
199+
name: 'legacy-redirect',
200+
dependencies: ['services'],
201+
});

apps/api/src/store/memory/state.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ export interface InMemoryState {
4343
projectSlugById: Map<string, string>;
4444
/** project.slug → project.id */
4545
projectIdBySlug: Map<string, string>;
46+
/**
47+
* project.legacyId → project.id. Populated only for records that carry a
48+
* laddr legacy ID (the importer sets these; runtime-created projects don't).
49+
* Used by the legacy-redirect plugin to resolve `/projects?ID=<n>` and
50+
* `/project-updates?ProjectID=<n>` to the canonical slug URL.
51+
* Per specs/behaviors/legacy-id-mapping.md.
52+
*/
53+
projectIdByLegacyId: Map<number, string>;
4654

4755
/** person.id → person.slug */
4856
personSlugById: Map<string, string>;
@@ -66,6 +74,13 @@ export interface InMemoryState {
6674
buzzByProject: Map<string, Set<string>>;
6775
/** projectId + buzzSlug → buzzId */
6876
buzzByProjectAndSlug: Map<string, string>;
77+
/**
78+
* buzz.slug → buzz.id (global). Buzz slugs are globally unique per
79+
* `data-model.md#projectbuzz`, so a flat map is the right shape for the
80+
* legacy `/project-buzz/<slug>` redirect (which carries only the buzz slug
81+
* with no project hint).
82+
*/
83+
buzzIdBySlug: Map<string, string>;
6984

7085
/** projectId → Set<roleId> */
7186
helpWantedByProject: Map<string, Set<string>>;
@@ -109,6 +124,7 @@ export function createEmptyState(): InMemoryState {
109124

110125
projectSlugById: new Map(),
111126
projectIdBySlug: new Map(),
127+
projectIdByLegacyId: new Map(),
112128
personSlugById: new Map(),
113129
personIdBySlug: new Map(),
114130
tagIdByHandle: new Map(),
@@ -118,6 +134,7 @@ export function createEmptyState(): InMemoryState {
118134
updateByProjectAndNumber: new Map(),
119135
buzzByProject: new Map(),
120136
buzzByProjectAndSlug: new Map(),
137+
buzzIdBySlug: new Map(),
121138
helpWantedByProject: new Map(),
122139
tagAssignmentsByTaggable: new Map(),
123140
tagAssignmentsByTag: new Map(),
@@ -147,10 +164,16 @@ export function indexProject(state: InMemoryState, project: Project): void {
147164
if (old) {
148165
state.projectSlugById.delete(old.id);
149166
state.projectIdBySlug.delete(old.slug);
167+
if (typeof old.legacyId === 'number') {
168+
state.projectIdByLegacyId.delete(old.legacyId);
169+
}
150170
}
151171
state.projects.set(project.id, project);
152172
state.projectSlugById.set(project.id, project.slug);
153173
state.projectIdBySlug.set(project.slug, project.id);
174+
if (typeof project.legacyId === 'number') {
175+
state.projectIdByLegacyId.set(project.legacyId, project.id);
176+
}
154177
}
155178

156179
/** Add or replace one person and update their secondary indices. */
@@ -212,6 +235,10 @@ export function indexProjectUpdate(state: InMemoryState, update: ProjectUpdate):
212235

213236
/** Add or replace a buzz item and update secondary indices. */
214237
export function indexProjectBuzz(state: InMemoryState, buzz: ProjectBuzz): void {
238+
const old = state.projectBuzz.get(buzz.id);
239+
if (old) {
240+
state.buzzIdBySlug.delete(old.slug);
241+
}
215242
state.projectBuzz.set(buzz.id, buzz);
216243

217244
let byProject = state.buzzByProject.get(buzz.projectId);
@@ -220,6 +247,7 @@ export function indexProjectBuzz(state: InMemoryState, buzz: ProjectBuzz): void
220247

221248
const key = `${buzz.projectId}:${buzz.slug}`;
222249
state.buzzByProjectAndSlug.set(key, buzz.id);
250+
state.buzzIdBySlug.set(buzz.slug, buzz.id);
223251
}
224252

225253
/** Add or replace a help-wanted role and update secondary indices. */

apps/api/src/store/state-apply.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,13 @@ export class StateApply {
6161

6262
removeProject(projectId: string, slug: string): this {
6363
this.#ops.push((state, fts) => {
64+
const old = state.projects.get(projectId);
6465
state.projects.delete(projectId);
6566
state.projectSlugById.delete(projectId);
6667
state.projectIdBySlug.delete(slug);
68+
if (old && typeof old.legacyId === 'number') {
69+
state.projectIdByLegacyId.delete(old.legacyId);
70+
}
6771
fts.removeProject(slug);
6872
});
6973
this.#invalidateFacets = true;
@@ -183,6 +187,7 @@ export class StateApply {
183187
state.projectBuzz.delete(b.id);
184188
state.buzzByProject.get(b.projectId)?.delete(b.id);
185189
state.buzzByProjectAndSlug.delete(`${b.projectId}:${b.slug}`);
190+
state.buzzIdBySlug.delete(b.slug);
186191
});
187192
return this;
188193
}

0 commit comments

Comments
 (0)