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 <noreply@anthropic.com>
This commit is contained in:
parent
dd525adaef
commit
6c3122d03d
3 changed files with 421 additions and 133 deletions
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,17 +19,43 @@ function formatBytes(bytes: number | null, unit?: string): string {
|
|||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
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<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [renaming, setRenaming] = useState<RenameState | null>(null);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(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");
|
||||
}
|
||||
|
|
@ -34,6 +64,87 @@ export default function WgPublicPortal() {
|
|||
.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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="page page-center bg-dark text-white text-center">
|
||||
|
|
@ -62,6 +173,8 @@ export default function WgPublicPortal() {
|
|||
<div className="container-xl py-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-12 col-md-10">
|
||||
|
||||
{/* Connection Info */}
|
||||
<div className="card bg-dark text-light border-secondary mb-4">
|
||||
<div className="card-header border-secondary">
|
||||
<h3 className="card-title text-success d-flex align-items-center">
|
||||
|
|
@ -95,7 +208,138 @@ export default function WgPublicPortal() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Capabilities */}
|
||||
{/* File Manager */}
|
||||
<div className="card bg-dark text-light border-secondary mb-4">
|
||||
<div className="card-header border-secondary d-flex align-items-center justify-content-between">
|
||||
<h3 className="card-title d-flex align-items-center mb-0">
|
||||
<IconFolders className="me-2" />
|
||||
File Manager
|
||||
</h3>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={loadFiles}
|
||||
disabled={filesLoading}
|
||||
title="Refresh"
|
||||
>
|
||||
<IconRefresh size={16} className={filesLoading ? "spin" : ""} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="d-none"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<IconUpload size={16} className="me-1" />
|
||||
{uploading ? "Uploading..." : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body p-0">
|
||||
{actionError && (
|
||||
<div className="alert alert-danger m-3 mb-0 py-2">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-dark table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 32 }}></th>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 100 }}>Size</th>
|
||||
<th style={{ width: 180 }}>Modified</th>
|
||||
<th style={{ width: 120 }} className="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filesLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted py-4">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : files.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted py-4">
|
||||
No files yet. Upload your first file.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td className="text-muted ps-3">
|
||||
<IconFile size={16} />
|
||||
</td>
|
||||
<td>
|
||||
{renaming?.oldName === file.name ? (
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm bg-dark text-white border-secondary"
|
||||
value={renaming.newName}
|
||||
autoFocus
|
||||
onChange={(e) => setRenaming({ ...renaming, newName: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRenameConfirm();
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
style={{ maxWidth: 260 }}
|
||||
/>
|
||||
<button className="btn btn-sm btn-success p-1" onClick={handleRenameConfirm} title="Confirm">
|
||||
<IconCheck size={14} />
|
||||
</button>
|
||||
<button className="btn btn-sm btn-secondary p-1" onClick={() => setRenaming(null)} title="Cancel">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-light">{file.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted small">{formatBytes(file.size)}</td>
|
||||
<td className="text-muted small">{formatDate(file.modified)}</td>
|
||||
<td className="text-end pe-3">
|
||||
<div className="d-flex gap-1 justify-content-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary p-1"
|
||||
onClick={() => handleDownload(file.name)}
|
||||
title="Download"
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-warning p-1"
|
||||
onClick={() => setRenaming({ oldName: file.name, newName: file.name })}
|
||||
title="Rename"
|
||||
disabled={!!renaming}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger p-1"
|
||||
onClick={() => handleDelete(file.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* REST API Documentation */}
|
||||
<div className="card bg-dark text-light border-secondary">
|
||||
<div className="card-header border-secondary">
|
||||
<h3 className="card-title d-flex align-items-center">
|
||||
|
|
@ -129,6 +373,13 @@ export default function WgPublicPortal() {
|
|||
</code>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h4 className="text-info"><IconFolders size={18} className="me-2" />Rename File</h4>
|
||||
<code className="d-block p-3 bg-black rounded border border-secondary text-warning">
|
||||
{`curl -X PATCH -H "Content-Type: application/json" -d '{"name":"newname.txt"}' http://${window.location.host}/api/wg-public/files/oldname.txt`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-info"><IconFolders size={18} className="me-2" />Delete File</h4>
|
||||
<code className="d-block p-3 bg-black rounded border border-secondary text-danger">
|
||||
|
|
@ -137,6 +388,7 @@ export default function WgPublicPortal() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue