init: 个人简历项目文件
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import * as cookie from "cookie";
|
||||
import { createRouter, publicQuery, authedQuery } from "./middleware";
|
||||
import { getSessionCookieOptions } from "./lib/cookies";
|
||||
import { signSessionToken } from "./lib/session";
|
||||
import { env } from "./lib/env";
|
||||
import { findUserByUnionId, upsertUser } from "./queries/users";
|
||||
import { Session } from "@contracts/constants";
|
||||
|
||||
export const authRouter = createRouter({
|
||||
login: publicQuery
|
||||
.input(z.object({ username: z.string(), password: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (
|
||||
input.username !== env.adminUsername ||
|
||||
input.password !== env.adminPassword
|
||||
) {
|
||||
throw new Error("Invalid username or password");
|
||||
}
|
||||
|
||||
await upsertUser({
|
||||
unionId: input.username,
|
||||
name: input.username,
|
||||
role: "admin" as const,
|
||||
lastSignInAt: new Date(),
|
||||
});
|
||||
|
||||
const user = await findUserByUnionId(input.username);
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
|
||||
const token = await signSessionToken({ userId: user.id });
|
||||
|
||||
const cookieOpts = getSessionCookieOptions(ctx.req.headers);
|
||||
ctx.resHeaders.append(
|
||||
"set-cookie",
|
||||
cookie.serialize(Session.cookieName, token, {
|
||||
httpOnly: cookieOpts.httpOnly,
|
||||
path: cookieOpts.path,
|
||||
sameSite: cookieOpts.sameSite?.toLowerCase() as "lax" | "none",
|
||||
secure: cookieOpts.secure,
|
||||
maxAge: Session.maxAgeMs / 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
return user;
|
||||
}),
|
||||
|
||||
me: authedQuery.query((opts) => opts.ctx.user),
|
||||
|
||||
logout: authedQuery.mutation(async ({ ctx }) => {
|
||||
const opts = getSessionCookieOptions(ctx.req.headers);
|
||||
ctx.resHeaders.append(
|
||||
"set-cookie",
|
||||
cookie.serialize(Session.cookieName, "", {
|
||||
httpOnly: opts.httpOnly,
|
||||
path: opts.path,
|
||||
sameSite: opts.sameSite?.toLowerCase() as "lax" | "none",
|
||||
secure: opts.secure,
|
||||
maxAge: 0,
|
||||
}),
|
||||
);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import type { HttpBindings } from "@hono/node-server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "./router";
|
||||
import { createContext } from "./context";
|
||||
import { env } from "./lib/env";
|
||||
|
||||
const app = new Hono<{ Bindings: HttpBindings }>();
|
||||
|
||||
app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 }));
|
||||
app.use("/api/trpc/*", async (c) => {
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: c.req.raw,
|
||||
router: appRouter,
|
||||
createContext,
|
||||
});
|
||||
});
|
||||
app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404));
|
||||
|
||||
export default app;
|
||||
|
||||
if (env.isProduction) {
|
||||
const { serve } = await import("@hono/node-server");
|
||||
const { serveStaticFiles } = await import("./lib/vite");
|
||||
serveStaticFiles(app);
|
||||
|
||||
const port = parseInt(process.env.PORT || "3000");
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Server running on http://localhost:${port}/`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||
import type { User } from "@db/schema";
|
||||
import * as cookie from "cookie";
|
||||
import { Session } from "@contracts/constants";
|
||||
import { verifySessionToken } from "./lib/session";
|
||||
import { findUserById } from "./queries/users";
|
||||
|
||||
export type TrpcContext = {
|
||||
req: Request;
|
||||
resHeaders: Headers;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export async function createContext(
|
||||
opts: FetchCreateContextFnOptions,
|
||||
): Promise<TrpcContext> {
|
||||
const ctx: TrpcContext = { req: opts.req, resHeaders: opts.resHeaders };
|
||||
try {
|
||||
const cookies = cookie.parse(opts.req.headers.get("cookie") || "");
|
||||
const token = cookies[Session.cookieName];
|
||||
if (token) {
|
||||
const claim = await verifySessionToken(token);
|
||||
if (claim) {
|
||||
ctx.user = await findUserById(claim.userId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Authentication is optional
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod";
|
||||
import { createRouter, publicQuery, adminQuery } from "./middleware";
|
||||
import { getDb } from "./queries/connection";
|
||||
import { documents } from "@db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
|
||||
export const documentsRouter = createRouter({
|
||||
list: publicQuery.query(async () => {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.published, "published"))
|
||||
.orderBy(desc(documents.createdAt));
|
||||
}),
|
||||
|
||||
listAll: adminQuery.query(async () => {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(documents)
|
||||
.orderBy(desc(documents.createdAt));
|
||||
}),
|
||||
|
||||
getBySlug: publicQuery
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const results = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.slug, input.slug))
|
||||
.limit(1);
|
||||
return results[0] ?? null;
|
||||
}),
|
||||
|
||||
create: adminQuery
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(255),
|
||||
excerpt: z.string().optional(),
|
||||
content: z.string().min(1),
|
||||
coverImage: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
published: z.enum(["draft", "published"]).default("published"),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const result = await db.insert(documents).values({
|
||||
title: input.title,
|
||||
slug: input.slug,
|
||||
excerpt: input.excerpt ?? "",
|
||||
content: input.content,
|
||||
coverImage: input.coverImage ?? "",
|
||||
tags: input.tags ?? "",
|
||||
published: input.published,
|
||||
});
|
||||
return { id: Number(result[0].insertId) };
|
||||
}),
|
||||
|
||||
update: adminQuery
|
||||
.input(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(255).optional(),
|
||||
excerpt: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
coverImage: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
published: z.enum(["draft", "published"]).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const { id, ...data } = input;
|
||||
await db
|
||||
.update(documents)
|
||||
.set(data)
|
||||
.where(eq(documents.id, id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminQuery
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
await db.delete(documents).where(eq(documents.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CookieOptions } from "hono/utils/cookie";
|
||||
|
||||
function isLocalhost(headers: Headers): boolean {
|
||||
const host = headers.get("host") || "";
|
||||
return host.startsWith("localhost:") || host.startsWith("127.0.0.1:");
|
||||
}
|
||||
|
||||
export function getSessionCookieOptions(headers: Headers): CookieOptions {
|
||||
const localhost = isLocalhost(headers);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: localhost ? "Lax" : "None",
|
||||
secure: !localhost,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import "dotenv/config";
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value && process.env.NODE_ENV === "production") {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
export const env = {
|
||||
appId: required("APP_ID"),
|
||||
appSecret: required("APP_SECRET"),
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
databaseUrl: required("DATABASE_URL"),
|
||||
adminUsername: required("ADMIN_USERNAME"),
|
||||
adminPassword: required("ADMIN_PASSWORD"),
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
interface RequestConfig extends RequestInit {
|
||||
baseUrl?: string;
|
||||
params?: Record<string, string | number>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private baseUrl: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, opts?: { headers?: Record<string, string> }) {
|
||||
this.baseUrl = baseURL;
|
||||
this.defaultHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
...opts?.headers,
|
||||
};
|
||||
}
|
||||
|
||||
async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
||||
const {
|
||||
method = "GET",
|
||||
params,
|
||||
body,
|
||||
headers,
|
||||
timeout = 30000,
|
||||
...rest
|
||||
} = config;
|
||||
|
||||
const url = new URL(`${this.baseUrl}${endpoint}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) =>
|
||||
url.searchParams.append(key, value.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
...rest,
|
||||
method,
|
||||
headers: { ...this.defaultHeaders, ...headers },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response
|
||||
.json()
|
||||
.catch(() => ({}))) as Record<string, string>;
|
||||
throw new Error(errorData.message || `HTTP Error: ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error("Request timeout");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(
|
||||
url: string,
|
||||
params?: RequestConfig["params"],
|
||||
config?: RequestConfig,
|
||||
) {
|
||||
return this.request<T>(url, { ...config, method: "GET", params });
|
||||
}
|
||||
|
||||
post<T>(url: string, body?: any, config?: RequestConfig) {
|
||||
return this.request<T>(url, { ...config, method: "POST", body });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as jose from "jose";
|
||||
import { env } from "./env";
|
||||
|
||||
const JWT_ALG = "HS256";
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export async function signSessionToken(
|
||||
payload: SessionPayload,
|
||||
): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.appSecret);
|
||||
return new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: JWT_ALG })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1 year")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifySessionToken(
|
||||
token: string,
|
||||
): Promise<SessionPayload | null> {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const secret = new TextEncoder().encode(env.appSecret);
|
||||
const { payload } = await jose.jwtVerify(token, secret, {
|
||||
algorithms: [JWT_ALG],
|
||||
});
|
||||
const userId = payload.userId as number;
|
||||
if (!userId) return null;
|
||||
return { userId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { HttpBindings } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
type App = Hono<{ Bindings: HttpBindings }>;
|
||||
|
||||
export function serveStaticFiles(app: App) {
|
||||
const distPath = path.resolve(import.meta.dirname, "../dist/public");
|
||||
|
||||
app.use("*", serveStatic({ root: "./dist/public" }));
|
||||
|
||||
app.notFound((c) => {
|
||||
const accept = c.req.header("accept") ?? "";
|
||||
if (!accept.includes("text/html")) {
|
||||
return c.json({ error: "Not Found" }, 404);
|
||||
}
|
||||
const indexPath = path.resolve(distPath, "index.html");
|
||||
const content = fs.readFileSync(indexPath, "utf-8");
|
||||
return c.html(content);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ErrorMessages } from "@contracts/constants";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import type { TrpcContext } from "./context";
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
export const createRouter = t.router;
|
||||
export const publicQuery = t.procedure;
|
||||
|
||||
const requireAuth = t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: ErrorMessages.unauthenticated,
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
function requireRole(role: string) {
|
||||
return t.middleware(async (opts) => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user || ctx.user.role !== role) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: ErrorMessages.insufficientRole,
|
||||
});
|
||||
}
|
||||
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
}
|
||||
|
||||
export const authedQuery = t.procedure.use(requireAuth);
|
||||
export const adminQuery = authedQuery.use(requireRole("admin"));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { env } from "../lib/env";
|
||||
import * as schema from "@db/schema";
|
||||
import * as relations from "@db/relations";
|
||||
|
||||
const fullSchema = { ...schema, ...relations };
|
||||
|
||||
let instance: ReturnType<typeof drizzle<typeof fullSchema>>;
|
||||
|
||||
export function getDb() {
|
||||
if (!instance) {
|
||||
instance = drizzle(env.databaseUrl, {
|
||||
mode: "planetscale",
|
||||
schema: fullSchema,
|
||||
});
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as schema from "@db/schema";
|
||||
import type { InsertUser } from "@db/schema";
|
||||
import { getDb } from "./connection";
|
||||
|
||||
export async function findUserByUnionId(unionId: string) {
|
||||
const rows = await getDb()
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.unionId, unionId))
|
||||
.limit(1);
|
||||
return rows.at(0);
|
||||
}
|
||||
|
||||
export async function findUserById(id: number) {
|
||||
const rows = await getDb()
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id))
|
||||
.limit(1);
|
||||
return rows.at(0);
|
||||
}
|
||||
|
||||
export async function upsertUser(data: InsertUser) {
|
||||
await getDb()
|
||||
.insert(schema.users)
|
||||
.values(data)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
lastSignInAt: new Date(),
|
||||
name: data.name,
|
||||
avatar: data.avatar,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { authRouter } from "./auth-router";
|
||||
import { documentsRouter } from "./documents-router";
|
||||
import { createRouter, publicQuery } from "./middleware";
|
||||
|
||||
export const appRouter = createRouter({
|
||||
ping: publicQuery.query(() => ({ ok: true, ts: Date.now() })),
|
||||
auth: authRouter,
|
||||
documents: documentsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
Reference in New Issue
Block a user