@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/package.json +4 -4
- package/src/assets/favicon.ico +0 -0
- package/src/assets/logo.png +0 -0
- package/src/core/config.js +70 -0
- package/src/core/scheduler.js +99 -0
- package/src/core/session.js +90 -0
- package/src/index.js +628 -3
- package/src/middleware/auth.middleware.js +41 -0
- package/src/services/backup.service.js +169 -0
- package/src/services/s3-client.js +28 -0
- package/src/views/DashboardPage.js +56 -0
- package/src/views/LoginPage.js +23 -0
- package/src/views/components/ActionArea.js +48 -0
- package/src/views/components/FilesTab.js +111 -0
- package/src/views/components/Head.js +56 -0
- package/src/views/components/Header.js +56 -0
- package/src/views/components/LoginCard.js +112 -0
- package/src/views/components/SecuritySection.js +166 -0
- package/src/views/components/SettingsTab.js +88 -0
- package/src/views/components/StatusCards.js +54 -0
- package/src/views/scripts/backupApp.js +324 -0
- package/src/views/scripts/loginApp.js +60 -0
- package/src/elysia-backup.js +0 -1736
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
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|