@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 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,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
- 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
47
60
 
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') |
61
+ ### Plugin Options
60
62
 
61
- ## API Endpoints
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
- | 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 |
73
-
74
- ## Environment Variables Example
75
-
76
- ```env
77
- R2_BUCKET=your-bucket-name
78
- R2_ACCESS_KEY_ID=your-access-key
79
- R2_SECRET_ACCESS_KEY=your-secret-key
80
- R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
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
- ## License
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
- MIT
171
+ <p align="center">
172
+ Made with ❤️ by <a href="https://riligar.click/">RiLiGar</a>
173
+ </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/elysia-backup",
3
- "version": "1.6.0",
3
+ "version": "1.8.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",
@@ -31,27 +31,29 @@ export const createScheduler = onBackup => {
31
31
  backupJob = new CronJob(
32
32
  cronSchedule,
33
33
  async () => {
34
- console.log('Running scheduled backup...')
35
- try {
36
- // Generate timestamp for the backup
37
- const now = new Date()
38
- const timestamp =
39
- now.getFullYear() +
40
- '-' +
41
- String(now.getMonth() + 1).padStart(2, '0') +
42
- '-' +
43
- String(now.getDate()).padStart(2, '0') +
44
- '_' +
45
- String(now.getHours()).padStart(2, '0') +
46
- '-' +
47
- String(now.getMinutes()).padStart(2, '0') +
48
- '-' +
49
- String(now.getSeconds()).padStart(2, '0')
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('Scheduled backup completed')
54
+ console.log(`✅ Scheduled backup completed at ${timeStr}`)
53
55
  } catch (e) {
54
- console.error('Scheduled backup failed:', e)
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 { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
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
- // Session Management
20
- const sessions = new Map()
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
- const getS3Client = () => {
111
- console.log('S3 Config:', {
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
- if (!(await file.exists())) {
245
- throw new Error(`File ${key} not found in bucket`)
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
- const getJobStatus = () => {
274
- const isRunning = !!backupJob && config.cronEnabled !== false
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 = getSession(sessionToken)
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 } = createSession(username, sessionDuration)
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
- deleteSession(sessionToken)
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
- setupCron()
407
+ scheduler.setup(config.cronSchedule, config.cronEnabled)
611
408
  return {
612
409
  status: 'success',
613
410
  config: { ...config, secretAccessKey: '***' },
614
- jobStatus: getJobStatus(),
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
- setupCron()
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 = getJobStatus()
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
- // 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
@@ -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 })}
@@ -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>
@@ -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
- ${OnboardingCard({ sourceDir })}
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
- 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] = [];
@@ -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')