@riligar/elysia-backup 1.3.0 → 1.4.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,612 @@
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
+
1
30
  /**
2
- * Elysia Backup Plugin
3
- * R2/S3 backup solution with built-in UI dashboard
31
+ * Create a new session for a user
4
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
+
63
+ /**
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')
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
+ if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout') {
285
+ return
286
+ }
287
+
288
+ const cookies = context.headers.cookie || ''
289
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
290
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
291
+
292
+ const session = getSession(sessionToken)
293
+
294
+ if (!session) {
295
+ context.set.status = 302
296
+ context.set.headers['Location'] = '/backup/login'
297
+ return new Response('Redirecting to login', {
298
+ status: 302,
299
+ headers: { Location: '/backup/login' },
300
+ })
301
+ }
302
+ }
303
+
304
+ return (
305
+ app
306
+ .onBeforeHandle(authMiddleware)
307
+
308
+ // AUTH: Login Page
309
+ .get('/login', ({ set }) => {
310
+ if (!config.auth || !config.auth.username || !config.auth.password) {
311
+ set.status = 302
312
+ set.headers['Location'] = '/backup'
313
+ return
314
+ }
315
+
316
+ return LoginPage({ totpEnabled: !!config.auth?.totpSecret })
317
+ })
318
+
319
+ // AUTH: Login Endpoint
320
+ .post(
321
+ '/auth/login',
322
+ async ({ body, set }) => {
323
+ if (!config.auth || !config.auth.username || !config.auth.password) {
324
+ set.status = 403
325
+ return {
326
+ status: 'error',
327
+ message: 'Authentication is not configured',
328
+ }
329
+ }
330
+
331
+ const { username, password, totpCode } = body
332
+
333
+ if (username === config.auth.username && password === config.auth.password) {
334
+ if (config.auth.totpSecret) {
335
+ if (!totpCode) {
336
+ set.status = 401
337
+ return { status: 'error', message: 'Authenticator code is required' }
338
+ }
339
+
340
+ const isValidTotp = authenticator.check(totpCode, config.auth.totpSecret)
341
+ if (!isValidTotp) {
342
+ set.status = 401
343
+ return { status: 'error', message: 'Invalid authenticator code' }
344
+ }
345
+ }
346
+
347
+ const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000
348
+ const { token, expiresAt } = createSession(username, sessionDuration)
349
+
350
+ const expiresDate = new Date(expiresAt)
351
+ set.headers['Set-Cookie'] = `backup-session=${token}; Path=/backup; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
352
+
353
+ return { status: 'success', message: 'Login successful' }
354
+ } else {
355
+ set.status = 401
356
+ return { status: 'error', message: 'Invalid username or password' }
357
+ }
358
+ },
359
+ {
360
+ body: t.Object({
361
+ username: t.String(),
362
+ password: t.String(),
363
+ totpCode: t.Optional(t.String()),
364
+ }),
365
+ }
366
+ )
367
+
368
+ // AUTH: Logout Endpoint
369
+ .post('/auth/logout', ({ headers, set }) => {
370
+ const cookies = headers.cookie || ''
371
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
372
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
373
+
374
+ if (sessionToken) {
375
+ deleteSession(sessionToken)
376
+ }
377
+
378
+ set.headers['Set-Cookie'] = `backup-session=; Path=/backup; HttpOnly; SameSite=Lax; Max-Age=0`
379
+
380
+ return { status: 'success', message: 'Logged out successfully' }
381
+ })
382
+
383
+ // TOTP: Get Status
384
+ .get('/api/totp/status', () => {
385
+ return {
386
+ enabled: !!config.auth?.totpSecret,
387
+ }
388
+ })
389
+
390
+ // TOTP: Generate new secret and QR code
391
+ .post('/api/totp/generate', async () => {
392
+ const secret = authenticator.generateSecret()
393
+ const serviceName = config.serviceName || 'Backup Manager'
394
+ const accountName = config.auth?.username || 'admin'
395
+
396
+ const otpauth = authenticator.keyuri(accountName, serviceName, secret)
397
+ const qrCodeDataUrl = await QRCode.toDataURL(otpauth)
398
+
399
+ return {
400
+ status: 'success',
401
+ secret,
402
+ qrCode: qrCodeDataUrl,
403
+ otpauth,
404
+ }
405
+ })
406
+
407
+ // TOTP: Verify and save
408
+ .post(
409
+ '/api/totp/verify',
410
+ async ({ body, set }) => {
411
+ const { secret, code } = body
412
+
413
+ const isValid = authenticator.check(code, secret)
414
+
415
+ if (!isValid) {
416
+ set.status = 400
417
+ return { status: 'error', message: 'Invalid code. Please try again.' }
418
+ }
419
+
420
+ config.auth = config.auth || {}
421
+ config.auth.totpSecret = secret
422
+
423
+ try {
424
+ await writeFile(configPath, JSON.stringify(config, null, 2))
425
+ } catch (e) {
426
+ console.error('Failed to save TOTP config:', e)
427
+ set.status = 500
428
+ return { status: 'error', message: 'Failed to save configuration' }
429
+ }
430
+
431
+ return { status: 'success', message: 'Two-factor authentication enabled successfully' }
432
+ },
433
+ {
434
+ body: t.Object({
435
+ secret: t.String(),
436
+ code: t.String(),
437
+ }),
438
+ }
439
+ )
440
+
441
+ // TOTP: Disable
442
+ .post(
443
+ '/api/totp/disable',
444
+ async ({ body, set }) => {
445
+ const { code } = body
446
+
447
+ if (config.auth?.totpSecret) {
448
+ const isValid = authenticator.check(code, config.auth.totpSecret)
449
+ if (!isValid) {
450
+ set.status = 400
451
+ return { status: 'error', message: 'Invalid code. Please enter your current authenticator code.' }
452
+ }
453
+ }
454
+
455
+ if (config.auth) {
456
+ delete config.auth.totpSecret
457
+ }
458
+
459
+ try {
460
+ await writeFile(configPath, JSON.stringify(config, null, 2))
461
+ } catch (e) {
462
+ console.error('Failed to save config:', e)
463
+ set.status = 500
464
+ return { status: 'error', message: 'Failed to save configuration' }
465
+ }
466
+
467
+ return { status: 'success', message: 'Two-factor authentication disabled' }
468
+ },
469
+ {
470
+ body: t.Object({
471
+ code: t.String(),
472
+ }),
473
+ }
474
+ )
475
+
476
+ // API: Run Backup
477
+ .post(
478
+ '/api/run',
479
+ async ({ body, set }) => {
480
+ try {
481
+ const { timestamp } = body || {}
482
+ console.log(`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`)
483
+ await processDirectory(config.sourceDir, timestamp)
484
+ return {
485
+ status: 'success',
486
+ message: 'Backup completed successfully',
487
+ timestamp: new Date().toISOString(),
488
+ }
489
+ } catch (error) {
490
+ console.error('Backup failed:', error)
491
+ set.status = 500
492
+ return { status: 'error', message: error.message }
493
+ }
494
+ },
495
+ {
496
+ body: t.Optional(
497
+ t.Object({
498
+ timestamp: t.Optional(t.String()),
499
+ })
500
+ ),
501
+ }
502
+ )
503
+
504
+ // API: List Files
505
+ .get('/api/files', async ({ set }) => {
506
+ try {
507
+ const files = await listRemoteFiles()
508
+ return {
509
+ files: files.map(f => ({
510
+ key: f.Key,
511
+ size: f.Size,
512
+ lastModified: f.LastModified,
513
+ })),
514
+ }
515
+ } catch (error) {
516
+ set.status = 500
517
+ return { status: 'error', message: error.message }
518
+ }
519
+ })
520
+
521
+ // API: Restore File
522
+ .post(
523
+ '/api/restore',
524
+ async ({ body, set }) => {
525
+ try {
526
+ const { key } = body
527
+ if (!key) throw new Error('Key is required')
528
+ const localPath = await restoreFile(key)
529
+ return { status: 'success', message: `Restored to ${localPath}` }
530
+ } catch (error) {
531
+ set.status = 500
532
+ return { status: 'error', message: error.message }
533
+ }
534
+ },
535
+ {
536
+ body: t.Object({
537
+ key: t.String(),
538
+ }),
539
+ }
540
+ )
541
+
542
+ // API: Delete File
543
+ .post(
544
+ '/api/delete',
545
+ async ({ body, set }) => {
546
+ try {
547
+ const { key } = body
548
+ if (!key) throw new Error('Key is required')
549
+ await deleteFile(key)
550
+ return { status: 'success', message: `Deleted ${key}` }
551
+ } catch (error) {
552
+ set.status = 500
553
+ return { status: 'error', message: error.message }
554
+ }
555
+ },
556
+ {
557
+ body: t.Object({
558
+ key: t.String(),
559
+ }),
560
+ }
561
+ )
562
+
563
+ // API: Update Config
564
+ .post(
565
+ '/api/config',
566
+ async ({ body }) => {
567
+ let newConfig = { ...body }
568
+ if (typeof newConfig.extensions === 'string') {
569
+ newConfig.extensions = newConfig.extensions
570
+ .split(',')
571
+ .map(e => e.trim())
572
+ .filter(Boolean)
573
+ }
574
+ config = { ...config, ...newConfig }
575
+
576
+ try {
577
+ await writeFile(configPath, JSON.stringify(config, null, 2))
578
+ } catch (e) {
579
+ console.error('Failed to save config:', e)
580
+ }
581
+
582
+ setupCron()
583
+ return {
584
+ status: 'success',
585
+ config: { ...config, secretAccessKey: '***' },
586
+ jobStatus: getJobStatus(),
587
+ }
588
+ },
589
+ {
590
+ body: t.Object({
591
+ bucket: t.String(),
592
+ endpoint: t.String(),
593
+ sourceDir: t.String(),
594
+ prefix: t.Optional(t.String()),
595
+ extensions: t.Optional(t.Union([t.Array(t.String()), t.String()])),
596
+ accessKeyId: t.String(),
597
+ secretAccessKey: t.String(),
598
+ cronSchedule: t.Optional(t.String()),
599
+ cronEnabled: t.Optional(t.Boolean()),
600
+ }),
601
+ }
602
+ )
5
603
 
6
- export { r2Backup } from "./elysia-backup.js";
604
+ // UI: Dashboard
605
+ .get('/', () => {
606
+ const jobStatus = getJobStatus()
607
+ const hasAuth = !!(config.auth && config.auth.username && config.auth.password)
608
+ return DashboardPage({ config, jobStatus, hasAuth })
609
+ })
610
+ )
611
+ })
612
+ }
@@ -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
+ }