feat: improve file list

This commit is contained in:
kieran 2025-01-27 11:15:26 +00:00
parent 0d8686a850
commit 5fbe40faae
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
8 changed files with 175141 additions and 73 deletions

View File

@ -1,11 +1,11 @@
use crate::auth::nip98::Nip98Auth; use crate::auth::nip98::Nip98Auth;
use crate::db::{Database, FileUpload}; use crate::db::{Database, FileUpload, User};
use crate::routes::{Nip94Event, PagedResult}; use crate::routes::{Nip94Event, PagedResult};
use crate::settings::Settings; use crate::settings::Settings;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::serde::Serialize; use rocket::serde::Serialize;
use rocket::{routes, Responder, Route, State}; use rocket::{routes, Responder, Route, State};
use sqlx::{Error, Row}; use sqlx::{Error, QueryBuilder, Row};
pub fn admin_routes() -> Vec<Route> { pub fn admin_routes() -> Vec<Route> {
routes![admin_list_files, admin_get_self] routes![admin_list_files, admin_get_self]
@ -55,6 +55,13 @@ pub struct SelfUser {
pub total_size: u64, pub total_size: u64,
} }
#[derive(Serialize)]
pub struct AdminNip94File {
#[serde(flatten)]
pub inner: Nip94Event,
pub uploader: Vec<String>,
}
#[rocket::get("/self")] #[rocket::get("/self")]
async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<SelfUser> { async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<SelfUser> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
@ -76,14 +83,15 @@ async fn admin_get_self(auth: Nip98Auth, db: &State<Database>) -> AdminResponse<
} }
} }
#[rocket::get("/files?<page>&<count>")] #[rocket::get("/files?<page>&<count>&<mime_type>")]
async fn admin_list_files( async fn admin_list_files(
auth: Nip98Auth, auth: Nip98Auth,
page: u32, page: u32,
count: u32, count: u32,
mime_type: Option<String>,
db: &State<Database>, db: &State<Database>,
settings: &State<Settings>, settings: &State<Settings>,
) -> AdminResponse<PagedResult<Nip94Event>> { ) -> AdminResponse<PagedResult<AdminNip94File>> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
let server_count = count.clamp(1, 5_000); let server_count = count.clamp(1, 5_000);
@ -95,14 +103,20 @@ async fn admin_list_files(
if !user.is_admin { if !user.is_admin {
return AdminResponse::error("User is not an admin"); return AdminResponse::error("User is not an admin");
} }
match db.list_all_files(page * server_count, server_count).await { match db
.list_all_files(page * server_count, server_count, mime_type)
.await
{
Ok((files, count)) => AdminResponse::success(PagedResult { Ok((files, count)) => AdminResponse::success(PagedResult {
count: files.len() as u32, count: files.len() as u32,
page, page,
total: count as u32, total: count as u32,
files: files files: files
.iter() .into_iter()
.map(|f| Nip94Event::from_upload(settings, f)) .map(|f| AdminNip94File {
inner: Nip94Event::from_upload(settings, &f.0),
uploader: f.1.into_iter().map(|u| hex::encode(&u.pubkey)).collect(),
})
.collect(), .collect(),
}), }),
Err(e) => AdminResponse::error(&format!("Could not list files: {}", e)), Err(e) => AdminResponse::error(&format!("Could not list files: {}", e)),
@ -114,21 +128,29 @@ impl Database {
&self, &self,
offset: u32, offset: u32,
limit: u32, limit: u32,
) -> Result<(Vec<FileUpload>, i64), Error> { mime_type: Option<String>,
let results: Vec<FileUpload> = sqlx::query_as( ) -> Result<(Vec<(FileUpload, Vec<User>)>, i64), Error> {
"select u.* \ let mut q = QueryBuilder::new("select u.* from uploads u ");
from uploads u \ if let Some(m) = mime_type {
order by u.created desc \ q.push("where u.mime_type = ");
limit ? offset ?", q.push_bind(m);
) }
.bind(limit) q.push(" order by u.created desc limit ");
.bind(offset) q.push_bind(limit);
.fetch_all(&self.pool) q.push(" offset ");
.await?; q.push_bind(offset);
let results: Vec<FileUpload> = q.build_query_as().fetch_all(&self.pool).await?;
let count: i64 = sqlx::query("select count(u.id) from uploads u") let count: i64 = sqlx::query("select count(u.id) from uploads u")
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await? .await?
.try_get(0)?; .try_get(0)?;
Ok((results, count))
let mut res = Vec::with_capacity(results.len());
for upload in results.into_iter() {
let upd = self.get_file_owners(&upload.id).await?;
res.push((upload, upd));
}
Ok((res, count))
} }
} }

View File

@ -2,19 +2,32 @@ import { hexToBech32 } from "@snort/shared";
import { NostrLink } from "@snort/system"; import { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
export default function Profile({ link }: { link: NostrLink }) { export default function Profile({
link,
size,
showName,
}: {
link: NostrLink;
size?: number;
showName?: boolean;
}) {
const profile = useUserProfile(link.id); const profile = useUserProfile(link.id);
const s = size ?? 40;
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<img <img
src={profile?.picture} src={profile?.picture}
className="rounded-full w-12 h-12 object-fit object-center" width={s}
height={s}
className="rounded-full object-fit object-center"
/> />
{(showName ?? true) && (
<div> <div>
{profile?.display_name ?? {profile?.display_name ??
profile?.name ?? profile?.name ??
hexToBech32("npub", link.id).slice(0, 12)} hexToBech32("npub", link.id).slice(0, 12)}
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -8,5 +8,5 @@ body {
} }
hr { hr {
@apply border-neutral-500 @apply border-neutral-500;
} }

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,11 @@ import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared"; import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system"; import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
export interface AdminSelf { is_admin: boolean, file_count: number, total_size: number } export interface AdminSelf {
is_admin: boolean;
file_count: number;
total_size: number;
}
export class Route96 { export class Route96 {
constructor( constructor(
@ -14,8 +18,7 @@ export class Route96 {
async getSelf() { async getSelf() {
const rsp = await this.#req("admin/self", "GET"); const rsp = await this.#req("admin/self", "GET");
const data = const data = await this.#handleResponse<AdminResponse<AdminSelf>>(rsp);
await this.#handleResponse<AdminResponse<AdminSelf>>(rsp);
return data; return data;
} }

View File

@ -51,9 +51,16 @@ export class Blossom {
} }
async mirror(url: string) { async mirror(url: string) {
const rsp = await this.#req("mirror", "PUT", "mirror", JSON.stringify({ url }), undefined, { const rsp = await this.#req(
"content-type": "application/json" "mirror",
}); "PUT",
"mirror",
JSON.stringify({ url }),
undefined,
{
"content-type": "application/json",
},
);
if (rsp.ok) { if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor; return (await rsp.json()) as BlobDescriptor;
} else { } else {

View File

@ -1,7 +1,8 @@
import { NostrEvent } from "@snort/system"; import { NostrEvent, NostrLink } from "@snort/system";
import { useState } from "react"; import { useState } from "react";
import { FormatBytes } from "../const"; import { FormatBytes } from "../const";
import classNames from "classnames"; import classNames from "classnames";
import Profile from "../components/profile";
interface FileInfo { interface FileInfo {
id: string; id: string;
@ -9,6 +10,7 @@ interface FileInfo {
name?: string; name?: string;
type?: string; type?: string;
size?: number; size?: number;
uploader?: Array<string>;
} }
export default function FileList({ export default function FileList({
@ -30,9 +32,17 @@ export default function FileList({
} }
function renderInner(f: FileInfo) { function renderInner(f: FileInfo) {
if (f.type?.startsWith("image/") || f.type?.startsWith("video/") || !f.type) { if (
f.type?.startsWith("image/") ||
f.type?.startsWith("video/") ||
!f.type
) {
return ( return (
<img src={f.url.replace(`/${f.id}`, `/thumb/${f.id}`)} className="w-full h-full object-contain object-center" loading="lazy" /> <img
src={f.url.replace(`/${f.id}`, `/thumb/${f.id}`)}
className="w-full h-full object-contain object-center"
loading="lazy"
/>
); );
} }
} }
@ -48,6 +58,7 @@ export default function FileList({
name: f.content, name: f.content,
type: f.tags.find((a) => a[0] === "m")?.at(1), type: f.tags.find((a) => a[0] === "m")?.at(1),
size: Number(f.tags.find((a) => a[0] === "size")?.at(1)), size: Number(f.tags.find((a) => a[0] === "size")?.at(1)),
uploader: "uploader" in f ? (f.uploader as Array<string>) : undefined,
}; };
} else { } else {
return { return {
@ -68,12 +79,14 @@ export default function FileList({
ret.push( ret.push(
<div <div
onClick={() => onPage?.(x)} onClick={() => onPage?.(x)}
className={classNames("bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold", className={classNames(
"bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold",
{ {
"rounded-l-md": x === start, "rounded-l-md": x === start,
"rounded-r-md": (x + 1) === n, "rounded-r-md": x + 1 === n,
"bg-neutral-400": page === x, "bg-neutral-400": page === x,
})} },
)}
> >
{x + 1} {x + 1}
</div>, </div>,
@ -96,24 +109,39 @@ export default function FileList({
> >
<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 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> <div>
{(info.name?.length ?? 0) === 0 ? "Untitled" : info.name} {(info.name?.length ?? 0) === 0
? "Untitled"
: info.name!.length > 20
? `${info.name?.substring(0, 10)}...${info.name?.substring(info.name.length - 10)}`
: info.name}
</div> </div>
<div> <div>
{info.size && !isNaN(info.size) {info.size && !isNaN(info.size)
? FormatBytes(info.size, 2) ? FormatBytes(info.size, 2)
: ""} : ""}
</div> </div>
<div>{info.type}</div>
<div className="flex gap-2"> <div className="flex gap-2">
<a href={info.url} className="underline" target="_blank"> <a href={info.url} className="underline" target="_blank">
Link Link
</a> </a>
{onDelete && <a href="#" onClick={e => { {onDelete && (
<a
href="#"
onClick={(e) => {
e.preventDefault(); e.preventDefault();
onDelete?.(info.id) onDelete?.(info.id);
}} className="underline"> }}
className="underline"
>
Delete Delete
</a>} </a>
)}
</div> </div>
{info.uploader &&
info.uploader.map((a) => (
<Profile link={NostrLink.publicKey(a)} size={20} />
))}
</div> </div>
{renderInner(info)} {renderInner(info)}
</div> </div>
@ -128,6 +156,9 @@ export default function FileList({
<table className="table-auto text-sm"> <table className="table-auto text-sm">
<thead> <thead>
<tr> <tr>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Preview
</th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Name Name
</th> </th>
@ -137,6 +168,11 @@ export default function FileList({
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Size Size
</th> </th>
{files.some((i) => "uploader" in i) && (
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Uploader
</th>
)}
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="border border-neutral-400 bg-neutral-500 py-1 px-2">
Actions Actions
</th> </th>
@ -147,6 +183,9 @@ export default function FileList({
const info = getInfo(a); const info = getInfo(a);
return ( return (
<tr key={info.id}> <tr key={info.id}>
<td className="border border-neutral-500 py-1 px-2 w-8 h-8">
{renderInner(info)}
</td>
<td className="border border-neutral-500 py-1 px-2 break-all"> <td className="border border-neutral-500 py-1 px-2 break-all">
{(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name} {(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name}
</td> </td>
@ -158,17 +197,30 @@ export default function FileList({
? FormatBytes(info.size, 2) ? FormatBytes(info.size, 2)
: ""} : ""}
</td> </td>
{info.uploader && (
<td className="border border-neutral-500 py-1 px-2">
{info.uploader.map((a) => (
<Profile link={NostrLink.publicKey(a)} size={20} />
))}
</td>
)}
<td className="border border-neutral-500 py-1 px-2"> <td className="border border-neutral-500 py-1 px-2">
<div className="flex gap-2"> <div className="flex gap-2">
<a href={info.url} className="underline" target="_blank"> <a href={info.url} className="underline" target="_blank">
Link Link
</a> </a>
{onDelete && <a href="#" onClick={e => { {onDelete && (
<a
href="#"
onClick={(e) => {
e.preventDefault(); e.preventDefault();
onDelete?.(info.id) onDelete?.(info.id);
}} className="underline"> }}
className="underline"
>
Delete Delete
</a>} </a>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -30,7 +30,8 @@ export default function Upload() {
const legacyFiles = Report as Record<string, Array<string>>; const legacyFiles = Report as Record<string, Array<string>>;
const myLegacyFiles = login ? (legacyFiles[login.pubkey] ?? []) : []; const myLegacyFiles = login ? (legacyFiles[login.pubkey] ?? []) : [];
const url = import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`; const url =
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`;
async function doUpload() { async function doUpload() {
if (!pub) return; if (!pub) return;
if (!toUpload) return; if (!toUpload) return;
@ -38,7 +39,9 @@ export default function Upload() {
setError(undefined); setError(undefined);
if (type === "blossom") { if (type === "blossom") {
const uploader = new Blossom(url, pub); const uploader = new Blossom(url, pub);
const result = noCompress ? await uploader.upload(toUpload) : await uploader.media(toUpload); const result = noCompress
? await uploader.upload(toUpload)
: await uploader.media(toUpload);
setResults((s) => [...s, result]); setResults((s) => [...s, result]);
} }
if (type === "nip96") { if (type === "nip96") {
@ -187,29 +190,33 @@ export default function Upload() {
</Button> </Button>
<Button <Button
className="flex-1" className="flex-1"
onClick={doUpload} disabled={login === undefined}> onClick={doUpload}
disabled={login === undefined}
>
Upload Upload
</Button> </Button>
</div> </div>
<hr /> <hr />
{!listedFiles && <Button disabled={login === undefined} onClick={() => listUploads(0)}> {!listedFiles && (
<Button disabled={login === undefined} onClick={() => listUploads(0)}>
List Uploads List Uploads
</Button>} </Button>
)}
{self && (
{self && <div className="flex justify-between font-medium"> <div className="flex justify-between font-medium">
<div>Uploads: {self.file_count.toLocaleString()}</div> <div>Uploads: {self.file_count.toLocaleString()}</div>
<div>Total Size: {FormatBytes(self.total_size)}</div> <div>Total Size: {FormatBytes(self.total_size)}</div>
</div>} </div>
)}
{login && myLegacyFiles.length > 0 && ( {login && myLegacyFiles.length > 0 && (
<div className="flex flex-col gap-4 font-bold"> <div className="flex flex-col gap-4 font-bold">
You have {myLegacyFiles.length.toLocaleString()} files which can be migrated from void.cat You have {myLegacyFiles.length.toLocaleString()} files which can be
migrated from void.cat
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => migrateLegacy()}> <Button onClick={() => migrateLegacy()}>Migrate Files</Button>
Migrate Files <Button onClick={() => setShowLegacy((s) => !s)}>
</Button>
<Button onClick={() => setShowLegacy(s => !s)}>
{!showLegacy ? "Show Files" : "Hide Files"} {!showLegacy ? "Show Files" : "Hide Files"}
</Button> </Button>
</div> </div>
@ -218,7 +225,10 @@ export default function Upload() {
)} )}
{showLegacy && ( {showLegacy && (
<FileList <FileList
files={myLegacyFiles.map(f => ({ id: f, url: `https://void.cat/d/${f}` }))} files={myLegacyFiles.map((f) => ({
id: f,
url: `https://void.cat/d/${f}`,
}))}
/> />
)} )}
{listedFiles && ( {listedFiles && (
@ -248,8 +258,7 @@ export default function Upload() {
onDelete={async (x) => { onDelete={async (x) => {
await deleteFile(x); await deleteFile(x);
await listAllUploads(adminListedPage); await listAllUploads(adminListedPage);
} }}
}
/> />
)} )}
</> </>