From c55e274718c286ba80e1f64931e8721bdd9666a7 Mon Sep 17 00:00:00 2001 From: Kenzo Date: Tue, 9 Dec 2025 13:58:39 +0100 Subject: [PATCH] woodpecker soll nun auch das backend deployen --- .woodpecker.yml | 17 ++++++- backend/package.json | 1 + backend/src/config/database.ts | 89 ++++++++++++++++++++++++++++++++++ backend/src/index.ts | 11 +++++ backend/src/routes/auth.ts | 7 ++- backend/src/routes/gallery.ts | 79 ++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 6 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index d6afc02..00a3e88 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,5 +1,5 @@ steps: - deploy: + deploy_frontend: image: node:20 environment: FLY_API_TOKEN: @@ -7,7 +7,20 @@ steps: commands: - curl -L https://fly.io/install.sh | sh - export PATH="$HOME/.fly/bin:$PATH" - - flyctl deploy --config fly.toml --app gallus-pub + - flyctl deploy --config fly.toml --app gallus-pub --remote-only + + build_and_deploy_backend: + image: node:20 + environment: + FLY_API_TOKEN: + from_secret: FLY_API_TOKEN + commands: + - cd backend + - npm ci + - npm run build + - curl -L https://fly.io/install.sh | sh + - export PATH="$HOME/.fly/bin:$PATH" + - flyctl deploy --config fly.toml --app gallus-cms-backend --remote-only when: branch: diff --git a/backend/package.json b/backend/package.json index 62fd9dd..9c21032 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.1", "@fastify/jwt": "^8.0.0", + "@fastify/static": "^6.12.0", "@fastify/multipart": "^8.1.0", "bcrypt": "^5.1.1", "better-sqlite3": "^11.10.0", diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index 48b064f..ec263d4 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -2,14 +2,103 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'; import Database from 'better-sqlite3'; import * as schema from '../db/schema.js'; import { env } from './env.js'; +import fs from 'fs'; +import path from 'path'; if (!env.DATABASE_PATH) { throw new Error('DATABASE_PATH environment variable is not set'); } +// Ensure directory exists BEFORE opening the database file +const dbDir = path.dirname(env.DATABASE_PATH); +if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); +} + const sqlite = new Database(env.DATABASE_PATH); // Enable WAL mode for better concurrent access sqlite.pragma('journal_mode = WAL'); export const db = drizzle(sqlite, { schema }); + +// Auto-create tables if they don't exist +export function initDatabase() { + console.log('🔧 Initializing database...'); + + try { + // Check if users table exists (acts as a sentinel for initial setup) + const tableCheck = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") + .get(); + + if (!tableCheck) { + console.log('📝 Creating database schema...'); + + sqlite.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + gitea_id TEXT UNIQUE NOT NULL, + gitea_username TEXT NOT NULL, + gitea_email TEXT, + display_name TEXT, + avatar_url TEXT, + role TEXT DEFAULT 'admin', + created_at INTEGER DEFAULT (unixepoch()), + last_login INTEGER + ); + + CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + date TEXT NOT NULL, + description TEXT NOT NULL, + image_url TEXT NOT NULL, + display_order INTEGER NOT NULL, + is_published INTEGER DEFAULT 1, + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS gallery_images ( + id TEXT PRIMARY KEY, + image_url TEXT NOT NULL, + alt_text TEXT NOT NULL, + display_order INTEGER NOT NULL, + is_published INTEGER DEFAULT 1, + created_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS content_sections ( + id TEXT PRIMARY KEY, + section_name TEXT UNIQUE NOT NULL, + content_json TEXT NOT NULL, + updated_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS site_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS publish_history ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + commit_hash TEXT, + commit_message TEXT, + published_at INTEGER DEFAULT (unixepoch()) + ); + `); + + console.log('✅ Database schema created successfully!'); + } else { + console.log('✅ Database already initialized.'); + } + } catch (error) { + console.error('❌ Error initializing database:', error); + throw error; + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index e83ae8c..ac320a6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,6 +5,9 @@ import multipart from '@fastify/multipart'; import cookie from '@fastify/cookie'; import { authenticate } from './middleware/auth.middleware.js'; import { env, validateEnv } from './config/env.js'; +import { db, initDatabase } from './config/database.js'; +import fastifyStatic from '@fastify/static'; +import path from 'path'; // Import routes import authRoute from './routes/auth.js'; @@ -57,6 +60,12 @@ fastify.register(multipart, { }, }); +// Serve static files (uploaded images, etc.) +fastify.register(fastifyStatic, { + root: path.join(process.cwd(), 'public'), + prefix: '/static/', +}); + // Decorate fastify with authenticate method fastify.decorate('authenticate', authenticate); @@ -99,6 +108,8 @@ fastify.setErrorHandler((error, request, reply) => { // Start server const start = async () => { try { + // Initialize database before starting server + initDatabase(); await fastify.listen({ port: env.PORT, host: '0.0.0.0' }); console.log(`🚀 Server listening on port ${env.PORT}`); console.log(`📝 Environment: ${env.NODE_ENV}`); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 951d08e..8b737bd 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -31,9 +31,8 @@ const authRoute: FastifyPluginAsync = async (fastify) => { reply.setCookie('oauth_state', state, { path: '/', httpOnly: true, - sameSite: 'lax', - // Use HTTPS-based detection to avoid setting Secure on localhost HTTP - secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'), + sameSite: 'none', + secure: true, maxAge: 10 * 60, // 10 minutes }); @@ -59,7 +58,7 @@ const authRoute: FastifyPluginAsync = async (fastify) => { // Verify CSRF state from cookie const expectedState = request.cookies?.oauth_state as string | undefined; if (!expectedState || state !== expectedState) { - return reply.code(400).send({ error: 'Invalid state parameter' }); + return reply.code(400).send({ error: 'Invalid state parameter' }); } // Clear state cookie diff --git a/backend/src/routes/gallery.ts b/backend/src/routes/gallery.ts index 8f61eaa..434c9f3 100644 --- a/backend/src/routes/gallery.ts +++ b/backend/src/routes/gallery.ts @@ -3,6 +3,9 @@ import { z } from 'zod'; import { db } from '../config/database.js'; import { galleryImages } from '../db/schema.js'; import { eq } from 'drizzle-orm'; +import fs from 'fs'; +import path from 'path'; +import sharp from 'sharp'; // Fastify JSON schema for gallery image body const galleryBodyJsonSchema = { @@ -54,6 +57,82 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => { return reply.code(201).send({ image: newImage }); }); + // Upload image file (multipart) + fastify.post('/gallery/upload', { + preHandler: [fastify.authenticate], + }, async (request, reply) => { + try { + // Expect a single file field named "file" + const file = await (request as any).file(); + if (!file) { + return reply.code(400).send({ error: 'No file uploaded' }); + } + + const altText = (file.fields?.altText?.value as string | undefined) || ''; + const displayOrderRaw = (file.fields?.displayOrder?.value as string | undefined) || '0'; + const displayOrder = Number.parseInt(displayOrderRaw) || 0; + + const mime = file.mimetype as string | undefined; + if (!mime || !mime.startsWith('image/')) { + return reply.code(400).send({ error: 'Only image uploads are allowed' }); + } + + // Prepare directories + const publicRoot = path.join(process.cwd(), 'public'); + const uploadDir = path.join(publicRoot, 'images', 'gallery'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + // Read uploaded stream into buffer + const chunks: Buffer[] = []; + for await (const chunk of file.file) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const inputBuffer = Buffer.concat(chunks); + + // Generate filename + const stamp = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 8); + const baseName = `${stamp}-${rand}`; + + // Try to convert to webp and limit size; fallback to original + let outBuffer: Buffer | null = null; + let outExt = '.webp'; + try { + outBuffer = await sharp(inputBuffer) + .rotate() + .resize({ width: 1600, withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer(); + } catch { + outBuffer = inputBuffer; + // naive extension from mimetype + const extFromMime = mime.split('/')[1] || 'bin'; + outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase(); + } + + const filename = baseName + outExt; + const destPath = path.join(uploadDir, filename); + fs.writeFileSync(destPath, outBuffer); + + // Public URL (served via /static) + const publicUrl = `/static/images/gallery/${filename}`; + + // Store in DB (optional but useful) + const [row] = await db.insert(galleryImages).values({ + imageUrl: publicUrl, + altText: altText || filename, + displayOrder, + isPublished: true, + }).returning(); + + return reply.code(201).send({ image: row }); + + } catch (err) { + fastify.log.error({ err }, 'Upload failed'); + return reply.code(500).send({ error: 'Failed to upload image' }); + } + }); + // Update gallery image fastify.put('/gallery/:id', { schema: {