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.
Files changed (115) hide show
  1. package/README.md +269 -2
  2. package/docker/Dockerfile +22 -0
  3. package/docker/Dockerfile.dockerignore +25 -0
  4. package/docker/compose.yaml +19 -0
  5. package/layers/base/app/app.vue +22 -0
  6. package/layers/base/app/components/AuthCheck.vue +316 -0
  7. package/layers/base/app/components/DataExplorer.vue +38 -0
  8. package/layers/base/app/components/LocaleSwitcher.vue +28 -0
  9. package/layers/base/app/components/TheConfirmation.vue +48 -0
  10. package/layers/base/app/components/TheLoading.vue +36 -0
  11. package/layers/base/app/components/TheNotifications.vue +71 -0
  12. package/layers/base/app/components/files/FilesDelete.vue +33 -0
  13. package/layers/base/app/components/files/FilesUpload.vue +85 -0
  14. package/layers/base/app/error.vue +28 -0
  15. package/layers/base/app/layouts/default.vue +15 -0
  16. package/layers/base/app/layouts/simple.vue +3 -0
  17. package/layers/base/app/middleware/admin.ts +15 -0
  18. package/layers/base/app/middleware/authenticated.ts +10 -0
  19. package/layers/base/app/middleware/mfa.global.ts +8 -0
  20. package/layers/base/app/pages/admin/logs.vue +364 -0
  21. package/layers/base/app/pages/signin.vue +121 -0
  22. package/layers/base/app/pages/user/auth.vue +509 -0
  23. package/layers/base/app/plugins/error.ts +21 -0
  24. package/layers/base/app/plugins/session.ts +22 -0
  25. package/layers/base/app/stores/app.ts +100 -0
  26. package/layers/base/app/utils/clipboard.ts +6 -0
  27. package/layers/base/app/utils/files.ts +95 -0
  28. package/layers/base/app/utils/image.ts +33 -0
  29. package/layers/base/nuxt.config.ts +146 -0
  30. package/layers/base/public/images/auth/microsoft.svg +1 -0
  31. package/layers/base/public/robots.txt +2 -0
  32. package/layers/base/server/api/admin/logs.get.ts +49 -0
  33. package/layers/base/server/api/auth/anonymous.post.ts +31 -0
  34. package/layers/base/server/api/auth/email/get.post.ts +46 -0
  35. package/layers/base/server/api/auth/email/verify.post.ts +46 -0
  36. package/layers/base/server/api/auth/index.get.ts +10 -0
  37. package/layers/base/server/api/auth/index.post.ts +19 -0
  38. package/layers/base/server/api/auth/options.post.ts +26 -0
  39. package/layers/base/server/api/auth/otp/get.post.ts +74 -0
  40. package/layers/base/server/api/auth/otp/verify.post.ts +70 -0
  41. package/layers/base/server/api/auth/password.post.ts +42 -0
  42. package/layers/base/server/api/auth/refresh.delete.ts +4 -0
  43. package/layers/base/server/api/auth/refresh.post.ts +5 -0
  44. package/layers/base/server/api/auth/totp/confirm.post.ts +26 -0
  45. package/layers/base/server/api/auth/totp/disable.post.ts +33 -0
  46. package/layers/base/server/api/auth/totp/generate.get.ts +6 -0
  47. package/layers/base/server/api/auth/totp/verify.post.ts +36 -0
  48. package/layers/base/server/api/auth/webauthn/authenticate.post.ts +39 -0
  49. package/layers/base/server/api/auth/webauthn/index.delete.ts +43 -0
  50. package/layers/base/server/api/auth/webauthn/index.post.ts +17 -0
  51. package/layers/base/server/api/auth/webauthn/register.post.ts +49 -0
  52. package/layers/base/server/api/files/[...path].get.ts +37 -0
  53. package/layers/base/server/api/files/chunk.post.ts +36 -0
  54. package/layers/base/server/api/files/copy.post.ts +22 -0
  55. package/layers/base/server/api/files/index.delete.ts +12 -0
  56. package/layers/base/server/api/files/index.get.ts +19 -0
  57. package/layers/base/server/api/files/index.post.ts +15 -0
  58. package/layers/base/server/api/log.post.ts +20 -0
  59. package/layers/base/server/database/defaults/access.ts +14 -0
  60. package/layers/base/server/database/defaults/db.ts +5 -0
  61. package/layers/base/server/database/defaults/relations.ts +5 -0
  62. package/layers/base/server/database/relations.ts +35 -0
  63. package/layers/base/server/database/schema.ts +66 -0
  64. package/layers/base/server/middleware/0.rateLimit.ts +21 -0
  65. package/layers/base/server/middleware/1.session.ts +14 -0
  66. package/layers/base/server/middleware/2.mfa.ts +34 -0
  67. package/layers/base/server/middleware/3.admin.ts +4 -0
  68. package/layers/base/server/plugins/session.ts +9 -0
  69. package/layers/base/server/routes/auth/microsoft.get.ts +21 -0
  70. package/layers/base/server/routes/dev/email/[template].ts +15 -0
  71. package/layers/base/server/tasks/backup.ts +42 -0
  72. package/layers/base/server/tasks/clean.ts +39 -0
  73. package/layers/base/server/utils/access.ts +8 -0
  74. package/layers/base/server/utils/auth.ts +207 -0
  75. package/layers/base/server/utils/backup.ts +98 -0
  76. package/layers/base/server/utils/db.ts +3 -0
  77. package/layers/base/server/utils/email.ts +138 -0
  78. package/layers/base/server/utils/files.ts +751 -0
  79. package/layers/base/server/utils/ip.ts +15 -0
  80. package/layers/base/server/utils/log.ts +31 -0
  81. package/layers/base/server/utils/rateLimit.ts +69 -0
  82. package/layers/base/server/utils/schemas.ts +8 -0
  83. package/layers/base/server/utils/text.ts +4 -0
  84. package/layers/base/server/utils/time.ts +3 -0
  85. package/layers/base/shared/types/auth.d.ts +22 -0
  86. package/layers/base/shared/utils/logs.ts +9 -0
  87. package/layers/base/shared/utils/url.ts +5 -0
  88. package/locales/en.json +33 -0
  89. package/locales/fr.json +33 -0
  90. package/locales/pages/admin-logs/en.json +1 -0
  91. package/locales/pages/admin-logs/fr.json +1 -0
  92. package/locales/pages/index/en.json +1 -0
  93. package/locales/pages/index/fr.json +1 -0
  94. package/locales/pages/signin/en.json +5 -0
  95. package/locales/pages/signin/fr.json +5 -0
  96. package/locales/pages/user-auth/en.json +22 -0
  97. package/locales/pages/user-auth/fr.json +22 -0
  98. package/nuxt.config.ts +54 -0
  99. package/package.json +92 -4
  100. package/scripts/db/migrate.ts +9 -0
  101. package/scripts/db/push-server.sh +45 -0
  102. package/scripts/db/restore.sh +26 -0
  103. package/scripts/deploy.sh +34 -0
  104. package/scripts/env.sh +20 -0
  105. package/scripts/remote/switch.sh +48 -0
  106. package/scripts/rollback.sh +12 -0
  107. package/server/assets/emails/base.html +43 -0
  108. package/server/assets/emails/en/base.html +1 -0
  109. package/server/assets/emails/en/emailChange.html +4 -0
  110. package/server/assets/emails/en/emailChanged.html +2 -0
  111. package/server/assets/emails/en/otp.html +10 -0
  112. package/server/assets/emails/fr/base.html +1 -0
  113. package/server/assets/emails/fr/emailChange.html +4 -0
  114. package/server/assets/emails/fr/emailChanged.html +2 -0
  115. 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,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const emailSchema = z.string().trim().toLowerCase().pipe(z.email())
4
+
5
+ export const passwordSchema = () => {
6
+ const { min, max } = useRuntimeConfig().public.password
7
+ return z.string().trim().min(min).max(max)
8
+ }
@@ -0,0 +1,4 @@
1
+ export function prepareStringForSearch(text?: string) {
2
+ if (!text) return
3
+ return `%${text.replace(/[%_]/g, '\\$&')}%`
4
+ }
@@ -0,0 +1,3 @@
1
+ export function daysToMilliseconds(days: number) {
2
+ return days * 24 * 60 * 60 * 1000
3
+ }
@@ -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)
@@ -0,0 +1,5 @@
1
+ export function safePath(value: unknown, fallback = '/') {
2
+ if (typeof value !== 'string' || !value.startsWith('/')) return fallback
3
+ if (value.startsWith('//') || value.startsWith('/\\')) return fallback
4
+ return value
5
+ }
@@ -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
+ }
@@ -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,5 @@
1
+ {
2
+ "continueAsGuest": "Continue as Guest",
3
+ "or": "or",
4
+ "signinWith": "Sign in with"
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "continueAsGuest": "Continuer en tant qu'invité",
3
+ "or": "ou",
4
+ "signinWith": "Se connecter avec"
5
+ }
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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"