feat: Implement S3 proxy client, enhance session handling, and improve Drizzle migration script.
This commit is contained in:
parent
30ad62aa41
commit
f7af557073
13 changed files with 150 additions and 77 deletions
|
|
@ -36,9 +36,21 @@ if (!process.env.POSTGRES_URL) {
|
|||
}
|
||||
}
|
||||
|
||||
const result = spawnSync('drizzle-kit', ['migrate', ...configArgs, ...extraArgs], {
|
||||
function resolveDrizzleKitBin() {
|
||||
const binName = process.platform === 'win32' ? 'drizzle-kit.cmd' : 'drizzle-kit';
|
||||
const localBin = path.join(process.cwd(), 'node_modules', '.bin', binName);
|
||||
if (fs.existsSync(localBin)) return localBin;
|
||||
return 'drizzle-kit';
|
||||
}
|
||||
|
||||
const result = spawnSync(resolveDrizzleKitBin(), ['migrate', ...configArgs, ...extraArgs], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
|
|
|||
|
|
@ -153,6 +153,17 @@ async function waitForEndpoint(url, timeoutSeconds) {
|
|||
throw new Error(`Embedded weed mini did not become ready at ${url} within ${timeoutSeconds}s.`);
|
||||
}
|
||||
|
||||
function isRunningInDocker() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
if (fs.existsSync('/.dockerenv')) return true;
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
|
||||
return /(docker|containerd|kubepods|podman)/i.test(cgroup);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnMainCommand(command, env) {
|
||||
const [cmd, ...args] = command;
|
||||
const child = spawn(cmd, args, {
|
||||
|
|
@ -351,41 +362,42 @@ async function main() {
|
|||
runtimeEnv.S3_SECRET_ACCESS_KEY = withDefault(runtimeEnv.S3_SECRET_ACCESS_KEY, randomBytes(32).toString('hex'));
|
||||
runtimeEnv.AWS_ACCESS_KEY_ID = runtimeEnv.S3_ACCESS_KEY_ID;
|
||||
runtimeEnv.AWS_SECRET_ACCESS_KEY = runtimeEnv.S3_SECRET_ACCESS_KEY;
|
||||
|
||||
|
||||
|
||||
fs.mkdirSync(runtimeEnv.WEED_MINI_DIR, { recursive: true });
|
||||
const runningInDocker = isRunningInDocker();
|
||||
const waitSec = Number.parseInt(runtimeEnv.WEED_MINI_WAIT_SEC || '20', 10);
|
||||
const waitTimeout = Number.isFinite(waitSec) ? waitSec : 20;
|
||||
const launchWeed = (endpointUrl) => {
|
||||
const parsedEndpoint = parseS3Endpoint(endpointUrl);
|
||||
const weedArgs = ['mini', `-dir=${runtimeEnv.WEED_MINI_DIR}`];
|
||||
weedArgs.push(`-s3.port=${parsedEndpoint.port}`);
|
||||
if (runningInDocker) {
|
||||
weedArgs.push('-ip.bind=0.0.0.0');
|
||||
}
|
||||
|
||||
weedProc = spawn('weed', weedArgs, {
|
||||
env: runtimeEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stopWeedStdoutForward = forwardChildStream(weedProc.stdout, process.stdout);
|
||||
stopWeedStderrForward = forwardChildStream(weedProc.stderr, process.stderr);
|
||||
weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined);
|
||||
|
||||
weedProc.on('exit', (code, signal) => {
|
||||
if (typeof code === 'number' && code !== 0) {
|
||||
console.error(`Embedded weed mini exited with code ${code}.`);
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
console.error(`Embedded weed mini exited due to signal ${signal}.`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
console.log('Starting embedded SeaweedFS weed mini...');
|
||||
const weedArgs = ['mini', `-dir=${runtimeEnv.WEED_MINI_DIR}`];
|
||||
if (configuredS3Endpoint || baseUrlHost) {
|
||||
const endpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT);
|
||||
weedArgs.push(`-ip=${endpoint.hostname}`);
|
||||
weedArgs.push(`-s3.port=${endpoint.port}`);
|
||||
}
|
||||
|
||||
weedProc = spawn('weed', weedArgs, {
|
||||
env: runtimeEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stopWeedStdoutForward = forwardChildStream(weedProc.stdout, process.stdout);
|
||||
stopWeedStderrForward = forwardChildStream(weedProc.stderr, process.stderr);
|
||||
weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined);
|
||||
|
||||
weedProc.on('exit', (code, signal) => {
|
||||
if (typeof code === 'number' && code !== 0) {
|
||||
console.error(`Embedded weed mini exited with code ${code}.`);
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
console.error(`Embedded weed mini exited due to signal ${signal}.`);
|
||||
}
|
||||
});
|
||||
|
||||
const endpoint = runtimeEnv.S3_ENDPOINT;
|
||||
const waitSec = Number.parseInt(runtimeEnv.WEED_MINI_WAIT_SEC || '20', 10);
|
||||
await waitForEndpoint(endpoint, Number.isFinite(waitSec) ? waitSec : 20);
|
||||
console.log(`Embedded SeaweedFS is ready at ${endpoint}`);
|
||||
launchWeed(runtimeEnv.S3_ENDPOINT);
|
||||
const startupEndpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT);
|
||||
await waitForEndpoint(`http://127.0.0.1:${startupEndpoint.port}`, waitTimeout);
|
||||
console.log(`Embedded SeaweedFS is ready at ${runtimeEnv.S3_ENDPOINT}`);
|
||||
}
|
||||
|
||||
const shouldRunStorageMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_FS_MIGRATIONS', true);
|
||||
|
|
|
|||
|
|
@ -250,6 +250,13 @@ export default function PublicLayout({ children }: { children: ReactNode }) {
|
|||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="landing">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { auth } from '@/lib/server/auth/auth';
|
||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Read and Listen to Documents',
|
||||
|
|
@ -45,23 +40,7 @@ export const metadata: Metadata = {
|
|||
},
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function LandingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const skipRedirect = params.redirect === 'false';
|
||||
|
||||
if (!skipRedirect && isAuthEnabled() && auth) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (session?.user) {
|
||||
redirect('/app');
|
||||
}
|
||||
}
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
|
|
@ -71,7 +50,7 @@ export default async function LandingPage({
|
|||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 4rem;
|
||||
text-align: center;
|
||||
animation: landing-fade-up 0.8s ease-out 0.15s both;
|
||||
animation: landing-fade-up 0.45s ease-out both;
|
||||
}
|
||||
.landing-hero-badge {
|
||||
display: inline-flex;
|
||||
|
|
@ -140,12 +119,9 @@ export default async function LandingPage({
|
|||
}
|
||||
.landing-feature-card {
|
||||
padding: 2rem;
|
||||
animation: landing-scale-in 0.6s ease-out both;
|
||||
animation: landing-scale-in 0.45s ease-out both;
|
||||
transition: transform 0.3s, border-color 0.3s;
|
||||
}
|
||||
.landing-feature-card:nth-child(1) { animation-delay: 0.3s; }
|
||||
.landing-feature-card:nth-child(2) { animation-delay: 0.4s; }
|
||||
.landing-feature-card:nth-child(3) { animation-delay: 0.5s; }
|
||||
.landing-feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: color-mix(in srgb, var(--g-accent), transparent 50%);
|
||||
|
|
@ -181,7 +157,7 @@ export default async function LandingPage({
|
|||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 5rem;
|
||||
animation: landing-fade-up 0.7s ease-out 0.55s both;
|
||||
animation: landing-fade-up 0.45s ease-out both;
|
||||
}
|
||||
.landing-tts-inner {
|
||||
display: grid;
|
||||
|
|
@ -260,7 +236,7 @@ export default async function LandingPage({
|
|||
margin: 0 auto;
|
||||
padding: 0 1.5rem 5rem;
|
||||
text-align: center;
|
||||
animation: landing-fade-up 0.7s ease-out 1s both;
|
||||
animation: landing-fade-up 0.45s ease-out both;
|
||||
}
|
||||
.landing-formats-label {
|
||||
font-family: var(--g-display);
|
||||
|
|
@ -300,7 +276,7 @@ export default async function LandingPage({
|
|||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 5rem;
|
||||
animation: landing-fade-up 0.7s ease-out 1.1s both;
|
||||
animation: landing-fade-up 0.45s ease-out both;
|
||||
}
|
||||
.landing-cta-card {
|
||||
padding: 3rem 2rem;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.info('[blob-fallback] download proxy used', { id });
|
||||
|
||||
const rows = (await db
|
||||
.select({ id: documents.id, userId: documents.userId, name: documents.name })
|
||||
.from(documents)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export async function GET(req: NextRequest) {
|
|||
const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`;
|
||||
const directUrl = await presignGet(doc.id, testNamespace).catch(() => null);
|
||||
if (!directUrl) {
|
||||
console.warn('[blob-fallback] presign download unavailable, redirecting to proxy fallback', { id: doc.id });
|
||||
return NextResponse.redirect(fallbackUrl, {
|
||||
status: 307,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ export async function GET(req: NextRequest) {
|
|||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.info('[blob-fallback] preview proxy used', {
|
||||
id,
|
||||
snippetRequested,
|
||||
});
|
||||
|
||||
const rows = (await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null);
|
||||
if (!directUrl) {
|
||||
console.warn('[blob-fallback] presign preview unavailable, redirecting to proxy fallback', { id: doc.id });
|
||||
return NextResponse.redirect(fallbackUrl, {
|
||||
status: 307,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
|
|
|
|||
|
|
@ -42,10 +42,15 @@ export async function PUT(req: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
console.info('[blob-fallback] upload proxy used', {
|
||||
id,
|
||||
contentType,
|
||||
bytes: body.byteLength,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, id });
|
||||
} catch (error) {
|
||||
console.error('Error proxy-uploading document blob:', error);
|
||||
return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
PutObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getS3Client, getS3Config } from '@/lib/server/storage/s3';
|
||||
import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3';
|
||||
|
||||
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
|
||||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||
|
|
@ -114,7 +114,7 @@ export async function headDocumentBlob(
|
|||
namespace: string | null,
|
||||
): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentKey(id, namespace);
|
||||
const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key }));
|
||||
return {
|
||||
|
|
@ -131,7 +131,7 @@ export async function getDocumentRange(
|
|||
namespace: string | null,
|
||||
): Promise<Buffer> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentKey(id, namespace);
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({
|
||||
|
|
@ -145,7 +145,7 @@ export async function getDocumentRange(
|
|||
|
||||
export async function getDocumentBlob(id: string, namespace: string | null): Promise<Buffer> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentKey(id, namespace);
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({
|
||||
|
|
@ -158,7 +158,7 @@ export async function getDocumentBlob(id: string, namespace: string | null): Pro
|
|||
|
||||
export async function getDocumentBlobStream(id: string, namespace: string | null): Promise<DocumentBlobBody> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentKey(id, namespace);
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({
|
||||
|
|
@ -194,7 +194,7 @@ export async function putDocumentBlob(
|
|||
namespace: string | null,
|
||||
): Promise<void> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentKey(id, namespace);
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
|
|
@ -210,7 +210,7 @@ export async function putDocumentBlob(
|
|||
|
||||
export async function deleteDocumentBlob(id: string, namespace: string | null): Promise<void> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentKey(id, namespace);
|
||||
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key }));
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ export function isMissingBlobError(error: unknown): boolean {
|
|||
|
||||
export async function deleteDocumentPrefix(prefix: string): Promise<number> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const cleanedPrefix = prefix.replace(/^\/+/, '');
|
||||
let deleted = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { deleteDocumentPrefix, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||
import { getS3Client, getS3Config } from '@/lib/server/storage/s3';
|
||||
import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3';
|
||||
|
||||
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
|
||||
const DEFAULT_NAMESPACE_SEGMENT = '_default';
|
||||
|
|
@ -84,7 +84,7 @@ export async function headDocumentPreview(
|
|||
namespace: string | null,
|
||||
): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentPreviewKey(documentId, namespace);
|
||||
const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key }));
|
||||
return {
|
||||
|
|
@ -96,7 +96,7 @@ export async function headDocumentPreview(
|
|||
|
||||
export async function getDocumentPreviewBuffer(documentId: string, namespace: string | null): Promise<Buffer> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentPreviewKey(documentId, namespace);
|
||||
const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key }));
|
||||
return bodyToBuffer(res.Body);
|
||||
|
|
@ -109,7 +109,7 @@ export async function putDocumentPreviewBuffer(
|
|||
options?: { ifNoneMatch?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = getS3Config();
|
||||
const client = getS3Client();
|
||||
const client = getS3ProxyClient();
|
||||
const key = documentPreviewKey(documentId, namespace);
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type S3Config = {
|
|||
};
|
||||
|
||||
let cachedClient: S3Client | null = null;
|
||||
let cachedLoopbackClient: S3Client | null = null;
|
||||
let cachedConfig: S3Config | null = null;
|
||||
|
||||
function parseBool(value: string | undefined): boolean {
|
||||
|
|
@ -25,6 +26,24 @@ function normalizePrefix(prefix: string | undefined): string {
|
|||
return base.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function isEmbeddedWeedMiniEnabled(): boolean {
|
||||
const raw = process.env.USE_EMBEDDED_WEED_MINI;
|
||||
if (raw == null || raw.trim() === '') return true;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function loopbackEndpoint(endpoint: string | undefined): string | undefined {
|
||||
if (!endpoint) return endpoint;
|
||||
try {
|
||||
const parsed = new URL(endpoint);
|
||||
parsed.hostname = '127.0.0.1';
|
||||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
|
||||
} catch {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
function loadS3ConfigFromEnv(): S3Config | null {
|
||||
const bucket = process.env.S3_BUCKET?.trim();
|
||||
const region = process.env.S3_REGION?.trim();
|
||||
|
|
@ -80,3 +99,22 @@ export function getS3Client(): S3Client {
|
|||
return cachedClient;
|
||||
}
|
||||
|
||||
export function getS3ProxyClient(): S3Client {
|
||||
const config = getS3Config();
|
||||
const useLoopback = isEmbeddedWeedMiniEnabled();
|
||||
if (!useLoopback) {
|
||||
return getS3Client();
|
||||
}
|
||||
|
||||
if (cachedLoopbackClient) return cachedLoopbackClient;
|
||||
cachedLoopbackClient = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: loopbackEndpoint(config.endpoint),
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
});
|
||||
return cachedLoopbackClient;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type { NextRequest } from 'next/server';
|
|||
* @see https://www.better-auth.com/docs/concepts/session-management
|
||||
*/
|
||||
const SESSION_COOKIE = 'better-auth.session_token';
|
||||
const SECURE_SESSION_COOKIE = '__Secure-better-auth.session_token';
|
||||
const SESSION_COOKIE_NAMES = [SESSION_COOKIE, SECURE_SESSION_COOKIE];
|
||||
|
||||
/**
|
||||
* Routes that never require a session cookie.
|
||||
|
|
@ -43,6 +45,18 @@ export function middleware(request: NextRequest) {
|
|||
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Fast-path redirect for signed-in users hitting the public landing page.
|
||||
// This avoids extra server work in the landing page render path.
|
||||
if (pathname === '/' && request.nextUrl.searchParams.get('redirect') !== 'false') {
|
||||
const hasSession = SESSION_COOKIE_NAMES.some((name) => request.cookies.has(name));
|
||||
if (hasSession) {
|
||||
const appUrl = request.nextUrl.clone();
|
||||
appUrl.pathname = '/app';
|
||||
appUrl.search = '';
|
||||
return NextResponse.redirect(appUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Public routes are always accessible.
|
||||
if (isPublicPath(pathname)) {
|
||||
return NextResponse.next();
|
||||
|
|
@ -55,7 +69,7 @@ export function middleware(request: NextRequest) {
|
|||
}
|
||||
|
||||
// Check for the presence of a session cookie.
|
||||
const hasSession = request.cookies.has(SESSION_COOKIE);
|
||||
const hasSession = SESSION_COOKIE_NAMES.some((name) => request.cookies.has(name));
|
||||
|
||||
if (!hasSession) {
|
||||
// API routes get a 401 instead of a redirect.
|
||||
|
|
|
|||
Loading…
Reference in a new issue