@riligar/elysia-backup 1.6.0 → 1.8.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 +141 -53
- package/package.json +1 -1
- package/src/core/scheduler.js +20 -18
- package/src/index.js +35 -238
- package/src/services/backup.service.js +40 -15
- package/src/services/s3-client.js +0 -8
- package/src/views/DashboardPage.js +4 -0
- package/src/views/LoginPage.js +9 -2
- package/src/views/OnboardingPage.js +9 -2
- package/src/views/components/Footer.js +25 -0
- package/src/views/scripts/backupApp.js +53 -2
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,134 @@ 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
|
|
47
60
|
|
|
48
|
-
|
|
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') |
|
|
61
|
+
### Plugin Options
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
| Option | Type | Required | Description |
|
|
64
|
+
| ------------ | ------ | -------- | ------------------------------------------------------ |
|
|
65
|
+
| `sourceDir` | string | ✅ | Local directory to backup |
|
|
66
|
+
| `configPath` | string | ❌ | Path to save runtime config (default: `./config.json`) |
|
|
67
|
+
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 |
|
|
121
|
+
|
|
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
|
|
81
161
|
```
|
|
82
162
|
|
|
83
|
-
##
|
|
163
|
+
## 🤝 Contributing
|
|
164
|
+
|
|
165
|
+
Contributions are welcome! See our [GitHub repository](https://github.com/riligar-solutions/elysia-backup) for more information.
|
|
166
|
+
|
|
167
|
+
## 📄 License
|
|
168
|
+
|
|
169
|
+
MIT © [RiLiGar](https://riligar.click/)
|
|
84
170
|
|
|
85
|
-
|
|
171
|
+
<p align="center">
|
|
172
|
+
Made with ❤️ by <a href="https://riligar.click/">RiLiGar</a>
|
|
173
|
+
</p>
|
package/package.json
CHANGED
package/src/core/scheduler.js
CHANGED
|
@@ -31,27 +31,29 @@ export const createScheduler = onBackup => {
|
|
|
31
31
|
backupJob = new CronJob(
|
|
32
32
|
cronSchedule,
|
|
33
33
|
async () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
34
|
+
// Generate timestamp for the backup
|
|
35
|
+
const now = new Date()
|
|
36
|
+
const timestamp =
|
|
37
|
+
now.getFullYear() +
|
|
38
|
+
'-' +
|
|
39
|
+
String(now.getMonth() + 1).padStart(2, '0') +
|
|
40
|
+
'-' +
|
|
41
|
+
String(now.getDate()).padStart(2, '0') +
|
|
42
|
+
'_' +
|
|
43
|
+
String(now.getHours()).padStart(2, '0') +
|
|
44
|
+
'-' +
|
|
45
|
+
String(now.getMinutes()).padStart(2, '0') +
|
|
46
|
+
'-' +
|
|
47
|
+
String(now.getSeconds()).padStart(2, '0')
|
|
48
|
+
|
|
49
|
+
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
|
50
|
+
console.log(`📦 Running scheduled backup at ${timeStr}`)
|
|
50
51
|
|
|
52
|
+
try {
|
|
51
53
|
await onBackup(timestamp)
|
|
52
|
-
console.log(
|
|
54
|
+
console.log(`✅ Scheduled backup completed at ${timeStr}`)
|
|
53
55
|
} catch (e) {
|
|
54
|
-
console.error(
|
|
56
|
+
console.error(`❌ Scheduled backup failed at ${timeStr}:`, e)
|
|
55
57
|
}
|
|
56
58
|
},
|
|
57
59
|
null,
|
package/src/index.js
CHANGED
|
@@ -1,65 +1,24 @@
|
|
|
1
1
|
// https://bun.com/docs/runtime/s3
|
|
2
2
|
// https://elysiajs.com/plugins/html
|
|
3
3
|
import { Elysia, t } from 'elysia'
|
|
4
|
-
import { S3Client } from 'bun'
|
|
5
|
-
import { CronJob } from 'cron'
|
|
6
4
|
import { authenticator } from 'otplib'
|
|
7
5
|
import QRCode from 'qrcode'
|
|
8
|
-
import {
|
|
9
|
-
import { readFileSync } from 'node:fs'
|
|
10
|
-
import { existsSync } from 'node:fs'
|
|
11
|
-
import { join, relative, dirname } from 'node:path'
|
|
6
|
+
import { writeFile } from 'node:fs/promises'
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
12
8
|
import { html } from '@elysiajs/html'
|
|
13
9
|
|
|
10
|
+
// Import core modules
|
|
11
|
+
import { createSessionManager } from './core/session.js'
|
|
12
|
+
import { createScheduler } from './core/scheduler.js'
|
|
13
|
+
import { createBackupService } from './services/backup.service.js'
|
|
14
|
+
|
|
14
15
|
// Import page components
|
|
15
16
|
import { LoginPage } from './views/LoginPage.js'
|
|
16
17
|
import { DashboardPage } from './views/DashboardPage.js'
|
|
17
18
|
import { OnboardingPage } from './views/OnboardingPage.js'
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Generate a secure random session token
|
|
24
|
-
*/
|
|
25
|
-
const generateSessionToken = () => {
|
|
26
|
-
const array = new Uint8Array(32)
|
|
27
|
-
crypto.getRandomValues(array)
|
|
28
|
-
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Create a new session for a user
|
|
33
|
-
*/
|
|
34
|
-
const createSession = (username, sessionDuration = 24 * 60 * 60 * 1000) => {
|
|
35
|
-
const token = generateSessionToken()
|
|
36
|
-
const expiresAt = Date.now() + sessionDuration
|
|
37
|
-
sessions.set(token, { username, expiresAt })
|
|
38
|
-
return { token, expiresAt }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Validate and return session data
|
|
43
|
-
*/
|
|
44
|
-
const getSession = token => {
|
|
45
|
-
if (!token) return null
|
|
46
|
-
const session = sessions.get(token)
|
|
47
|
-
if (!session) return null
|
|
48
|
-
if (Date.now() > session.expiresAt) {
|
|
49
|
-
sessions.delete(token)
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
52
|
-
return session
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Delete a session
|
|
57
|
-
*/
|
|
58
|
-
const deleteSession = token => {
|
|
59
|
-
if (token) {
|
|
60
|
-
sessions.delete(token)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
20
|
+
// Create session manager instance
|
|
21
|
+
const sessionManager = createSessionManager()
|
|
63
22
|
|
|
64
23
|
/**
|
|
65
24
|
* Elysia Plugin for R2/S3 Backup with UI (using native Bun.s3)
|
|
@@ -92,7 +51,6 @@ export const r2Backup = initialConfig => app => {
|
|
|
92
51
|
}
|
|
93
52
|
|
|
94
53
|
let config = { ...initialConfig, ...savedConfig }
|
|
95
|
-
let backupJob = null
|
|
96
54
|
|
|
97
55
|
// Helper to check if config.json exists and has required fields
|
|
98
56
|
const hasValidConfig = () => {
|
|
@@ -107,184 +65,16 @@ export const r2Backup = initialConfig => app => {
|
|
|
107
65
|
}
|
|
108
66
|
}
|
|
109
67
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
bucket: config.bucket,
|
|
113
|
-
endpoint: config.endpoint,
|
|
114
|
-
accessKeyId: config.accessKeyId ? '***' + config.accessKeyId.slice(-4) : 'missing',
|
|
115
|
-
hasSecret: !!config.secretAccessKey,
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
return new S3Client({
|
|
119
|
-
accessKeyId: config.accessKeyId,
|
|
120
|
-
secretAccessKey: config.secretAccessKey,
|
|
121
|
-
endpoint: config.endpoint,
|
|
122
|
-
bucket: config.bucket,
|
|
123
|
-
region: 'auto',
|
|
124
|
-
})
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const uploadFile = async (filePath, rootDir, timestampPrefix) => {
|
|
128
|
-
const s3 = getS3Client()
|
|
129
|
-
const fileContent = await readFile(filePath)
|
|
130
|
-
|
|
131
|
-
const relativePath = relative(rootDir, filePath)
|
|
132
|
-
const dir = dirname(relativePath)
|
|
133
|
-
const filename = relativePath.split('/').pop()
|
|
134
|
-
|
|
135
|
-
const timestamp = timestampPrefix || new Date().toISOString()
|
|
136
|
-
const newFilename = `${timestamp}_${filename}`
|
|
137
|
-
|
|
138
|
-
const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
|
|
139
|
-
const key = config.prefix ? join(config.prefix, finalPath) : finalPath
|
|
140
|
-
|
|
141
|
-
console.log(`Uploading ${key}...`)
|
|
142
|
-
await s3.write(key, fileContent)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const processDirectory = async (dir, timestampPrefix) => {
|
|
146
|
-
const files = await readdir(dir)
|
|
147
|
-
for (const file of files) {
|
|
148
|
-
const fullPath = join(dir, file)
|
|
149
|
-
const stats = await stat(fullPath)
|
|
150
|
-
if (stats.isDirectory()) {
|
|
151
|
-
await processDirectory(fullPath, timestampPrefix)
|
|
152
|
-
} else {
|
|
153
|
-
const allowedExtensions = config.extensions || []
|
|
154
|
-
const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
|
|
155
|
-
|
|
156
|
-
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)_/
|
|
157
|
-
if (timestampRegex.test(file)) {
|
|
158
|
-
console.log(`Skipping backup-like file: ${file}`)
|
|
159
|
-
continue
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (allowedExtensions.length === 0 || hasExtension) {
|
|
163
|
-
await uploadFile(fullPath, config.sourceDir, timestampPrefix)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const setupCron = () => {
|
|
170
|
-
if (backupJob) {
|
|
171
|
-
backupJob.stop()
|
|
172
|
-
backupJob = null
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (config.cronSchedule && config.cronEnabled !== false) {
|
|
176
|
-
console.log(`Setting up backup cron: ${config.cronSchedule}`)
|
|
177
|
-
try {
|
|
178
|
-
backupJob = new CronJob(
|
|
179
|
-
config.cronSchedule,
|
|
180
|
-
async () => {
|
|
181
|
-
console.log('Running scheduled backup...')
|
|
182
|
-
try {
|
|
183
|
-
const now = new Date()
|
|
184
|
-
const timestamp =
|
|
185
|
-
now.getFullYear() +
|
|
186
|
-
'-' +
|
|
187
|
-
String(now.getMonth() + 1).padStart(2, '0') +
|
|
188
|
-
'-' +
|
|
189
|
-
String(now.getDate()).padStart(2, '0') +
|
|
190
|
-
'_' +
|
|
191
|
-
String(now.getHours()).padStart(2, '0') +
|
|
192
|
-
'-' +
|
|
193
|
-
String(now.getMinutes()).padStart(2, '0') +
|
|
194
|
-
'-' +
|
|
195
|
-
String(now.getSeconds()).padStart(2, '0')
|
|
196
|
-
|
|
197
|
-
await processDirectory(config.sourceDir, timestamp)
|
|
198
|
-
console.log('Scheduled backup completed')
|
|
199
|
-
} catch (e) {
|
|
200
|
-
console.error('Scheduled backup failed:', e)
|
|
201
|
-
}
|
|
202
|
-
},
|
|
203
|
-
null,
|
|
204
|
-
true
|
|
205
|
-
)
|
|
206
|
-
} catch (e) {
|
|
207
|
-
console.error('Invalid cron schedule:', e.message)
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
setupCron()
|
|
213
|
-
|
|
214
|
-
const listRemoteFiles = async () => {
|
|
215
|
-
const s3 = getS3Client()
|
|
216
|
-
try {
|
|
217
|
-
const response = await s3.list({ prefix: config.prefix || '' })
|
|
218
|
-
if (Array.isArray(response)) {
|
|
219
|
-
return response.map(f => ({
|
|
220
|
-
Key: f.key || f.name,
|
|
221
|
-
Size: f.size,
|
|
222
|
-
LastModified: f.lastModified,
|
|
223
|
-
}))
|
|
224
|
-
}
|
|
225
|
-
if (response.contents) {
|
|
226
|
-
return response.contents.map(f => ({
|
|
227
|
-
Key: f.key,
|
|
228
|
-
Size: f.size,
|
|
229
|
-
LastModified: f.lastModified,
|
|
230
|
-
}))
|
|
231
|
-
}
|
|
232
|
-
console.log('Unknown list response structure:', response)
|
|
233
|
-
return []
|
|
234
|
-
} catch (e) {
|
|
235
|
-
console.error('Error listing files with Bun.s3:', e)
|
|
236
|
-
return []
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const restoreFile = async key => {
|
|
241
|
-
const s3 = getS3Client()
|
|
242
|
-
const file = s3.file(key)
|
|
68
|
+
// Create backup service with config getter
|
|
69
|
+
const backupService = createBackupService(() => config)
|
|
243
70
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const arrayBuffer = await file.arrayBuffer()
|
|
249
|
-
const byteArray = new Uint8Array(arrayBuffer)
|
|
250
|
-
|
|
251
|
-
const relativePath = config.prefix ? key.replace(config.prefix, '') : key
|
|
252
|
-
const cleanRelative = relativePath.replace(/^[\/\\]/, '')
|
|
253
|
-
|
|
254
|
-
const dir = dirname(cleanRelative)
|
|
255
|
-
const filename = cleanRelative.split('/').pop()
|
|
256
|
-
|
|
257
|
-
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)_/
|
|
258
|
-
const originalFilename = filename.replace(timestampRegex, '')
|
|
259
|
-
|
|
260
|
-
const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
|
|
261
|
-
const localPath = join(config.sourceDir, finalLocalRelativePath)
|
|
262
|
-
|
|
263
|
-
await mkdir(dirname(localPath), { recursive: true })
|
|
264
|
-
await writeFile(localPath, byteArray)
|
|
265
|
-
return localPath
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const deleteFile = async key => {
|
|
269
|
-
const s3 = getS3Client()
|
|
270
|
-
await s3.delete(key)
|
|
271
|
-
}
|
|
71
|
+
// Create scheduler with backup callback
|
|
72
|
+
const scheduler = createScheduler(async timestamp => {
|
|
73
|
+
await backupService.processDirectory(config.sourceDir, timestamp)
|
|
74
|
+
})
|
|
272
75
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
let nextRun = null
|
|
276
|
-
if (isRunning && backupJob) {
|
|
277
|
-
try {
|
|
278
|
-
const nextDate = backupJob.nextDate()
|
|
279
|
-
if (nextDate) {
|
|
280
|
-
nextRun = nextDate.toJSDate().toISOString()
|
|
281
|
-
}
|
|
282
|
-
} catch (e) {
|
|
283
|
-
console.error('Error getting next date', e)
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return { isRunning, nextRun }
|
|
287
|
-
}
|
|
76
|
+
// Setup initial cron schedule
|
|
77
|
+
scheduler.setup(config.cronSchedule, config.cronEnabled !== false)
|
|
288
78
|
|
|
289
79
|
return app.use(html()).group('/backup', app => {
|
|
290
80
|
// Authentication Middleware
|
|
@@ -317,9 +107,16 @@ export const r2Backup = initialConfig => app => {
|
|
|
317
107
|
const sessionMatch = cookies.match(/backup-session=([^;]+)/)
|
|
318
108
|
const sessionToken = sessionMatch ? sessionMatch[1] : null
|
|
319
109
|
|
|
320
|
-
const session =
|
|
110
|
+
const session = sessionManager.get(sessionToken)
|
|
321
111
|
|
|
322
112
|
if (!session) {
|
|
113
|
+
// Return JSON error for API routes
|
|
114
|
+
if (path.startsWith('/backup/api/')) {
|
|
115
|
+
context.set.status = 401
|
|
116
|
+
return { status: 'error', message: 'Session expired. Please login again.' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Redirect to login for page routes
|
|
323
120
|
context.set.status = 302
|
|
324
121
|
context.set.headers['Location'] = '/backup/login'
|
|
325
122
|
return new Response('Redirecting to login', {
|
|
@@ -373,7 +170,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
373
170
|
}
|
|
374
171
|
|
|
375
172
|
const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000
|
|
376
|
-
const { token, expiresAt } =
|
|
173
|
+
const { token, expiresAt } = sessionManager.create(username, sessionDuration)
|
|
377
174
|
|
|
378
175
|
const expiresDate = new Date(expiresAt)
|
|
379
176
|
set.headers['Set-Cookie'] = `backup-session=${token}; Path=/backup; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
|
|
@@ -400,7 +197,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
400
197
|
const sessionToken = sessionMatch ? sessionMatch[1] : null
|
|
401
198
|
|
|
402
199
|
if (sessionToken) {
|
|
403
|
-
|
|
200
|
+
sessionManager.delete(sessionToken)
|
|
404
201
|
}
|
|
405
202
|
|
|
406
203
|
set.headers['Set-Cookie'] = `backup-session=; Path=/backup; HttpOnly; SameSite=Lax; Max-Age=0`
|
|
@@ -508,7 +305,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
508
305
|
try {
|
|
509
306
|
const { timestamp } = body || {}
|
|
510
307
|
console.log(`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`)
|
|
511
|
-
await processDirectory(config.sourceDir, timestamp)
|
|
308
|
+
await backupService.processDirectory(config.sourceDir, timestamp)
|
|
512
309
|
return {
|
|
513
310
|
status: 'success',
|
|
514
311
|
message: 'Backup completed successfully',
|
|
@@ -532,7 +329,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
532
329
|
// API: List Files
|
|
533
330
|
.get('/api/files', async ({ set }) => {
|
|
534
331
|
try {
|
|
535
|
-
const files = await listRemoteFiles()
|
|
332
|
+
const files = await backupService.listRemoteFiles()
|
|
536
333
|
return {
|
|
537
334
|
files: files.map(f => ({
|
|
538
335
|
key: f.Key,
|
|
@@ -553,7 +350,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
553
350
|
try {
|
|
554
351
|
const { key } = body
|
|
555
352
|
if (!key) throw new Error('Key is required')
|
|
556
|
-
const localPath = await restoreFile(key)
|
|
353
|
+
const localPath = await backupService.restoreFile(key)
|
|
557
354
|
return { status: 'success', message: `Restored to ${localPath}` }
|
|
558
355
|
} catch (error) {
|
|
559
356
|
set.status = 500
|
|
@@ -574,7 +371,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
574
371
|
try {
|
|
575
372
|
const { key } = body
|
|
576
373
|
if (!key) throw new Error('Key is required')
|
|
577
|
-
await deleteFile(key)
|
|
374
|
+
await backupService.deleteFile(key)
|
|
578
375
|
return { status: 'success', message: `Deleted ${key}` }
|
|
579
376
|
} catch (error) {
|
|
580
377
|
set.status = 500
|
|
@@ -607,11 +404,11 @@ export const r2Backup = initialConfig => app => {
|
|
|
607
404
|
console.error('Failed to save config:', e)
|
|
608
405
|
}
|
|
609
406
|
|
|
610
|
-
|
|
407
|
+
scheduler.setup(config.cronSchedule, config.cronEnabled)
|
|
611
408
|
return {
|
|
612
409
|
status: 'success',
|
|
613
410
|
config: { ...config, secretAccessKey: '***' },
|
|
614
|
-
jobStatus:
|
|
411
|
+
jobStatus: scheduler.getStatus(config.cronEnabled),
|
|
615
412
|
}
|
|
616
413
|
},
|
|
617
414
|
{
|
|
@@ -702,7 +499,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
702
499
|
config = { ...config, ...initialConfigData }
|
|
703
500
|
|
|
704
501
|
// Setup cron if enabled
|
|
705
|
-
|
|
502
|
+
scheduler.setup(config.cronSchedule, config.cronEnabled)
|
|
706
503
|
|
|
707
504
|
return { status: 'success', message: 'Configuration saved successfully' }
|
|
708
505
|
} catch (e) {
|
|
@@ -736,7 +533,7 @@ export const r2Backup = initialConfig => app => {
|
|
|
736
533
|
return
|
|
737
534
|
}
|
|
738
535
|
|
|
739
|
-
const jobStatus =
|
|
536
|
+
const jobStatus = scheduler.getStatus(config.cronEnabled)
|
|
740
537
|
const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
|
|
741
538
|
return DashboardPage({ config, jobStatus, hasAuth })
|
|
742
539
|
})
|
|
@@ -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
|
|
@@ -10,14 +10,6 @@ import { S3Client } from 'bun'
|
|
|
10
10
|
* @returns {S3Client} Configured S3 client
|
|
11
11
|
*/
|
|
12
12
|
export const createS3Client = config => {
|
|
13
|
-
// Debug config (masked for security)
|
|
14
|
-
console.log('S3 Config:', {
|
|
15
|
-
bucket: config.bucket,
|
|
16
|
-
endpoint: config.endpoint,
|
|
17
|
-
accessKeyId: config.accessKeyId ? '***' + config.accessKeyId.slice(-4) : 'missing',
|
|
18
|
-
hasSecret: !!config.secretAccessKey,
|
|
19
|
-
})
|
|
20
|
-
|
|
21
13
|
return new S3Client({
|
|
22
14
|
accessKeyId: config.accessKeyId,
|
|
23
15
|
secretAccessKey: config.secretAccessKey,
|
|
@@ -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>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Head } from './components/Head.js'
|
|
7
7
|
import { OnboardingCard } from './components/OnboardingCard.js'
|
|
8
|
+
import { Footer } from './components/Footer.js'
|
|
8
9
|
import { onboardingAppScript } from './scripts/onboardingApp.js'
|
|
9
10
|
|
|
10
11
|
export const OnboardingPage = ({ sourceDir }) => `
|
|
@@ -13,8 +14,14 @@ export const OnboardingPage = ({ sourceDir }) => `
|
|
|
13
14
|
<head>
|
|
14
15
|
${Head({ title: 'Setup - Backup Manager' })}
|
|
15
16
|
</head>
|
|
16
|
-
<body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
|
|
17
|
-
|
|
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>
|
|
18
25
|
|
|
19
26
|
${onboardingAppScript({ sourceDir })}
|
|
20
27
|
</body>
|
|
@@ -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
|
+
`
|
|
@@ -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] = [];
|
|
@@ -260,6 +285,14 @@ export const backupAppScript = ({ config, jobStatus }) => `
|
|
|
260
285
|
headers: { 'Content-Type': 'application/json' },
|
|
261
286
|
body: JSON.stringify({ key })
|
|
262
287
|
})
|
|
288
|
+
|
|
289
|
+
// Handle session expiration
|
|
290
|
+
if (res.status === 401) {
|
|
291
|
+
this.addLog('Session expired. Redirecting to login...', 'error')
|
|
292
|
+
setTimeout(() => window.location.href = '/backup/login', 1500)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
263
296
|
const data = await res.json()
|
|
264
297
|
if (data.status === 'success') {
|
|
265
298
|
this.addLog(data.message, 'success')
|
|
@@ -278,6 +311,14 @@ export const backupAppScript = ({ config, jobStatus }) => `
|
|
|
278
311
|
headers: { 'Content-Type': 'application/json' },
|
|
279
312
|
body: JSON.stringify({ key })
|
|
280
313
|
})
|
|
314
|
+
|
|
315
|
+
// Handle session expiration
|
|
316
|
+
if (res.status === 401) {
|
|
317
|
+
this.addLog('Session expired. Redirecting to login...', 'error')
|
|
318
|
+
setTimeout(() => window.location.href = '/backup/login', 1500)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
281
322
|
const data = await res.json()
|
|
282
323
|
if (data.status === 'success') {
|
|
283
324
|
this.addLog(data.message, 'success')
|
|
@@ -297,12 +338,22 @@ export const backupAppScript = ({ config, jobStatus }) => `
|
|
|
297
338
|
headers: { 'Content-Type': 'application/json' },
|
|
298
339
|
body: JSON.stringify(this.configForm)
|
|
299
340
|
})
|
|
341
|
+
|
|
342
|
+
// Handle session expiration
|
|
343
|
+
if (res.status === 401) {
|
|
344
|
+
this.addLog('Session expired. Redirecting to login...', 'error')
|
|
345
|
+
setTimeout(() => window.location.href = '/backup/login', 1500)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
300
349
|
const data = await res.json()
|
|
301
350
|
if (data.status === 'success') {
|
|
302
351
|
this.config = data.config
|
|
303
352
|
if (data.jobStatus) this.cronStatus = data.jobStatus
|
|
304
353
|
this.addLog('Configuration updated', 'success')
|
|
305
354
|
this.activeTab = 'dashboard'
|
|
355
|
+
} else {
|
|
356
|
+
throw new Error(data.message || 'Failed to save config')
|
|
306
357
|
}
|
|
307
358
|
} catch (err) {
|
|
308
359
|
this.addLog('Failed to save config: ' + err.message, 'error')
|