@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.
@@ -1,1736 +0,0 @@
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
- // Session Management
15
- const sessions = new Map()
16
-
17
- /**
18
- * Generate a secure random session token
19
- */
20
- const generateSessionToken = () => {
21
- const array = new Uint8Array(32)
22
- crypto.getRandomValues(array)
23
- return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
24
- }
25
-
26
- /**
27
- * Create a new session for a user
28
- */
29
- const createSession = (username, sessionDuration = 24 * 60 * 60 * 1000) => {
30
- const token = generateSessionToken()
31
- const expiresAt = Date.now() + sessionDuration
32
- sessions.set(token, { username, expiresAt })
33
- return { token, expiresAt }
34
- }
35
-
36
- /**
37
- * Validate and return session data
38
- */
39
- const getSession = token => {
40
- if (!token) return null
41
- const session = sessions.get(token)
42
- if (!session) return null
43
- if (Date.now() > session.expiresAt) {
44
- sessions.delete(token)
45
- return null
46
- }
47
- return session
48
- }
49
-
50
- /**
51
- * Delete a session
52
- */
53
- const deleteSession = token => {
54
- if (token) {
55
- sessions.delete(token)
56
- }
57
- }
58
-
59
- /**
60
- * Elysia Plugin for R2/S3 Backup with UI (using native Bun.s3)
61
- *
62
- * @param {Object} config
63
- * @param {string} config.bucket - The R2/S3 bucket name
64
- * @param {string} config.accessKeyId - R2/S3 Access Key ID
65
- * @param {string} config.secretAccessKey - R2/S3 Secret Access Key
66
- * @param {string} config.endpoint - R2/S3 Endpoint URL
67
- * @param {string} config.sourceDir - Local directory to backup
68
- * @param {string} [config.prefix] - Optional prefix for S3 keys (e.g. 'backups/')
69
- * @param {string} [config.cronSchedule] - Cron schedule expression
70
- * @param {boolean} [config.cronEnabled] - Whether the cron schedule is enabled
71
- * @param {string} [config.configPath] - Path to save runtime configuration (default: './config.json')
72
- */
73
- export const r2Backup = initialConfig => app => {
74
- // State to hold runtime configuration (allows UI updates)
75
- const configPath = initialConfig.configPath || './config.json'
76
-
77
- // Load saved config if exists
78
- let savedConfig = {}
79
- if (existsSync(configPath)) {
80
- try {
81
- // We use synchronous read here or just init with promise (but top level await is tricky in plugins depending on usage)
82
- // For simplicity in this context, we'll rely on the fact that this runs once on startup.
83
- // However, since we are in a function, we can't easily do async await at top level unless the plugin is async.
84
- // Elysia plugins can be async.
85
- // Using readFileSync for startup config loading to ensure it's ready.
86
- const fileContent = readFileSync(configPath, 'utf-8')
87
- savedConfig = JSON.parse(fileContent)
88
- console.log('Loaded backup config from', configPath)
89
- } catch (e) {
90
- console.error('Failed to load backup config:', e)
91
- }
92
- }
93
-
94
- let config = { ...initialConfig, ...savedConfig }
95
- let backupJob = null
96
-
97
- const getS3Client = () => {
98
- // Debug config (masked)
99
- console.log('S3 Config:', {
100
- bucket: config.bucket,
101
- endpoint: config.endpoint,
102
- accessKeyId: config.accessKeyId ? '***' + config.accessKeyId.slice(-4) : 'missing',
103
- hasSecret: !!config.secretAccessKey,
104
- })
105
-
106
- return new S3Client({
107
- accessKeyId: config.accessKeyId,
108
- secretAccessKey: config.secretAccessKey,
109
- endpoint: config.endpoint,
110
- bucket: config.bucket,
111
- region: 'auto',
112
- // R2 requires specific region handling or defaults.
113
- // Bun S3 defaults to us-east-1 which is usually fine for R2 if endpoint is correct.
114
- })
115
- }
116
-
117
- const uploadFile = async (filePath, rootDir, timestampPrefix) => {
118
- const s3 = getS3Client()
119
- const fileContent = await readFile(filePath)
120
-
121
- const relativePath = relative(rootDir, filePath)
122
- const dir = dirname(relativePath)
123
- const filename = relativePath.split('/').pop() // or basename
124
-
125
- // Format: YYYY-MM-DD_HH-mm-ss_filename.ext (or ISO if not provided)
126
- const timestamp = timestampPrefix || new Date().toISOString()
127
- const newFilename = `${timestamp}_${filename}`
128
-
129
- // Reconstruct path with new filename
130
- const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
131
-
132
- const key = config.prefix ? join(config.prefix, finalPath) : finalPath
133
-
134
- console.log(`Uploading ${key}...`)
135
-
136
- // Bun.s3 API: s3.write(key, data)
137
- await s3.write(key, fileContent)
138
- }
139
-
140
- const processDirectory = async (dir, timestampPrefix) => {
141
- const files = await readdir(dir)
142
- for (const file of files) {
143
- const fullPath = join(dir, file)
144
- const stats = await stat(fullPath)
145
- if (stats.isDirectory()) {
146
- await processDirectory(fullPath, timestampPrefix)
147
- } else {
148
- // Filter by extension
149
- const allowedExtensions = config.extensions || []
150
- const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
151
-
152
- // Skip files that look like backups to prevent recursion/duplication
153
- // Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
154
- 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)_/
155
- if (timestampRegex.test(file)) {
156
- console.log(`Skipping backup-like file: ${file}`)
157
- continue
158
- }
159
-
160
- if (allowedExtensions.length === 0 || hasExtension) {
161
- await uploadFile(fullPath, config.sourceDir, timestampPrefix)
162
- }
163
- }
164
- }
165
- }
166
-
167
- const setupCron = () => {
168
- if (backupJob) {
169
- backupJob.stop()
170
- backupJob = null
171
- }
172
-
173
- if (config.cronSchedule && config.cronEnabled !== false) {
174
- console.log(`Setting up backup cron: ${config.cronSchedule}`)
175
- try {
176
- backupJob = new CronJob(
177
- config.cronSchedule,
178
- async () => {
179
- console.log('Running scheduled backup...')
180
- try {
181
- // Generate timestamp similar to manual run
182
- const now = new Date()
183
- const timestamp =
184
- now.getFullYear() +
185
- '-' +
186
- String(now.getMonth() + 1).padStart(2, '0') +
187
- '-' +
188
- String(now.getDate()).padStart(2, '0') +
189
- '_' +
190
- String(now.getHours()).padStart(2, '0') +
191
- '-' +
192
- String(now.getMinutes()).padStart(2, '0') +
193
- '-' +
194
- String(now.getSeconds()).padStart(2, '0')
195
-
196
- await processDirectory(config.sourceDir, timestamp)
197
- console.log('Scheduled backup completed')
198
- } catch (e) {
199
- console.error('Scheduled backup failed:', e)
200
- }
201
- },
202
- null,
203
- true // start
204
- )
205
- } catch (e) {
206
- console.error('Invalid cron schedule:', e.message)
207
- }
208
- }
209
- }
210
-
211
- // Initialize cron
212
- setupCron()
213
-
214
- const listRemoteFiles = async () => {
215
- const s3 = getS3Client()
216
- // Bun.s3 list API
217
- // Returns a Promise that resolves to the list of files (or object with contents)
218
- // Based on Bun docs/behavior, list() returns an array of S3File-like objects or similar structure
219
- try {
220
- const response = await s3.list({ prefix: config.prefix || '' })
221
- // If response is array
222
- if (Array.isArray(response)) {
223
- return response.map(f => ({
224
- Key: f.key || f.name, // Handle potential property names
225
- Size: f.size,
226
- LastModified: f.lastModified,
227
- }))
228
- }
229
- // If response has contents (AWS-like)
230
- if (response.contents) {
231
- return response.contents.map(f => ({
232
- Key: f.key,
233
- Size: f.size,
234
- LastModified: f.lastModified,
235
- }))
236
- }
237
- console.log('Unknown list response structure:', response)
238
- return []
239
- } catch (e) {
240
- console.error('Error listing files with Bun.s3:', e)
241
- return []
242
- }
243
- }
244
-
245
- const restoreFile = async key => {
246
- const s3 = getS3Client()
247
- const file = s3.file(key)
248
-
249
- if (!(await file.exists())) {
250
- throw new Error(`File ${key} not found in bucket`)
251
- }
252
-
253
- const arrayBuffer = await file.arrayBuffer()
254
- const byteArray = new Uint8Array(arrayBuffer)
255
-
256
- // Determine local path
257
- // Remove prefix from key to get relative path
258
- const relativePath = config.prefix ? key.replace(config.prefix, '') : key
259
- // Clean leading slashes if any
260
- const cleanRelative = relativePath.replace(/^[\/\\]/, '')
261
-
262
- // Extract directory and filename
263
- const dir = dirname(cleanRelative)
264
- const filename = cleanRelative.split('/').pop()
265
-
266
- // Strip timestamp prefix if present to restore original filename
267
- // Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
268
- 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)_/
269
- const originalFilename = filename.replace(timestampRegex, '')
270
-
271
- const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
272
- const localPath = join(config.sourceDir, finalLocalRelativePath)
273
-
274
- // Ensure directory exists
275
- await mkdir(dirname(localPath), { recursive: true })
276
- await writeFile(localPath, byteArray)
277
- return localPath
278
- }
279
-
280
- const deleteFile = async key => {
281
- const s3 = getS3Client()
282
- // Bun S3 client delete
283
- await s3.delete(key)
284
- }
285
-
286
- const getJobStatus = () => {
287
- const isRunning = !!backupJob && config.cronEnabled !== false
288
- let nextRun = null
289
- if (isRunning && backupJob) {
290
- try {
291
- const nextDate = backupJob.nextDate()
292
- if (nextDate) {
293
- // cron returns Luxon DateTime, convert to JS Date for consistent ISO string
294
- nextRun = nextDate.toJSDate().toISOString()
295
- }
296
- } catch (e) {
297
- console.error('Error getting next date', e)
298
- }
299
- }
300
- return { isRunning, nextRun }
301
- }
302
-
303
- return app.use(html()).group('/backup', app => {
304
- // Authentication Middleware
305
- const authMiddleware = context => {
306
- // Only bypass auth if no credentials are configured at all
307
- if (!config.auth || !config.auth.username || !config.auth.password) {
308
- return
309
- }
310
-
311
- const path = context.path
312
-
313
- // Allow access to login page and auth endpoints without authentication
314
- if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout') {
315
- return
316
- }
317
-
318
- // Check session cookie
319
- const cookies = context.headers.cookie || ''
320
- const sessionMatch = cookies.match(/backup-session=([^;]+)/)
321
- const sessionToken = sessionMatch ? sessionMatch[1] : null
322
-
323
- const session = getSession(sessionToken)
324
-
325
- if (!session) {
326
- // Redirect to login page
327
- context.set.status = 302
328
- context.set.headers['Location'] = '/backup/login'
329
- return new Response('Redirecting to login', {
330
- status: 302,
331
- headers: { Location: '/backup/login' },
332
- })
333
- }
334
- }
335
-
336
- return (
337
- app
338
- .onBeforeHandle(authMiddleware)
339
-
340
- // AUTH: Login Page
341
- .get('/login', ({ set }) => {
342
- // If no auth credentials configured, redirect to dashboard
343
- if (!config.auth || !config.auth.username || !config.auth.password) {
344
- set.status = 302
345
- set.headers['Location'] = '/backup'
346
- return
347
- }
348
-
349
- set.headers['Content-Type'] = 'text/html; charset=utf8'
350
- return `
351
- <!DOCTYPE html>
352
- <html lang="en">
353
- <head>
354
- <meta charset="UTF-8">
355
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
356
- <title>Login - Backup Manager</title>
357
- <script src="https://cdn.tailwindcss.com"></script>
358
- <script>
359
- tailwind.config = {
360
- theme: {
361
- extend: {
362
- fontFamily: {
363
- sans: ['Montserrat', 'sans-serif'],
364
- }
365
- }
366
- }
367
- }
368
- </script>
369
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
370
- <script src="https://unpkg.com/lucide@latest"></script>
371
- <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
372
- <style>
373
- [x-cloak] { display: none !important; }
374
- </style>
375
- </head>
376
- <body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
377
- <div class="w-full max-w-md" x-data="loginApp()">
378
- <!-- Login Card -->
379
- <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_8px_30px_rgba(0,0,0,0.08)] overflow-hidden">
380
- <!-- Header -->
381
- <div class="p-10 text-center border-b border-gray-100">
382
- <div class="w-16 h-16 bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-4">
383
- <i data-lucide="shield-check" class="w-8 h-8 text-white"></i>
384
- </div>
385
- <h1 class="text-2xl font-bold text-gray-900 mb-2">Backup Manager</h1>
386
- <p class="text-sm text-gray-500">Access Control Panel</p>
387
- </div>
388
-
389
- <!-- Form -->
390
- <div class="p-10">
391
- <form @submit.prevent="login" class="space-y-6">
392
- <!-- Error Message -->
393
- <div x-show="error" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-4">
394
- <div class="flex items-center gap-3">
395
- <i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
396
- <span class="text-sm text-red-800 font-medium" x-text="error"></span>
397
- </div>
398
- </div>
399
-
400
- <!-- Username -->
401
- <div>
402
- <label class="block text-sm font-semibold text-gray-700 mb-2">Username</label>
403
- <div class="relative">
404
- <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
405
- <i data-lucide="user" class="w-5 h-5 text-gray-400"></i>
406
- </div>
407
- <input
408
- type="text"
409
- x-model="username"
410
- required
411
- class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium"
412
- placeholder="Enter your username"
413
- autofocus
414
- >
415
- </div>
416
- </div>
417
-
418
- <!-- Password -->
419
- <div>
420
- <label class="block text-sm font-semibold text-gray-700 mb-2">Password</label>
421
- <div class="relative">
422
- <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
423
- <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
424
- </div>
425
- <input
426
- type="password"
427
- x-model="password"
428
- required
429
- class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium"
430
- placeholder="Enter your password"
431
- >
432
- </div>
433
- </div>
434
-
435
- <!-- TOTP Code (only shown when TOTP is enabled) -->
436
- <div x-show="totpEnabled" x-cloak>
437
- <label class="block text-sm font-semibold text-gray-700 mb-2">Authenticator Code</label>
438
- <div class="relative">
439
- <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
440
- <i data-lucide="smartphone" class="w-5 h-5 text-gray-400"></i>
441
- </div>
442
- <input
443
- type="text"
444
- x-model="totpCode"
445
- inputmode="numeric"
446
- pattern="[0-9]*"
447
- maxlength="6"
448
- :required="totpEnabled"
449
- class="w-full bg-gray-50 border border-gray-200 rounded-lg pl-12 pr-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
450
- placeholder="000000"
451
- >
452
- </div>
453
- <p class="text-xs text-gray-500 mt-2 flex items-center gap-1">
454
- <i data-lucide="info" class="w-3 h-3"></i>
455
- Enter the 6-digit code from your authenticator app
456
- </p>
457
- </div>
458
-
459
- <!-- Submit Button -->
460
- <button
461
- type="submit"
462
- :disabled="loading"
463
- class="w-full bg-gray-900 hover:bg-gray-800 text-white font-bold py-3.5 px-6 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
464
- >
465
- <span x-show="!loading" class="flex items-center gap-2">
466
- <span>Sign In</span>
467
- <i data-lucide="arrow-right" class="w-5 h-5"></i>
468
- </span>
469
- <span x-show="loading" class="flex items-center gap-2">
470
- <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
471
- <span>Authenticating...</span>
472
- </span>
473
- </button>
474
- </form>
475
- </div>
476
- </div>
477
-
478
- <!-- Footer -->
479
- <div class="text-center mt-6 text-sm text-gray-500">
480
- <i data-lucide="info" class="w-4 h-4 inline-block mr-1"></i>
481
- Secure connection required for production use
482
- </div>
483
- </div>
484
-
485
- <script>
486
- document.addEventListener('alpine:init', () => {
487
- Alpine.data('loginApp', () => ({
488
- username: '',
489
- password: '',
490
- totpCode: '',
491
- totpEnabled: ${config.auth?.totpSecret ? 'true' : 'false'},
492
- loading: false,
493
- error: '',
494
-
495
- init() {
496
- this.$nextTick(() => lucide.createIcons());
497
- },
498
-
499
- async login() {
500
- this.loading = true;
501
- this.error = '';
502
-
503
- try {
504
- const payload = {
505
- username: this.username,
506
- password: this.password
507
- };
508
-
509
- if (this.totpEnabled && this.totpCode) {
510
- payload.totpCode = this.totpCode;
511
- }
512
-
513
- const response = await fetch('/backup/auth/login', {
514
- method: 'POST',
515
- headers: { 'Content-Type': 'application/json' },
516
- body: JSON.stringify(payload)
517
- });
518
-
519
- const data = await response.json();
520
-
521
- if (response.ok && data.status === 'success') {
522
- // Redirect to dashboard
523
- window.location.href = '/backup';
524
- } else {
525
- this.error = data.message || 'Invalid credentials';
526
- this.$nextTick(() => lucide.createIcons());
527
- }
528
- } catch (err) {
529
- this.error = 'Connection failed. Please try again.';
530
- this.$nextTick(() => lucide.createIcons());
531
- } finally {
532
- this.loading = false;
533
- this.$nextTick(() => lucide.createIcons());
534
- }
535
- }
536
- }));
537
- });
538
- </script>
539
- </body>
540
- </html>
541
- `
542
- })
543
-
544
- // AUTH: Login Endpoint
545
- .post(
546
- '/auth/login',
547
- async ({ body, set }) => {
548
- // Check if auth credentials are configured
549
- if (!config.auth || !config.auth.username || !config.auth.password) {
550
- set.status = 403
551
- return {
552
- status: 'error',
553
- message: 'Authentication is not configured',
554
- }
555
- }
556
-
557
- const { username, password, totpCode } = body
558
-
559
- // Validate credentials
560
- if (username === config.auth.username && password === config.auth.password) {
561
- // Validate TOTP if configured
562
- if (config.auth.totpSecret) {
563
- if (!totpCode) {
564
- set.status = 401
565
- return { status: 'error', message: 'Authenticator code is required' }
566
- }
567
-
568
- const isValidTotp = authenticator.check(totpCode, config.auth.totpSecret)
569
- if (!isValidTotp) {
570
- set.status = 401
571
- return { status: 'error', message: 'Invalid authenticator code' }
572
- }
573
- }
574
-
575
- // Create session
576
- const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000 // 24h default
577
- const { token, expiresAt } = createSession(username, sessionDuration)
578
-
579
- // Set cookie
580
- const expiresDate = new Date(expiresAt)
581
- set.headers['Set-Cookie'] = `backup-session=${token}; Path=/backup; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
582
-
583
- return { status: 'success', message: 'Login successful' }
584
- } else {
585
- set.status = 401
586
- return { status: 'error', message: 'Invalid username or password' }
587
- }
588
- },
589
- {
590
- body: t.Object({
591
- username: t.String(),
592
- password: t.String(),
593
- totpCode: t.Optional(t.String()),
594
- }),
595
- }
596
- )
597
-
598
- // AUTH: Logout Endpoint
599
- .post('/auth/logout', ({ headers, set }) => {
600
- const cookies = headers.cookie || ''
601
- const sessionMatch = cookies.match(/backup-session=([^;]+)/)
602
- const sessionToken = sessionMatch ? sessionMatch[1] : null
603
-
604
- if (sessionToken) {
605
- deleteSession(sessionToken)
606
- }
607
-
608
- // Clear cookie
609
- set.headers['Set-Cookie'] = `backup-session=; Path=/backup; HttpOnly; SameSite=Lax; Max-Age=0`
610
-
611
- return { status: 'success', message: 'Logged out successfully' }
612
- })
613
-
614
- // TOTP: Get Status
615
- .get('/api/totp/status', () => {
616
- return {
617
- enabled: !!config.auth?.totpSecret,
618
- }
619
- })
620
-
621
- // TOTP: Generate new secret and QR code
622
- .post('/api/totp/generate', async () => {
623
- const secret = authenticator.generateSecret()
624
- const serviceName = config.serviceName || 'Backup Manager'
625
- const accountName = config.auth?.username || 'admin'
626
-
627
- const otpauth = authenticator.keyuri(accountName, serviceName, secret)
628
- const qrCodeDataUrl = await QRCode.toDataURL(otpauth)
629
-
630
- return {
631
- status: 'success',
632
- secret,
633
- qrCode: qrCodeDataUrl,
634
- otpauth,
635
- }
636
- })
637
-
638
- // TOTP: Verify and save
639
- .post(
640
- '/api/totp/verify',
641
- async ({ body, set }) => {
642
- const { secret, code } = body
643
-
644
- // Verify the code is valid
645
- const isValid = authenticator.check(code, secret)
646
-
647
- if (!isValid) {
648
- set.status = 400
649
- return { status: 'error', message: 'Invalid code. Please try again.' }
650
- }
651
-
652
- // Save the secret to config
653
- config.auth = config.auth || {}
654
- config.auth.totpSecret = secret
655
-
656
- // Persist config
657
- try {
658
- await writeFile(configPath, JSON.stringify(config, null, 2))
659
- } catch (e) {
660
- console.error('Failed to save TOTP config:', e)
661
- set.status = 500
662
- return { status: 'error', message: 'Failed to save configuration' }
663
- }
664
-
665
- return { status: 'success', message: 'Two-factor authentication enabled successfully' }
666
- },
667
- {
668
- body: t.Object({
669
- secret: t.String(),
670
- code: t.String(),
671
- }),
672
- }
673
- )
674
-
675
- // TOTP: Disable
676
- .post(
677
- '/api/totp/disable',
678
- async ({ body, set }) => {
679
- const { code } = body
680
-
681
- // Require valid TOTP code to disable
682
- if (config.auth?.totpSecret) {
683
- const isValid = authenticator.check(code, config.auth.totpSecret)
684
- if (!isValid) {
685
- set.status = 400
686
- return { status: 'error', message: 'Invalid code. Please enter your current authenticator code.' }
687
- }
688
- }
689
-
690
- // Remove TOTP secret from config
691
- if (config.auth) {
692
- delete config.auth.totpSecret
693
- }
694
-
695
- // Persist config
696
- try {
697
- await writeFile(configPath, JSON.stringify(config, null, 2))
698
- } catch (e) {
699
- console.error('Failed to save config:', e)
700
- set.status = 500
701
- return { status: 'error', message: 'Failed to save configuration' }
702
- }
703
-
704
- return { status: 'success', message: 'Two-factor authentication disabled' }
705
- },
706
- {
707
- body: t.Object({
708
- code: t.String(),
709
- }),
710
- }
711
- )
712
-
713
- // API: Run Backup
714
- .post(
715
- '/api/run',
716
- async ({ body, set }) => {
717
- try {
718
- const { timestamp } = body || {}
719
- console.log(`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`)
720
- await processDirectory(config.sourceDir, timestamp)
721
- return {
722
- status: 'success',
723
- message: 'Backup completed successfully',
724
- timestamp: new Date().toISOString(),
725
- }
726
- } catch (error) {
727
- console.error('Backup failed:', error)
728
- set.status = 500
729
- return { status: 'error', message: error.message }
730
- }
731
- },
732
- {
733
- body: t.Optional(
734
- t.Object({
735
- timestamp: t.Optional(t.String()),
736
- })
737
- ),
738
- }
739
- )
740
-
741
- // API: List Files
742
- .get('/api/files', async ({ set }) => {
743
- try {
744
- const files = await listRemoteFiles()
745
- return {
746
- files: files.map(f => ({
747
- key: f.Key,
748
- size: f.Size,
749
- lastModified: f.LastModified,
750
- })),
751
- }
752
- } catch (error) {
753
- set.status = 500
754
- return { status: 'error', message: error.message }
755
- }
756
- })
757
-
758
- // API: Restore File
759
- .post(
760
- '/api/restore',
761
- async ({ body, set }) => {
762
- try {
763
- const { key } = body
764
- if (!key) throw new Error('Key is required')
765
- const localPath = await restoreFile(key)
766
- return { status: 'success', message: `Restored to ${localPath}` }
767
- } catch (error) {
768
- set.status = 500
769
- return { status: 'error', message: error.message }
770
- }
771
- },
772
- {
773
- body: t.Object({
774
- key: t.String(),
775
- }),
776
- }
777
- )
778
-
779
- // API: Delete File
780
- .post(
781
- '/api/delete',
782
- async ({ body, set }) => {
783
- try {
784
- const { key } = body
785
- if (!key) throw new Error('Key is required')
786
- await deleteFile(key)
787
- return { status: 'success', message: `Deleted ${key}` }
788
- } catch (error) {
789
- set.status = 500
790
- return { status: 'error', message: error.message }
791
- }
792
- },
793
- {
794
- body: t.Object({
795
- key: t.String(),
796
- }),
797
- }
798
- )
799
-
800
- // API: Update Config
801
- .post(
802
- '/api/config',
803
- async ({ body }) => {
804
- // Handle extensions if passed as string
805
- let newConfig = { ...body }
806
- if (typeof newConfig.extensions === 'string') {
807
- newConfig.extensions = newConfig.extensions
808
- .split(',')
809
- .map(e => e.trim())
810
- .filter(Boolean)
811
- }
812
- config = { ...config, ...newConfig }
813
-
814
- // Persist config
815
- try {
816
- // Don't save secrets in plain text if possible, but for this simple tool we might have to
817
- // or just save the non-env parts.
818
- // For now, we save everything that overrides the defaults.
819
- await writeFile(configPath, JSON.stringify(config, null, 2))
820
- } catch (e) {
821
- console.error('Failed to save config:', e)
822
- }
823
-
824
- setupCron() // Restart cron with new config
825
- return {
826
- status: 'success',
827
- config: { ...config, secretAccessKey: '***' },
828
- jobStatus: getJobStatus(),
829
- }
830
- },
831
- {
832
- body: t.Object({
833
- bucket: t.String(),
834
- endpoint: t.String(),
835
- sourceDir: t.String(),
836
- prefix: t.Optional(t.String()),
837
- extensions: t.Optional(t.Union([t.Array(t.String()), t.String()])), // Allow array or comma-separated string
838
- accessKeyId: t.String(),
839
- secretAccessKey: t.String(),
840
- cronSchedule: t.Optional(t.String()),
841
- cronEnabled: t.Optional(t.Boolean()),
842
- }),
843
- }
844
- )
845
-
846
- // UI: Dashboard
847
- .get('/', ({ set }) => {
848
- set.headers['Content-Type'] = 'text/html; charset=utf8'
849
- const jobStatus = getJobStatus()
850
- return `
851
- <!DOCTYPE html>
852
- <html lang="en">
853
- <head>
854
- <meta charset="UTF-8">
855
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
856
- <title>R2 Backup Manager</title>
857
- <script src="https://cdn.tailwindcss.com"></script>
858
- <script>
859
- tailwind.config = {
860
- theme: {
861
- extend: {
862
- fontFamily: {
863
- sans: ['Montserrat', 'sans-serif'],
864
- },
865
- colors: {
866
- gray: {
867
- 50: '#F9FAFB',
868
- 100: '#F3F4F6',
869
- 200: '#E5E7EB',
870
- 300: '#D1D5DB',
871
- 400: '#9CA3AF',
872
- 500: '#6B7280',
873
- 600: '#4B5563',
874
- 700: '#374151',
875
- 800: '#1F2937',
876
- 900: '#111827',
877
- }
878
- }
879
- }
880
- }
881
- }
882
- </script>
883
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
884
- <script src="https://unpkg.com/lucide@latest"></script>
885
- <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
886
- <style>
887
- [x-cloak] { display: none !important; }
888
- </style>
889
- </head>
890
- <body class="bg-gray-50 text-gray-900 min-h-screen p-12 antialiased selection:bg-gray-900 selection:text-white">
891
- <div class="max-w-5xl mx-auto" x-data="backupApp()">
892
- <!-- Header -->
893
- <div class="flex flex-col md:flex-row md:items-end justify-between mb-16 gap-6">
894
- <div>
895
- <h6 class="text-xs font-bold tracking-widest text-gray-500 uppercase mb-2 flex items-center gap-2">
896
- <i data-lucide="shield-check" class="w-4 h-4"></i>
897
- System Administration
898
- </h6>
899
- <h1 class="text-4xl font-bold text-gray-900 tracking-tight flex items-center gap-3">
900
- Backup Manager
901
- </h1>
902
- </div>
903
-
904
- <div class="flex items-center gap-4">
905
- <!-- Tabs -->
906
- <div class="flex p-1 bg-gray-200/50 rounded-xl">
907
- <button @click="activeTab = 'dashboard'; $nextTick(() => lucide.createIcons())"
908
- :class="activeTab === 'dashboard' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
909
- class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
910
- <i data-lucide="layout-dashboard" class="w-4 h-4"></i>
911
- Overview
912
- </button>
913
- <button @click="activeTab = 'files'; fetchFiles()"
914
- :class="activeTab === 'files' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
915
- class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
916
- <i data-lucide="folder-open" class="w-4 h-4"></i>
917
- Files & Restore
918
- </button>
919
- <button @click="activeTab = 'settings'; $nextTick(() => lucide.createIcons())"
920
- :class="activeTab === 'settings' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
921
- class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
922
- <i data-lucide="settings" class="w-4 h-4"></i>
923
- Settings
924
- </button>
925
- </div>
926
-
927
- ${
928
- config.auth && config.auth.username && config.auth.password
929
- ? `
930
- <!-- Logout Button -->
931
- <button @click="logout" class="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold text-sm rounded-lg transition-all">
932
- <i data-lucide="log-out" class="w-4 h-4"></i>
933
- <span>Logout</span>
934
- </button>
935
- `
936
- : ''
937
- }
938
- </div>
939
- </div>
940
-
941
- <!-- Dashboard Tab -->
942
- <div x-show="activeTab === 'dashboard'" class="space-y-12"
943
- x-transition:enter="transition ease-out duration-300"
944
- x-transition:enter-start="opacity-0 translate-y-2"
945
- x-transition:enter-end="opacity-100 translate-y-0">
946
-
947
- <!-- Status Cards -->
948
- <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
949
- <div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
950
- <div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-2">
951
- <i data-lucide="folder" class="w-4 h-4"></i>
952
- Local Source
953
- </div>
954
- <div class="text-gray-900 font-semibold truncate" x-text="config.sourceDir"></div>
955
- </div>
956
- <div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
957
- <div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-2">
958
- <i data-lucide="cloud" class="w-4 h-4"></i>
959
- Target Bucket
960
- </div>
961
- <div class="text-gray-900 font-semibold truncate" x-text="config.bucket"></div>
962
- </div>
963
- <div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
964
- <div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-2">
965
- <i data-lucide="clock" class="w-4 h-4"></i>
966
- Last Backup
967
- </div>
968
- <div class="text-gray-900 font-semibold" x-text="lastBackup || 'No backup recorded'"></div>
969
- </div>
970
- <div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] relative overflow-hidden group">
971
- <div class="text-gray-500 text-sm font-medium mb-3 flex items-center gap-2">
972
- <i data-lucide="calendar-clock" class="w-4 h-4"></i>
973
- Schedule Status
974
- </div>
975
-
976
- <template x-if="cronStatus.isRunning">
977
- <div class="flex items-center gap-3">
978
- <span class="relative flex h-2.5 w-2.5 shrink-0" title="Active">
979
- <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
980
- <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
981
- </span>
982
- <div class="flex items-baseline gap-1.5 min-w-0">
983
- <span class="text-gray-900 font-bold text-xs font-mono truncate" x-text="new Date(cronStatus.nextRun).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium' })"></span>
984
- </div>
985
- </div>
986
- </template>
987
-
988
- <template x-if="!cronStatus.isRunning">
989
- <div class="flex items-center gap-3">
990
- <span class="h-2.5 w-2.5 rounded-full bg-gray-300 shrink-0" title="Stopped"></span>
991
- <span class="text-gray-400 text-sm italic">Scheduler paused</span>
992
- </div>
993
- </template>
994
- </div>
995
- </div>
996
-
997
- <!-- Action Area -->
998
- <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden">
999
- <div class="p-10 flex flex-col items-center justify-center text-center">
1000
- <div class="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-6 text-gray-400">
1001
- <i data-lucide="save" class="w-8 h-8"></i>
1002
- </div>
1003
- <h3 class="text-xl font-bold text-gray-900 mb-2">Trigger Manual Backup</h3>
1004
- <p class="text-gray-500 max-w-md mb-8 leading-relaxed">Initiate a backup of your local directory to the configured R2 bucket. This will upload all files recursively.</p>
1005
-
1006
- <button
1007
- @click="runBackup()"
1008
- :disabled="loading"
1009
- class="group relative inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 bg-gray-900 rounded-xl hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-70 disabled:cursor-not-allowed">
1010
- <span x-show="!loading" class="flex items-center gap-2">
1011
- <i data-lucide="play-circle" class="w-5 h-5"></i>
1012
- Start Backup Process
1013
- </span>
1014
- <span x-show="loading" class="flex items-center gap-2">
1015
- <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
1016
- Processing...
1017
- </span>
1018
- </button>
1019
- </div>
1020
-
1021
- <!-- Logs -->
1022
- <div class="bg-gray-50 border-t border-gray-100 p-8">
1023
- <h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Activity Log</h4>
1024
- <div class="space-y-3">
1025
- <template x-for="log in logs" :key="log.id">
1026
- <div class="flex items-start gap-4 text-sm">
1027
- <span class="font-mono text-xs text-gray-400 mt-0.5" x-text="log.time"></span>
1028
- <div class="flex items-center gap-2">
1029
- <i :data-lucide="log.type === 'error' ? 'alert-circle' : 'info'"
1030
- :class="log.type === 'error' ? 'text-red-600' : 'text-gray-400'"
1031
- class="w-4 h-4"></i>
1032
- <span :class="log.type === 'error' ? 'text-red-600 font-medium' : 'text-gray-600'" x-text="log.message"></span>
1033
- </div>
1034
- </div>
1035
- </template>
1036
- <div x-show="logs.length === 0" class="text-gray-400 text-sm italic">No recent activity recorded.</div>
1037
- </div>
1038
- </div>
1039
- </div>
1040
- </div>
1041
-
1042
- <!-- Files Tab -->
1043
- <div x-show="activeTab === 'files'" class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden"
1044
- x-transition:enter="transition ease-out duration-300"
1045
- x-transition:enter-start="opacity-0 translate-y-2"
1046
- x-transition:enter-end="opacity-100 translate-y-0">
1047
- <div class="p-8 border-b border-gray-100 flex justify-between items-center">
1048
- <div>
1049
- <h2 class="text-xl font-bold text-gray-900 flex items-center gap-2">
1050
- <i data-lucide="server" class="w-5 h-5 text-gray-400"></i>
1051
- Remote Files
1052
- </h2>
1053
- <p class="text-gray-500 text-sm mt-1">Files currently stored in your R2 bucket</p>
1054
- </div>
1055
- <button @click="fetchFiles()" class="text-gray-900 hover:text-gray-600 font-semibold text-sm transition-colors flex items-center gap-2">
1056
- <i data-lucide="refresh-cw" class="w-4 h-4"></i>
1057
- Refresh List
1058
- </button>
1059
- </div>
1060
- <div class="overflow-x-auto">
1061
- <table class="w-full text-left">
1062
- <thead>
1063
- <tr class="border-b border-gray-100">
1064
- <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">File Key</th>
1065
- <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Size</th>
1066
- <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Last Modified</th>
1067
- <th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider text-right">Action</th>
1068
- </tr>
1069
- </thead>
1070
- <template x-for="group in groups" :key="group.name">
1071
- <tbody class="divide-y divide-gray-50 border-b border-gray-100">
1072
- <!-- Folder Header -->
1073
- <tr class="bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors select-none"
1074
- @click="group.expanded = !group.expanded; $nextTick(() => lucide.createIcons())">
1075
- <td colspan="4" class="px-8 py-3">
1076
- <div class="flex items-center gap-3">
1077
- <i :data-lucide="group.expanded ? 'folder-open' : 'folder'" class="w-5 h-5 text-gray-400"></i>
1078
- <span class="font-bold text-gray-700 text-sm" x-text="formatDateHeader(group.name)"></span>
1079
- <span class="text-xs font-medium text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full" x-text="group.files.length"></span>
1080
- </div>
1081
- </td>
1082
- </tr>
1083
-
1084
- <!-- Files in Group -->
1085
- <template x-for="file in group.files" :key="file.key">
1086
- <tr x-show="group.expanded" class="hover:bg-gray-50 transition-colors duration-150 group bg-white">
1087
- <td class="px-8 py-5 font-medium text-gray-900 text-sm pl-12" x-text="file.key"></td>
1088
- <td class="px-8 py-5 text-gray-500 text-sm" x-text="formatBytes(file.size)"></td>
1089
- <td class="px-8 py-5 text-gray-500 text-sm" x-text="new Date(file.lastModified).toLocaleString()"></td>
1090
- <td class="px-8 py-5 text-right">
1091
- <div class="flex justify-end gap-3 items-center">
1092
- <!-- Restore Button -->
1093
- <button
1094
- x-data="holdButton(() => restoreFile(file.key))"
1095
- @mousedown="start()" @touchstart.prevent="start()"
1096
- @mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
1097
- class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors select-none"
1098
- title="Hold 3s to Restore"
1099
- >
1100
- <div class="absolute inset-0 bg-blue-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
1101
- <span class="relative z-10 flex items-center gap-2">
1102
- <i data-lucide="rotate-ccw" class="w-3 h-3"></i>
1103
- <span x-text="progress > 0 ? 'Hold...' : 'Restore'"></span>
1104
- </span>
1105
- </button>
1106
-
1107
- <!-- Delete Button -->
1108
- <button
1109
- x-data="holdButton(() => deleteFile(file.key))"
1110
- @mousedown="start()" @touchstart.prevent="start()"
1111
- @mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
1112
- class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-red-600 bg-red-50 hover:bg-red-100 transition-colors select-none"
1113
- title="Hold 3s to Delete"
1114
- >
1115
- <div class="absolute inset-0 bg-red-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
1116
- <span class="relative z-10 flex items-center gap-2">
1117
- <i data-lucide="trash-2" class="w-3 h-3"></i>
1118
- <span x-text="progress > 0 ? 'Hold...' : 'Delete'"></span>
1119
- </span>
1120
- </button>
1121
- </div>
1122
- </td>
1123
- </tr>
1124
- </template>
1125
- </tbody>
1126
- </template>
1127
-
1128
- <!-- Empty State (outside the loop) -->
1129
- <tbody x-show="groups.length === 0">
1130
- <tr>
1131
- <td colspan="4" class="px-8 py-16 text-center">
1132
- <div class="flex flex-col items-center justify-center">
1133
- <span x-show="!loadingFiles" class="text-gray-500 font-medium flex flex-col items-center gap-2">
1134
- <i data-lucide="inbox" class="w-8 h-8 text-gray-300"></i>
1135
- No files found in bucket
1136
- </span>
1137
- <span x-show="loadingFiles" class="text-gray-500 font-medium animate-pulse flex flex-col items-center gap-2">
1138
- <i data-lucide="loader" class="w-8 h-8 animate-spin text-gray-300"></i>
1139
- Loading remote files...
1140
- </span>
1141
- </div>
1142
- </td>
1143
- </tr>
1144
- </tbody>
1145
- </table>
1146
- </div>
1147
- </div>
1148
-
1149
- <!-- Settings Tab -->
1150
- <div x-show="activeTab === 'settings'" class="max-w-3xl mx-auto"
1151
- x-transition:enter="transition ease-out duration-300"
1152
- x-transition:enter-start="opacity-0 translate-y-2"
1153
- x-transition:enter-end="opacity-100 translate-y-0">
1154
- <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] p-10">
1155
- <h2 class="text-xl font-bold text-gray-900 mb-8 flex items-center gap-2">
1156
- <i data-lucide="sliders" class="w-5 h-5"></i>
1157
- Configuration
1158
- </h2>
1159
- <form @submit.prevent="saveConfig" class="space-y-8">
1160
- <div class="space-y-6">
1161
- <div>
1162
- <label class="block text-sm font-semibold text-gray-700 mb-2">Endpoint URL</label>
1163
- <input type="text" x-model="configForm.endpoint" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1164
- </div>
1165
- <div class="grid grid-cols-2 gap-6">
1166
- <div>
1167
- <label class="block text-sm font-semibold text-gray-700 mb-2">Bucket Name</label>
1168
- <input type="text" x-model="configForm.bucket" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1169
- </div>
1170
- <div>
1171
- <label class="block text-sm font-semibold text-gray-700 mb-2">Prefix (Folder)</label>
1172
- <input type="text" x-model="configForm.prefix" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1173
- </div>
1174
- </div>
1175
- <div>
1176
- <label class="block text-sm font-semibold text-gray-700 mb-2">Source Directory (Local)</label>
1177
- <input type="text" x-model="configForm.sourceDir" readonly class="w-full bg-gray-100 border border-gray-200 rounded-lg px-4 py-3 text-gray-500 cursor-not-allowed focus:outline-none font-medium">
1178
- <p class="text-xs text-gray-400 mt-1">Defined in server configuration</p>
1179
- </div>
1180
- <div>
1181
- <label class="block text-sm font-semibold text-gray-700 mb-2">Allowed Extensions (comma separated)</label>
1182
- <input type="text" x-model="configForm.extensions" placeholder=".db, .sqlite" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1183
- </div>
1184
- <div>
1185
- <label class="block text-sm font-semibold text-gray-700 mb-2">Cron Schedule</label>
1186
- <div class="flex gap-4 items-center">
1187
- <div class="relative flex-grow">
1188
- <input type="text" x-model="configForm.cronSchedule" placeholder="0 0 * * *" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1189
- <div class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
1190
- <a href="https://crontab.guru/" target="_blank" class="underline hover:text-gray-800">Help</a>
1191
- </div>
1192
- </div>
1193
- <div class="flex gap-2 shrink-0">
1194
- <button
1195
- type="button"
1196
- @click="configForm.cronEnabled = true; saveConfig()"
1197
- :class="configForm.cronEnabled !== false ? 'bg-green-600 text-white shadow-md' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'"
1198
- class="px-4 py-3 rounded-lg font-bold text-sm transition-all flex items-center gap-2"
1199
- >
1200
- <i data-lucide="play" class="w-4 h-4"></i>
1201
- Start
1202
- </button>
1203
- <button
1204
- type="button"
1205
- @click="configForm.cronEnabled = false; saveConfig()"
1206
- :class="configForm.cronEnabled === false ? 'bg-red-600 text-white shadow-md' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'"
1207
- class="px-4 py-3 rounded-lg font-bold text-sm transition-all flex items-center gap-2"
1208
- >
1209
- <i data-lucide="square" class="w-4 h-4"></i>
1210
- Stop
1211
- </button>
1212
- </div>
1213
- </div>
1214
- <p class="text-xs text-gray-400 mt-1">Format: Minute Hour Day Month DayOfWeek (e.g., "0 0 * * *" for daily at midnight)</p>
1215
- </div>
1216
- <div class="grid grid-cols-2 gap-6">
1217
- <div>
1218
- <label class="block text-sm font-semibold text-gray-700 mb-2">Access Key ID</label>
1219
- <input type="text" x-model="configForm.accessKeyId" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1220
- </div>
1221
- <div>
1222
- <label class="block text-sm font-semibold text-gray-700 mb-2">Secret Access Key</label>
1223
- <input type="password" x-model="configForm.secretAccessKey" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
1224
- </div>
1225
- </div>
1226
- </div>
1227
-
1228
- <div class="pt-4 flex justify-end">
1229
- <button type="submit" class="bg-gray-900 hover:bg-gray-800 text-white font-bold py-3 px-8 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center gap-2">
1230
- <i data-lucide="save" class="w-4 h-4"></i>
1231
- Save Changes
1232
- </button>
1233
- </div>
1234
- </form>
1235
- </div>
1236
-
1237
- <!-- Security Section -->
1238
- <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] p-10 mt-8">
1239
- <h2 class="text-xl font-bold text-gray-900 mb-8 flex items-center gap-2">
1240
- <i data-lucide="shield" class="w-5 h-5"></i>
1241
- Security
1242
- </h2>
1243
-
1244
- <!-- TOTP Setup -->
1245
- <div class="space-y-6">
1246
- <div class="flex items-start justify-between">
1247
- <div>
1248
- <h3 class="font-semibold text-gray-900 flex items-center gap-2">
1249
- <i data-lucide="smartphone" class="w-4 h-4"></i>
1250
- Two-Factor Authentication (2FA)
1251
- </h3>
1252
- <p class="text-sm text-gray-500 mt-1">
1253
- Add an extra layer of security using an authenticator app
1254
- </p>
1255
- </div>
1256
- <div x-show="!totpEnabled && !showTotpSetup">
1257
- <button
1258
- @click="generateTotp()"
1259
- class="bg-gray-900 hover:bg-gray-800 text-white font-bold py-2.5 px-5 rounded-lg transition-all flex items-center gap-2"
1260
- >
1261
- <i data-lucide="plus" class="w-4 h-4"></i>
1262
- Enable 2FA
1263
- </button>
1264
- </div>
1265
- <div x-show="totpEnabled && !showTotpSetup">
1266
- <span class="inline-flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-lg font-semibold text-sm">
1267
- <i data-lucide="check-circle" class="w-4 h-4"></i>
1268
- Enabled
1269
- </span>
1270
- </div>
1271
- </div>
1272
-
1273
- <!-- TOTP Setup Flow -->
1274
- <div x-show="showTotpSetup" x-cloak class="border-t border-gray-100 pt-6 mt-6">
1275
- <!-- Loading -->
1276
- <div x-show="totpLoading" class="text-center py-8">
1277
- <i data-lucide="loader-2" class="w-8 h-8 animate-spin text-gray-400 mx-auto"></i>
1278
- <p class="text-sm text-gray-500 mt-2">Generating secure key...</p>
1279
- </div>
1280
-
1281
- <!-- QR Code Display -->
1282
- <div x-show="!totpLoading && totpQrCode" class="space-y-6">
1283
- <div class="bg-gray-50 rounded-xl p-6 text-center">
1284
- <p class="text-sm font-medium text-gray-700 mb-4">
1285
- Scan this QR code with your authenticator app:
1286
- </p>
1287
- <img :src="totpQrCode" alt="TOTP QR Code" class="mx-auto w-48 h-48 rounded-lg shadow-sm">
1288
-
1289
- <div class="mt-4 text-xs text-gray-500">
1290
- <p class="mb-2">Or enter this code manually:</p>
1291
- <code class="bg-white px-3 py-1.5 rounded border border-gray-200 font-mono text-gray-800 select-all" x-text="totpSecret"></code>
1292
- </div>
1293
- </div>
1294
-
1295
- <!-- Verification -->
1296
- <div class="space-y-4">
1297
- <label class="block text-sm font-semibold text-gray-700">
1298
- Enter the 6-digit code from your authenticator app:
1299
- </label>
1300
- <div class="flex gap-4">
1301
- <input
1302
- type="text"
1303
- x-model="totpVerifyCode"
1304
- inputmode="numeric"
1305
- pattern="[0-9]*"
1306
- maxlength="6"
1307
- placeholder="000000"
1308
- class="flex-grow bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
1309
- >
1310
- <button
1311
- @click="verifyTotp()"
1312
- :disabled="totpVerifyCode.length !== 6 || totpVerifying"
1313
- class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
1314
- >
1315
- <template x-if="!totpVerifying">
1316
- <span class="flex items-center gap-2">
1317
- <i data-lucide="check" class="w-4 h-4"></i>
1318
- Verify & Enable
1319
- </span>
1320
- </template>
1321
- <template x-if="totpVerifying">
1322
- <span class="flex items-center gap-2">
1323
- <i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
1324
- Verifying...
1325
- </span>
1326
- </template>
1327
- </button>
1328
- </div>
1329
- <div x-show="totpError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800 flex items-center gap-2">
1330
- <i data-lucide="alert-circle" class="w-4 h-4"></i>
1331
- <span x-text="totpError"></span>
1332
- </div>
1333
- <button
1334
- @click="cancelTotpSetup()"
1335
- class="text-sm text-gray-500 hover:text-gray-700 underline"
1336
- >
1337
- Cancel
1338
- </button>
1339
- </div>
1340
- </div>
1341
- </div>
1342
-
1343
- <!-- Disable 2FA -->
1344
- <div x-show="totpEnabled && !showTotpSetup" x-cloak class="border-t border-gray-100 pt-6 mt-6">
1345
- <div x-show="!showDisableTotp">
1346
- <button
1347
- @click="showDisableTotp = true"
1348
- class="text-sm text-red-600 hover:text-red-700 font-medium flex items-center gap-2"
1349
- >
1350
- <i data-lucide="shield-off" class="w-4 h-4"></i>
1351
- Disable two-factor authentication
1352
- </button>
1353
- </div>
1354
- <div x-show="showDisableTotp" class="space-y-4">
1355
- <p class="text-sm text-gray-600">
1356
- Enter your current authenticator code to disable 2FA:
1357
- </p>
1358
- <div class="flex gap-4">
1359
- <input
1360
- type="text"
1361
- x-model="totpDisableCode"
1362
- inputmode="numeric"
1363
- pattern="[0-9]*"
1364
- maxlength="6"
1365
- placeholder="000000"
1366
- class="flex-grow bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium tracking-widest text-center text-lg"
1367
- >
1368
- <button
1369
- @click="disableTotp()"
1370
- :disabled="totpDisableCode.length !== 6 || totpDisabling"
1371
- class="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
1372
- >
1373
- <template x-if="!totpDisabling">
1374
- <span>Disable 2FA</span>
1375
- </template>
1376
- <template x-if="totpDisabling">
1377
- <span class="flex items-center gap-2">
1378
- <i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i>
1379
- Disabling...
1380
- </span>
1381
- </template>
1382
- </button>
1383
- </div>
1384
- <div x-show="totpError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800 flex items-center gap-2">
1385
- <i data-lucide="alert-circle" class="w-4 h-4"></i>
1386
- <span x-text="totpError"></span>
1387
- </div>
1388
- <button
1389
- @click="showDisableTotp = false; totpDisableCode = ''; totpError = ''"
1390
- class="text-sm text-gray-500 hover:text-gray-700 underline"
1391
- >
1392
- Cancel
1393
- </button>
1394
- </div>
1395
- </div>
1396
- </div>
1397
- </div>
1398
- </div>
1399
- </div>
1400
- <script>
1401
- document.addEventListener('alpine:init', () => {
1402
- Alpine.data('holdButton', (action) => ({
1403
- progress: 0,
1404
- interval: null,
1405
- start() {
1406
- this.progress = 0
1407
- // 3 seconds = 3000ms. Update every 30ms. 100 steps.
1408
- // 100% / 100 steps = 1% per step.
1409
- this.interval = setInterval(() => {
1410
- this.progress += 1
1411
- if (this.progress >= 100) {
1412
- this.trigger()
1413
- }
1414
- }, 30)
1415
- },
1416
- stop() {
1417
- clearInterval(this.interval)
1418
- this.progress = 0
1419
- },
1420
- trigger() {
1421
- this.stop()
1422
- action()
1423
- }
1424
- }))
1425
- })
1426
-
1427
- function backupApp() {
1428
- return {
1429
- activeTab: 'dashboard',
1430
- loading: false,
1431
- loadingFiles: false,
1432
- lastBackup: null,
1433
- files: [],
1434
- groups: [],
1435
- logs: [],
1436
- config: ${JSON.stringify(config)},
1437
- cronStatus: ${JSON.stringify(jobStatus)},
1438
- configForm: { ...${JSON.stringify(config)} },
1439
-
1440
- // TOTP State
1441
- totpEnabled: ${!!config.auth?.totpSecret},
1442
- showTotpSetup: false,
1443
- showDisableTotp: false,
1444
- totpLoading: false,
1445
- totpVerifying: false,
1446
- totpDisabling: false,
1447
- totpSecret: '',
1448
- totpQrCode: '',
1449
- totpVerifyCode: '',
1450
- totpDisableCode: '',
1451
- totpError: '',
1452
-
1453
- init() {
1454
- // Initial load if needed
1455
- this.$nextTick(() => {
1456
- lucide.createIcons()
1457
- })
1458
- },
1459
-
1460
- // TOTP Methods
1461
- async generateTotp() {
1462
- this.showTotpSetup = true;
1463
- this.totpLoading = true;
1464
- this.totpError = '';
1465
- this.$nextTick(() => lucide.createIcons());
1466
-
1467
- try {
1468
- const response = await fetch('/backup/api/totp/generate', { method: 'POST' });
1469
- const data = await response.json();
1470
-
1471
- if (data.status === 'success') {
1472
- this.totpSecret = data.secret;
1473
- this.totpQrCode = data.qrCode;
1474
- } else {
1475
- this.totpError = data.message || 'Failed to generate TOTP';
1476
- }
1477
- } catch (err) {
1478
- this.totpError = 'Connection failed. Please try again.';
1479
- } finally {
1480
- this.totpLoading = false;
1481
- this.$nextTick(() => lucide.createIcons());
1482
- }
1483
- },
1484
-
1485
- async verifyTotp() {
1486
- this.totpVerifying = true;
1487
- this.totpError = '';
1488
-
1489
- try {
1490
- const response = await fetch('/backup/api/totp/verify', {
1491
- method: 'POST',
1492
- headers: { 'Content-Type': 'application/json' },
1493
- body: JSON.stringify({
1494
- secret: this.totpSecret,
1495
- code: this.totpVerifyCode
1496
- })
1497
- });
1498
-
1499
- const data = await response.json();
1500
-
1501
- if (data.status === 'success') {
1502
- this.totpEnabled = true;
1503
- this.showTotpSetup = false;
1504
- this.totpSecret = '';
1505
- this.totpQrCode = '';
1506
- this.totpVerifyCode = '';
1507
- this.addLog('Two-factor authentication enabled', 'success');
1508
- } else {
1509
- this.totpError = data.message || 'Verification failed';
1510
- }
1511
- } catch (err) {
1512
- this.totpError = 'Connection failed. Please try again.';
1513
- } finally {
1514
- this.totpVerifying = false;
1515
- this.$nextTick(() => lucide.createIcons());
1516
- }
1517
- },
1518
-
1519
- cancelTotpSetup() {
1520
- this.showTotpSetup = false;
1521
- this.totpSecret = '';
1522
- this.totpQrCode = '';
1523
- this.totpVerifyCode = '';
1524
- this.totpError = '';
1525
- this.$nextTick(() => lucide.createIcons());
1526
- },
1527
-
1528
- async disableTotp() {
1529
- this.totpDisabling = true;
1530
- this.totpError = '';
1531
-
1532
- try {
1533
- const response = await fetch('/backup/api/totp/disable', {
1534
- method: 'POST',
1535
- headers: { 'Content-Type': 'application/json' },
1536
- body: JSON.stringify({ code: this.totpDisableCode })
1537
- });
1538
-
1539
- const data = await response.json();
1540
-
1541
- if (data.status === 'success') {
1542
- this.totpEnabled = false;
1543
- this.showDisableTotp = false;
1544
- this.totpDisableCode = '';
1545
- this.addLog('Two-factor authentication disabled', 'info');
1546
- } else {
1547
- this.totpError = data.message || 'Failed to disable 2FA';
1548
- }
1549
- } catch (err) {
1550
- this.totpError = 'Connection failed. Please try again.';
1551
- } finally {
1552
- this.totpDisabling = false;
1553
- this.$nextTick(() => lucide.createIcons());
1554
- }
1555
- },
1556
-
1557
- addLog(message, type = 'info') {
1558
- this.logs.unshift({
1559
- id: Date.now(),
1560
- message,
1561
- type,
1562
- time: new Date().toLocaleTimeString()
1563
- })
1564
- this.$nextTick(() => lucide.createIcons())
1565
- },
1566
-
1567
- formatBytes(bytes, decimals = 2) {
1568
- if (!+bytes) return '0 Bytes'
1569
- const k = 1024
1570
- const dm = decimals < 0 ? 0 : decimals
1571
- const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']
1572
- const i = Math.floor(Math.log(bytes) / Math.log(k))
1573
- return \`\${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} \${sizes[i]}\`
1574
- },
1575
-
1576
- formatDateHeader(dateStr) {
1577
- if (dateStr === 'Others') return 'Others';
1578
- // dateStr is YYYY-MM-DD
1579
- // Create date object treating the string as local time components to avoid timezone shifts
1580
- const [y, m, d] = dateStr.split('-').map(Number);
1581
- const date = new Date(y, m - 1, d);
1582
- return date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
1583
- },
1584
-
1585
- async runBackup() {
1586
- this.loading = true
1587
-
1588
- // Generate local timestamp: YYYY-MM-DD_HH-mm-ss
1589
- const now = new Date()
1590
- const timestamp = now.getFullYear() + '-' +
1591
- String(now.getMonth() + 1).padStart(2, '0') + '-' +
1592
- String(now.getDate()).padStart(2, '0') + '_' +
1593
- String(now.getHours()).padStart(2, '0') + '-' +
1594
- String(now.getMinutes()).padStart(2, '0') + '-' +
1595
- String(now.getSeconds()).padStart(2, '0')
1596
-
1597
- try {
1598
- const res = await fetch('/backup/api/run', {
1599
- method: 'POST',
1600
- headers: { 'Content-Type': 'application/json' },
1601
- body: JSON.stringify({ timestamp })
1602
- })
1603
- const data = await res.json()
1604
- if (data.status === 'success') {
1605
- this.lastBackup = new Date().toLocaleString()
1606
- this.addLog('Backup completed successfully', 'success')
1607
- this.fetchFiles() // Refresh file list
1608
- } else {
1609
- throw new Error(data.message)
1610
- }
1611
- } catch (err) {
1612
- this.addLog('Backup failed: ' + err.message, 'error')
1613
- } finally {
1614
- this.loading = false
1615
- }
1616
- },
1617
-
1618
- async fetchFiles() {
1619
- this.loadingFiles = true
1620
- try {
1621
- const res = await fetch('/backup/api/files')
1622
- const data = await res.json()
1623
- if (data.files) {
1624
- // 1. Sort files by Key Descending (Newest first)
1625
- const sortedFiles = data.files.sort((a, b) => b.key.localeCompare(a.key));
1626
-
1627
- // 2. Group by Date
1628
- const groupsMap = {};
1629
- sortedFiles.forEach(file => {
1630
- // Extract YYYY-MM-DD from the filename (handles prefixes like 'backups/2025-...')
1631
- // Looks for YYYY-MM-DD followed by underscore or T (for ISO)
1632
- // Note: Backslashes must be double-escaped in this server-side template string
1633
- const match = file.key.match(/(?:^|\\/)(\\d{4}-\\d{2}-\\d{2})[_T]/);
1634
- const dateKey = match ? match[1] : 'Others';
1635
-
1636
- if (!groupsMap[dateKey]) {
1637
- groupsMap[dateKey] = [];
1638
- }
1639
- groupsMap[dateKey].push(file);
1640
- });
1641
-
1642
- // 3. Convert to array and sort groups descending
1643
- this.groups = Object.keys(groupsMap)
1644
- .sort()
1645
- .reverse()
1646
- .map((dateKey, index) => ({
1647
- name: dateKey,
1648
- files: groupsMap[dateKey],
1649
- expanded: false // Start with all folders collapsed
1650
- }));
1651
-
1652
- this.$nextTick(() => lucide.createIcons())
1653
- }
1654
- } catch (err) {
1655
- this.addLog('Failed to fetch files: ' + err.message, 'error')
1656
- } finally {
1657
- this.loadingFiles = false
1658
- }
1659
- },
1660
-
1661
- async restoreFile(key) {
1662
- // Removed confirm dialog in favor of hold button
1663
- try {
1664
- const res = await fetch('/backup/api/restore', {
1665
- method: 'POST',
1666
- headers: { 'Content-Type': 'application/json' },
1667
- body: JSON.stringify({ key })
1668
- })
1669
- const data = await res.json()
1670
- if (data.status === 'success') {
1671
- this.addLog(data.message, 'success')
1672
- } else {
1673
- throw new Error(data.message)
1674
- }
1675
- } catch (err) {
1676
- this.addLog('Restore failed: ' + err.message, 'error')
1677
- }
1678
- },
1679
-
1680
- async deleteFile(key) {
1681
- try {
1682
- const res = await fetch('/backup/api/delete', {
1683
- method: 'POST',
1684
- headers: { 'Content-Type': 'application/json' },
1685
- body: JSON.stringify({ key })
1686
- })
1687
- const data = await res.json()
1688
- if (data.status === 'success') {
1689
- this.addLog(data.message, 'success')
1690
- this.fetchFiles() // Refresh list
1691
- } else {
1692
- throw new Error(data.message)
1693
- }
1694
- } catch (err) {
1695
- this.addLog('Delete failed: ' + err.message, 'error')
1696
- }
1697
- },
1698
-
1699
- async saveConfig() {
1700
- try {
1701
- const res = await fetch('/backup/api/config', {
1702
- method: 'POST',
1703
- headers: { 'Content-Type': 'application/json' },
1704
- body: JSON.stringify(this.configForm)
1705
- })
1706
- const data = await res.json()
1707
- if (data.status === 'success') {
1708
- this.config = data.config
1709
- if (data.jobStatus) this.cronStatus = data.jobStatus
1710
- this.addLog('Configuration updated', 'success')
1711
- this.activeTab = 'dashboard'
1712
- }
1713
- } catch (err) {
1714
- this.addLog('Failed to save config: ' + err.message, 'error')
1715
- }
1716
- },
1717
-
1718
- async logout() {
1719
- try {
1720
- await fetch('/backup/auth/logout', { method: 'POST' });
1721
- window.location.href = '/backup/login';
1722
- } catch (err) {
1723
- console.error('Logout failed:', err);
1724
- window.location.href = '/backup/login';
1725
- }
1726
- }
1727
- }
1728
- }
1729
- </script>
1730
- </body>
1731
- </html>
1732
- `
1733
- })
1734
- )
1735
- })
1736
- }