feat(backend): initial setup for cms backend service
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user