Milstolpe 1: skelett — Fastify-backend med login, Vue-frontend (mörkt Jellyseerr-tema), Dockerfile
All checks were successful
build-and-push / build (push) Successful in 32s
All checks were successful
build-and-push / build (push) Successful in 32s
- backend: /api/health, /api/login|logout|session, sessionscookie, timingSafeEqual-lösenordskoll, allt annat under /api kräver inloggning, serverar byggd frontend med SPA-fallback; 4 tester (node:test) - frontend: Vue 3 + Vite, login-sida, Upptäck-skal, sökfält (stub), mörkt tema enligt Jellyseerr-förlagan - docker: multi-stage arm64-image; config-plumbing för Lidarr/Jellyfin/ Gemini via env (Jellyfin-historik prioriterad som signal per Björn) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
1133
backend/package-lock.json
generated
Normal file
1133
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
backend/package.json
Normal file
17
backend/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "lyssnarr-backend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/session": "^11.1.0",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"fastify": "^5.3.2"
|
||||
}
|
||||
}
|
||||
80
backend/src/app.js
Normal file
80
backend/src/app.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import Fastify from 'fastify'
|
||||
import fastifyCookie from '@fastify/cookie'
|
||||
import fastifySession from '@fastify/session'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session'])
|
||||
|
||||
function passwordOk(given, expected) {
|
||||
if (typeof given !== 'string' || !expected) return false
|
||||
const a = Buffer.from(given)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
export function buildApp(config) {
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
||||
app.register(fastifyCookie)
|
||||
app.register(fastifySession, {
|
||||
secret: config.sessionSecret,
|
||||
cookie: {
|
||||
secure: 'auto',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
})
|
||||
|
||||
// allt under /api utom OPEN_ROUTES kräver inloggad session
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (!req.url.startsWith('/api/')) return
|
||||
const route = req.url.split('?')[0]
|
||||
if (OPEN_ROUTES.has(route)) return
|
||||
if (req.session.get('user')) return
|
||||
return reply.code(401).send({ error: 'ej inloggad' })
|
||||
})
|
||||
|
||||
app.get('/api/health', async () => ({ status: 'ok' }))
|
||||
|
||||
app.get('/api/session', async (req) => ({
|
||||
authenticated: Boolean(req.session.get('user')),
|
||||
}))
|
||||
|
||||
app.post('/api/login', async (req, reply) => {
|
||||
if (!config.appPassword) {
|
||||
return reply.code(500).send({ error: 'APP_PASSWORD är inte satt' })
|
||||
}
|
||||
const { password } = req.body ?? {}
|
||||
if (!passwordOk(password, config.appPassword)) {
|
||||
return reply.code(401).send({ error: 'fel lösenord' })
|
||||
}
|
||||
req.session.set('user', 'brasse')
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/api/logout', async (req) => {
|
||||
await req.session.destroy()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// byggd frontend (finns bara i containern / efter npm run build)
|
||||
const publicDir = path.resolve(__dirname, '../public')
|
||||
if (existsSync(publicDir)) {
|
||||
app.register(fastifyStatic, { root: publicDir })
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api/')) {
|
||||
return reply.code(404).send({ error: 'finns inte' })
|
||||
}
|
||||
return reply.sendFile('index.html')
|
||||
})
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
21
backend/src/config.js
Normal file
21
backend/src/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export function loadConfig(env = process.env) {
|
||||
return {
|
||||
port: Number(env.PORT ?? 3000),
|
||||
host: env.HOST ?? '0.0.0.0',
|
||||
appPassword: env.APP_PASSWORD ?? '',
|
||||
sessionSecret: env.SESSION_SECRET ?? '',
|
||||
dataDir: env.DATA_DIR ?? '/data',
|
||||
lidarr: {
|
||||
url: env.LIDARR_URL ?? 'http://lidarr:8686',
|
||||
apiKey: env.LIDARR_API_KEY ?? '',
|
||||
},
|
||||
jellyfin: {
|
||||
url: env.JELLYFIN_URL ?? 'http://jellyfin:8096',
|
||||
apiKey: env.JELLYFIN_API_KEY ?? '',
|
||||
},
|
||||
gemini: {
|
||||
apiKey: env.GEMINI_API_KEY ?? '',
|
||||
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
||||
},
|
||||
}
|
||||
}
|
||||
16
backend/src/server.js
Normal file
16
backend/src/server.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { loadConfig } from './config.js'
|
||||
import { buildApp } from './app.js'
|
||||
|
||||
const config = loadConfig()
|
||||
|
||||
if (!config.sessionSecret || config.sessionSecret.length < 32) {
|
||||
console.error('SESSION_SECRET saknas eller är kortare än 32 tecken')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const app = buildApp(config)
|
||||
|
||||
app.listen({ port: config.port, host: config.host }).catch((err) => {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
57
backend/test/app.test.js
Normal file
57
backend/test/app.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildApp } from '../src/app.js'
|
||||
|
||||
const config = {
|
||||
appPassword: 'testlösen',
|
||||
sessionSecret: 'x'.repeat(32),
|
||||
lidarr: { url: '', apiKey: '' },
|
||||
jellyfin: { url: '', apiKey: '' },
|
||||
gemini: { apiKey: '', model: '' },
|
||||
}
|
||||
|
||||
test('health svarar utan inloggning', async () => {
|
||||
const app = buildApp(config)
|
||||
const res = await app.inject({ method: 'GET', url: '/api/health' })
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(res.json(), { status: 'ok' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('fel lösenord ger 401', async () => {
|
||||
const app = buildApp(config)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/login',
|
||||
payload: { password: 'fel' },
|
||||
})
|
||||
assert.equal(res.statusCode, 401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('rätt lösenord ger session-cookie och åtkomst', async () => {
|
||||
const app = buildApp(config)
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/login',
|
||||
payload: { password: 'testlösen' },
|
||||
})
|
||||
assert.equal(login.statusCode, 200)
|
||||
const cookie = login.headers['set-cookie']
|
||||
assert.ok(cookie, 'ingen session-cookie sattes')
|
||||
|
||||
const session = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/session',
|
||||
headers: { cookie },
|
||||
})
|
||||
assert.equal(session.json().authenticated, true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('skyddad API-rutt utan session ger 401', async () => {
|
||||
const app = buildApp(config)
|
||||
const res = await app.inject({ method: 'GET', url: '/api/whatever' })
|
||||
assert.equal(res.statusCode, 401)
|
||||
await app.close()
|
||||
})
|
||||
Reference in New Issue
Block a user