beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

View File

@@ -1,35 +1,88 @@
import { env } from '$env/dynamic/private';
import { building, dev } from '$app/environment';
import { betterAuth } from 'better-auth/minimal';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { genericOAuth } from 'better-auth/plugins';
import { APIError, createAuthMiddleware } from 'better-auth/api';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
import { error, type RequestEvent } from '@sveltejs/kit';
import { osuProfile } from './osu-profile';
export { isAdmin, requireAdmin, requireUser } from './authorization';
if (!building && (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 32)) {
throw new Error('Set BETTER_AUTH_SECRET to a random secret of at least 32 characters');
}
if (!building && !dev && !env.BETTER_AUTH_URL && !env.ORIGIN) {
throw new Error('Set BETTER_AUTH_URL to the public site origin');
}
export const authProviders = {
google: !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET),
osu: !!(env.OSU_CLIENT_ID && env.OSU_CLIENT_SECRET)
};
export const auth = betterAuth({
baseURL: env.ORIGIN ?? 'http://localhost:3000',
secret: env.BETTER_AUTH_SECRET ?? 'build-time-placeholder',
baseURL: env.BETTER_AUTH_URL || env.ORIGIN || 'http://localhost:5173',
secret: env.BETTER_AUTH_SECRET || 'build-only-placeholder-not-a-runtime-secret',
database: drizzleAdapter(db, { provider: 'pg', schema }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET
emailAndPassword: { enabled: true, minPasswordLength: 12, maxPasswordLength: 128 },
user: {
additionalFields: {
role: { type: 'string', required: false, defaultValue: 'user', input: false }
}
},
account: {
accountLinking: {
enabled: true,
disableImplicitLinking: true,
allowDifferentEmails: true,
trustedProviders: ['osu']
}
},
rateLimit: { enabled: true },
socialProviders: authProviders.google
? {
google: { clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET! }
}
: {},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// osu! has no email scope. Reserve its internal identifiers against credential signup.
if (
ctx.path === '/sign-up/email' &&
typeof ctx.body?.email === 'string' &&
ctx.body.email.toLowerCase().endsWith('.invalid')
) {
throw new APIError('BAD_REQUEST', { message: 'Please use a deliverable email address' });
}
})
},
plugins: [
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
genericOAuth({
config: authProviders.osu
? [
{
providerId: 'osu',
clientId: env.OSU_CLIENT_ID!,
clientSecret: env.OSU_CLIENT_SECRET!,
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
tokenUrl: 'https://osu.ppy.sh/oauth/token',
scopes: ['identify'],
getUserInfo: async (tokens) => {
const response = await fetch('https://osu.ppy.sh/api/v2/me', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000)
});
if (!response.ok) throw new Error('Could not load osu! profile');
return osuProfile(await response.json());
}
}
]
: []
}),
sveltekitCookies(getRequestEvent)
]
});
export function isAdmin(event: RequestEvent): boolean {
const token = event.cookies.get('admin_token');
return !!token && token === env.ADMIN_SECRET;
}
export function requireAdmin(event: RequestEvent): void {
if (!isAdmin(event)) {
error(401, { message: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { isAdmin, requireAdmin, requireUser } from './authorization';
function event(role?: string) {
return { locals: { user: role ? { id: 'alice', role } : null, session: null } } as {
locals: App.Locals;
};
}
describe('central authorization', () => {
it('rejects anonymous requests even with a legacy admin cookie', () => {
const anonymous = { ...event(), cookies: { get: () => 'old-admin-secret' } };
expect(isAdmin(anonymous)).toBe(false);
expect(() => requireAdmin(anonymous)).toThrow(expect.objectContaining({ status: 401 }));
});
it('allows normal users to own data but not administer the site', () => {
expect(requireUser(event('user')).id).toBe('alice');
expect(() => requireAdmin(event('user'))).toThrow(expect.objectContaining({ status: 403 }));
});
it('allows only the explicit admin role', () => {
expect(requireAdmin(event('admin')).id).toBe('alice');
expect(isAdmin(event('superadmin'))).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
type AuthEvent = { locals: App.Locals };
export function requireUser(event: AuthEvent) {
if (!event.locals.user) error(401, 'Log in to continue');
return event.locals.user;
}
export function isAdmin(event: AuthEvent): boolean {
return event.locals.user?.role === 'admin';
}
export function requireAdmin(event: AuthEvent) {
const user = requireUser(event);
if (!isAdmin(event)) error(403, 'Administrator access required');
return user;
}

View File

@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const catalog = JSON.parse(readFileSync('data/pokemon-catalog.json', 'utf8')) as {
pokemon: { number: number; name: string; hp: number; type1: string }[];
games: { sourceId: number; name: string }[];
entries: [number, number, string][];
};
describe('Pokémon catalog snapshot', () => {
it('contains the national species catalog and the latest source versions', () => {
expect(catalog.pokemon).toHaveLength(1025);
expect(catalog.games.length).toBeGreaterThanOrEqual(53);
expect(catalog.pokemon.find((p) => p.number === 25)).toMatchObject({
name: 'Pikachu',
type1: 'Electric',
hp: 35
});
expect(catalog.games.some((g) => g.name.includes('Z-A'))).toBe(true);
});
it('has unique species, games, and valid entry references', () => {
const pokemon = new Set(catalog.pokemon.map((p) => p.number));
const games = new Set(catalog.games.map((g) => g.sourceId));
expect(pokemon.size).toBe(catalog.pokemon.length);
expect(games.size).toBe(catalog.games.length);
expect(new Set(catalog.entries.map(([p, g]) => `${p}/${g}`)).size).toBe(catalog.entries.length);
expect(
catalog.entries.every(
([p, g, a]) => pokemon.has(p) && games.has(g) && ['catchable', 'unknown'].includes(a)
)
).toBe(true);
});
it('does not invent exclusive availability from missing encounters', () => {
expect(catalog.entries.some(([, , a]) => a === 'catchable')).toBe(true);
expect(catalog.entries.some(([, , a]) => a === 'unknown')).toBe(true);
});
});

View File

@@ -2,6 +2,27 @@ import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';
export const relations = defineRelations(schema, (r) => ({
// ── Auth ──────────────────────────────────────
user: {
sessions: r.many.session(),
accounts: r.many.account(),
entries: r.many.userEntries(),
games: r.many.userGames(),
listCompletions: r.many.listItemCompletions()
},
session: {
user: r.one.user({
from: r.session.userId,
to: r.user.id
})
},
account: {
user: r.one.user({
from: r.account.userId,
to: r.user.id
})
},
// ── Progress ──────────────────────────────────
tasks: {
tasksToTopics: r.many.tasksToTopics()
@@ -21,61 +42,41 @@ export const relations = defineRelations(schema, (r) => ({
},
// ── CatchEmAll ────────────────────────────────
game: {
entries: r.many.pokemonGameEntry()
games: {
entries: r.many.pokemonGameEntries()
},
pokemon: {
entry: r.many.pokemonGameEntry()
entry: r.many.pokemonGameEntries()
},
pokemonGameEntry: {
game: r.one.game({
from: r.pokemonGameEntry.gameId,
to: r.game.id
pokemonGameEntries: {
game: r.one.games({
from: r.pokemonGameEntries.gameId,
to: r.games.id
}),
pokemon: r.one.pokemon({
from: r.pokemonGameEntry.pokemonId,
from: r.pokemonGameEntries.pokemonId,
to: r.pokemon.id
}),
userEntries: r.many.userEntry()
userEntries: r.many.userEntries()
},
userEntry: {
pokemonGameEntry: r.one.pokemonGameEntry({
from: r.userEntry.pokemonGameEntryId,
to: r.pokemonGameEntry.id
userEntries: {
pokemonGameEntry: r.one.pokemonGameEntries({
from: r.userEntries.pokemonGameEntryId,
to: r.pokemonGameEntries.id
}),
user: r.one.user({
from: r.userEntry.userId,
from: r.userEntries.userId,
to: r.user.id
})
},
userGame: {
userGames: {
user: r.one.user({
from: r.userGame.userId,
from: r.userGames.userId,
to: r.user.id
}),
game: r.one.game({
from: r.userGame.gameId,
to: r.game.id
})
},
// ── Auth ──────────────────────────────────────
user: {
sessions: r.many.session(),
accounts: r.many.account(),
entries: r.many.userEntry(),
games: r.many.userGame()
},
session: {
user: r.one.user({
from: r.session.userId,
to: r.user.id
})
},
account: {
user: r.one.user({
from: r.account.userId,
to: r.user.id
game: r.one.games({
from: r.userGames.gameId,
to: r.games.id
})
},
@@ -95,5 +96,27 @@ export const relations = defineRelations(schema, (r) => ({
from: r.photos.sightingId,
to: r.sightings.id
})
},
// ── Lists ─────────────────────────────────────
lists: {
items: r.many.listItems()
},
listItems: {
list: r.one.lists({
from: r.listItems.listId,
to: r.lists.id
}),
completions: r.many.listItemCompletions()
},
listItemCompletions: {
item: r.one.listItems({
from: r.listItemCompletions.itemId,
to: r.listItems.id
}),
user: r.one.user({
from: r.listItemCompletions.userId,
to: r.user.id
})
}
}));

View File

@@ -1,234 +0,0 @@
import {
doublePrecision,
index,
integer,
pgEnum,
pgTable,
real,
serial,
text,
timestamp,
unique,
uuid,
varchar
} from 'drizzle-orm/pg-core';
import { user } from './auth.schema';
import { availabilityValues } from '../../availability';
export const tasks = pgTable('tasks', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
currentPoints: integer('current_points').default(0),
totalPoints: integer('total_points').default(1),
notes: varchar('notes', { length: 8192 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const topics = pgTable('topics', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
emoji: varchar('emoji', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const tasksToTopics = pgTable('tasks_to_topics', {
taskId: integer('task_id')
.notNull()
.references(() => tasks.id),
topicId: integer('topic_id')
.notNull()
.references(() => topics.id)
});
export const universities = pgTable('universities', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
progress: integer('progress').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const certificates = pgTable('certificates', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
progress: integer('progress').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
export const publications = pgTable('publications', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
// Legacy ^
// New
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
export const blogArticles = pgTable(
'blog_articles',
{
id: serial('id').primaryKey(),
title: varchar('title', { length: 256 }).notNull(),
slug: varchar('slug', { length: 256 }).notNull(),
content: text('content').default(''),
status: blogArticleStatus('status').notNull().default('draft'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
},
(table) => [unique().on(table.slug)]
);
// CatchEmAll
export const availability = pgEnum('availability', availabilityValues);
export const pokemon = pgTable('pokemon', {
id: serial('id').primaryKey(),
number: integer('number').notNull(),
generation: integer('generation').notNull().default(1),
name: varchar('name', { length: 256 }).notNull(),
type1: varchar('type1', { length: 256 }).notNull(),
type2: varchar('type2', { length: 256 }),
hp: integer('hp').notNull().default(0),
attack: integer('attack').notNull().default(0),
defense: integer('defense').notNull().default(0),
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0)
});
export const game = pgTable('game', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 256 }).notNull(),
generation: integer('generation').notNull().default(1)
});
export const pokemonGameEntry = pgTable(
'pokemon_game_entry',
{
id: serial('id').primaryKey(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => game.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
note: varchar('note', { length: 256 })
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const userEntry = pgTable(
'user_entry',
{
id: serial('id').primaryKey(),
pokemonGameEntryId: integer('pokemon_game_entry_id')
.notNull()
.references(() => pokemonGameEntry.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
);
// which games a user has chosen to track ("rules")
export const userGame = pgTable(
'user_game',
{
id: serial('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => game.id, { onDelete: 'cascade' })
},
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
);
// Animaldex
export const animals = pgTable('animals', {
id: uuid('id').primaryKey().defaultRandom(),
species: text('species').notNull(),
breed: text('breed'),
animalName: text('animal_name'),
description: text('description'),
// AI detection stored on the animal (from the first/primary photo)
aiBreedSuggestion: text('ai_breed_suggestion'),
aiBreedConfidence: real('ai_breed_confidence'),
// Moderation — the animal entity is moderated once.
// Accepted = visible on map (as long as it has accepted sightings too).
// Denied animals are hidden but not deleted.
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
deniedAt: timestamp('denied_at', { withTimezone: true })
});
export const sightings = pgTable('sightings', {
id: uuid('id').primaryKey().defaultRandom(),
animalId: uuid('animal_id')
.notNull()
.references(() => animals.id, { onDelete: 'cascade' }),
reporterName: text('reporter_name'),
seenAt: timestamp('seen_at', { withTimezone: true }).notNull(),
lat: doublePrecision('lat').notNull(),
lng: doublePrecision('lng').notNull(),
submittedAt: timestamp('submitted_at', { withTimezone: true }).notNull().defaultNow(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
deniedAt: timestamp('denied_at', { withTimezone: true })
});
export const photos = pgTable('photos', {
id: uuid('id').primaryKey().defaultRandom(),
sightingId: uuid('sighting_id')
.notNull()
.references(() => sightings.id, { onDelete: 'cascade' }),
r2Key: text('r2_key').notNull(),
url: text('url').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow()
});
export type Pokemon = typeof pokemon.$inferSelect;
export type Game = typeof game.$inferSelect;
export type PokemonGameEntry = typeof pokemonGameEntry.$inferSelect;
export type UserEntry = typeof userEntry.$inferSelect;
export type UserGame = typeof userGame.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];
export type Animal = typeof animals.$inferSelect;
export type NewAnimal = typeof animals.$inferInsert;
export type Sighting = typeof sightings.$inferSelect;
export type NewSighting = typeof sightings.$inferInsert;
export type Photo = typeof photos.$inferSelect;
// ── Helpers ───────────────────────────────────────────────────────────────────
export type ModerationStatus = 'pending' | 'accepted' | 'denied';
export function getModerationStatus(
row: Pick<Animal | Sighting, 'acceptedAt' | 'deniedAt'>
): ModerationStatus {
if (row.deniedAt) return 'denied';
if (row.acceptedAt) return 'accepted';
return 'pending';
}
export * from './auth.schema';

View File

@@ -0,0 +1,69 @@
import { doublePrecision, integer, pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps, timestamptz } from './helpers';
export const animals = pgTable('animals', {
id: id(),
species: text('species').notNull(),
breed: text('breed'),
animalName: text('animal_name'),
description: text('description'),
// AI detection stored on the animal (from the first/primary photo)
aiBreedSuggestion: text('ai_breed_suggestion'),
aiBreedConfidence: doublePrecision('ai_breed_confidence'),
// Moderation — the animal entity is moderated once.
// Accepted = visible on map (as long as it has accepted sightings too).
// Denied animals are hidden but not deleted.
submittedAt: timestamptz('submitted_at').notNull().defaultNow(),
acceptedAt: timestamptz('accepted_at'),
deniedAt: timestamptz('denied_at'),
...timestamps
});
export const sightings = pgTable('sightings', {
id: id(),
animalId: integer('animal_id')
.notNull()
.references(() => animals.id, { onDelete: 'cascade' }),
reporterName: text('reporter_name'),
seenAt: timestamptz('seen_at').notNull(),
lat: doublePrecision('lat').notNull(),
lng: doublePrecision('lng').notNull(),
submittedAt: timestamptz('submitted_at').notNull().defaultNow(),
acceptedAt: timestamptz('accepted_at'),
deniedAt: timestamptz('denied_at'),
...timestamps
});
export const photos = pgTable('photos', {
id: id(),
sightingId: integer('sighting_id')
.notNull()
.references(() => sightings.id, { onDelete: 'cascade' }),
r2Key: text('r2_key').notNull(),
url: text('url').notNull(),
sortOrder: integer('sort_order').notNull().default(0),
uploadedAt: timestamptz('uploaded_at').notNull().defaultNow(),
...timestamps
});
export type Animal = typeof animals.$inferSelect;
export type NewAnimal = typeof animals.$inferInsert;
export type Sighting = typeof sightings.$inferSelect;
export type NewSighting = typeof sightings.$inferInsert;
export type Photo = typeof photos.$inferSelect;
export type ModerationStatus = 'pending' | 'accepted' | 'denied';
export function getModerationStatus(
row: Pick<Animal | Sighting, 'acceptedAt' | 'deniedAt'>
): ModerationStatus {
if (row.deniedAt) return 'denied';
if (row.acceptedAt) return 'accepted';
return 'pending';
}

View File

@@ -1,13 +1,14 @@
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
import { pgTable, text, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
role: text('role').notNull().default('user'),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
@@ -17,10 +18,10 @@ export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
@@ -37,6 +38,7 @@ export const account = pgTable(
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
issuer: text('issuer').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
@@ -44,16 +46,19 @@ export const account = pgTable(
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
(table) => [
index('account_userId_idx').on(table.userId),
uniqueIndex('account_issuer_account_id_unique').on(table.issuer, table.accountId)
]
);
export const verification = pgTable(
@@ -62,9 +67,9 @@ export const verification = pgTable(
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()

View File

@@ -0,0 +1,22 @@
import { pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
export const blogArticles = pgTable(
'blog_articles',
{
id: id(),
title: text('title').notNull(),
slug: text('slug').notNull(),
content: text('content').notNull().default(''),
tags: text('tags').array().notNull().default([]),
status: blogArticleStatus('status').notNull().default('draft'),
...timestamps
},
(table) => [unique().on(table.slug)]
);
export type BlogArticle = typeof blogArticles.$inferSelect;
export type NewBlogArticle = typeof blogArticles.$inferInsert;
export type BlogArticleStatus = (typeof blogArticleStatus.enumValues)[number];

View File

@@ -0,0 +1,96 @@
import { index, integer, pgEnum, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { availabilityValues } from '../../../availability';
import { user } from './auth';
import { id, timestamps } from './helpers';
export const availability = pgEnum('availability', availabilityValues);
export const pokemon = pgTable('pokemon', {
id: id(),
number: integer('number').notNull().unique(),
generation: integer('generation').notNull().default(1),
name: text('name').notNull(),
type1: text('type1').notNull(),
type2: text('type2'),
hp: integer('hp').notNull().default(0),
attack: integer('attack').notNull().default(0),
defense: integer('defense').notNull().default(0),
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0),
...timestamps
});
export const games = pgTable('games', {
id: id(),
sourceId: integer('source_id').unique(),
name: text('name').notNull(),
generation: integer('generation').notNull().default(1),
...timestamps
});
export const pokemonGameEntries = pgTable(
'pokemon_game_entries',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// optional extra detail for the tooltip, e.g. "trade with a Blue version owner"
note: text('note'),
...timestamps
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const userEntries = pgTable(
'user_entries',
{
id: id(),
pokemonGameEntryId: integer('pokemon_game_entry_id')
.notNull()
.references(() => pokemonGameEntries.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
);
// which games a user has chosen to track ("rules")
export const userGames = pgTable(
'user_games',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
);
// A row distinguishes an explicit empty game selection from a new account.
export const catchEmAllSettings = pgTable('catch_em_all_settings', {
id: id(),
userId: text('user_id')
.notNull()
.unique()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
});
export type Pokemon = typeof pokemon.$inferSelect;
export type Game = typeof games.$inferSelect;
export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
export type UserEntry = typeof userEntries.$inferSelect;
export type UserGame = typeof userGames.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];

View File

@@ -0,0 +1,17 @@
import { serial, timestamp } from 'drizzle-orm/pg-core';
// Shared column patterns so every table is built the same way.
// Timestamps are always `timestamptz` — they represent a moment in time, not a
// wall-clock reading.
export const id = () => serial('id').primaryKey();
export const timestamptz = (name: string) => timestamp(name, { withTimezone: true });
export const timestamps = {
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
};

View File

@@ -0,0 +1,8 @@
// One file per domain. Everything is re-exported here so the rest of the app
// (and drizzle-kit / relations) only ever imports `$lib/server/db/schema`.
export * from './auth';
export * from './blog';
export * from './progress';
export * from './catch-em-all';
export * from './animaldex';
export * from './lists';

View File

@@ -0,0 +1,46 @@
import { boolean, index, integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
import { user } from './auth';
export const lists = pgTable('lists', {
id: id(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
isProgress: boolean('is_progress').notNull().default(false),
...timestamps
});
export const listItems = pgTable(
'list_items',
{
id: id(),
listId: integer('list_id')
.notNull()
.references(() => lists.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
pictureUrl: text('picture_url'),
description: text('description'),
...timestamps
},
(table) => [index().on(table.listId)]
);
export const listItemCompletions = pgTable(
'list_item_completions',
{
id: id(),
itemId: integer('item_id')
.notNull()
.references(() => listItems.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.itemId, table.userId), index().on(table.userId)]
);
export type List = typeof lists.$inferSelect;
export type NewList = typeof lists.$inferInsert;
export type ListItem = typeof listItems.$inferSelect;
export type NewListItem = typeof listItems.$inferInsert;

View File

@@ -0,0 +1,38 @@
import { integer, pgTable, text, unique } from 'drizzle-orm/pg-core';
import { id, timestamps } from './helpers';
export const tasks = pgTable('tasks', {
id: id(),
name: text('name').notNull(),
currentPoints: integer('current_points').notNull().default(0),
totalPoints: integer('total_points').notNull().default(1),
notes: text('notes'),
...timestamps
});
export const topics = pgTable('topics', {
id: id(),
name: text('name').notNull(),
emoji: text('emoji'),
...timestamps
});
export const tasksToTopics = pgTable(
'tasks_to_topics',
{
id: id(),
taskId: integer('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
topicId: integer('topic_id')
.notNull()
.references(() => topics.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.taskId, table.topicId)]
);
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
export type Topic = typeof topics.$inferSelect;
export type NewTopic = typeof topics.$inferInsert;

View File

@@ -0,0 +1,119 @@
export class ApiError extends Error {
constructor(
public provider: string,
public status: number,
message: string
) {
super(message);
}
}
export function publicError(error: unknown): string {
return error instanceof ApiError
? error.message
: 'Search could not be completed. Please try again.';
}
export function logApiError(error: unknown) {
console.error(
'Map finder request failed',
error instanceof ApiError
? { provider: error.provider, status: error.status }
: { type: 'unexpected' }
);
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const cooldowns = new Map<string, number>();
export async function requestJson(
fetchFn: typeof fetch,
provider: string,
url: string,
init: RequestInit = {},
wait = sleep
): Promise<unknown> {
for (let attempt = 0; attempt < 3; attempt++) {
if ((cooldowns.get(provider) ?? 0) > Date.now())
throw new ApiError(provider, 429, `${provider} is rate-limited. Please try again later.`);
let response: Response;
try {
response = await fetchFn(url, { ...init, signal: AbortSignal.timeout(12000) });
} catch {
if (attempt < 2) {
await wait(300 * 2 ** attempt + Math.random() * 150);
continue;
}
throw new ApiError(provider, 503, `${provider} is unavailable. Please try again later.`);
}
if (response.ok) {
try {
return await response.json();
} catch {
throw new ApiError(
provider,
502,
`${provider} returned an invalid response. Please try again.`
);
}
}
if (response.status === 429 || response.status >= 500) {
const retry = response.headers.get('Retry-After');
const delay = retry
? /^\d+(\.\d+)?$/.test(retry)
? Number(retry) * 1000
: Math.max(0, Date.parse(retry) - Date.now())
: 300 * 2 ** attempt;
const boundedDelay = Number.isFinite(delay) ? delay : 1000;
if (response.status === 429) cooldowns.set(provider, Date.now() + boundedDelay);
if (attempt < 2 && boundedDelay <= 4000) {
await wait(boundedDelay + Math.random() * 150);
continue;
}
}
throw new ApiError(
provider,
response.status,
response.status === 429
? `${provider} is rate-limited. Please try again later.`
: `${provider} request failed. Please try again later.`
);
}
throw new ApiError(provider, 503, `${provider} is unavailable.`);
}
export function createTokenCache() {
let token: { value: string; expires: number } | undefined;
let pending: Promise<string> | undefined;
return async (refresh: () => Promise<unknown>): Promise<string> => {
if (token && token.expires > Date.now() + 30000) return token.value;
if (!pending)
pending = (async () => {
const result = (await refresh()) as { access_token?: unknown; expires_in?: unknown };
if (
typeof result?.access_token !== 'string' ||
typeof result.expires_in !== 'number' ||
result.expires_in <= 0
)
throw new ApiError('OAuth', 502, 'API authentication is temporarily unavailable.');
token = { value: result.access_token, expires: Date.now() + result.expires_in * 1000 };
return token.value;
})().finally(() => {
pending = undefined;
});
return pending;
};
}
export async function mapConcurrent<T, R>(
items: T[],
limit: number,
work: (item: T) => Promise<R>
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) throw new Error('Concurrency must be positive.');
const results: R[] = new Array(items.length);
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor++;
results[index] = await work(items[index]);
}
})
);
return results;
}

View File

@@ -0,0 +1,28 @@
export const RATE_WINDOW_MS = 10 * 60_000;
export const RATE_BUDGET = 12;
export function createRateLimiter() {
let activeSearches = 0;
const records = new Map<string, { expires: number; used: number; active: boolean }>();
return (key: string, cost: number, now = Date.now()) => {
// Opportunistic cleanup on every action; no timers keep the server alive.
for (const [id, entry] of records)
if (entry.expires <= now && !entry.active) records.delete(id);
let entry = records.get(key);
if (!entry) {
if (records.size >= 10000) return null;
entry = { expires: now + RATE_WINDOW_MS, used: 0, active: false };
records.set(key, entry);
}
if (activeSearches >= 6 || entry.active || entry.used + cost > RATE_BUDGET) return null;
entry.used += cost;
entry.active = true;
activeSearches++;
return () => {
if (entry.active) {
entry.active = false;
activeSearches--;
}
};
};
}
export const acquireSearch = createRateLimiter();

View File

@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { SPOTIFY_CLIENT_ID: 'test', SPOTIFY_CLIENT_SECRET: 'test' }
}));
import { createTokenCache, mapConcurrent, requestJson } from './finder-http';
import { createRateLimiter, RATE_WINDOW_MS } from './finder-limit';
import { getSpotifyMetadata, playlistTracks, MAX_PLAYLIST_TRACKS } from './spotify';
const id = (n: number) => String(n).padStart(22, '0');
const track = (n: number) => ({
type: 'track',
id: id(n),
name: `Song ${n}`,
artists: [{ name: 'Artist' }],
album: { name: 'Album', images: [] }
});
afterEach(() => vi.useRealTimers());
describe('request controls', () => {
it('reuses tokens, coalesces refreshes and refreshes near expiry', async () => {
vi.useFakeTimers();
const cache = createTokenCache();
const refresh = vi.fn(async () => ({ access_token: 'token', expires_in: 60 }));
expect(await Promise.all([cache(refresh), cache(refresh)])).toEqual(['token', 'token']);
await cache(refresh);
expect(refresh).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(31000);
await cache(refresh);
expect(refresh).toHaveBeenCalledTimes(2);
});
it('bounds concurrency and preserves order', async () => {
let active = 0,
peak = 0;
const result = await mapConcurrent([3, 2, 1, 0], 2, async (n) => {
active++;
peak = Math.max(peak, active);
await new Promise((r) => setTimeout(r, n));
active--;
return n * 2;
});
expect(peak).toBe(2);
expect(result).toEqual([6, 4, 2, 0]);
});
it('retries 429 after Retry-After', async () => {
vi.useFakeTimers();
const fetchFn = vi
.fn()
.mockResolvedValueOnce(new Response('', { status: 429, headers: { 'Retry-After': '1' } }))
.mockResolvedValueOnce(Response.json({ ok: true }));
const wait = vi.fn(async (ms: number) => {
vi.advanceTimersByTime(ms);
});
expect(await requestJson(fetchFn, 'retry-test', 'https://example.test', {}, wait)).toEqual({
ok: true
});
expect(wait.mock.calls[0][0]).toBeGreaterThanOrEqual(1000);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('does not retry 404 or malformed JSON', async () => {
const fetchFn = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
await expect(requestJson(fetchFn, 'test', 'https://example.test')).rejects.toMatchObject({
status: 404
});
expect(fetchFn).toHaveBeenCalledTimes(1);
await expect(
requestJson(
vi.fn().mockResolvedValue(new Response('invalid')),
'test',
'https://example.test'
)
).rejects.toMatchObject({ status: 502 });
});
it('limits server retries and respects long cooldowns without sleeping', async () => {
const fetchFn = vi.fn().mockImplementation(async () => new Response('', { status: 503 }));
await expect(
requestJson(fetchFn, 'server-test', 'https://example.test', {}, async () => {})
).rejects.toMatchObject({ status: 503 });
expect(fetchFn).toHaveBeenCalledTimes(3);
const throttled = vi
.fn()
.mockResolvedValue(new Response('', { status: 429, headers: { 'Retry-After': '60' } }));
await expect(
requestJson(throttled, 'long-retry', 'https://example.test')
).rejects.toMatchObject({ status: 429 });
expect(throttled).toHaveBeenCalledTimes(1);
});
it('limits weighted searches, concurrent requests and expires records', () => {
const acquire = createRateLimiter();
const release = acquire('ip', 6, 0)!;
expect(acquire('ip', 1, 1)).toBeNull();
release();
acquire('ip', 6, 2)!();
expect(acquire('ip', 1, 3)).toBeNull();
expect(acquire('ip', 6, RATE_WINDOW_MS + 1)).toBeTypeOf('function');
});
});
describe('playlists', () => {
it('skips unusable entries and deduplicates in order', () =>
expect(
playlistTracks([
{ track: track(2) },
{ track: null },
{ track: { ...track(3), is_local: true } },
{ track: { ...track(4), type: 'episode' } },
{ track: { ...track(5), is_playable: false } },
{ track: track(2) },
{ item: track(1) },
{ track: { id: id(7) } }
]).map((t) => t.id)
).toEqual([id(2), id(1)]));
it('paginates, deduplicates across pages and truncates', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) => {
const value = String(url);
if (value.includes('/api/token'))
return Response.json({ access_token: 'token', expires_in: 3600 });
if (value.includes('offset=2'))
return Response.json({
items: Array.from({ length: 55 }, (_, n) => ({ track: track(n + 1) })),
next: null,
total: 57
});
return Response.json({
name: 'Playlist',
owner: { display_name: 'Owner' },
tracks: {
items: [{ track: track(1) }, { track: null }],
next: 'https://evil.test',
total: 57
}
});
});
const result = await getSpotifyMetadata(fetchFn as typeof fetch, {
kind: 'playlist',
id: id(1)
});
expect(result.tracks).toHaveLength(MAX_PLAYLIST_TRACKS);
expect(result.tracks[0].id).toBe(id(1));
expect(result.tracks[1].id).toBe(id(2));
expect(result.truncated).toBe(true);
expect(fetchFn.mock.calls.every(([url]) => !String(url).includes('evil.test'))).toBe(true);
});
it('supports the newer items/item response', async () => {
const fetchFn = vi.fn(async () =>
Response.json({ name: 'New playlist', items: { items: [{ item: track(1) }], next: null } })
);
const result = await getSpotifyMetadata(fetchFn, { kind: 'playlist', id: id(1) });
expect(result.tracks).toHaveLength(1);
expect(result.truncated).toBe(false);
});
});
it('paginates album tracks and attaches shared album metadata', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) =>
String(url).includes('offset=1')
? Response.json({ items: [track(2)], next: null, total: 2 })
: Response.json({
name: 'Album name',
artists: [{ name: 'Artist' }],
images: [{ url: 'https://example.test/cover.jpg' }],
tracks: { items: [track(1)], next: 'next', total: 2 }
})
);
const result = await getSpotifyMetadata(fetchFn as typeof fetch, { kind: 'album', id: id(1) });
expect(result.collection).toMatchObject({ kind: 'album', name: 'Album name', owner: 'Artist' });
expect(result.tracks.map((t) => t.id)).toEqual([id(1), id(2)]);
expect(result.tracks[1].album.name).toBe('Album name');
expect(result.truncated).toBe(false);
expect(String(fetchFn.mock.calls[1][0])).toContain('/albums/');
});

View File

@@ -0,0 +1,20 @@
import { expect, it } from 'vitest';
import { osuProfile } from './osu-profile';
it('binds osu accounts to numeric provider IDs, never a claimed email or username', () => {
const profile = osuProfile({
id: 123,
username: 'Alice',
avatar_url: 'https://a.ppy.sh/123',
email: 'admin@example.com',
email_verified: true
});
expect(profile).toMatchObject({ id: '123', email: '123@osu.invalid', emailVerified: false });
expect(
osuProfile({ id: 123, username: 'Renamed', avatar_url: 'https://a.ppy.sh/123' }).email
).toBe(profile.email);
});
it('rejects malformed provider identities', () => {
expect(() =>
osuProfile({ id: 'admin', username: 'Alice', avatar_url: 'https://a.ppy.sh/123' })
).toThrow();
});

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
const profileSchema = z.object({
id: z.number().int().positive(),
username: z.string().min(1),
avatar_url: z.string().url()
});
export function osuProfile(value: unknown) {
const profile = profileSchema.parse(value);
return {
id: String(profile.id),
name: profile.username,
image: profile.avatar_url,
email: `${profile.id}@osu.invalid`,
emailVerified: false
};
}

View File

@@ -0,0 +1,37 @@
import { expect, it, vi } from 'vitest';
vi.mock('$env/dynamic/private', () => ({ env: { OSU_CLIENT_ID: '1', OSU_CLIENT_SECRET: 'test' } }));
import { getOsuMaps } from './osu';
it('falls back deliberately, merges IDs, reuses OAuth and caches matching results', async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) => {
if (String(url).includes('/oauth/token'))
return Response.json({ access_token: 'token', expires_in: 3600 });
const query = new URL(String(url)).searchParams.get('q');
return Response.json({
beatmapsets: [
{
id: 1,
title: 'Unique Song',
artist: query === 'unique song' ? 'Artist' : 'Wrong Performer'
}
]
});
});
const track = {
id: '6PUzxtIHkv346yP89NzP9X',
name: 'Unique Song',
artists: [{ name: 'Artist' }],
album: { name: '', images: [] }
};
const results = await Promise.all([
getOsuMaps(fetchFn as typeof fetch, track),
getOsuMaps(fetchFn as typeof fetch, track)
]);
expect(results[0]).toHaveLength(1);
expect(results[0][0].score).toBe(100);
expect(results[1]).toEqual(results[0]);
await getOsuMaps(fetchFn as typeof fetch, track);
expect(fetchFn).toHaveBeenCalledTimes(3);
expect(fetchFn.mock.calls.filter(([url]) => String(url).includes('/oauth/token'))).toHaveLength(
1
);
});

90
src/lib/server/osu.ts Normal file
View File

@@ -0,0 +1,90 @@
import { env } from '$env/dynamic/private';
import type { OsuBeatmapset, ScoredBeatmapset, SpotifyTrack } from '$lib/map-finder/types';
import { cleanTitle, normalize, rankCandidates, MEDIUM_CONFIDENCE } from '$lib/map-finder/matching';
import { ApiError, createTokenCache, requestJson } from './finder-http';
const token = createTokenCache();
const cache = new Map<string, { expires: number; result: ScoredBeatmapset[] }>();
const pending = new Map<string, Promise<ScoredBeatmapset[]>>();
export const MATCH_CONCURRENCY = 3;
// Global permits also bound outbound work across simultaneous visitors.
let active = 0;
const queue: (() => void)[] = [];
async function limited<T>(work: () => Promise<T>): Promise<T> {
if (active >= MATCH_CONCURRENCY) await new Promise<void>((resolve) => queue.push(resolve));
else active++;
try {
return await work();
} finally {
const next = queue.shift();
if (next) next();
else active--;
}
}
async function search(fetchFn: typeof fetch, query: string): Promise<OsuBeatmapset[]> {
const access = await token(() => {
if (!env.OSU_CLIENT_ID || !env.OSU_CLIENT_SECRET)
throw new ApiError('osu!', 503, 'osu! is not configured on this server.');
return requestJson(fetchFn, 'osu!', 'https://osu.ppy.sh/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: Number(env.OSU_CLIENT_ID),
client_secret: env.OSU_CLIENT_SECRET,
grant_type: 'client_credentials',
scope: 'public'
})
});
});
const response = (await requestJson(
fetchFn,
'osu!',
`https://osu.ppy.sh/api/v2/beatmapsets/search?${new URLSearchParams({ q: query, s: 'any' })}`,
{ headers: { Authorization: `Bearer ${access}`, Accept: 'application/json' } }
)) as { beatmapsets?: unknown[] };
if (!Array.isArray(response?.beatmapsets))
throw new ApiError('osu!', 502, 'osu! returned an invalid search response.');
return response.beatmapsets
.filter(
(item): item is OsuBeatmapset =>
!!item &&
typeof item === 'object' &&
Number.isInteger((item as OsuBeatmapset).id) &&
typeof (item as OsuBeatmapset).title === 'string' &&
typeof (item as OsuBeatmapset).artist === 'string'
)
.map((item) => ({
id: item.id,
title: item.title,
artist: item.artist,
title_unicode: typeof item.title_unicode === 'string' ? item.title_unicode : undefined,
artist_unicode: typeof item.artist_unicode === 'string' ? item.artist_unicode : undefined,
status: typeof item.status === 'string' ? item.status : undefined,
covers: { card: typeof item.covers?.card === 'string' ? item.covers.card : undefined },
beatmaps: Array.isArray(item.beatmaps) ? item.beatmaps.map(() => ({ id: 0 })) : undefined
}));
}
export async function getOsuMaps(
fetchFn: typeof fetch,
track: SpotifyTrack
): Promise<ScoredBeatmapset[]> {
const key = JSON.stringify([normalize(track.name), track.artists.map((a) => normalize(a.name))]);
for (const [key, entry] of cache) if (entry.expires <= Date.now()) cache.delete(key);
const hit = cache.get(key);
if (hit) return hit.result;
const existing = pending.get(key);
if (existing) return existing;
const work = limited(async () => {
const maps = await search(fetchFn, `${track.artists[0].name} ${track.name}`);
let ranked = rankCandidates(track, maps);
if (!ranked.some((m) => m.score >= MEDIUM_CONFIDENCE))
ranked = rankCandidates(track, [
...maps,
...(await search(fetchFn, cleanTitle(track.name) || track.name))
]);
if (cache.size >= 500) cache.delete(cache.keys().next().value!);
cache.set(key, { expires: Date.now() + 5 * 60_000, result: ranked });
return ranked;
}).finally(() => pending.delete(key));
pending.set(key, work);
return work;
}

View File

@@ -1,5 +1,4 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { env } from '$env/dynamic/private';
import { randomUUID } from 'crypto';
@@ -44,38 +43,41 @@ const EXT: Record<AllowedMimeType, string> = {
'image/heic': 'heic'
};
export interface PresignedUpload {
/** R2 object key — store this in the DB */
key: string;
/** One-time presigned PUT URL — browser PUTs file directly here */
uploadUrl: string;
/** Permanent public URL to store in photos table */
publicUrl: string;
}
/**
* Generate a presigned PUT URL for a single photo.
*
* Flow:
* 1. Client POSTs to /api/upload/presign → gets { key, uploadUrl, publicUrl }
* 2. Client PUTs file to uploadUrl (direct to R2, server never sees the bytes)
* 3. Client submits sighting with photo keys included
* 4. Server stores key + publicUrl in photos table
*/
export async function createPresignedUpload(mimeType: AllowedMimeType): Promise<PresignedUpload> {
const key = `sightings/${randomUUID()}.${EXT[mimeType]}`;
const uploadUrl = await getSignedUrl(
getClient(),
/** Upload through the site server so browsers do not need R2 CORS access. */
export async function uploadImage(
file: File,
prefix: 'sightings' | 'lists' = 'sightings'
): Promise<{ key: string; publicUrl: string }> {
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
const mimeType = file.type;
const key = `${prefix}/${randomUUID()}.${EXT[mimeType]}`;
await getClient().send(
new PutObjectCommand({
Bucket: env.R2_BUCKET,
Key: key,
ContentType: mimeType
}),
{ expiresIn: 3600 }
ContentType: mimeType,
Body: new Uint8Array(await file.arrayBuffer())
})
);
return { key, publicUrl: `${env.R2_PUBLIC_URL!.replace(/\/$/, '')}/${key}` };
}
return { key, uploadUrl, publicUrl: `${env.R2_PUBLIC_URL}/${key}` };
export function listImageUrl(key: string): string | null {
if (
!/^lists\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|png|webp)$/.test(
key
)
)
return null;
if (!env.R2_PUBLIC_URL) return null;
return `${env.R2_PUBLIC_URL.replace(/\/$/, '')}/${key}`;
}
export function listImageKey(url: string): string | null {
const base = env.R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;
const key = url.slice(base.length + 1);
return listImageUrl(key) === url ? key : null;
}
/**

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { slugify, validSlug } from './slugify';
describe('content slugs', () => {
it('turns titles into readable URL segments', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
expect(slugify(' A / B ')).toBe('a-b');
expect(slugify('!!!', 'list')).toBe('list');
});
it('accepts only canonical editable slugs', () => {
expect(validSlug('my-list-2026')).toBe(true);
for (const value of ['My-List', 'two words', '-leading', 'trailing-', 'double--dash', '']) {
expect(validSlug(value)).toBe(false);
}
});
});

View File

@@ -1,14 +1,14 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { blogArticles, lists } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export function slugify(title: string): string {
export function slugify(title: string, fallback = 'article'): string {
return (
title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'article'
.replace(/^-+|-+$/g, '') || fallback
);
}
@@ -27,3 +27,17 @@ export async function uniqueSlug(title: string): Promise<string> {
return slug;
}
export async function uniqueListSlug(name: string): Promise<string> {
const base = slugify(name, 'list');
let slug = base;
let suffix = 2;
while ((await db.select({ id: lists.id }).from(lists).where(eq(lists.slug, slug))).length > 0) {
slug = `${base}-${suffix++}`;
}
return slug;
}
export function validSlug(slug: string): boolean {
return slug.length <= 120 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
}

188
src/lib/server/spotify.ts Normal file
View File

@@ -0,0 +1,188 @@
import { env } from '$env/dynamic/private';
import type { SpotifyInput } from '$lib/map-finder/input';
import type { SpotifyTrack, SpotifyCollection } from '$lib/map-finder/types';
import { ApiError, createTokenCache, requestJson } from './finder-http';
export const MAX_PLAYLIST_TRACKS = 50;
export const MAX_PLAYLIST_PAGES = 10;
const token = createTokenCache();
type RecordValue = Record<string, unknown>;
function record(value: unknown): RecordValue {
return value && typeof value === 'object' ? (value as RecordValue) : {};
}
export function usableTrack(value: unknown): SpotifyTrack | null {
const track = record(value),
album = record(track.album);
if (
track.type !== 'track' ||
track.is_local ||
track.is_playable === false ||
typeof track.id !== 'string' ||
!/^[a-zA-Z0-9]{22}$/.test(track.id) ||
typeof track.name !== 'string' ||
!track.name.trim()
)
return null;
const artists = Array.isArray(track.artists)
? track.artists
.map(record)
.filter((a) => typeof a.name === 'string' && a.name.trim())
.map((a) => ({ name: a.name as string }))
: [];
if (!artists.length) return null;
const images = Array.isArray(album.images)
? album.images
.map(record)
.filter((i) => typeof i.url === 'string' && i.url.startsWith('https://'))
.map((i) => ({ url: i.url as string }))
: [];
return {
id: track.id,
name: track.name,
artists,
album: { name: typeof album.name === 'string' ? album.name : '', images }
};
}
export function playlistTracks(items: unknown[], seen = new Set<string>()): SpotifyTrack[] {
const result: SpotifyTrack[] = [];
for (const entry of items) {
const item = record(entry);
if (item.is_local) continue;
const track = usableTrack(item.track ?? item.item);
if (track && !seen.has(track.id)) {
seen.add(track.id);
result.push(track);
}
}
return result;
}
async function spotify(fetchFn: typeof fetch, path: string) {
const access = await token(() => {
if (!env.SPOTIFY_CLIENT_ID || !env.SPOTIFY_CLIENT_SECRET)
throw new ApiError('Spotify', 503, 'Spotify is not configured on this server.');
return requestJson(fetchFn, 'Spotify', 'https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`)}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({ grant_type: 'client_credentials' })
});
});
return record(
await requestJson(fetchFn, 'Spotify', `https://api.spotify.com/v1/${path}`, {
headers: { Authorization: `Bearer ${access}` }
})
);
}
export async function getSpotifyMetadata(
fetchFn: typeof fetch,
input:
| Extract<SpotifyInput, { kind: 'track' | 'playlist' | 'album' }>
| Extract<SpotifyInput, { kind: 'search' }>
): Promise<{ tracks: SpotifyTrack[]; collection?: SpotifyCollection; truncated: boolean }> {
if (input.kind === 'track' || input.kind === 'search') {
let response: RecordValue;
try {
response = await spotify(
fetchFn,
input.kind === 'track'
? `tracks/${input.id}`
: `search?${new URLSearchParams({ q: input.query, type: 'track', limit: '1' })}`
);
} catch (error) {
if (error instanceof ApiError && error.status === 404)
throw new ApiError('Spotify', 404, 'Spotify track not found.');
throw error;
}
const track = usableTrack(
input.kind === 'track'
? response
: (record(response.tracks).items as unknown[] | undefined)?.[0]
);
if (!track)
throw new ApiError(
'Spotify',
404,
input.kind === 'track'
? 'Spotify track is unavailable.'
: 'No Spotify text-search result. Try an artist and song title.'
);
return { tracks: [track], truncated: false };
}
try {
const isAlbum = input.kind === 'album';
const resource = isAlbum ? 'albums' : 'playlists';
const metadata = await spotify(fetchFn, `${resource}/${input.id}`);
if (typeof metadata.name !== 'string')
throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist metadata.');
const owner = record(metadata.owner),
images = metadata.images;
const collection: SpotifyCollection = {
id: input.id,
kind: input.kind,
name: metadata.name,
owner:
isAlbum && Array.isArray(metadata.artists)
? metadata.artists
.map(record)
.map((a) => (typeof a.name === 'string' ? a.name : ''))
.filter(Boolean)
.join(', ')
: typeof owner.display_name === 'string'
? owner.display_name
: '',
image:
Array.isArray(images) && typeof record(images[0]).url === 'string'
? (record(images[0]).url as string)
: undefined
};
// Extended-quota responses use tracks/track; newer development-mode responses use items/item.
const modern = metadata.items !== undefined;
let page = record(modern ? metadata.items : metadata.tracks);
let offset = 0;
const tracks: SpotifyTrack[] = [];
const seen = new Set<string>();
for (let pages = 0; pages < MAX_PLAYLIST_PAGES; pages++) {
if (!Array.isArray(page.items))
page = await spotify(
fetchFn,
`${resource}/${input.id}/${modern ? 'items' : 'tracks'}?limit=50&offset=${offset}`
);
if (!Array.isArray(page.items))
throw new ApiError(
'Spotify',
502,
'Spotify playlist tracks are unavailable for this application.'
);
const usable = playlistTracks(
isAlbum
? page.items.map((item) => ({
track: { ...record(item), album: { name: metadata.name, images: metadata.images } }
}))
: page.items,
seen
);
const room = MAX_PLAYLIST_TRACKS - tracks.length;
tracks.push(...usable.slice(0, room));
offset += page.items.length;
const more = Boolean(page.next) || (typeof page.total === 'number' && offset < page.total);
if (tracks.length >= MAX_PLAYLIST_TRACKS || !more || pages === MAX_PLAYLIST_PAGES - 1)
return { tracks, collection, truncated: usable.length > room || more };
if (!page.items.length)
throw new ApiError('Spotify', 502, 'Spotify returned incomplete playlist tracks.');
// Construct pagination locally: never forward credentials to an upstream-supplied URL.
page = {};
}
return { tracks, collection, truncated: true };
} catch (error) {
if (error instanceof ApiError && [401, 403, 404].includes(error.status))
throw new ApiError(
'Spotify',
error.status,
input.kind === 'album'
? 'Spotify album unavailable or not found. Try an individual track.'
: 'Spotify playlist unavailable or private, or this application does not have playlist access. Try a public playlist or an individual track.'
);
throw error;
}
}