@riligar/elysia-backup 1.7.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
@@ -160,28 +160,6 @@ src/
160
160
  │ └── onboardingApp.js
161
161
  ```
162
162
 
163
- ## 🌍 Environment Variables Example
164
-
165
- ```env
166
- # Optional - for initial configuration
167
- R2_BUCKET=your-bucket-name
168
- R2_ACCESS_KEY_ID=your-access-key
169
- R2_SECRET_ACCESS_KEY=your-secret-key
170
- R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
171
- ```
172
-
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
163
  ## 🤝 Contributing
186
164
 
187
165
  Contributions are welcome! See our [GitHub repository](https://github.com/riligar-solutions/elysia-backup) for more information.
@@ -190,8 +168,6 @@ Contributions are welcome! See our [GitHub repository](https://github.com/riliga
190
168
 
191
169
  MIT © [RiLiGar](https://riligar.click/)
192
170
 
193
- ---
194
-
195
171
  <p align="center">
196
172
  Made with ❤️ by <a href="https://riligar.click/">RiLiGar</a>
197
173
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/elysia-backup",
3
- "version": "1.7.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,214 +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
- // 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
- }
152
- const key = config.prefix ? join(config.prefix, finalPath) : finalPath
153
-
154
- console.log(`Uploading ${key}...`)
155
- await s3.write(key, fileContent)
156
- }
157
-
158
- const processDirectory = async (dir, timestampPrefix) => {
159
- const files = await readdir(dir)
160
- for (const file of files) {
161
- const fullPath = join(dir, file)
162
- const stats = await stat(fullPath)
163
- if (stats.isDirectory()) {
164
- await processDirectory(fullPath, timestampPrefix)
165
- } else {
166
- const allowedExtensions = config.extensions || []
167
- const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
168
-
169
- 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)_/
170
- if (timestampRegex.test(file)) {
171
- console.log(`Skipping backup-like file: ${file}`)
172
- continue
173
- }
174
-
175
- if (allowedExtensions.length === 0 || hasExtension) {
176
- await uploadFile(fullPath, config.sourceDir, timestampPrefix)
177
- }
178
- }
179
- }
180
- }
181
-
182
- const setupCron = () => {
183
- if (backupJob) {
184
- backupJob.stop()
185
- backupJob = null
186
- }
68
+ // Create backup service with config getter
69
+ const backupService = createBackupService(() => config)
187
70
 
188
- if (config.cronSchedule && config.cronEnabled !== false) {
189
- console.log(`Setting up backup cron: ${config.cronSchedule}`)
190
- try {
191
- backupJob = new CronJob(
192
- config.cronSchedule,
193
- async () => {
194
- try {
195
- const now = new Date()
196
- const timestamp =
197
- now.getFullYear() +
198
- '-' +
199
- String(now.getMonth() + 1).padStart(2, '0') +
200
- '-' +
201
- String(now.getDate()).padStart(2, '0') +
202
- '_' +
203
- String(now.getHours()).padStart(2, '0') +
204
- '-' +
205
- String(now.getMinutes()).padStart(2, '0') +
206
- '-' +
207
- String(now.getSeconds()).padStart(2, '0')
208
-
209
- await processDirectory(config.sourceDir, timestamp)
210
- console.log('Scheduled backup completed')
211
- } catch (e) {
212
- console.error('Scheduled backup failed:', e)
213
- }
214
- },
215
- null,
216
- true
217
- )
218
- } catch (e) {
219
- console.error('Invalid cron schedule:', e.message)
220
- }
221
- }
222
- }
223
-
224
- setupCron()
225
-
226
- const listRemoteFiles = async () => {
227
- const s3 = getS3Client()
228
- try {
229
- const response = await s3.list({ prefix: config.prefix || '' })
230
- if (Array.isArray(response)) {
231
- return response.map(f => ({
232
- Key: f.key || f.name,
233
- Size: f.size,
234
- LastModified: f.lastModified,
235
- }))
236
- }
237
- if (response.contents) {
238
- return response.contents.map(f => ({
239
- Key: f.key,
240
- Size: f.size,
241
- LastModified: f.lastModified,
242
- }))
243
- }
244
- console.log('Unknown list response structure:', response)
245
- return []
246
- } catch (e) {
247
- console.error('Error listing files with Bun.s3:', e)
248
- return []
249
- }
250
- }
251
-
252
- const restoreFile = async key => {
253
- const s3 = getS3Client()
254
- const file = s3.file(key)
255
-
256
- if (!(await file.exists())) {
257
- throw new Error(`File ${key} not found in bucket`)
258
- }
259
-
260
- const arrayBuffer = await file.arrayBuffer()
261
- const byteArray = new Uint8Array(arrayBuffer)
262
-
263
- // Remove prefix from key to get relative path
264
- const relativePath = config.prefix ? key.replace(config.prefix, '') : key
265
- const cleanRelative = relativePath.replace(/^[\/\\]/, '')
266
-
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
- }
284
-
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, '')
289
-
290
- const finalLocalRelativePath = subdir ? join(subdir, originalFilename) : originalFilename
291
- const localPath = join(config.sourceDir, finalLocalRelativePath)
292
-
293
- await mkdir(dirname(localPath), { recursive: true })
294
- await writeFile(localPath, byteArray)
295
- return localPath
296
- }
297
-
298
- const deleteFile = async key => {
299
- const s3 = getS3Client()
300
- await s3.delete(key)
301
- }
71
+ // Create scheduler with backup callback
72
+ const scheduler = createScheduler(async timestamp => {
73
+ await backupService.processDirectory(config.sourceDir, timestamp)
74
+ })
302
75
 
303
- const getJobStatus = () => {
304
- const isRunning = !!backupJob && config.cronEnabled !== false
305
- let nextRun = null
306
- if (isRunning && backupJob) {
307
- try {
308
- const nextDate = backupJob.nextDate()
309
- if (nextDate) {
310
- nextRun = nextDate.toJSDate().toISOString()
311
- }
312
- } catch (e) {
313
- console.error('Error getting next date', e)
314
- }
315
- }
316
- return { isRunning, nextRun }
317
- }
76
+ // Setup initial cron schedule
77
+ scheduler.setup(config.cronSchedule, config.cronEnabled !== false)
318
78
 
319
79
  return app.use(html()).group('/backup', app => {
320
80
  // Authentication Middleware
@@ -347,7 +107,7 @@ export const r2Backup = initialConfig => app => {
347
107
  const sessionMatch = cookies.match(/backup-session=([^;]+)/)
348
108
  const sessionToken = sessionMatch ? sessionMatch[1] : null
349
109
 
350
- const session = getSession(sessionToken)
110
+ const session = sessionManager.get(sessionToken)
351
111
 
352
112
  if (!session) {
353
113
  // Return JSON error for API routes
@@ -410,7 +170,7 @@ export const r2Backup = initialConfig => app => {
410
170
  }
411
171
 
412
172
  const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000
413
- const { token, expiresAt } = createSession(username, sessionDuration)
173
+ const { token, expiresAt } = sessionManager.create(username, sessionDuration)
414
174
 
415
175
  const expiresDate = new Date(expiresAt)
416
176
  set.headers['Set-Cookie'] = `backup-session=${token}; Path=/backup; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
@@ -437,7 +197,7 @@ export const r2Backup = initialConfig => app => {
437
197
  const sessionToken = sessionMatch ? sessionMatch[1] : null
438
198
 
439
199
  if (sessionToken) {
440
- deleteSession(sessionToken)
200
+ sessionManager.delete(sessionToken)
441
201
  }
442
202
 
443
203
  set.headers['Set-Cookie'] = `backup-session=; Path=/backup; HttpOnly; SameSite=Lax; Max-Age=0`
@@ -545,7 +305,7 @@ export const r2Backup = initialConfig => app => {
545
305
  try {
546
306
  const { timestamp } = body || {}
547
307
  console.log(`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`)
548
- await processDirectory(config.sourceDir, timestamp)
308
+ await backupService.processDirectory(config.sourceDir, timestamp)
549
309
  return {
550
310
  status: 'success',
551
311
  message: 'Backup completed successfully',
@@ -569,7 +329,7 @@ export const r2Backup = initialConfig => app => {
569
329
  // API: List Files
570
330
  .get('/api/files', async ({ set }) => {
571
331
  try {
572
- const files = await listRemoteFiles()
332
+ const files = await backupService.listRemoteFiles()
573
333
  return {
574
334
  files: files.map(f => ({
575
335
  key: f.Key,
@@ -590,7 +350,7 @@ export const r2Backup = initialConfig => app => {
590
350
  try {
591
351
  const { key } = body
592
352
  if (!key) throw new Error('Key is required')
593
- const localPath = await restoreFile(key)
353
+ const localPath = await backupService.restoreFile(key)
594
354
  return { status: 'success', message: `Restored to ${localPath}` }
595
355
  } catch (error) {
596
356
  set.status = 500
@@ -611,7 +371,7 @@ export const r2Backup = initialConfig => app => {
611
371
  try {
612
372
  const { key } = body
613
373
  if (!key) throw new Error('Key is required')
614
- await deleteFile(key)
374
+ await backupService.deleteFile(key)
615
375
  return { status: 'success', message: `Deleted ${key}` }
616
376
  } catch (error) {
617
377
  set.status = 500
@@ -644,11 +404,11 @@ export const r2Backup = initialConfig => app => {
644
404
  console.error('Failed to save config:', e)
645
405
  }
646
406
 
647
- setupCron()
407
+ scheduler.setup(config.cronSchedule, config.cronEnabled)
648
408
  return {
649
409
  status: 'success',
650
410
  config: { ...config, secretAccessKey: '***' },
651
- jobStatus: getJobStatus(),
411
+ jobStatus: scheduler.getStatus(config.cronEnabled),
652
412
  }
653
413
  },
654
414
  {
@@ -739,7 +499,7 @@ export const r2Backup = initialConfig => app => {
739
499
  config = { ...config, ...initialConfigData }
740
500
 
741
501
  // Setup cron if enabled
742
- setupCron()
502
+ scheduler.setup(config.cronSchedule, config.cronEnabled)
743
503
 
744
504
  return { status: 'success', message: 'Configuration saved successfully' }
745
505
  } catch (e) {
@@ -773,7 +533,7 @@ export const r2Backup = initialConfig => app => {
773
533
  return
774
534
  }
775
535
 
776
- const jobStatus = getJobStatus()
536
+ const jobStatus = scheduler.getStatus(config.cronEnabled)
777
537
  const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
778
538
  return DashboardPage({ config, jobStatus, hasAuth })
779
539
  })
@@ -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,
@@ -285,6 +285,14 @@ export const backupAppScript = ({ config, jobStatus }) => `
285
285
  headers: { 'Content-Type': 'application/json' },
286
286
  body: JSON.stringify({ key })
287
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
+
288
296
  const data = await res.json()
289
297
  if (data.status === 'success') {
290
298
  this.addLog(data.message, 'success')
@@ -303,6 +311,14 @@ export const backupAppScript = ({ config, jobStatus }) => `
303
311
  headers: { 'Content-Type': 'application/json' },
304
312
  body: JSON.stringify({ key })
305
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
+
306
322
  const data = await res.json()
307
323
  if (data.status === 'success') {
308
324
  this.addLog(data.message, 'success')
@@ -322,12 +338,22 @@ export const backupAppScript = ({ config, jobStatus }) => `
322
338
  headers: { 'Content-Type': 'application/json' },
323
339
  body: JSON.stringify(this.configForm)
324
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
+
325
349
  const data = await res.json()
326
350
  if (data.status === 'success') {
327
351
  this.config = data.config
328
352
  if (data.jobStatus) this.cronStatus = data.jobStatus
329
353
  this.addLog('Configuration updated', 'success')
330
354
  this.activeTab = 'dashboard'
355
+ } else {
356
+ throw new Error(data.message || 'Failed to save config')
331
357
  }
332
358
  } catch (err) {
333
359
  this.addLog('Failed to save config: ' + err.message, 'error')