fix: build errors and merging projects and homepage
This commit is contained in:
38
src/lib/server/auth.ts
Normal file
38
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
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 type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.ORIGIN,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
database: drizzleAdapter(db, { provider: 'pg', schema }),
|
||||
emailAndPassword: { enabled: true },
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array
|
||||
]
|
||||
});
|
||||
|
||||
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)) {
|
||||
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
50
src/lib/server/breed-detector.ts
Normal file
50
src/lib/server/breed-detector.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: env.ANTHROPIC_API_KEY
|
||||
});
|
||||
|
||||
export interface BreedDetectionResult {
|
||||
breed: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const PROMPT = `You are an expert at identifying animal breeds and species from photos.
|
||||
|
||||
Look at the animal in this image and identify its breed or species as specifically as possible.
|
||||
|
||||
Respond ONLY with a JSON object — no markdown, no explanation:
|
||||
{"breed": "Golden Retriever", "confidence": 0.92}
|
||||
|
||||
Rules:
|
||||
- "breed" should be the most specific correct identification (e.g. "Siberian Husky", not just "dog")
|
||||
- "confidence" is your certainty from 0.0 to 1.0
|
||||
- If you genuinely cannot identify the animal, respond: {"breed": null, "confidence": 0}`;
|
||||
|
||||
export async function detectBreed(imageUrl: string): Promise<BreedDetectionResult | null> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: anthropic('claude-haiku-4-5'),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', image: new URL(imageUrl) },
|
||||
{ type: 'text', text: PROMPT }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(text.trim());
|
||||
|
||||
return {
|
||||
breed: parsed.breed,
|
||||
confidence: Math.min(1, Math.max(0, Number(parsed.confidence) || 0))
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
73
src/lib/server/db/auth.schema.ts
Normal file
73
src/lib/server/db/auth.schema.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
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')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
'session',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' })
|
||||
},
|
||||
(table) => [index('session_userId_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
'account',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index('account_userId_idx').on(table.userId)]
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
'verification',
|
||||
{
|
||||
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')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [index('verification_identifier_idx').on(table.identifier)]
|
||||
);
|
||||
11
src/lib/server/db/index.ts
Executable file
11
src/lib/server/db/index.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { relations } from './relations';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres(env.DB_URL, { prepare: false });
|
||||
|
||||
export const db = drizzle({
|
||||
relations,
|
||||
client
|
||||
});
|
||||
99
src/lib/server/db/relations.ts
Normal file
99
src/lib/server/db/relations.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineRelations } from 'drizzle-orm';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
// ── Progress ──────────────────────────────────
|
||||
tasks: {
|
||||
tasksToTopics: r.many.tasksToTopics()
|
||||
},
|
||||
topics: {
|
||||
topicsToTasks: r.many.tasksToTopics()
|
||||
},
|
||||
tasksToTopics: {
|
||||
task: r.one.tasks({
|
||||
from: r.tasksToTopics.taskId,
|
||||
to: r.tasks.id
|
||||
}),
|
||||
topic: r.one.topics({
|
||||
from: r.tasksToTopics.topicId,
|
||||
to: r.topics.id
|
||||
})
|
||||
},
|
||||
|
||||
// ── CatchEmAll ────────────────────────────────
|
||||
game: {
|
||||
entries: r.many.pokemonGameEntry()
|
||||
},
|
||||
pokemon: {
|
||||
entry: r.many.pokemonGameEntry()
|
||||
},
|
||||
pokemonGameEntry: {
|
||||
game: r.one.game({
|
||||
from: r.pokemonGameEntry.gameId,
|
||||
to: r.game.id
|
||||
}),
|
||||
pokemon: r.one.pokemon({
|
||||
from: r.pokemonGameEntry.pokemonId,
|
||||
to: r.pokemon.id
|
||||
}),
|
||||
userEntries: r.many.userEntry()
|
||||
},
|
||||
userEntry: {
|
||||
pokemonGameEntry: r.one.pokemonGameEntry({
|
||||
from: r.userEntry.pokemonGameEntryId,
|
||||
to: r.pokemonGameEntry.id
|
||||
}),
|
||||
user: r.one.user({
|
||||
from: r.userEntry.userId,
|
||||
to: r.user.id
|
||||
})
|
||||
},
|
||||
userGame: {
|
||||
user: r.one.user({
|
||||
from: r.userGame.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
|
||||
})
|
||||
},
|
||||
|
||||
// ── Animaldex ─────────────────────────────────
|
||||
animals: {
|
||||
sightings: r.many.sightings()
|
||||
},
|
||||
sightings: {
|
||||
animal: r.one.animals({
|
||||
from: r.sightings.animalId,
|
||||
to: r.animals.id
|
||||
}),
|
||||
photos: r.many.photos()
|
||||
},
|
||||
photos: {
|
||||
sighting: r.one.sightings({
|
||||
from: r.photos.sightingId,
|
||||
to: r.sightings.id
|
||||
})
|
||||
}
|
||||
}));
|
||||
232
src/lib/server/db/schema.ts
Executable file
232
src/lib/server/db/schema.ts
Executable file
@@ -0,0 +1,232 @@
|
||||
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 blogArticles = pgTable('blog_articles', {
|
||||
id: serial('id').primaryKey(),
|
||||
title: varchar('title', { length: 256 }),
|
||||
slug: varchar('slug', { length: 256 }),
|
||||
content: varchar('content', { length: 8192 }),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow()
|
||||
});
|
||||
|
||||
// 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 })
|
||||
});
|
||||
|
||||
// The PostGIS `location geography(Point, 4326)` column is managed outside Drizzle
|
||||
// via a trigger in the migration — it auto-syncs from lat/lng on insert/update.
|
||||
// Never write to `location` directly from application code.
|
||||
|
||||
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(),
|
||||
// `location` geography column exists in DB but not mapped in Drizzle — use sql`` for spatial queries
|
||||
|
||||
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';
|
||||
14
src/lib/server/db/seed.ts
Executable file
14
src/lib/server/db/seed.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import { topics } from './schema';
|
||||
import * as schema from './schema';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const client = postgres('postgresql://postgres:postgres@localhost:5432/db');
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
await db.insert(topics).values([
|
||||
{ name: 'Math', emoji: '👍' },
|
||||
{ name: 'Programming', emoji: '👍' }
|
||||
]);
|
||||
|
||||
process.exit(0);
|
||||
86
src/lib/server/r2.ts
Normal file
86
src/lib/server/r2.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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';
|
||||
|
||||
function isConfigured(): boolean {
|
||||
return !!(
|
||||
env.R2_ACCESS_KEY_ID &&
|
||||
env.R2_SECRET_ACCESS_KEY &&
|
||||
env.R2_ACCOUNT_ID &&
|
||||
env.R2_BUCKET &&
|
||||
env.R2_PUBLIC_URL
|
||||
);
|
||||
}
|
||||
|
||||
let _client: S3Client | null = null;
|
||||
|
||||
function getClient(): S3Client {
|
||||
if (!_client) {
|
||||
if (!isConfigured()) throw new Error('R2 is not configured — check your .env');
|
||||
_client = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY!
|
||||
}
|
||||
});
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'] as const;
|
||||
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];
|
||||
|
||||
export function isAllowedMimeType(mime: string): mime is AllowedMimeType {
|
||||
return (ALLOWED_MIME_TYPES as readonly string[]).includes(mime);
|
||||
}
|
||||
|
||||
const EXT: Record<AllowedMimeType, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'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(),
|
||||
new PutObjectCommand({
|
||||
Bucket: env.R2_BUCKET,
|
||||
Key: key,
|
||||
ContentType: mimeType
|
||||
}),
|
||||
{ expiresIn: 3600 }
|
||||
);
|
||||
|
||||
return { key, uploadUrl, publicUrl: `${env.R2_PUBLIC_URL}/${key}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a photo from R2 by its key.
|
||||
*/
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
await getClient().send(new DeleteObjectCommand({ Bucket: env.R2_BUCKET, Key: key }));
|
||||
}
|
||||
Reference in New Issue
Block a user