feat(server): with api server, service-lize (#807)
Co-authored-by: Neko Ayaka <neko@ayaka.moe> Co-authored-by: Lovehsigure_520 <1260907335@qq.com>
This commit is contained in:
co-authored by
Neko Ayaka
Lovehsigure_520
parent
dd46b8deff
commit
d064a959cf
@@ -0,0 +1,99 @@
|
||||
import process, { exit } from 'node:process'
|
||||
|
||||
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger as honoLogger } from 'hono/logger'
|
||||
import { injeca } from 'injeca'
|
||||
|
||||
import { createAuth } from './services/auth'
|
||||
import { createDrizzle } from './services/db'
|
||||
import { parsedEnv } from './services/env'
|
||||
import { getTrustedOrigin } from './utils/origin'
|
||||
|
||||
async function createApp() {
|
||||
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
|
||||
|
||||
const resolved = await injeca.resolve({ parsedEnv })
|
||||
|
||||
const logger = useLogger('app').useGlobalConfig()
|
||||
const db = createDrizzle(resolved.parsedEnv.DATABASE_URL)
|
||||
const auth = createAuth(db, resolved.parsedEnv)
|
||||
|
||||
db.execute('SELECT 1')
|
||||
.then(() => {
|
||||
logger.log('Connected to database')
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.withError(err).error('Failed to connect to database')
|
||||
exit(1)
|
||||
})
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null
|
||||
session: typeof auth.$Infer.Session.session | null
|
||||
}
|
||||
}>()
|
||||
|
||||
app.use(
|
||||
'/api/auth/*', // or replace with "*" to enable cors for all routes
|
||||
cors({
|
||||
origin(origin: string) {
|
||||
return getTrustedOrigin(origin)
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
|
||||
app.use(honoLogger())
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||
|
||||
if (!session) {
|
||||
c.set('user', null)
|
||||
c.set('session', null)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
c.set('user', session.user)
|
||||
c.set('session', session.session)
|
||||
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get('/session', (c) => {
|
||||
const session = c.get('session')
|
||||
const user = c.get('user')
|
||||
|
||||
if (!user)
|
||||
return c.body(null, 401)
|
||||
|
||||
return c.json({
|
||||
session,
|
||||
user,
|
||||
})
|
||||
})
|
||||
|
||||
// NOTICE: required by better-auth
|
||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
||||
return auth.handler(c.req.raw)
|
||||
})
|
||||
|
||||
logger.withFields({ port: 3000 }).log('Server started')
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// eslint-disable-next-line antfu/no-top-level-await
|
||||
serve(await createApp())
|
||||
|
||||
function handleError(error: unknown, type: string) {
|
||||
useLogger().withError(error).error(type)
|
||||
}
|
||||
|
||||
process.on('uncaughtException', error => handleError(error, 'Uncaught exception'))
|
||||
process.on('unhandledRejection', error => handleError(error, 'Unhandled rejection'))
|
||||
@@ -0,0 +1,93 @@
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { boolean, index, pgTable, text, timestamp } 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)],
|
||||
)
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
}))
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,8 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { createAuth } from '../services/auth'
|
||||
import { createDrizzle } from '../services/db'
|
||||
import { parseEnv } from '../services/env'
|
||||
|
||||
const env = parseEnv(process.env)
|
||||
export default createAuth(createDrizzle(env.DATABASE_URL), env)
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Database } from './db'
|
||||
import type { Env } from './env'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { bearer } from 'better-auth/plugins'
|
||||
|
||||
import * as authSchema from '../schemas/auth'
|
||||
|
||||
export function createAuth(db: Database, env: Env) {
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
...authSchema,
|
||||
},
|
||||
}),
|
||||
|
||||
plugins: [
|
||||
bearer(),
|
||||
],
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
baseURL: process.env.API_SERVER_URL || 'http://localhost:3000',
|
||||
trustedOrigins: ['*'],
|
||||
|
||||
// To skip state-mismatch errors
|
||||
// https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378
|
||||
advanced: {
|
||||
defaultCookieAttributes: {
|
||||
sameSite: 'None', // this enables cross-site cookies
|
||||
secure: true, // required for SameSite=None
|
||||
},
|
||||
},
|
||||
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
github: {
|
||||
clientId: env.AUTH_GITHUB_CLIENT_ID,
|
||||
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
|
||||
export type Database = ReturnType<typeof createDrizzle>
|
||||
|
||||
export function createDrizzle(dsn: string) {
|
||||
return drizzle(postgres(dsn))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import { env, exit } from 'node:process'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { injeca } from 'injeca'
|
||||
import { nonEmpty, object, parse, pipe, string } from 'valibot'
|
||||
|
||||
const EnvSchema = object({
|
||||
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
|
||||
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
|
||||
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
|
||||
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
|
||||
})
|
||||
|
||||
export type Env = InferOutput<typeof EnvSchema>
|
||||
|
||||
export function parseEnv(inputEnv: Record<string, string> | typeof env): Env {
|
||||
try {
|
||||
return parse(EnvSchema, inputEnv)
|
||||
}
|
||||
catch (err) {
|
||||
useLogger().withError(err).error('Invalid environment variables')
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export const parsedEnv = injeca.provide('env', () => parseEnv(env))
|
||||
@@ -0,0 +1,20 @@
|
||||
export function getTrustedOrigin(origin: string): string {
|
||||
// 1. Allow Dev (Localhost with any port)
|
||||
if (!origin || origin.startsWith('http://localhost:')) {
|
||||
return origin
|
||||
}
|
||||
|
||||
// 2. Allow Production (Exact Match)
|
||||
if (origin === 'https://airi.moeru.ai') {
|
||||
return origin
|
||||
}
|
||||
|
||||
// 3. Allow Dynamic Subdomains (Strict Regex)
|
||||
// Matches: https://foo.kwaa.workers.dev
|
||||
if (/^https:\/\/.*\.kwaa\.workers\.dev$/.test(origin)) {
|
||||
return origin
|
||||
}
|
||||
|
||||
// Default: Block
|
||||
return ''
|
||||
}
|
||||
Reference in New Issue
Block a user