Burn it all to the ground and start with bun and a reorg

This commit is contained in:
2025-05-09 17:51:29 +01:00
parent 6eaf1d6b9f
commit 95f317fd75
82 changed files with 3001 additions and 13108 deletions

23
src/server/api/root.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { photosRouter } from "./routers/photos/photos";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
photos: photosRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
/**
* Create a server-side caller for the tRPC API.
* @example
* const trpc = createCaller(createContext);
* const res = await trpc.post.all();
* ^? Post[]
*/
export const createCaller = createCallerFactory(appRouter);

View File

@@ -0,0 +1,56 @@
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
import { shake } from "radash";
export type ImageData = {
width: number;
height: number;
blur: `data:image/${string}`;
src: string;
camera?: string;
exif: Partial<{
exposureBiasValue: number;
fNumber: number;
isoSpeedRatings: number;
focalLength: number;
takenAt: Date;
LensModel: string;
}>;
title?: string;
description?: string;
};
export type ListOptions = {
cursor: number;
limit: number;
};
export async function list(options: ListOptions): Promise<ImageData[]> {
const currentSources = await db
.select()
.from(photos)
.limit(options.limit)
.offset(options.cursor);
const images = currentSources.map((photo) => {
return {
width: photo.width,
height: photo.height,
blur: `data:image/jpeg;base64,${photo.blur.toString("base64")}` as `data:image/${string}`,
src: photo.src,
camera: photo.camera ?? undefined,
exif: shake({
exposureBiasValue: photo.exposureBiasValue,
fNumber: photo.fNumber,
isoSpeedRatings: photo.isoSpeedRatings,
focalLength: photo.focalLength,
takenAt: photo.takenAt,
lensModel: photo.lensModel,
}),
title: photo.title ?? undefined,
description: photo.description ?? undefined,
};
});
return images;
}

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "@/server/api/trpc";
import { list } from "./list";
import { update } from "./update";
export const photosRouter = createTRPCRouter({
list: publicProcedure
.input(
z
.object({
limit: z.number().nonnegative().default(1),
cursor: z.number().nonnegative().default(0),
})
.optional()
.default({}),
)
.query(async ({ input }) => {
const ret = await list({
limit: input.limit + 1,
cursor: input.cursor,
});
let next: number | undefined;
if (ret.length > input.limit) {
next = input.limit;
ret.pop();
}
return {
data: ret,
next,
};
}),
update: protectedProcedure.query(update),
});

View File

@@ -0,0 +1,90 @@
import {
GetObjectCommand,
ListObjectsV2Command,
S3Client,
} from "@aws-sdk/client-s3";
import exifReader from "exif-reader";
import { diff, sift } from "radash";
import sharp from "sharp";
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
export async function update(): Promise<string[]> {
const allPhotos = await db.select().from(photos);
const currentSources = allPhotos.map((photo) => photo.src);
const s3Client = new S3Client({
region: "auto",
endpoint: "https://fly.storage.tigris.dev",
});
const listObjCmd = new ListObjectsV2Command({
Bucket: "joemonk-photos",
});
const s3Res = await s3Client.send(listObjCmd);
if (!s3Res.Contents) {
throw new TRPCError({
code: "GATEWAY_TIMEOUT",
message: "Could not get contents from Tigris",
});
}
const s3Photos = sift(
s3Res.Contents.map((obj) => {
if (!obj.Key?.endsWith("/")) {
return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`;
}
return null;
}),
);
const newPhotos = diff(s3Photos, currentSources);
if (newPhotos.length === 0) {
return [];
}
const imageData = newPhotos.map(async (fileName: string) => {
const getImageCmd = new GetObjectCommand({
Bucket: "joemonk-photos",
Key: fileName.replace(
"https://fly.storage.tigris.dev/joemonk-photos/",
"",
),
});
const imgRes = await s3Client.send(getImageCmd);
const image = await imgRes.Body?.transformToByteArray();
const { width, height, exif } = await sharp(image).metadata();
const blur = await sharp(image)
.resize({ width: 12, height: 12, fit: "inside" })
.toBuffer();
const exifData = exif ? exifReader(exif) : undefined;
const photo: typeof photos.$inferInsert = {
src: fileName,
width: width ?? 10,
height: height ?? 10,
blur: blur,
camera: exifData?.Image?.Model ?? null,
exposureBiasValue: exifData?.Photo?.ExposureBiasValue ?? null,
fNumber: exifData?.Photo?.FNumber ?? null,
isoSpeedRatings: exifData?.Photo?.ISOSpeedRatings ?? null,
focalLength: exifData?.Photo?.FocalLength ?? null,
takenAt: exifData?.Photo?.DateTimeOriginal ?? null,
lensModel: exifData?.Photo?.LensModel ?? null,
};
return photo;
});
const images = await Promise.all(imageData);
await db.insert(photos).values(images);
return newPhotos;
}

133
src/server/api/trpc.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1).
* 2. You want to create a new middleware or type of procedure (see Part 3).
*
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { TRPCError, initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
* wrap this and provides the required context.
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
db,
session,
...opts,
};
};
/**
* 2. INITIALIZATION
*
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* Create a server-side caller.
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const createCallerFactory = t.createCallerFactory;
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these a lot in the
* "/src/server/api/routers" directory.
*/
/**
* This is how you create new routers and sub-routers in your tRPC API.
*
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
/**
* Protected (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
* the session is valid and guarantees `ctx.session.user` is not null.
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});

53
src/server/auth/config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { env } from "@/env";
import { getBaseUrl } from "@/lib/base-url";
import type { DefaultSession, NextAuthConfig } from "next-auth";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
providers: [
{
id: "authelia",
name: "Authelia",
type: "oidc",
issuer: "https://auth.home.joemonk.co.uk",
clientId: env.AUTH_CLIENT_ID,
clientSecret: env.AUTH_CLIENT_SECRET,
},
],
trustHost: true,
redirectProxyUrl: `${getBaseUrl()}/api/auth`,
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
},
} satisfies NextAuthConfig;

10
src/server/auth/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };

19
src/server/db/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { type Client, createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { env } from "@/env";
import * as schema from "./schema";
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
* update.
*/
const globalForDb = globalThis as unknown as {
client: Client | undefined;
};
export const client =
globalForDb.client ?? createClient({ url: env.DATABASE_URL });
if (env.NODE_ENV !== "production") globalForDb.client = client;
export const db = drizzle(client, { schema });

20
src/server/db/schema.ts Normal file
View File

@@ -0,0 +1,20 @@
import { sqliteTable } from "drizzle-orm/sqlite-core";
export const photos = sqliteTable("photo", (d) => ({
id: d.integer({ mode: "number" }).primaryKey({ autoIncrement: true }),
src: d.text({ length: 256 }).notNull().unique(),
width: d.integer({ mode: "number" }).notNull(),
height: d.integer({ mode: "number" }).notNull(),
blur: d.blob({ mode: "buffer" }).notNull(),
camera: d.text({ length: 128 }),
title: d.text({ length: 128 }),
description: d.text({ length: 1024 }),
exposureBiasValue: d.integer({ mode: "number" }),
fNumber: d.real(),
isoSpeedRatings: d.integer({ mode: "number" }),
focalLength: d.integer({ mode: "number" }),
takenAt: d.integer({ mode: "timestamp" }),
lensModel: d.text({ length: 128 }),
}));