chore: aktuellen Live-Stand sichern (Admin-API, Services, Auth-Erweiterungen)

Sichert den bisher uncommitteten Produktionsstand des Kundenbereich-Servers,
damit kuenftige Deploys (git reset --hard) nichts mehr verwerfen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:24:53 +00:00
parent cff72eb528
commit cf4b3ce4d1
24 changed files with 1883 additions and 123 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@ node_modules/
.DS_Store
preview-data/
*.log
server/graphify-out/
graphify-out/

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jose": "^6.0.11",
"node-appwrite": "^14.1.0"
},
"engines": {
@@ -472,6 +473,15 @@
"node": ">= 0.10"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -17,6 +17,7 @@
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jose": "^6.0.11",
"node-appwrite": "^14.1.0"
}
}

View File

@@ -44,7 +44,7 @@ function renderProjectCard(project, features) {
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
<dl>
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '')}</dd>
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a href="${escapeAttr(project.previewUrl)}" target="_blank" rel="noopener">${escapeHtml(project.previewUrl)}</a>` : ''}</dd>
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a class="btn btn-green" href="${escapeAttr(project.previewUrl)}">Projekt-Webseite öffnen</a>` : ''}</dd>
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : ''}</dd>
<dt>Status</dt><dd>${escapeHtml(project.status || '')}</dd>
<dt>Bereitstellung</dt><dd>${escapeHtml(project.provisioningStatus || '')}</dd>
@@ -66,11 +66,84 @@ function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, '&#39;')
}
function getQueryParam(name) {
return new URLSearchParams(window.location.search).get(name) || ''
}
function safeRedirectUrl(url) {
try {
const u = new URL(url)
if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') {
return u.toString()
}
} catch {
/* ignore invalid URL */
}
return null
}
function isProjectPreviewReady(project) {
const status = (project?.provisioningStatus || project?.status || '').toLowerCase()
return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl)
}
function previewSubdomainFromUrl(url) {
try {
const host = new URL(url).hostname.toLowerCase()
const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/)
return match ? match[1] : ''
} catch {
return ''
}
}
async function canAccessPreviewUrl(url) {
const subdomain = previewSubdomainFromUrl(url)
if (!subdomain) return false
try {
const access = await fetch(
`/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`,
{ credentials: 'same-origin' }
).then((r) => r.json())
return Boolean(access.allowed)
} catch {
return false
}
}
async function resolvePostLoginTarget() {
const next = safeRedirectUrl(getQueryParam('next'))
if (next && (await canAccessPreviewUrl(next))) {
return next
}
try {
const { projects } = await api('/api/projects')
const readyProjects = (projects || []).filter(isProjectPreviewReady)
if (readyProjects.length === 1) {
return readyProjects[0].previewUrl
}
} catch {
/* dashboard fallback */
}
return '/dashboard.html'
}
async function postLoginRedirect() {
window.location.href = await resolvePostLoginTarget()
}
async function initLoginPage() {
const form = document.getElementById('login-form')
const errorEl = document.getElementById('login-error')
const btn = document.getElementById('login-btn')
const errorFromQuery = getQueryParam('error')
if (errorFromQuery) {
showError(errorEl, errorFromQuery)
}
async function applyCooldown(seconds) {
if (seconds <= 0) return
showError(
@@ -106,7 +179,7 @@ async function initLoginPage() {
try {
const me = await api('/api/auth/me')
if (me.authenticated && me.customer) {
window.location.href = '/dashboard.html'
await postLoginRedirect()
return
}
} catch {
@@ -129,7 +202,7 @@ async function initLoginPage() {
password: document.getElementById('password').value,
}),
})
window.location.href = '/dashboard.html'
await postLoginRedirect()
return
} catch (err) {
showError(errorEl, err.message)

View File

@@ -43,17 +43,39 @@ export const config = {
customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess',
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
},
gitea: {
webhookToken: process.env.GITEA_WEBHOOK_TOKEN || '',
baseUrl: process.env.GITEA_BASE_URL || 'https://git.webklar.com',
apiToken: process.env.GITEA_API_TOKEN || '',
templateOwner: process.env.GITEA_TEMPLATE_OWNER || 'knso',
templateRepo: process.env.GITEA_TEMPLATE_REPO || 'webklar-preview-template',
defaultOwner: process.env.GITEA_DEFAULT_OWNER || 'knso',
},
cors: {
allowedOrigins: (process.env.ADMIN_CORS_ORIGINS || 'https://ticket.webklar.com,http://localhost:5173')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
preview: {
baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com',
deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '',
sessionSecret:
process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '',
cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com',
sessionMaxAge: Number(process.env.PREVIEW_SESSION_MAX_AGE || 60 * 60 * 24 * 7),
},
invoiceNinja: {
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
},
}

View File

@@ -8,6 +8,12 @@ import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
import featuresRoutes from './routes/features.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
import adminCustomersRoutes from './routes/admin/customers.js'
import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public')
@@ -19,9 +25,28 @@ app.set('trust proxy', 1)
app.use(sessionMiddleware())
app.use(express.json({ limit: '2mb' }))
app.use((req, res, next) => {
const origin = req.get('Origin')
if (origin && config.cors.allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
}
return next()
})
app.use('/api/auth', authRoutes)
app.use('/api/projects', projectsRoutes)
app.use('/api/features', featuresRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
app.use('/webhook', giteaWebhookRoutes)
app.get('/api/health', async (_req, res) => {

View File

@@ -0,0 +1,44 @@
import { config } from '../config.js'
export async function requireAppwriteStaff(req, res, next) {
const authHeader = req.get('Authorization') || ''
const match = authHeader.match(/^Bearer\s+(.+)$/i)
if (!match) {
return res.status(401).json({ error: 'Authorization Bearer JWT erforderlich' })
}
const jwt = match[1]
try {
const response = await fetch(`${config.appwrite.endpoint}/account`, {
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': config.appwrite.projectId,
'X-Appwrite-JWT': jwt,
},
})
const text = await response.text()
let data = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = { message: text }
}
}
if (!response.ok) {
return res.status(401).json({ error: data?.message || 'Ung<6E>ltiges JWT' })
}
if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
}
req.appwriteUser = data
return next()
} catch (err) {
return res.status(500).json({ error: err.message || 'JWT-Validierung fehlgeschlagen' })
}
}

View File

@@ -0,0 +1,53 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import {
createCustomerWithPortalAccess,
deleteCustomerWithPortalAccess,
listCustomersForAdmin,
updateCustomerWithPortalAccess,
} from '../../services/customerAdmin.js'
const router = Router()
router.use(requireAppwriteStaff)
router.get('/', async (_req, res) => {
try {
const customers = await listCustomersForAdmin()
return res.json({ customers })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
}
})
router.post('/', async (req, res) => {
try {
const result = await createCustomerWithPortalAccess(req.body || {})
return res.status(201).json(result)
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht angelegt werden' })
}
})
router.patch('/:customerId', async (req, res) => {
try {
const result = await updateCustomerWithPortalAccess(req.params.customerId, req.body || {})
return res.json(result)
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht aktualisiert werden' })
}
})
router.delete('/:customerId', async (req, res) => {
try {
return res.json(await deleteCustomerWithPortalAccess(req.params.customerId))
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunde konnte nicht gelöscht werden' })
}
})
export default router

View File

@@ -0,0 +1,51 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import {
createEmployeeWithLogin,
deleteEmployeeWithLogin,
listEmployeesForAdmin,
updateEmployeeWithLogin,
} from '../../services/employeeAdmin.js'
const router = Router()
router.use(requireAppwriteStaff)
router.get('/', async (_req, res) => {
try {
return res.json({ employees: await listEmployeesForAdmin() })
} catch (error) {
return res.status(error.status || 500).json({ error: error.message })
}
})
router.post('/', async (req, res) => {
try {
return res.status(201).json(await createEmployeeWithLogin(req.body || {}))
} catch (error) {
return res.status(error.status || 500).json({ error: error.message })
}
})
router.patch('/:employeeId', async (req, res) => {
try {
return res.json(await updateEmployeeWithLogin(
req.params.employeeId,
req.body || {},
req.appwriteUser.$id
))
} catch (error) {
return res.status(error.status || 500).json({ error: error.message })
}
})
router.delete('/:employeeId', async (req, res) => {
try {
return res.json(
await deleteEmployeeWithLogin(req.params.employeeId, req.appwriteUser.$id)
)
} catch (error) {
return res.status(error.status || 500).json({ error: error.message })
}
})
export default router

View File

@@ -0,0 +1,37 @@
import { Router } from 'express'
import { syncReposToAppwrite, fetchRepoReadme } from '../../services/giteaAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
const router = Router()
router.use(requireAppwriteStaff)
router.post('/sync-repos', async (_req, res) => {
try {
const result = await syncReposToAppwrite()
return res.json({ ok: true, ...result })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Gitea-Sync fehlgeschlagen' })
}
})
router.get('/repo-readme', async (req, res) => {
const repoFullName = String(req.query.repoFullName || '').trim()
if (!repoFullName) {
return res.status(400).json({ error: 'repoFullName ist erforderlich' })
}
try {
const readme = await fetchRepoReadme(repoFullName)
if (!readme) {
return res.json({ ok: true, found: false, repoFullName })
}
return res.json({ ok: true, found: true, repoFullName, ...readme })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'README konnte nicht geladen werden' })
}
})
export default router

View File

@@ -0,0 +1,19 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { listInvoiceNinjaClients } from '../../services/invoiceNinja.js'
const router = Router()
router.use(requireAppwriteStaff)
router.get('/', async (_req, res) => {
try {
const clients = await listInvoiceNinjaClients()
return res.json({ clients })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Kunden konnten nicht geladen werden' })
}
})
export default router

View File

@@ -0,0 +1,91 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js'
const router = Router()
function isStaffAdmin(user) {
return (user?.labels || []).includes('admin')
}
function isPreviewProjectReady(project) {
if (!project) return false
if (project.hasPreview === false) return false
const status = (project.provisioningStatus || project.status || '').toLowerCase()
return ['ready', 'deployed'].includes(status)
}
function safePreviewUrl(url) {
try {
const parsed = new URL(String(url || ''))
if (
parsed.hostname.endsWith('.project.webklar.com') ||
parsed.hostname === 'project.webklar.com'
) {
return parsed.toString()
}
} catch {
/* ignore */
}
return null
}
function previewUrlForSubdomain(subdomain) {
const safe = String(subdomain || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
if (!safe) return null
return `https://${safe}.${config.preview.baseHost}`
}
router.use(requireAppwriteStaff)
router.post('/', async (req, res) => {
const user = req.appwriteUser
if (!isStaffAdmin(user)) {
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
}
const subdomain = String(req.body?.subdomain || '')
.trim()
.toLowerCase()
const previewUrl = safePreviewUrl(req.body?.previewUrl) || previewUrlForSubdomain(subdomain)
if (!subdomain || !previewUrl) {
return res.status(400).json({ error: 'subdomain oder gueltige previewUrl erforderlich' })
}
try {
const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(1),
])
const project = projects[0]
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden' })
}
if (!isPreviewProjectReady(project)) {
return res.status(409).json({ error: 'Projekt-Vorschau ist noch nicht bereit' })
}
const token = createStaffPreviewToken({
userId: user.$id,
email: user.email || '',
name: user.name || user.email || 'Admin',
previewUrl,
customerId: project.customerId || '',
})
const portalBase = `https://${config.preview.baseHost}`
return res.json({
openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}`,
})
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Preview-Login fehlgeschlagen' })
}
})
export default router

View File

@@ -0,0 +1,32 @@
import { Router } from 'express'
import { createProjectFromTemplate } from '../../services/giteaAdmin.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
const router = Router()
router.use(requireAppwriteStaff)
router.post('/create-from-template', async (req, res) => {
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
if (!repoName || !subdomain) {
return res.status(400).json({ error: 'repoName und subdomain sind erforderlich' })
}
try {
const result = await createProjectFromTemplate({
repoName,
displayName,
subdomain,
customerId,
ticketId,
owner,
})
return res.status(201).json({ ok: true, ...result })
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Projekt konnte nicht angelegt werden' })
}
})
export default router

View File

@@ -3,14 +3,25 @@ import { config } from '../config.js'
import {
getCustomerByAppwriteUserId,
getCustomerByEmail,
getDocument,
getPortalAccessByCustomerId,
listDocuments,
updateDocument,
Query,
} from '../services/appwriteAdmin.js'
import { loginWithAppwrite, getLoginCooldownRemainingSec } from '../services/appwriteClient.js'
import {
clearPortalSession,
setPortalSession,
} from '../middleware/session.js'
import {
PREVIEW_COOKIE_NAME,
clearPreviewSession,
createPreviewSessionToken,
previewSessionCookieOptions,
verifyPreviewSessionToken,
} from '../services/previewSession.js'
import { consumeStaffPreviewToken } from '../services/staffPreviewTokens.js'
const router = Router()
@@ -64,6 +75,62 @@ async function validatePortalAccess(appwriteUserId, email) {
return { customer, portalAccess }
}
async function syncCustomerAppwriteUserId(customer, appwriteUserId) {
if (!customer?.$id || !appwriteUserId) return customer
if (customer.appwriteUserId === appwriteUserId) return customer
const now = new Date().toISOString()
return updateDocument(config.collections.customers, customer.$id, {
appwriteUserId,
portalAccessEnabled: true,
updatedAt: now,
})
}
async function resolveSessionCustomer(session) {
if (!session?.customerId) return null
let customer = await getDocument(config.collections.customers, session.customerId).catch(() => null)
if (customer) return customer
if (session.appwriteUserId) {
customer = await getCustomerByAppwriteUserId(session.appwriteUserId)
if (customer) return customer
}
if (session.email) {
customer = await getCustomerByEmail(session.email)
}
return customer
}
function isPreviewProjectReady(project) {
if (!project) return false
if (project.hasPreview === false) return false
const status = (project.provisioningStatus || project.status || '').toLowerCase()
return ['ready', 'deployed'].includes(status)
}
function isAdminSession(session) {
return session?.role === 'admin' || (session?.labels || []).includes('admin')
}
function safePreviewRedirectUrl(url) {
try {
const parsed = new URL(String(url || ''))
if (
parsed.hostname.endsWith('.project.webklar.com') ||
parsed.hostname === 'project.webklar.com'
) {
return parsed.toString()
}
} catch {
/* ignore */
}
return null
}
router.get('/login-status', (_req, res) => {
const retryAfterSeconds = getLoginCooldownRemainingSec()
res.json({
@@ -80,7 +147,8 @@ router.post('/login', async (req, res) => {
try {
const user = await loginWithAppwrite(email.trim(), password)
const { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
let { customer, portalAccess } = await validatePortalAccess(user.$id, email.trim())
customer = await syncCustomerAppwriteUserId(customer, user.$id)
setPortalSession(res, {
customerId: customer.$id,
@@ -89,6 +157,17 @@ router.post('/login', async (req, res) => {
email: customer.email || user.email || email,
})
const previewToken = await createPreviewSessionToken({
userId: user.$id,
customerId: customer.$id,
email: customer.email || user.email || email,
name: customer.name || user.name || '',
role: 'customer',
labels: [],
authSource: 'kundenbereich',
})
res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions())
try {
await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, {
lastLoginAt: new Date().toISOString(),
@@ -120,9 +199,78 @@ router.post('/login', async (req, res) => {
router.post('/logout', (_req, res) => {
clearPortalSession(res)
clearPreviewSession(res)
res.json({ success: true })
})
router.get('/preview-access', async (req, res) => {
const token = req.cookies?.[PREVIEW_COOKIE_NAME]
if (!token) {
return res.status(401).json({ allowed: false })
}
const session = await verifyPreviewSessionToken(token)
const admin = isAdminSession(session)
if (!session || (!session.customerId && !admin)) {
return res.status(401).json({ allowed: false })
}
const subdomain = String(req.query.subdomain || '')
.trim()
.toLowerCase()
if (!subdomain) {
return res.status(400).json({ allowed: false, error: 'subdomain required' })
}
try {
const queries = [Query.equal('subdomain', subdomain), Query.limit(1)]
if (!admin) {
queries.splice(1, 0, Query.equal('customerId', session.customerId))
}
const projects = await listDocuments(config.collections.websiteProjects, queries)
const project = projects[0]
if (!project) {
return res.json({ allowed: false, reason: 'no_project' })
}
if (!isPreviewProjectReady(project)) {
return res.json({ allowed: false, reason: 'not_ready', projectId: project.$id })
}
return res.json({ allowed: true, projectId: project.$id })
} catch (err) {
return res.status(500).json({ allowed: false, error: err.message || 'Fehler' })
}
})
router.get('/staff-preview', async (req, res) => {
const tokenValue = String(req.query.token || '').trim()
if (!tokenValue) {
return res.status(400).send('Token fehlt')
}
const tokenData = consumeStaffPreviewToken(tokenValue)
if (!tokenData) {
return res.status(410).send('Der Preview-Link ist abgelaufen. Bitte erneut aus dem Ticketsystem oeffnen.')
}
const previewUrl = safePreviewRedirectUrl(tokenData.previewUrl)
if (!previewUrl) {
return res.status(400).send('Ungueltige Preview-URL')
}
const previewToken = await createPreviewSessionToken({
userId: tokenData.userId,
customerId: tokenData.customerId || '',
email: tokenData.email || '',
name: tokenData.name || 'Admin',
role: 'admin',
labels: ['admin'],
authSource: 'ticket-admin',
})
res.cookie(PREVIEW_COOKIE_NAME, previewToken, previewSessionCookieOptions())
return res.redirect(previewUrl)
})
router.get('/me', async (req, res) => {
const raw = req.signedCookies?.[config.cookieName]
if (!raw) {
@@ -135,12 +283,25 @@ router.get('/me', async (req, res) => {
return res.json({ authenticated: false })
}
const customer = await getCustomerByAppwriteUserId(session.appwriteUserId)
let customer = await resolveSessionCustomer(session)
if (!customer) {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false })
}
try {
await validatePortalAccess(session.appwriteUserId, session.email || customer.email)
} catch {
clearPortalSession(res)
clearPreviewSession(res)
return res.json({ authenticated: false })
}
if (customer.appwriteUserId !== session.appwriteUserId) {
customer = await syncCustomerAppwriteUserId(customer, session.appwriteUserId)
}
return res.json({
authenticated: true,
customer: sanitizeCustomer(customer),

View File

@@ -1,12 +1,14 @@
import { Router } from 'express'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo } from '../../services/appwriteAdmin.js'
import {
fetchPreviewConfig,
defaultPreviewConfig,
runDeploy,
buildPreviewUrl,
} from '../../services/previewDeploy.js'
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
const execFileAsync = promisify(execFile)
const router = Router()
function verifyWebhookToken(req) {
@@ -17,84 +19,6 @@ function verifyWebhookToken(req) {
return queryToken === token || headerToken === token
}
async function fetchPreviewConfig(repoFullName, branch) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo || !config.gitea.apiToken) {
return null
}
const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
const response = await fetch(url, {
headers: { Authorization: `token ${config.gitea.apiToken}` },
})
if (!response.ok) return null
const data = await response.json()
const content = Buffer.from(data.content || '', 'base64').toString('utf8')
return JSON.parse(content)
}
function defaultPreviewConfig(repoFullName) {
const repoName = repoFullName.split('/').pop() || 'preview'
return {
enabled: true,
type: 'static',
branch: 'main',
displayName: repoName,
subdomain: repoName,
}
}
async function cloneRepo(cloneUrl, targetDir, branch) {
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
await execFileAsync('git', [
'clone',
'--depth',
'1',
'--branch',
branch,
cloneUrl,
targetDir,
])
}
async function deployStatic(sourceDir, targetDir, indexSubdir = '') {
const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir])
}
async function deployNodeBuild(sourceDir, targetDir) {
await execFileAsync('npm', ['ci'], { cwd: sourceDir })
await execFileAsync('npm', ['run', 'build'], { cwd: sourceDir })
const distDir = path.join(sourceDir, 'dist')
await deployStatic(distDir, targetDir)
}
async function runDeploy(repoFullName, branch, previewConfig) {
if (!config.preview.deployRoot) {
return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' }
}
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git`
const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
const targetDir = path.join(config.preview.deployRoot, subdomain)
await cloneRepo(cloneUrl, workDir, branch)
if (previewConfig.type === 'node_build') {
await deployNodeBuild(workDir, targetDir)
} else {
await deployStatic(workDir, targetDir, previewConfig.index || '')
}
return { deployed: true, subdomain, targetDir }
}
router.post('/gitea', async (req, res) => {
if (!verifyWebhookToken(req)) {
return res.status(401).json({ error: 'Unauthorized' })
@@ -104,6 +28,9 @@ router.post('/gitea', async (req, res) => {
const ref = payload.ref || ''
const repo = payload.repository || {}
const repoFullName = repo.full_name || ''
const commits = payload.commits || []
const pusher = payload.pusher || {}
const commitSha = payload.after || commits[commits.length - 1]?.id || ''
if (!repoFullName) {
return res.status(400).json({ error: 'repository.full_name fehlt' })
@@ -112,42 +39,58 @@ router.post('/gitea', async (req, res) => {
const branch = ref.replace('refs/heads/', '') || 'main'
try {
let previewConfig = await fetchPreviewConfig(repoFullName, branch)
if (!previewConfig) {
previewConfig = defaultPreviewConfig(repoFullName)
}
if (!previewConfig.enabled) {
return res.json({ ok: true, skipped: true, reason: 'preview disabled' })
}
const configBranch = previewConfig.branch || 'main'
if (branch !== configBranch) {
return res.json({ ok: true, skipped: true, reason: `branch ${branch} != ${configBranch}` })
}
const previewConfigFile = await fetchPreviewConfig(repoFullName, branch)
const previewConfig = previewConfigFile || defaultPreviewConfig(repoFullName)
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
const previewUrl = `https://${subdomain}.${config.preview.baseHost}`
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
let deployResult = { deployed: false }
let deployResult = { deployed: false, skipped: true }
const configBranch = previewConfig.branch || 'main'
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig)
} catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
}
}
const project = await upsertWebsiteProjectByRepo(repoFullName, {
projectName: previewConfig.displayName || subdomain,
templateName: previewConfig.templateName || 'webklar-preview-template',
const projectPayload = {
projectName: previewConfig.displayName || repo.name || subdomain,
templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || subdomain,
repoFullName,
subdomain,
previewUrl,
status: deployResult.deployed ? 'deployed' : 'pending',
provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed',
}
if (subdomain) projectPayload.subdomain = subdomain
if (previewUrl) projectPayload.previewUrl = previewUrl
if (deployResult.deployed) {
projectPayload.status = 'deployed'
projectPayload.provisioningStatus = 'ready'
} else if (hasPreview) {
projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
} else {
projectPayload.status = 'synced'
}
const project = await upsertWebsiteProjectByRepo(repoFullName, projectPayload)
let gitWorksheet = null
if (project.ticketId && commitSha && commitSha !== '0000000000000000000000000000000000000000') {
gitWorksheet = await createGitPushWorksheet({
ticketId: project.ticketId,
repoFullName,
branch,
commits,
pusher,
commitSha,
})
}
return res.json({
ok: true,
@@ -156,6 +99,7 @@ router.post('/gitea', async (req, res) => {
previewUrl,
deploy: deployResult,
projectId: project.$id,
gitWorksheet,
})
} catch (err) {
console.error('[webhook]', err)

View File

@@ -16,9 +16,9 @@ function adminHeaders() {
}
}
function formatRequestBody(body, method) {
function formatRequestBody(body, method, { raw = false } = {}) {
if (!body || method === 'GET' || method === 'DELETE') return body
if (body.data !== undefined) return body
if (raw || body.data !== undefined) return body
const { documentId, ...fields } = body
const payload = { data: fields }
if (documentId) payload.documentId = documentId
@@ -37,7 +37,8 @@ async function adminFetch(path, { method = 'GET', body, queries = [] } = {}) {
url.searchParams.append('queries[]', q)
}
const requestBody = formatRequestBody(body, method)
const useRawBody = path.startsWith('/users')
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
const response = await fetch(url.toString(), {
method,
@@ -90,6 +91,50 @@ export async function getUserById(userId) {
return adminFetch(`/users/${userId}`)
}
export async function createAppwriteUser({ email, password, name }) {
return adminFetch('/users', {
method: 'POST',
body: {
userId: ID.unique(),
email: email.trim(),
password,
name: name?.trim() || email.trim(),
},
})
}
export async function updateAppwriteUserEmail(userId, email) {
return adminFetch(`/users/${userId}/email`, {
method: 'PATCH',
body: { email: email.trim() },
})
}
export async function updateAppwriteUserPassword(userId, password) {
return adminFetch(`/users/${userId}/password`, {
method: 'PATCH',
body: { password },
})
}
export async function updateAppwriteUserName(userId, name) {
return adminFetch(`/users/${userId}/name`, {
method: 'PATCH',
body: { name: name.trim() },
})
}
export async function updateAppwriteUserLabels(userId, labels) {
return adminFetch(`/users/${userId}/labels`, {
method: 'PUT',
body: { labels },
})
}
export async function deleteAppwriteUser(userId) {
return adminFetch(`/users/${userId}`, { method: 'DELETE' })
}
export async function deleteUserSession(userId, sessionId) {
return adminFetch(`/users/${userId}/sessions/${sessionId}`, { method: 'DELETE' })
}
@@ -131,14 +176,47 @@ export async function updateDocument(collectionId, documentId, data) {
})
}
export async function getDocument(collectionId, documentId) {
return adminFetch(`${collectionPath(collectionId)}/${documentId}`)
}
export async function createDocument(collectionId, data, documentId = ID.unique()) {
return adminFetch(collectionPath(collectionId), {
method: 'POST',
body: { ...data, documentId },
})
}
export async function deleteDocument(collectionId, documentId) {
return adminFetch(`${collectionPath(collectionId)}/${documentId}`, {
method: 'DELETE',
})
}
const PRESERVE_IF_EMPTY = new Set(['customerId', 'ticketId'])
function buildProjectPayload(existing, data) {
const now = new Date().toISOString()
const payload = { updatedAt: now }
for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue
if (PRESERVE_IF_EMPTY.has(key) && (value === '' || value === null)) {
if (existing?.[key]) continue
}
payload[key] = value
}
return payload
}
export async function upsertWebsiteProjectByRepo(repoFullName, data) {
const existing = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const now = new Date().toISOString()
const payload = { ...data, updatedAt: now }
const payload = buildProjectPayload(existing[0], data)
if (existing[0]) {
return updateDocument(
@@ -148,9 +226,17 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
)
}
const now = new Date().toISOString()
return adminFetch(collectionPath(config.collections.websiteProjects), {
method: 'POST',
body: { ...payload, createdAt: now, documentId: ID.unique() },
body: {
...payload,
customerId: payload.customerId ?? '',
ticketId: payload.ticketId ?? '',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
createdAt: now,
documentId: ID.unique(),
},
})
}

View File

@@ -0,0 +1,258 @@
import { config } from '../config.js'
import {
createAppwriteUser,
createDocument,
deleteDocument,
getCustomerByEmail,
getDocument,
getPortalAccessByCustomerId,
listDocuments,
updateAppwriteUserEmail,
updateAppwriteUserPassword,
deleteAppwriteUser,
updateDocument,
Query,
} from './appwriteAdmin.js'
const PORTAL_PASSWORD_PREFIX = '__portalPassword__:'
function readPortalPassword(customer) {
if (customer?.portalPassword) return customer.portalPassword
const notes = customer?.notes || ''
const match = notes.match(/^__portalPassword__:([^\n]+)/)
return match ? match[1] : ''
}
function notesWithPortalPassword(existingNotes, password) {
const stripped = (existingNotes || '').replace(/^__portalPassword__:[^\n]*\n?/, '').trim()
if (!password) return stripped || ''
const line = `${PORTAL_PASSWORD_PREFIX}${password}`
return stripped ? `${line}\n${stripped}` : line
}
function sanitizeCustomer(customer, { portalPassword } = {}) {
const storedPassword = portalPassword || readPortalPassword(customer)
return {
$id: customer.$id,
code: customer.code || '',
name: customer.name || '',
location: customer.location || '',
email: customer.email || '',
phone: customer.phone || '',
companyName: customer.companyName || '',
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
appwriteUserId: customer.appwriteUserId || '',
customerStatus: customer.customerStatus || '',
portalPassword: storedPassword || '',
}
}
async function ensurePortalAccess(customerId, appwriteUserId) {
const now = new Date().toISOString()
let portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
portalAccess = await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, {
enabled: true,
appwriteUserId,
passwordSet: true,
})
} else {
portalAccess = await createDocument(config.collections.customerPortalAccess, {
customerId,
enabled: true,
appwriteUserId,
passwordSet: true,
})
}
return portalAccess
}
export async function createCustomerWithPortalAccess(payload) {
const {
code = '',
name,
location = '',
email,
phone = '',
password,
} = payload || {}
if (!name?.trim()) {
const error = new Error('Name ist erforderlich')
error.status = 400
throw error
}
if (!email?.trim()) {
const error = new Error('E-Mail ist erforderlich')
error.status = 400
throw error
}
if (!password || password.length < 8) {
const error = new Error('Passwort muss mindestens 8 Zeichen haben')
error.status = 400
throw error
}
const existing = await getCustomerByEmail(email.trim())
if (existing) {
const error = new Error('Ein Kunde mit dieser E-Mail existiert bereits')
error.status = 409
throw error
}
const appwriteUser = await createAppwriteUser({
email: email.trim(),
password,
name: name.trim(),
})
const now = new Date().toISOString()
let customer
try {
customer = await createDocument(config.collections.customers, {
code: code.trim(),
name: name.trim(),
location: location.trim(),
email: email.trim(),
phone: phone.trim(),
notes: notesWithPortalPassword('', password),
portalAccessEnabled: true,
appwriteUserId: appwriteUser.$id,
customerStatus: 'active',
createdAt: now,
updatedAt: now,
})
await ensurePortalAccess(customer.$id, appwriteUser.$id)
} catch (err) {
try {
await deleteAppwriteUser(appwriteUser.$id)
} catch {
/* ignore rollback failure */
}
throw err
}
return {
customer: sanitizeCustomer(customer, { portalPassword: password }),
}
}
export async function updateCustomerWithPortalAccess(customerId, payload) {
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
if (!customer) {
const error = new Error('Kunde nicht gefunden')
error.status = 404
throw error
}
const {
code,
name,
location,
email,
phone,
password,
} = payload || {}
const nextEmail = (email ?? customer.email ?? '').trim()
if (!nextEmail) {
const error = new Error('E-Mail ist erforderlich')
error.status = 400
throw error
}
if (password && password.length < 8) {
const error = new Error('Passwort muss mindestens 8 Zeichen haben')
error.status = 400
throw error
}
const normalizedPassword =
typeof password === 'string' && password.trim().length > 0 ? password : undefined
if (nextEmail.toLowerCase() !== (customer.email || '').trim().toLowerCase()) {
const existing = await getCustomerByEmail(nextEmail)
if (existing && existing.$id !== customerId) {
const error = new Error('Ein anderer Kunde nutzt diese E-Mail bereits')
error.status = 409
throw error
}
}
let appwriteUserId = customer.appwriteUserId || ''
let portalPassword
if (!appwriteUserId && nextEmail && normalizedPassword) {
const appwriteUser = await createAppwriteUser({
email: nextEmail,
password: normalizedPassword,
name: (name ?? customer.name ?? nextEmail).trim(),
})
appwriteUserId = appwriteUser.$id
portalPassword = normalizedPassword
} else if (appwriteUserId) {
if (nextEmail && nextEmail !== customer.email) {
await updateAppwriteUserEmail(appwriteUserId, nextEmail)
}
if (normalizedPassword) {
await updateAppwriteUserPassword(appwriteUserId, normalizedPassword)
portalPassword = normalizedPassword
}
} else if (normalizedPassword) {
const error = new Error('Portal-Login fehlt <20> bitte E-Mail setzen und Passwort erneut speichern')
error.status = 400
throw error
}
const now = new Date().toISOString()
const nextPortalPassword =
normalizedPassword || readPortalPassword(customer) || undefined
const updated = await updateDocument(config.collections.customers, customerId, {
...(code !== undefined ? { code: String(code).trim() } : {}),
...(name !== undefined ? { name: String(name).trim() } : {}),
...(location !== undefined ? { location: String(location).trim() } : {}),
email: nextEmail,
...(phone !== undefined ? { phone: String(phone).trim() } : {}),
...(nextPortalPassword
? { notes: notesWithPortalPassword(customer.notes, nextPortalPassword) }
: {}),
...(appwriteUserId ? { appwriteUserId, portalAccessEnabled: true, customerStatus: 'active' } : {}),
updatedAt: now,
})
if (appwriteUserId) {
await ensurePortalAccess(customerId, appwriteUserId)
}
return {
customer: sanitizeCustomer(updated, portalPassword ? { portalPassword } : {}),
}
}
export async function listCustomersForAdmin() {
const customers = await listDocuments(config.collections.customers, [Query.orderAsc('name')])
return customers.map((customer) => sanitizeCustomer(customer))
}
export async function deleteCustomerWithPortalAccess(customerId) {
const customer = await getDocument(config.collections.customers, customerId)
.catch(() => null)
if (!customer) {
const error = new Error('Kunde nicht gefunden')
error.status = 404
throw error
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
config.collections.customerPortalAccess,
portalAccess.$id
)
}
await deleteDocument(config.collections.customers, customerId)
if (customer.appwriteUserId) {
await deleteAppwriteUser(customer.appwriteUserId).catch(() => {})
}
return { success: true }
}

View File

@@ -0,0 +1,160 @@
import { config } from '../config.js'
import {
createAppwriteUser,
createDocument,
deleteAppwriteUser,
deleteDocument,
getDocument,
getUserById,
listDocuments,
updateAppwriteUserEmail,
updateAppwriteUserLabels,
updateAppwriteUserName,
updateAppwriteUserPassword,
updateDocument,
Query,
} from './appwriteAdmin.js'
function httpError(status, message) {
const error = new Error(message)
error.status = status
return error
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase()
}
function normalizeShortcode(value) {
return String(value || '').trim().toUpperCase().slice(0, 10)
}
function sanitizeEmployee(employee, user) {
return {
$id: employee.$id,
userId: employee.userId,
displayName: employee.displayName || user?.name || '',
email: employee.email || user?.email || '',
shortcode: employee.shortcode || '',
isAdmin: Boolean(user?.labels?.includes('admin')),
}
}
async function employeeWithUser(employee) {
const user = await getUserById(employee.userId).catch(() => null)
return sanitizeEmployee(employee, user)
}
async function setAdminLabel(userId, isAdmin) {
const user = await getUserById(userId)
const labels = new Set(user.labels || [])
if (isAdmin) labels.add('admin')
else labels.delete('admin')
await updateAppwriteUserLabels(userId, [...labels])
}
export async function listEmployeesForAdmin() {
const employees = await listDocuments(config.collections.employees, [
Query.orderAsc('displayName'),
Query.limit(500),
])
return Promise.all(employees.map(employeeWithUser))
}
export async function createEmployeeWithLogin(payload) {
const displayName = String(payload?.displayName || '').trim()
const email = normalizeEmail(payload?.email)
const password = String(payload?.password || '')
const shortcode = normalizeShortcode(payload?.shortcode)
const isAdmin = Boolean(payload?.isAdmin)
if (!displayName) throw httpError(400, 'Name ist erforderlich')
if (!email) throw httpError(400, 'E-Mail ist erforderlich')
if (password.length < 8) {
throw httpError(400, 'Passwort muss mindestens 8 Zeichen haben')
}
const user = await createAppwriteUser({ email, password, name: displayName })
let employee
try {
await setAdminLabel(user.$id, isAdmin)
employee = await createDocument(config.collections.employees, {
userId: user.$id,
displayName,
email,
shortcode,
})
} catch (error) {
await deleteAppwriteUser(user.$id).catch(() => {})
throw error
}
return { employee: await employeeWithUser(employee) }
}
export async function updateEmployeeWithLogin(employeeId, payload, actorUserId) {
const employee = await getDocument(config.collections.employees, employeeId)
.catch(() => null)
if (!employee) throw httpError(404, 'Mitarbeiter nicht gefunden')
const displayName = payload?.displayName === undefined
? employee.displayName
: String(payload.displayName || '').trim()
const email = payload?.email === undefined
? normalizeEmail(employee.email)
: normalizeEmail(payload.email)
const shortcode = payload?.shortcode === undefined
? employee.shortcode || ''
: normalizeShortcode(payload.shortcode)
const password = typeof payload?.password === 'string' ? payload.password : ''
if (
employee.userId === actorUserId &&
payload?.isAdmin === false
) {
throw httpError(400, 'Die eigene Admin-Berechtigung kann nicht entfernt werden')
}
if (!displayName) throw httpError(400, 'Name ist erforderlich')
if (!email) throw httpError(400, 'E-Mail ist erforderlich')
if (password && password.length < 8) {
throw httpError(400, 'Passwort muss mindestens 8 Zeichen haben')
}
const user = await getUserById(employee.userId)
if (email !== normalizeEmail(user.email)) {
await updateAppwriteUserEmail(employee.userId, email)
}
if (displayName !== (user.name || '')) {
await updateAppwriteUserName(employee.userId, displayName)
}
if (password) {
await updateAppwriteUserPassword(employee.userId, password)
}
if (payload?.isAdmin !== undefined) {
await setAdminLabel(employee.userId, Boolean(payload.isAdmin))
}
const updated = await updateDocument(config.collections.employees, employeeId, {
displayName,
email,
shortcode,
})
return { employee: await employeeWithUser(updated) }
}
export async function deleteEmployeeWithLogin(employeeId, actorUserId) {
const employee = await getDocument(config.collections.employees, employeeId)
.catch(() => null)
if (!employee) throw httpError(404, 'Mitarbeiter nicht gefunden')
if (employee.userId === actorUserId) {
throw httpError(400, 'Der eigene angemeldete Account kann nicht gelöscht werden')
}
await deleteDocument(config.collections.employees, employeeId)
await deleteAppwriteUser(employee.userId).catch((error) => {
error.message = `Mitarbeiter entfernt, Login konnte nicht gelöscht werden: ${error.message}`
throw error
})
return { success: true }
}

View File

@@ -0,0 +1,123 @@
import { config } from '../config.js'
import {
listDocuments,
getDocument,
createDocument,
Query,
ID,
} from './appwriteAdmin.js'
const WSID_START = 100000
function formatToday() {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}${m}${day}`
}
function formatTimeNow() {
const d = new Date()
return `${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
}
async function generateNextWsid() {
const docs = await listDocuments(config.collections.worksheets, [
Query.orderDesc('wsid'),
Query.limit(1),
])
const highest = docs[0]?.wsid ? parseInt(docs[0].wsid, 10) : WSID_START - 1
return String(Math.max(WSID_START, highest + 1))
}
async function gitWorksheetExists(woid, commitSha) {
if (!commitSha) return false
const docs = await listDocuments(config.collections.worksheets, [
Query.equal('woid', woid),
Query.limit(100),
])
const marker = `Commit: ${commitSha.slice(0, 7)}`
return docs.some((doc) => doc.details?.includes(marker) || doc.details?.includes(commitSha))
}
function formatCommitLines(commits, repoFullName) {
if (!Array.isArray(commits) || commits.length === 0) {
return '- (keine Commit-Details im Webhook)'
}
return commits
.map((commit) => {
const sha = (commit.id || commit.sha || '').slice(0, 7)
const message = (commit.message || '').split('\n')[0]
const author = commit.author?.name || commit.committer?.name || 'Unbekannt'
const url = commit.url || `${config.gitea.baseUrl}/${repoFullName}/commit/${commit.id || commit.sha}`
return `- [${sha}](${url}): ${message} (${author})`
})
.join('\n')
}
export async function createGitPushWorksheet({
ticketId,
repoFullName,
branch,
commits = [],
pusher,
commitSha,
}) {
if (!ticketId) return null
let workorder
try {
workorder = await getDocument(config.collections.workorders, ticketId)
} catch {
console.warn('[gitWorksheet] Workorder nicht gefunden:', ticketId)
return null
}
const woid = workorder.woid
if (!woid) return null
if (await gitWorksheetExists(woid, commitSha)) {
return { skipped: true, reason: 'duplicate_commit' }
}
const wsid = await generateNextWsid()
const pusherName = pusher?.full_name || pusher?.username || pusher?.login || 'Gitea'
const latestCommit = commits[commits.length - 1] || commits[0]
const details = [
`Git Push auf \`${repoFullName}\` (Branch: ${branch})`,
`Commit: ${commitSha?.slice(0, 7) || latestCommit?.id?.slice(0, 7) || '?'}`,
'',
formatCommitLines(commits, repoFullName),
].join('\n')
const today = formatToday()
const worksheet = await createDocument(
config.collections.worksheets,
{
wsid,
woid: String(woid),
workorderId: ticketId,
employeeId: 'gitea',
employeeName: pusherName,
employeeShort: 'GIT',
serviceType: 'GIT',
oldStatus: workorder.status || '',
newStatus: workorder.status || '',
oldResponseLevel: workorder.response || '',
newResponseLevel: workorder.response || '',
totalTime: 0,
startDate: today,
startTime: formatTimeNow(),
endDate: today,
endTime: formatTimeNow(),
details,
isComment: true,
createdAt: new Date().toISOString(),
},
ID.unique()
)
return { created: true, wsid, worksheetId: worksheet.$id }
}

View File

@@ -0,0 +1,323 @@
import { config } from '../config.js'
import { upsertWebsiteProjectByRepo, listDocuments, Query } from './appwriteAdmin.js'
import {
runDeploy,
buildPreviewUrl,
} from './previewDeploy.js'
function giteaHeaders() {
if (!config.gitea.apiToken) {
throw new Error('GITEA_API_TOKEN fehlt in .env')
}
return {
Authorization: `token ${config.gitea.apiToken}`,
'Content-Type': 'application/json',
}
}
async function giteaFetch(path, options = {}) {
const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, {
...options,
headers: { ...giteaHeaders(), ...options.headers },
})
const text = await response.text()
let data = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = { message: text }
}
}
if (!response.ok) {
let message = data?.message || `Gitea ${response.status}`
if (response.status === 409 && /same name already exists/i.test(message)) {
message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w<>hlen.'
}
const error = new Error(message)
error.status = response.status
throw error
}
return data
}
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100)
}
export async function listAllAccessibleRepos() {
const repos = []
const limit = 50
let page = 1
while (true) {
const batch = await giteaFetch(`/repos/search?limit=${limit}&page=${page}`)
const data = Array.isArray(batch?.data) ? batch.data : Array.isArray(batch) ? batch : []
if (data.length === 0) break
repos.push(...data)
if (data.length < limit) break
page += 1
}
return repos
}
async function resolveSubdomainForSync(repo, existingDoc) {
if (existingDoc?.subdomain) return existingDoc.subdomain
const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop())
if (!candidate) return ''
const conflict = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', candidate),
Query.limit(1),
])
if (!conflict[0] || conflict[0].repoFullName === repo.full_name) {
return candidate
}
const ownerSlug = slugify(repo.full_name?.replace('/', '-'))
if (!ownerSlug) return ''
const ownerConflict = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', ownerSlug),
Query.limit(1),
])
if (!ownerConflict[0] || ownerConflict[0].repoFullName === repo.full_name) {
return ownerSlug
}
return ''
}
export async function syncReposToAppwrite() {
const repos = await listAllAccessibleRepos()
let created = 0
let updated = 0
for (const repo of repos) {
const repoFullName = repo.full_name
if (!repoFullName) continue
const existing = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const branch = repo.default_branch || 'main'
const subdomain = await resolveSubdomainForSync(repo, existing[0])
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
const payload = {
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || repoFullName.split('/').pop(),
repoFullName,
}
if (subdomain) payload.subdomain = subdomain
if (previewUrl) payload.previewUrl = previewUrl
if (!existing[0]) {
payload.status = 'synced'
created += 1
} else {
updated += 1
}
await upsertWebsiteProjectByRepo(repoFullName, payload)
}
return { synced: repos.length, created, updated }
}
export async function ensureSubdomainAvailable(subdomain) {
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(1),
])
if (docs[0]) {
const error = new Error(`Subdomain "${subdomain}" ist bereits vergeben`)
error.status = 409
throw error
}
}
export async function generateRepoFromTemplate({ owner, repoName }) {
const templateOwner = config.gitea.templateOwner
const templateRepo = config.gitea.templateRepo
const targetOwner = owner || config.gitea.defaultOwner
if (!targetOwner) {
throw new Error('GITEA_DEFAULT_OWNER fehlt in .env')
}
return giteaFetch(`/repos/${templateOwner}/${templateRepo}/generate`, {
method: 'POST',
body: JSON.stringify({
owner: targetOwner,
name: repoName,
private: false,
description: `Website-Projekt ${repoName}`,
git_content: true,
}),
})
}
async function waitForRepoBranch(owner, repo, preferredBranch = 'main', maxAttempts = 15, delayMs = 400) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const repoInfo = await giteaFetch(`/repos/${owner}/${repo}`)
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches?limit=50`)
const branchNames = Array.isArray(branches) ? branches.map((entry) => entry.name) : []
if (branchNames.includes(preferredBranch)) return preferredBranch
if (repoInfo?.default_branch && branchNames.includes(repoInfo.default_branch)) {
return repoInfo.default_branch
}
if (branchNames.length > 0) return branchNames[0]
} catch {
/* retry */
}
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
throw new Error(`Branch "${preferredBranch}" existiert im Repository ${owner}/${repo} noch nicht`)
}
async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'main') {
const [owner, repo] = repoFullName.split('/')
const content = JSON.stringify(previewConfig, null, 2) + '\n'
const encoded = Buffer.from(content).toString('base64')
let sha = null
try {
const existing = await giteaFetch(
`/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
)
sha = existing.sha
} catch {
/* file may not exist yet */
}
await giteaFetch(`/repos/${owner}/${repo}/contents/.webklar-preview.json`, {
method: sha ? 'PUT' : 'POST',
body: JSON.stringify({
message: 'Update preview config from ticket system',
content: encoded,
branch,
...(sha ? { sha } : {}),
}),
})
}
export async function createProjectFromTemplate({
repoName,
displayName,
subdomain,
customerId = '',
ticketId = '',
owner,
}) {
const targetOwner = owner || config.gitea.defaultOwner
const safeRepoName = slugify(repoName) || slugify(subdomain)
const safeSubdomain = slugify(subdomain) || safeRepoName
if (!safeRepoName || !safeSubdomain) {
throw new Error('repoName und subdomain sind erforderlich')
}
await ensureSubdomainAvailable(safeSubdomain)
const repo = await generateRepoFromTemplate({
owner: targetOwner,
repoName: safeRepoName,
})
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
const [repoOwner, repoSlug] = repoFullName.split('/')
const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main')
const previewConfig = {
enabled: true,
type: 'static',
branch,
displayName: displayName || safeRepoName,
subdomain: safeSubdomain,
templateName: config.gitea.templateRepo,
}
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
let deployResult = { deployed: false }
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig)
} catch (deployErr) {
console.error('[giteaAdmin] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
}
const previewUrl = buildPreviewUrl(safeSubdomain)
const project = await upsertWebsiteProjectByRepo(repoFullName, {
customerId,
ticketId,
projectName: previewConfig.displayName,
templateName: config.gitea.templateRepo,
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || safeRepoName,
repoFullName,
subdomain: safeSubdomain,
previewUrl,
status: deployResult.deployed ? 'deployed' : 'pending',
provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed',
})
return {
projectId: project.$id,
repoFullName,
previewUrl,
subdomain: safeSubdomain,
deploy: deployResult,
}
}
const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README']
export async function fetchRepoReadme(repoFullName) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo) {
const error = new Error('repoFullName muss owner/repo sein')
error.status = 400
throw error
}
for (const filename of README_CANDIDATES) {
try {
const data = await giteaFetch(
`/repos/${owner}/${repo}/contents/${encodeURIComponent(filename)}`
)
const encoding = data.encoding || 'base64'
const raw = encoding === 'base64'
? Buffer.from(data.content || '', 'base64').toString('utf8')
: String(data.content || '')
return {
filename: data.name || filename,
content: raw,
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}/src/branch/main/${filename}`,
}
} catch (err) {
if (err.status === 404) continue
throw err
}
}
return null
}

View File

@@ -0,0 +1,81 @@
import { config } from '../config.js'
function getPrimaryContact(client) {
const contacts = client?.contacts || []
return contacts.find((c) => c.is_primary) || contacts[0] || {}
}
export function sanitizeInvoiceClient(client) {
const contact = getPrimaryContact(client)
const baseUrl = config.invoiceNinja.url
return {
id: client.id,
number: client.number || '',
displayName: client.display_name || client.name || '',
companyName: client.name || '',
website: client.website || '',
phone: contact.phone || client.phone || '',
email: contact.email || '',
contactFirstName: contact.first_name || '',
contactLastName: contact.last_name || '',
address1: client.address1 || '',
address2: client.address2 || '',
city: client.city || '',
state: client.state || '',
postalCode: client.postal_code || '',
countryId: client.country_id || '',
vatNumber: client.vat_number || '',
idNumber: client.id_number || '',
balance: Number(client.balance ?? 0),
paidToDate: Number(client.paid_to_date ?? 0),
publicNotes: client.public_notes || '',
privateNotes: client.private_notes || '',
editUrl: `${baseUrl}/clients/${client.id}/edit`,
}
}
export async function listInvoiceNinjaClients() {
const token = config.invoiceNinja.apiToken
if (!token) {
const error = new Error('INVOICE_NINJA_API_TOKEN ist nicht konfiguriert')
error.status = 503
throw error
}
const clients = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const url = new URL(`${config.invoiceNinja.url}/api/v1/clients`)
url.searchParams.set('per_page', '100')
url.searchParams.set('page', String(page))
url.searchParams.set('include', 'contacts')
const response = await fetch(url, {
headers: {
'X-API-TOKEN': token,
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(data.message || 'Invoice Ninja API Fehler')
error.status = response.status === 401 ? 502 : response.status
throw error
}
const batch = (data.data || []).filter((c) => !c.is_deleted)
clients.push(...batch.map(sanitizeInvoiceClient))
totalPages = Number(data.meta?.pagination?.total_pages || 1)
page += 1
}
return clients.sort((a, b) =>
(a.displayName || a.companyName).localeCompare(b.displayName || b.companyName, 'de')
)
}

View File

@@ -0,0 +1,89 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
import { config } from '../config.js'
const execFileAsync = promisify(execFile)
export async function fetchPreviewConfig(repoFullName, branch) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo || !config.gitea.apiToken) {
return null
}
const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
const response = await fetch(url, {
headers: { Authorization: `token ${config.gitea.apiToken}` },
})
if (!response.ok) return null
const data = await response.json()
const content = Buffer.from(data.content || '', 'base64').toString('utf8')
return JSON.parse(content)
}
export function defaultPreviewConfig(repoFullName) {
const repoName = repoFullName.split('/').pop() || 'preview'
return {
enabled: false,
type: 'static',
branch: 'main',
displayName: repoName,
subdomain: repoName,
}
}
async function cloneRepo(cloneUrl, targetDir, branch) {
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
await execFileAsync('git', [
'clone',
'--depth',
'1',
'--branch',
branch,
cloneUrl,
targetDir,
])
}
async function deployStatic(sourceDir, targetDir, indexSubdir = '') {
const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir])
}
async function deployNodeBuild(sourceDir, targetDir) {
await execFileAsync('npm', ['ci'], { cwd: sourceDir })
await execFileAsync('npm', ['run', 'build'], { cwd: sourceDir })
const distDir = path.join(sourceDir, 'dist')
await deployStatic(distDir, targetDir)
}
export function buildPreviewUrl(subdomain) {
return `https://${subdomain}.${config.preview.baseHost}`
}
export async function runDeploy(repoFullName, branch, previewConfig) {
if (!config.preview.deployRoot) {
return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' }
}
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git`
const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
const targetDir = path.join(config.preview.deployRoot, subdomain)
await cloneRepo(cloneUrl, workDir, branch)
if (previewConfig.type === 'node_build') {
await deployNodeBuild(workDir, targetDir)
} else {
await deployStatic(workDir, targetDir, previewConfig.index || '')
}
return { deployed: true, subdomain, targetDir }
}

View File

@@ -0,0 +1,44 @@
import { SignJWT, jwtVerify } from 'jose'
import { config } from '../config.js'
export const PREVIEW_COOKIE_NAME = 'preview_session'
const secret = new TextEncoder().encode(config.preview.sessionSecret)
export async function createPreviewSessionToken(payload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${config.preview.sessionMaxAge}s`)
.sign(secret)
}
export async function verifyPreviewSessionToken(token) {
try {
const { payload } = await jwtVerify(token, secret)
return payload
} catch {
return null
}
}
export function previewSessionCookieOptions() {
return {
domain: config.preview.cookieDomain,
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: config.preview.sessionMaxAge * 1000,
}
}
export function clearPreviewSession(res) {
res.clearCookie(PREVIEW_COOKIE_NAME, {
domain: config.preview.cookieDomain,
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
})
}

View File

@@ -0,0 +1,30 @@
import { randomBytes } from 'node:crypto'
const TOKEN_TTL_MS = 60 * 1000
const tokens = new Map()
function purgeExpired() {
const now = Date.now()
for (const [key, entry] of tokens) {
if (entry.expiresAt <= now) tokens.delete(key)
}
}
export function createStaffPreviewToken(payload) {
purgeExpired()
const token = randomBytes(32).toString('hex')
tokens.set(token, {
...payload,
expiresAt: Date.now() + TOKEN_TTL_MS,
})
return token
}
export function consumeStaffPreviewToken(token) {
purgeExpired()
const entry = tokens.get(token)
if (!entry) return null
tokens.delete(token)
if (entry.expiresAt <= Date.now()) return null
return entry
}