@riligar/elysia-backup 1.3.0 → 1.5.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/src/index.js CHANGED
@@ -1,6 +1,631 @@
1
+ // https://bun.com/docs/runtime/s3
2
+ // https://elysiajs.com/plugins/html
3
+ import { Elysia, t } from 'elysia'
4
+ import { S3Client } from 'bun'
5
+ import { CronJob } from 'cron'
6
+ import { authenticator } from 'otplib'
7
+ 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'
12
+ import { html } from '@elysiajs/html'
13
+
14
+ // Import page components
15
+ import { LoginPage } from './views/LoginPage.js'
16
+ import { DashboardPage } from './views/DashboardPage.js'
17
+
18
+ // Session Management
19
+ const sessions = new Map()
20
+
21
+ /**
22
+ * Generate a secure random session token
23
+ */
24
+ const generateSessionToken = () => {
25
+ const array = new Uint8Array(32)
26
+ crypto.getRandomValues(array)
27
+ return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
28
+ }
29
+
30
+ /**
31
+ * Create a new session for a user
32
+ */
33
+ const createSession = (username, sessionDuration = 24 * 60 * 60 * 1000) => {
34
+ const token = generateSessionToken()
35
+ const expiresAt = Date.now() + sessionDuration
36
+ sessions.set(token, { username, expiresAt })
37
+ return { token, expiresAt }
38
+ }
39
+
40
+ /**
41
+ * Validate and return session data
42
+ */
43
+ const getSession = token => {
44
+ if (!token) return null
45
+ const session = sessions.get(token)
46
+ if (!session) return null
47
+ if (Date.now() > session.expiresAt) {
48
+ sessions.delete(token)
49
+ return null
50
+ }
51
+ return session
52
+ }
53
+
54
+ /**
55
+ * Delete a session
56
+ */
57
+ const deleteSession = token => {
58
+ if (token) {
59
+ sessions.delete(token)
60
+ }
61
+ }
62
+
1
63
  /**
2
- * Elysia Backup Plugin
3
- * R2/S3 backup solution with built-in UI dashboard
64
+ * Elysia Plugin for R2/S3 Backup with UI (using native Bun.s3)
65
+ *
66
+ * @param {Object} config
67
+ * @param {string} config.bucket - The R2/S3 bucket name
68
+ * @param {string} config.accessKeyId - R2/S3 Access Key ID
69
+ * @param {string} config.secretAccessKey - R2/S3 Secret Access Key
70
+ * @param {string} config.endpoint - R2/S3 Endpoint URL
71
+ * @param {string} config.sourceDir - Local directory to backup
72
+ * @param {string} [config.prefix] - Optional prefix for S3 keys (e.g. 'backups/')
73
+ * @param {string} [config.cronSchedule] - Cron schedule expression
74
+ * @param {boolean} [config.cronEnabled] - Whether the cron schedule is enabled
75
+ * @param {string} [config.configPath] - Path to save runtime configuration (default: './config.json')
4
76
  */
77
+ export const r2Backup = initialConfig => app => {
78
+ // State to hold runtime configuration (allows UI updates)
79
+ const configPath = initialConfig.configPath || './config.json'
80
+
81
+ // Load saved config if exists
82
+ let savedConfig = {}
83
+ if (existsSync(configPath)) {
84
+ try {
85
+ const fileContent = readFileSync(configPath, 'utf-8')
86
+ savedConfig = JSON.parse(fileContent)
87
+ console.log('Loaded config from', configPath)
88
+ } catch (e) {
89
+ console.error('Failed to load backup config:', e)
90
+ }
91
+ }
92
+
93
+ let config = { ...initialConfig, ...savedConfig }
94
+ let backupJob = null
95
+
96
+ const getS3Client = () => {
97
+ console.log('S3 Config:', {
98
+ bucket: config.bucket,
99
+ endpoint: config.endpoint,
100
+ accessKeyId: config.accessKeyId ? '***' + config.accessKeyId.slice(-4) : 'missing',
101
+ hasSecret: !!config.secretAccessKey,
102
+ })
103
+
104
+ return new S3Client({
105
+ accessKeyId: config.accessKeyId,
106
+ secretAccessKey: config.secretAccessKey,
107
+ endpoint: config.endpoint,
108
+ bucket: config.bucket,
109
+ region: 'auto',
110
+ })
111
+ }
112
+
113
+ const uploadFile = async (filePath, rootDir, timestampPrefix) => {
114
+ const s3 = getS3Client()
115
+ const fileContent = await readFile(filePath)
116
+
117
+ const relativePath = relative(rootDir, filePath)
118
+ const dir = dirname(relativePath)
119
+ const filename = relativePath.split('/').pop()
120
+
121
+ const timestamp = timestampPrefix || new Date().toISOString()
122
+ const newFilename = `${timestamp}_${filename}`
123
+
124
+ const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
125
+ const key = config.prefix ? join(config.prefix, finalPath) : finalPath
126
+
127
+ console.log(`Uploading ${key}...`)
128
+ await s3.write(key, fileContent)
129
+ }
130
+
131
+ const processDirectory = async (dir, timestampPrefix) => {
132
+ const files = await readdir(dir)
133
+ for (const file of files) {
134
+ const fullPath = join(dir, file)
135
+ const stats = await stat(fullPath)
136
+ if (stats.isDirectory()) {
137
+ await processDirectory(fullPath, timestampPrefix)
138
+ } else {
139
+ const allowedExtensions = config.extensions || []
140
+ const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
141
+
142
+ 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)_/
143
+ if (timestampRegex.test(file)) {
144
+ console.log(`Skipping backup-like file: ${file}`)
145
+ continue
146
+ }
147
+
148
+ if (allowedExtensions.length === 0 || hasExtension) {
149
+ await uploadFile(fullPath, config.sourceDir, timestampPrefix)
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ const setupCron = () => {
156
+ if (backupJob) {
157
+ backupJob.stop()
158
+ backupJob = null
159
+ }
160
+
161
+ if (config.cronSchedule && config.cronEnabled !== false) {
162
+ console.log(`Setting up backup cron: ${config.cronSchedule}`)
163
+ try {
164
+ backupJob = new CronJob(
165
+ config.cronSchedule,
166
+ async () => {
167
+ console.log('Running scheduled backup...')
168
+ try {
169
+ const now = new Date()
170
+ const timestamp =
171
+ now.getFullYear() +
172
+ '-' +
173
+ String(now.getMonth() + 1).padStart(2, '0') +
174
+ '-' +
175
+ String(now.getDate()).padStart(2, '0') +
176
+ '_' +
177
+ String(now.getHours()).padStart(2, '0') +
178
+ '-' +
179
+ String(now.getMinutes()).padStart(2, '0') +
180
+ '-' +
181
+ String(now.getSeconds()).padStart(2, '0')
182
+
183
+ await processDirectory(config.sourceDir, timestamp)
184
+ console.log('Scheduled backup completed')
185
+ } catch (e) {
186
+ console.error('Scheduled backup failed:', e)
187
+ }
188
+ },
189
+ null,
190
+ true
191
+ )
192
+ } catch (e) {
193
+ console.error('Invalid cron schedule:', e.message)
194
+ }
195
+ }
196
+ }
197
+
198
+ setupCron()
199
+
200
+ const listRemoteFiles = async () => {
201
+ const s3 = getS3Client()
202
+ try {
203
+ const response = await s3.list({ prefix: config.prefix || '' })
204
+ if (Array.isArray(response)) {
205
+ return response.map(f => ({
206
+ Key: f.key || f.name,
207
+ Size: f.size,
208
+ LastModified: f.lastModified,
209
+ }))
210
+ }
211
+ if (response.contents) {
212
+ return response.contents.map(f => ({
213
+ Key: f.key,
214
+ Size: f.size,
215
+ LastModified: f.lastModified,
216
+ }))
217
+ }
218
+ console.log('Unknown list response structure:', response)
219
+ return []
220
+ } catch (e) {
221
+ console.error('Error listing files with Bun.s3:', e)
222
+ return []
223
+ }
224
+ }
225
+
226
+ const restoreFile = async key => {
227
+ const s3 = getS3Client()
228
+ const file = s3.file(key)
229
+
230
+ if (!(await file.exists())) {
231
+ throw new Error(`File ${key} not found in bucket`)
232
+ }
233
+
234
+ const arrayBuffer = await file.arrayBuffer()
235
+ const byteArray = new Uint8Array(arrayBuffer)
236
+
237
+ const relativePath = config.prefix ? key.replace(config.prefix, '') : key
238
+ const cleanRelative = relativePath.replace(/^[\/\\]/, '')
239
+
240
+ const dir = dirname(cleanRelative)
241
+ const filename = cleanRelative.split('/').pop()
242
+
243
+ const timestampRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/
244
+ const originalFilename = filename.replace(timestampRegex, '')
245
+
246
+ const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
247
+ const localPath = join(config.sourceDir, finalLocalRelativePath)
248
+
249
+ await mkdir(dirname(localPath), { recursive: true })
250
+ await writeFile(localPath, byteArray)
251
+ return localPath
252
+ }
253
+
254
+ const deleteFile = async key => {
255
+ const s3 = getS3Client()
256
+ await s3.delete(key)
257
+ }
258
+
259
+ const getJobStatus = () => {
260
+ const isRunning = !!backupJob && config.cronEnabled !== false
261
+ let nextRun = null
262
+ if (isRunning && backupJob) {
263
+ try {
264
+ const nextDate = backupJob.nextDate()
265
+ if (nextDate) {
266
+ nextRun = nextDate.toJSDate().toISOString()
267
+ }
268
+ } catch (e) {
269
+ console.error('Error getting next date', e)
270
+ }
271
+ }
272
+ return { isRunning, nextRun }
273
+ }
274
+
275
+ return app.use(html()).group('/backup', app => {
276
+ // Authentication Middleware
277
+ const authMiddleware = context => {
278
+ if (!config.auth || !config.auth.username || !config.auth.password) {
279
+ return
280
+ }
281
+
282
+ const path = context.path
283
+
284
+ // Skip auth for login, logout, and static assets
285
+ if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout' || path === '/backup/favicon.ico' || path === '/backup/logo.png') {
286
+ return
287
+ }
288
+
289
+ const cookies = context.headers.cookie || ''
290
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
291
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
292
+
293
+ const session = getSession(sessionToken)
294
+
295
+ if (!session) {
296
+ context.set.status = 302
297
+ context.set.headers['Location'] = '/backup/login'
298
+ return new Response('Redirecting to login', {
299
+ status: 302,
300
+ headers: { Location: '/backup/login' },
301
+ })
302
+ }
303
+ }
304
+
305
+ return (
306
+ app
307
+ .onBeforeHandle(authMiddleware)
308
+
309
+ // AUTH: Login Page
310
+ .get('/login', ({ set }) => {
311
+ if (!config.auth || !config.auth.username || !config.auth.password) {
312
+ set.status = 302
313
+ set.headers['Location'] = '/backup'
314
+ return
315
+ }
316
+
317
+ return LoginPage({ totpEnabled: !!config.auth?.totpSecret })
318
+ })
319
+
320
+ // AUTH: Login Endpoint
321
+ .post(
322
+ '/auth/login',
323
+ async ({ body, set }) => {
324
+ if (!config.auth || !config.auth.username || !config.auth.password) {
325
+ set.status = 403
326
+ return {
327
+ status: 'error',
328
+ message: 'Authentication is not configured',
329
+ }
330
+ }
331
+
332
+ const { username, password, totpCode } = body
333
+
334
+ if (username === config.auth.username && password === config.auth.password) {
335
+ if (config.auth.totpSecret) {
336
+ if (!totpCode) {
337
+ set.status = 401
338
+ return { status: 'error', message: 'Authenticator code is required' }
339
+ }
340
+
341
+ const isValidTotp = authenticator.check(totpCode, config.auth.totpSecret)
342
+ if (!isValidTotp) {
343
+ set.status = 401
344
+ return { status: 'error', message: 'Invalid authenticator code' }
345
+ }
346
+ }
347
+
348
+ const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000
349
+ const { token, expiresAt } = createSession(username, sessionDuration)
350
+
351
+ const expiresDate = new Date(expiresAt)
352
+ set.headers['Set-Cookie'] = `backup-session=${token}; Path=/backup; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
353
+
354
+ return { status: 'success', message: 'Login successful' }
355
+ } else {
356
+ set.status = 401
357
+ return { status: 'error', message: 'Invalid username or password' }
358
+ }
359
+ },
360
+ {
361
+ body: t.Object({
362
+ username: t.String(),
363
+ password: t.String(),
364
+ totpCode: t.Optional(t.String()),
365
+ }),
366
+ }
367
+ )
368
+
369
+ // AUTH: Logout Endpoint
370
+ .post('/auth/logout', ({ headers, set }) => {
371
+ const cookies = headers.cookie || ''
372
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
373
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
374
+
375
+ if (sessionToken) {
376
+ deleteSession(sessionToken)
377
+ }
378
+
379
+ set.headers['Set-Cookie'] = `backup-session=; Path=/backup; HttpOnly; SameSite=Lax; Max-Age=0`
380
+
381
+ return { status: 'success', message: 'Logged out successfully' }
382
+ })
383
+
384
+ // TOTP: Get Status
385
+ .get('/api/totp/status', () => {
386
+ return {
387
+ enabled: !!config.auth?.totpSecret,
388
+ }
389
+ })
390
+
391
+ // TOTP: Generate new secret and QR code
392
+ .post('/api/totp/generate', async () => {
393
+ const secret = authenticator.generateSecret()
394
+ const serviceName = config.serviceName || 'Backup Manager'
395
+ const accountName = config.auth?.username || 'admin'
396
+
397
+ const otpauth = authenticator.keyuri(accountName, serviceName, secret)
398
+ const qrCodeDataUrl = await QRCode.toDataURL(otpauth)
399
+
400
+ return {
401
+ status: 'success',
402
+ secret,
403
+ qrCode: qrCodeDataUrl,
404
+ otpauth,
405
+ }
406
+ })
407
+
408
+ // TOTP: Verify and save
409
+ .post(
410
+ '/api/totp/verify',
411
+ async ({ body, set }) => {
412
+ const { secret, code } = body
413
+
414
+ const isValid = authenticator.check(code, secret)
415
+
416
+ if (!isValid) {
417
+ set.status = 400
418
+ return { status: 'error', message: 'Invalid code. Please try again.' }
419
+ }
420
+
421
+ config.auth = config.auth || {}
422
+ config.auth.totpSecret = secret
423
+
424
+ try {
425
+ await writeFile(configPath, JSON.stringify(config, null, 2))
426
+ } catch (e) {
427
+ console.error('Failed to save TOTP config:', e)
428
+ set.status = 500
429
+ return { status: 'error', message: 'Failed to save configuration' }
430
+ }
431
+
432
+ return { status: 'success', message: 'Two-factor authentication enabled successfully' }
433
+ },
434
+ {
435
+ body: t.Object({
436
+ secret: t.String(),
437
+ code: t.String(),
438
+ }),
439
+ }
440
+ )
441
+
442
+ // TOTP: Disable
443
+ .post(
444
+ '/api/totp/disable',
445
+ async ({ body, set }) => {
446
+ const { code } = body
447
+
448
+ if (config.auth?.totpSecret) {
449
+ const isValid = authenticator.check(code, config.auth.totpSecret)
450
+ if (!isValid) {
451
+ set.status = 400
452
+ return { status: 'error', message: 'Invalid code. Please enter your current authenticator code.' }
453
+ }
454
+ }
455
+
456
+ if (config.auth) {
457
+ delete config.auth.totpSecret
458
+ }
459
+
460
+ try {
461
+ await writeFile(configPath, JSON.stringify(config, null, 2))
462
+ } catch (e) {
463
+ console.error('Failed to save config:', e)
464
+ set.status = 500
465
+ return { status: 'error', message: 'Failed to save configuration' }
466
+ }
467
+
468
+ return { status: 'success', message: 'Two-factor authentication disabled' }
469
+ },
470
+ {
471
+ body: t.Object({
472
+ code: t.String(),
473
+ }),
474
+ }
475
+ )
476
+
477
+ // API: Run Backup
478
+ .post(
479
+ '/api/run',
480
+ async ({ body, set }) => {
481
+ try {
482
+ const { timestamp } = body || {}
483
+ console.log(`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`)
484
+ await processDirectory(config.sourceDir, timestamp)
485
+ return {
486
+ status: 'success',
487
+ message: 'Backup completed successfully',
488
+ timestamp: new Date().toISOString(),
489
+ }
490
+ } catch (error) {
491
+ console.error('Backup failed:', error)
492
+ set.status = 500
493
+ return { status: 'error', message: error.message }
494
+ }
495
+ },
496
+ {
497
+ body: t.Optional(
498
+ t.Object({
499
+ timestamp: t.Optional(t.String()),
500
+ })
501
+ ),
502
+ }
503
+ )
504
+
505
+ // API: List Files
506
+ .get('/api/files', async ({ set }) => {
507
+ try {
508
+ const files = await listRemoteFiles()
509
+ return {
510
+ files: files.map(f => ({
511
+ key: f.Key,
512
+ size: f.Size,
513
+ lastModified: f.LastModified,
514
+ })),
515
+ }
516
+ } catch (error) {
517
+ set.status = 500
518
+ return { status: 'error', message: error.message }
519
+ }
520
+ })
521
+
522
+ // API: Restore File
523
+ .post(
524
+ '/api/restore',
525
+ async ({ body, set }) => {
526
+ try {
527
+ const { key } = body
528
+ if (!key) throw new Error('Key is required')
529
+ const localPath = await restoreFile(key)
530
+ return { status: 'success', message: `Restored to ${localPath}` }
531
+ } catch (error) {
532
+ set.status = 500
533
+ return { status: 'error', message: error.message }
534
+ }
535
+ },
536
+ {
537
+ body: t.Object({
538
+ key: t.String(),
539
+ }),
540
+ }
541
+ )
542
+
543
+ // API: Delete File
544
+ .post(
545
+ '/api/delete',
546
+ async ({ body, set }) => {
547
+ try {
548
+ const { key } = body
549
+ if (!key) throw new Error('Key is required')
550
+ await deleteFile(key)
551
+ return { status: 'success', message: `Deleted ${key}` }
552
+ } catch (error) {
553
+ set.status = 500
554
+ return { status: 'error', message: error.message }
555
+ }
556
+ },
557
+ {
558
+ body: t.Object({
559
+ key: t.String(),
560
+ }),
561
+ }
562
+ )
563
+
564
+ // API: Update Config
565
+ .post(
566
+ '/api/config',
567
+ async ({ body }) => {
568
+ let newConfig = { ...body }
569
+ if (typeof newConfig.extensions === 'string') {
570
+ newConfig.extensions = newConfig.extensions
571
+ .split(',')
572
+ .map(e => e.trim())
573
+ .filter(Boolean)
574
+ }
575
+ config = { ...config, ...newConfig }
576
+
577
+ try {
578
+ await writeFile(configPath, JSON.stringify(config, null, 2))
579
+ } catch (e) {
580
+ console.error('Failed to save config:', e)
581
+ }
582
+
583
+ setupCron()
584
+ return {
585
+ status: 'success',
586
+ config: { ...config, secretAccessKey: '***' },
587
+ jobStatus: getJobStatus(),
588
+ }
589
+ },
590
+ {
591
+ body: t.Object({
592
+ bucket: t.String(),
593
+ endpoint: t.String(),
594
+ sourceDir: t.String(),
595
+ prefix: t.Optional(t.String()),
596
+ extensions: t.Optional(t.Union([t.Array(t.String()), t.String()])),
597
+ accessKeyId: t.String(),
598
+ secretAccessKey: t.String(),
599
+ cronSchedule: t.Optional(t.String()),
600
+ cronEnabled: t.Optional(t.Boolean()),
601
+ }),
602
+ }
603
+ )
604
+
605
+ // Static Assets: Favicon
606
+ .get('/favicon.ico', () => {
607
+ const faviconPath = new URL('./assets/favicon.ico', import.meta.url).pathname
608
+ const content = readFileSync(faviconPath)
609
+ return new Response(content, {
610
+ headers: { 'Content-Type': 'image/x-icon', 'Cache-Control': 'public, max-age=86400' },
611
+ })
612
+ })
613
+
614
+ // Static Assets: Logo
615
+ .get('/logo.png', () => {
616
+ const logoPath = new URL('./assets/logo.png', import.meta.url).pathname
617
+ const content = readFileSync(logoPath)
618
+ return new Response(content, {
619
+ headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' },
620
+ })
621
+ })
5
622
 
6
- export { r2Backup } from "./elysia-backup.js";
623
+ // UI: Dashboard
624
+ .get('/', () => {
625
+ const jobStatus = getJobStatus()
626
+ const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
627
+ return DashboardPage({ config, jobStatus, hasAuth })
628
+ })
629
+ )
630
+ })
631
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Create authentication middleware
3
+ * @param {Object} options - Middleware options
4
+ * @param {Function} options.getConfig - Function to get current config
5
+ * @param {Object} options.sessionManager - Session manager instance
6
+ * @returns {Function} Elysia middleware function
7
+ */
8
+ export const createAuthMiddleware = ({ getConfig, sessionManager }) => {
9
+ return context => {
10
+ const config = getConfig()
11
+
12
+ // Only bypass auth if no credentials are configured at all
13
+ if (!config.auth || !config.auth.username || !config.auth.password) {
14
+ return
15
+ }
16
+
17
+ const path = context.path
18
+
19
+ // Allow access to login page and auth endpoints without authentication
20
+ if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout') {
21
+ return
22
+ }
23
+
24
+ // Check session cookie
25
+ const cookies = context.headers.cookie || ''
26
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
27
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
28
+
29
+ const session = sessionManager.get(sessionToken)
30
+
31
+ if (!session) {
32
+ // Redirect to login page
33
+ context.set.status = 302
34
+ context.set.headers['Location'] = '/backup/login'
35
+ return new Response('Redirecting to login', {
36
+ status: 302,
37
+ headers: { Location: '/backup/login' },
38
+ })
39
+ }
40
+ }
41
+ }