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

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 }
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig)
} catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
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)