feat(backend): initial setup for cms backend service

This commit is contained in:
Fx64b
2025-11-15 14:56:43 +01:00
parent 193f3ff0bb
commit 688b4de945
32 changed files with 5600 additions and 0 deletions

View 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'
);
}
}
}

View 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']);
}
}

View 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');
}
}

View 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);
}
}