feat: HTTPS-TLS-Sync nach Deploy, Projekte ohne Subdomain, portalPassword-Feld
- previewDeploy.syncPreviewTls(): schreibt Traefik-Router (Host-Regeln) fuer alle Preview-Subdomains nach preview-hosts.yml, damit Let's Encrypt Zertifikate ausstellt. Laeuft nach jedem Deploy und beim Serverstart (ersetzt das nie aufgerufene bash-Script sync-preview-tls.sh). - createProjectFromTemplate: Subdomain optional. Ohne Subdomain wird nur das Gitea-Repo angelegt (projectType 'repo', Status 'created'), kein Deploy. - customerAdmin: Portal-Passwort liegt jetzt im Feld customers.portalPassword statt als __portalPassword__-Prefix in den Notizen; Legacy-Notizen werden beim naechsten Update bereinigt. CRM-Stufe (lead/customer) wird beim Setzen des Portal-Passworts nicht mehr auf 'active' ueberschrieben. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ export const config = {
|
|||||||
preview: {
|
preview: {
|
||||||
baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com',
|
baseHost: process.env.PREVIEW_BASE_HOST || 'project.webklar.com',
|
||||||
deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '',
|
deployRoot: process.env.PREVIEW_DEPLOY_ROOT || '',
|
||||||
|
traefikDynamicDir: process.env.TRAEFIK_DYNAMIC_DIR || '',
|
||||||
sessionSecret:
|
sessionSecret:
|
||||||
process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '',
|
process.env.PREVIEW_SESSION_SECRET || process.env.SESSION_SECRET || '',
|
||||||
cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com',
|
cookieDomain: process.env.PREVIEW_COOKIE_DOMAIN || '.project.webklar.com',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
|
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
|
||||||
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
|
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
|
||||||
|
import { syncPreviewTls } from './services/previewDeploy.js'
|
||||||
import { sessionMiddleware } from './middleware/session.js'
|
import { sessionMiddleware } from './middleware/session.js'
|
||||||
import authRoutes from './routes/auth.js'
|
import authRoutes from './routes/auth.js'
|
||||||
import projectsRoutes from './routes/projects.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', () => {
|
const server = app.listen(config.port, '0.0.0.0', () => {
|
||||||
console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`)
|
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) => {
|
verifyDatabaseAccess().then((result) => {
|
||||||
if (result.ok) return
|
if (result.ok) return
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ router.use(requireAppwriteStaff)
|
|||||||
router.post('/create-from-template', async (req, res) => {
|
router.post('/create-from-template', async (req, res) => {
|
||||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
|
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {}
|
||||||
|
|
||||||
if (!repoName || !subdomain) {
|
if (!repoName && !displayName) {
|
||||||
return res.status(400).json({ error: 'repoName und subdomain sind erforderlich' })
|
return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,20 +14,16 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from './appwriteAdmin.js'
|
} from './appwriteAdmin.js'
|
||||||
|
|
||||||
const PORTAL_PASSWORD_PREFIX = '__portalPassword__:'
|
|
||||||
|
|
||||||
function readPortalPassword(customer) {
|
function readPortalPassword(customer) {
|
||||||
if (customer?.portalPassword) return customer.portalPassword
|
if (customer?.portalPassword) return customer.portalPassword
|
||||||
|
// Fallback: Altbestand, bei dem das Passwort noch in den Notizen lag
|
||||||
const notes = customer?.notes || ''
|
const notes = customer?.notes || ''
|
||||||
const match = notes.match(/^__portalPassword__:([^\n]+)/)
|
const match = notes.match(/^__portalPassword__:([^\n]+)/)
|
||||||
return match ? match[1] : ''
|
return match ? match[1] : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function notesWithPortalPassword(existingNotes, password) {
|
function notesWithoutPortalPassword(notes) {
|
||||||
const stripped = (existingNotes || '').replace(/^__portalPassword__:[^\n]*\n?/, '').trim()
|
return (notes || '').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 } = {}) {
|
function sanitizeCustomer(customer, { portalPassword } = {}) {
|
||||||
@@ -115,7 +111,7 @@ export async function createCustomerWithPortalAccess(payload) {
|
|||||||
location: location.trim(),
|
location: location.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
phone: phone.trim(),
|
phone: phone.trim(),
|
||||||
notes: notesWithPortalPassword('', password),
|
portalPassword: password,
|
||||||
portalAccessEnabled: true,
|
portalAccessEnabled: true,
|
||||||
appwriteUserId: appwriteUser.$id,
|
appwriteUserId: appwriteUser.$id,
|
||||||
customerStatus: 'active',
|
customerStatus: 'active',
|
||||||
@@ -206,6 +202,9 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const nextPortalPassword =
|
const nextPortalPassword =
|
||||||
normalizedPassword || readPortalPassword(customer) || undefined
|
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, {
|
const updated = await updateDocument(config.collections.customers, customerId, {
|
||||||
...(code !== undefined ? { code: String(code).trim() } : {}),
|
...(code !== undefined ? { code: String(code).trim() } : {}),
|
||||||
@@ -213,10 +212,16 @@ export async function updateCustomerWithPortalAccess(customerId, payload) {
|
|||||||
...(location !== undefined ? { location: String(location).trim() } : {}),
|
...(location !== undefined ? { location: String(location).trim() } : {}),
|
||||||
email: nextEmail,
|
email: nextEmail,
|
||||||
...(phone !== undefined ? { phone: String(phone).trim() } : {}),
|
...(phone !== undefined ? { phone: String(phone).trim() } : {}),
|
||||||
...(nextPortalPassword
|
...(nextPortalPassword ? { portalPassword: nextPortalPassword } : {}),
|
||||||
? { notes: notesWithPortalPassword(customer.notes, 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,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -229,14 +229,19 @@ export async function createProjectFromTemplate({
|
|||||||
owner,
|
owner,
|
||||||
}) {
|
}) {
|
||||||
const targetOwner = owner || config.gitea.defaultOwner
|
const targetOwner = owner || config.gitea.defaultOwner
|
||||||
const safeRepoName = slugify(repoName) || slugify(subdomain)
|
const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain)
|
||||||
const safeSubdomain = slugify(subdomain) || safeRepoName
|
// 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) {
|
if (!safeRepoName) {
|
||||||
throw new Error('repoName und subdomain sind erforderlich')
|
throw new Error('repoName oder displayName ist erforderlich')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasWebsite) {
|
||||||
await ensureSubdomainAvailable(safeSubdomain)
|
await ensureSubdomainAvailable(safeSubdomain)
|
||||||
|
}
|
||||||
|
|
||||||
const repo = await generateRepoFromTemplate({
|
const repo = await generateRepoFromTemplate({
|
||||||
owner: targetOwner,
|
owner: targetOwner,
|
||||||
@@ -247,25 +252,27 @@ export async function createProjectFromTemplate({
|
|||||||
const [repoOwner, repoSlug] = repoFullName.split('/')
|
const [repoOwner, repoSlug] = repoFullName.split('/')
|
||||||
const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main')
|
const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main')
|
||||||
const previewConfig = {
|
const previewConfig = {
|
||||||
enabled: true,
|
enabled: hasWebsite,
|
||||||
type: 'static',
|
type: 'static',
|
||||||
branch,
|
branch,
|
||||||
displayName: displayName || safeRepoName,
|
displayName: displayName || safeRepoName,
|
||||||
subdomain: safeSubdomain,
|
...(hasWebsite ? { subdomain: safeSubdomain } : {}),
|
||||||
templateName: config.gitea.templateRepo,
|
templateName: config.gitea.templateRepo,
|
||||||
}
|
}
|
||||||
|
|
||||||
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
|
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
|
||||||
|
|
||||||
let deployResult = { deployed: false }
|
let deployResult = { deployed: false, skipped: !hasWebsite }
|
||||||
|
if (hasWebsite) {
|
||||||
try {
|
try {
|
||||||
deployResult = await runDeploy(repoFullName, branch, previewConfig)
|
deployResult = await runDeploy(repoFullName, branch, previewConfig)
|
||||||
} catch (deployErr) {
|
} catch (deployErr) {
|
||||||
console.error('[giteaAdmin] deploy failed:', deployErr.message)
|
console.error('[giteaAdmin] deploy failed:', deployErr.message)
|
||||||
deployResult = { deployed: false, error: deployErr.message }
|
deployResult = { deployed: false, error: deployErr.message }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const previewUrl = buildPreviewUrl(safeSubdomain)
|
const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
|
||||||
const project = await upsertWebsiteProjectByRepo(repoFullName, {
|
const project = await upsertWebsiteProjectByRepo(repoFullName, {
|
||||||
customerId,
|
customerId,
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -276,8 +283,11 @@ export async function createProjectFromTemplate({
|
|||||||
repoFullName,
|
repoFullName,
|
||||||
subdomain: safeSubdomain,
|
subdomain: safeSubdomain,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
status: deployResult.deployed ? 'deployed' : 'pending',
|
projectType: hasWebsite ? 'website' : 'repo',
|
||||||
provisioningStatus: deployResult.deployed ? 'ready' : 'deploy_failed',
|
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
||||||
|
provisioningStatus: hasWebsite
|
||||||
|
? (deployResult.deployed ? 'ready' : 'deploy_failed')
|
||||||
|
: 'ready',
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -285,6 +295,7 @@ export async function createProjectFromTemplate({
|
|||||||
repoFullName,
|
repoFullName,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
subdomain: safeSubdomain,
|
subdomain: safeSubdomain,
|
||||||
|
projectType: hasWebsite ? 'website' : 'repo',
|
||||||
deploy: deployResult,
|
deploy: deployResult,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,71 @@ export function buildPreviewUrl(subdomain) {
|
|||||||
return `https://${subdomain}.${config.preview.baseHost}`
|
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) {
|
export async function runDeploy(repoFullName, branch, previewConfig) {
|
||||||
if (!config.preview.deployRoot) {
|
if (!config.preview.deployRoot) {
|
||||||
return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' }
|
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 || '')
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user