maxibase 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -2
- package/docker/Dockerfile +22 -0
- package/docker/Dockerfile.dockerignore +25 -0
- package/docker/compose.yaml +19 -0
- package/layers/base/app/app.vue +22 -0
- package/layers/base/app/components/AuthCheck.vue +316 -0
- package/layers/base/app/components/DataExplorer.vue +38 -0
- package/layers/base/app/components/LocaleSwitcher.vue +28 -0
- package/layers/base/app/components/TheConfirmation.vue +48 -0
- package/layers/base/app/components/TheLoading.vue +36 -0
- package/layers/base/app/components/TheNotifications.vue +71 -0
- package/layers/base/app/components/files/FilesDelete.vue +33 -0
- package/layers/base/app/components/files/FilesUpload.vue +85 -0
- package/layers/base/app/error.vue +28 -0
- package/layers/base/app/layouts/default.vue +15 -0
- package/layers/base/app/layouts/simple.vue +3 -0
- package/layers/base/app/middleware/admin.ts +15 -0
- package/layers/base/app/middleware/authenticated.ts +10 -0
- package/layers/base/app/middleware/mfa.global.ts +8 -0
- package/layers/base/app/pages/admin/logs.vue +364 -0
- package/layers/base/app/pages/signin.vue +121 -0
- package/layers/base/app/pages/user/auth.vue +509 -0
- package/layers/base/app/plugins/error.ts +21 -0
- package/layers/base/app/plugins/session.ts +22 -0
- package/layers/base/app/stores/app.ts +100 -0
- package/layers/base/app/utils/clipboard.ts +6 -0
- package/layers/base/app/utils/files.ts +95 -0
- package/layers/base/app/utils/image.ts +33 -0
- package/layers/base/nuxt.config.ts +146 -0
- package/layers/base/public/images/auth/microsoft.svg +1 -0
- package/layers/base/public/robots.txt +2 -0
- package/layers/base/server/api/admin/logs.get.ts +49 -0
- package/layers/base/server/api/auth/anonymous.post.ts +31 -0
- package/layers/base/server/api/auth/email/get.post.ts +46 -0
- package/layers/base/server/api/auth/email/verify.post.ts +46 -0
- package/layers/base/server/api/auth/index.get.ts +10 -0
- package/layers/base/server/api/auth/index.post.ts +19 -0
- package/layers/base/server/api/auth/options.post.ts +26 -0
- package/layers/base/server/api/auth/otp/get.post.ts +74 -0
- package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
- package/layers/base/server/api/auth/password.post.ts +42 -0
- package/layers/base/server/api/auth/refresh.delete.ts +4 -0
- package/layers/base/server/api/auth/refresh.post.ts +5 -0
- package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
- package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
- package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
- package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
- package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
- package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
- package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
- package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
- package/layers/base/server/api/files/[...path].get.ts +37 -0
- package/layers/base/server/api/files/chunk.post.ts +36 -0
- package/layers/base/server/api/files/copy.post.ts +22 -0
- package/layers/base/server/api/files/index.delete.ts +12 -0
- package/layers/base/server/api/files/index.get.ts +19 -0
- package/layers/base/server/api/files/index.post.ts +15 -0
- package/layers/base/server/api/log.post.ts +20 -0
- package/layers/base/server/database/defaults/access.ts +14 -0
- package/layers/base/server/database/defaults/db.ts +5 -0
- package/layers/base/server/database/defaults/relations.ts +5 -0
- package/layers/base/server/database/relations.ts +35 -0
- package/layers/base/server/database/schema.ts +66 -0
- package/layers/base/server/middleware/0.rateLimit.ts +21 -0
- package/layers/base/server/middleware/1.session.ts +14 -0
- package/layers/base/server/middleware/2.mfa.ts +34 -0
- package/layers/base/server/middleware/3.admin.ts +4 -0
- package/layers/base/server/plugins/session.ts +9 -0
- package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
- package/layers/base/server/routes/dev/email/[template].ts +15 -0
- package/layers/base/server/tasks/backup.ts +42 -0
- package/layers/base/server/tasks/clean.ts +39 -0
- package/layers/base/server/utils/access.ts +8 -0
- package/layers/base/server/utils/auth.ts +207 -0
- package/layers/base/server/utils/backup.ts +98 -0
- package/layers/base/server/utils/db.ts +3 -0
- package/layers/base/server/utils/email.ts +138 -0
- package/layers/base/server/utils/files.ts +751 -0
- package/layers/base/server/utils/ip.ts +15 -0
- package/layers/base/server/utils/log.ts +31 -0
- package/layers/base/server/utils/rateLimit.ts +69 -0
- package/layers/base/server/utils/schemas.ts +8 -0
- package/layers/base/server/utils/text.ts +4 -0
- package/layers/base/server/utils/time.ts +3 -0
- package/layers/base/shared/types/auth.d.ts +22 -0
- package/layers/base/shared/utils/logs.ts +9 -0
- package/layers/base/shared/utils/url.ts +5 -0
- package/locales/en.json +33 -0
- package/locales/fr.json +33 -0
- package/locales/pages/admin-logs/en.json +1 -0
- package/locales/pages/admin-logs/fr.json +1 -0
- package/locales/pages/index/en.json +1 -0
- package/locales/pages/index/fr.json +1 -0
- package/locales/pages/signin/en.json +5 -0
- package/locales/pages/signin/fr.json +5 -0
- package/locales/pages/user-auth/en.json +22 -0
- package/locales/pages/user-auth/fr.json +22 -0
- package/nuxt.config.ts +54 -0
- package/package.json +92 -4
- package/scripts/db/migrate.ts +9 -0
- package/scripts/db/push-server.sh +45 -0
- package/scripts/db/restore.sh +26 -0
- package/scripts/deploy.sh +34 -0
- package/scripts/env.sh +20 -0
- package/scripts/remote/switch.sh +48 -0
- package/scripts/rollback.sh +12 -0
- package/server/assets/emails/base.html +43 -0
- package/server/assets/emails/en/base.html +1 -0
- package/server/assets/emails/en/emailChange.html +4 -0
- package/server/assets/emails/en/emailChanged.html +2 -0
- package/server/assets/emails/en/otp.html +10 -0
- package/server/assets/emails/fr/base.html +1 -0
- package/server/assets/emails/fr/emailChange.html +4 -0
- package/server/assets/emails/fr/emailChanged.html +2 -0
- package/server/assets/emails/fr/otp.html +10 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
|
|
3
|
+
export function getClientIP(event: H3Event): string {
|
|
4
|
+
const { trustedProxies } = useRuntimeConfig().rateLimit
|
|
5
|
+
|
|
6
|
+
// A client can send any x-forwarded-for it likes, so only the entries appended by our own
|
|
7
|
+
// proxies can be trusted. Those are the rightmost ones: count back past them to the real client.
|
|
8
|
+
if (trustedProxies > 0) {
|
|
9
|
+
const forwarded = (getRequestHeader(event, 'x-forwarded-for') || '').split(',')
|
|
10
|
+
const clientIP = forwarded[forwarded.length - trustedProxies]?.trim()
|
|
11
|
+
if (clientIP) return clientIP
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return event.node.req.socket?.remoteAddress || 'unknown'
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { logs } from '#server/database/schema'
|
|
2
|
+
import { lt } from 'drizzle-orm/sql/expressions/conditions'
|
|
3
|
+
|
|
4
|
+
export async function log(
|
|
5
|
+
summary: string,
|
|
6
|
+
data?: any,
|
|
7
|
+
origin?: string,
|
|
8
|
+
type?: string,
|
|
9
|
+
event?: any,
|
|
10
|
+
) {
|
|
11
|
+
const session = event ? await getUserSession(event) : null
|
|
12
|
+
const userAgent = event?.node?.req?.headers['user-agent']
|
|
13
|
+
|
|
14
|
+
await db.insert(logs).values({
|
|
15
|
+
userId: session?.user?.id,
|
|
16
|
+
type,
|
|
17
|
+
origin: origin || event?.path + ' ' + event?.node?.req?.method,
|
|
18
|
+
summary,
|
|
19
|
+
data,
|
|
20
|
+
ipAddress: event ? getClientIP(event) : null,
|
|
21
|
+
userAgent,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function cleanLogs() {
|
|
26
|
+
const config = useRuntimeConfig()
|
|
27
|
+
const retentionMS = daysToMilliseconds(config.logs.retentionDays)
|
|
28
|
+
const cutoffDate = new Date(Date.now() - retentionMS)
|
|
29
|
+
|
|
30
|
+
await db.delete(logs).where(lt(logs.time, cutoffDate))
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
|
|
3
|
+
export interface RateLimitRule {
|
|
4
|
+
limit: number // max requests per window
|
|
5
|
+
window: number // window duration in seconds
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface RateLimitEntry {
|
|
9
|
+
count: number
|
|
10
|
+
resetAt: number
|
|
11
|
+
bannedUntil: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const entries = new Map<string, RateLimitEntry>()
|
|
15
|
+
|
|
16
|
+
const SWEEP_INTERVAL = 10 * 60 * 1000
|
|
17
|
+
setInterval(() => {
|
|
18
|
+
const now = Date.now()
|
|
19
|
+
for (const [key, entry] of entries) {
|
|
20
|
+
if (now > entry.resetAt && now > entry.bannedUntil) entries.delete(key)
|
|
21
|
+
}
|
|
22
|
+
}, SWEEP_INTERVAL).unref?.()
|
|
23
|
+
|
|
24
|
+
async function getId(event: H3Event): Promise<string> {
|
|
25
|
+
const { user } = await getUserSession(event)
|
|
26
|
+
if (user) return user.id
|
|
27
|
+
return getClientIP(event)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function enforceRateLimit(event: H3Event, route: string, rule: RateLimitRule) {
|
|
31
|
+
const config = useRuntimeConfig()
|
|
32
|
+
const id = await getId(event)
|
|
33
|
+
const key = `${route}:${id}`
|
|
34
|
+
const now = Date.now()
|
|
35
|
+
|
|
36
|
+
let entry = entries.get(key)
|
|
37
|
+
|
|
38
|
+
if (entry && entry.bannedUntil > now) {
|
|
39
|
+
throw tooManyRequests(event, entry.bannedUntil - now)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!entry || now > entry.resetAt) {
|
|
43
|
+
entry = { count: 0, resetAt: now + rule.window * 1000, bannedUntil: 0 }
|
|
44
|
+
entries.set(key, entry)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
entry.count++
|
|
48
|
+
if (entry.count <= rule.limit) return
|
|
49
|
+
|
|
50
|
+
if (entry.count === rule.limit * config.rateLimit.banMultiplier) {
|
|
51
|
+
entry.bannedUntil = now + config.rateLimit.banSeconds * 1000
|
|
52
|
+
await log('Rate limit ban', undefined, route, 'security', event).catch(() => {})
|
|
53
|
+
throw tooManyRequests(event, entry.bannedUntil - now)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (entry.count === rule.limit + 1) {
|
|
57
|
+
await log('Rate limit exceeded', undefined, route, 'security', event).catch(() => {})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw tooManyRequests(event, entry.resetAt - now)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tooManyRequests(event: H3Event, retryAfterMS: number) {
|
|
64
|
+
setResponseHeader(event, 'Retry-After', Math.ceil(retryAfterMS / 1000))
|
|
65
|
+
return createError({
|
|
66
|
+
status: 429,
|
|
67
|
+
message: 'Too many requests',
|
|
68
|
+
})
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
declare module '#auth-utils' {
|
|
2
|
+
interface User {
|
|
3
|
+
id: string
|
|
4
|
+
email?: string
|
|
5
|
+
role: 'admin' | 'user'
|
|
6
|
+
isAnonymous?: boolean
|
|
7
|
+
requiresMfaSetup?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UserSession {
|
|
11
|
+
expiresAt?: number
|
|
12
|
+
authenticatedAt: number
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module 'h3' {
|
|
17
|
+
interface SessionConfig {
|
|
18
|
+
unique?: boolean
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export {}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const logTypes = [
|
|
2
|
+
{ value: 'info', label: 'Info', color: 'primary-text' },
|
|
3
|
+
{ value: 'warn', label: 'Warn', color: 'warning-text' },
|
|
4
|
+
{ value: 'error', label: 'Error', color: 'danger-text' },
|
|
5
|
+
{ value: 'debug', label: 'Debug', color: 'muted-text' },
|
|
6
|
+
{ value: 'security', label: 'Security', color: 'secondary-text' },
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export const logTypeValues = logTypes.map((logType) => logType.value)
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"add": "Add",
|
|
3
|
+
"areYouSure": "Are you sure?",
|
|
4
|
+
"cancel": "Cancel",
|
|
5
|
+
"confirm": "Confirm",
|
|
6
|
+
"continue": "Continue",
|
|
7
|
+
"copied": "Copied",
|
|
8
|
+
"delete": "Delete",
|
|
9
|
+
"deleted": "Deleted",
|
|
10
|
+
"description": "Website",
|
|
11
|
+
"disable": "Disable",
|
|
12
|
+
"email": "Email",
|
|
13
|
+
"enable": "Enable",
|
|
14
|
+
"formatFailed": "Could not format or upload {files}",
|
|
15
|
+
"language": "Language | Languages",
|
|
16
|
+
"paste": "Paste",
|
|
17
|
+
"rename": "Rename",
|
|
18
|
+
"retry": "Retry",
|
|
19
|
+
"save": "Save",
|
|
20
|
+
"saved": "Saved",
|
|
21
|
+
"settings": "Settings",
|
|
22
|
+
"signout": "Sign Out",
|
|
23
|
+
"upload": "Upload",
|
|
24
|
+
"authCheck": {
|
|
25
|
+
"authenticatorCode": "Authenticator code",
|
|
26
|
+
"clipboardPermission": "Please allow clipboard access to paste",
|
|
27
|
+
"code": "Code",
|
|
28
|
+
"getCode": "Get code",
|
|
29
|
+
"otpSent": "Code sent to {email}",
|
|
30
|
+
"password": "Password",
|
|
31
|
+
"signin": "Sign In"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/locales/fr.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"add": "Ajouter",
|
|
3
|
+
"areYouSure": "Êtes-vous sûr ?",
|
|
4
|
+
"cancel": "Annuler",
|
|
5
|
+
"confirm": "Confirmer",
|
|
6
|
+
"continue": "Continuer",
|
|
7
|
+
"copied": "Copié",
|
|
8
|
+
"delete": "Supprimer",
|
|
9
|
+
"deleted": "Supprimé",
|
|
10
|
+
"description": "Site web",
|
|
11
|
+
"disable": "Désactiver",
|
|
12
|
+
"email": "Email",
|
|
13
|
+
"enable": "Activer",
|
|
14
|
+
"formatFailed": "Impossible de formater ou téléverser {files}",
|
|
15
|
+
"language": "Langue | Langues",
|
|
16
|
+
"paste": "Coller",
|
|
17
|
+
"rename": "Renommer",
|
|
18
|
+
"retry": "Réessayer",
|
|
19
|
+
"save": "Enregistrer",
|
|
20
|
+
"saved": "Enregistré",
|
|
21
|
+
"settings": "Paramètres",
|
|
22
|
+
"signout": "Se déconnecter",
|
|
23
|
+
"upload": "Téléverser",
|
|
24
|
+
"authCheck": {
|
|
25
|
+
"authenticatorCode": "Code de l'application d'authentification",
|
|
26
|
+
"clipboardPermission": "Veuillez autoriser l'accès au presse-papiers pour coller",
|
|
27
|
+
"code": "Code",
|
|
28
|
+
"getCode": "Demander le code",
|
|
29
|
+
"otpSent": "Code envoyé à {email}",
|
|
30
|
+
"password": "Mot de passe",
|
|
31
|
+
"signin": "Se connecter"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"2fa": "Two-Factor Authentication",
|
|
3
|
+
"authentication": "Authentication",
|
|
4
|
+
"authenticatorApp": "Authenticator app",
|
|
5
|
+
"changeEmail": "Change email address",
|
|
6
|
+
"changePassword": "Change password",
|
|
7
|
+
"code": "Code",
|
|
8
|
+
"codeInvalid": "Code invalid",
|
|
9
|
+
"deleteRefreshTokens": "Sign out from all devices",
|
|
10
|
+
"disableTOTP": "Do you really want to disable your authenticator?",
|
|
11
|
+
"lastFactor": "Keep at least one second factor.",
|
|
12
|
+
"emailChangeCodeSent": "Code sent to {email}",
|
|
13
|
+
"mfaRequired": "Two-factor authentication required",
|
|
14
|
+
"mfaRequiredDescription": "Set up a passkey or an authenticator app to finish signing in.",
|
|
15
|
+
"passkeyLimit": "Up to 4 passkeys",
|
|
16
|
+
"password": "Password",
|
|
17
|
+
"passwordPolicy": "At least {count} characters",
|
|
18
|
+
"registerPasskey": "Add a passkey",
|
|
19
|
+
"settings": "Settings",
|
|
20
|
+
"setupTotp": "Use an authenticator app",
|
|
21
|
+
"totpSecret": "Scan this QR code with your authenticator app or copy the code manually."
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"2fa": "Authentification à deux facteurs",
|
|
3
|
+
"authentication": "Authentification",
|
|
4
|
+
"authenticatorApp": "Application d'authentification",
|
|
5
|
+
"changeEmail": "Modifier l'adresse email",
|
|
6
|
+
"changePassword": "Modifier le mot de passe",
|
|
7
|
+
"code": "Code",
|
|
8
|
+
"codeInvalid": "Code invalide",
|
|
9
|
+
"deleteRefreshTokens": "Se déconnecter de tous les appareils",
|
|
10
|
+
"disableTOTP": "Voulez-vous vraiment désactiver vos code temporaire ?",
|
|
11
|
+
"lastFactor": "Conservez au moins un second facteur.",
|
|
12
|
+
"emailChangeCodeSent": "Code envoyé à {email}",
|
|
13
|
+
"mfaRequired": "Authentification à deux facteurs requise",
|
|
14
|
+
"mfaRequiredDescription": "Configurez une passkey ou une application d'authentification pour terminer la connexion.",
|
|
15
|
+
"passkeyLimit": "Jusqu'à 4 passkeys",
|
|
16
|
+
"password": "Mot de passe",
|
|
17
|
+
"passwordPolicy": "Au moins {count} caractères",
|
|
18
|
+
"registerPasskey": "Ajouter une passkey",
|
|
19
|
+
"settings": "Paramètres",
|
|
20
|
+
"setupTotp": "Utiliser une application d'authentification",
|
|
21
|
+
"totpSecret": "Scannez ce code QR avec votre application d'authentification ou copiez le code manuellement."
|
|
22
|
+
}
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
2
|
+
const privateRoute = { ssr: false, headers: { 'X-Robots-Tag': 'noindex' } }
|
|
3
|
+
|
|
4
|
+
export default defineNuxtConfig({
|
|
5
|
+
routeRules: {
|
|
6
|
+
'/signin': { ssr: false },
|
|
7
|
+
'/user/**': { ...privateRoute, appMiddleware: 'authenticated' },
|
|
8
|
+
'/admin/**': { ...privateRoute, appMiddleware: 'admin' },
|
|
9
|
+
},
|
|
10
|
+
app: {
|
|
11
|
+
head: {
|
|
12
|
+
htmlAttrs: {
|
|
13
|
+
lang: 'en',
|
|
14
|
+
},
|
|
15
|
+
title: process.env.NUXT_PUBLIC_NAME,
|
|
16
|
+
link: [],
|
|
17
|
+
meta: [{ name: 'theme-color', content: '#000000' }],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
runtimeConfig: {
|
|
21
|
+
public: {
|
|
22
|
+
url: 'http://localhost:3000',
|
|
23
|
+
name: 'Nuxt base',
|
|
24
|
+
logo: 'https://placehold.co/200x100?text=logo',
|
|
25
|
+
},
|
|
26
|
+
rateLimit: {
|
|
27
|
+
routes: {
|
|
28
|
+
// limit: max requests per IP, window: in seconds
|
|
29
|
+
'/api/log': { limit: 30, window: 60 },
|
|
30
|
+
'/api/auth/password': { limit: 5, window: 60 },
|
|
31
|
+
'/api/auth/otp/get': { limit: 5, window: 900 },
|
|
32
|
+
'/api/auth/email/get': { limit: 5, window: 900 },
|
|
33
|
+
'/api/auth/email/verify': { limit: 10, window: 60 },
|
|
34
|
+
'/api/auth/otp/verify': { limit: 10, window: 60 },
|
|
35
|
+
'/api/auth/totp/verify': { limit: 10, window: 60 },
|
|
36
|
+
'/api/auth/webauthn/authenticate': { limit: 10, window: 60 },
|
|
37
|
+
'/api/auth/anonymous': { limit: 5, window: 3600 },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
i18n: {
|
|
42
|
+
locales: [
|
|
43
|
+
{ code: 'en', iso: 'en-US' },
|
|
44
|
+
{ code: 'fr', iso: 'fr-FR' },
|
|
45
|
+
],
|
|
46
|
+
defaultLocale: 'en',
|
|
47
|
+
translationDir: 'locales',
|
|
48
|
+
},
|
|
49
|
+
css: ['~/assets/css/index.css'],
|
|
50
|
+
compatibilityDate: '2025-07-15',
|
|
51
|
+
devtools: { enabled: true },
|
|
52
|
+
srcDir: 'app/',
|
|
53
|
+
extends: ['./layers/base'],
|
|
54
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,94 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxibase",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Nuxt layer with auth, files, logs and backups",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nuxt",
|
|
7
|
+
"nuxt-layer",
|
|
8
|
+
"auth",
|
|
9
|
+
"webauthn",
|
|
10
|
+
"drizzle"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./nuxt.config.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"docker",
|
|
16
|
+
"layers/base",
|
|
17
|
+
"locales",
|
|
18
|
+
"scripts",
|
|
19
|
+
"server/assets",
|
|
20
|
+
"nuxt.config.ts"
|
|
21
|
+
],
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/maxirozay/nuxt-base.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/maxirozay/nuxt-base#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/maxirozay/nuxt-base/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "nuxt build",
|
|
39
|
+
"dev": "nuxt dev",
|
|
40
|
+
"generate": "nuxt generate",
|
|
41
|
+
"preview": "nuxt preview",
|
|
42
|
+
"prod": "./scripts/deploy.sh",
|
|
43
|
+
"rollback": "./scripts/rollback.sh",
|
|
44
|
+
"db:seed": "npx jiti ./server/database/seed.ts",
|
|
45
|
+
"db:generate": "drizzle-kit generate",
|
|
46
|
+
"db:push": "drizzle-kit push",
|
|
47
|
+
"db:push-server": "./scripts/db/push-server.sh",
|
|
48
|
+
"db:restore": "./scripts/db/restore.sh",
|
|
49
|
+
"db:migrate": "npx jiti ./scripts/db/migrate.ts",
|
|
50
|
+
"db:studio": "drizzle-kit studio",
|
|
51
|
+
"lint": "oxlint",
|
|
52
|
+
"lint:fix": "oxlint --fix",
|
|
53
|
+
"fmt": "oxfmt",
|
|
54
|
+
"fmt:check": "oxfmt --check",
|
|
55
|
+
"typecheck": "nuxt typecheck",
|
|
56
|
+
"test": "vitest run",
|
|
57
|
+
"test:watch": "vitest",
|
|
58
|
+
"prepublishOnly": "pnpm lint && pnpm fmt:check && pnpm typecheck && pnpm test"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@aws-sdk/client-s3": "^3.1146.0",
|
|
62
|
+
"@aws-sdk/s3-request-presigner": "^3.1146.0",
|
|
63
|
+
"@iconify-json/lucide": "^1.2.140",
|
|
64
|
+
"@nuxt/hints": "^1.1.4",
|
|
65
|
+
"@nuxt/icon": "^2.5.1",
|
|
66
|
+
"@pinia/nuxt": "^1.0.2",
|
|
67
|
+
"@simplewebauthn/browser": "^11.0.0",
|
|
68
|
+
"@simplewebauthn/server": "^11.0.0",
|
|
69
|
+
"@types/nodemailer": "^8.0.2",
|
|
70
|
+
"@types/pg": "^8.23.1",
|
|
71
|
+
"@types/qrcode": "^1.5.6",
|
|
72
|
+
"drizzle-orm": "1.0.0-rc.3",
|
|
73
|
+
"goku-css": "^9.0.8",
|
|
74
|
+
"nodemailer": "^9.1.1",
|
|
75
|
+
"nuxt-auth-utils": "^0.5.30",
|
|
76
|
+
"nuxt-i18n-micro": "^3.30.0",
|
|
77
|
+
"otplib": "^13.5.0",
|
|
78
|
+
"pg": "^8.23.1",
|
|
79
|
+
"pinia": "^4.0.3",
|
|
80
|
+
"qrcode": "^1.5.4",
|
|
81
|
+
"zod": "^4.6.5"
|
|
82
|
+
},
|
|
83
|
+
"devDependencies": {
|
|
84
|
+
"drizzle-kit": "1.0.0-rc.3",
|
|
85
|
+
"drizzle-seed": "1.0.0-rc.3",
|
|
86
|
+
"maxibase": "link:.",
|
|
87
|
+
"nuxt": "^4.6.0",
|
|
88
|
+
"oxfmt": "^0.72.0",
|
|
89
|
+
"oxlint": "^1.87.0",
|
|
90
|
+
"typescript": "^5.9.3",
|
|
91
|
+
"vitest": "^5.0.3",
|
|
92
|
+
"vue-tsc": "^3.3.12"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { migrate } from 'drizzle-orm/node-postgres/migrator'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
// NUXT_DB from the project's .env, a variable already set in the environment wins
|
|
6
|
+
if (existsSync('.env')) process.loadEnvFile()
|
|
7
|
+
|
|
8
|
+
const db = drizzle(process.env.NUXT_DB!)
|
|
9
|
+
migrate(db, { migrationsFolder: './server/database/migrations' })
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -eo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
5
|
+
source "$SCRIPT_DIR/../env.sh"
|
|
6
|
+
|
|
7
|
+
LOCAL_PORT=5433
|
|
8
|
+
REMOTE_PORT=5432
|
|
9
|
+
SSH_KEY="$HOME/.ssh/$SSH_KEY_NAME"
|
|
10
|
+
CONTROL_SOCKET="/tmp/.${PROJECT_NAME}-db-tunnel.$$"
|
|
11
|
+
|
|
12
|
+
# Master connection, reused for both the tunnel and the credential read, so this
|
|
13
|
+
# authenticates once. -f backgrounds it, -N runs no remote command.
|
|
14
|
+
ssh -M -S "$CONTROL_SOCKET" -f -N -L "$LOCAL_PORT:localhost:$REMOTE_PORT" -i "$SSH_KEY" "$SERVER_URL"
|
|
15
|
+
trap 'ssh -S "$CONTROL_SOCKET" -O exit "$SERVER_URL" 2>/dev/null || true' EXIT
|
|
16
|
+
|
|
17
|
+
# The app's own connection string is the single source of truth, so there is no second
|
|
18
|
+
# copy of the credentials to keep in sync. Only that one line crosses the wire, and it
|
|
19
|
+
# is never written to disk locally.
|
|
20
|
+
# -n: read no stdin, so this cannot swallow keystrokes meant for drizzle-kit's prompts.
|
|
21
|
+
REMOTE_LINE=$(ssh -n -S "$CONTROL_SOCKET" "$SERVER_URL" "grep -m1 '^NUXT_DB=' $REMOTE_PATH/.env" | tr -d '\r')
|
|
22
|
+
|
|
23
|
+
if [ -z "$REMOTE_LINE" ]; then
|
|
24
|
+
echo "No NUXT_DB found in $REMOTE_PATH/.env on $SERVER_URL" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Repoint whatever host the server uses at the local end of the tunnel, leaving the
|
|
29
|
+
# credentials byte-for-byte as the server has them.
|
|
30
|
+
TUNNELED_DB=$(REMOTE_LINE="$REMOTE_LINE" LOCAL_PORT="$LOCAL_PORT" node -p '
|
|
31
|
+
const raw = process.env.REMOTE_LINE.replace(/^NUXT_DB=/, "").trim().replace(/^["\x27]|["\x27]$/g, "")
|
|
32
|
+
new URL(raw) // the validation pg-connection-string applies; throws on a malformed URL
|
|
33
|
+
const m = raw.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(.*)$/)
|
|
34
|
+
const end = m[2].search(/[/?#]/)
|
|
35
|
+
const authority = end === -1 ? m[2] : m[2].slice(0, end)
|
|
36
|
+
const tail = end === -1 ? "" : m[2].slice(end)
|
|
37
|
+
const at = authority.lastIndexOf("@")
|
|
38
|
+
process.stderr.write(`Pushing schema to ${authority.slice(at + 1)}${tail.split("?")[0]} on ${process.env.SERVER_URL}\n`)
|
|
39
|
+
if (/ssl/i.test(tail)) process.stderr.write("Note: the connection string sets SSL options, but through the tunnel the host is localhost, so certificate verification may fail. The tunnel is already encrypted.\n")
|
|
40
|
+
m[1] + authority.slice(0, at + 1) + "localhost:" + process.env.LOCAL_PORT + tail
|
|
41
|
+
')
|
|
42
|
+
|
|
43
|
+
NUXT_DB="$TUNNELED_DB" pnpm drizzle-kit push
|
|
44
|
+
|
|
45
|
+
echo "Migration complete!"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -eo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
5
|
+
source "$SCRIPT_DIR/../env.sh"
|
|
6
|
+
|
|
7
|
+
BACKUP_PATH="$1"
|
|
8
|
+
if [ -z "$BACKUP_PATH" ]; then
|
|
9
|
+
echo "Usage: $0 <backup-file> [age-identity-file]"
|
|
10
|
+
echo " .age backups need the private key, either as the 2nd argument or in AGE_IDENTITY_FILE."
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if [[ "$BACKUP_PATH" == *.age ]]; then
|
|
15
|
+
AGE_IDENTITY_FILE="${2:-$AGE_IDENTITY_FILE}"
|
|
16
|
+
if [ -z "$AGE_IDENTITY_FILE" ]; then
|
|
17
|
+
echo "$BACKUP_PATH is encrypted: pass the age identity file (the key.txt holding age-secret-key-...)" >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
# Decrypt straight into pg_restore so the plaintext dump is never written to disk.
|
|
21
|
+
age -d -i "$AGE_IDENTITY_FILE" "$BACKUP_PATH" | pg_restore --disable-triggers -d "$NUXT_DB"
|
|
22
|
+
else
|
|
23
|
+
pg_restore --disable-triggers -d "$NUXT_DB" "$BACKUP_PATH"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
echo "Restore complete!"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
5
|
+
source "$SCRIPT_DIR/env.sh"
|
|
6
|
+
|
|
7
|
+
NAME=$PROJECT_NAME
|
|
8
|
+
SSH_KEY="$HOME/.ssh/$SSH_KEY_NAME"
|
|
9
|
+
TAG="$(date -u +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
|
10
|
+
git diff --quiet HEAD || TAG="$TAG-dirty"
|
|
11
|
+
IMAGE_NAME="$NAME:$TAG"
|
|
12
|
+
|
|
13
|
+
echo $TAG
|
|
14
|
+
|
|
15
|
+
# The project's own Dockerfile and compose.yaml, else the package's. The package's Dockerfile
|
|
16
|
+
# brings its own Dockerfile.dockerignore, a project Dockerfile uses the project's .dockerignore.
|
|
17
|
+
DOCKERFILE=Dockerfile
|
|
18
|
+
[ -f "$DOCKERFILE" ] || DOCKERFILE="$SCRIPT_DIR/../docker/Dockerfile"
|
|
19
|
+
COMPOSE=compose.yaml
|
|
20
|
+
[ -f "$COMPOSE" ] || COMPOSE="$SCRIPT_DIR/../docker/compose.yaml"
|
|
21
|
+
|
|
22
|
+
docker build --platform linux/amd64 -f "$DOCKERFILE" -t $IMAGE_NAME .
|
|
23
|
+
docker save $IMAGE_NAME > $NAME.tar
|
|
24
|
+
|
|
25
|
+
scp -i $SSH_KEY $NAME.tar "$COMPOSE" $SERVER_URL:$REMOTE_PATH
|
|
26
|
+
scp -i $SSH_KEY "$SCRIPT_DIR/remote/switch.sh" $SERVER_URL:$REMOTE_PATH/switch.sh
|
|
27
|
+
ssh -i $SSH_KEY $SERVER_URL "
|
|
28
|
+
cd $REMOTE_PATH && \
|
|
29
|
+
sudo docker load < $NAME.tar && \
|
|
30
|
+
rm -f $NAME.tar && \
|
|
31
|
+
bash switch.sh $NAME $TAG
|
|
32
|
+
"
|
|
33
|
+
rm -f $NAME.tar
|
|
34
|
+
docker rmi $IMAGE_NAME
|
package/scripts/env.sh
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
ROOT_DIR=$(pwd)
|
|
2
|
+
ENV_FILE="$ROOT_DIR/.env"
|
|
3
|
+
|
|
4
|
+
while [ $# -gt 0 ]; do
|
|
5
|
+
case "$1" in
|
|
6
|
+
-e|--env)
|
|
7
|
+
ENV_FILE="$ROOT_DIR/$2"
|
|
8
|
+
shift 2
|
|
9
|
+
;;
|
|
10
|
+
*)
|
|
11
|
+
break
|
|
12
|
+
;;
|
|
13
|
+
esac
|
|
14
|
+
done
|
|
15
|
+
|
|
16
|
+
set -a
|
|
17
|
+
if [ -f "$ENV_FILE" ]; then
|
|
18
|
+
. "$ENV_FILE"
|
|
19
|
+
fi
|
|
20
|
+
set +a
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Runs on the server, from the project folder. Recreates the app container from an image
|
|
3
|
+
# still on the server; deploy.sh and rollback.sh both go through it.
|
|
4
|
+
#
|
|
5
|
+
# switch.sh <name> <tag> run <name>:<tag>
|
|
6
|
+
# switch.sh <name> previous run the image deployed before the live one
|
|
7
|
+
# switch.sh <name> --list list the images that can be run
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
NAME="$1"
|
|
11
|
+
TAG="$2"
|
|
12
|
+
KEEP=5
|
|
13
|
+
|
|
14
|
+
CURRENT=$(cat .active-tag 2>/dev/null || true)
|
|
15
|
+
# Tags start with a UTC timestamp, so newest first is a plain reverse sort.
|
|
16
|
+
TAGS=$(sudo docker images "$NAME" --format '{{.Tag}}' | grep -v '^latest$' | sort -r || true)
|
|
17
|
+
|
|
18
|
+
if [ "$TAG" = "--list" ]; then
|
|
19
|
+
echo "$TAGS" | sed "${CURRENT:+s/^$CURRENT\$/& (live)/}"
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if [ "$TAG" = "previous" ]; then
|
|
24
|
+
if [ -z "$CURRENT" ]; then
|
|
25
|
+
echo "No live version recorded yet, pass a tag (--list shows them)" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
TAG=$(echo "$TAGS" | grep -A1 -x "$CURRENT" | sed -n 2p)
|
|
29
|
+
if [ -z "$TAG" ]; then
|
|
30
|
+
echo "No image older than $CURRENT is left on the server" >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if ! sudo docker image inspect "$NAME:$TAG" > /dev/null 2>&1; then
|
|
36
|
+
echo "Image $NAME:$TAG is not on the server (--list shows the ones that are)" >&2
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
echo "Running $NAME:$TAG (was: ${CURRENT:-unknown})"
|
|
41
|
+
sudo docker tag "$NAME:$TAG" "$NAME:latest"
|
|
42
|
+
sudo docker compose up -d --force-recreate
|
|
43
|
+
echo "$TAG" > .active-tag
|
|
44
|
+
|
|
45
|
+
# Images in use are refused by rmi, so the live one always survives.
|
|
46
|
+
echo "$TAGS" | tail -n +$((KEEP + 1)) | xargs -r -I {} sudo docker rmi "$NAME:{}" > /dev/null 2>&1 || true
|
|
47
|
+
|
|
48
|
+
echo "Live: $NAME:$TAG"
|