init: me-api 个人简历后台
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Backend
|
||||||
|
APP_ID= # Application ID
|
||||||
|
APP_SECRET= # Application secret for JWT signing
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL= # MySQL connection string (mysql://user:pass@host:port/db)
|
||||||
|
|
||||||
|
# Admin Login
|
||||||
|
ADMIN_USERNAME= # Admin login username
|
||||||
|
ADMIN_PASSWORD= # Admin login password
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Test
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# DB migrations
|
||||||
|
db/migrations/*.sql
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:24-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
CMD ["node", "dist/boot.js"]
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
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 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,17 @@
|
|||||||
|
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 = {
|
||||||
|
jwtSecret: required("JWT_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.jwtSecret);
|
||||||
|
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.jwtSecret);
|
||||||
|
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,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: "default",
|
||||||
|
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;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const Session = {
|
||||||
|
cookieName: "admin_sid",
|
||||||
|
maxAgeMs: 365 * 24 * 60 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ErrorMessages = {
|
||||||
|
unauthenticated: "Authentication required",
|
||||||
|
insufficientRole: "Insufficient permissions",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const Paths = {
|
||||||
|
login: "/login",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
type AppError = { tag: "app_error"; status: number; message: string };
|
||||||
|
|
||||||
|
function appError(status: number, message: string): AppError {
|
||||||
|
return { tag: "app_error", status, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Errors = {
|
||||||
|
badRequest: (msg: string) => appError(400, msg),
|
||||||
|
unauthorized: (msg: string) => appError(401, msg),
|
||||||
|
forbidden: (msg: string) => appError(403, msg),
|
||||||
|
notFound: (msg: string) => appError(404, msg),
|
||||||
|
internal: (msg: string) => appError(500, msg),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type { AppError };
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type * from "../db/schema";
|
||||||
|
export * from "./errors";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import {} from "./schema";
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
mysqlTable,
|
||||||
|
mysqlEnum,
|
||||||
|
serial,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
// bigint,
|
||||||
|
} from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
|
export const users = mysqlTable("users", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
unionId: varchar("unionId", { length: 255 }).notNull().unique(),
|
||||||
|
name: varchar("name", { length: 255 }),
|
||||||
|
email: varchar("email", { length: 320 }),
|
||||||
|
avatar: text("avatar"),
|
||||||
|
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt")
|
||||||
|
.defaultNow()
|
||||||
|
.notNull()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
lastSignInAt: timestamp("lastSignInAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type InsertUser = typeof users.$inferInsert;
|
||||||
|
|
||||||
|
export const documents = mysqlTable("documents", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
title: varchar("title", { length: 255 }).notNull(),
|
||||||
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
|
excerpt: text("excerpt"),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
coverImage: text("coverImage"),
|
||||||
|
tags: text("tags"),
|
||||||
|
published: mysqlEnum("published", ["draft", "published"]).default("published").notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt")
|
||||||
|
.defaultNow()
|
||||||
|
.notNull()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Document = typeof documents.$inferSelect;
|
||||||
|
export type InsertDocument = typeof documents.$inferInsert;
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import { getDb } from "../api/queries/connection";
|
||||||
|
// TODO: import tables from "./schema"
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const db = getDb();
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
// TODO: insert seed data, e.g.
|
||||||
|
// await db.insert(schema.posts).values([
|
||||||
|
// { title: "First post", content: "Hello world" },
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
process.exit(0); // close MySQL connection pool
|
||||||
|
}
|
||||||
|
|
||||||
|
seed();
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
me-api:
|
||||||
|
build: .
|
||||||
|
image: me-api:latest
|
||||||
|
container_name: me-api
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
|
name: aliyun-app-network
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL is required to run drizzle commands");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./db/schema.ts",
|
||||||
|
out: "./db/migrations",
|
||||||
|
dialect: "mysql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: connectionString,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "me-api",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch api/boot.ts",
|
||||||
|
"build": "tsc -b && esbuild api/boot.ts --platform=node --bundle --format=esm --outdir=dist",
|
||||||
|
"start": "NODE_ENV=production node dist/boot.js",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.965.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.965.0",
|
||||||
|
"@hono/node-server": "^1.14.3",
|
||||||
|
"@trpc/server": "^11.8.1",
|
||||||
|
"cookie": "^1.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"hono": "^4.8.3",
|
||||||
|
"jose": "6.1.3",
|
||||||
|
"mysql2": "^3.14.1",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"superjson": "^2.2.6",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"esbuild": "^0.27.2",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@contracts/*": ["./contracts/*"],
|
||||||
|
"@db/*": ["./db/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["api", "contracts", "db", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user