Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/proxy/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ ALLOWED_ORIGIN="http://localhost:4200"
GEMINI_MODEL="gemini-2.5-flash-lite"
BETTER_AUTH_URL="http://localhost:8787"
GOOGLE_CLIENT_ID=""
EMAIL_FROM=""
1 change: 1 addition & 0 deletions backend/proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"drizzle-orm": "^0.45.1",
"hono": "^4.10.6",
"hono-openapi": "^1.2.0",
"resend": "^6.9.2",
"zod": "^4.3.5"
}
}
47 changes: 47 additions & 0 deletions backend/proxy/scripts/setup-secrets-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { execSync } from 'node:child_process'

const LOCAL_SECRET_STORE_ID = '4a9ca50edba84431879f34b0b67f9998'

const LOCAL_SECRETS = [
{
name: 'DANMAKU_GEMINI_API_KEY_STG',
scope: 'workers',
value: 'gemini-api',
},
{
name: 'DA_AI_GATEWAY_ID_STG',
scope: 'workers',
value: 'da-ai-gateway-id',
},
{
name: 'DA_AI_GATEWAY_NAME_STG',
scope: 'workers',
value: 'da-ai-gateway-name',
},
{
name: 'BETTER_AUTH_SECRET_STG',
scope: 'workers',
value: 'better-auth-secret',
},
{
name: 'GOOGLE_CLIENT_SECRET_STG',
scope: 'workers',
value: 'google-client-secret',
},
{
name: 'RESEND_API_KEY_STG',
scope: 'workers',
value: 'resend-api-key',
},
]

async function setupSecretsStore() {
for (const secret of LOCAL_SECRETS) {
console.log(`Creating secret ${secret.name}...`)
execSync(
`pnpm wrangler secrets-store secret create ${LOCAL_SECRET_STORE_ID} --name ${secret.name} --scopes ${secret.scope} --value ${secret.value}`
)
}
}

void setupSecretsStore()
38 changes: 37 additions & 1 deletion backend/proxy/src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin, bearer, jwt, openAPI } from 'better-auth/plugins'
import { ac, adminRole, moderatorRole, userRole } from '@/auth/permissions'
import { getOrCreateDb } from '@/db'
import { getOrCreateEmailService } from '@/email'

async function getGoogleProvider(env: Env) {
const clientId = env.GOOGLE_CLIENT_ID?.trim()
Expand All @@ -19,6 +20,17 @@ async function getGoogleProvider(env: Env) {
}
}

// Updated per-request via setWaitUntil before auth handlers run
let currentWaitUntil: (promise: Promise<unknown>) => void = () => {
// noop
}

export function setWaitUntil(
waitUntil: (promise: Promise<unknown>) => void
): void {
currentWaitUntil = waitUntil
}

async function createAuth(env: Env) {
const secret = await env.BETTER_AUTH_SECRET.get()

Expand All @@ -36,7 +48,31 @@ async function createAuth(env: Env) {
basePath: '/auth',
secret,
trustedOrigins: [env.BETTER_AUTH_TRUSTED_ORIGINS],
emailAndPassword: { enabled: true, minPasswordLength: 6 },
emailAndPassword: {
enabled: true,
minPasswordLength: 6,
sendResetPassword: async ({ user, url }) => {
const emailService = await getOrCreateEmailService(env)
const promise = emailService.send({
to: user.email,
subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`,
})
currentWaitUntil(promise)
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
const emailService = await getOrCreateEmailService(env)
const promise = emailService.send({
to: user.email,
subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`,
})
currentWaitUntil(promise)
},
},
socialProviders: googleProvider ? { google: googleProvider } : {},
database: drizzleAdapter(getOrCreateDb(env.DB), {
provider: 'sqlite',
Expand Down
18 changes: 18 additions & 0 deletions backend/proxy/src/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ResendEmailService } from './resend'
import type { EmailService } from './types'

export type { EmailService, SendEmailParams } from './types'

let emailService: EmailService | null = null

export async function getOrCreateEmailService(env: Env): Promise<EmailService> {
if (emailService) {
return emailService
}

const apiKey = await env.RESEND_API_KEY.get()
const from = env.EMAIL_FROM.trim()

emailService = new ResendEmailService(apiKey, from)
Comment on lines +14 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The EMAIL_FROM environment variable is trimmed but not checked for being empty. If the variable is not set or contains only whitespace, from will be an empty string, which will cause the email sending to fail at runtime. It's better to fail fast with a clear error message if this crucial configuration is missing.

  const from = env.EMAIL_FROM.trim()

  if (!from) {
    throw new Error('EMAIL_FROM environment variable is not configured.')
  }

  emailService = new ResendEmailService(apiKey, from)

return emailService
}
43 changes: 43 additions & 0 deletions backend/proxy/src/email/resend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Resend } from 'resend'
import type { EmailService, SendEmailParams } from './types'

/**
* Resend implementation of the email service abstraction.
* Only this file should import from 'resend' so the rest of the app stays provider-agnostic.
*/
export class ResendEmailService implements EmailService {
private readonly resend: Resend
private readonly from: string

constructor(apiKey: string, from: string) {
this.resend = new Resend(apiKey)
this.from = from
}

async send(params: SendEmailParams): Promise<void> {
const to = Array.isArray(params.to) ? params.to : [params.to]
const html =
params.html ??
(params.text
? `<p>${params.text.replaceAll('\n', '<br>')}</p>`
: '<p></p>')
Comment on lines +19 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When converting a plain text body to HTML, the text content is not escaped. This could lead to a Cross-Site Scripting (XSS) vulnerability if the params.text contains malicious HTML, for example from user-controlled data in the future. While the current usage in better-auth seems safe, it's best practice to always escape content that is injected into HTML.

Suggested change
const html =
params.html ??
(params.text
? `<p>${params.text.replaceAll('\n', '<br>')}</p>`
: '<p></p>')
const escapeHtml = (unsafe: string) =>
unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const html =
params.html ??
(params.text
? `<p>${escapeHtml(params.text).replaceAll('\n', '<br>')}</p>`
: '<p></p>')

const text = params.text

const payload: Parameters<Resend['emails']['send']>[0] = {
from: this.from,
to,
subject: params.subject,
html,
}
if (text) payload.text = text

console.log('Sending email to', to)
const { error, data } = await this.resend.emails.send(payload)

if (error) {
throw new Error(`Resend send failed: ${JSON.stringify(error)}`)
}

console.log('Email sent successfully', data)
}
}
19 changes: 19 additions & 0 deletions backend/proxy/src/email/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Abstraction for sending transactional emails.
* Keeps the app decoupled from a specific provider (e.g. Resend).
*/

export interface SendEmailParams {
/** Recipient address(es) */
to: string | string[]
/** Email subject */
subject: string
/** Plain text body (optional if html is set) */
text?: string
/** HTML body (optional if text is set) */
html?: string
}

export interface EmailService {
send(params: SendEmailParams): Promise<void>
}
3 changes: 2 additions & 1 deletion backend/proxy/src/middleware/authContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getOrCreateAuth } from '@/auth/config'
import { getOrCreateAuth, setWaitUntil } from '@/auth/config'
import type { AuthSession } from '@/auth/types'
import { factory } from '@/factory'
import { getIsTestEnv } from '@/utils/getIsTestEnv'
Expand All @@ -23,6 +23,7 @@ export const authContext = () =>
}

try {
setWaitUntil(context.executionCtx.waitUntil)
const auth = await getOrCreateAuth(context.env)
const { user, session } = await resolveAuthSession(
context.req.raw.headers,
Expand Down
4 changes: 3 additions & 1 deletion backend/proxy/src/routes/api/auth/router.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { getOrCreateAuth } from '@/auth/config'
import { getOrCreateAuth, setWaitUntil } from '@/auth/config'
import { factory } from '@/factory'

export const authRouter = factory.createApp()

authRouter.get('/docs', async (context) => {
setWaitUntil(context.executionCtx.waitUntil)
const auth = await getOrCreateAuth(context.env)
return context.json(await auth.api.generateOpenAPISchema(), { status: 200 })
})

authRouter.on(['GET', 'POST'], '/*', async (context) => {
try {
setWaitUntil(context.executionCtx.waitUntil)
const auth = await getOrCreateAuth(context.env)
return auth.handler(context.req.raw)
} catch (error) {
Expand Down
46 changes: 29 additions & 17 deletions backend/proxy/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 91211e0623a626a08ef3dc56a3388fb4)
// Generated by Wrangler by running `wrangler types` (hash: cc001bfc772b383e98e6060476f6ad78)
// Runtime types generated with workerd@1.20260205.0 2025-05-05 nodejs_compat
declare namespace Cloudflare {
interface GlobalProps {
Expand All @@ -13,13 +13,17 @@ declare namespace Cloudflare {
DA_AI_GATEWAY_NAME: SecretsStoreSecret;
BETTER_AUTH_SECRET: SecretsStoreSecret;
GOOGLE_CLIENT_SECRET: SecretsStoreSecret;
RESEND_API_KEY: SecretsStoreSecret;
CF_VERSION_METADATA: WorkerVersionMetadata;
ENVIRONMENT: "staging";
ALLOWED_ORIGIN: "https://danmaku.weeblify.app,https://danmaku-staging.weeblify.app,http://localhost:4200";
BETTER_AUTH_TRUSTED_ORIGINS: "https://**.weeblify.app";
GEMINI_MODEL: "gemini-2.5-flash-lite";
BETTER_AUTH_URL: "https://api.danmaku-staging.weeblify.app";
GOOGLE_CLIENT_ID: "";
EMAIL_FROM: "Danmaku Anywhere <hello@danmaku.weeblify.app>";
ENVIRONMENT: string;
DANDANPLAY_API_HOST: string;
DANDANPLAY_APP_ID: string;
ALLOWED_ORIGIN: string;
GEMINI_MODEL: string;
BETTER_AUTH_URL: string;
GOOGLE_CLIENT_ID: string;
DDP_SERVICE: Fetcher /* ddp-microservice-staging */;
}
interface ProductionEnv {
Expand All @@ -30,30 +34,38 @@ declare namespace Cloudflare {
DA_AI_GATEWAY_NAME: SecretsStoreSecret;
BETTER_AUTH_SECRET: SecretsStoreSecret;
GOOGLE_CLIENT_SECRET: SecretsStoreSecret;
RESEND_API_KEY: SecretsStoreSecret;
CF_VERSION_METADATA: WorkerVersionMetadata;
ENVIRONMENT: "production";
ALLOWED_ORIGIN: "https://danmaku.weeblify.app";
BETTER_AUTH_TRUSTED_ORIGINS: "https://**.weeblify.app";
GEMINI_MODEL: "gemini-2.5-flash-lite";
BETTER_AUTH_URL: "https://api.danmaku.weeblify.app";
GOOGLE_CLIENT_ID: "";
EMAIL_FROM: "Danmaku Anywhere <hello@danmaku.weeblify.app>";
ENVIRONMENT: string;
DANDANPLAY_API_HOST: string;
DANDANPLAY_APP_ID: string;
ALLOWED_ORIGIN: string;
GEMINI_MODEL: string;
BETTER_AUTH_URL: string;
GOOGLE_CLIENT_ID: string;
DDP_SERVICE: Fetcher /* ddp-microservice-prod */;
}
interface Env {
ENVIRONMENT: string;
DANDANPLAY_API_HOST: string;
DANDANPLAY_APP_ID: string;
ALLOWED_ORIGIN: string;
GEMINI_MODEL: string;
BETTER_AUTH_URL: string;
GOOGLE_CLIENT_ID: string;
FILES_BUCKET: R2Bucket;
DB: D1Database;
DANMAKU_GEMINI_API_KEY: SecretsStoreSecret;
DA_AI_GATEWAY_ID: SecretsStoreSecret;
DA_AI_GATEWAY_NAME: SecretsStoreSecret;
BETTER_AUTH_SECRET: SecretsStoreSecret;
GOOGLE_CLIENT_SECRET: SecretsStoreSecret;
RESEND_API_KEY: SecretsStoreSecret;
CF_VERSION_METADATA: WorkerVersionMetadata;
ENVIRONMENT: "staging" | "production";
ALLOWED_ORIGIN: "https://danmaku.weeblify.app,https://danmaku-staging.weeblify.app,http://localhost:4200" | "https://danmaku.weeblify.app";
BETTER_AUTH_TRUSTED_ORIGINS: "https://**.weeblify.app";
GEMINI_MODEL: "gemini-2.5-flash-lite";
BETTER_AUTH_URL: "https://api.danmaku-staging.weeblify.app" | "https://api.danmaku.weeblify.app";
GOOGLE_CLIENT_ID: "";
EMAIL_FROM: "Danmaku Anywhere <hello@danmaku.weeblify.app>";
DDP_SERVICE: Fetcher /* ddp-microservice-staging */ | Fetcher /* ddp-microservice-prod */;
}
}
Expand All @@ -62,7 +74,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ENVIRONMENT" | "ALLOWED_ORIGIN" | "BETTER_AUTH_TRUSTED_ORIGINS" | "GEMINI_MODEL" | "BETTER_AUTH_URL" | "GOOGLE_CLIENT_ID">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "BETTER_AUTH_TRUSTED_ORIGINS" | "EMAIL_FROM" | "ENVIRONMENT" | "DANDANPLAY_API_HOST" | "DANDANPLAY_APP_ID" | "ALLOWED_ORIGIN" | "GEMINI_MODEL" | "BETTER_AUTH_URL" | "GOOGLE_CLIENT_ID">> {}
}

// Begin runtime types
Expand Down
Loading
Loading