This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
22
src/lib/server/authorization.test.ts
Normal file
22
src/lib/server/authorization.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
src/lib/server/authorization.ts
Normal file
18
src/lib/server/authorization.ts
Normal 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;
|
||||
}
|
||||
35
src/lib/server/catalog.test.ts
Normal file
35
src/lib/server/catalog.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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';
|
||||
69
src/lib/server/db/schema/animaldex.ts
Normal file
69
src/lib/server/db/schema/animaldex.ts
Normal 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';
|
||||
}
|
||||
@@ -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()
|
||||
22
src/lib/server/db/schema/blog.ts
Normal file
22
src/lib/server/db/schema/blog.ts
Normal 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];
|
||||
96
src/lib/server/db/schema/catch-em-all.ts
Normal file
96
src/lib/server/db/schema/catch-em-all.ts
Normal 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];
|
||||
17
src/lib/server/db/schema/helpers.ts
Normal file
17
src/lib/server/db/schema/helpers.ts
Normal 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())
|
||||
};
|
||||
8
src/lib/server/db/schema/index.ts
Normal file
8
src/lib/server/db/schema/index.ts
Normal 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';
|
||||
46
src/lib/server/db/schema/lists.ts
Normal file
46
src/lib/server/db/schema/lists.ts
Normal 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;
|
||||
38
src/lib/server/db/schema/progress.ts
Normal file
38
src/lib/server/db/schema/progress.ts
Normal 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;
|
||||
119
src/lib/server/finder-http.ts
Normal file
119
src/lib/server/finder-http.ts
Normal 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;
|
||||
}
|
||||
28
src/lib/server/finder-limit.ts
Normal file
28
src/lib/server/finder-limit.ts
Normal 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();
|
||||
167
src/lib/server/finder.test.ts
Normal file
167
src/lib/server/finder.test.ts
Normal 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/');
|
||||
});
|
||||
20
src/lib/server/osu-profile.test.ts
Normal file
20
src/lib/server/osu-profile.test.ts
Normal 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();
|
||||
});
|
||||
16
src/lib/server/osu-profile.ts
Normal file
16
src/lib/server/osu-profile.ts
Normal 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
|
||||
};
|
||||
}
|
||||
37
src/lib/server/osu.test.ts
Normal file
37
src/lib/server/osu.test.ts
Normal 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
90
src/lib/server/osu.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
17
src/lib/server/slugify.test.ts
Normal file
17
src/lib/server/slugify.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
188
src/lib/server/spotify.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user