From 6c3122d03d7bec0bda97b4ef4f234c6aa09314a2 Mon Sep 17 00:00:00 2001 From: xtcnet Date: Tue, 17 Mar 2026 21:57:49 +0700 Subject: [PATCH] feat(wg-public): add file manager UI with upload, rename, delete - Add File Manager card above REST API Documentation on /wg-public page with table showing name, size, modified date and action buttons - Upload: file picker button, enforces storage quota - Rename: inline editable row (Enter to confirm, Escape to cancel) - Delete: with confirmation dialog - Download: opens decrypted file in new tab - Add renameFile() method to wireguard-fs.js (fs.rename, no re-encryption) - Add PATCH /api/wg-public/files/:filename endpoint for rename - Fix bug: saveEncryptedFile -> uploadFile in wg_public.js - Fix bug: getDecryptedFileStream + pipe -> downloadFile in wg_public.js - Add Rename curl example to REST API Documentation section Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/wireguard-fs.js | 20 + backend/routes/wg_public.js | 24 +- frontend/src/pages/WgPublicPortal/index.tsx | 510 +++++++++++++++----- 3 files changed, 421 insertions(+), 133 deletions(-) diff --git a/backend/internal/wireguard-fs.js b/backend/internal/wireguard-fs.js index 590d110..be1c61e 100644 --- a/backend/internal/wireguard-fs.js +++ b/backend/internal/wireguard-fs.js @@ -168,5 +168,25 @@ export default { await fs.promises.unlink(filePath); } return { success: true }; + }, + + /** + * Rename an encrypted file (no re-encryption needed, just fs.rename) + */ + async renameFile(ipv4Address, oldName, newName) { + const dir = this.getClientDir(ipv4Address); + const safeOld = path.basename(oldName); + const safeNew = path.basename(newName); + const oldPath = path.join(dir, safeOld); + const newPath = path.join(dir, safeNew); + + if (!fs.existsSync(oldPath)) { + throw new Error("File not found"); + } + if (fs.existsSync(newPath)) { + throw new Error("File name already exists"); + } + await fs.promises.rename(oldPath, newPath); + return { success: true }; } }; diff --git a/backend/routes/wg_public.js b/backend/routes/wg_public.js index 5c01eb5..c544e12 100644 --- a/backend/routes/wg_public.js +++ b/backend/routes/wg_public.js @@ -96,7 +96,7 @@ router.post("/files", async (req, res, next) => { } } - await internalWireguardFs.saveEncryptedFile(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, file.name, file.data); + await internalWireguardFs.uploadFile(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, file.name, file.data); res.status(200).json({ success: true, message: "File encrypted and saved safely via your Wireguard IP Auth!" }); } catch (err) { next(err); @@ -110,9 +110,25 @@ router.post("/files", async (req, res, next) => { router.get("/files/:filename", async (req, res, next) => { try { const filename = req.params.filename; - const fileStream = await internalWireguardFs.getDecryptedFileStream(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, filename); - res.attachment(filename); - fileStream.pipe(res); + await internalWireguardFs.downloadFile(req.wgClient.ipv4_address, req.wgClient.pre_shared_key, filename, res); + } catch (err) { + next(err); + } +}); + +/** + * PATCH /api/wg-public/files/:filename + * Rename an encrypted file + */ +router.patch("/files/:filename", async (req, res, next) => { + try { + const oldName = req.params.filename; + const newName = req.body?.name?.trim(); + if (!newName) { + return res.status(400).json({ error: { message: "New file name is required" } }); + } + await internalWireguardFs.renameFile(req.wgClient.ipv4_address, oldName, newName); + res.status(200).json({ success: true, message: "File renamed successfully" }); } catch (err) { next(err); } diff --git a/frontend/src/pages/WgPublicPortal/index.tsx b/frontend/src/pages/WgPublicPortal/index.tsx index 4f01765..6710d0e 100644 --- a/frontend/src/pages/WgPublicPortal/index.tsx +++ b/frontend/src/pages/WgPublicPortal/index.tsx @@ -1,5 +1,9 @@ -import { useState, useEffect } from "react"; -import { IconShieldLock, IconNetwork, IconApi, IconFolders } from "@tabler/icons-react"; +import { useState, useEffect, useRef } from "react"; +import { + IconShieldLock, IconNetwork, IconApi, IconFolders, + IconUpload, IconDownload, IconTrash, IconPencil, + IconCheck, IconX, IconRefresh, IconFile, +} from "@tabler/icons-react"; function formatBytes(bytes: number | null, unit?: string): string { if (bytes === null || bytes === 0) return unit ? `0.00 ${unit}` : "0 B"; @@ -15,131 +19,379 @@ function formatBytes(bytes: number | null, unit?: string): string { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } -export default function WgPublicPortal() { - const [client, setClient] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - - useEffect(() => { - fetch("/api/wg-public/me") - .then(res => res.json().then(data => ({ status: res.status, data }))) - .then(({ status, data }) => { - if (status === 200) { - setClient(data); - } else { - setError(data.error?.message || "Unauthorized context"); - } - }) - .catch((e) => setError("Network Error: " + e.message)) - .finally(() => setLoading(false)); - }, []); - - if (loading) { - return ( -
-

Verifying WireGuard Tunnel...

-
- ); - } - - if (error || !client) { - return ( -
-
- -

Access Denied

-

- This portal is restricted to devices actively connected through the WireGuard VPN.
- {error} -

-
-
- ); - } - - return ( -
-
-
-
-
-
-

- - Secure Intranet Connection Active -

-
-
-
-
-
Assigned IP
-

{client.ipv4_address}

-
-
-
Client Name
-

{client.name}

-
-
-
Storage Quota
-

- {formatBytes(client.storage_usage_bytes)} / {client.storage_limit_mb ? formatBytes(client.storage_limit_mb * 1024 * 1024) : "Unlimited"} -

-
-
-
Traffic Throttle (RX/TX)
-

- {client.rx_limit ? formatBytes(client.rx_limit) + "/s" : "Unlimited"} / {client.tx_limit ? formatBytes(client.tx_limit) + "/s" : "Unlimited"} -

-
-
-
-
- - {/* API Capabilities */} -
-
-

- - REST API Documentation -

-
-
-

- You can access your isolated AES-256 encrypted storage partition directly through these headless programmatic endpoints securely. -

- -
-

List Files

- - GET http://{window.location.host}/api/wg-public/files - -
- -
-

Upload File

- - curl -F "file=@/path/to/local/file.txt" http://{window.location.host}/api/wg-public/files - -
- -
-

Download File

- - curl -O http://{window.location.host}/api/wg-public/files/filename.txt - -
- -
-

Delete File

- - curl -X DELETE http://{window.location.host}/api/wg-public/files/filename.txt - -
-
-
-
-
-
-
- ); +function formatDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleString(); +} + +interface FileEntry { + name: string; + size: number; + created: string; + modified: string; +} + +interface RenameState { + oldName: string; + newName: string; +} + +export default function WgPublicPortal() { + const [client, setClient] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [files, setFiles] = useState([]); + const [filesLoading, setFilesLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [renaming, setRenaming] = useState(null); + const [actionError, setActionError] = useState(""); + + const fileInputRef = useRef(null); + + useEffect(() => { + fetch("/api/wg-public/me") + .then(res => res.json().then(data => ({ status: res.status, data }))) + .then(({ status, data }) => { + if (status === 200) { + setClient(data); + loadFiles(); + } else { + setError(data.error?.message || "Unauthorized context"); + } + }) + .catch((e) => setError("Network Error: " + e.message)) + .finally(() => setLoading(false)); + }, []); + + const loadFiles = async () => { + setFilesLoading(true); + setActionError(""); + try { + const res = await fetch("/api/wg-public/files"); + const data = await res.json(); + setFiles(Array.isArray(data) ? data : []); + } catch (e: any) { + setActionError("Failed to load files: " + e.message); + } + setFilesLoading(false); + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + setActionError(""); + try { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/wg-public/files", { method: "POST", body: formData }); + const data = await res.json(); + if (!res.ok) { + setActionError(data.error?.message || "Upload failed"); + } else { + await loadFiles(); + } + } catch (e: any) { + setActionError("Upload error: " + e.message); + } + setUploading(false); + e.target.value = ""; + }; + + const handleDownload = (filename: string) => { + window.open(`/api/wg-public/files/${encodeURIComponent(filename)}`, "_blank"); + }; + + const handleDelete = async (filename: string) => { + if (!window.confirm(`Delete "${filename}"?`)) return; + setActionError(""); + try { + const res = await fetch(`/api/wg-public/files/${encodeURIComponent(filename)}`, { method: "DELETE" }); + const data = await res.json(); + if (!res.ok) { + setActionError(data.error?.message || "Delete failed"); + } else { + await loadFiles(); + } + } catch (e: any) { + setActionError("Delete error: " + e.message); + } + }; + + const handleRenameConfirm = async () => { + if (!renaming) return; + const { oldName, newName } = renaming; + if (!newName.trim() || newName.trim() === oldName) { + setRenaming(null); + return; + } + setActionError(""); + try { + const res = await fetch(`/api/wg-public/files/${encodeURIComponent(oldName)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName.trim() }), + }); + const data = await res.json(); + if (!res.ok) { + setActionError(data.error?.message || "Rename failed"); + } else { + setRenaming(null); + await loadFiles(); + } + } catch (e: any) { + setActionError("Rename error: " + e.message); + } + }; + + if (loading) { + return ( +
+

Verifying WireGuard Tunnel...

+
+ ); + } + + if (error || !client) { + return ( +
+
+ +

Access Denied

+

+ This portal is restricted to devices actively connected through the WireGuard VPN.
+ {error} +

+
+
+ ); + } + + return ( +
+
+
+
+ + {/* Connection Info */} +
+
+

+ + Secure Intranet Connection Active +

+
+
+
+
+
Assigned IP
+

{client.ipv4_address}

+
+
+
Client Name
+

{client.name}

+
+
+
Storage Quota
+

+ {formatBytes(client.storage_usage_bytes)} / {client.storage_limit_mb ? formatBytes(client.storage_limit_mb * 1024 * 1024) : "Unlimited"} +

+
+
+
Traffic Throttle (RX/TX)
+

+ {client.rx_limit ? formatBytes(client.rx_limit) + "/s" : "Unlimited"} / {client.tx_limit ? formatBytes(client.tx_limit) + "/s" : "Unlimited"} +

+
+
+
+
+ + {/* File Manager */} +
+
+

+ + File Manager +

+
+ + + +
+
+
+ {actionError && ( +
+ {actionError} +
+ )} +
+ + + + + + + + + + + + {filesLoading ? ( + + + + ) : files.length === 0 ? ( + + + + ) : ( + files.map((file) => ( + + + + + + + + )) + )} + +
NameSizeModifiedActions
+ Loading... +
+ No files yet. Upload your first file. +
+ + + {renaming?.oldName === file.name ? ( +
+ setRenaming({ ...renaming, newName: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") handleRenameConfirm(); + if (e.key === "Escape") setRenaming(null); + }} + style={{ maxWidth: 260 }} + /> + + +
+ ) : ( + {file.name} + )} +
{formatBytes(file.size)}{formatDate(file.modified)} +
+ + + +
+
+
+
+
+ + {/* REST API Documentation */} +
+
+

+ + REST API Documentation +

+
+
+

+ You can access your isolated AES-256 encrypted storage partition directly through these headless programmatic endpoints securely. +

+ +
+

List Files

+ + GET http://{window.location.host}/api/wg-public/files + +
+ +
+

Upload File

+ + curl -F "file=@/path/to/local/file.txt" http://{window.location.host}/api/wg-public/files + +
+ +
+

Download File

+ + curl -O http://{window.location.host}/api/wg-public/files/filename.txt + +
+ +
+

Rename File

+ + {`curl -X PATCH -H "Content-Type: application/json" -d '{"name":"newname.txt"}' http://${window.location.host}/api/wg-public/files/oldname.txt`} + +
+ +
+

Delete File

+ + curl -X DELETE http://{window.location.host}/api/wg-public/files/filename.txt + +
+
+
+ +
+
+
+
+ ); }