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:
2026-05-22 23:38:38 +02:00
commit f31727aeb4
23 changed files with 2056 additions and 0 deletions

45
server/config.js Normal file
View File

@@ -0,0 +1,45 @@
import 'dotenv/config'
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
cookieName: process.env.SESSION_COOKIE_NAME || 'webklar_portal_session',
allowedCustomerStatuses: (process.env.ALLOWED_CUSTOMER_STATUSES || 'active')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
appwrite: {
endpoint: process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1',
projectId: process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05',
databaseId: process.env.APPWRITE_DATABASE_ID || 'woms-database',
apiKey: process.env.APPWRITE_API_KEY || '',
},
collections: {
customers: process.env.APPWRITE_COLLECTION_CUSTOMERS || 'customers',
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',
},
gitea: {
webhookToken: process.env.GITEA_WEBHOOK_TOKEN || '',
baseUrl: process.env.GITEA_BASE_URL || 'https://git.webklar.com',
apiToken: process.env.GITEA_API_TOKEN || '',
},
preview: {
baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com',
deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '',
},
}
export function assertServerConfig() {
if (!config.sessionSecret || config.sessionSecret.length < 32) {
console.warn('[config] SESSION_SECRET fehlt oder ist zu kurz (min. 32 Zeichen).')
}
if (!config.appwrite.apiKey) {
console.warn('[config] APPWRITE_API_KEY fehlt DB-Zugriff und Webhook schlagen fehl.')
}
}

45
server/index.js Normal file
View File

@@ -0,0 +1,45 @@
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config, assertServerConfig } from './config.js'
import { sessionMiddleware } from './middleware/session.js'
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'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.join(__dirname, '..', 'public')
assertServerConfig()
const app = express()
app.use(sessionMiddleware())
app.use(express.json({ limit: '2mb' }))
app.use('/api/auth', authRoutes)
app.use('/api/projects', projectsRoutes)
app.use('/api/features', featuresRoutes)
app.use('/webhook', giteaWebhookRoutes)
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'webklar-kundenbereich' })
})
app.use(express.static(publicDir))
app.get('/dashboard.html', (req, res, next) => {
const raw = req.signedCookies?.[config.cookieName]
if (!raw) {
return res.redirect('/login.html')
}
next()
})
app.get('/', (_req, res) => {
res.redirect('/login.html')
})
app.listen(config.port, () => {
console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`)
})

View File

@@ -0,0 +1,49 @@
import cookieParser from 'cookie-parser'
import { config } from '../config.js'
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
export function sessionMiddleware() {
return cookieParser(config.sessionSecret)
}
export function setPortalSession(res, data) {
res.cookie(config.cookieName, JSON.stringify(data), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_MS,
signed: true,
})
}
export function clearPortalSession(res) {
res.clearCookie(config.cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
signed: true,
})
}
export function requireSession(req, res, next) {
const raw = req.signedCookies?.[config.cookieName]
if (!raw) {
return res.status(401).json({ error: 'Nicht angemeldet' })
}
try {
const session = JSON.parse(raw)
if (!session.customerId || !session.appwriteUserId) {
return res.status(401).json({ error: 'Ungültige Session' })
}
req.session = session
next()
} catch {
return res.status(401).json({ error: 'Ungültige Session' })
}
}
export function getSessionCustomerId(req) {
return req.session?.customerId
}

112
server/routes/auth.js Normal file
View 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
View 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
View 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

View 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

View File

@@ -0,0 +1,88 @@
import { Client, Account, Databases, ID, Query } from 'node-appwrite'
import { config } from '../config.js'
export function createAdminClient() {
const client = new Client()
.setEndpoint(config.appwrite.endpoint)
.setProject(config.appwrite.projectId)
.setKey(config.appwrite.apiKey)
return {
client,
databases: new Databases(client),
}
}
export function createUserClient() {
const client = new Client()
.setEndpoint(config.appwrite.endpoint)
.setProject(config.appwrite.projectId)
return {
client,
account: new Account(client),
}
}
export async function listDocuments(collectionId, queries = []) {
const { databases } = createAdminClient()
const response = await databases.listDocuments(
config.appwrite.databaseId,
collectionId,
queries
)
return response.documents
}
export async function getCustomerByAppwriteUserId(appwriteUserId) {
const docs = await listDocuments(config.collections.customers, [
Query.equal('appwriteUserId', appwriteUserId),
Query.limit(1),
])
return docs[0] || null
}
export async function getPortalAccessByCustomerId(customerId) {
const docs = await listDocuments(config.collections.customerPortalAccess, [
Query.equal('customerId', customerId),
Query.limit(1),
])
return docs[0] || null
}
export async function updateDocument(collectionId, documentId, data) {
const { databases } = createAdminClient()
return databases.updateDocument(
config.appwrite.databaseId,
collectionId,
documentId,
data
)
}
export async function upsertWebsiteProjectByRepo(repoFullName, data) {
const { databases } = createAdminClient()
const existing = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const now = new Date().toISOString()
const payload = { ...data, updatedAt: now }
if (existing[0]) {
return databases.updateDocument(
config.appwrite.databaseId,
config.collections.websiteProjects,
existing[0].$id,
payload
)
}
return databases.createDocument(
config.appwrite.databaseId,
config.collections.websiteProjects,
ID.unique(),
{ ...payload, createdAt: now }
)
}

View File

@@ -0,0 +1,24 @@
import { createUserClient } from './appwriteAdmin.js'
export async function loginWithAppwrite(email, password) {
const { client, account } = createUserClient()
try {
await account.createEmailPasswordSession(email, password)
} catch (err) {
const message = err?.message || 'Anmeldung fehlgeschlagen'
const error = new Error(message)
error.status = 401
throw error
}
const user = await account.get()
try {
await account.deleteSession('current')
} catch {
// Portal nutzt eigene Session; Appwrite-Session wird nicht persistiert
}
return user
}