Remove .env.example and fix auth cookie secure flag

Delete the environment variable example file. Add error handling to the
Ollama AI route. Update Docker configuration to support Ollama services
and create required image directories. Fix indentation in auth library
and allow overriding secure cookie setting in non-production environments.
This commit is contained in:
2026-05-20 00:39:25 +02:00
parent 499894474a
commit 947b64b50e
5 changed files with 82 additions and 68 deletions
-9
View File
@@ -1,9 +0,0 @@
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/loam
# Auth (required for user accounts)
JWT_SECRET=change-this-to-a-long-random-string-in-production
# AI recommendations (optional, defaults shown)
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
+2
View File
@@ -27,6 +27,8 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
RUN mkdir -p /app/public/images/user && chown -R nextjs:nodejs /app/public
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
+16 -3
View File
@@ -4,15 +4,27 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- '3333:3000' - "3333:3000"
environment: environment:
DATABASE_URL: postgresql://bikeapp:bikeapp@db:5432/bikeapp DATABASE_URL: postgresql://bikeapp:bikeapp@db:5432/bikeapp
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
OLLAMA_URL: http://ollama:11434
COOKIE_SECURE: "false"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
# ollama:
# condition: service_started
restart: unless-stopped restart: unless-stopped
# ollama:
# image: ollama/ollama:latest
# ports:
# - "11434:11434"
# volumes:
# - ollama_data:/root/.ollama
# restart: unless-stopped
db: db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
@@ -22,12 +34,13 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- '5432:5432' - "5432:5432"
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U bikeapp -d bikeapp'] test: ["CMD-SHELL", "pg_isready -U bikeapp -d bikeapp"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
volumes: volumes:
postgres_data: postgres_data:
# ollama_data:
+24 -16
View File
@@ -47,22 +47,30 @@ export async function POST(request: Request) {
const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434"; const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b"; const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b";
const response = await fetch(`${ollamaUrl}/api/chat`, { let response: Response;
method: "POST", try {
headers: { "Content-Type": "application/json" }, response = await fetch(`${ollamaUrl}/api/chat`, {
body: JSON.stringify({ method: "POST",
model, headers: { "Content-Type": "application/json" },
stream: false, body: JSON.stringify({
messages: [ model,
{ stream: false,
role: "system", messages: [
content: {
"Use metric units plus PSI/clicks. Structure output as Fork, Shock, Tires, Rationale, Next Ride.", role: "system",
}, content:
{ role: "user", content: prompt }, "Use metric units plus PSI/clicks. Structure output as Fork, Shock, Tires, Rationale, Next Ride.",
], },
}), { role: "user", content: prompt },
}); ],
}),
});
} catch (err) {
return NextResponse.json(
{ error: `Unable to reach Ollama at ${ollamaUrl}. Is the Ollama service running?` },
{ status: 503 },
);
}
if (!response.ok) { if (!response.ok) {
return NextResponse.json( return NextResponse.json(
+40 -40
View File
@@ -7,74 +7,74 @@ const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET ?? "dev-secre
const COOKIE_NAME = "loam-session"; const COOKIE_NAME = "loam-session";
export type AuthUser = { export type AuthUser = {
id: string; id: string;
email: string; email: string;
name: string | null; name: string | null;
}; };
export async function hashPassword(password: string) { export async function hashPassword(password: string) {
return bcrypt.hash(password, 12); return bcrypt.hash(password, 12);
} }
export async function verifyPassword(password: string, hash: string) { export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }
export async function createToken(user: AuthUser) { export async function createToken(user: AuthUser) {
return new SignJWT({ id: user.id, email: user.email, name: user.name }) return new SignJWT({ id: user.id, email: user.email, name: user.name })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setIssuedAt() .setIssuedAt()
.setExpirationTime("7d") .setExpirationTime("7d")
.sign(JWT_SECRET); .sign(JWT_SECRET);
} }
export async function verifyToken(token: string): Promise<AuthUser | null> { export async function verifyToken(token: string): Promise<AuthUser | null> {
try { try {
const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 }); const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 });
if (typeof payload.id !== "string" || typeof payload.email !== "string") return null; if (typeof payload.id !== "string" || typeof payload.email !== "string") return null;
return { id: payload.id, email: payload.email, name: (payload.name as string | null) ?? null }; return { id: payload.id, email: payload.email, name: (payload.name as string | null) ?? null };
} catch { } catch {
return null; return null;
} }
} }
export async function getCurrentUser(): Promise<AuthUser | null> { export async function getCurrentUser(): Promise<AuthUser | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value; const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null; if (!token) return null;
return verifyToken(token); return verifyToken(token);
} }
export async function requireAuth(): Promise<AuthUser> { export async function requireAuth(): Promise<AuthUser> {
const user = await getCurrentUser(); const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized"); if (!user) throw new Error("Unauthorized");
return user; return user;
} }
export async function setSessionCookie(token: string) { export async function setSessionCookie(token: string) {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, { cookieStore.set(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production" && process.env.COOKIE_SECURE !== "false",
sameSite: "lax", sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
path: "/", path: "/",
}); });
} }
export async function clearSessionCookie() { export async function clearSessionCookie() {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME); cookieStore.delete(COOKIE_NAME);
} }
export async function findUserByEmail(email: string) { export async function findUserByEmail(email: string) {
return prisma.user.findUnique({ where: { email: email.toLowerCase().trim() } }); return prisma.user.findUnique({ where: { email: email.toLowerCase().trim() } });
} }
export async function createUser(email: string, password: string, name?: string) { export async function createUser(email: string, password: string, name?: string) {
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { email: email.toLowerCase().trim(), passwordHash, name: name || null }, data: { email: email.toLowerCase().trim(), passwordHash, name: name || null },
}); });
return { id: user.id, email: user.email, name: user.name }; return { id: user.id, email: user.email, name: user.name };
} }