Compare commits
12 Commits
835f3bb2bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22c3fffed4 | |||
| 065e603efb | |||
| 3a8bcb6623 | |||
| 44293deb9b | |||
| 99d1d85293 | |||
| 04c014d48b | |||
| f59e6c8582 | |||
| e323ed0a9c | |||
| 8dcae4d60f | |||
| 8a284edcb1 | |||
| fabf6a5c38 | |||
| 1b31c4da71 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
README.md
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ build/
|
|||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
test-results/
|
test-results/
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# --- deps + build use the Bun image so we honor bun.lock ---
|
||||||
|
FROM oven/bun:1.3 AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM oven/bun:1.3 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
# Dummy DATABASE_URL so Next.js can collect page data at build time.
|
||||||
|
# The real value is injected at runtime via env vars.
|
||||||
|
ENV DATABASE_URL=postgres://build:build@localhost:5432/build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# --- runner: Playwright base image so the scraper has browsers available ---
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.50.0-jammy AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
COPY --from=build /app/.next/standalone ./
|
||||||
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
COPY --from=build /app/drizzle ./drizzle
|
||||||
|
COPY --from=build /app/drizzle.config.ts ./
|
||||||
|
COPY --from=build /app/src/lib/db/schema.ts ./schema.ts
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
21
README.md
Normal file
21
README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# preis-tracker
|
||||||
|
|
||||||
|
Tracking von Produktpreisen bei Amazon, Idealo, Geizhals mit Pushover-Alerts.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
Next.js 16, TypeScript, Drizzle + PostgreSQL, Playwright + Cheerio, Zitadel OIDC, iron-session, Recharts.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill in
|
||||||
|
bun install
|
||||||
|
bunx playwright install chromium
|
||||||
|
bun run db:push
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
Coolify-App `preis.kuns.dev`. Daily scrape via Scheduled Task `0 6 * * *`.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
|
||||||
6
next-env.d.ts
vendored
6
next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/dev/types/routes.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
57
src/app/add/page.tsx
Normal file
57
src/app/add/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AddPage() {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/products', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({ error: 'unknown' }))
|
||||||
|
setError(j.error || `HTTP ${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { id } = await res.json()
|
||||||
|
router.push(`/products/${id}`)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-bold">Produkt hinzufügen</h1>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://www.amazon.de/dp/..."
|
||||||
|
className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">Unterstützt: Amazon, Idealo, Geizhals</p>
|
||||||
|
{error && <div className="rounded bg-red-950 px-3 py-2 text-sm text-red-300">{error}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Wird abgerufen…' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/app/api/alerts/[id]/route.ts
Normal file
9
src/app/api/alerts/[id]/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { db, alerts } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
await db.delete(alerts).where(eq(alerts.id, id))
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
39
src/app/api/alerts/route.ts
Normal file
39
src/app/api/alerts/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db, alerts } from '@/lib/db'
|
||||||
|
|
||||||
|
const CreateSchema = z.object({
|
||||||
|
productId: z.string().uuid(),
|
||||||
|
type: z.enum(['target_price', 'all_time_low', 'percent_drop']),
|
||||||
|
config: z.record(z.string(), z.unknown()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json()
|
||||||
|
const parsed = CreateSchema.safeParse(body)
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: parsed.error.message }, { status: 400 })
|
||||||
|
|
||||||
|
const cfg = parsed.data.config
|
||||||
|
switch (parsed.data.type) {
|
||||||
|
case 'target_price':
|
||||||
|
if (typeof cfg.threshold !== 'number' || cfg.threshold <= 0) {
|
||||||
|
return NextResponse.json({ error: 'threshold (number > 0) required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'percent_drop':
|
||||||
|
if (typeof cfg.lookback_days !== 'number' || typeof cfg.percent !== 'number') {
|
||||||
|
return NextResponse.json({ error: 'lookback_days + percent required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'all_time_low':
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db.insert(alerts).values({
|
||||||
|
productId: parsed.data.productId,
|
||||||
|
type: parsed.data.type,
|
||||||
|
config: parsed.data.config,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
return NextResponse.json(inserted, { status: 201 })
|
||||||
|
}
|
||||||
38
src/app/api/auth/callback/route.ts
Normal file
38
src/app/api/auth/callback/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth/session'
|
||||||
|
import { exchangeCode, verifyIdToken, isAllowedUser } from '@/lib/auth/zitadel'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
if (!code || !state) return NextResponse.json({ error: 'missing code/state' }, { status: 400 })
|
||||||
|
|
||||||
|
const session = await getSession()
|
||||||
|
const pending = session.loginInProgress
|
||||||
|
if (!pending || pending.state !== state) {
|
||||||
|
return NextResponse.json({ error: 'state mismatch' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the pending login atomically before doing the token exchange
|
||||||
|
// to prevent state replay on concurrent callbacks.
|
||||||
|
const { codeVerifier } = pending
|
||||||
|
delete session.loginInProgress
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
|
||||||
|
const tokens = await exchangeCode({ code, codeVerifier, redirectUri })
|
||||||
|
const claims = await verifyIdToken(tokens.id_token)
|
||||||
|
|
||||||
|
if (!isAllowedUser(claims.sub)) {
|
||||||
|
session.destroy()
|
||||||
|
return NextResponse.json({ error: 'user not allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
session.userId = claims.sub
|
||||||
|
session.email = claims.email
|
||||||
|
session.name = claims.name
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL('/', req.url))
|
||||||
|
}
|
||||||
18
src/app/api/auth/login/route.ts
Normal file
18
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth/session'
|
||||||
|
import { generateVerifier, challengeFromVerifier, generateState } from '@/lib/auth/pkce'
|
||||||
|
import { buildAuthorizeUrl } from '@/lib/auth/zitadel'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const verifier = generateVerifier()
|
||||||
|
const challenge = challengeFromVerifier(verifier)
|
||||||
|
const state = generateState()
|
||||||
|
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
|
||||||
|
|
||||||
|
const session = await getSession()
|
||||||
|
session.loginInProgress = { state, codeVerifier: verifier }
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
const url = buildAuthorizeUrl({ state, codeChallenge: challenge, redirectUri })
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
9
src/app/api/auth/logout/route.ts
Normal file
9
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth/session'
|
||||||
|
import { buildEndSessionUrl } from '@/lib/auth/zitadel'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getSession()
|
||||||
|
session.destroy()
|
||||||
|
return NextResponse.redirect(buildEndSessionUrl(`${process.env.NEXT_PUBLIC_BASE_URL}/`))
|
||||||
|
}
|
||||||
123
src/app/api/cron/scrape/route.ts
Normal file
123
src/app/api/cron/scrape/route.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { eq, and } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots, alerts } from '@/lib/db'
|
||||||
|
import { scrapeUrl } from '@/lib/scrapers'
|
||||||
|
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
|
||||||
|
import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
|
||||||
|
import { sendPush } from '@/lib/pushover'
|
||||||
|
|
||||||
|
export const maxDuration = 300
|
||||||
|
|
||||||
|
const CONCURRENCY = 2
|
||||||
|
const FAIL_WARN_THRESHOLD = 3
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const auth = req.headers.get('authorization')
|
||||||
|
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
ensureScrapersRegistered()
|
||||||
|
|
||||||
|
const productsToScrape = await db.select().from(products).where(eq(products.enabled, true))
|
||||||
|
console.log(`[cron] scraping ${productsToScrape.length} products`)
|
||||||
|
|
||||||
|
const queue = [...productsToScrape]
|
||||||
|
const summary = { ok: 0, failed: 0, alertsTriggered: 0 }
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const p = queue.shift()
|
||||||
|
if (!p) break
|
||||||
|
try {
|
||||||
|
await processProduct(p, summary)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[cron] product ${p.id} crashed`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
|
||||||
|
|
||||||
|
return NextResponse.json(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processProduct(p: typeof products.$inferSelect, summary: { ok: number; failed: number; alertsTriggered: number }) {
|
||||||
|
const result = await scrapeUrl(p.url)
|
||||||
|
await db.insert(priceSnapshots).values({
|
||||||
|
productId: p.id,
|
||||||
|
price: result.price !== null ? String(result.price) : null,
|
||||||
|
currency: result.currency,
|
||||||
|
availability: result.availability,
|
||||||
|
error: result.error ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.price === null) {
|
||||||
|
const failures = p.consecutiveFailures + 1
|
||||||
|
await db.update(products).set({
|
||||||
|
lastScrapedAt: new Date(),
|
||||||
|
consecutiveFailures: failures,
|
||||||
|
}).where(eq(products.id, p.id))
|
||||||
|
summary.failed++
|
||||||
|
if (failures === FAIL_WARN_THRESHOLD) {
|
||||||
|
await sendPush({
|
||||||
|
title: `⚠ Scrape-Fehler`,
|
||||||
|
message: `${p.name} ist ${failures}× hintereinander fehlgeschlagen: ${result.error ?? 'unknown'}`,
|
||||||
|
url: p.url,
|
||||||
|
}).catch((e) => console.error('pushover failed', e))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.ok++
|
||||||
|
await db.update(products).set({ lastScrapedAt: new Date(), consecutiveFailures: 0 }).where(eq(products.id, p.id))
|
||||||
|
|
||||||
|
const allSnapshots = await db.select().from(priceSnapshots)
|
||||||
|
.where(and(eq(priceSnapshots.productId, p.id)))
|
||||||
|
const maxId = Math.max(...allSnapshots.map((x) => x.id))
|
||||||
|
const history: SnapshotInput[] = allSnapshots
|
||||||
|
.filter((s) => s.price !== null)
|
||||||
|
.filter((s) => s.id !== maxId)
|
||||||
|
.map((s) => ({ price: Number(s.price), scrapedAt: s.scrapedAt }))
|
||||||
|
|
||||||
|
const productAlerts = await db.select().from(alerts)
|
||||||
|
.where(and(eq(alerts.productId, p.id), eq(alerts.enabled, true)))
|
||||||
|
|
||||||
|
for (const alert of productAlerts) {
|
||||||
|
const r = evaluateAlert({
|
||||||
|
alert: {
|
||||||
|
type: alert.type as 'target_price' | 'all_time_low' | 'percent_drop',
|
||||||
|
config: alert.config as Record<string, unknown>,
|
||||||
|
lastTriggeredAt: alert.lastTriggeredAt,
|
||||||
|
},
|
||||||
|
currentPrice: result.price,
|
||||||
|
history,
|
||||||
|
})
|
||||||
|
if (r.triggered) {
|
||||||
|
summary.alertsTriggered++
|
||||||
|
await sendPush({
|
||||||
|
title: alertTitle(alert.type as string, r.context, p.name),
|
||||||
|
message: alertMessage(alert.type as string, r.context, result.price),
|
||||||
|
url: p.url,
|
||||||
|
urlTitle: 'Zum Shop',
|
||||||
|
}).catch((e) => console.error('pushover failed', e))
|
||||||
|
await db.update(alerts).set({ lastTriggeredAt: new Date() }).where(eq(alerts.id, alert.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertTitle(type: string, ctx: Record<string, number | string>, name: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'target_price': return `📉 ${name} unter ${ctx.threshold}€`
|
||||||
|
case 'all_time_low': return `🎯 Allzeit-Tief: ${name}`
|
||||||
|
case 'percent_drop': return `⬇️ ${name} −${ctx.percent}%`
|
||||||
|
default: return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertMessage(type: string, ctx: Record<string, number | string>, price: number): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'target_price': return `Jetzt ${price}€ (Ziel: ${ctx.threshold}€)`
|
||||||
|
case 'all_time_low': return `Jetzt ${price}€ (vorher min: ${ctx.prevMin}€)`
|
||||||
|
case 'percent_drop': return `Jetzt ${price}€ (vorher Ø ${ctx.avg}€ in ${ctx.percent ?? '?'}% Drop)`
|
||||||
|
default: return `Jetzt ${price}€`
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/products/[id]/route.ts
Normal file
23
src/app/api/products/[id]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { eq, desc } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots, alerts } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const [product] = await db.select().from(products).where(eq(products.id, id))
|
||||||
|
if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const snapshots = await db.select().from(priceSnapshots)
|
||||||
|
.where(eq(priceSnapshots.productId, id))
|
||||||
|
.orderBy(desc(priceSnapshots.scrapedAt))
|
||||||
|
.limit(1000)
|
||||||
|
const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
|
||||||
|
|
||||||
|
return NextResponse.json({ product, snapshots, alerts: productAlerts })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
await db.delete(products).where(eq(products.id, id))
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
27
src/app/api/products/[id]/scrape/route.ts
Normal file
27
src/app/api/products/[id]/scrape/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots } from '@/lib/db'
|
||||||
|
import { scrapeUrl } from '@/lib/scrapers'
|
||||||
|
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
|
||||||
|
|
||||||
|
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
ensureScrapersRegistered()
|
||||||
|
const { id } = await params
|
||||||
|
const [product] = await db.select().from(products).where(eq(products.id, id))
|
||||||
|
if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const result = await scrapeUrl(product.url)
|
||||||
|
await db.insert(priceSnapshots).values({
|
||||||
|
productId: id,
|
||||||
|
price: result.price !== null ? String(result.price) : null,
|
||||||
|
currency: result.currency,
|
||||||
|
availability: result.availability,
|
||||||
|
error: result.error ?? null,
|
||||||
|
})
|
||||||
|
await db.update(products).set({
|
||||||
|
lastScrapedAt: new Date(),
|
||||||
|
consecutiveFailures: result.price === null ? product.consecutiveFailures + 1 : 0,
|
||||||
|
}).where(eq(products.id, id))
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
}
|
||||||
59
src/app/api/products/route.ts
Normal file
59
src/app/api/products/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { eq, sql } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots } from '@/lib/db'
|
||||||
|
import { detectShop } from '@/lib/shops'
|
||||||
|
import { scrapeUrl } from '@/lib/scrapers'
|
||||||
|
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
|
||||||
|
|
||||||
|
const AddSchema = z.object({ url: z.string().url() })
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const rows = await db.execute<{
|
||||||
|
id: string; url: string; shop: string; name: string; image_url: string | null;
|
||||||
|
last_price: string | null; last_scraped_at: Date | null; min_price: string | null;
|
||||||
|
}>(sql`
|
||||||
|
select p.id, p.url, p.shop, p.name, p.image_url, p.last_scraped_at,
|
||||||
|
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
|
||||||
|
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
|
||||||
|
from products p
|
||||||
|
where p.enabled = true
|
||||||
|
order by p.created_at desc
|
||||||
|
`)
|
||||||
|
return NextResponse.json(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
ensureScrapersRegistered()
|
||||||
|
const body = await req.json()
|
||||||
|
const parsed = AddSchema.safeParse(body)
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: 'invalid url' }, { status: 400 })
|
||||||
|
|
||||||
|
const shop = detectShop(parsed.data.url)
|
||||||
|
if (!shop) return NextResponse.json({ error: 'unsupported shop' }, { status: 400 })
|
||||||
|
|
||||||
|
const result = await scrapeUrl(parsed.data.url)
|
||||||
|
const name = result.name || parsed.data.url
|
||||||
|
|
||||||
|
const [inserted] = await db.insert(products).values({
|
||||||
|
url: parsed.data.url,
|
||||||
|
shop,
|
||||||
|
name,
|
||||||
|
imageUrl: result.imageUrl ?? null,
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
await db.insert(priceSnapshots).values({
|
||||||
|
productId: inserted.id,
|
||||||
|
price: result.price !== null ? String(result.price) : null,
|
||||||
|
currency: result.currency,
|
||||||
|
availability: result.availability,
|
||||||
|
error: result.error ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.update(products).set({
|
||||||
|
lastScrapedAt: new Date(),
|
||||||
|
consecutiveFailures: result.price === null ? 1 : 0,
|
||||||
|
}).where(eq(products.id, inserted.id))
|
||||||
|
|
||||||
|
return NextResponse.json({ id: inserted.id }, { status: 201 })
|
||||||
|
}
|
||||||
@@ -1,3 +1,63 @@
|
|||||||
export default function Home() {
|
import Link from 'next/link'
|
||||||
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
|
import { desc, eq, sql } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots } from '@/lib/db'
|
||||||
|
import { ProductCard } from '@/components/ProductCard'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const rows = await db.execute<{
|
||||||
|
id: string; url: string; shop: string; name: string; image_url: string | null;
|
||||||
|
last_price: string | null; min_price: string | null;
|
||||||
|
}>(sql`
|
||||||
|
select p.id, p.url, p.shop, p.name, p.image_url,
|
||||||
|
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
|
||||||
|
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
|
||||||
|
from products p where p.enabled = true order by p.created_at desc
|
||||||
|
`)
|
||||||
|
|
||||||
|
const sparklines = new Map<string, Array<{ price: number; t: string }>>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const snaps = await db.select({ price: priceSnapshots.price, scrapedAt: priceSnapshots.scrapedAt })
|
||||||
|
.from(priceSnapshots)
|
||||||
|
.where(eq(priceSnapshots.productId, r.id))
|
||||||
|
.orderBy(desc(priceSnapshots.scrapedAt))
|
||||||
|
.limit(30)
|
||||||
|
sparklines.set(r.id, snaps
|
||||||
|
.filter((s) => s.price !== null)
|
||||||
|
.reverse()
|
||||||
|
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl p-6">
|
||||||
|
<header className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Preis-Tracker</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href="/add" className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500">+ Produkt</Link>
|
||||||
|
<Link href="/api/auth/logout" className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Logout</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded border border-dashed border-zinc-700 p-12 text-center text-zinc-400">
|
||||||
|
Noch keine Produkte. <Link href="/add" className="text-emerald-400 underline">Erstes hinzufügen</Link>.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<ProductCard
|
||||||
|
key={r.id}
|
||||||
|
id={r.id}
|
||||||
|
name={r.name}
|
||||||
|
shop={r.shop}
|
||||||
|
imageUrl={r.image_url}
|
||||||
|
lastPrice={r.last_price}
|
||||||
|
minPrice={r.min_price}
|
||||||
|
sparkline={sparklines.get(r.id) ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/app/products/[id]/page.tsx
Normal file
55
src/app/products/[id]/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { eq, desc } from 'drizzle-orm'
|
||||||
|
import { db, products, priceSnapshots, alerts } from '@/lib/db'
|
||||||
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
|
import { AlertList } from '@/components/AlertList'
|
||||||
|
import { AlertForm } from '@/components/AlertForm'
|
||||||
|
import { ProductActions } from '@/components/ProductActions'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
const [product] = await db.select().from(products).where(eq(products.id, id))
|
||||||
|
if (!product) notFound()
|
||||||
|
|
||||||
|
const snaps = await db.select().from(priceSnapshots)
|
||||||
|
.where(eq(priceSnapshots.productId, id))
|
||||||
|
.orderBy(desc(priceSnapshots.scrapedAt))
|
||||||
|
.limit(500)
|
||||||
|
const chartData = snaps
|
||||||
|
.filter((s) => s.price !== null)
|
||||||
|
.reverse()
|
||||||
|
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() }))
|
||||||
|
|
||||||
|
const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl p-6">
|
||||||
|
<a href="/" className="text-sm text-zinc-500 hover:underline">← Zurück</a>
|
||||||
|
<div className="mt-2 flex items-start gap-4">
|
||||||
|
{product.imageUrl && <img src={product.imageUrl} alt="" className="h-24 w-24 rounded bg-white object-contain" />}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">{product.shop}</div>
|
||||||
|
<h1 className="text-xl font-bold">{product.name}</h1>
|
||||||
|
<a href={product.url} target="_blank" rel="noreferrer" className="text-sm text-emerald-400 hover:underline">Zum Shop ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Preisverlauf</h2>
|
||||||
|
<PriceChart data={chartData} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Alerts</h2>
|
||||||
|
<AlertList alerts={productAlerts.map((a) => ({ id: a.id, type: a.type, config: a.config as Record<string, unknown>, enabled: a.enabled }))} />
|
||||||
|
<div className="mt-3"><AlertForm productId={id} /></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<ProductActions productId={id} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/components/AlertForm.tsx
Normal file
54
src/components/AlertForm.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export function AlertForm({ productId }: { productId: string }) {
|
||||||
|
const [type, setType] = useState<'target_price' | 'all_time_low' | 'percent_drop'>('target_price')
|
||||||
|
const [threshold, setThreshold] = useState('')
|
||||||
|
const [percent, setPercent] = useState('10')
|
||||||
|
const [lookback, setLookback] = useState('7')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
let config: Record<string, unknown> = {}
|
||||||
|
if (type === 'target_price') config = { threshold: Number(threshold) }
|
||||||
|
if (type === 'percent_drop') config = { percent: Number(percent), lookback_days: Number(lookback) }
|
||||||
|
const res = await fetch('/api/alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ productId, type, config }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setThreshold('')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-2 rounded border border-zinc-800 p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className="rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm">
|
||||||
|
<option value="target_price">Zielpreis</option>
|
||||||
|
<option value="all_time_low">Allzeit-Tief</option>
|
||||||
|
<option value="percent_drop">% Drop</option>
|
||||||
|
</select>
|
||||||
|
{type === 'target_price' && (
|
||||||
|
<input type="number" step="0.01" required value={threshold} onChange={(e) => setThreshold(e.target.value)}
|
||||||
|
placeholder="€" className="w-24 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
|
||||||
|
)}
|
||||||
|
{type === 'percent_drop' && (
|
||||||
|
<>
|
||||||
|
<input type="number" required value={percent} onChange={(e) => setPercent(e.target.value)}
|
||||||
|
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
|
||||||
|
<span className="self-center text-xs">% in</span>
|
||||||
|
<input type="number" required value={lookback} onChange={(e) => setLookback(e.target.value)}
|
||||||
|
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
|
||||||
|
<span className="self-center text-xs">Tagen</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="rounded bg-emerald-600 px-3 py-1 text-sm hover:bg-emerald-500">+ Alert</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/components/AlertList.tsx
Normal file
32
src/components/AlertList.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
interface Alert { id: string; type: string; config: Record<string, unknown>; enabled: boolean }
|
||||||
|
|
||||||
|
export function AlertList({ alerts }: { alerts: Alert[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
async function del(id: string) {
|
||||||
|
await fetch(`/api/alerts/${id}`, { method: 'DELETE' })
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
if (alerts.length === 0) return <p className="text-sm text-zinc-500">Keine Alerts.</p>
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{alerts.map((a) => (
|
||||||
|
<li key={a.id} className="flex items-center justify-between rounded border border-zinc-800 px-3 py-2 text-sm">
|
||||||
|
<span>{labelFor(a)}</span>
|
||||||
|
<button onClick={() => del(a.id)} className="text-xs text-red-400 hover:underline">Löschen</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(a: Alert): string {
|
||||||
|
switch (a.type) {
|
||||||
|
case 'target_price': return `Zielpreis ≤ ${a.config.threshold}€`
|
||||||
|
case 'all_time_low': return `Allzeit-Tief`
|
||||||
|
case 'percent_drop': return `−${a.config.percent}% in ${a.config.lookback_days} Tagen`
|
||||||
|
default: return a.type
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/components/PriceChart.tsx
Normal file
35
src/components/PriceChart.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
|
||||||
|
|
||||||
|
interface Snap { price: number; t: string }
|
||||||
|
|
||||||
|
export function PriceChart({ data }: { data: Snap[] }) {
|
||||||
|
const [range, setRange] = useState<'30d' | '90d' | 'all'>('30d')
|
||||||
|
const cutoff = range === 'all' ? 0 : Date.now() - (range === '30d' ? 30 : 90) * 86400_000
|
||||||
|
const filtered = data.filter((d) => new Date(d.t).getTime() >= cutoff)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex gap-2">
|
||||||
|
{(['30d', '90d', 'all'] as const).map((r) => (
|
||||||
|
<button key={r} onClick={() => setRange(r)}
|
||||||
|
className={`rounded px-2 py-1 text-xs ${range === r ? 'bg-emerald-600' : 'bg-zinc-800'}`}>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={filtered}>
|
||||||
|
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="t" tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => new Date(v).toLocaleDateString('de-DE')} />
|
||||||
|
<YAxis tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => `${v}€`} domain={['dataMin', 'dataMax']} />
|
||||||
|
<Tooltip contentStyle={{ background: '#18181b', border: '1px solid #3f3f46' }}
|
||||||
|
labelFormatter={(v) => new Date(v).toLocaleString('de-DE')}
|
||||||
|
formatter={(v) => [`${v}€`, 'Preis']} />
|
||||||
|
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/components/ProductActions.tsx
Normal file
19
src/components/ProductActions.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function ProductActions({ productId }: { productId: string }) {
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch(`/api/products/${productId}/scrape`, { method: 'POST' })
|
||||||
|
if (res.ok) location.reload()
|
||||||
|
}
|
||||||
|
async function del() {
|
||||||
|
if (!confirm('Wirklich löschen?')) return
|
||||||
|
const res = await fetch(`/api/products/${productId}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) location.href = '/'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={refresh} className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Jetzt aktualisieren</button>
|
||||||
|
<button onClick={del} className="rounded bg-red-900 px-3 py-1.5 text-sm hover:bg-red-800">Löschen</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/components/ProductCard.tsx
Normal file
37
src/components/ProductCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Sparkline } from './Sparkline'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
shop: string
|
||||||
|
imageUrl: string | null
|
||||||
|
lastPrice: string | null
|
||||||
|
minPrice: string | null
|
||||||
|
sparkline: Array<{ price: number; t: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCard(p: Props) {
|
||||||
|
const last = p.lastPrice ? Number(p.lastPrice) : null
|
||||||
|
const min = p.minPrice ? Number(p.minPrice) : null
|
||||||
|
const deltaFromMin = last !== null && min !== null ? (last - min).toFixed(2) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/products/${p.id}`} className="block rounded-lg border border-zinc-800 bg-zinc-900 p-4 hover:border-zinc-700 transition">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{p.imageUrl && <img src={p.imageUrl} alt="" className="h-16 w-16 object-contain rounded bg-white" />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-zinc-500">{p.shop}</div>
|
||||||
|
<div className="truncate text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-lg font-semibold">{last !== null ? `${last.toFixed(2)} €` : '—'}</span>
|
||||||
|
{deltaFromMin !== null && (
|
||||||
|
<span className="text-xs text-zinc-400">+{deltaFromMin} € vom Tief</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2"><Sparkline data={p.sparkline} /></div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/components/Sparkline.tsx
Normal file
14
src/components/Sparkline.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use client'
|
||||||
|
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
export function Sparkline({ data }: { data: Array<{ price: number; t: string }> }) {
|
||||||
|
if (data.length === 0) return <div className="h-10 text-xs text-zinc-500">keine Daten</div>
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={40}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<YAxis hide domain={['dataMin', 'dataMax']} />
|
||||||
|
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/lib/auth/pkce.ts
Normal file
13
src/lib/auth/pkce.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { randomBytes, createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
export function generateVerifier(): string {
|
||||||
|
return randomBytes(64).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function challengeFromVerifier(verifier: string): string {
|
||||||
|
return createHash('sha256').update(verifier).digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateState(): string {
|
||||||
|
return randomBytes(32).toString('base64url')
|
||||||
|
}
|
||||||
29
src/lib/auth/session.ts
Normal file
29
src/lib/auth/session.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { SessionOptions } from 'iron-session'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
userId?: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
loginInProgress?: { state: string; codeVerifier: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionOptions: SessionOptions = {
|
||||||
|
password: process.env.SESSION_PASSWORD || '',
|
||||||
|
cookieName: 'preis_tracker_session',
|
||||||
|
cookieOptions: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 7 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
if (!sessionOptions.password || (typeof sessionOptions.password === 'string' && sessionOptions.password.length < 32)) {
|
||||||
|
throw new Error('SESSION_PASSWORD must be set to a 32+ char value')
|
||||||
|
}
|
||||||
|
const c = await cookies()
|
||||||
|
return getIronSession<SessionData>(c, sessionOptions)
|
||||||
|
}
|
||||||
87
src/lib/auth/zitadel.ts
Normal file
87
src/lib/auth/zitadel.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
||||||
|
|
||||||
|
const issuer = process.env.ZITADEL_ISSUER!
|
||||||
|
const clientId = process.env.ZITADEL_CLIENT_ID!
|
||||||
|
|
||||||
|
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null
|
||||||
|
function getJWKS() {
|
||||||
|
if (!jwksCache) {
|
||||||
|
jwksCache = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`))
|
||||||
|
}
|
||||||
|
return jwksCache
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenSet {
|
||||||
|
access_token: string
|
||||||
|
id_token: string
|
||||||
|
refresh_token?: string
|
||||||
|
expires_in: number
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(args: {
|
||||||
|
code: string
|
||||||
|
codeVerifier: string
|
||||||
|
redirectUri: string
|
||||||
|
}): Promise<TokenSet> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: clientId,
|
||||||
|
code: args.code,
|
||||||
|
code_verifier: args.codeVerifier,
|
||||||
|
redirect_uri: args.redirectUri,
|
||||||
|
})
|
||||||
|
const res = await fetch(`${issuer}/oauth/v2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Zitadel token exchange failed: ${res.status} ${await res.text()}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdTokenClaims {
|
||||||
|
sub: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
iss: string
|
||||||
|
aud: string | string[]
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyIdToken(idToken: string): Promise<IdTokenClaims> {
|
||||||
|
const { payload } = await jwtVerify(idToken, getJWKS(), {
|
||||||
|
issuer,
|
||||||
|
audience: clientId,
|
||||||
|
})
|
||||||
|
return payload as unknown as IdTokenClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorizeUrl(args: {
|
||||||
|
state: string
|
||||||
|
codeChallenge: string
|
||||||
|
redirectUri: string
|
||||||
|
}): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: clientId,
|
||||||
|
scope: 'openid email profile',
|
||||||
|
redirect_uri: args.redirectUri,
|
||||||
|
state: args.state,
|
||||||
|
code_challenge: args.codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
})
|
||||||
|
return `${issuer}/oauth/v2/authorize?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEndSessionUrl(redirectTo: string): string {
|
||||||
|
const params = new URLSearchParams({ post_logout_redirect_uri: redirectTo })
|
||||||
|
return `${issuer}/oidc/v1/end_session?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedUser(sub: string): boolean {
|
||||||
|
const allowed = (process.env.ALLOWED_USER_IDS || '').split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
return allowed.includes(sub)
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ export const idealoScraper: PriceScraper = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractJsonLdPrice($: cheerio.CheerioAPI): string | null {
|
function extractJsonLdPrice($: cheerio.Root): string | null {
|
||||||
const scripts = $('script[type="application/ld+json"]')
|
const scripts = $('script[type="application/ld+json"]')
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
for (let i = 0; i < scripts.length; i++) {
|
||||||
const raw = $(scripts[i]).contents().text()
|
const raw = $(scripts[i]).contents().text()
|
||||||
|
|||||||
25
src/middleware.ts
Normal file
25
src/middleware.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
|
import { sessionOptions, type SessionData } from '@/lib/auth/session'
|
||||||
|
|
||||||
|
const PUBLIC_PREFIXES = ['/api/auth/', '/api/cron/', '/_next/', '/favicon']
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl
|
||||||
|
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return NextResponse.next()
|
||||||
|
|
||||||
|
const res = NextResponse.next()
|
||||||
|
const session = await getIronSession<SessionData>(req, res, sessionOptions)
|
||||||
|
|
||||||
|
if (!session.userId) {
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(new URL('/api/auth/login', req.url))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user