@riligar/elysia-backup 1.1.0 → 1.3.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/README.md +12 -12
- package/package.json +10 -6
- package/src/elysia-backup.js +1116 -373
package/src/elysia-backup.js
CHANGED
|
@@ -1,13 +1,60 @@
|
|
|
1
1
|
// https://bun.com/docs/runtime/s3
|
|
2
2
|
// https://elysiajs.com/plugins/html
|
|
3
|
-
import { Elysia, t } from
|
|
4
|
-
import { S3Client } from
|
|
5
|
-
import { CronJob } from
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
+
}
|
|
11
58
|
|
|
12
59
|
/**
|
|
13
60
|
* Elysia Plugin for R2/S3 Backup with UI (using native Bun.s3)
|
|
@@ -21,387 +68,786 @@ import { html } from "@elysiajs/html";
|
|
|
21
68
|
* @param {string} [config.prefix] - Optional prefix for S3 keys (e.g. 'backups/')
|
|
22
69
|
* @param {string} [config.cronSchedule] - Cron schedule expression
|
|
23
70
|
* @param {boolean} [config.cronEnabled] - Whether the cron schedule is enabled
|
|
24
|
-
* @param {string} [config.configPath] - Path to save runtime configuration (default: './
|
|
71
|
+
* @param {string} [config.configPath] - Path to save runtime configuration (default: './config.json')
|
|
25
72
|
*/
|
|
26
|
-
export const r2Backup =
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
}
|
|
73
|
+
export const r2Backup = initialConfig => app => {
|
|
74
|
+
// State to hold runtime configuration (allows UI updates)
|
|
75
|
+
const configPath = initialConfig.configPath || './config.json'
|
|
117
76
|
|
|
118
|
-
|
|
119
|
-
|
|
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)
|
|
120
91
|
}
|
|
121
|
-
}
|
|
122
92
|
}
|
|
123
|
-
};
|
|
124
93
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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}`)
|
|
138
175
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
)
|
|
156
205
|
} catch (e) {
|
|
157
|
-
|
|
206
|
+
console.error('Invalid cron schedule:', e.message)
|
|
158
207
|
}
|
|
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 [];
|
|
208
|
+
}
|
|
200
209
|
}
|
|
201
|
-
};
|
|
202
210
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const file = s3.file(key);
|
|
211
|
+
// Initialize cron
|
|
212
|
+
setupCron()
|
|
206
213
|
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
}
|
|
209
243
|
}
|
|
210
244
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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();
|
|
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`)
|
|
255
251
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
259
278
|
}
|
|
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
279
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
}),
|
|
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
|
+
}
|
|
330
299
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
}
|
|
351
334
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
}),
|
|
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
|
+
}
|
|
397
367
|
}
|
|
398
|
-
|
|
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>
|
|
399
477
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 `
|
|
405
851
|
<!DOCTYPE html>
|
|
406
852
|
<html lang="en">
|
|
407
853
|
<head>
|
|
@@ -455,8 +901,9 @@ export const r2Backup = (initialConfig) => (app) => {
|
|
|
455
901
|
</h1>
|
|
456
902
|
</div>
|
|
457
903
|
|
|
458
|
-
|
|
459
|
-
|
|
904
|
+
<div class="flex items-center gap-4">
|
|
905
|
+
<!-- Tabs -->
|
|
906
|
+
<div class="flex p-1 bg-gray-200/50 rounded-xl">
|
|
460
907
|
<button @click="activeTab = 'dashboard'; $nextTick(() => lucide.createIcons())"
|
|
461
908
|
:class="activeTab === 'dashboard' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
462
909
|
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
@@ -476,6 +923,19 @@ export const r2Backup = (initialConfig) => (app) => {
|
|
|
476
923
|
Settings
|
|
477
924
|
</button>
|
|
478
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>
|
|
479
939
|
</div>
|
|
480
940
|
|
|
481
941
|
<!-- Dashboard Tab -->
|
|
@@ -773,6 +1233,168 @@ export const r2Backup = (initialConfig) => (app) => {
|
|
|
773
1233
|
</div>
|
|
774
1234
|
</form>
|
|
775
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>
|
|
776
1398
|
</div>
|
|
777
1399
|
</div>
|
|
778
1400
|
<script>
|
|
@@ -814,6 +1436,19 @@ export const r2Backup = (initialConfig) => (app) => {
|
|
|
814
1436
|
config: ${JSON.stringify(config)},
|
|
815
1437
|
cronStatus: ${JSON.stringify(jobStatus)},
|
|
816
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: '',
|
|
817
1452
|
|
|
818
1453
|
init() {
|
|
819
1454
|
// Initial load if needed
|
|
@@ -821,6 +1456,103 @@ export const r2Backup = (initialConfig) => (app) => {
|
|
|
821
1456
|
lucide.createIcons()
|
|
822
1457
|
})
|
|
823
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
|
+
},
|
|
824
1556
|
|
|
825
1557
|
addLog(message, type = 'info') {
|
|
826
1558
|
this.logs.unshift({
|
|
@@ -981,13 +1713,24 @@ export const r2Backup = (initialConfig) => (app) => {
|
|
|
981
1713
|
} catch (err) {
|
|
982
1714
|
this.addLog('Failed to save config: ' + err.message, 'error')
|
|
983
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
|
+
}
|
|
984
1726
|
}
|
|
985
1727
|
}
|
|
986
1728
|
}
|
|
987
1729
|
</script>
|
|
988
1730
|
</body>
|
|
989
1731
|
</html>
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
1732
|
+
`
|
|
1733
|
+
})
|
|
1734
|
+
)
|
|
1735
|
+
})
|
|
1736
|
+
}
|