feat: admin delete

feat: total usage stats
This commit is contained in:
kieran 2024-12-16 12:52:27 +00:00
parent b30b90cb41
commit 961a6910ac
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
13 changed files with 228 additions and 63 deletions

View File

@ -15,7 +15,7 @@ path = "src/bin/main.rs"
name = "route96"
[features]
default = ["nip96", "blossom", "analytics", "ranges"]
default = ["nip96", "blossom", "analytics", "ranges", "react-ui"]
media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"]
labels = ["nip96", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"]
nip96 = ["media-compression"]
@ -25,6 +25,7 @@ torrent-v2 = []
analytics = []
void-cat-redirects = ["dep:sqlx-postgres"]
ranges = ["dep:http-range-header"]
react-ui = []
[dependencies]
log = "0.4.21"

View File

@ -51,6 +51,12 @@ impl FileLabel {
}
}
#[derive(Clone, FromRow, Serialize)]
pub struct UserStats {
pub file_count: u64,
pub total_size: u64,
}
#[derive(Clone)]
pub struct Database {
pub(crate) pool: sqlx::pool::Pool<sqlx::mysql::MySql>,
@ -88,6 +94,19 @@ impl Database {
.await
}
pub async fn get_user_stats(&self, id: u64) -> Result<UserStats, Error> {
sqlx::query_as(
"select cast(count(user_uploads.file) as unsigned integer) as file_count, \
cast(sum(uploads.size) as unsigned integer) as total_size \
from user_uploads,uploads \
where user_uploads.user_id = ? \
and user_uploads.file = uploads.id",
)
.bind(id)
.fetch_one(&self.pool)
.await
}
pub async fn get_user_id(&self, pubkey: &Vec<u8>) -> Result<u64, Error> {
sqlx::query("select id from users where pubkey = ?")
.bind(pubkey)
@ -167,6 +186,14 @@ impl Database {
Ok(())
}
pub async fn delete_all_file_owner(&self, file: &Vec<u8>) -> Result<(), Error> {
sqlx::query("delete from user_uploads where file = ?")
.bind(file)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_file(&self, file: &Vec<u8>) -> Result<(), Error> {
sqlx::query("delete from uploads where id = ?")
.bind(file)

View File

@ -1,5 +1,5 @@
use crate::auth::nip98::Nip98Auth;
use crate::db::{Database, FileUpload, User};
use crate::db::{Database, FileUpload};
use crate::routes::{Nip94Event, PagedResult};
use crate::settings::Settings;
use rocket::serde::json::Json;
@ -48,11 +48,30 @@ impl<T> AdminResponse<T> {
}
}
#[derive(Serialize)]
pub struct SelfUser {
pub is_admin: bool,
pub file_count: u64,
pub total_size: u64,
}
#[rocket::get("/self")]
async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<User> {
async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<SelfUser> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
match db.get_user(&pubkey_vec).await {
Ok(user) => AdminResponse::success(user),
Ok(user) => {
let s = match db.get_user_stats(user.id).await {
Ok(r) => r,
Err(e) => {
return AdminResponse::error(&format!("Failed to load user stats: {}", e))
}
};
AdminResponse::success(SelfUser {
is_admin: user.is_admin,
file_count: s.file_count,
total_size: s.total_size,
})
}
Err(_) => AdminResponse::error("User not found"),
}
}
@ -66,7 +85,7 @@ async fn admin_list_files(
settings: &State<Settings>,
) -> AdminResponse<PagedResult<Nip94Event>> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
let server_count = count.min(5_000).max(1);
let server_count = count.clamp(1, 5_000);
let user = match db.get_user(&pubkey_vec).await {
Ok(user) => user,

View File

@ -253,22 +253,34 @@ async fn delete_file(
}
if let Ok(Some(_info)) = db.get_file(&id).await {
let pubkey_vec = auth.pubkey.to_bytes().to_vec();
let auth_user = db.get_user(&pubkey_vec).await?;
let owners = db.get_file_owners(&id).await?;
let this_owner = match owners.iter().find(|o| o.pubkey.eq(&pubkey_vec)) {
Some(o) => o,
None => return Err(Error::msg("You dont own this file, you cannot delete it")),
};
if let Err(e) = db.delete_file_owner(&id, this_owner.id).await {
return Err(Error::msg(format!("Failed to delete (db): {}", e)));
}
// only 1 owner was left, delete file completely
if owners.len() == 1 {
if auth_user.is_admin {
if let Err(e) = db.delete_all_file_owner(&id).await {
return Err(Error::msg(format!("Failed to delete (db): {}", e)));
}
if let Err(e) = db.delete_file(&id).await {
return Err(Error::msg(format!("Failed to delete (fs): {}", e)));
}
if let Err(e) = tokio::fs::remove_file(fs.get(&id)).await {
return Err(Error::msg(format!("Failed to delete (fs): {}", e)));
warn!("Failed to delete (fs): {}", e);
}
} else {
let this_owner = match owners.iter().find(|o| o.pubkey.eq(&pubkey_vec)) {
Some(o) => o,
None => return Err(Error::msg("You dont own this file, you cannot delete it")),
};
if let Err(e) = db.delete_file_owner(&id, this_owner.id).await {
return Err(Error::msg(format!("Failed to delete (db): {}", e)));
}
// only 1 owner was left, delete file completely
if owners.len() == 1 {
if let Err(e) = db.delete_file(&id).await {
return Err(Error::msg(format!("Failed to delete (fs): {}", e)));
}
if let Err(e) = tokio::fs::remove_file(fs.get(&id)).await {
warn!("Failed to delete (fs): {}", e);
}
}
}
Ok(())
@ -279,10 +291,12 @@ async fn delete_file(
#[rocket::get("/")]
pub async fn root() -> Result<NamedFile, Status> {
#[cfg(debug_assertions)]
let index = "./index.html";
#[cfg(not(debug_assertions))]
#[cfg(all(debug_assertions, feature = "react-ui"))]
let index = "./ui_src/dist/index.html";
#[cfg(all(not(debug_assertions), feature = "react-ui"))]
let index = "./ui/index.html";
#[cfg(not(feature = "react-ui"))]
let index = "./index.html";
if let Ok(f) = NamedFile::open(index).await {
Ok(f)
} else {

View File

@ -14,6 +14,7 @@
"@snort/shared": "^1.0.17",
"@snort/system": "^1.5.1",
"@snort/system-react": "^1.5.1",
"classnames": "^2.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@ -22,7 +22,7 @@ export default function Button({
}
return (
<button
className={`py-2 px-4 rounded-md border-0 text-sm font-semibold bg-neutral-600 hover:bg-neutral-500 ${className} ${props.disabled ? "opacity-50" : ""}`}
className={`py-2 px-4 rounded-md border-0 text-sm font-semibold bg-neutral-700 hover:bg-neutral-600 ${className} ${props.disabled ? "opacity-50" : ""}`}
onClick={doClick}
{...props}
disabled={loading || (props.disabled ?? false)}

View File

@ -4,5 +4,9 @@
html,
body {
@apply bg-neutral-900 text-white;
@apply bg-black text-white;
}
hr {
@apply border-neutral-500
}

View File

@ -2,6 +2,8 @@ import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
export interface AdminSelf { is_admin: boolean, file_count: number, total_size: number }
export class Route96 {
constructor(
readonly url: string,
@ -11,15 +13,15 @@ export class Route96 {
}
async getSelf() {
const rsp = await this.#req("/admin/self", "GET");
const rsp = await this.#req("admin/self", "GET");
const data =
await this.#handleResponse<AdminResponse<{ is_admin: boolean }>>(rsp);
await this.#handleResponse<AdminResponse<AdminSelf>>(rsp);
return data;
}
async listFiles(page = 0, count = 10) {
const rsp = await this.#req(
`/admin/files?page=${page}&count=${count}`,
`admin/files?page=${page}&count=${count}`,
"GET",
);
const data = await this.#handleResponse<AdminResponseFileList>(rsp);

View File

@ -25,7 +25,7 @@ export class Blossom {
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("/upload", "PUT", file, tags);
const rsp = await this.#req("upload", "PUT", "upload", file, tags);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
@ -41,7 +41,7 @@ export class Blossom {
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("/media", "PUT", file, tags);
const rsp = await this.#req("media", "PUT", "upload", file, tags);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
@ -50,9 +50,30 @@ export class Blossom {
}
}
async list(pk: string) {
const rsp = await this.#req(`list/${pk}`, "GET", "list");
if (rsp.ok) {
return (await rsp.json()) as Array<BlobDescriptor>;
} else {
const text = await rsp.text();
throw new Error(text);
}
}
async delete(id: string) {
const tags = [["x", id]];
const rsp = await this.#req(id, "DELETE", "delete", undefined, tags);
if (!rsp.ok) {
const text = await rsp.text();
throw new Error(text);
}
}
async #req(
path: string,
method: "GET" | "POST" | "DELETE" | "PUT",
term: string,
body?: BodyInit,
tags?: Array<Array<string>>,
) {
@ -64,8 +85,8 @@ export class Blossom {
const auth = await this.publisher.generic((eb) => {
eb.kind(24_242 as EventKind)
.tag(["u", url])
.tag(["method", method])
.tag(["t", path.slice(1)])
.tag(["method", method.toLowerCase()])
.tag(["t", term])
.tag(["expiration", (now + 10).toString()]);
tags?.forEach((t) => eb.tag(t));
return eb;

View File

@ -1,6 +1,7 @@
import { NostrEvent } from "@snort/system";
import { useState } from "react";
import { FormatBytes } from "../const";
import classNames from "classnames";
interface FileInfo {
id: string;
@ -15,11 +16,13 @@ export default function FileList({
pages,
page,
onPage,
onDelete,
}: {
files: Array<File | NostrEvent>;
pages?: number;
page?: number;
onPage?: (n: number) => void;
onDelete?: (id: string) => void;
}) {
const [viewType, setViewType] = useState<"grid" | "list">("grid");
if (files.length === 0) {
@ -68,7 +71,12 @@ export default function FileList({
ret.push(
<div
onClick={() => onPage?.(x)}
className={`bg-neutral-800 hover:bg-neutral-700 min-w-8 text-center cursor-pointer font-bold ${x === start ? "rounded-l-md" : ""} ${x === n - 1 ? "rounded-r-md" : ""} ${page === x ? "bg-neutral-500" : ""}`}
className={classNames("bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold",
{
"rounded-l-md": x === start,
"rounded-r-md": (x + 1) === n,
"bg-neutral-400": page === x,
})}
>
{x + 1}
</div>,
@ -87,9 +95,9 @@ export default function FileList({
return (
<div
key={info.id}
className="relative rounded-md aspect-square overflow-hidden bg-neutral-800"
className="relative rounded-md aspect-square overflow-hidden bg-neutral-900"
>
<div className="absolute flex flex-col items-center justify-center w-full h-full text-wrap text-sm break-all text-center opacity-0 hover:opacity-100 hover:bg-black/60">
<div className="absolute flex flex-col items-center justify-center w-full h-full text-wrap text-sm break-all text-center opacity-0 hover:opacity-100 hover:bg-black/80">
<div>
{(info.name?.length ?? 0) === 0 ? "Untitled" : info.name}
</div>
@ -98,9 +106,17 @@ export default function FileList({
? FormatBytes(info.size, 2)
: ""}
</div>
<a href={info.url} target="_blank" className="underline">
Link
</a>
<div className="flex gap-2">
<a href={info.url} className="underline" target="_blank">
Link
</a>
<a href="#" onClick={e => {
e.preventDefault();
onDelete?.(info.id)
}} className="underline">
Delete
</a>
</div>
</div>
{renderInner(info)}
</div>
@ -118,6 +134,9 @@ export default function FileList({
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Name
</th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Type
</th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Size
</th>
@ -131,18 +150,29 @@ export default function FileList({
const info = getInfo(a);
return (
<tr key={info.id}>
<td className="border border-neutral-500 py-1 px-2">
<td className="border border-neutral-500 py-1 px-2 break-all">
{(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name}
</td>
<td className="border border-neutral-500 py-1 px-2 break-all">
{info.type}
</td>
<td className="border border-neutral-500 py-1 px-2">
{info.size && !isNaN(info.size)
? FormatBytes(info.size, 2)
: ""}
</td>
<td className="border border-neutral-500 py-1 px-2">
<a href={info.url} className="underline" target="_blank">
Link
</a>
<div className="flex gap-2">
<a href={info.url} className="underline" target="_blank">
Link
</a>
<a href="#" onClick={e => {
e.preventDefault();
onDelete?.(info.id)
}} className="underline">
Delete
</a>
</div>
</td>
</tr>
);
@ -157,13 +187,13 @@ export default function FileList({
<div className="flex">
<div
onClick={() => setViewType("grid")}
className={`bg-neutral-800 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-l-md ${viewType === "grid" ? "bg-neutral-500" : ""}`}
className={`bg-neutral-700 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-l-md ${viewType === "grid" ? "bg-neutral-500" : ""}`}
>
Grid
</div>
<div
onClick={() => setViewType("list")}
className={`bg-neutral-800 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-r-md ${viewType === "list" ? "bg-neutral-500" : ""}`}
className={`bg-neutral-700 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-r-md ${viewType === "list" ? "bg-neutral-500" : ""}`}
>
List
</div>
@ -171,8 +201,7 @@ export default function FileList({
{viewType === "grid" ? showGrid() : showList()}
{pages !== undefined && (
<>
Page:
<div className="flex">{pageButtons(page ?? 0, pages)}</div>
<div className="flex flex-wrap">{pageButtons(page ?? 0, pages)}</div>
</>
)}
</>

View File

@ -6,13 +6,14 @@ import { Blossom } from "../upload/blossom";
import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96";
import { Route96 } from "../upload/admin";
import { AdminSelf, Route96 } from "../upload/admin";
import { FormatBytes } from "../const";
export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("nip96");
const [type, setType] = useState<"blossom" | "nip96">("blossom");
const [noCompress, setNoCompress] = useState(false);
const [toUpload, setToUpload] = useState<File>();
const [self, setSelf] = useState<{ is_admin: boolean }>();
const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<object>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
@ -88,6 +89,23 @@ export default function Upload() {
}
}
async function deleteFile(id: string) {
if (!pub) return;
try {
setError(undefined);
const uploader = new Blossom(url, pub);
await uploader.delete(id);
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List files failed");
}
}
}
useEffect(() => {
listUploads(listedPage);
}, [listedPage]);
@ -104,7 +122,7 @@ export default function Upload() {
}, [pub, self]);
return (
<div className="flex flex-col gap-2 bg-neutral-700 p-8 rounded-xl w-full">
<div className="flex flex-col gap-2 bg-neutral-800 p-8 rounded-xl w-full">
<h1 className="text-lg font-bold">
Welcome to {window.location.hostname}
</h1>
@ -136,41 +154,62 @@ export default function Upload() {
<input type="checkbox" checked={noCompress} />
</div>
<Button
onClick={async () => {
const f = await openFile();
setToUpload(f);
}}
>
Choose Files
</Button>
<FileList files={toUpload ? [toUpload] : []} />
<Button onClick={doUpload} disabled={login === undefined}>
Upload
</Button>
<Button disabled={login === undefined} onClick={() => listUploads(0)}>
{toUpload && <FileList files={toUpload ? [toUpload] : []} />}
<div className="flex gap-4">
<Button
className="flex-1"
onClick={async () => {
const f = await openFile();
setToUpload(f);
}}
>
Choose Files
</Button>
<Button
className="flex-1"
onClick={doUpload} disabled={login === undefined}>
Upload
</Button>
</div>
<hr />
{!listedFiles && <Button disabled={login === undefined} onClick={() => listUploads(0)}>
List Uploads
</Button>
</Button>}
{self && <div className="flex justify-between font-medium">
<div>Uploads: {self.file_count.toLocaleString()}</div>
<div>Total Size: {FormatBytes(self.total_size)}</div>
</div>}
{listedFiles && (
<FileList
files={listedFiles.files}
pages={listedFiles.total / listedFiles.count}
pages={Math.ceil(listedFiles.total / listedFiles.count)}
page={listedFiles.page}
onPage={(x) => setListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listUploads(listedPage);
}}
/>
)}
{self?.is_admin && (
<>
<hr />
<h3>Admin File List:</h3>
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
{adminListedFiles && (
<FileList
files={adminListedFiles.files}
pages={adminListedFiles.total / adminListedFiles.count}
pages={Math.ceil(adminListedFiles.total / adminListedFiles.count)}
page={adminListedFiles.page}
onPage={(x) => setAdminListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listAllUploads(adminListedPage);
}
}
/>
)}
</>

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/upload.tsx"],"version":"5.6.2"}
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/upload.tsx"],"version":"5.6.2"}

View File

@ -1455,6 +1455,13 @@ __metadata:
languageName: node
linkType: hard
"classnames@npm:^2.5.1":
version: 2.5.1
resolution: "classnames@npm:2.5.1"
checksum: 10c0/afff4f77e62cea2d79c39962980bf316bacb0d7c49e13a21adaadb9221e1c6b9d3cdb829d8bb1b23c406f4e740507f37e1dcf506f7e3b7113d17c5bab787aa69
languageName: node
linkType: hard
"clean-stack@npm:^2.0.0":
version: 2.2.0
resolution: "clean-stack@npm:2.2.0"
@ -3721,6 +3728,7 @@ __metadata:
"@types/react-dom": "npm:^18.3.0"
"@vitejs/plugin-react": "npm:^4.3.1"
autoprefixer: "npm:^10.4.20"
classnames: "npm:^2.5.1"
eslint: "npm:^9.9.0"
eslint-plugin-react-hooks: "npm:^5.1.0-rc.0"
eslint-plugin-react-refresh: "npm:^0.4.9"