@riligar/elysia-backup 1.1.0 → 1.2.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.
Files changed (2) hide show
  1. package/package.json +8 -6
  2. package/src/elysia-backup.js +695 -372
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riligar/elysia-backup",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Elysia plugin for R2/S3 backup with a built-in UI dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -36,21 +36,23 @@
36
36
  "cloudflare",
37
37
  "bun"
38
38
  ],
39
- "author": "RiLiGar",
39
+ "author": {
40
+ "name": "Ciro Cesar Maciel",
41
+ "url": "https://github.com/ciro-maciel"
42
+ },
40
43
  "license": "MIT",
41
44
  "peerDependencies": {
42
45
  "elysia": ">=1.0.0",
43
46
  "@elysiajs/html": ">=1.0.0"
44
47
  },
45
48
  "dependencies": {
46
- "cron": "^3.1.0"
49
+ "cron": "^3.5.0"
47
50
  },
48
51
  "devDependencies": {
49
- "@elysiajs/html": "^1.1.1",
52
+ "@elysiajs/html": "^1.4.0",
50
53
  "@semantic-release/changelog": "^6.0.3",
51
54
  "@semantic-release/git": "^10.0.1",
52
- "bun-types": "^1.1.34",
53
- "elysia": "^1.1.26",
55
+ "elysia": "^1.4.19",
54
56
  "semantic-release": "^24.2.9"
55
57
  }
56
58
  }
@@ -1,13 +1,58 @@
1
1
  // https://bun.com/docs/runtime/s3
2
2
  // https://elysiajs.com/plugins/html
3
- import { Elysia, t } from "elysia";
4
- import { S3Client } from "bun";
5
- import { CronJob } from "cron";
6
- import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises";
7
- import { readFileSync } from "node:fs";
8
- import { existsSync } from "node:fs";
9
- import { join, relative, dirname } from "node:path";
10
- import { html } from "@elysiajs/html";
3
+ import { Elysia, t } from 'elysia'
4
+ import { S3Client } from 'bun'
5
+ import { CronJob } from 'cron'
6
+ import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
7
+ import { readFileSync } from 'node:fs'
8
+ import { existsSync } from 'node:fs'
9
+ import { join, relative, dirname } from 'node:path'
10
+ import { html } from '@elysiajs/html'
11
+
12
+ // Session Management
13
+ const sessions = new Map()
14
+
15
+ /**
16
+ * Generate a secure random session token
17
+ */
18
+ const generateSessionToken = () => {
19
+ const array = new Uint8Array(32)
20
+ crypto.getRandomValues(array)
21
+ return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
22
+ }
23
+
24
+ /**
25
+ * Create a new session for a user
26
+ */
27
+ const createSession = (username, sessionDuration = 24 * 60 * 60 * 1000) => {
28
+ const token = generateSessionToken()
29
+ const expiresAt = Date.now() + sessionDuration
30
+ sessions.set(token, { username, expiresAt })
31
+ return { token, expiresAt }
32
+ }
33
+
34
+ /**
35
+ * Validate and return session data
36
+ */
37
+ const getSession = token => {
38
+ if (!token) return null
39
+ const session = sessions.get(token)
40
+ if (!session) return null
41
+ if (Date.now() > session.expiresAt) {
42
+ sessions.delete(token)
43
+ return null
44
+ }
45
+ return session
46
+ }
47
+
48
+ /**
49
+ * Delete a session
50
+ */
51
+ const deleteSession = token => {
52
+ if (token) {
53
+ sessions.delete(token)
54
+ }
55
+ }
11
56
 
12
57
  /**
13
58
  * Elysia Plugin for R2/S3 Backup with UI (using native Bun.s3)
@@ -23,385 +68,638 @@ import { html } from "@elysiajs/html";
23
68
  * @param {boolean} [config.cronEnabled] - Whether the cron schedule is enabled
24
69
  * @param {string} [config.configPath] - Path to save runtime configuration (default: './backup-config.json')
25
70
  */
26
- export const r2Backup = (initialConfig) => (app) => {
27
- // State to hold runtime configuration (allows UI updates)
28
- const configPath = initialConfig.configPath || "./backup-config.json";
29
-
30
- // Load saved config if exists
31
- let savedConfig = {};
32
- if (existsSync(configPath)) {
33
- try {
34
- // We use synchronous read here or just init with promise (but top level await is tricky in plugins depending on usage)
35
- // For simplicity in this context, we'll rely on the fact that this runs once on startup.
36
- // However, since we are in a function, we can't easily do async await at top level unless the plugin is async.
37
- // Elysia plugins can be async.
38
- // Using readFileSync for startup config loading to ensure it's ready.
39
- const fileContent = readFileSync(configPath, "utf-8");
40
- savedConfig = JSON.parse(fileContent);
41
- console.log("Loaded backup config from", configPath);
42
- } catch (e) {
43
- console.error("Failed to load backup config:", e);
44
- }
45
- }
46
-
47
- let config = { ...initialConfig, ...savedConfig };
48
- let backupJob = null;
49
-
50
- const getS3Client = () => {
51
- // Debug config (masked)
52
- console.log("S3 Config:", {
53
- bucket: config.bucket,
54
- endpoint: config.endpoint,
55
- accessKeyId: config.accessKeyId
56
- ? "***" + config.accessKeyId.slice(-4)
57
- : "missing",
58
- hasSecret: !!config.secretAccessKey,
59
- });
60
-
61
- return new S3Client({
62
- accessKeyId: config.accessKeyId,
63
- secretAccessKey: config.secretAccessKey,
64
- endpoint: config.endpoint,
65
- bucket: config.bucket,
66
- region: "auto",
67
- // R2 requires specific region handling or defaults.
68
- // Bun S3 defaults to us-east-1 which is usually fine for R2 if endpoint is correct.
69
- });
70
- };
71
-
72
- const uploadFile = async (filePath, rootDir, timestampPrefix) => {
73
- const s3 = getS3Client();
74
- const fileContent = await readFile(filePath);
75
-
76
- const relativePath = relative(rootDir, filePath);
77
- const dir = dirname(relativePath);
78
- const filename = relativePath.split("/").pop(); // or basename
79
-
80
- // Format: YYYY-MM-DD_HH-mm-ss_filename.ext (or ISO if not provided)
81
- const timestamp = timestampPrefix || new Date().toISOString();
82
- const newFilename = `${timestamp}_${filename}`;
83
-
84
- // Reconstruct path with new filename
85
- const finalPath = dir === "." ? newFilename : join(dir, newFilename);
86
-
87
- const key = config.prefix ? join(config.prefix, finalPath) : finalPath;
88
-
89
- console.log(`Uploading ${key}...`);
90
-
91
- // Bun.s3 API: s3.write(key, data)
92
- await s3.write(key, fileContent);
93
- };
94
-
95
- const processDirectory = async (dir, timestampPrefix) => {
96
- const files = await readdir(dir);
97
- for (const file of files) {
98
- const fullPath = join(dir, file);
99
- const stats = await stat(fullPath);
100
- if (stats.isDirectory()) {
101
- await processDirectory(fullPath, timestampPrefix);
102
- } else {
103
- // Filter by extension
104
- const allowedExtensions = config.extensions || [];
105
- const hasExtension = allowedExtensions.some((ext) =>
106
- file.endsWith(ext)
107
- );
108
-
109
- // Skip files that look like backups to prevent recursion/duplication
110
- // Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
111
- const timestampRegex =
112
- /^(\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)_/;
113
- if (timestampRegex.test(file)) {
114
- console.log(`Skipping backup-like file: ${file}`);
115
- continue;
116
- }
71
+ export const r2Backup = initialConfig => app => {
72
+ // State to hold runtime configuration (allows UI updates)
73
+ const configPath = initialConfig.configPath || './backup-config.json'
117
74
 
118
- if (allowedExtensions.length === 0 || hasExtension) {
119
- await uploadFile(fullPath, config.sourceDir, timestampPrefix);
75
+ // Load saved config if exists
76
+ let savedConfig = {}
77
+ if (existsSync(configPath)) {
78
+ try {
79
+ // We use synchronous read here or just init with promise (but top level await is tricky in plugins depending on usage)
80
+ // For simplicity in this context, we'll rely on the fact that this runs once on startup.
81
+ // However, since we are in a function, we can't easily do async await at top level unless the plugin is async.
82
+ // Elysia plugins can be async.
83
+ // Using readFileSync for startup config loading to ensure it's ready.
84
+ const fileContent = readFileSync(configPath, 'utf-8')
85
+ savedConfig = JSON.parse(fileContent)
86
+ console.log('Loaded backup config from', configPath)
87
+ } catch (e) {
88
+ console.error('Failed to load backup config:', e)
120
89
  }
121
- }
122
90
  }
123
- };
124
91
 
125
- const setupCron = () => {
126
- if (backupJob) {
127
- backupJob.stop();
128
- backupJob = null;
92
+ let config = { ...initialConfig, ...savedConfig }
93
+ let backupJob = null
94
+
95
+ const getS3Client = () => {
96
+ // Debug config (masked)
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
+ // R2 requires specific region handling or defaults.
111
+ // Bun S3 defaults to us-east-1 which is usually fine for R2 if endpoint is correct.
112
+ })
129
113
  }
130
114
 
131
- if (config.cronSchedule && config.cronEnabled !== false) {
132
- console.log(`Setting up backup cron: ${config.cronSchedule}`);
133
- try {
134
- backupJob = new CronJob(
135
- config.cronSchedule,
136
- async () => {
137
- console.log("Running scheduled backup...");
115
+ const uploadFile = async (filePath, rootDir, timestampPrefix) => {
116
+ const s3 = getS3Client()
117
+ const fileContent = await readFile(filePath)
118
+
119
+ const relativePath = relative(rootDir, filePath)
120
+ const dir = dirname(relativePath)
121
+ const filename = relativePath.split('/').pop() // or basename
122
+
123
+ // Format: YYYY-MM-DD_HH-mm-ss_filename.ext (or ISO if not provided)
124
+ const timestamp = timestampPrefix || new Date().toISOString()
125
+ const newFilename = `${timestamp}_${filename}`
126
+
127
+ // Reconstruct path with new filename
128
+ const finalPath = dir === '.' ? newFilename : join(dir, newFilename)
129
+
130
+ const key = config.prefix ? join(config.prefix, finalPath) : finalPath
131
+
132
+ console.log(`Uploading ${key}...`)
133
+
134
+ // Bun.s3 API: s3.write(key, data)
135
+ await s3.write(key, fileContent)
136
+ }
137
+
138
+ const processDirectory = async (dir, timestampPrefix) => {
139
+ const files = await readdir(dir)
140
+ for (const file of files) {
141
+ const fullPath = join(dir, file)
142
+ const stats = await stat(fullPath)
143
+ if (stats.isDirectory()) {
144
+ await processDirectory(fullPath, timestampPrefix)
145
+ } else {
146
+ // Filter by extension
147
+ const allowedExtensions = config.extensions || []
148
+ const hasExtension = allowedExtensions.some(ext => file.endsWith(ext))
149
+
150
+ // Skip files that look like backups to prevent recursion/duplication
151
+ // Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
152
+ 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)_/
153
+ if (timestampRegex.test(file)) {
154
+ console.log(`Skipping backup-like file: ${file}`)
155
+ continue
156
+ }
157
+
158
+ if (allowedExtensions.length === 0 || hasExtension) {
159
+ await uploadFile(fullPath, config.sourceDir, timestampPrefix)
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ const setupCron = () => {
166
+ if (backupJob) {
167
+ backupJob.stop()
168
+ backupJob = null
169
+ }
170
+
171
+ if (config.cronSchedule && config.cronEnabled !== false) {
172
+ console.log(`Setting up backup cron: ${config.cronSchedule}`)
138
173
  try {
139
- // Generate timestamp similar to manual run
140
- const now = new Date();
141
- const timestamp =
142
- now.getFullYear() +
143
- "-" +
144
- String(now.getMonth() + 1).padStart(2, "0") +
145
- "-" +
146
- String(now.getDate()).padStart(2, "0") +
147
- "_" +
148
- String(now.getHours()).padStart(2, "0") +
149
- "-" +
150
- String(now.getMinutes()).padStart(2, "0") +
151
- "-" +
152
- String(now.getSeconds()).padStart(2, "0");
153
-
154
- await processDirectory(config.sourceDir, timestamp);
155
- console.log("Scheduled backup completed");
174
+ backupJob = new CronJob(
175
+ config.cronSchedule,
176
+ async () => {
177
+ console.log('Running scheduled backup...')
178
+ try {
179
+ // Generate timestamp similar to manual run
180
+ const now = new Date()
181
+ const timestamp =
182
+ now.getFullYear() +
183
+ '-' +
184
+ String(now.getMonth() + 1).padStart(2, '0') +
185
+ '-' +
186
+ String(now.getDate()).padStart(2, '0') +
187
+ '_' +
188
+ String(now.getHours()).padStart(2, '0') +
189
+ '-' +
190
+ String(now.getMinutes()).padStart(2, '0') +
191
+ '-' +
192
+ String(now.getSeconds()).padStart(2, '0')
193
+
194
+ await processDirectory(config.sourceDir, timestamp)
195
+ console.log('Scheduled backup completed')
196
+ } catch (e) {
197
+ console.error('Scheduled backup failed:', e)
198
+ }
199
+ },
200
+ null,
201
+ true // start
202
+ )
156
203
  } catch (e) {
157
- console.error("Scheduled backup failed:", e);
204
+ console.error('Invalid cron schedule:', e.message)
158
205
  }
159
- },
160
- null,
161
- true // start
162
- );
163
- } catch (e) {
164
- console.error("Invalid cron schedule:", e.message);
165
- }
166
- }
167
- };
168
-
169
- // Initialize cron
170
- setupCron();
171
-
172
- const listRemoteFiles = async () => {
173
- const s3 = getS3Client();
174
- // Bun.s3 list API
175
- // Returns a Promise that resolves to the list of files (or object with contents)
176
- // Based on Bun docs/behavior, list() returns an array of S3File-like objects or similar structure
177
- try {
178
- const response = await s3.list({ prefix: config.prefix || "" });
179
- // If response is array
180
- if (Array.isArray(response)) {
181
- return response.map((f) => ({
182
- Key: f.key || f.name, // Handle potential property names
183
- Size: f.size,
184
- LastModified: f.lastModified,
185
- }));
186
- }
187
- // If response has contents (AWS-like)
188
- if (response.contents) {
189
- return response.contents.map((f) => ({
190
- Key: f.key,
191
- Size: f.size,
192
- LastModified: f.lastModified,
193
- }));
194
- }
195
- console.log("Unknown list response structure:", response);
196
- return [];
197
- } catch (e) {
198
- console.error("Error listing files with Bun.s3:", e);
199
- return [];
206
+ }
200
207
  }
201
- };
202
208
 
203
- const restoreFile = async (key) => {
204
- const s3 = getS3Client();
205
- const file = s3.file(key);
209
+ // Initialize cron
210
+ setupCron()
206
211
 
207
- if (!(await file.exists())) {
208
- throw new Error(`File ${key} not found in bucket`);
212
+ const listRemoteFiles = async () => {
213
+ const s3 = getS3Client()
214
+ // Bun.s3 list API
215
+ // Returns a Promise that resolves to the list of files (or object with contents)
216
+ // Based on Bun docs/behavior, list() returns an array of S3File-like objects or similar structure
217
+ try {
218
+ const response = await s3.list({ prefix: config.prefix || '' })
219
+ // If response is array
220
+ if (Array.isArray(response)) {
221
+ return response.map(f => ({
222
+ Key: f.key || f.name, // Handle potential property names
223
+ Size: f.size,
224
+ LastModified: f.lastModified,
225
+ }))
226
+ }
227
+ // If response has contents (AWS-like)
228
+ if (response.contents) {
229
+ return response.contents.map(f => ({
230
+ Key: f.key,
231
+ Size: f.size,
232
+ LastModified: f.lastModified,
233
+ }))
234
+ }
235
+ console.log('Unknown list response structure:', response)
236
+ return []
237
+ } catch (e) {
238
+ console.error('Error listing files with Bun.s3:', e)
239
+ return []
240
+ }
209
241
  }
210
242
 
211
- const arrayBuffer = await file.arrayBuffer();
212
- const byteArray = new Uint8Array(arrayBuffer);
213
-
214
- // Determine local path
215
- // Remove prefix from key to get relative path
216
- const relativePath = config.prefix ? key.replace(config.prefix, "") : key;
217
- // Clean leading slashes if any
218
- const cleanRelative = relativePath.replace(/^[\/\\]/, "");
219
-
220
- // Extract directory and filename
221
- const dir = dirname(cleanRelative);
222
- const filename = cleanRelative.split("/").pop();
223
-
224
- // Strip timestamp prefix if present to restore original filename
225
- // Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
226
- const timestampRegex =
227
- /^(\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)_/;
228
- const originalFilename = filename.replace(timestampRegex, "");
229
-
230
- const finalLocalRelativePath =
231
- dir === "." ? originalFilename : join(dir, originalFilename);
232
- const localPath = join(config.sourceDir, finalLocalRelativePath);
233
-
234
- // Ensure directory exists
235
- await mkdir(dirname(localPath), { recursive: true });
236
- await writeFile(localPath, byteArray);
237
- return localPath;
238
- };
239
-
240
- const deleteFile = async (key) => {
241
- const s3 = getS3Client();
242
- // Bun S3 client delete
243
- await s3.delete(key);
244
- };
245
-
246
- const getJobStatus = () => {
247
- const isRunning = !!backupJob && config.cronEnabled !== false;
248
- let nextRun = null;
249
- if (isRunning && backupJob) {
250
- try {
251
- const nextDate = backupJob.nextDate();
252
- if (nextDate) {
253
- // cron returns Luxon DateTime, convert to JS Date for consistent ISO string
254
- nextRun = nextDate.toJSDate().toISOString();
243
+ const restoreFile = async key => {
244
+ const s3 = getS3Client()
245
+ const file = s3.file(key)
246
+
247
+ if (!(await file.exists())) {
248
+ throw new Error(`File ${key} not found in bucket`)
255
249
  }
256
- } catch (e) {
257
- console.error("Error getting next date", e);
258
- }
250
+
251
+ const arrayBuffer = await file.arrayBuffer()
252
+ const byteArray = new Uint8Array(arrayBuffer)
253
+
254
+ // Determine local path
255
+ // Remove prefix from key to get relative path
256
+ const relativePath = config.prefix ? key.replace(config.prefix, '') : key
257
+ // Clean leading slashes if any
258
+ const cleanRelative = relativePath.replace(/^[\/\\]/, '')
259
+
260
+ // Extract directory and filename
261
+ const dir = dirname(cleanRelative)
262
+ const filename = cleanRelative.split('/').pop()
263
+
264
+ // Strip timestamp prefix if present to restore original filename
265
+ // Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
266
+ 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)_/
267
+ const originalFilename = filename.replace(timestampRegex, '')
268
+
269
+ const finalLocalRelativePath = dir === '.' ? originalFilename : join(dir, originalFilename)
270
+ const localPath = join(config.sourceDir, finalLocalRelativePath)
271
+
272
+ // Ensure directory exists
273
+ await mkdir(dirname(localPath), { recursive: true })
274
+ await writeFile(localPath, byteArray)
275
+ return localPath
259
276
  }
260
- return { isRunning, nextRun };
261
- };
262
-
263
- return app.use(html()).group("/backup", (app) =>
264
- app
265
- // API: Run Backup
266
- .post(
267
- "/api/run",
268
- async ({ body, set }) => {
269
- try {
270
- const { timestamp } = body || {};
271
- console.log(
272
- `Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`
273
- );
274
- await processDirectory(config.sourceDir, timestamp);
275
- return {
276
- status: "success",
277
- message: "Backup completed successfully",
278
- timestamp: new Date().toISOString(),
279
- };
280
- } catch (error) {
281
- console.error("Backup failed:", error);
282
- set.status = 500;
283
- return { status: "error", message: error.message };
284
- }
285
- },
286
- {
287
- body: t.Optional(
288
- t.Object({
289
- timestamp: t.Optional(t.String()),
290
- })
291
- ),
292
- }
293
- )
294
277
 
295
- // API: List Files
296
- .get("/api/files", async ({ set }) => {
297
- try {
298
- const files = await listRemoteFiles();
299
- return {
300
- files: files.map((f) => ({
301
- key: f.Key,
302
- size: f.Size,
303
- lastModified: f.LastModified,
304
- })),
305
- };
306
- } catch (error) {
307
- set.status = 500;
308
- return { status: "error", message: error.message };
309
- }
310
- })
311
-
312
- // API: Restore File
313
- .post(
314
- "/api/restore",
315
- async ({ body, set }) => {
316
- try {
317
- const { key } = body;
318
- if (!key) throw new Error("Key is required");
319
- const localPath = await restoreFile(key);
320
- return { status: "success", message: `Restored to ${localPath}` };
321
- } catch (error) {
322
- set.status = 500;
323
- return { status: "error", message: error.message };
324
- }
325
- },
326
- {
327
- body: t.Object({
328
- key: t.String(),
329
- }),
278
+ const deleteFile = async key => {
279
+ const s3 = getS3Client()
280
+ // Bun S3 client delete
281
+ await s3.delete(key)
282
+ }
283
+
284
+ const getJobStatus = () => {
285
+ const isRunning = !!backupJob && config.cronEnabled !== false
286
+ let nextRun = null
287
+ if (isRunning && backupJob) {
288
+ try {
289
+ const nextDate = backupJob.nextDate()
290
+ if (nextDate) {
291
+ // cron returns Luxon DateTime, convert to JS Date for consistent ISO string
292
+ nextRun = nextDate.toJSDate().toISOString()
293
+ }
294
+ } catch (e) {
295
+ console.error('Error getting next date', e)
296
+ }
330
297
  }
331
- )
332
-
333
- // API: Delete File
334
- .post(
335
- "/api/delete",
336
- async ({ body, set }) => {
337
- try {
338
- const { key } = body;
339
- if (!key) throw new Error("Key is required");
340
- await deleteFile(key);
341
- return { status: "success", message: `Deleted ${key}` };
342
- } catch (error) {
343
- set.status = 500;
344
- return { status: "error", message: error.message };
345
- }
346
- },
347
- {
348
- body: t.Object({
349
- key: t.String(),
350
- }),
298
+ return { isRunning, nextRun }
299
+ }
300
+
301
+ return app.use(html()).group('/backup', app => {
302
+ // Authentication Middleware
303
+ const authMiddleware = context => {
304
+ // Only bypass auth if no credentials are configured at all
305
+ if (!config.auth || !config.auth.username || !config.auth.password) {
306
+ return
307
+ }
308
+
309
+ const path = context.path
310
+
311
+ // Allow access to login page and auth endpoints without authentication
312
+ if (path === '/backup/login' || path === '/backup/auth/login' || path === '/backup/auth/logout') {
313
+ return
314
+ }
315
+
316
+ // Check session cookie
317
+ const cookies = context.headers.cookie || ''
318
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
319
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
320
+
321
+ const session = getSession(sessionToken)
322
+
323
+ if (!session) {
324
+ // Redirect to login page
325
+ context.set.status = 302
326
+ context.set.headers['Location'] = '/backup/login'
327
+ return new Response('Redirecting to login', {
328
+ status: 302,
329
+ headers: { Location: '/backup/login' },
330
+ })
331
+ }
351
332
  }
352
- )
353
-
354
- // API: Update Config
355
- .post(
356
- "/api/config",
357
- async ({ body }) => {
358
- // Handle extensions if passed as string
359
- let newConfig = { ...body };
360
- if (typeof newConfig.extensions === "string") {
361
- newConfig.extensions = newConfig.extensions
362
- .split(",")
363
- .map((e) => e.trim())
364
- .filter(Boolean);
365
- }
366
- config = { ...config, ...newConfig };
367
-
368
- // Persist config
369
- try {
370
- // Don't save secrets in plain text if possible, but for this simple tool we might have to
371
- // or just save the non-env parts.
372
- // For now, we save everything that overrides the defaults.
373
- await writeFile(configPath, JSON.stringify(config, null, 2));
374
- } catch (e) {
375
- console.error("Failed to save config:", e);
376
- }
377
-
378
- setupCron(); // Restart cron with new config
379
- return {
380
- status: "success",
381
- config: { ...config, secretAccessKey: "***" },
382
- jobStatus: getJobStatus(),
383
- };
384
- },
385
- {
386
- body: t.Object({
387
- bucket: t.String(),
388
- endpoint: t.String(),
389
- sourceDir: t.String(),
390
- prefix: t.Optional(t.String()),
391
- extensions: t.Optional(t.Union([t.Array(t.String()), t.String()])), // Allow array or comma-separated string
392
- accessKeyId: t.String(),
393
- secretAccessKey: t.String(),
394
- cronSchedule: t.Optional(t.String()),
395
- cronEnabled: t.Optional(t.Boolean()),
396
- }),
333
+
334
+ return (
335
+ app
336
+ .onBeforeHandle(authMiddleware)
337
+
338
+ // AUTH: Login Page
339
+ .get('/login', ({ set }) => {
340
+ // If no auth credentials configured, redirect to dashboard
341
+ if (!config.auth || !config.auth.username || !config.auth.password) {
342
+ set.status = 302
343
+ set.headers['Location'] = '/backup'
344
+ return
345
+ }
346
+
347
+ set.headers['Content-Type'] = 'text/html; charset=utf8'
348
+ return `
349
+ <!DOCTYPE html>
350
+ <html lang="en">
351
+ <head>
352
+ <meta charset="UTF-8">
353
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
354
+ <title>Login - Backup Manager</title>
355
+ <script src="https://cdn.tailwindcss.com"></script>
356
+ <script>
357
+ tailwind.config = {
358
+ theme: {
359
+ extend: {
360
+ fontFamily: {
361
+ sans: ['Montserrat', 'sans-serif'],
362
+ }
363
+ }
364
+ }
397
365
  }
398
- )
366
+ </script>
367
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
368
+ <script src="https://unpkg.com/lucide@latest"></script>
369
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
370
+ <style>
371
+ [x-cloak] { display: none !important; }
372
+ </style>
373
+ </head>
374
+ <body class="bg-gray-50 min-h-screen flex items-center justify-center p-6 antialiased">
375
+ <div class="w-full max-w-md" x-data="loginApp()">
376
+ <!-- Login Card -->
377
+ <div class="bg-white rounded-2xl border border-gray-100 shadow-[0_8px_30px_rgba(0,0,0,0.08)] overflow-hidden">
378
+ <!-- Header -->
379
+ <div class="p-10 text-center border-b border-gray-100">
380
+ <div class="w-16 h-16 bg-gray-900 rounded-full flex items-center justify-center mx-auto mb-4">
381
+ <i data-lucide="shield-check" class="w-8 h-8 text-white"></i>
382
+ </div>
383
+ <h1 class="text-2xl font-bold text-gray-900 mb-2">Backup Manager</h1>
384
+ <p class="text-sm text-gray-500">Access Control Panel</p>
385
+ </div>
386
+
387
+ <!-- Form -->
388
+ <div class="p-10">
389
+ <form @submit.prevent="login" class="space-y-6">
390
+ <!-- Error Message -->
391
+ <div x-show="error" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-4">
392
+ <div class="flex items-center gap-3">
393
+ <i data-lucide="alert-circle" class="w-5 h-5 text-red-600"></i>
394
+ <span class="text-sm text-red-800 font-medium" x-text="error"></span>
395
+ </div>
396
+ </div>
397
+
398
+ <!-- Username -->
399
+ <div>
400
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Username</label>
401
+ <div class="relative">
402
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
403
+ <i data-lucide="user" class="w-5 h-5 text-gray-400"></i>
404
+ </div>
405
+ <input
406
+ type="text"
407
+ x-model="username"
408
+ required
409
+ 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"
410
+ placeholder="Enter your username"
411
+ autofocus
412
+ >
413
+ </div>
414
+ </div>
415
+
416
+ <!-- Password -->
417
+ <div>
418
+ <label class="block text-sm font-semibold text-gray-700 mb-2">Password</label>
419
+ <div class="relative">
420
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
421
+ <i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
422
+ </div>
423
+ <input
424
+ type="password"
425
+ x-model="password"
426
+ required
427
+ 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"
428
+ placeholder="Enter your password"
429
+ >
430
+ </div>
431
+ </div>
432
+
433
+ <!-- Submit Button -->
434
+ <button
435
+ type="submit"
436
+ :disabled="loading"
437
+ 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"
438
+ >
439
+ <span x-show="!loading" class="flex items-center gap-2">
440
+ <span>Sign In</span>
441
+ <i data-lucide="arrow-right" class="w-5 h-5"></i>
442
+ </span>
443
+ <span x-show="loading" class="flex items-center gap-2">
444
+ <i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
445
+ <span>Authenticating...</span>
446
+ </span>
447
+ </button>
448
+ </form>
449
+ </div>
450
+ </div>
451
+
452
+ <!-- Footer -->
453
+ <div class="text-center mt-6 text-sm text-gray-500">
454
+ <i data-lucide="info" class="w-4 h-4 inline-block mr-1"></i>
455
+ Secure connection required for production use
456
+ </div>
457
+ </div>
458
+
459
+ <script>
460
+ document.addEventListener('alpine:init', () => {
461
+ Alpine.data('loginApp', () => ({
462
+ username: '',
463
+ password: '',
464
+ loading: false,
465
+ error: '',
466
+
467
+ init() {
468
+ this.$nextTick(() => lucide.createIcons());
469
+ },
470
+
471
+ async login() {
472
+ this.loading = true;
473
+ this.error = '';
474
+
475
+ try {
476
+ const response = await fetch('/backup/auth/login', {
477
+ method: 'POST',
478
+ headers: { 'Content-Type': 'application/json' },
479
+ body: JSON.stringify({
480
+ username: this.username,
481
+ password: this.password
482
+ })
483
+ });
484
+
485
+ const data = await response.json();
486
+
487
+ if (response.ok && data.status === 'success') {
488
+ // Redirect to dashboard
489
+ window.location.href = '/backup';
490
+ } else {
491
+ this.error = data.message || 'Invalid credentials';
492
+ this.$nextTick(() => lucide.createIcons());
493
+ }
494
+ } catch (err) {
495
+ this.error = 'Connection failed. Please try again.';
496
+ this.$nextTick(() => lucide.createIcons());
497
+ } finally {
498
+ this.loading = false;
499
+ this.$nextTick(() => lucide.createIcons());
500
+ }
501
+ }
502
+ }));
503
+ });
504
+ </script>
505
+ </body>
506
+ </html>
507
+ `
508
+ })
509
+
510
+ // AUTH: Login Endpoint
511
+ .post(
512
+ '/auth/login',
513
+ async ({ body, set }) => {
514
+ // Check if auth credentials are configured
515
+ if (!config.auth || !config.auth.username || !config.auth.password) {
516
+ set.status = 403
517
+ return {
518
+ status: 'error',
519
+ message: 'Authentication is not configured',
520
+ }
521
+ }
522
+
523
+ const { username, password } = body
524
+
525
+ // Validate credentials
526
+ if (username === config.auth.username && password === config.auth.password) {
527
+ // Create session
528
+ const sessionDuration = config.auth.sessionDuration || 24 * 60 * 60 * 1000 // 24h default
529
+ const { token, expiresAt } = createSession(username, sessionDuration)
530
+
531
+ // Set cookie
532
+ const expiresDate = new Date(expiresAt)
533
+ set.headers['Set-Cookie'] = `backup-session=${token}; Path=/backup; HttpOnly; SameSite=Lax; Expires=${expiresDate.toUTCString()}`
534
+
535
+ return { status: 'success', message: 'Login successful' }
536
+ } else {
537
+ set.status = 401
538
+ return { status: 'error', message: 'Invalid username or password' }
539
+ }
540
+ },
541
+ {
542
+ body: t.Object({
543
+ username: t.String(),
544
+ password: t.String(),
545
+ }),
546
+ }
547
+ )
548
+
549
+ // AUTH: Logout Endpoint
550
+ .post('/auth/logout', ({ headers, set }) => {
551
+ const cookies = headers.cookie || ''
552
+ const sessionMatch = cookies.match(/backup-session=([^;]+)/)
553
+ const sessionToken = sessionMatch ? sessionMatch[1] : null
554
+
555
+ if (sessionToken) {
556
+ deleteSession(sessionToken)
557
+ }
399
558
 
400
- // UI: Dashboard
401
- .get("/", ({ set }) => {
402
- set.headers["Content-Type"] = "text/html; charset=utf8";
403
- const jobStatus = getJobStatus();
404
- return `
559
+ // Clear cookie
560
+ set.headers['Set-Cookie'] = `backup-session=; Path=/backup; HttpOnly; SameSite=Lax; Max-Age=0`
561
+
562
+ return { status: 'success', message: 'Logged out successfully' }
563
+ })
564
+
565
+ // API: Run Backup
566
+ .post(
567
+ '/api/run',
568
+ async ({ body, set }) => {
569
+ try {
570
+ const { timestamp } = body || {}
571
+ console.log(`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`)
572
+ await processDirectory(config.sourceDir, timestamp)
573
+ return {
574
+ status: 'success',
575
+ message: 'Backup completed successfully',
576
+ timestamp: new Date().toISOString(),
577
+ }
578
+ } catch (error) {
579
+ console.error('Backup failed:', error)
580
+ set.status = 500
581
+ return { status: 'error', message: error.message }
582
+ }
583
+ },
584
+ {
585
+ body: t.Optional(
586
+ t.Object({
587
+ timestamp: t.Optional(t.String()),
588
+ })
589
+ ),
590
+ }
591
+ )
592
+
593
+ // API: List Files
594
+ .get('/api/files', async ({ set }) => {
595
+ try {
596
+ const files = await listRemoteFiles()
597
+ return {
598
+ files: files.map(f => ({
599
+ key: f.Key,
600
+ size: f.Size,
601
+ lastModified: f.LastModified,
602
+ })),
603
+ }
604
+ } catch (error) {
605
+ set.status = 500
606
+ return { status: 'error', message: error.message }
607
+ }
608
+ })
609
+
610
+ // API: Restore File
611
+ .post(
612
+ '/api/restore',
613
+ async ({ body, set }) => {
614
+ try {
615
+ const { key } = body
616
+ if (!key) throw new Error('Key is required')
617
+ const localPath = await restoreFile(key)
618
+ return { status: 'success', message: `Restored to ${localPath}` }
619
+ } catch (error) {
620
+ set.status = 500
621
+ return { status: 'error', message: error.message }
622
+ }
623
+ },
624
+ {
625
+ body: t.Object({
626
+ key: t.String(),
627
+ }),
628
+ }
629
+ )
630
+
631
+ // API: Delete File
632
+ .post(
633
+ '/api/delete',
634
+ async ({ body, set }) => {
635
+ try {
636
+ const { key } = body
637
+ if (!key) throw new Error('Key is required')
638
+ await deleteFile(key)
639
+ return { status: 'success', message: `Deleted ${key}` }
640
+ } catch (error) {
641
+ set.status = 500
642
+ return { status: 'error', message: error.message }
643
+ }
644
+ },
645
+ {
646
+ body: t.Object({
647
+ key: t.String(),
648
+ }),
649
+ }
650
+ )
651
+
652
+ // API: Update Config
653
+ .post(
654
+ '/api/config',
655
+ async ({ body }) => {
656
+ // Handle extensions if passed as string
657
+ let newConfig = { ...body }
658
+ if (typeof newConfig.extensions === 'string') {
659
+ newConfig.extensions = newConfig.extensions
660
+ .split(',')
661
+ .map(e => e.trim())
662
+ .filter(Boolean)
663
+ }
664
+ config = { ...config, ...newConfig }
665
+
666
+ // Persist config
667
+ try {
668
+ // Don't save secrets in plain text if possible, but for this simple tool we might have to
669
+ // or just save the non-env parts.
670
+ // For now, we save everything that overrides the defaults.
671
+ await writeFile(configPath, JSON.stringify(config, null, 2))
672
+ } catch (e) {
673
+ console.error('Failed to save config:', e)
674
+ }
675
+
676
+ setupCron() // Restart cron with new config
677
+ return {
678
+ status: 'success',
679
+ config: { ...config, secretAccessKey: '***' },
680
+ jobStatus: getJobStatus(),
681
+ }
682
+ },
683
+ {
684
+ body: t.Object({
685
+ bucket: t.String(),
686
+ endpoint: t.String(),
687
+ sourceDir: t.String(),
688
+ prefix: t.Optional(t.String()),
689
+ extensions: t.Optional(t.Union([t.Array(t.String()), t.String()])), // Allow array or comma-separated string
690
+ accessKeyId: t.String(),
691
+ secretAccessKey: t.String(),
692
+ cronSchedule: t.Optional(t.String()),
693
+ cronEnabled: t.Optional(t.Boolean()),
694
+ }),
695
+ }
696
+ )
697
+
698
+ // UI: Dashboard
699
+ .get('/', ({ set }) => {
700
+ set.headers['Content-Type'] = 'text/html; charset=utf8'
701
+ const jobStatus = getJobStatus()
702
+ return `
405
703
  <!DOCTYPE html>
406
704
  <html lang="en">
407
705
  <head>
@@ -455,8 +753,9 @@ export const r2Backup = (initialConfig) => (app) => {
455
753
  </h1>
456
754
  </div>
457
755
 
458
- <!-- Tabs -->
459
- <div class="flex p-1 bg-gray-200/50 rounded-xl">
756
+ <div class="flex items-center gap-4">
757
+ <!-- Tabs -->
758
+ <div class="flex p-1 bg-gray-200/50 rounded-xl">
460
759
  <button @click="activeTab = 'dashboard'; $nextTick(() => lucide.createIcons())"
461
760
  :class="activeTab === 'dashboard' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
462
761
  class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
@@ -476,6 +775,19 @@ export const r2Backup = (initialConfig) => (app) => {
476
775
  Settings
477
776
  </button>
478
777
  </div>
778
+
779
+ ${
780
+ config.auth && config.auth.enabled
781
+ ? `
782
+ <!-- Logout Button -->
783
+ <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">
784
+ <i data-lucide="log-out" class="w-4 h-4"></i>
785
+ <span>Logout</span>
786
+ </button>
787
+ `
788
+ : ''
789
+ }
790
+ </div>
479
791
  </div>
480
792
 
481
793
  <!-- Dashboard Tab -->
@@ -981,13 +1293,24 @@ export const r2Backup = (initialConfig) => (app) => {
981
1293
  } catch (err) {
982
1294
  this.addLog('Failed to save config: ' + err.message, 'error')
983
1295
  }
1296
+ },
1297
+
1298
+ async logout() {
1299
+ try {
1300
+ await fetch('/backup/auth/logout', { method: 'POST' });
1301
+ window.location.href = '/backup/login';
1302
+ } catch (err) {
1303
+ console.error('Logout failed:', err);
1304
+ window.location.href = '/backup/login';
1305
+ }
984
1306
  }
985
1307
  }
986
1308
  }
987
1309
  </script>
988
1310
  </body>
989
1311
  </html>
990
- `;
991
- })
992
- );
993
- };
1312
+ `
1313
+ })
1314
+ )
1315
+ })
1316
+ }