Skip to content
Open
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
23 changes: 23 additions & 0 deletions apps/flox/cloudflare-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// <reference types="@cloudflare/workers-types" />

// Bindings/vars available through `getRequestContext().env` on next-on-pages.
interface CloudflareEnv {
DB: D1Database
DB_TYPE?: string
BETTER_AUTH_SECRET?: string
BETTER_AUTH_URL?: string
GOOGLE_CLIENT_ID?: string
GOOGLE_CLIENT_SECRET?: string
LIBSQL_URL?: string
LIBSQL_AUTH_TOKEN?: string
}

// @cloudflare/next-on-pages ships no types for its root export, so declare the
// only symbol we use here.
declare module '@cloudflare/next-on-pages' {
export function getRequestContext(): {
env: CloudflareEnv
cf: IncomingRequestCfProperties
ctx: ExecutionContext
}
}
45 changes: 45 additions & 0 deletions apps/flox/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Config } from 'drizzle-kit'
import { defineConfig } from 'drizzle-kit'

const {
DB_TYPE = 'libsql',
CLOUDFLARE_ACCOUNT_ID = '',
CLOUDFLARE_DATABASE_ID = '',
CLOUDFLARE_API_TOKEN = '',
LIBSQL_URL = 'file:./src/database/data.db',
LIBSQL_AUTH_TOKEN = undefined,
} = process.env

const configFactory = {
base: {
schema: './src/database/schema.ts',
out: './src/database',
} as const,

libsql: () =>
({
...configFactory.base,
dialect: 'turso',
dbCredentials: {
url: LIBSQL_URL,
authToken: LIBSQL_AUTH_TOKEN,
},
}) as const satisfies Config,

d1: () =>
({
...configFactory.base,
dialect: 'sqlite',
driver: 'd1-http',
dbCredentials: {
accountId: CLOUDFLARE_ACCOUNT_ID,
databaseId: CLOUDFLARE_DATABASE_ID,
token: CLOUDFLARE_API_TOKEN,
},
}) as const satisfies Config,
} as const

const config =
DB_TYPE === 'libsql' ? configFactory.libsql() : configFactory.d1()

export default defineConfig(config)
11 changes: 10 additions & 1 deletion apps/flox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"db:gen": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"cf:localdb": "wrangler d1 migrations apply flox",
"cf:remotedb": "wrangler d1 migrations apply flox --remote"
},
"dependencies": {
"@cdlab996/genid": "catalog:prod",
Expand All @@ -27,8 +31,11 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@libsql/client": "catalog:prod",
"@tanstack/react-form": "catalog:prod",
"@tanstack/react-query": "catalog:prod",
"better-auth": "catalog:prod",
"drizzle-orm": "catalog:prod",
"hls.js": "catalog:prod",
"date-fns": "catalog:prod",
"lucide-react": "catalog:prod",
Expand All @@ -43,7 +50,9 @@
"devDependencies": {
"@cdlab996/tsconfig": "workspace:*",
"@cloudflare/next-on-pages": "catalog:dev",
"@cloudflare/workers-types": "catalog:dev",
"@types/node": "catalog:dev",
"drizzle-kit": "catalog:dev",
"@types/react": "catalog:dev",
"@types/react-dom": "catalog:dev",
"typescript": "catalog:dev"
Expand Down
13 changes: 13 additions & 0 deletions apps/flox/src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getAuth } from '@/lib/auth'

export const runtime = 'edge'

export async function GET(req: Request) {
const auth = await getAuth()
return auth.handler(req)
}

export async function POST(req: Request) {
const auth = await getAuth()
return auth.handler(req)
}
24 changes: 0 additions & 24 deletions apps/flox/src/app/api/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,17 @@
* Exposes configuration status (never actual values) to the client
*/

import { verifyPasswordFn } from '@cdlab996/utils'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

export const runtime = 'edge'

const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || ''
const PERSIST_PASSWORD = process.env.PERSIST_PASSWORD !== 'false'
const SUBSCRIPTION_SOURCES =
process.env.SUBSCRIPTION_SOURCES ||
process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES ||
''

export async function GET() {
return NextResponse.json({
hasEnvPassword: ACCESS_PASSWORD.length > 0,
persistPassword: PERSIST_PASSWORD,
subscriptionSources: SUBSCRIPTION_SOURCES,
})
}

export async function POST(request: NextRequest) {
try {
const { hash } = await request.json()

if (!ACCESS_PASSWORD) {
return NextResponse.json({ valid: false, message: 'No env password set' })
}

const valid = await verifyPasswordFn(hash, ACCESS_PASSWORD)
return NextResponse.json({ valid })
} catch {
return NextResponse.json(
{ valid: false, message: 'Invalid request' },
{ status: 400 },
)
}
}
6 changes: 3 additions & 3 deletions apps/flox/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import { Suspense } from 'react'

import '@cdlab996/ui/globals.css'
import { AdKeywordsInjector } from '@/components/AdKeywordsInjector'
import { AuthGate } from '@/components/AuthGate'
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar'
import { ClientProviders, Header } from '@/components/layout'
import { PasswordGate } from '@/components/PasswordGate'
import { ScrollPositionManager } from '@/components/ScrollPositionManager'
import { ServiceWorkerRegister } from '@/components/ServiceWorkerRegister'
import { BackToTop } from '@/components/ui/BackToTop'
Expand Down Expand Up @@ -242,7 +242,7 @@ export default function RootLayout({
suppressHydrationWarning
>
<ClientProviders>
<PasswordGate hasEnvPassword={!!process.env.ACCESS_PASSWORD}>
<AuthGate>
<AdKeywordsWrapper />
<Header />
{children}
Expand All @@ -254,7 +254,7 @@ export default function RootLayout({
<WatchLaterSidebar />
<WatchHistorySidebar />
</Suspense>
</PasswordGate>
</AuthGate>
<ServiceWorkerRegister />
<Toaster richColors position="top-center" duration={3000} />
</ClientProviders>
Expand Down
11 changes: 1 addition & 10 deletions apps/flox/src/app/settings/hooks/useSettingsPage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useSourceSettings } from '@/lib/hooks/useSourceSettings'
import { clearAppCaches, resetAllStores } from '@/lib/store/registry'
Expand Down Expand Up @@ -67,14 +67,6 @@ export function useSettingsPage({
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
const [envPasswordSet, setEnvPasswordSet] = useState(false)

useEffect(() => {
fetch('/api/config')
.then((res) => res.json())
.then((data) => setEnvPasswordSet(data.hasEnvPassword))
.catch(() => setEnvPasswordSet(false))
}, [])

const handleExport = (
includeSearchHistory: boolean,
Expand Down Expand Up @@ -166,7 +158,6 @@ export function useSettingsPage({
sources,
subscriptions,
sortBy,
envPasswordSet,
realtimeLatency,
searchDisplayMode,
fullscreenType,
Expand Down
163 changes: 163 additions & 0 deletions apps/flox/src/components/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
'use client'

import { Button } from '@cdlab996/ui/components/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cdlab996/ui/components/card'
import { Input } from '@cdlab996/ui/components/input'
import { Label } from '@cdlab996/ui/components/label'
import type React from 'react'
import { useState } from 'react'
import { authClient } from '@/lib/auth-client'

const GOOGLE_ENABLED = process.env.NEXT_PUBLIC_GOOGLE_ENABLED === 'true'

function LoginScreen() {
const [mode, setMode] = useState<'signIn' | 'signUp'>('signIn')
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)

async function submit() {
setError(null)
setLoading(true)
try {
const res =
mode === 'signIn'
? await authClient.signIn.email({ email, password })
: await authClient.signUp.email({ name, email, password })
if (res.error) {
setError(res.error.message || '操作失败,请重试')
}
// On success the session cookie is set; useSession() refetches and the
// gate swaps to the app automatically.
} catch {
setError('网络错误,请稍后再试')
} finally {
setLoading(false)
}
}

async function google() {
setError(null)
setLoading(true)
try {
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
} catch {
setError('无法跳转 Google 登录')
setLoading(false)
}
}

return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{mode === 'signIn' ? '登录 flox' : '注册 flox'}</CardTitle>
<CardDescription>
{mode === 'signIn'
? '使用邮箱密码登录以继续'
: '创建账号以继续使用'}
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
>
{mode === 'signUp' && (
<div className="space-y-2">
<Label htmlFor="name">昵称</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
autoComplete={
mode === 'signIn' ? 'current-password' : 'new-password'
}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>

{error && <p className="text-sm text-destructive">{error}</p>}

<Button type="submit" className="w-full" disabled={loading}>
{loading ? '请稍候…' : mode === 'signIn' ? '登录' : '注册'}
</Button>
</form>

{GOOGLE_ENABLED && (
<Button
type="button"
variant="outline"
className="mt-3 w-full"
disabled={loading}
onClick={() => void google()}
>
使用 Google 登录
</Button>
)}

<button
type="button"
className="mt-4 w-full text-center text-sm text-muted-foreground transition-colors hover:text-foreground"
onClick={() => {
setError(null)
setMode((m) => (m === 'signIn' ? 'signUp' : 'signIn'))
}}
>
{mode === 'signIn' ? '没有账号?去注册' : '已有账号?去登录'}
</button>
</CardContent>
</Card>
</div>
)
}

export function AuthGate({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = authClient.useSession()

if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
加载中…
</div>
)
}

if (!session) return <LoginScreen />

return <>{children}</>
}
Loading