feat: Implement S3 proxy client, enhance session handling, and improve Drizzle migration script.

This commit is contained in:
Richard R 2026-02-18 02:39:54 -07:00
parent 30ad62aa41
commit f7af557073
13 changed files with 150 additions and 77 deletions

View file

@ -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);

View file

@ -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);

View file

@ -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">

View file

@ -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;

View file

@ -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)

View file

@ -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' },

View file

@ -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,

View file

@ -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' },

View file

@ -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 });
}
}

View file

@ -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;

View file

@ -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({

View file

@ -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;
}

View file

@ -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.