first commit
This commit is contained in:
commit
3c0479faf0
169 files changed
+27617
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import AuditLog from '#models/audit_log'
|
||||
import UserError from '#exceptions/user_error'
|
||||
|
||||
const indexValidator = vine.create({
|
||||
page: vine.number().withoutDecimals().min(1).optional(),
|
||||
status: vine.enum(['running', 'success', 'error'] as const).optional(),
|
||||
})
|
||||
|
||||
export default class AuditController {
|
||||
async index({ request }: HttpContext) {
|
||||
const { page = 1, status } = await indexValidator.validate(request.qs())
|
||||
|
||||
const query = AuditLog.query()
|
||||
.select('id', 'username', 'ip', 'action', 'target', 'status', 'created_at', 'finished_at')
|
||||
.select(db.raw('length(output) > 0 as hasOutput'))
|
||||
.orderBy('id', 'desc')
|
||||
if (status) query.where('status', status)
|
||||
|
||||
const result = await query.paginate(page, 50)
|
||||
return {
|
||||
meta: { total: result.total, page: result.currentPage, lastPage: result.lastPage },
|
||||
data: result.all().map((entry) => ({
|
||||
id: entry.id,
|
||||
username: entry.username,
|
||||
ip: entry.ip,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
status: entry.status,
|
||||
createdAt: entry.createdAt.toISO(),
|
||||
finishedAt: entry.finishedAt?.toISO() ?? null,
|
||||
hasOutput: Boolean(entry.$extras.hasOutput),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async show({ params }: HttpContext) {
|
||||
const entry = await AuditLog.find(params.id)
|
||||
if (!entry) throw new UserError('Entrée introuvable.', 404)
|
||||
return {
|
||||
id: entry.id,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
status: entry.status,
|
||||
output: entry.output,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import User from '#models/user'
|
||||
import limiter from '@adonisjs/limiter/services/main'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import totpService from '#services/totp_service'
|
||||
import { audit } from '#services/audit'
|
||||
import { loginValidator } from '#validators/auth'
|
||||
|
||||
class InvalidLogin extends Error {}
|
||||
|
||||
/**
|
||||
* 5 failed attempts per IP, then blocked for 15 minutes.
|
||||
*/
|
||||
const loginLimiter = () =>
|
||||
limiter.use({ requests: 5, duration: '15 mins', blockDuration: '15 mins' })
|
||||
|
||||
export default class AuthController {
|
||||
async login(ctx: HttpContext) {
|
||||
const { request, response, auth } = ctx
|
||||
const { username, password, code } = await request.validateUsing(loginValidator)
|
||||
|
||||
const penalize = () =>
|
||||
loginLimiter().penalize(`login_${request.ip()}`, async (): Promise<User> => {
|
||||
const user = await User.verifyCredentials(username, password).catch(() => null)
|
||||
if (!user || !(await totpService.verify(user, code))) throw new InvalidLogin()
|
||||
return user
|
||||
})
|
||||
|
||||
let result: Awaited<ReturnType<typeof penalize>>
|
||||
try {
|
||||
result = await penalize()
|
||||
} catch (error) {
|
||||
if (!(error instanceof InvalidLogin)) throw error
|
||||
await audit(ctx, { action: 'Connexion refusée', target: username, status: 'error' })
|
||||
return response.badRequest({ message: 'Identifiant, mot de passe ou code incorrect.' })
|
||||
}
|
||||
|
||||
const [throttled, user] = result
|
||||
if (throttled) {
|
||||
const minutes = Math.max(1, Math.ceil(throttled.response.availableIn / 60))
|
||||
return response.tooManyRequests({
|
||||
message: `Trop de tentatives. Réessaie dans ${minutes} min.`,
|
||||
})
|
||||
}
|
||||
|
||||
await auth.use('web').login(user!)
|
||||
await audit(ctx, { action: 'Connexion', username: user!.username, status: 'success' })
|
||||
return { username: user!.username }
|
||||
}
|
||||
|
||||
async logout(ctx: HttpContext) {
|
||||
await audit(ctx, { action: 'Déconnexion', status: 'success' })
|
||||
await ctx.auth.use('web').logout()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async me({ auth }: HttpContext) {
|
||||
return { username: auth.getUserOrFail().username }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import vpsConfig from '#config/vps'
|
||||
import backupService from '#services/backup_service'
|
||||
import { actor, audit } from '#services/audit'
|
||||
|
||||
export default class BackupsController {
|
||||
async index() {
|
||||
return {
|
||||
dir: vpsConfig.backups.dir,
|
||||
schedule: backupService.schedule,
|
||||
files: await backupService.list(),
|
||||
}
|
||||
}
|
||||
|
||||
async store(ctx: HttpContext) {
|
||||
const job = await backupService.run(actor(ctx))
|
||||
return { jobId: job.id }
|
||||
}
|
||||
|
||||
async download(ctx: HttpContext) {
|
||||
const path = backupService.path(ctx.params.name)
|
||||
await audit(ctx, {
|
||||
action: 'Téléchargement de sauvegarde',
|
||||
target: ctx.params.name,
|
||||
status: 'success',
|
||||
})
|
||||
return ctx.response.attachment(path, ctx.params.name)
|
||||
}
|
||||
|
||||
async destroy(ctx: HttpContext) {
|
||||
await backupService.delete(ctx.params.name)
|
||||
await audit(ctx, {
|
||||
action: 'Suppression de sauvegarde',
|
||||
target: ctx.params.name,
|
||||
status: 'success',
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import vpsConfig from '#config/vps'
|
||||
import CaddyfileVersion from '#models/caddyfile_version'
|
||||
import UserError from '#exceptions/user_error'
|
||||
import caddyService, { parseCaddyfile, type ReloadResult } from '#services/caddy_service'
|
||||
import siteMonitor from '#services/site_monitor'
|
||||
import { actor, audit } from '#services/audit'
|
||||
import { updateCaddyfileValidator } from '#validators/caddy'
|
||||
|
||||
export default class CaddyController {
|
||||
async show() {
|
||||
const content = await caddyService.read()
|
||||
return {
|
||||
path: vpsConfig.caddy.caddyfilePath,
|
||||
container: vpsConfig.caddy.container,
|
||||
content,
|
||||
sites: parseCaddyfile(content),
|
||||
}
|
||||
}
|
||||
|
||||
async update(ctx: HttpContext) {
|
||||
const { content, reason } = await ctx.request.validateUsing(updateCaddyfileValidator)
|
||||
const result = await caddyService.saveAndReload(content, {
|
||||
username: actor(ctx).username,
|
||||
reason: reason || 'Modification manuelle',
|
||||
})
|
||||
return this.#respond(ctx, 'Modification du Caddyfile', result)
|
||||
}
|
||||
|
||||
async reload(ctx: HttpContext) {
|
||||
const result = await caddyService.reload()
|
||||
return this.#respond(ctx, 'Rechargement de Caddy', result)
|
||||
}
|
||||
|
||||
async versions() {
|
||||
const versions = await CaddyfileVersion.query()
|
||||
.select('id', 'username', 'reason', 'created_at')
|
||||
.orderBy('id', 'desc')
|
||||
return versions.map((version) => ({
|
||||
id: version.id,
|
||||
username: version.username,
|
||||
reason: version.reason,
|
||||
createdAt: version.createdAt.toISO(),
|
||||
}))
|
||||
}
|
||||
|
||||
async version({ params }: HttpContext) {
|
||||
const version = await CaddyfileVersion.find(params.id)
|
||||
if (!version) throw new UserError('Version introuvable.', 404)
|
||||
return {
|
||||
id: version.id,
|
||||
username: version.username,
|
||||
reason: version.reason,
|
||||
createdAt: version.createdAt.toISO(),
|
||||
content: version.content,
|
||||
}
|
||||
}
|
||||
|
||||
async restore(ctx: HttpContext) {
|
||||
const version = await CaddyfileVersion.find(ctx.params.id)
|
||||
if (!version) throw new UserError('Version introuvable.', 404)
|
||||
const result = await caddyService.saveAndReload(version.content, {
|
||||
username: actor(ctx).username,
|
||||
reason: `Restauration de la version #${version.id}`,
|
||||
})
|
||||
return this.#respond(ctx, `Restauration du Caddyfile (#${version.id})`, result)
|
||||
}
|
||||
|
||||
async #respond(ctx: HttpContext, action: string, result: ReloadResult) {
|
||||
await audit(ctx, {
|
||||
action,
|
||||
target: vpsConfig.caddy.caddyfilePath,
|
||||
status: result.ok ? 'success' : 'error',
|
||||
output: result.output,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return ctx.response.unprocessableEntity({
|
||||
message: 'Caddy a refusé la configuration : le Caddyfile est inchangé.',
|
||||
output: result.output,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* New or removed domains show up right away on the sites page.
|
||||
*/
|
||||
siteMonitor.sync().catch(() => {})
|
||||
return { ok: true, output: result.output }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import dockerService from '#services/docker_service'
|
||||
import { audit } from '#services/audit'
|
||||
import { openSse } from '#services/sse'
|
||||
|
||||
const ACTION_LABELS = {
|
||||
start: 'Démarrage du conteneur',
|
||||
stop: 'Arrêt du conteneur',
|
||||
restart: 'Redémarrage du conteneur',
|
||||
} as const
|
||||
|
||||
const actionValidator = vine.create({
|
||||
action: vine.enum(['start', 'stop', 'restart'] as const),
|
||||
})
|
||||
|
||||
const logsValidator = vine.create({
|
||||
tail: vine.number().withoutDecimals().range([0, 5000]).optional(),
|
||||
})
|
||||
|
||||
export default class ContainersController {
|
||||
async index() {
|
||||
return dockerService.list()
|
||||
}
|
||||
|
||||
async show({ params }: HttpContext) {
|
||||
return dockerService.inspect(params.id)
|
||||
}
|
||||
|
||||
async action(ctx: HttpContext) {
|
||||
const { action } = await actionValidator.validate(ctx.params)
|
||||
const info = await dockerService.inspect(ctx.params.id)
|
||||
|
||||
try {
|
||||
await dockerService.action(info.id, action)
|
||||
} catch (error) {
|
||||
await audit(ctx, {
|
||||
action: ACTION_LABELS[action],
|
||||
target: info.name,
|
||||
status: 'error',
|
||||
output: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
await audit(ctx, { action: ACTION_LABELS[action], target: info.name, status: 'success' })
|
||||
return dockerService.inspect(info.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the logs of the container (Server-Sent Events).
|
||||
*/
|
||||
async logs(ctx: HttpContext) {
|
||||
const { tail } = await logsValidator.validate(ctx.request.qs())
|
||||
const channel = openSse(ctx)
|
||||
|
||||
try {
|
||||
const stop = await dockerService.followLogs(
|
||||
ctx.params.id,
|
||||
{ tail: tail ?? 300 },
|
||||
(line) => channel.send('line', line),
|
||||
() => {
|
||||
channel.send('end', {})
|
||||
channel.close()
|
||||
}
|
||||
)
|
||||
channel.onClose(stop)
|
||||
} catch (error) {
|
||||
channel.send('failure', { message: error instanceof Error ? error.message : String(error) })
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import AuditLog from '#models/audit_log'
|
||||
import UserError from '#exceptions/user_error'
|
||||
import jobRunner, { type JobLine } from '#services/job_runner'
|
||||
import { openSse } from '#services/sse'
|
||||
|
||||
export default class JobsController {
|
||||
/**
|
||||
* Replays the output of a job, then follows it until the end. Jobs
|
||||
* finished a while ago are read from the action log.
|
||||
*/
|
||||
async stream(ctx: HttpContext) {
|
||||
const id = Number(ctx.params.id)
|
||||
const job = jobRunner.get(id)
|
||||
const entry = job ? null : await AuditLog.find(id)
|
||||
if (!job && !entry) throw new UserError('Action introuvable.', 404)
|
||||
|
||||
const channel = openSse(ctx)
|
||||
|
||||
if (!job) {
|
||||
const lines = (entry!.output ?? '')
|
||||
.split('\n')
|
||||
.map((text) => ({ ts: 0, stream: 'out', text }))
|
||||
channel.send('meta', { id, action: entry!.action, target: entry!.target })
|
||||
channel.send('lines', lines)
|
||||
channel.send('end', { status: entry!.status })
|
||||
channel.close()
|
||||
return
|
||||
}
|
||||
|
||||
channel.send('meta', { id, action: job.meta.action, target: job.meta.target })
|
||||
channel.send('lines', job.lines)
|
||||
if (job.status !== 'running') {
|
||||
channel.send('end', { status: job.status })
|
||||
channel.close()
|
||||
return
|
||||
}
|
||||
|
||||
const onLine = (line: JobLine) => channel.send('lines', [line])
|
||||
const onEnd = (status: string) => {
|
||||
channel.send('end', { status })
|
||||
channel.close()
|
||||
}
|
||||
job.on('line', onLine)
|
||||
job.once('end', onEnd)
|
||||
channel.onClose(() => {
|
||||
job.off('line', onLine)
|
||||
job.off('end', onEnd)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import metricsService, { type HostSample } from '#services/metrics_service'
|
||||
import { openSse } from '#services/sse'
|
||||
|
||||
const historyValidator = vine.create({
|
||||
range: vine.enum(['1h', '24h', '7d', '30d'] as const),
|
||||
})
|
||||
|
||||
export default class MetricsController {
|
||||
/**
|
||||
* Live samples (every 5 s) for the charts of the overview.
|
||||
*/
|
||||
async live(ctx: HttpContext) {
|
||||
const channel = openSse(ctx)
|
||||
channel.send('init', metricsService.live)
|
||||
|
||||
const onSample = (sample: HostSample) => channel.send('sample', sample)
|
||||
metricsService.on('sample', onSample)
|
||||
channel.onClose(() => metricsService.off('sample', onSample))
|
||||
}
|
||||
|
||||
async history({ request }: HttpContext) {
|
||||
const { range } = await historyValidator.validate(request.qs())
|
||||
return metricsService.history(range)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import os from 'node:os'
|
||||
import Site from '#models/site'
|
||||
import AuditLog from '#models/audit_log'
|
||||
import backupService from '#services/backup_service'
|
||||
import dockerService from '#services/docker_service'
|
||||
import metricsService from '#services/metrics_service'
|
||||
|
||||
const CERT_WARNING_DAYS = 14
|
||||
|
||||
export default class OverviewController {
|
||||
async index() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const [containers, sites, backups, recent, uptime] = await Promise.all([
|
||||
dockerService.list().catch((error: Error) => error),
|
||||
Site.query().where('active', true).orderBy('domain'),
|
||||
backupService.list(),
|
||||
AuditLog.query().orderBy('id', 'desc').limit(8),
|
||||
metricsService.uptime(),
|
||||
])
|
||||
|
||||
const docker =
|
||||
containers instanceof Error
|
||||
? { ok: false as const, error: containers.message }
|
||||
: {
|
||||
ok: true as const,
|
||||
total: containers.length,
|
||||
running: containers.filter((c) => c.state === 'running').length,
|
||||
unhealthy: containers.filter((c) => c.health === 'unhealthy').map((c) => c.name),
|
||||
stopped: containers.filter((c) => c.state !== 'running').map((c) => c.name),
|
||||
}
|
||||
|
||||
return {
|
||||
host: {
|
||||
latest: metricsService.latest,
|
||||
uptime,
|
||||
cpus: os.cpus().length,
|
||||
},
|
||||
docker,
|
||||
sites: {
|
||||
total: sites.length,
|
||||
up: sites.filter((site) => site.lastOk === true).length,
|
||||
down: sites
|
||||
.filter((site) => site.lastOk === false)
|
||||
.map((site) => ({ id: site.id, domain: site.domain, error: site.lastError })),
|
||||
certSoon: sites
|
||||
.filter(
|
||||
(site) => site.certExpiresAt && site.certExpiresAt - now < CERT_WARNING_DAYS * 86_400
|
||||
)
|
||||
.map((site) => ({
|
||||
id: site.id,
|
||||
domain: site.domain,
|
||||
days: Math.floor((site.certExpiresAt! - now) / 86_400),
|
||||
})),
|
||||
},
|
||||
backups: {
|
||||
last: backups[0] ?? null,
|
||||
count: backups.length,
|
||||
schedule: backupService.schedule,
|
||||
},
|
||||
recent: recent.map((entry) => ({
|
||||
id: entry.id,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
status: entry.status,
|
||||
username: entry.username,
|
||||
createdAt: entry.createdAt.toISO(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import vpsConfig from '#config/vps'
|
||||
import UserError from '#exceptions/user_error'
|
||||
import projectService, { PROJECT_ACTIONS, type ProjectAction } from '#services/project_service'
|
||||
import { DEFAULT_NODE_COMMAND } from '#services/templates'
|
||||
import { actor } from '#services/audit'
|
||||
import { createProjectValidator, projectActionValidator } from '#validators/project'
|
||||
|
||||
export default class ProjectsController {
|
||||
async index() {
|
||||
return projectService.list()
|
||||
}
|
||||
|
||||
/**
|
||||
* Values used by the "new project" form.
|
||||
*/
|
||||
async options() {
|
||||
return {
|
||||
baseDomain: vpsConfig.baseDomain,
|
||||
categories: vpsConfig.projectCategories,
|
||||
srvRoot: vpsConfig.srvRoot,
|
||||
defaultNodeCommand: DEFAULT_NODE_COMMAND,
|
||||
}
|
||||
}
|
||||
|
||||
async show({ params }: HttpContext) {
|
||||
const project = await projectService.find(params.category, params.name)
|
||||
return projectService.details(project)
|
||||
}
|
||||
|
||||
async fetch({ params }: HttpContext) {
|
||||
const project = await projectService.find(params.category, params.name)
|
||||
return projectService.fetch(project)
|
||||
}
|
||||
|
||||
async action(ctx: HttpContext) {
|
||||
const action = ctx.params.action as ProjectAction
|
||||
if (!PROJECT_ACTIONS.includes(action)) throw new UserError('Action inconnue.', 404)
|
||||
|
||||
const { commit } = await ctx.request.validateUsing(projectActionValidator)
|
||||
const project = await projectService.find(ctx.params.category, ctx.params.name)
|
||||
const job = await projectService.runAction(project, action, { commit }, actor(ctx))
|
||||
return { jobId: job.id }
|
||||
}
|
||||
|
||||
async store(ctx: HttpContext) {
|
||||
const input = await ctx.request.validateUsing(createProjectValidator)
|
||||
const job = await projectService.create(input, actor(ctx))
|
||||
return { jobId: job.id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import vine from '@vinejs/vine'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import Site from '#models/site'
|
||||
import siteMonitor from '#services/site_monitor'
|
||||
import UserError from '#exceptions/user_error'
|
||||
|
||||
const showValidator = vine.create({
|
||||
range: vine.enum(['24h', '7d', '30d'] as const).optional(),
|
||||
})
|
||||
|
||||
const RANGES = {
|
||||
'24h': { seconds: 86_400, bucket: 300 },
|
||||
'7d': { seconds: 7 * 86_400, bucket: 1800 },
|
||||
'30d': { seconds: 30 * 86_400, bucket: 7200 },
|
||||
} as const
|
||||
|
||||
export default class SitesController {
|
||||
async index() {
|
||||
const [sites, day, week] = await Promise.all([
|
||||
Site.query().orderBy('active', 'desc').orderBy('domain'),
|
||||
siteMonitor.stats(86_400),
|
||||
siteMonitor.stats(7 * 86_400),
|
||||
])
|
||||
|
||||
return sites.map((site) => ({
|
||||
...serializeSite(site),
|
||||
uptime24h: day.get(site.id)?.uptime ?? null,
|
||||
uptime7d: week.get(site.id)?.uptime ?? null,
|
||||
avgResponseMs24h: day.get(site.id)?.avgResponseMs ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
async show({ params, request }: HttpContext) {
|
||||
const { range = '24h' } = await showValidator.validate(request.qs())
|
||||
const site = await Site.find(params.id)
|
||||
if (!site) throw new UserError('Site introuvable.', 404)
|
||||
|
||||
const { seconds, bucket } = RANGES[range]
|
||||
const [history, stats] = await Promise.all([
|
||||
siteMonitor.history(site.id, seconds, bucket),
|
||||
siteMonitor.stats(seconds),
|
||||
])
|
||||
|
||||
return {
|
||||
site: serializeSite(site),
|
||||
range,
|
||||
bucket,
|
||||
uptime: stats.get(site.id)?.uptime ?? null,
|
||||
avgResponseMs: stats.get(site.id)?.avgResponseMs ?? null,
|
||||
...history,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the checks right away instead of waiting for the next minute.
|
||||
*/
|
||||
async check() {
|
||||
await siteMonitor.checkAll()
|
||||
return this.index()
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSite(site: Site) {
|
||||
return {
|
||||
id: site.id,
|
||||
domain: site.domain,
|
||||
active: site.active,
|
||||
kind: site.kind,
|
||||
upstream: site.upstream,
|
||||
lastOk: site.lastOk,
|
||||
lastStatus: site.lastStatus,
|
||||
lastResponseMs: site.lastResponseMs,
|
||||
lastError: site.lastError,
|
||||
lastCheckedAt: site.lastCheckedAt,
|
||||
certExpiresAt: site.certExpiresAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
/**
|
||||
* Serves the Angular application for every URL that is neither a file
|
||||
* of "public/" nor an API route (/containers, /projects/…).
|
||||
*/
|
||||
export default class SpaController {
|
||||
async index({ request, response }: HttpContext) {
|
||||
if (request.url().startsWith('/api/')) {
|
||||
return response.notFound({ message: 'Route inconnue.' })
|
||||
}
|
||||
|
||||
const index = app.publicPath('index.html')
|
||||
if (!existsSync(index)) {
|
||||
return response.notFound(
|
||||
'Interface non compilée : lance "npm run build" dans web/ (ou "npm start" pour le développement).'
|
||||
)
|
||||
}
|
||||
|
||||
response.header('Cache-Control', 'no-cache')
|
||||
return response.download(index)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user