diff --git a/server/config.js b/server/config.js index 651cd79..5d67741 100644 --- a/server/config.js +++ b/server/config.js @@ -67,6 +67,7 @@ export const config = { preview: { baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com', deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '', + traefikDynamicDir: process.env.TRAEFIK_DYNAMIC_DIR || '', sessionSecret: process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '', cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com', diff --git a/server/index.js b/server/index.js index 16462bd..aa489ee 100644 --- a/server/index.js +++ b/server/index.js @@ -3,6 +3,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' import { verifyDatabaseAccess } from './services/appwriteAdmin.js' +import { syncPreviewTls } from './services/previewDeploy.js' import { sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' @@ -83,6 +84,9 @@ app.use((err, _req, res, _next) => { const server = app.listen(config.port, '0.0.0.0', () => { console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`) + syncPreviewTls() + .then((r) => console.log('[startup] Preview-TLS-Sync:', JSON.stringify(r))) + .catch((err) => console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message)) verifyDatabaseAccess().then((result) => { if (result.ok) return console.error( diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index 19da1f4..5d1738a 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -9,8 +9,8 @@ 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' }) + if (!repoName && !displayName) { + return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) } try { diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index 694adc2..bb504bb 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -14,20 +14,16 @@ import { Query, } from './appwriteAdmin.js' -const PORTAL_PASSWORD_PREFIX = '__portalPassword__:' - function readPortalPassword(customer) { if (customer?.portalPassword) return customer.portalPassword + // Fallback: Altbestand, bei dem das Passwort noch in den Notizen lag 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 notesWithoutPortalPassword(notes) { + return (notes || '').replace(/^__portalPassword__:[^\n]*\n?/, '').trim() } function sanitizeCustomer(customer, { portalPassword } = {}) { @@ -115,7 +111,7 @@ export async function createCustomerWithPortalAccess(payload) { location: location.trim(), email: email.trim(), phone: phone.trim(), - notes: notesWithPortalPassword('', password), + portalPassword: password, portalAccessEnabled: true, appwriteUserId: appwriteUser.$id, customerStatus: 'active', @@ -206,6 +202,9 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { const now = new Date().toISOString() const nextPortalPassword = normalizedPassword || readPortalPassword(customer) || undefined + // Legacy-Prefix aus den Notizen entfernen, Passwort liegt jetzt im eigenen Feld + const cleanedNotes = notesWithoutPortalPassword(customer.notes) + const notesChanged = cleanedNotes !== (customer.notes || '') const updated = await updateDocument(config.collections.customers, customerId, { ...(code !== undefined ? { code: String(code).trim() } : {}), @@ -213,10 +212,16 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { ...(location !== undefined ? { location: String(location).trim() } : {}), email: nextEmail, ...(phone !== undefined ? { phone: String(phone).trim() } : {}), - ...(nextPortalPassword - ? { notes: notesWithPortalPassword(customer.notes, nextPortalPassword) } + ...(nextPortalPassword ? { portalPassword: nextPortalPassword } : {}), + ...(notesChanged ? { notes: cleanedNotes } : {}), + // CRM-Stufe (lead/customer/lost) nicht ueberschreiben - nur setzen, wenn leer + ...(appwriteUserId + ? { + appwriteUserId, + portalAccessEnabled: true, + ...(customer.customerStatus ? {} : { customerStatus: 'active' }), + } : {}), - ...(appwriteUserId ? { appwriteUserId, portalAccessEnabled: true, customerStatus: 'active' } : {}), updatedAt: now, }) diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 0d5f865..bd38629 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -229,14 +229,19 @@ export async function createProjectFromTemplate({ owner, }) { const targetOwner = owner || config.gitea.defaultOwner - const safeRepoName = slugify(repoName) || slugify(subdomain) - const safeSubdomain = slugify(subdomain) || safeRepoName + const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) + // Subdomain ist optional: ohne Subdomain wird nur das Repo angelegt (normales + // Projekt ohne Website-Preview), mit Subdomain zusaetzlich deployed. + const safeSubdomain = slugify(subdomain) + const hasWebsite = Boolean(safeSubdomain) - if (!safeRepoName || !safeSubdomain) { - throw new Error('repoName und subdomain sind erforderlich') + if (!safeRepoName) { + throw new Error('repoName oder displayName ist erforderlich') } - await ensureSubdomainAvailable(safeSubdomain) + if (hasWebsite) { + await ensureSubdomainAvailable(safeSubdomain) + } const repo = await generateRepoFromTemplate({ owner: targetOwner, @@ -247,25 +252,27 @@ export async function createProjectFromTemplate({ const [repoOwner, repoSlug] = repoFullName.split('/') const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main') const previewConfig = { - enabled: true, + enabled: hasWebsite, type: 'static', branch, displayName: displayName || safeRepoName, - subdomain: safeSubdomain, + ...(hasWebsite ? { 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 } + let deployResult = { deployed: false, skipped: !hasWebsite } + if (hasWebsite) { + 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 previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' const project = await upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, @@ -276,8 +283,11 @@ export async function createProjectFromTemplate({ repoFullName, subdomain: safeSubdomain, previewUrl, - status: deployResult.deployed ? 'deployed' : 'pending', - provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed', + projectType: hasWebsite ? 'website' : 'repo', + status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', + provisioningStatus: hasWebsite + ? (deployResult.deployed ? 'ready' : 'deploy_failed') + : 'ready', }) return { @@ -285,6 +295,7 @@ export async function createProjectFromTemplate({ repoFullName, previewUrl, subdomain: safeSubdomain, + projectType: hasWebsite ? 'website' : 'repo', deploy: deployResult, } } diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 17111b9..6967d2b 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -67,6 +67,71 @@ export function buildPreviewUrl(subdomain) { return `https://${subdomain}.${config.preview.baseHost}` } +/** + * Schreibt pro deployter Preview-Subdomain einen Traefik-Router mit Host()-Regel + * nach preview-hosts.yml, damit Let's Encrypt (HTTP-01) gueltige Zertifikate + * ausstellt. HostRegexp allein liefert nur das Default-Zertifikat ("Nicht sicher"). + * Ersetzt traefik/scripts/sync-preview-tls.sh (bash fehlt im Alpine-Container). + */ +export async function syncPreviewTls() { + const previewRoot = config.preview.deployRoot + const outDir = config.preview.traefikDynamicDir + if (!previewRoot || !outDir) { + return { synced: false, reason: 'PREVIEW_DEPLOY_ROOT oder TRAEFIK_DYNAMIC_DIR nicht gesetzt' } + } + + const baseHost = config.preview.baseHost + let entries = [] + try { + entries = await fs.readdir(previewRoot, { withFileTypes: true }) + } catch { + entries = [] + } + + const slugs = entries + .filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + + const blocks = slugs.map((name) => { + const host = `${name}.${baseHost}` + const id = `preview-${name}` + return [ + ` ${id}-https:`, + ` rule: "Host(\`${host}\`)"`, + ' entryPoints:', + ' - websecure', + ' service: preview-nginx@docker', + ' middlewares:', + ' - preview-chain@docker', + ' priority: 100', + ' tls:', + ' certResolver: letsencrypt', + '', + ` ${id}-http:`, + ` rule: "Host(\`${host}\`)"`, + ' entryPoints:', + ' - web', + ' service: preview-nginx@docker', + ' middlewares:', + ' - preview-sites-redirect@docker', + ' priority: 100', + ].join('\n') + }) + + const content = [ + '# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually', + 'http:', + ' routers:', + ...blocks, + '', + ].join('\n') + + await fs.mkdir(outDir, { recursive: true }) + await fs.writeFile(path.join(outDir, 'preview-hosts.yml'), content, 'utf8') + return { synced: true, hosts: slugs.length } +} + export async function runDeploy(repoFullName, branch, previewConfig) { if (!config.preview.deployRoot) { return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' } @@ -85,5 +150,13 @@ export async function runDeploy(repoFullName, branch, previewConfig) { await deployStatic(workDir, targetDir, previewConfig.index || '') } - return { deployed: true, subdomain, targetDir } + let tls = { synced: false } + try { + tls = await syncPreviewTls() + } catch (err) { + console.error('[previewDeploy] TLS-Sync fehlgeschlagen:', err.message) + tls = { synced: false, error: err.message } + } + + return { deployed: true, subdomain, targetDir, tls } }