@riligar/elysia-backup 1.5.0 → 1.7.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 CHANGED
@@ -1,23 +1,37 @@
1
- # ElysiaJS Backup Plugin
1
+ # @riligar/elysia-backup
2
2
 
3
- Elysia plugin for R2/S3 backup with a built-in UI dashboard. Uses native Bun S3 client for optimal performance.
3
+ [![Open Source](https://img.shields.io/badge/Open%20Source-RiLiGar-blue)](https://riligar.click/)
4
+ [![npm version](https://img.shields.io/npm/v/@riligar/elysia-backup.svg)](https://www.npmjs.com/package/@riligar/elysia-backup)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
4
6
 
5
- ## Features
7
+ > **An open source project by [RiLiGar](https://riligar.click/)**
6
8
 
7
- - 📁 Backup local directories to R2/S3
8
- - 🔄 Scheduled backups with cron expressions
9
- - 🖥️ Built-in UI dashboard
10
- - ⬇️ One-click restore
11
- - 🗑️ Delete remote backups
12
- - ⚙️ Runtime configuration
9
+ A powerful Elysia plugin for R2/S3 backup with a beautiful built-in UI dashboard. Designed for Bun runtime with native S3 client for optimal performance.
13
10
 
14
- ## Installation
11
+ ## ✨ Features
12
+
13
+ - 📁 **Smart Backup** — Backup local directories to R2/S3 with file filtering
14
+ - 🔄 **Scheduled Backups** — Cron-based automatic backups
15
+ - 🖥️ **Beautiful Dashboard** — Modern UI with real-time status
16
+ - 🔐 **Secure Authentication** — Session-based auth with 2FA/TOTP support
17
+ - 🧭 **Guided Onboarding** — Multi-step setup wizard for first-time configuration
18
+ - ⬇️ **One-Click Restore** — Easily restore files from backup
19
+ - 🗑️ **Backup Management** — Delete old backups from the UI
20
+ - ⚙️ **Runtime Configuration** — Update settings without restarting
21
+
22
+ ## 📦 Installation
15
23
 
16
24
  ```bash
17
25
  bun add @riligar/elysia-backup
18
26
  ```
19
27
 
20
- ## Usage
28
+ ### Peer Dependencies
29
+
30
+ ```bash
31
+ bun add elysia @elysiajs/html
32
+ ```
33
+
34
+ ## 🚀 Quick Start
21
35
 
22
36
  ```javascript
23
37
  import { Elysia } from 'elysia'
@@ -26,60 +40,158 @@ import { r2Backup } from '@riligar/elysia-backup'
26
40
  const app = new Elysia()
27
41
  .use(
28
42
  r2Backup({
29
- bucket: process.env.R2_BUCKET,
30
- accessKeyId: process.env.R2_ACCESS_KEY_ID,
31
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
32
- endpoint: process.env.R2_ENDPOINT,
33
43
  sourceDir: './data',
34
- prefix: 'backups/',
35
- extensions: ['.db', '.sqlite'],
36
- cronSchedule: '0 0 * * *', // Daily at midnight
37
- cronEnabled: true,
44
+ configPath: './config.json',
38
45
  })
39
46
  )
40
47
  .listen(3000)
41
48
 
42
- console.log('Server running at http://localhost:3000')
43
- console.log('Backup UI at http://localhost:3000/backup')
49
+ console.log('🦊 Server running at http://localhost:3000')
50
+ console.log('📦 Backup UI at http://localhost:3000/backup')
44
51
  ```
45
52
 
46
- ## Configuration
53
+ On first run, you'll be guided through an onboarding wizard to configure:
54
+
55
+ - Storage credentials (R2/S3)
56
+ - Backup schedule
57
+ - Admin authentication
58
+
59
+ ## ⚙️ Configuration
60
+
61
+ ### Plugin Options
47
62
 
48
- | Option | Type | Required | Description |
49
- | ----------------- | -------- | -------- | ------------------------------------------------------ |
50
- | `bucket` | string | ✅ | R2/S3 bucket name |
51
- | `accessKeyId` | string | | R2/S3 Access Key ID |
52
- | `secretAccessKey` | string | ✅ | R2/S3 Secret Access Key |
53
- | `endpoint` | string | ✅ | R2/S3 Endpoint URL |
54
- | `sourceDir` | string | ✅ | Local directory to backup |
55
- | `prefix` | string | ❌ | Prefix for S3 keys (e.g., 'backups/') |
56
- | `extensions` | string[] | ❌ | File extensions to include |
57
- | `cronSchedule` | string | ❌ | Cron expression for scheduled backups |
58
- | `cronEnabled` | boolean | ❌ | Enable/disable scheduled backups |
59
- | `configPath` | string | ❌ | Path to save runtime config (default: './config.json') |
63
+ | Option | Type | Required | Description |
64
+ | ------------ | ------ | -------- | ------------------------------------------------------ |
65
+ | `sourceDir` | string | ✅ | Local directory to backup |
66
+ | `configPath` | string | | Path to save runtime config (default: `./config.json`) |
60
67
 
61
- ## API Endpoints
68
+ ### Runtime Configuration (via UI or config.json)
69
+
70
+ | Option | Type | Description |
71
+ | ----------------- | -------- | ---------------------------------------------- |
72
+ | `bucket` | string | R2/S3 bucket name |
73
+ | `accessKeyId` | string | R2/S3 Access Key ID |
74
+ | `secretAccessKey` | string | R2/S3 Secret Access Key |
75
+ | `endpoint` | string | R2/S3 Endpoint URL |
76
+ | `prefix` | string | Prefix for S3 keys (e.g., `backups/`) |
77
+ | `extensions` | string[] | File extensions to include (empty = all files) |
78
+ | `cronSchedule` | string | Cron expression for scheduled backups |
79
+ | `cronEnabled` | boolean | Enable/disable scheduled backups |
80
+ | `auth.username` | string | Admin username |
81
+ | `auth.password` | string | Admin password |
82
+ | `auth.totpSecret` | string | TOTP secret for 2FA (optional) |
83
+
84
+ ## 🔌 API Endpoints
62
85
 
63
86
  The plugin adds the following routes under `/backup`:
64
87
 
65
- | Method | Path | Description |
66
- | ------ | --------------------- | --------------------- |
67
- | GET | `/backup` | UI Dashboard |
68
- | POST | `/backup/api/run` | Trigger manual backup |
69
- | GET | `/backup/api/files` | List remote files |
70
- | POST | `/backup/api/restore` | Restore a file |
71
- | POST | `/backup/api/delete` | Delete a remote file |
72
- | POST | `/backup/api/config` | Update configuration |
88
+ ### Pages
89
+
90
+ | Method | Path | Description |
91
+ | ------ | -------------------- | ----------------------- |
92
+ | GET | `/backup` | Dashboard UI |
93
+ | GET | `/backup/login` | Login page |
94
+ | GET | `/backup/onboarding` | First-time setup wizard |
95
+
96
+ ### Authentication
97
+
98
+ | Method | Path | Description |
99
+ | ------ | --------------------- | ----------------- |
100
+ | POST | `/backup/auth/login` | Authenticate user |
101
+ | POST | `/backup/auth/logout` | End session |
102
+
103
+ ### Backup Operations
104
+
105
+ | Method | Path | Description |
106
+ | ------ | --------------------- | --------------------------- |
107
+ | POST | `/backup/api/run` | Trigger manual backup |
108
+ | GET | `/backup/api/files` | List remote backup files |
109
+ | POST | `/backup/api/restore` | Restore a specific file |
110
+ | POST | `/backup/api/delete` | Delete a remote backup file |
111
+ | POST | `/backup/api/config` | Update configuration |
112
+
113
+ ### Two-Factor Authentication (TOTP)
114
+
115
+ | Method | Path | Description |
116
+ | ------ | --------------------------- | ----------------------------- |
117
+ | GET | `/backup/api/totp/status` | Check 2FA status |
118
+ | POST | `/backup/api/totp/generate` | Generate new TOTP secret & QR |
119
+ | POST | `/backup/api/totp/verify` | Verify and enable 2FA |
120
+ | POST | `/backup/api/totp/disable` | Disable 2FA |
73
121
 
74
- ## Environment Variables Example
122
+ ## 🔐 Security Features
123
+
124
+ ### Session-Based Authentication
125
+
126
+ - Secure session tokens with configurable expiration
127
+ - HTTP-only cookies for session management
128
+ - Automatic session cleanup
129
+
130
+ ### Two-Factor Authentication (TOTP)
131
+
132
+ - Compatible with any authenticator app (Google Authenticator, Authy, etc.)
133
+ - QR code for easy setup
134
+ - Backup codes support
135
+
136
+ ## 📂 Project Structure
137
+
138
+ ```
139
+ src/
140
+ ├── index.js # Main plugin entry
141
+ ├── assets/ # Static assets (favicon, logo)
142
+ ├── views/
143
+ │ ├── LoginPage.js # Login page
144
+ │ ├── DashboardPage.js # Main dashboard
145
+ │ ├── OnboardingPage.js # Setup wizard
146
+ │ ├── components/ # UI components
147
+ │ │ ├── ActionArea.js
148
+ │ │ ├── FilesTab.js
149
+ │ │ ├── Footer.js
150
+ │ │ ├── Head.js
151
+ │ │ ├── Header.js
152
+ │ │ ├── LoginCard.js
153
+ │ │ ├── OnboardingCard.js
154
+ │ │ ├── SecuritySection.js
155
+ │ │ ├── SettingsTab.js
156
+ │ │ └── StatusCards.js
157
+ │ └── scripts/ # Client-side JavaScript
158
+ │ ├── backupApp.js
159
+ │ ├── loginApp.js
160
+ │ └── onboardingApp.js
161
+ ```
162
+
163
+ ## 🌍 Environment Variables Example
75
164
 
76
165
  ```env
166
+ # Optional - for initial configuration
77
167
  R2_BUCKET=your-bucket-name
78
168
  R2_ACCESS_KEY_ID=your-access-key
79
169
  R2_SECRET_ACCESS_KEY=your-secret-key
80
170
  R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
81
171
  ```
82
172
 
83
- ## License
173
+ > **Note:** All configuration can be set via the onboarding wizard. Environment variables are optional.
174
+
175
+ ## 📸 Screenshots
176
+
177
+ The dashboard provides:
178
+
179
+ - **Status Overview** — Cron status, next run time, bucket info
180
+ - **Quick Actions** — Manual backup trigger with real-time feedback
181
+ - **Files Browser** — View, restore, and delete backup files
182
+ - **Settings** — Update storage and backup configuration
183
+ - **Security** — Enable/disable 2FA
184
+
185
+ ## 🤝 Contributing
186
+
187
+ Contributions are welcome! See our [GitHub repository](https://github.com/riligar-solutions/elysia-backup) for more information.
188
+
189
+ ## 📄 License
190
+
191
+ MIT © [RiLiGar](https://riligar.click/)
192
+
193
+ ---
84
194
 
85
- MIT
195
+ <p align="center">
196
+ Made with ❤️ by <a href="https://riligar.click/">RiLiGar</a>
197
+ </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/elysia-backup",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Elysia plugin for R2/S3 backup with a built-in UI dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ import { html } from '@elysiajs/html'
14
14
  // Import page components
15
15
  import { LoginPage } from './views/LoginPage.js'
16
16
  import { DashboardPage } from './views/DashboardPage.js'
17
+ import { OnboardingPage } from './views/OnboardingPage.js'
17
18
 
18
19
  // Session Management
19
20
  const sessions = new Map()
@@ -93,6 +94,19 @@ export const r2Backup = initialConfig => app => {
93
94
  let config = { ...initialConfig, ...savedConfig }
94
95
  let backupJob = null
95
96
 
97
+ // Helper to check if config.json exists and has required fields
98
+ const hasValidConfig = () => {
99
+ if (!existsSync(configPath)) return false
100
+ try {
101
+ const content = readFileSync(configPath, 'utf-8')
102
+ const parsed = JSON.parse(content)
103
+ // Check minimum required fields for system to work
104
+ return !!(parsed.bucket && parsed.endpoint && parsed.accessKeyId && parsed.secretAccessKey && parsed.auth?.username && parsed.auth?.password)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
96
110
  const getS3Client = () => {
97
111
  console.log('S3 Config:', {
98
112
  bucket: config.bucket,
@@ -118,10 +132,23 @@ export const r2Backup = initialConfig => app => {
118
132
  const dir = dirname(relativePath)
119
133
  const filename = relativePath.split('/').pop()
120
134
 
121
- const timestamp = timestampPrefix || new Date().toISOString()
122
- const newFilename = `${timestamp}_${filename}`
123
-
124
- const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
135
+ // Parse timestamp to extract date and time parts
136
+ // Expected format: YYYY-MM-DD_HH-mm-ss
137
+ const timestamp = timestampPrefix || new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
138
+ const datePart = timestamp.slice(0, 10) // YYYY-MM-DD
139
+ const timePart = timestamp.slice(11) || '00-00-00' // HH-mm-ss
140
+
141
+ // Format: YYYY-MM-DD/HH-mm-ss_filename.ext
142
+ const newFilename = `${timePart}_${filename}`
143
+ const dateFolder = datePart
144
+
145
+ // Reconstruct path: prefix/date/[subdir/]time_filename
146
+ let finalPath
147
+ if (dir === '.') {
148
+ finalPath = join(dateFolder, newFilename)
149
+ } else {
150
+ finalPath = join(dateFolder, dir, newFilename)
151
+ }
125
152
  const key = config.prefix ? join(config.prefix, finalPath) : finalPath
126
153
 
127
154
  console.log(`Uploading ${key}...`)
@@ -164,7 +191,6 @@ export const r2Backup = initialConfig => app => {
164
191
  backupJob = new CronJob(
165
192
  config.cronSchedule,
166
193
  async () => {
167
- console.log('Running scheduled backup...')
168
194
  try {
169
195
  const now = new Date()
170
196
  const timestamp =
@@ -234,16 +260,34 @@ export const r2Backup = initialConfig => app => {
234
260
  const arrayBuffer = await file.arrayBuffer()
235
261
  const byteArray = new Uint8Array(arrayBuffer)
236
262
 
263
+ // Remove prefix from key to get relative path
237
264
  const relativePath = config.prefix ? key.replace(config.prefix, '') : key
238
265
  const cleanRelative = relativePath.replace(/^[\/\\]/, '')
239
266
 
240
- const dir = dirname(cleanRelative)
241
- const filename = cleanRelative.split('/').pop()
267
+ // New structure: YYYY-MM-DD/[subdir/]HH-mm-ss_filename.ext
268
+ // Old structure: YYYY-MM-DD_HH-mm-ss_filename.ext or timestamp_filename.ext
269
+ const pathParts = cleanRelative.split('/')
270
+ const filename = pathParts.pop()
271
+
272
+ // Check if first part is a date folder (YYYY-MM-DD)
273
+ const dateFolderRegex = /^\d{4}-\d{2}-\d{2}$/
274
+ let subdir = ''
275
+
276
+ if (pathParts.length > 0 && dateFolderRegex.test(pathParts[0])) {
277
+ // New format: remove date folder, keep remaining subdirs
278
+ pathParts.shift() // Remove date folder
279
+ subdir = pathParts.join('/')
280
+ } else {
281
+ // Old format: use dir as-is
282
+ subdir = pathParts.join('/')
283
+ }
242
284
 
243
- const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
244
- const originalFilename = filename.replace(timestampRegex, '')
285
+ // Strip time prefix from filename (HH-mm-ss_filename or old YYYY-MM-DD_HH-mm-ss_filename)
286
+ const timeOnlyRegex = /^\d{2}-\d{2}-\d{2}_/
287
+ const fullTimestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
288
+ let originalFilename = filename.replace(timeOnlyRegex, '').replace(fullTimestampRegex, '')
245
289
 
246
- const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
290
+ const finalLocalRelativePath = subdir ? join(subdir, originalFilename) : originalFilename
247
291
  const localPath = join(config.sourceDir, finalLocalRelativePath)
248
292
 
249
293
  await mkdir(dirname(localPath), { recursive: true })
@@ -275,14 +319,27 @@ export const r2Backup = initialConfig => app => {
275
319
  return app.use(html()).group('/backup', app => {
276
320
  // Authentication Middleware
277
321
  const authMiddleware = context => {
322
+ // Skip auth entirely if no valid config (needs onboarding)
323
+ if (!hasValidConfig()) {
324
+ return
325
+ }
326
+
278
327
  if (!config.auth || !config.auth.username || !config.auth.password) {
279
328
  return
280
329
  }
281
330
 
282
331
  const path = context.path
283
332
 
284
- // Skip auth for login, logout, and static assets
285
- if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout' || path === '/backup/favicon.ico' || path === '/backup/logo.png') {
333
+ // Skip auth for login, logout, onboarding, and static assets
334
+ if (
335
+ path === '/backup/login' ||
336
+ path === '/backup/auth/login' ||
337
+ path === '/backup/auth/logout' ||
338
+ path === '/backup/onboarding' ||
339
+ path === '/backup/api/onboarding' ||
340
+ path === '/backup/favicon.ico' ||
341
+ path === '/backup/logo.png'
342
+ ) {
286
343
  return
287
344
  }
288
345
 
@@ -293,6 +350,13 @@ export const r2Backup = initialConfig => app => {
293
350
  const session = getSession(sessionToken)
294
351
 
295
352
  if (!session) {
353
+ // Return JSON error for API routes
354
+ if (path.startsWith('/backup/api/')) {
355
+ context.set.status = 401
356
+ return { status: 'error', message: 'Session expired. Please login again.' }
357
+ }
358
+
359
+ // Redirect to login for page routes
296
360
  context.set.status = 302
297
361
  context.set.headers['Location'] = '/backup/login'
298
362
  return new Response('Redirecting to login', {
@@ -620,8 +684,95 @@ export const r2Backup = initialConfig => app => {
620
684
  })
621
685
  })
622
686
 
687
+ // ONBOARDING: Setup Page
688
+ .get('/onboarding', ({ set }) => {
689
+ // If already configured, redirect to dashboard
690
+ if (hasValidConfig()) {
691
+ set.status = 302
692
+ set.headers['Location'] = '/backup'
693
+ return
694
+ }
695
+ return OnboardingPage({ sourceDir: config.sourceDir })
696
+ })
697
+
698
+ // ONBOARDING: Save Initial Config
699
+ .post(
700
+ '/api/onboarding',
701
+ async ({ body, set }) => {
702
+ // Don't allow if already configured
703
+ if (hasValidConfig()) {
704
+ set.status = 403
705
+ return { status: 'error', message: 'System is already configured' }
706
+ }
707
+
708
+ const { endpoint, bucket, prefix, accessKeyId, secretAccessKey, extensions, cronSchedule, cronEnabled, username, password } = body
709
+
710
+ // Parse extensions
711
+ let parsedExtensions = []
712
+ if (extensions) {
713
+ parsedExtensions = extensions
714
+ .split(',')
715
+ .map(e => e.trim())
716
+ .filter(Boolean)
717
+ }
718
+
719
+ // Build initial config
720
+ const initialConfigData = {
721
+ endpoint,
722
+ bucket,
723
+ prefix: prefix || '',
724
+ accessKeyId,
725
+ secretAccessKey,
726
+ extensions: parsedExtensions,
727
+ cronSchedule: cronSchedule || '0 0 * * *',
728
+ cronEnabled: cronEnabled !== false,
729
+ auth: {
730
+ username,
731
+ password,
732
+ },
733
+ }
734
+
735
+ try {
736
+ await writeFile(configPath, JSON.stringify(initialConfigData, null, 2))
737
+
738
+ // Update runtime config
739
+ config = { ...config, ...initialConfigData }
740
+
741
+ // Setup cron if enabled
742
+ setupCron()
743
+
744
+ return { status: 'success', message: 'Configuration saved successfully' }
745
+ } catch (e) {
746
+ console.error('Failed to save onboarding config:', e)
747
+ set.status = 500
748
+ return { status: 'error', message: 'Failed to save configuration' }
749
+ }
750
+ },
751
+ {
752
+ body: t.Object({
753
+ endpoint: t.String(),
754
+ bucket: t.String(),
755
+ prefix: t.Optional(t.String()),
756
+ accessKeyId: t.String(),
757
+ secretAccessKey: t.String(),
758
+ extensions: t.Optional(t.String()),
759
+ cronSchedule: t.Optional(t.String()),
760
+ cronEnabled: t.Optional(t.Boolean()),
761
+ username: t.String(),
762
+ password: t.String(),
763
+ }),
764
+ }
765
+ )
766
+
623
767
  // UI: Dashboard
624
- .get('/', () => {
768
+ .get('/', ({ set }) => {
769
+ // Redirect to onboarding if no valid config
770
+ if (!hasValidConfig()) {
771
+ set.status = 302
772
+ set.headers['Location'] = '/backup/onboarding'
773
+ return
774
+ }
775
+
625
776
  const jobStatus = getJobStatus()
626
777
  const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
627
778
  return DashboardPage({ config, jobStatus, hasAuth })
@@ -17,7 +17,7 @@ export const createBackupService = getConfig => {
17
17
  * Upload a single file to S3
18
18
  * @param {string} filePath - Local file path
19
19
  * @param {string} rootDir - Root directory for relative path calculation
20
- * @param {string} timestampPrefix - Timestamp to prepend to filename
20
+ * @param {string} timestampPrefix - Timestamp to prepend to filename (YYYY-MM-DD_HH-mm-ss)
21
21
  */
22
22
  const uploadFile = async (filePath, rootDir, timestampPrefix) => {
23
23
  const config = getConfig()
@@ -28,15 +28,25 @@ export const createBackupService = getConfig => {
28
28
  const dir = dirname(relativePath)
29
29
  const filename = relativePath.split('/').pop()
30
30
 
31
- // Format: YYYY-MM-DD_HH-mm-ss_filename.ext
32
- const timestamp = timestampPrefix || new Date().toISOString()
33
- const newFilename = `${timestamp}_${filename}`
34
-
35
- // Reconstruct path with new filename
36
- const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
31
+ // Parse timestamp to extract date and time parts
32
+ // Expected format: YYYY-MM-DD_HH-mm-ss
33
+ const timestamp = timestampPrefix || new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
34
+ const datePart = timestamp.slice(0, 10) // YYYY-MM-DD
35
+ const timePart = timestamp.slice(11) || '00-00-00' // HH-mm-ss
36
+
37
+ // Format: YYYY-MM-DD/HH-mm-ss_filename.ext
38
+ const newFilename = `${timePart}_${filename}`
39
+ const dateFolder = datePart
40
+
41
+ // Reconstruct path: prefix/date/[subdir/]time_filename
42
+ let finalPath
43
+ if (dir === '.') {
44
+ finalPath = join(dateFolder, newFilename)
45
+ } else {
46
+ finalPath = join(dateFolder, dir, newFilename)
47
+ }
37
48
  const key = config.prefix ? join(config.prefix, finalPath) : finalPath
38
49
 
39
- console.log(`Uploading ${key}...`)
40
50
  await s3.write(key, fileContent)
41
51
  }
42
52
 
@@ -132,15 +142,30 @@ export const createBackupService = getConfig => {
132
142
  const relativePath = config.prefix ? key.replace(config.prefix, '') : key
133
143
  const cleanRelative = relativePath.replace(/^[\/\\]/, '')
134
144
 
135
- // Extract directory and filename
136
- const dir = dirname(cleanRelative)
137
- const filename = cleanRelative.split('/').pop()
145
+ // New structure: YYYY-MM-DD/[subdir/]HH-mm-ss_filename.ext
146
+ // Old structure: YYYY-MM-DD_HH-mm-ss_filename.ext or timestamp_filename.ext
147
+ const pathParts = cleanRelative.split('/')
148
+ const filename = pathParts.pop()
149
+
150
+ // Check if first part is a date folder (YYYY-MM-DD)
151
+ const dateFolderRegex = /^\d{4}-\d{2}-\d{2}$/
152
+ let subdir = ''
153
+
154
+ if (pathParts.length > 0 && dateFolderRegex.test(pathParts[0])) {
155
+ // New format: remove date folder, keep remaining subdirs
156
+ pathParts.shift() // Remove date folder
157
+ subdir = pathParts.join('/')
158
+ } else {
159
+ // Old format: use dir as-is
160
+ subdir = pathParts.join('/')
161
+ }
138
162
 
139
- // Strip timestamp prefix to restore original filename
140
- const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
141
- const originalFilename = filename.replace(timestampRegex, '')
163
+ // Strip time prefix from filename (HH-mm-ss_filename or old YYYY-MM-DD_HH-mm-ss_filename)
164
+ const timeOnlyRegex = /^\d{2}-\d{2}-\d{2}_/
165
+ const fullTimestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
166
+ let originalFilename = filename.replace(timeOnlyRegex, '').replace(fullTimestampRegex, '')
142
167
 
143
- const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
168
+ const finalLocalRelativePath = subdir ? join(subdir, originalFilename) : originalFilename
144
169
  const localPath = join(config.sourceDir, finalLocalRelativePath)
145
170
 
146
171
  // Ensure directory exists
@@ -11,6 +11,7 @@ import { ActionArea } from './components/ActionArea.js'
11
11
  import { FilesTab } from './components/FilesTab.js'
12
12
  import { SettingsTab } from './components/SettingsTab.js'
13
13
  import { SecuritySection } from './components/SecuritySection.js'
14
+ import { Footer } from './components/Footer.js'
14
15
  import { backupAppScript } from './scripts/backupApp.js'
15
16
 
16
17
  export const DashboardPage = ({ config, jobStatus, hasAuth }) => `
@@ -48,6 +49,9 @@ export const DashboardPage = ({ config, jobStatus, hasAuth }) => `
48
49
  ${SettingsTab()}
49
50
  ${SecuritySection()}
50
51
  </div>
52
+
53
+ <!-- Footer -->
54
+ ${Footer()}
51
55
  </div>
52
56
 
53
57
  ${backupAppScript({ config, jobStatus })}
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { Head } from './components/Head.js'
8
8
  import { LoginCard } from './components/LoginCard.js'
9
+ import { Footer } from './components/Footer.js'
9
10
  import { loginAppScript } from './scripts/loginApp.js'
10
11
 
11
12
  export const LoginPage = ({ totpEnabled = false }) => `
@@ -14,8 +15,14 @@ export const LoginPage = ({ totpEnabled = false }) => `
14
15
  <head>
15
16
  ${Head({ title: 'Login - Backup Manager' })}
16
17
  </head>
17
- <body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
18
- ${LoginCard({ totpEnabled })}
18
+ <body class="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6 antialiased">
19
+ <div class="flex-1 flex items-center justify-center w-full">
20
+ ${LoginCard({ totpEnabled })}
21
+ </div>
22
+
23
+ <div class="w-full max-w-md">
24
+ ${Footer()}
25
+ </div>
19
26
 
20
27
  ${loginAppScript({ totpEnabled })}
21
28
  </body>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Onboarding page component
3
+ * First-run setup wizard for configuring the backup system
4
+ * @returns {string} HTML string
5
+ */
6
+ import { Head } from './components/Head.js'
7
+ import { OnboardingCard } from './components/OnboardingCard.js'
8
+ import { Footer } from './components/Footer.js'
9
+ import { onboardingAppScript } from './scripts/onboardingApp.js'
10
+
11
+ export const OnboardingPage = ({ sourceDir }) => `
12
+ <!DOCTYPE html>
13
+ <html lang="en">
14
+ <head>
15
+ ${Head({ title: 'Setup - Backup Manager' })}
16
+ </head>
17
+ <body class="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6 antialiased">
18
+ <div class="flex-1 flex items-center justify-center w-full">
19
+ ${OnboardingCard({ sourceDir })}
20
+ </div>
21
+
22
+ <div class="w-full max-w-2xl">
23
+ ${Footer()}
24
+ </div>
25
+
26
+ ${onboardingAppScript({ sourceDir })}
27
+ </body>
28
+ </html>
29
+ `
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Footer component with RiLiGar branding
3
+ * Shows open source project attribution
4
+ * @returns {string} HTML string
5
+ */
6
+ export const Footer = () => `
7
+ <footer class="pt-4 text-center">
8
+ <p class="text-sm text-gray-500">
9
+ Open source project by
10
+ <a href="https://riligar.click/" target="_blank" rel="noopener noreferrer"
11
+ class="text-gray-700 hover:text-gray-900 font-medium transition-colors underline underline-offset-2 decoration-gray-300 hover:decoration-gray-600">
12
+ RiLiGar
13
+ </a>
14
+ </p>
15
+ <p class="text-xs text-gray-400 mt-1">
16
+ <a href="https://github.com/riligar/elysia-backup" target="_blank" rel="noopener noreferrer"
17
+ class="hover:text-gray-600 transition-colors inline-flex items-center gap-1">
18
+ <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
19
+ <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
20
+ </svg>
21
+ View on GitHub
22
+ </a>
23
+ </p>
24
+ </footer>
25
+ `
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Onboarding card component - Multi-step setup wizard
3
+ * @param {{ sourceDir: string }} props
4
+ * @returns {string} HTML string
5
+ */
6
+ export const OnboardingCard = ({ sourceDir }) => `
7
+ <div class="w-full max-w-2xl" x-data="onboardingApp()">
8
+ <!-- Onboarding Card -->
9
+ <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_8px_30px_rgba(0,0,0,0.08)] overflow-hidden">
10
+ <!-- Header -->
11
+ <div class="p-8 text-center border-b border-gray-100 bg-gradient-to-br from-primary-50 to-white">
12
+ <img src="/backup/logo.png" alt="Backup Manager" class="w-16 h-16 mx-auto mb-4 rounded-xl">
13
+ <h1 class="text-2xl font-bold text-gray-900 mb-2">Welcome to Backup Manager</h1>
14
+ <p class="text-sm text-gray-500">Let's configure your backup system in a few steps</p>
15
+ </div>
16
+
17
+ <!-- Progress Steps -->
18
+ <div class="px-8 py-4 bg-gray-50 border-b border-gray-100">
19
+ <div class="flex items-center justify-center gap-2">
20
+ <template x-for="(stepName, index) in ['Storage', 'Backup', 'Security']" :key="index">
21
+ <div class="flex items-center">
22
+ <div
23
+ :class="step > index + 1 ? 'bg-primary-500 text-white' : (step === index + 1 ? 'bg-primary-500 text-white ring-4 ring-primary-100' : 'bg-gray-200 text-gray-500')"
24
+ class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
25
+ >
26
+ <span x-show="step <= index + 1" x-text="index + 1"></span>
27
+ <i x-show="step > index + 1" data-lucide="check" class="w-4 h-4"></i>
28
+ </div>
29
+ <span
30
+ :class="step >= index + 1 ? 'text-gray-900 font-semibold' : 'text-gray-400'"
31
+ class="ml-2 text-sm hidden sm:inline"
32
+ x-text="stepName"
33
+ ></span>
34
+ <div x-show="index < 2" class="w-8 h-0.5 mx-3 bg-gray-200 hidden sm:block">
35
+ <div
36
+ :class="step > index + 1 ? 'w-full' : 'w-0'"
37
+ class="h-full bg-primary-500 transition-all duration-300"
38
+ ></div>
39
+ </div>
40
+ </div>
41
+ </template>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Form -->
46
+ <div class="p-8">
47
+ <!-- Error Message -->
48
+ <div x-show="error" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
49
+ <div class="flex items-center gap-3">
50
+ <i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
51
+ <span class="text-sm text-red-800 font-medium" x-text="error"></span>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Step 1: Storage Configuration -->
56
+ <div x-show="step === 1" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0">
57
+ <h2 class="text-lg font-bold text-gray-900 mb-1 flex items-center gap-2">
58
+ <i data-lucide="cloud" class="w-5 h-5 text-primary-500"></i>
59
+ Storage Configuration
60
+ </h2>
61
+ <p class="text-sm text-gray-500 mb-6">Configure your R2/S3 compatible storage</p>
62
+
63
+ <div class="space-y-5">
64
+ <div>
65
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Endpoint URL *</label>
66
+ <input
67
+ type="url"
68
+ x-model="form.endpoint"
69
+ placeholder="https://your-account.r2.cloudflarestorage.com"
70
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
71
+ >
72
+ </div>
73
+ <div class="grid grid-cols-2 gap-4">
74
+ <div>
75
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Bucket Name *</label>
76
+ <input
77
+ type="text"
78
+ x-model="form.bucket"
79
+ placeholder="my-backups"
80
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
81
+ >
82
+ </div>
83
+ <div>
84
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Prefix (Folder)</label>
85
+ <input
86
+ type="text"
87
+ x-model="form.prefix"
88
+ placeholder="backups/"
89
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
90
+ >
91
+ </div>
92
+ </div>
93
+ <div class="grid grid-cols-2 gap-4">
94
+ <div>
95
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Access Key ID *</label>
96
+ <input
97
+ type="text"
98
+ x-model="form.accessKeyId"
99
+ placeholder="Your access key"
100
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
101
+ >
102
+ </div>
103
+ <div>
104
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Secret Access Key *</label>
105
+ <input
106
+ type="password"
107
+ x-model="form.secretAccessKey"
108
+ placeholder="Your secret key"
109
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
110
+ >
111
+ </div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Step 2: Backup Configuration -->
117
+ <div x-show="step === 2" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0">
118
+ <h2 class="text-lg font-bold text-gray-900 mb-1 flex items-center gap-2">
119
+ <i data-lucide="hard-drive" class="w-5 h-5 text-primary-500"></i>
120
+ Backup Configuration
121
+ </h2>
122
+ <p class="text-sm text-gray-500 mb-6">Configure backup source and schedule</p>
123
+
124
+ <div class="space-y-5">
125
+ <div>
126
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Source Directory</label>
127
+ <input
128
+ type="text"
129
+ value="${sourceDir}"
130
+ disabled
131
+ class="w-full bg-gray-100 border border-gray-200 rounded-lg px-4 py-3 text-gray-500 cursor-not-allowed font-medium"
132
+ >
133
+ <p class="text-xs text-gray-400 mt-1">Defined in server configuration</p>
134
+ </div>
135
+ <div>
136
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Allowed Extensions</label>
137
+ <input
138
+ type="text"
139
+ x-model="form.extensions"
140
+ placeholder=".db, .sqlite, .json (leave empty for all files)"
141
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
142
+ >
143
+ <p class="text-xs text-gray-400 mt-1">Comma-separated list of file extensions to backup</p>
144
+ </div>
145
+ <div>
146
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Cron Schedule</label>
147
+ <input
148
+ type="text"
149
+ x-model="form.cronSchedule"
150
+ placeholder="0 0 * * * (daily at midnight)"
151
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
152
+ >
153
+ <p class="text-xs text-gray-400 mt-1">
154
+ Format: Minute Hour Day Month DayOfWeek.
155
+ <a href="https://crontab.guru/" target="_blank" class="text-primary-500 hover:underline">Help</a>
156
+ </p>
157
+ </div>
158
+ <div class="flex items-center gap-3 p-4 bg-gray-50 rounded-lg">
159
+ <input
160
+ type="checkbox"
161
+ x-model="form.cronEnabled"
162
+ id="cronEnabled"
163
+ class="w-5 h-5 text-primary-500 rounded border-gray-300 focus:ring-primary-500"
164
+ >
165
+ <label for="cronEnabled" class="text-sm font-medium text-gray-700">Enable scheduled backups</label>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Step 3: Security Configuration -->
171
+ <div x-show="step === 3" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0">
172
+ <h2 class="text-lg font-bold text-gray-900 mb-1 flex items-center gap-2">
173
+ <i data-lucide="shield" class="w-5 h-5 text-primary-500"></i>
174
+ Security Configuration
175
+ </h2>
176
+ <p class="text-sm text-gray-500 mb-6">Set up your admin credentials</p>
177
+
178
+ <div class="space-y-5">
179
+ <div>
180
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Admin Username *</label>
181
+ <div class="relative">
182
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
183
+ <i data-lucide="user" class="w-5 h-5 text-gray-400"></i>
184
+ </div>
185
+ <input
186
+ type="text"
187
+ x-model="form.username"
188
+ placeholder="admin"
189
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
190
+ >
191
+ </div>
192
+ </div>
193
+ <div>
194
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Password *</label>
195
+ <div class="relative">
196
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
197
+ <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
198
+ </div>
199
+ <input
200
+ type="password"
201
+ x-model="form.password"
202
+ placeholder="Enter a strong password"
203
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
204
+ >
205
+ </div>
206
+ </div>
207
+ <div>
208
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Confirm Password *</label>
209
+ <div class="relative">
210
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
211
+ <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
212
+ </div>
213
+ <input
214
+ type="password"
215
+ x-model="form.confirmPassword"
216
+ placeholder="Confirm your password"
217
+ class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all font-medium"
218
+ :class="form.confirmPassword && form.password !== form.confirmPassword ? 'border-red-300 focus:ring-red-500' : ''"
219
+ >
220
+ </div>
221
+ <p x-show="form.confirmPassword && form.password !== form.confirmPassword" class="text-xs text-red-500 mt-1">
222
+ Passwords do not match
223
+ </p>
224
+ </div>
225
+
226
+ <div class="p-4 bg-primary-50 rounded-lg border border-primary-100">
227
+ <div class="flex items-start gap-3">
228
+ <i data-lucide="info" class="w-5 h-5 text-primary-600 mt-0.5"></i>
229
+ <div class="text-sm text-primary-800">
230
+ <p class="font-medium mb-1">Two-Factor Authentication</p>
231
+ <p class="text-primary-700">You can enable 2FA later in the Security settings after completing the setup.</p>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <!-- Navigation Buttons -->
239
+ <div class="flex justify-between mt-8 pt-6 border-t border-gray-100">
240
+ <button
241
+ x-show="step > 1"
242
+ @click="prevStep()"
243
+ class="px-6 py-3 text-gray-600 hover:text-gray-900 font-semibold transition-colors flex items-center gap-2"
244
+ >
245
+ <i data-lucide="arrow-left" class="w-4 h-4"></i>
246
+ Back
247
+ </button>
248
+ <div x-show="step === 1"></div>
249
+
250
+ <button
251
+ x-show="step < 3"
252
+ @click="nextStep()"
253
+ class="px-8 py-3 bg-primary-500 hover:bg-primary-600 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-xl flex items-center gap-2"
254
+ >
255
+ Continue
256
+ <i data-lucide="arrow-right" class="w-4 h-4"></i>
257
+ </button>
258
+
259
+ <button
260
+ x-show="step === 3"
261
+ @click="submitSetup()"
262
+ :disabled="loading || !canSubmit()"
263
+ class="px-8 py-3 bg-primary-500 hover:bg-primary-600 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-xl flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
264
+ >
265
+ <span x-show="!loading" class="flex items-center gap-2">
266
+ <i data-lucide="check-circle" class="w-5 h-5"></i>
267
+ Complete Setup
268
+ </span>
269
+ <span x-show="loading" class="flex items-center gap-2">
270
+ <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
271
+ Saving...
272
+ </span>
273
+ </button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ `
@@ -201,6 +201,14 @@ export const backupAppScript = ({ config, jobStatus }) => `
201
201
  headers: { 'Content-Type': 'application/json' },
202
202
  body: JSON.stringify({ timestamp })
203
203
  })
204
+
205
+ // Handle session expiration
206
+ if (res.status === 401) {
207
+ this.addLog('Session expired. Redirecting to login...', 'error')
208
+ setTimeout(() => window.location.href = '/backup/login', 1500)
209
+ return
210
+ }
211
+
204
212
  const data = await res.json()
205
213
  if (data.status === 'success') {
206
214
  this.lastBackup = new Date().toLocaleString()
@@ -220,14 +228,31 @@ export const backupAppScript = ({ config, jobStatus }) => `
220
228
  this.loadingFiles = true
221
229
  try {
222
230
  const res = await fetch('/backup/api/files')
231
+
232
+ // Handle session expiration
233
+ if (res.status === 401) {
234
+ this.addLog('Session expired. Redirecting to login...', 'error')
235
+ setTimeout(() => window.location.href = '/backup/login', 1500)
236
+ return
237
+ }
238
+
223
239
  const data = await res.json()
240
+
241
+ // Check for error response
242
+ if (data.status === 'error') {
243
+ throw new Error(data.message)
244
+ }
245
+
224
246
  if (data.files) {
225
247
  const sortedFiles = data.files.sort((a, b) => b.key.localeCompare(a.key));
226
248
 
227
249
  const groupsMap = {};
228
250
  sortedFiles.forEach(file => {
229
- const match = file.key.match(/(?:^|\\/)(\\d{4}-\\d{2}-\\d{2})[_T]/);
230
- const dateKey = match ? match[1] : 'Others';
251
+ // Try to extract date from folder path first (new format: YYYY-MM-DD/)
252
+ // Then fall back to extracting from filename (legacy format: YYYY-MM-DD_HH-mm-ss_filename)
253
+ const folderMatch = file.key.match(/(?:^|\\/)(\\d{4}-\\d{2}-\\d{2})\\//);
254
+ const filenameMatch = file.key.match(/(?:^|\\/)(\\d{4}-\\d{2}-\\d{2})[_T]/);
255
+ const dateKey = folderMatch ? folderMatch[1] : (filenameMatch ? filenameMatch[1] : 'Others');
231
256
 
232
257
  if (!groupsMap[dateKey]) {
233
258
  groupsMap[dateKey] = [];
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Onboarding Alpine.js application script
3
+ * Handles multi-step form navigation and submission
4
+ * @param {{ sourceDir: string }} props
5
+ * @returns {string} Script tag with onboarding logic
6
+ */
7
+ export const onboardingAppScript = ({ sourceDir }) => `
8
+ <script>
9
+ document.addEventListener('alpine:init', () => {
10
+ Alpine.data('onboardingApp', () => ({
11
+ step: 1,
12
+ loading: false,
13
+ error: '',
14
+ form: {
15
+ // Storage
16
+ endpoint: '',
17
+ bucket: '',
18
+ prefix: '',
19
+ accessKeyId: '',
20
+ secretAccessKey: '',
21
+ // Backup
22
+ extensions: '',
23
+ cronSchedule: '0 0 * * *',
24
+ cronEnabled: true,
25
+ // Security
26
+ username: '',
27
+ password: '',
28
+ confirmPassword: ''
29
+ },
30
+
31
+ init() {
32
+ this.$nextTick(() => lucide.createIcons());
33
+ },
34
+
35
+ validateStep1() {
36
+ if (!this.form.endpoint) {
37
+ this.error = 'Endpoint URL is required';
38
+ return false;
39
+ }
40
+ if (!this.form.bucket) {
41
+ this.error = 'Bucket name is required';
42
+ return false;
43
+ }
44
+ if (!this.form.accessKeyId) {
45
+ this.error = 'Access Key ID is required';
46
+ return false;
47
+ }
48
+ if (!this.form.secretAccessKey) {
49
+ this.error = 'Secret Access Key is required';
50
+ return false;
51
+ }
52
+ return true;
53
+ },
54
+
55
+ validateStep2() {
56
+ // Step 2 has no required fields
57
+ return true;
58
+ },
59
+
60
+ validateStep3() {
61
+ if (!this.form.username) {
62
+ this.error = 'Username is required';
63
+ return false;
64
+ }
65
+ if (!this.form.password) {
66
+ this.error = 'Password is required';
67
+ return false;
68
+ }
69
+ if (this.form.password.length < 8) {
70
+ this.error = 'Password must be at least 8 characters';
71
+ return false;
72
+ }
73
+ if (this.form.password !== this.form.confirmPassword) {
74
+ this.error = 'Passwords do not match';
75
+ return false;
76
+ }
77
+ return true;
78
+ },
79
+
80
+ nextStep() {
81
+ this.error = '';
82
+
83
+ if (this.step === 1 && !this.validateStep1()) return;
84
+ if (this.step === 2 && !this.validateStep2()) return;
85
+
86
+ this.step++;
87
+ this.$nextTick(() => lucide.createIcons());
88
+ },
89
+
90
+ prevStep() {
91
+ this.error = '';
92
+ this.step--;
93
+ this.$nextTick(() => lucide.createIcons());
94
+ },
95
+
96
+ canSubmit() {
97
+ return this.form.username &&
98
+ this.form.password &&
99
+ this.form.password === this.form.confirmPassword &&
100
+ this.form.password.length >= 8;
101
+ },
102
+
103
+ async submitSetup() {
104
+ this.error = '';
105
+
106
+ if (!this.validateStep3()) return;
107
+
108
+ this.loading = true;
109
+
110
+ try {
111
+ const response = await fetch('/backup/api/onboarding', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({
115
+ endpoint: this.form.endpoint,
116
+ bucket: this.form.bucket,
117
+ prefix: this.form.prefix,
118
+ accessKeyId: this.form.accessKeyId,
119
+ secretAccessKey: this.form.secretAccessKey,
120
+ extensions: this.form.extensions,
121
+ cronSchedule: this.form.cronSchedule,
122
+ cronEnabled: this.form.cronEnabled,
123
+ username: this.form.username,
124
+ password: this.form.password
125
+ })
126
+ });
127
+
128
+ const data = await response.json();
129
+
130
+ if (data.status === 'success') {
131
+ window.location.href = '/backup';
132
+ } else {
133
+ this.error = data.message || 'Setup failed. Please try again.';
134
+ }
135
+ } catch (e) {
136
+ this.error = 'Connection error. Please try again.';
137
+ } finally {
138
+ this.loading = false;
139
+ this.$nextTick(() => lucide.createIcons());
140
+ }
141
+ }
142
+ }));
143
+ });
144
+
145
+ document.addEventListener('DOMContentLoaded', () => {
146
+ lucide.createIcons();
147
+ });
148
+ </script>
149
+ `