Files
Webklar-Kundenbereich/server/services/giteaAdmin.js
T
2026-09-30 21:54:01 +00:00

704 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../config.js'
import {
upsertWebsiteProjectByRepo,
listDocuments,
Query,
getDocument,
updateDocument,
} from './appwriteAdmin.js'
import {
runDeploy,
buildPreviewUrl,
removeDeploy,
syncPreviewTls,
fetchPreviewConfig,
} from './previewDeploy.js'
import { giteaFetch } from './giteaClient.js'
import { slugify } from './slug.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } from './repoMove.js'
// Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte
// Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht
// endlos weiter (40 x 50 = 2000 Repos).
const MAX_REPO_PAGES = 40
export async function listAllAccessibleRepos() {
const repos = []
const limit = 50
let page = 1
while (true) {
const batch = await giteaFetch(`/repos/search?limit=${limit}&page=${page}`)
const data = Array.isArray(batch?.data) ? batch.data : Array.isArray(batch) ? batch : []
if (data.length === 0) break
repos.push(...data)
if (data.length < limit) break
if (page >= MAX_REPO_PAGES) {
console.warn(
`[giteaAdmin] mehr als ${MAX_REPO_PAGES} Seiten Repos – weitere Repos werden nicht gelistet`
)
break
}
page += 1
}
return repos
}
/**
* Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer
* jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) -
* ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem
* Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert
* parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die
* innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere
* gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung).
*/
export async function importNewRepos() {
return withLocks(['import'], async () => {
const [repos, docs] = await Promise.all([
listAllAccessibleRepos(),
listDocuments(config.collections.websiteProjects, [Query.limit(5000)]),
])
// Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling).
const existingNames = new Set(
docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean)
)
let created = 0
const createdRepos = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName || repo.archived) continue
if (existingNames.has(fullName.toLowerCase())) continue
await withLocks([repoLockKey(fullName)], async () => {
const recheck = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', fullName),
Query.limit(1),
])
if (recheck[0]) return
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
repoFullName: fullName,
archived: false,
status: 'synced',
giteaSyncedAt: new Date().toISOString(),
})
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
}
return { created, createdRepos }
})
}
export async function syncReposToAppwrite() {
const repos = await listAllAccessibleRepos()
let created = 0
let updated = 0
for (const repo of repos) {
const repoFullName = repo.full_name
if (!repoFullName) continue
const existing = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const payload = {
projectName: repo.description || repo.name || repoFullName.split('/').pop(),
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || repoFullName.split('/').pop(),
repoFullName,
// In Gitea archivierte Repos werden im Ticketsystem ausgeblendet.
archived: Boolean(repo.archived),
}
if (!existing[0]) {
payload.status = 'synced'
created += 1
} else {
updated += 1
}
await upsertWebsiteProjectByRepo(repoFullName, payload)
}
return { synced: repos.length, created, updated }
}
export async function ensureSubdomainAvailable(subdomain) {
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(1),
])
if (docs[0]) {
const error = new Error(`Subdomain "${subdomain}" ist bereits vergeben`)
error.status = 409
throw error
}
}
export async function generateRepoFromTemplate({ owner, repoName }) {
const templateOwner = config.gitea.templateOwner
const templateRepo = config.gitea.templateRepo
const targetOwner = owner || config.gitea.defaultOwner
if (!targetOwner) {
throw new Error('GITEA_DEFAULT_OWNER fehlt in .env')
}
return giteaFetch(`/repos/${templateOwner}/${templateRepo}/generate`, {
method: 'POST',
body: JSON.stringify({
owner: targetOwner,
name: repoName,
private: false,
description: `Website-Projekt ${repoName}`,
git_content: true,
}),
})
}
async function waitForRepoBranch(owner, repo, preferredBranch = 'main', maxAttempts = 15, delayMs = 400) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const repoInfo = await giteaFetch(`/repos/${owner}/${repo}`)
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches?limit=50`)
const branchNames = Array.isArray(branches) ? branches.map((entry) => entry.name) : []
if (branchNames.includes(preferredBranch)) return preferredBranch
if (repoInfo?.default_branch && branchNames.includes(repoInfo.default_branch)) {
return repoInfo.default_branch
}
if (branchNames.length > 0) return branchNames[0]
} catch {
/* retry */
}
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
throw new Error(`Branch "${preferredBranch}" existiert im Repository ${owner}/${repo} noch nicht`)
}
async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'main') {
const [owner, repo] = repoFullName.split('/')
const content = JSON.stringify(previewConfig, null, 2) + '\n'
const encoded = Buffer.from(content).toString('base64')
let sha = null
try {
const existing = await giteaFetch(
`/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
)
sha = existing.sha
} catch {
/* file may not exist yet */
}
await giteaFetch(`/repos/${owner}/${repo}/contents/.webklar-preview.json`, {
method: sha ? 'PUT' : 'POST',
body: JSON.stringify({
message: 'Update preview config from ticket system',
content: encoded,
branch,
...(sha ? { sha } : {}),
}),
})
}
/**
* true, wenn zu diesem Projekt wirklich ein Hosting gehoert (Preview/Website/Vorlage, oder ein
* bereits deployter bzw. anstehender Stand) - im Unterschied zu einem nur importierten/
* abgeglichenen Repo ohne eigenes Hosting (z. B. status "synced" ohne Website). Bestimmt u. a.,
* ob die Selbstheilung (ensureRepoPreviewConfig) fuer dieses Projekt ueberhaupt aktiv werden darf -
* sonst wuerde mit dem organisationsweiten Webhook jeder Push in ein bloss abgeglichenes Repo eine
* Preview anlegen.
*/
export function isHostedProject(doc) {
if (['preview', 'website', 'template'].includes(doc?.projectType)) return true
if (['deployed', 'pending'].includes(doc?.status)) return true
return false
}
/**
* Selbstheilung fuer eine geloeschte/fehlende .webklar-preview.json:
* Rekonstruiert die Hosting-Config aus dem in Appwrite gespeicherten
* Projektstand (Subdomain, Typ, Name), schreibt sie ins Repo zurueck und liefert
* sie fuer einen sofortigen Deploy zurueck. Gibt null zurueck, wenn zu dem Repo
* kein gehostetes, aktives Projekt bekannt ist (dann bleibt es Repo-only).
*/
export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') {
if (!repoFullName) return null
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', repoFullName),
Query.limit(1),
])
const doc = docs[0]
if (!doc || doc.archived) return null
if (!isHostedProject(doc)) return null
const type = deriveProjectType(doc)
const subdomain = doc.subdomain || ''
if (!subdomain) return null
const previewConfig = {
enabled: true,
type: 'static',
branch,
displayName: doc.projectName || repoFullName.split('/').pop(),
templateName: doc.templateName || config.gitea.templateRepo,
projectType: type,
public: type === 'website',
template: type === 'template',
subdomain,
}
try {
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
} catch (err) {
console.error('[ensureRepoPreviewConfig] .webklar-preview.json zurueckschreiben fehlgeschlagen:', err.message)
}
return previewConfig
}
export async function createProjectFromTemplate({
repoName,
displayName,
subdomain,
customerId = '',
ticketId = '',
owner,
}) {
const targetOwner = owner || config.gitea.defaultOwner
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) {
throw new Error('repoName oder displayName ist erforderlich')
}
if (hasWebsite) {
await ensureSubdomainAvailable(safeSubdomain)
}
const repo = await generateRepoFromTemplate({
owner: targetOwner,
repoName: safeRepoName,
})
const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}`
const [repoOwner, repoSlug] = repoFullName.split('/')
const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main')
const previewConfig = {
enabled: hasWebsite,
type: 'static',
branch,
displayName: displayName || safeRepoName,
...(hasWebsite ? { subdomain: safeSubdomain } : {}),
templateName: config.gitea.templateRepo,
}
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
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 = hasWebsite ? buildPreviewUrl(safeSubdomain) : ''
const project = await upsertWebsiteProjectByRepo(repoFullName, {
customerId,
ticketId,
projectName: previewConfig.displayName,
templateName: config.gitea.templateRepo,
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
giteaRepoName: repo.name || safeRepoName,
repoFullName,
subdomain: safeSubdomain,
previewUrl,
projectType: hasWebsite ? 'website' : 'repo',
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready',
})
return {
projectId: project.$id,
repoFullName,
previewUrl,
subdomain: safeSubdomain,
projectType: hasWebsite ? 'website' : 'repo',
deploy: deployResult,
}
}
/**
* Leitet den Projekt-Typ aus einem bestehenden Dokument ab, wenn im Request
* kein expliziter Typ mitgegeben wurde.
* preview -> Website mit Login-geschuetzter Preview (Standard)
* website -> oeffentliche Website ohne Login
* template -> Website-Vorlage (login-geschuetzt, nur Admin, in Vorlagen-Ansicht)
* project -> internes Projekt ohne Hosting/Subdomain
*/
function deriveProjectType(doc) {
if (doc?.isTemplate) return 'template'
if (doc?.isPublic) return 'website'
if (doc?.subdomain) return 'preview'
return 'project'
}
async function patchGiteaRepo(repoFullName, body) {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return null
return giteaFetch(`/repos/${owner}/${repo}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
/**
* Bearbeitet ein bestehendes Website-Projekt (Name / Subdomain / Typ) und
* synchronisiert die Aenderung ueber ALLE Systeme:
* - Appwrite-Dokument (projectName, subdomain, previewUrl, projectType, isPublic, status)
* - Gitea: .webklar-preview.json (enabled/subdomain/displayName/public) + Repo-Beschreibung
* - Preview-Hosting: Redeploy in neues Verzeichnis, altes entfernen, Traefik-TLS neu
* Der Gitea-Repo-Slug/die Klon-URL bleiben bewusst stabil (kein Repo-Rename).
*/
export async function updateWebsiteProject(projectId, { projectName, subdomain, projectType } = {}) {
const doc = await getDocument(config.collections.websiteProjects, projectId)
if (!doc) {
const error = new Error('Projekt nicht gefunden')
error.status = 404
throw error
}
const repoFullName = doc.repoFullName || ''
const [owner, repoSlug] = repoFullName.split('/')
const nextName = String(projectName ?? doc.projectName ?? '').trim() || doc.projectName || repoSlug || ''
const nextType = projectType || deriveProjectType(doc)
// template = login-geschuetzte Vorlage: braucht Preview (wie 'preview'), aber
// nicht oeffentlich; wird als isTemplate markiert.
const hasWebsite = nextType === 'preview' || nextType === 'website' || nextType === 'template'
const isPublic = nextType === 'website'
const isTemplate = nextType === 'template'
const oldSubdomain = doc.subdomain || ''
let nextSubdomain = ''
if (hasWebsite) {
const requested = slugify(subdomain !== undefined ? subdomain : oldSubdomain)
nextSubdomain = requested || slugify(repoSlug)
}
const subdomainChanged = nextSubdomain !== oldSubdomain
// Subdomain-Eindeutigkeit pruefen (eigenes Dokument ignorieren)
if (hasWebsite && subdomainChanged && nextSubdomain) {
const conflicts = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', nextSubdomain),
Query.limit(5),
])
if (conflicts.some((c) => c.$id !== projectId)) {
const error = new Error(`Subdomain "${nextSubdomain}" ist bereits vergeben`)
error.status = 409
throw error
}
}
let branch = 'main'
try {
const info = await getRepoInfo(repoFullName)
branch = info?.defaultBranch || 'main'
} catch {
/* Default main */
}
// Deploy-Typ (z.B. node_build) und index-Unterordner aus der vorhandenen
// Repo-Config uebernehmen - sonst wuerde jedes Bearbeiten im Portal
// App-Projekte (Expo/Vite/React) wieder auf "static" zuruecksetzen.
let existingCfg = null
try {
existingCfg = await fetchPreviewConfig(repoFullName, branch)
} catch {
/* keine bestehende Config -> Defaults */
}
const previewConfig = {
enabled: hasWebsite,
type: existingCfg?.type || 'static',
...(existingCfg?.index ? { index: existingCfg.index } : {}),
branch,
displayName: nextName,
templateName: doc.templateName || config.gitea.templateRepo,
projectType: nextType,
public: isPublic,
template: isTemplate,
...(hasWebsite && nextSubdomain ? { subdomain: nextSubdomain } : {}),
}
if (owner && repoSlug) {
try {
await updatePreviewConfigFile(repoFullName, previewConfig, branch)
} catch (err) {
console.error('[updateWebsiteProject] preview-config schreiben fehlgeschlagen:', err.message)
}
try {
await patchGiteaRepo(repoFullName, { description: nextName })
} catch (err) {
console.error('[updateWebsiteProject] Gitea-Beschreibung fehlgeschlagen:', err.message)
}
}
// Deploy / Teardown
let deployResult = { deployed: false, skipped: true }
if (hasWebsite && nextSubdomain) {
if (subdomainChanged && oldSubdomain) {
try { await removeDeploy(oldSubdomain) } catch (err) {
console.error('[updateWebsiteProject] alten Deploy entfernen fehlgeschlagen:', err.message)
}
}
try {
deployResult = await runDeploy(repoFullName, branch, previewConfig, { project: doc })
} catch (err) {
console.error('[updateWebsiteProject] deploy fehlgeschlagen:', err.message)
deployResult = { deployed: false, error: err.message }
}
} else if (oldSubdomain) {
try { await removeDeploy(oldSubdomain) } catch (err) {
console.error('[updateWebsiteProject] Deploy entfernen fehlgeschlagen:', err.message)
}
}
const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : ''
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
projectName: nextName,
subdomain: nextSubdomain,
previewUrl,
projectType: nextType,
isPublic,
isTemplate,
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
: 'ready',
updatedAt: new Date().toISOString(),
})
// TLS-Router NACH dem Appwrite-Update synchronisieren, damit der public/auth-
// Zustand (isPublic) korrekt gelesen wird - auch wenn kein Redeploy stattfand
// (z.B. nur Typ preview <-> website getauscht).
try { await syncPreviewTls() } catch (err) {
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
}
return {
project: updated,
deploy: deployResult,
previewUrl,
subdomain: nextSubdomain,
projectType: nextType,
isPublic,
}
}
/**
* Archiviert (oder reaktiviert) ein Projekt: setzt das Gitea-Repo auf archived
* und pflegt das Appwrite-Flag. Beim Archivieren wird zusaetzlich der Preview-
* Deploy entfernt; beim Reaktivieren mit Subdomain neu deployt.
*/
export async function archiveRepo(projectId, archived = true) {
const doc = await getDocument(config.collections.websiteProjects, projectId)
if (!doc) {
const error = new Error('Projekt nicht gefunden')
error.status = 404
throw error
}
const repoFullName = doc.repoFullName || ''
let repoMissing = false
if (repoFullName.includes('/')) {
try {
await patchGiteaRepo(repoFullName, { archived: Boolean(archived) })
} catch (err) {
// Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem
// nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet.
if (err.status === 404) {
repoMissing = true
console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`)
} else {
const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message)
error.status = err.status || 500
throw error
}
}
}
if (archived && doc.subdomain) {
try { await removeDeploy(doc.subdomain) } catch (err) {
console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message)
}
}
if (!archived && !repoMissing && doc.subdomain) {
try {
const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main'
const cfg = (await fetchPreviewConfig(repoFullName, branch)) || {
enabled: true,
type: 'static',
branch,
subdomain: doc.subdomain,
displayName: doc.projectName,
}
await runDeploy(repoFullName, branch, cfg, { project: doc })
} catch (err) {
console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message)
}
}
const updated = await updateDocument(config.collections.websiteProjects, projectId, {
archived: Boolean(archived),
...(archived ? { status: 'archived' } : {}),
updatedAt: new Date().toISOString(),
})
return { project: updated, archived: Boolean(archived) }
}
/** Repo-Basisdaten (Groesse in KB, Default-Branch). */
export async function getRepoInfo(repoFullName) {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return null
const data = await giteaFetch(`/repos/${owner}/${repo}`)
return {
sizeKb: Number(data.size || 0),
defaultBranch: data.default_branch || 'main',
updatedAt: data.updated_at || '',
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}`,
}
}
/** Letzte Commits eines Repos (volle Message inkl. Body). */
export async function listRepoCommits(repoFullName, { branch = 'main', limit = 20 } = {}) {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return []
// stat/verification/files abschalten - sonst berechnet Gitea Diff-Stats pro Commit
const data = await giteaFetch(
`/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=${limit}&stat=false&verification=false&files=false`
)
return (Array.isArray(data) ? data : []).map((entry) => ({
sha: entry.sha || '',
shortSha: (entry.sha || '').slice(0, 7),
message: entry.commit?.message || '',
authorName:
entry.commit?.author?.name || entry.author?.login || entry.committer?.login || 'Unbekannt',
date: entry.commit?.author?.date || entry.created || '',
url: entry.html_url || `${config.gitea.baseUrl}/${repoFullName}/commit/${entry.sha}`,
}))
}
/**
* Datei-Uebersicht: aggregiert den Git-Baum serverseitig nach Top-Level-Eintraegen
* (nie den rohen Baum ans Frontend geben - kann tausende Eintraege haben).
*/
export async function getRepoTreeSummary(repoFullName, ref = 'main') {
const [owner, repo] = String(repoFullName || '').split('/')
if (!owner || !repo) return null
const data = await giteaFetch(
`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=1000`
)
const entries = Array.isArray(data?.tree) ? data.tree : []
const topLevelMap = new Map()
let totalFiles = 0
let totalSizeBytes = 0
for (const entry of entries) {
const isBlob = entry.type === 'blob'
const size = isBlob ? Number(entry.size || 0) : 0
const path = String(entry.path || '')
const slash = path.indexOf('/')
const topSegment = slash === -1 ? path : path.slice(0, slash)
const isDir = slash !== -1 || entry.type === 'tree'
if (isBlob) {
totalFiles += 1
totalSizeBytes += size
}
if (!topSegment) continue
const existing = topLevelMap.get(topSegment) || {
path: topSegment,
type: isDir ? 'dir' : 'file',
fileCount: 0,
sizeBytes: 0,
}
if (isDir) existing.type = 'dir'
if (isBlob) {
existing.fileCount += 1
existing.sizeBytes += size
}
topLevelMap.set(topSegment, existing)
}
const topLevel = [...topLevelMap.values()]
.sort((a, b) => b.sizeBytes - a.sizeBytes)
.slice(0, 50)
return {
totalFiles,
totalSizeBytes,
truncated: Boolean(data?.truncated),
topLevel,
}
}
const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README']
export async function fetchRepoReadme(repoFullName) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo) {
const error = new Error('repoFullName muss owner/repo sein')
error.status = 400
throw error
}
for (const filename of README_CANDIDATES) {
try {
const data = await giteaFetch(
`/repos/${owner}/${repo}/contents/${encodeURIComponent(filename)}`
)
const encoding = data.encoding || 'base64'
const raw = encoding === 'base64'
? Buffer.from(data.content || '', 'base64').toString('utf8')
: String(data.content || '')
return {
filename: data.name || filename,
content: raw,
htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}/src/branch/main/${filename}`,
}
} catch (err) {
if (err.status === 404) continue
throw err
}
}
return null
}