feat(backend): initial setup for cms backend service
This commit is contained in:
15
backend/src/config/database.ts
Normal file
15
backend/src/config/database.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import * as schema from '../db/schema.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
if (!env.DATABASE_PATH) {
|
||||
throw new Error('DATABASE_PATH environment variable is not set');
|
||||
}
|
||||
|
||||
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 });
|
||||
51
backend/src/config/env.ts
Normal file
51
backend/src/config/env.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// Environment configuration with validation
|
||||
export const env = {
|
||||
// Database
|
||||
DATABASE_PATH: process.env.DATABASE_PATH || './data/gallus_cms.db',
|
||||
|
||||
// Gitea OAuth
|
||||
GITEA_URL: process.env.GITEA_URL || 'https://git.bookageek.ch',
|
||||
GITEA_CLIENT_ID: process.env.GITEA_CLIENT_ID || '',
|
||||
GITEA_CLIENT_SECRET: process.env.GITEA_CLIENT_SECRET || '',
|
||||
GITEA_REDIRECT_URI: process.env.GITEA_REDIRECT_URI || 'http://localhost:3000/api/auth/callback',
|
||||
GITEA_ALLOWED_USERS: process.env.GITEA_ALLOWED_USERS || '',
|
||||
|
||||
// Git Configuration
|
||||
GIT_REPO_URL: process.env.GIT_REPO_URL || '',
|
||||
GIT_TOKEN: process.env.GIT_TOKEN || '',
|
||||
GIT_USER_NAME: process.env.GIT_USER_NAME || 'Gallus CMS',
|
||||
GIT_USER_EMAIL: process.env.GIT_USER_EMAIL || 'cms@galluspub.ch',
|
||||
GIT_WORKSPACE_DIR: process.env.GIT_WORKSPACE_DIR || '/tmp/gallus-repo',
|
||||
|
||||
// JWT & Session
|
||||
JWT_SECRET: process.env.JWT_SECRET || '',
|
||||
SESSION_SECRET: process.env.SESSION_SECRET || '',
|
||||
|
||||
// Server
|
||||
PORT: parseInt(process.env.PORT || '3000', 10),
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
|
||||
// Upload
|
||||
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
export function validateEnv() {
|
||||
const required = [
|
||||
'DATABASE_PATH',
|
||||
'GITEA_CLIENT_ID',
|
||||
'GITEA_CLIENT_SECRET',
|
||||
'GIT_REPO_URL',
|
||||
'GIT_TOKEN',
|
||||
'JWT_SECRET',
|
||||
'SESSION_SECRET',
|
||||
];
|
||||
|
||||
const missing = required.filter(key => !env[key as keyof typeof env]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
62
backend/src/db/schema.ts
Normal file
62
backend/src/db/schema.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
// Users table - stores Gitea user info for audit and access control
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
giteaId: text('gitea_id').notNull().unique(),
|
||||
giteaUsername: text('gitea_username').notNull(),
|
||||
giteaEmail: text('gitea_email'),
|
||||
displayName: text('display_name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: text('role').default('admin'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
lastLogin: integer('last_login', { mode: 'timestamp' }),
|
||||
});
|
||||
|
||||
// Events table
|
||||
export const events = sqliteTable('events', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
title: text('title').notNull(),
|
||||
date: text('date').notNull(),
|
||||
description: text('description').notNull(),
|
||||
imageUrl: text('image_url').notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Gallery images table
|
||||
export const galleryImages = sqliteTable('gallery_images', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
imageUrl: text('image_url').notNull(),
|
||||
altText: text('alt_text').notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
isPublished: integer('is_published', { mode: 'boolean' }).default(true),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Content sections table (for text-based sections)
|
||||
export const contentSections = sqliteTable('content_sections', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
sectionName: text('section_name').notNull().unique(),
|
||||
contentJson: text('content_json', { mode: 'json' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Site settings table (global config)
|
||||
export const siteSettings = sqliteTable('site_settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Publish history (audit log)
|
||||
export const publishHistory = sqliteTable('publish_history', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
commitHash: text('commit_hash'),
|
||||
commitMessage: text('commit_message'),
|
||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||
});
|
||||
118
backend/src/index.ts
Normal file
118
backend/src/index.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import multipart from '@fastify/multipart';
|
||||
import cookie from '@fastify/cookie';
|
||||
import session from '@fastify/session';
|
||||
import { authenticate } from './middleware/auth.middleware.js';
|
||||
import { env, validateEnv } from './config/env.js';
|
||||
|
||||
// Import routes
|
||||
import authRoute from './routes/auth.js';
|
||||
import eventsRoute from './routes/events.js';
|
||||
import galleryRoute from './routes/gallery.js';
|
||||
import contentRoute from './routes/content.js';
|
||||
import settingsRoute from './routes/settings.js';
|
||||
import publishRoute from './routes/publish.js';
|
||||
|
||||
// Validate environment variables
|
||||
try {
|
||||
validateEnv();
|
||||
} catch (error) {
|
||||
console.error('Environment validation failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: env.NODE_ENV === 'development' ? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
} : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Register plugins
|
||||
fastify.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
fastify.register(cookie);
|
||||
|
||||
fastify.register(session, {
|
||||
secret: env.SESSION_SECRET,
|
||||
cookie: {
|
||||
secure: env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 600000, // 10 minutes (only needed for OAuth flow)
|
||||
},
|
||||
});
|
||||
|
||||
fastify.register(jwt, {
|
||||
secret: env.JWT_SECRET,
|
||||
});
|
||||
|
||||
fastify.register(multipart, {
|
||||
limits: {
|
||||
fileSize: env.MAX_FILE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
// Decorate fastify with authenticate method
|
||||
fastify.decorate('authenticate', authenticate);
|
||||
|
||||
// Register routes
|
||||
fastify.register(authRoute, { prefix: '/api' });
|
||||
fastify.register(eventsRoute, { prefix: '/api' });
|
||||
fastify.register(galleryRoute, { prefix: '/api' });
|
||||
fastify.register(contentRoute, { prefix: '/api' });
|
||||
fastify.register(settingsRoute, { prefix: '/api' });
|
||||
fastify.register(publishRoute, { prefix: '/api' });
|
||||
|
||||
// Health check
|
||||
fastify.get('/health', async () => {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: env.NODE_ENV,
|
||||
};
|
||||
});
|
||||
|
||||
// Root endpoint
|
||||
fastify.get('/', async () => {
|
||||
return {
|
||||
name: 'Gallus Pub CMS Backend',
|
||||
version: '1.0.0',
|
||||
status: 'running',
|
||||
};
|
||||
});
|
||||
|
||||
// Error handler
|
||||
fastify.setErrorHandler((error, request, reply) => {
|
||||
fastify.log.error(error);
|
||||
|
||||
reply.status(error.statusCode || 500).send({
|
||||
error: error.message || 'Internal Server Error',
|
||||
statusCode: error.statusCode || 500,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const start = async () => {
|
||||
try {
|
||||
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}`);
|
||||
console.log(`🔐 CORS Origin: ${env.CORS_ORIGIN}`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
12
backend/src/middleware/auth.middleware.ts
Normal file
12
backend/src/middleware/auth.middleware.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export async function authenticate(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
164
backend/src/routes/auth.ts
Normal file
164
backend/src/routes/auth.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { GiteaService } from '../services/gitea.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const callbackSchema = z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
const authRoute: FastifyPluginAsync = async (fastify) => {
|
||||
const giteaService = new GiteaService();
|
||||
|
||||
/**
|
||||
* GET /auth/gitea
|
||||
* Initiate OAuth flow
|
||||
*/
|
||||
fastify.get('/auth/gitea', async (request, reply) => {
|
||||
// Generate CSRF state token
|
||||
const state = giteaService.generateState();
|
||||
|
||||
// Store state in session
|
||||
request.session.set('oauth_state', state);
|
||||
|
||||
// Generate authorization URL
|
||||
const authUrl = giteaService.getAuthorizationUrl(state);
|
||||
|
||||
// Redirect to Gitea
|
||||
return reply.redirect(authUrl);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/callback
|
||||
* OAuth callback endpoint
|
||||
*/
|
||||
fastify.get('/auth/callback', {
|
||||
schema: {
|
||||
querystring: callbackSchema,
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { code, state } = request.query as z.infer<typeof callbackSchema>;
|
||||
|
||||
// Verify CSRF state
|
||||
const expectedState = request.session.get('oauth_state');
|
||||
if (!expectedState || state !== expectedState) {
|
||||
return reply.code(400).send({ error: 'Invalid state parameter' });
|
||||
}
|
||||
|
||||
// Clear state from session
|
||||
request.session.delete('oauth_state');
|
||||
|
||||
// Exchange code for access token
|
||||
const tokenResponse = await giteaService.exchangeCodeForToken(code);
|
||||
|
||||
// Fetch user info from Gitea
|
||||
const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token);
|
||||
|
||||
// Check if user is allowed
|
||||
if (!giteaService.isUserAllowed(giteaUser.login)) {
|
||||
return reply.code(403).send({
|
||||
error: 'Access denied. You are not authorized to access this CMS.'
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create user in database
|
||||
let [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.giteaId, giteaUser.id.toString()))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
// Create new user
|
||||
[user] = await db.insert(users).values({
|
||||
giteaId: giteaUser.id.toString(),
|
||||
giteaUsername: giteaUser.login,
|
||||
giteaEmail: giteaUser.email,
|
||||
displayName: giteaUser.full_name,
|
||||
avatarUrl: giteaUser.avatar_url,
|
||||
lastLogin: new Date(),
|
||||
}).returning();
|
||||
} else {
|
||||
// Update existing user
|
||||
[user] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
giteaUsername: giteaUser.login,
|
||||
giteaEmail: giteaUser.email,
|
||||
displayName: giteaUser.full_name,
|
||||
avatarUrl: giteaUser.avatar_url,
|
||||
lastLogin: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Generate JWT for session management
|
||||
const token = fastify.jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
giteaId: user.giteaId,
|
||||
username: user.giteaUsername,
|
||||
role: user.role,
|
||||
},
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// Redirect to frontend with token
|
||||
const frontendUrl = env.FRONTEND_URL;
|
||||
return reply.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error('OAuth callback error:', error);
|
||||
return reply.code(500).send({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/me
|
||||
* Get current user info
|
||||
*/
|
||||
fastify.get('/auth/me', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const userId = request.user.id;
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.giteaUsername,
|
||||
email: user.giteaEmail,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Logout (client-side token deletion)
|
||||
*/
|
||||
fastify.post('/auth/logout', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
// For JWT, logout is primarily client-side (delete token)
|
||||
// You could maintain a token blacklist in Redis for production
|
||||
return { message: 'Logged out successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default authRoute;
|
||||
99
backend/src/routes/content.ts
Normal file
99
backend/src/routes/content.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { contentSections } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const contentSectionSchema = z.object({
|
||||
contentJson: z.record(z.any()),
|
||||
});
|
||||
|
||||
const contentRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// Get content section
|
||||
fastify.get('/content/:section', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { section } = request.params as { section: string };
|
||||
|
||||
const [content] = await db
|
||||
.select()
|
||||
.from(contentSections)
|
||||
.where(eq(contentSections.sectionName, section))
|
||||
.limit(1);
|
||||
|
||||
if (!content) {
|
||||
return reply.code(404).send({ error: 'Content section not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
section: content.sectionName,
|
||||
content: content.contentJson,
|
||||
updatedAt: content.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Update content section
|
||||
fastify.put('/content/:section', {
|
||||
schema: {
|
||||
body: contentSectionSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { section } = request.params as { section: string };
|
||||
const { contentJson } = request.body as z.infer<typeof contentSectionSchema>;
|
||||
|
||||
// Check if section exists
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(contentSections)
|
||||
.where(eq(contentSections.sectionName, section))
|
||||
.limit(1);
|
||||
|
||||
let result;
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
[result] = await db
|
||||
.update(contentSections)
|
||||
.set({
|
||||
contentJson,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(contentSections.sectionName, section))
|
||||
.returning();
|
||||
} else {
|
||||
// Create new
|
||||
[result] = await db
|
||||
.insert(contentSections)
|
||||
.values({
|
||||
sectionName: section,
|
||||
contentJson,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
return {
|
||||
section: result.sectionName,
|
||||
content: result.contentJson,
|
||||
updatedAt: result.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// List all content sections
|
||||
fastify.get('/content', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const sections = await db.select().from(contentSections);
|
||||
|
||||
return {
|
||||
sections: sections.map(s => ({
|
||||
section: s.sectionName,
|
||||
content: s.contentJson,
|
||||
updatedAt: s.updatedAt,
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default contentRoute;
|
||||
123
backend/src/routes/events.ts
Normal file
123
backend/src/routes/events.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { events } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const eventSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
date: z.string().min(1).max(100),
|
||||
description: z.string().min(1),
|
||||
imageUrl: z.string().url(),
|
||||
displayOrder: z.number().int().min(0),
|
||||
isPublished: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// List all events
|
||||
fastify.get('/events', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const allEvents = await db.select().from(events).orderBy(events.displayOrder);
|
||||
return { events: allEvents };
|
||||
});
|
||||
|
||||
// Get single event
|
||||
fastify.get('/events/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const event = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||
|
||||
if (event.length === 0) {
|
||||
return reply.code(404).send({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
return { event: event[0] };
|
||||
});
|
||||
|
||||
// Create event
|
||||
fastify.post('/events', {
|
||||
schema: {
|
||||
body: eventSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const data = request.body as z.infer<typeof eventSchema>;
|
||||
|
||||
const [newEvent] = await db.insert(events).values(data).returning();
|
||||
|
||||
return reply.code(201).send({ event: newEvent });
|
||||
});
|
||||
|
||||
// Update event
|
||||
fastify.put('/events/:id', {
|
||||
schema: {
|
||||
body: eventSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as z.infer<typeof eventSchema>;
|
||||
|
||||
const [updated] = await db
|
||||
.update(events)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(events.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
return { event: updated };
|
||||
});
|
||||
|
||||
// Delete event
|
||||
fastify.delete('/events/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(events)
|
||||
.where(eq(events.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
return { message: 'Event deleted successfully' };
|
||||
});
|
||||
|
||||
// Reorder events
|
||||
fastify.put('/events/reorder', {
|
||||
schema: {
|
||||
body: z.object({
|
||||
orders: z.array(z.object({
|
||||
id: z.string().uuid(),
|
||||
displayOrder: z.number().int().min(0),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||
|
||||
// Update all in transaction
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { id, displayOrder } of orders) {
|
||||
await tx
|
||||
.update(events)
|
||||
.set({ displayOrder })
|
||||
.where(eq(events.id, id));
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Events reordered successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default eventsRoute;
|
||||
121
backend/src/routes/gallery.ts
Normal file
121
backend/src/routes/gallery.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { galleryImages } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const galleryImageSchema = z.object({
|
||||
imageUrl: z.string().url(),
|
||||
altText: z.string().min(1).max(200),
|
||||
displayOrder: z.number().int().min(0),
|
||||
isPublished: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// List all gallery images
|
||||
fastify.get('/gallery', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const images = await db.select().from(galleryImages).orderBy(galleryImages.displayOrder);
|
||||
return { images };
|
||||
});
|
||||
|
||||
// Get single gallery image
|
||||
fastify.get('/gallery/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const image = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||
|
||||
if (image.length === 0) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return { image: image[0] };
|
||||
});
|
||||
|
||||
// Create gallery image
|
||||
fastify.post('/gallery', {
|
||||
schema: {
|
||||
body: galleryImageSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const data = request.body as z.infer<typeof galleryImageSchema>;
|
||||
|
||||
const [newImage] = await db.insert(galleryImages).values(data).returning();
|
||||
|
||||
return reply.code(201).send({ image: newImage });
|
||||
});
|
||||
|
||||
// Update gallery image
|
||||
fastify.put('/gallery/:id', {
|
||||
schema: {
|
||||
body: galleryImageSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const data = request.body as z.infer<typeof galleryImageSchema>;
|
||||
|
||||
const [updated] = await db
|
||||
.update(galleryImages)
|
||||
.set(data)
|
||||
.where(eq(galleryImages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return { image: updated };
|
||||
});
|
||||
|
||||
// Delete gallery image
|
||||
fastify.delete('/gallery/:id', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(galleryImages)
|
||||
.where(eq(galleryImages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: 'Image not found' });
|
||||
}
|
||||
|
||||
return { message: 'Image deleted successfully' };
|
||||
});
|
||||
|
||||
// Reorder gallery images
|
||||
fastify.put('/gallery/reorder', {
|
||||
schema: {
|
||||
body: z.object({
|
||||
orders: z.array(z.object({
|
||||
id: z.string().uuid(),
|
||||
displayOrder: z.number().int().min(0),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { orders } = request.body as { orders: Array<{ id: string; displayOrder: number }> };
|
||||
|
||||
// Update all in transaction
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { id, displayOrder } of orders) {
|
||||
await tx
|
||||
.update(galleryImages)
|
||||
.set({ displayOrder })
|
||||
.where(eq(galleryImages.id, id));
|
||||
}
|
||||
});
|
||||
|
||||
return { message: 'Gallery images reordered successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default galleryRoute;
|
||||
122
backend/src/routes/publish.ts
Normal file
122
backend/src/routes/publish.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { GitService } from '../services/git.service.js';
|
||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||
import { db } from '../config/database.js';
|
||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const publishSchema = z.object({
|
||||
commitMessage: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
const publishRoute: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.post('/publish', {
|
||||
schema: {
|
||||
body: publishSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { commitMessage } = request.body as z.infer<typeof publishSchema>;
|
||||
const userId = request.user.id;
|
||||
|
||||
fastify.log.info('Starting publish process...');
|
||||
|
||||
// Initialize git service
|
||||
const gitService = new GitService();
|
||||
await gitService.initialize();
|
||||
|
||||
fastify.log.info('Git repository initialized');
|
||||
|
||||
// Fetch all content from database
|
||||
const eventsData = await db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.isPublished, true))
|
||||
.orderBy(events.displayOrder);
|
||||
|
||||
const galleryData = await db
|
||||
.select()
|
||||
.from(galleryImages)
|
||||
.where(eq(galleryImages.isPublished, true))
|
||||
.orderBy(galleryImages.displayOrder);
|
||||
|
||||
const sectionsData = await db.select().from(contentSections);
|
||||
const sectionsMap = new Map(
|
||||
sectionsData.map(s => [s.sectionName, s.contentJson as any])
|
||||
);
|
||||
|
||||
fastify.log.info(`Fetched ${eventsData.length} events, ${galleryData.length} images, ${sectionsData.length} sections`);
|
||||
|
||||
// Generate and write files
|
||||
const fileGenerator = new FileGeneratorService();
|
||||
await fileGenerator.writeFiles(
|
||||
gitService.getWorkspacePath(''),
|
||||
eventsData.map(e => ({
|
||||
title: e.title,
|
||||
date: e.date,
|
||||
description: e.description,
|
||||
imageUrl: e.imageUrl,
|
||||
})),
|
||||
galleryData.map(g => ({
|
||||
imageUrl: g.imageUrl,
|
||||
altText: g.altText,
|
||||
})),
|
||||
sectionsMap
|
||||
);
|
||||
|
||||
fastify.log.info('Files generated successfully');
|
||||
|
||||
// Commit and push
|
||||
const commitHash = await gitService.commitAndPush(commitMessage);
|
||||
|
||||
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||
|
||||
// Record in history
|
||||
await db.insert(publishHistory).values({
|
||||
userId,
|
||||
commitHash,
|
||||
commitMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitHash,
|
||||
message: 'Changes published successfully',
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error('Publish error:', error);
|
||||
|
||||
// Attempt to reset git state on error
|
||||
try {
|
||||
const gitService = new GitService();
|
||||
await gitService.reset();
|
||||
} catch (resetError) {
|
||||
fastify.log.error('Failed to reset git state:', resetError);
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
success: false,
|
||||
error: 'Failed to publish changes',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get publish history
|
||||
fastify.get('/publish/history', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const history = await db
|
||||
.select()
|
||||
.from(publishHistory)
|
||||
.orderBy(publishHistory.publishedAt)
|
||||
.limit(20);
|
||||
|
||||
return { history };
|
||||
});
|
||||
};
|
||||
|
||||
export default publishRoute;
|
||||
116
backend/src/routes/settings.ts
Normal file
116
backend/src/routes/settings.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../config/database.js';
|
||||
import { siteSettings } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const settingSchema = z.object({
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const settingsRoute: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// Get all settings
|
||||
fastify.get('/settings', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const settings = await db.select().from(siteSettings);
|
||||
|
||||
return {
|
||||
settings: settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
};
|
||||
});
|
||||
|
||||
// Get single setting
|
||||
fastify.get('/settings/:key', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
|
||||
const [setting] = await db
|
||||
.select()
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (!setting) {
|
||||
return reply.code(404).send({ error: 'Setting not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
updatedAt: setting.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Update setting
|
||||
fastify.put('/settings/:key', {
|
||||
schema: {
|
||||
body: settingSchema,
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
const { value } = request.body as z.infer<typeof settingSchema>;
|
||||
|
||||
// Check if setting exists
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, key))
|
||||
.limit(1);
|
||||
|
||||
let result;
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
[result] = await db
|
||||
.update(siteSettings)
|
||||
.set({
|
||||
value,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(siteSettings.key, key))
|
||||
.returning();
|
||||
} else {
|
||||
// Create new
|
||||
[result] = await db
|
||||
.insert(siteSettings)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
return {
|
||||
key: result.key,
|
||||
value: result.value,
|
||||
updatedAt: result.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Delete setting
|
||||
fastify.delete('/settings/:key', {
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { key } = request.params as { key: string };
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(siteSettings)
|
||||
.where(eq(siteSettings.key, key))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: 'Setting not found' });
|
||||
}
|
||||
|
||||
return { message: 'Setting deleted successfully' };
|
||||
});
|
||||
};
|
||||
|
||||
export default settingsRoute;
|
||||
239
backend/src/services/file-generator.service.ts
Normal file
239
backend/src/services/file-generator.service.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
interface Event {
|
||||
title: string;
|
||||
date: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
interface GalleryImage {
|
||||
imageUrl: string;
|
||||
altText: string;
|
||||
}
|
||||
|
||||
interface ContentSection {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class FileGeneratorService {
|
||||
|
||||
escapeQuotes(str: string): string {
|
||||
return str.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
escapeBackticks(str: string): string {
|
||||
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||
}
|
||||
|
||||
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||
const eventsCode = events.map(e => `\t{
|
||||
\t\timage: "${e.imageUrl}",
|
||||
\t\ttitle: "${this.escapeQuotes(e.title)}",
|
||||
\t\tdate: "${e.date}",
|
||||
\t\tdescription: \`
|
||||
\t\t\t${this.escapeBackticks(e.description)}
|
||||
\t\t\`,
|
||||
\t}`).join(',\n');
|
||||
|
||||
const imagesCode = images.map(g =>
|
||||
`\t{ src: "${g.imageUrl}", alt: "${this.escapeQuotes(g.altText)}" }`
|
||||
).join(',\n');
|
||||
|
||||
return `---
|
||||
import Layout from "../components/Layout.astro";
|
||||
import Hero from "../components/Hero.astro";
|
||||
import Welcome from "../components/Welcome.astro";
|
||||
import EventsGrid from "../components/EventsGrid.astro";
|
||||
import Drinks from "../components/Drinks.astro";
|
||||
import ImageCarousel from "../components/ImageCarousel.astro";
|
||||
import Contact from "../components/Contact.astro";
|
||||
import About from "../components/About.astro";
|
||||
|
||||
const events = [
|
||||
${eventsCode}
|
||||
];
|
||||
|
||||
const images = [
|
||||
${imagesCode}
|
||||
];
|
||||
---
|
||||
|
||||
<Layout>
|
||||
\t<Hero id="hero" />
|
||||
\t<Welcome id="welcome" />
|
||||
\t<EventsGrid id="events" events={events} />
|
||||
\t<ImageCarousel id="gallery" images={images} />
|
||||
\t<Drinks id="drinks" />
|
||||
</Layout>
|
||||
`;
|
||||
}
|
||||
|
||||
generateHeroComponent(content: ContentSection): string {
|
||||
return `---
|
||||
// src/components/Hero.astro
|
||||
import "../styles/components/Hero.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section id={id} class="hero container">
|
||||
|
||||
\t<div class="hero-overlay">
|
||||
|
||||
\t\t<div class="hero-content">
|
||||
|
||||
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
||||
|
||||
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
||||
|
||||
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||
\t\t</div>
|
||||
|
||||
\t</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
generateWelcomeComponent(content: ContentSection): string {
|
||||
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
||||
).join('\n\n');
|
||||
|
||||
return `---
|
||||
// src/components/Welcome.astro
|
||||
import "../styles/components/Welcome.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
|
||||
<section id={id} class="welcome container">
|
||||
|
||||
\t<div class="welcome-text">
|
||||
|
||||
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
||||
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.introText || ''}
|
||||
\t\t</p>
|
||||
|
||||
\t\t<p><b>Unsere Highlights:</b></p>
|
||||
|
||||
\t\t<ul>
|
||||
${highlightsList}
|
||||
\t\t</ul>
|
||||
|
||||
\t\t<p>
|
||||
\t\t\t${content.closingText || ''}
|
||||
\t\t</p>
|
||||
|
||||
\t</div>
|
||||
|
||||
|
||||
\t<div class="welcome-image">
|
||||
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
||||
\t</div>
|
||||
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
generateDrinksComponent(content: ContentSection): string {
|
||||
return `---
|
||||
import "../styles/components/Drinks.css"
|
||||
|
||||
const { id } = Astro.props;
|
||||
---
|
||||
<section id={id} class="Drinks">
|
||||
<h2 class="title">Drinks</h2>
|
||||
|
||||
<p class="note">
|
||||
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
||||
</p>
|
||||
|
||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||
|
||||
<h3 class="monats-hit">Monats Hit</h3>
|
||||
|
||||
<div class="mate-vodka">
|
||||
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
||||
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
||||
</p>
|
||||
|
||||
<div class="circle-row">
|
||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
||||
<span class="circle-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async writeFiles(
|
||||
workspaceDir: string,
|
||||
events: Event[],
|
||||
images: GalleryImage[],
|
||||
sections: Map<string, ContentSection>
|
||||
) {
|
||||
// Write index.astro
|
||||
const indexContent = this.generateIndexAstro(events, images);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/pages/index.astro'),
|
||||
indexContent,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Write Hero component
|
||||
if (sections.has('hero')) {
|
||||
const heroContent = this.generateHeroComponent(sections.get('hero')!);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/components/Hero.astro'),
|
||||
heroContent,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// Write Welcome component
|
||||
if (sections.has('welcome')) {
|
||||
const welcomeContent = this.generateWelcomeComponent(sections.get('welcome')!);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/components/Welcome.astro'),
|
||||
welcomeContent,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// Write Drinks component
|
||||
if (sections.has('drinks')) {
|
||||
const drinksContent = this.generateDrinksComponent(sections.get('drinks')!);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, 'src/components/Drinks.astro'),
|
||||
drinksContent,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
backend/src/services/git.service.ts
Normal file
65
backend/src/services/git.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import simpleGit, { SimpleGit } from 'simple-git';
|
||||
import { mkdir, rm } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export class GitService {
|
||||
private git: SimpleGit;
|
||||
private workspaceDir: string;
|
||||
private repoUrl: string;
|
||||
private token: string;
|
||||
|
||||
constructor() {
|
||||
this.workspaceDir = env.GIT_WORKSPACE_DIR;
|
||||
this.repoUrl = env.GIT_REPO_URL;
|
||||
this.token = env.GIT_TOKEN;
|
||||
this.git = simpleGit();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Ensure workspace directory exists
|
||||
await mkdir(this.workspaceDir, { recursive: true });
|
||||
|
||||
// Add token to repo URL for authentication
|
||||
const authenticatedUrl = this.repoUrl.replace(
|
||||
'https://',
|
||||
`https://oauth2:${this.token}@`
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if repo already exists
|
||||
await this.git.cwd(this.workspaceDir);
|
||||
await this.git.status();
|
||||
console.log('Repository already exists, pulling latest...');
|
||||
await this.git.pull();
|
||||
} catch {
|
||||
// Clone if doesn't exist
|
||||
console.log('Cloning repository...');
|
||||
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||
await this.git.cwd(this.workspaceDir);
|
||||
}
|
||||
|
||||
// Configure git user
|
||||
await this.git.addConfig('user.name', env.GIT_USER_NAME);
|
||||
await this.git.addConfig('user.email', env.GIT_USER_EMAIL);
|
||||
}
|
||||
|
||||
async commitAndPush(message: string): Promise<string> {
|
||||
await this.git.add('.');
|
||||
await this.git.commit(message);
|
||||
await this.git.push('origin', 'main');
|
||||
|
||||
const log = await this.git.log({ maxCount: 1 });
|
||||
return log.latest?.hash || '';
|
||||
}
|
||||
|
||||
getWorkspacePath(relativePath: string): string {
|
||||
return path.join(this.workspaceDir, relativePath);
|
||||
}
|
||||
|
||||
async reset() {
|
||||
await this.git.reset(['--hard', 'HEAD']);
|
||||
await this.git.clean('f', ['-d']);
|
||||
}
|
||||
}
|
||||
112
backend/src/services/gitea.service.ts
Normal file
112
backend/src/services/gitea.service.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import crypto from 'crypto';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
interface GiteaUser {
|
||||
id: number;
|
||||
login: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export class GiteaService {
|
||||
private giteaUrl: string;
|
||||
private clientId: string;
|
||||
private clientSecret: string;
|
||||
private redirectUri: string;
|
||||
private allowedUsers: Set<string>;
|
||||
|
||||
constructor() {
|
||||
this.giteaUrl = env.GITEA_URL;
|
||||
this.clientId = env.GITEA_CLIENT_ID;
|
||||
this.clientSecret = env.GITEA_CLIENT_SECRET;
|
||||
this.redirectUri = env.GITEA_REDIRECT_URI;
|
||||
|
||||
const allowed = env.GITEA_ALLOWED_USERS;
|
||||
this.allowedUsers = new Set(allowed.split(',').map(u => u.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization URL
|
||||
*/
|
||||
getAuthorizationUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
scope: 'read:user',
|
||||
});
|
||||
|
||||
return `${this.giteaUrl}/login/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
async exchangeCodeForToken(code: string): Promise<OAuthTokenResponse> {
|
||||
const response = await fetch(`${this.giteaUrl}/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to exchange code: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from Gitea using access token
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<GiteaUser> {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is allowed to access the CMS
|
||||
*/
|
||||
isUserAllowed(username: string): boolean {
|
||||
// If no allowed users specified, allow all
|
||||
if (this.allowedUsers.size === 0) {
|
||||
return true;
|
||||
}
|
||||
return this.allowedUsers.has(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
generateState(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
87
backend/src/services/media.service.ts
Normal file
87
backend/src/services/media.service.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import sharp from 'sharp';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export class MediaService {
|
||||
private allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
private maxFileSize: number;
|
||||
|
||||
constructor() {
|
||||
this.maxFileSize = env.MAX_FILE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type and size
|
||||
*/
|
||||
async validateFile(file: any): Promise<void> {
|
||||
if (!this.allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new Error(`Invalid file type. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const buffer = await file.toBuffer();
|
||||
if (buffer.length > this.maxFileSize) {
|
||||
throw new Error(`File too large. Maximum size: ${this.maxFileSize / 1024 / 1024}MB`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate safe filename
|
||||
*/
|
||||
generateFilename(originalName: string): string {
|
||||
const ext = path.extname(originalName);
|
||||
const hash = crypto.randomBytes(8).toString('hex');
|
||||
const timestamp = Date.now();
|
||||
return `${timestamp}-${hash}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize and save image
|
||||
*/
|
||||
async processAndSaveImage(
|
||||
file: any,
|
||||
destinationDir: string
|
||||
): Promise<{ filename: string; url: string }> {
|
||||
await this.validateFile(file);
|
||||
|
||||
// Ensure destination directory exists
|
||||
await mkdir(destinationDir, { recursive: true });
|
||||
|
||||
// Generate filename
|
||||
const filename = this.generateFilename(file.filename);
|
||||
const filepath = path.join(destinationDir, filename);
|
||||
|
||||
// Get file buffer
|
||||
const buffer = await file.toBuffer();
|
||||
|
||||
// Process image with sharp (optimize and resize if needed)
|
||||
await sharp(buffer)
|
||||
.resize(2000, 2000, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 85 })
|
||||
.png({ quality: 85 })
|
||||
.webp({ quality: 85 })
|
||||
.toFile(filepath);
|
||||
|
||||
// Return filename and URL path
|
||||
return {
|
||||
filename,
|
||||
url: `/images/${filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save image to git workspace
|
||||
*/
|
||||
async saveToGitWorkspace(
|
||||
file: any,
|
||||
workspaceDir: string
|
||||
): Promise<{ filename: string; url: string }> {
|
||||
const imagesDir = path.join(workspaceDir, 'public', 'images');
|
||||
return this.processAndSaveImage(file, imagesDir);
|
||||
}
|
||||
}
|
||||
25
backend/src/types/index.ts
Normal file
25
backend/src/types/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
export interface JWTPayload {
|
||||
id: string;
|
||||
giteaId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
user: JWTPayload;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: JWTPayload;
|
||||
user: JWTPayload;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user