@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 +158 -46
- package/package.json +1 -1
- package/src/index.js +164 -13
- package/src/services/backup.service.js +40 -15
- package/src/views/DashboardPage.js +4 -0
- package/src/views/LoginPage.js +9 -2
- package/src/views/OnboardingPage.js +29 -0
- package/src/views/components/Footer.js +25 -0
- package/src/views/components/OnboardingCard.js +278 -0
- package/src/views/scripts/backupApp.js +27 -2
- package/src/views/scripts/onboardingApp.js +149 -0
package/README.md
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @riligar/elysia-backup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://riligar.click/)
|
|
4
|
+
[](https://www.npmjs.com/package/@riligar/elysia-backup)
|
|
5
|
+
[](LICENSE)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
> **An open source project by [RiLiGar](https://riligar.click/)**
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
| GET | `/backup
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
<p align="center">
|
|
196
|
+
Made with ❤️ by <a href="https://riligar.click/">RiLiGar</a>
|
|
197
|
+
</p>
|
package/package.json
CHANGED
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
const
|
|
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 =
|
|
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 (
|
|
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
|
-
//
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
136
|
-
|
|
137
|
-
const
|
|
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
|
|
140
|
-
const
|
|
141
|
-
const
|
|
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 =
|
|
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 })}
|
package/src/views/LoginPage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
+
`
|