Implementiere Kundenportal mit zentraler Appwrite-Anbindung.
Express-Server für Appwrite-Auth, Session, Projekt-Dashboard und Gitea-Webhook; statisches Frontend und Schema-Dokumentation für woms-database. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
112
server/routes/auth.js
Normal file
112
server/routes/auth.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
getCustomerByAppwriteUserId,
|
||||
getPortalAccessByCustomerId,
|
||||
updateDocument,
|
||||
} from '../services/appwriteAdmin.js'
|
||||
import { loginWithAppwrite } from '../services/appwriteClient.js'
|
||||
import {
|
||||
clearPortalSession,
|
||||
requireSession,
|
||||
setPortalSession,
|
||||
} from '../middleware/session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function sanitizeCustomer(customer) {
|
||||
return {
|
||||
id: customer.$id,
|
||||
code: customer.code || '',
|
||||
name: customer.name || '',
|
||||
companyName: customer.companyName || '',
|
||||
email: customer.email || '',
|
||||
phone: customer.phone || '',
|
||||
location: customer.location || '',
|
||||
customerStatus: customer.customerStatus || '',
|
||||
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
async function validatePortalAccess(appwriteUserId) {
|
||||
const customer = await getCustomerByAppwriteUserId(appwriteUserId)
|
||||
if (!customer) {
|
||||
const error = new Error('Kein Kundenkonto für diesen Login gefunden.')
|
||||
error.status = 403
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!customer.portalAccessEnabled) {
|
||||
const error = new Error('Portalzugang ist nicht freigeschaltet.')
|
||||
error.status = 403
|
||||
throw error
|
||||
}
|
||||
|
||||
const portalAccess = await getPortalAccessByCustomerId(customer.$id)
|
||||
if (!portalAccess || !portalAccess.enabled) {
|
||||
const error = new Error('Portalzugang ist deaktiviert.')
|
||||
error.status = 403
|
||||
throw error
|
||||
}
|
||||
|
||||
const status = (customer.customerStatus || '').toLowerCase()
|
||||
if (!config.allowedCustomerStatuses.includes(status)) {
|
||||
const error = new Error('Kundenkonto ist nicht aktiv.')
|
||||
error.status = 403
|
||||
throw error
|
||||
}
|
||||
|
||||
return { customer, portalAccess }
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password } = req.body || {}
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' })
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await loginWithAppwrite(email.trim(), password)
|
||||
const { customer, portalAccess } = await validatePortalAccess(user.$id)
|
||||
|
||||
setPortalSession(res, {
|
||||
customerId: customer.$id,
|
||||
appwriteUserId: user.$id,
|
||||
name: customer.name || user.name || '',
|
||||
email: customer.email || user.email || email,
|
||||
})
|
||||
|
||||
try {
|
||||
await updateDocument(config.collections.customerPortalAccess, portalAccess.$id, {
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[auth] lastLoginAt update failed:', err.message)
|
||||
}
|
||||
|
||||
return res.json({ success: true, customer: sanitizeCustomer(customer) })
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
return res.status(status).json({ error: err.message || 'Anmeldung fehlgeschlagen' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/logout', (_req, res) => {
|
||||
clearPortalSession(res)
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
router.get('/me', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getCustomerByAppwriteUserId(req.session.appwriteUserId)
|
||||
if (!customer) {
|
||||
clearPortalSession(res)
|
||||
return res.status(403).json({ error: 'Kundenkonto nicht gefunden' })
|
||||
}
|
||||
return res.json({ customer: sanitizeCustomer(customer) })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Fehler beim Laden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
36
server/routes/features.js
Normal file
36
server/routes/features.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express'
|
||||
import { Query } from 'node-appwrite'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
if (!customerId) {
|
||||
return res.status(401).json({ error: 'Nicht angemeldet' })
|
||||
}
|
||||
|
||||
try {
|
||||
const features = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.equal('enabled', true),
|
||||
])
|
||||
|
||||
const sanitized = features.map((f) => ({
|
||||
id: f.$id,
|
||||
projectId: f.projectId || '',
|
||||
featureKey: f.featureKey || '',
|
||||
enabled: Boolean(f.enabled),
|
||||
unlockedByPurchase: Boolean(f.unlockedByPurchase),
|
||||
purchaseStatus: f.purchaseStatus || '',
|
||||
}))
|
||||
|
||||
return res.json({ features: sanitized })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Features konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
40
server/routes/projects.js
Normal file
40
server/routes/projects.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Router } from 'express'
|
||||
import { Query } from 'node-appwrite'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
if (!customerId) {
|
||||
return res.status(401).json({ error: 'Nicht angemeldet' })
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await listDocuments(config.collections.websiteProjects, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
])
|
||||
|
||||
const sanitized = projects.map((p) => ({
|
||||
id: p.$id,
|
||||
projectName: p.projectName || '',
|
||||
subdomain: p.subdomain || '',
|
||||
previewUrl: p.previewUrl || '',
|
||||
liveDomain: p.liveDomain || '',
|
||||
status: p.status || '',
|
||||
provisioningStatus: p.provisioningStatus || '',
|
||||
templateName: p.templateName || '',
|
||||
giteaRepoUrl: p.giteaRepoUrl || '',
|
||||
repoFullName: p.repoFullName || '',
|
||||
}))
|
||||
|
||||
return res.json({ projects: sanitized })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Projekte konnten nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
166
server/routes/webhook/gitea.js
Normal file
166
server/routes/webhook/gitea.js
Normal file
@@ -0,0 +1,166 @@
|
||||
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'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const router = Router()
|
||||
|
||||
function verifyWebhookToken(req) {
|
||||
const token = config.gitea.webhookToken
|
||||
if (!token) return false
|
||||
const queryToken = req.query.token
|
||||
const headerToken = req.get('X-Gitea-Token') || req.get('X-Gogs-Signature')
|
||||
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' })
|
||||
}
|
||||
|
||||
const payload = req.body || {}
|
||||
const ref = payload.ref || ''
|
||||
const repo = payload.repository || {}
|
||||
const repoFullName = repo.full_name || ''
|
||||
|
||||
if (!repoFullName) {
|
||||
return res.status(400).json({ error: 'repository.full_name fehlt' })
|
||||
}
|
||||
|
||||
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 subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
|
||||
const previewUrl = `https://${subdomain}.${config.preview.baseHost}`
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const project = await upsertWebsiteProjectByRepo(repoFullName, {
|
||||
projectName: previewConfig.displayName || subdomain,
|
||||
templateName: previewConfig.templateName || 'webklar-preview-template',
|
||||
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',
|
||||
})
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
repoFullName,
|
||||
subdomain,
|
||||
previewUrl,
|
||||
deploy: deployResult,
|
||||
projectId: project.$id,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[webhook]', err)
|
||||
return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user