feat: admin list

This commit is contained in:
kieran 2024-10-04 18:00:37 +01:00
parent d7b332905b
commit 2b194ad10c
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
19 changed files with 395 additions and 29 deletions

View File

@ -19,4 +19,4 @@ steps:
- docker login -u kieran -p $TOKEN git.v0l.io - docker login -u kieran -p $TOKEN git.v0l.io
- docker login -u voidic -p $TOKEN_DOCKER - docker login -u voidic -p $TOKEN_DOCKER
- docker buildx build --push -t git.v0l.io/kieran/route96:latest -t voidic/route96:latest --build-arg FEATURES="labels" . - docker buildx build --push -t git.v0l.io/kieran/route96:latest -t voidic/route96:latest --build-arg FEATURES="labels" .
- kill $(cat /var/run/docker.pid) - kill $(cat /var/run/docker.pid)

3
Cargo.lock generated
View File

@ -1434,6 +1434,9 @@ name = "hex"
version = "0.4.3" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "hex-conservative" name = "hex-conservative"

View File

@ -30,7 +30,7 @@ pretty_env_logger = "0.5.0"
rocket = { version = "0.5.0", features = ["json"] } rocket = { version = "0.5.0", features = ["json"] }
tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] }
base64 = "0.22.1" base64 = "0.22.1"
hex = "0.4.3" hex = { version = "0.4.3", features = ["serde"] }
serde = { version = "1.0.198", features = ["derive"] } serde = { version = "1.0.198", features = ["derive"] }
uuid = { version = "1.8.0", features = ["v4"] } uuid = { version = "1.8.0", features = ["v4"] }
anyhow = "1.0.82" anyhow = "1.0.82"

View File

@ -3,6 +3,7 @@
Image hosting service Image hosting service
## Features ## Features
- [NIP-96 Support](https://github.com/nostr-protocol/nips/blob/master/96.md) - [NIP-96 Support](https://github.com/nostr-protocol/nips/blob/master/96.md)
- [Blossom Support](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - [Blossom Support](https://github.com/hzrd149/blossom/blob/master/buds/01.md)
- [BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - [BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md)
@ -13,18 +14,23 @@ Image hosting service
- AI image labeling ([ViT224](https://huggingface.co/google/vit-base-patch16-224)) - AI image labeling ([ViT224](https://huggingface.co/google/vit-base-patch16-224))
## Planned ## Planned
- Torrent seed V2 - Torrent seed V2
## Running ## Running
### Docker Compose ### Docker Compose
The easiest way to run `route96` is to use `docker compose` The easiest way to run `route96` is to use `docker compose`
```bash ```bash
docker compose -f docker-compose.prod.yml up docker compose -f docker-compose.prod.yml up
``` ```
### Manual ### Manual
Assuming you already created your `config.toml` and configured the `database` run: Assuming you already created your `config.toml` and configured the `database` run:
```bash ```bash
docker run --rm -it \ docker run --rm -it \
-p 8000:8000 \ -p 8000:8000 \
@ -36,19 +42,25 @@ docker run --rm -it \
## Building ## Building
### Feature Flags ### Feature Flags
Default = `nip96` & `blossom` Default = `nip96` & `blossom`
- `nip96`: Enable NIP-96 support - `nip96`: Enable NIP-96 support
- `blossom`: Enable blossom support - `blossom`: Enable blossom support
- `labels`: Enable AI image labeling (Depends on `nip96`) - `labels`: Enable AI image labeling (Depends on `nip96`)
### Default build: ### Default build:
`cargo build --release` `cargo build --release`
### Build only Blossom support ### Build only Blossom support
`cargo build --release --no-default-features --features blossom` `cargo build --release --no-default-features --features blossom`
### Build dependencies ### Build dependencies
If you want to support NIP-96 you will need the following dependencies: If you want to support NIP-96 you will need the following dependencies:
```bash ```bash
libavcodec-dev libavformat-dev libswscale-dev libavutil-dev libavdevice-dev libavfilter-dev libavcodec-dev libavformat-dev libswscale-dev libavutil-dev libavdevice-dev libavfilter-dev
``` ```

View File

@ -18,4 +18,4 @@ services:
- "8000:8000" - "8000:8000"
volumes: volumes:
- "files:/app/data" - "files:/app/data"
- "./config.prod.toml:/app/config.toml" - "./config.prod.toml:/app/config.toml"

View File

@ -9,4 +9,4 @@ services:
ports: ports:
- "3366:3306" - "3366:3306"
volumes: volumes:
- "db:/var/lib/mysql" - "db:/var/lib/mysql"

View File

@ -0,0 +1,2 @@
alter table users
add column is_admin bit(1) not null;

View File

@ -18,7 +18,7 @@ impl<'r> FromRequest<'r> for Nip98Auth {
type Error = &'static str; type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
return if let Some(auth) = request.headers().get_one("authorization") { if let Some(auth) = request.headers().get_one("authorization") {
if auth.starts_with("Nostr ") { if auth.starts_with("Nostr ") {
let event = if let Ok(j) = BASE64_STANDARD.decode(&auth[6..]) { let event = if let Ok(j) = BASE64_STANDARD.decode(&auth[6..]) {
if let Ok(ev) = Event::from_json(j) { if let Ok(ev) = Event::from_json(j) {
@ -103,6 +103,6 @@ impl<'r> FromRequest<'r> for Nip98Auth {
} }
} else { } else {
Outcome::Error((Status::new(403), "Auth header not found")) Outcome::Error((Status::new(403), "Auth header not found"))
}; }
} }
} }

View File

@ -62,7 +62,8 @@ async fn main() -> Result<(), Error> {
) )
.attach(CORS) .attach(CORS)
.attach(Shield::new()) // disable .attach(Shield::new()) // disable
.mount("/", routes![root, get_blob, head_blob]); .mount("/", routes![root, get_blob, head_blob])
.mount("/admin", routes::admin_routes());
#[cfg(feature = "analytics")] #[cfg(feature = "analytics")]
{ {

View File

@ -1,10 +1,11 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use sqlx::{Error, Executor, FromRow, Row};
use sqlx::migrate::MigrateError; use sqlx::migrate::MigrateError;
use sqlx::{Error, Executor, FromRow, Row};
#[derive(Clone, FromRow, Default, Serialize)] #[derive(Clone, FromRow, Default, Serialize)]
pub struct FileUpload { pub struct FileUpload {
#[serde(with = "hex")]
pub id: Vec<u8>, pub id: Vec<u8>,
pub name: String, pub name: String,
pub size: u64, pub size: u64,
@ -20,11 +21,13 @@ pub struct FileUpload {
pub labels: Vec<FileLabel>, pub labels: Vec<FileLabel>,
} }
#[derive(Clone, FromRow)] #[derive(Clone, FromRow, Serialize)]
pub struct User { pub struct User {
pub id: u64, pub id: u64,
#[serde(with = "hex")]
pub pubkey: Vec<u8>, pub pubkey: Vec<u8>,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
pub is_admin: bool,
} }
#[cfg(feature = "labels")] #[cfg(feature = "labels")]
@ -50,7 +53,7 @@ impl FileLabel {
#[derive(Clone)] #[derive(Clone)]
pub struct Database { pub struct Database {
pool: sqlx::pool::Pool<sqlx::mysql::MySql>, pub(crate) pool: sqlx::pool::Pool<sqlx::mysql::MySql>,
} }
impl Database { impl Database {
@ -78,6 +81,13 @@ impl Database {
} }
} }
pub async fn get_user(&self, pubkey: &Vec<u8>) -> Result<User, Error> {
sqlx::query_as("select * from users where pubkey = ?")
.bind(pubkey)
.fetch_one(&self.pool)
.await
}
pub async fn get_user_id(&self, pubkey: &Vec<u8>) -> Result<u64, Error> { pub async fn get_user_id(&self, pubkey: &Vec<u8>) -> Result<u64, Error> {
sqlx::query("select id from users where pubkey = ?") sqlx::query("select id from users where pubkey = ?")
.bind(pubkey) .bind(pubkey)
@ -178,8 +188,7 @@ impl Database {
"select count(uploads.id) from uploads, users, user_uploads \ "select count(uploads.id) from uploads, users, user_uploads \
where users.pubkey = ? \ where users.pubkey = ? \
and users.id = user_uploads.user_id \ and users.id = user_uploads.user_id \
and user_uploads.file = uploads.id \ and user_uploads.file = uploads.id")
order by uploads.created desc")
.bind(pubkey) .bind(pubkey)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await? .await?

125
src/routes/admin.rs Normal file
View File

@ -0,0 +1,125 @@
use crate::auth::nip98::Nip98Auth;
use crate::db::{Database, FileUpload, User};
use crate::routes::{Nip94Event, PagedResult};
use rocket::serde::json::Json;
use rocket::serde::Serialize;
use rocket::{routes, Responder, Route, State};
use sqlx::{Error, Row};
use crate::settings::Settings;
pub fn admin_routes() -> Vec<Route> {
routes![admin_list_files, admin_get_self]
}
#[derive(Serialize, Default)]
#[serde(crate = "rocket::serde")]
struct AdminResponseBase<T>
{
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
}
#[derive(Responder)]
enum AdminResponse<T>
{
#[response(status = 500)]
GenericError(Json<AdminResponseBase<T>>),
#[response(status = 200)]
Ok(Json<AdminResponseBase<T>>),
}
impl<T> AdminResponse<T>
{
pub fn error(msg: &str) -> Self {
Self::GenericError(Json(AdminResponseBase {
status: "error".to_string(),
message: Some(msg.to_string()),
data: None,
}))
}
pub fn success(msg: T) -> Self {
Self::Ok(Json(AdminResponseBase {
status: "success".to_string(),
message: None,
data: Some(msg),
}))
}
}
#[rocket::get("/self")]
async fn admin_get_self(
auth: Nip98Auth,
db: &State<Database>,
) -> AdminResponse<User> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
match db.get_user(&pubkey_vec).await {
Ok(user) => AdminResponse::success(user),
Err(_) => {
AdminResponse::error("User not found")
}
}
}
#[rocket::get("/files?<page>&<count>")]
async fn admin_list_files(
auth: Nip98Auth,
page: u32,
count: u32,
db: &State<Database>,
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 user = match db.get_user(&pubkey_vec).await {
Ok(user) => user,
Err(_) => {
return AdminResponse::error("User not found")
}
};
if !user.is_admin {
return AdminResponse::error("User is not an admin");
}
match db
.list_all_files(page * server_count, server_count)
.await
{
Ok((files, count)) => AdminResponse::success(PagedResult {
count: files.len() as u32,
page,
total: count as u32,
files: files
.iter()
.map(|f| Nip94Event::from_upload(settings, f))
.collect(),
}),
Err(e) => AdminResponse::error(&format!("Could not list files: {}", e)),
}
}
impl Database {
pub async fn list_all_files(&self, offset: u32, limit: u32) -> Result<(Vec<FileUpload>, i64), Error> {
let results: Vec<FileUpload> = sqlx::query_as(
"select u.* \
from uploads u \
order by u.created desc \
limit ? offset ?",
)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await?;
let count: i64 = sqlx::query(
"select count(u.id) from uploads u")
.fetch_one(&self.pool)
.await?
.try_get(0)?;
Ok((results, count))
}
}

View File

@ -4,6 +4,7 @@ use std::str::FromStr;
use crate::db::{Database, FileUpload}; use crate::db::{Database, FileUpload};
use crate::filesystem::FileStore; use crate::filesystem::FileStore;
pub use crate::routes::admin::admin_routes;
#[cfg(feature = "blossom")] #[cfg(feature = "blossom")]
pub use crate::routes::blossom::blossom_routes; pub use crate::routes::blossom::blossom_routes;
#[cfg(feature = "nip96")] #[cfg(feature = "nip96")]
@ -22,6 +23,8 @@ mod blossom;
#[cfg(feature = "nip96")] #[cfg(feature = "nip96")]
mod nip96; mod nip96;
mod admin;
pub struct FilePayload { pub struct FilePayload {
pub file: File, pub file: File,
pub info: FileUpload, pub info: FileUpload,
@ -35,6 +38,15 @@ struct Nip94Event {
pub tags: Vec<Vec<String>>, pub tags: Vec<Vec<String>>,
} }
#[derive(Serialize, Default)]
#[serde(crate = "rocket::serde")]
struct PagedResult<T> {
pub count: u32,
pub page: u32,
pub total: u32,
pub files: Vec<T>,
}
impl Nip94Event { impl Nip94Event {
pub fn from_upload(settings: &Settings, upload: &FileUpload) -> Self { pub fn from_upload(settings: &Settings, upload: &FileUpload) -> Self {
let hex_id = hex::encode(&upload.id); let hex_id = hex::encode(&upload.id);

View File

@ -15,7 +15,7 @@ use rocket::{routes, FromForm, Responder, Route, State};
use crate::auth::nip98::Nip98Auth; use crate::auth::nip98::Nip98Auth;
use crate::db::{Database, FileUpload}; use crate::db::{Database, FileUpload};
use crate::filesystem::FileStore; use crate::filesystem::FileStore;
use crate::routes::{delete_file, Nip94Event}; use crate::routes::{delete_file, Nip94Event, PagedResult};
use crate::settings::Settings; use crate::settings::Settings;
use crate::webhook::Webhook; use crate::webhook::Webhook;
@ -66,15 +66,6 @@ struct Nip96MediaTransformations {
pub video: Option<Vec<String>>, pub video: Option<Vec<String>>,
} }
#[derive(Serialize, Default)]
#[serde(crate = "rocket::serde")]
struct Nip96FileListResults {
pub count: u32,
pub page: u32,
pub total: u32,
pub files: Vec<Nip94Event>,
}
#[derive(Responder)] #[derive(Responder)]
enum Nip96Response { enum Nip96Response {
#[response(status = 500)] #[response(status = 500)]
@ -84,11 +75,11 @@ enum Nip96Response {
UploadResult(Json<Nip96UploadResult>), UploadResult(Json<Nip96UploadResult>),
#[response(status = 200)] #[response(status = 200)]
FileList(Json<Nip96FileListResults>), FileList(Json<PagedResult<Nip94Event>>),
} }
impl Nip96Response { impl Nip96Response {
fn error(msg: &str) -> Self { pub(crate)fn error(msg: &str) -> Self {
Nip96Response::GenericError(Json(Nip96UploadResult { Nip96Response::GenericError(Json(Nip96UploadResult {
status: "error".to_string(), status: "error".to_string(),
message: Some(msg.to_string()), message: Some(msg.to_string()),
@ -295,7 +286,7 @@ async fn list_files(
.list_files(&pubkey_vec, page * server_count, server_count) .list_files(&pubkey_vec, page * server_count, server_count)
.await .await
{ {
Ok((files, total)) => Nip96Response::FileList(Json(Nip96FileListResults { Ok((files, total)) => Nip96Response::FileList(Json(PagedResult {
count: server_count, count: server_count,
page, page,
total: total as u32, total: total as u32,

View File

@ -1,3 +1,7 @@
{ {
"recommendations": ["arcanis.vscode-zipfs", "dbaeumer.vscode-eslint"] "recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
} }

32
ui_src/.yarn/sdks/prettier/bin/prettier.cjs vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire, register } = require(`module`);
const { resolve } = require(`path`);
const { pathToFileURL } = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? (exports) => absRequire(absUserWrapperPath)(exports)
: (exports) => exports;
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = wrapWithUserWrapper(absRequire(`prettier/bin/prettier.cjs`));

32
ui_src/.yarn/sdks/prettier/index.cjs vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const { existsSync } = require(`fs`);
const { createRequire, register } = require(`module`);
const { resolve } = require(`path`);
const { pathToFileURL } = require(`url`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? (exports) => absRequire(absUserWrapperPath)(exports)
: (exports) => exports;
// Defer to the real prettier your application uses
module.exports = wrapWithUserWrapper(absRequire(`prettier`));

View File

@ -0,0 +1,7 @@
{
"name": "prettier",
"version": "3.3.3-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

View File

@ -0,0 +1,87 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
export class Route96 {
constructor(
readonly url: string,
readonly publisher: EventPublisher,
) {
this.url = new URL(this.url).toString();
}
async getSelf() {
const rsp = await this.#req("/admin/self", "GET");
const data =
await this.#handleResponse<AdminResponse<{ is_admin: boolean }>>(rsp);
return data;
}
async listFiles(page = 0, count = 10) {
const rsp = await this.#req(
`/admin/files?page=${page}&count=${count}`,
"GET",
);
const data = await this.#handleResponse<AdminResponseFileList>(rsp);
return {
...data,
...data.data,
files: data.data.files,
};
}
async #handleResponse<T extends AdminResponseBase>(rsp: Response) {
if (rsp.ok) {
return (await rsp.json()) as T;
} else {
const text = await rsp.text();
try {
const obj = JSON.parse(text) as AdminResponseBase;
throw new Error(obj.message);
} catch {
throw new Error(`Upload failed: ${text}`);
}
}
}
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) {
throwIfOffline();
const auth = async (url: string, method: string) => {
const auth = await this.publisher.generic((eb) => {
return eb
.kind(EventKind.HttpAuthentication)
.tag(["u", url])
.tag(["method", method]);
});
return `Nostr ${base64.encode(
new TextEncoder().encode(JSON.stringify(auth)),
)}`;
};
const u = `${this.url}${path}`;
return await fetch(u, {
method,
body,
headers: {
accept: "application/json",
authorization: await auth(u, method),
},
});
}
}
export interface AdminResponseBase {
status: string;
message?: string;
}
export type AdminResponse<T> = AdminResponseBase & {
data: T;
};
export type AdminResponseFileList = AdminResponse<{
total: number;
page: number;
count: number;
files: Array<NostrEvent>;
}>;

View File

@ -6,21 +6,25 @@ import { Blossom } from "../upload/blossom";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher"; import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96"; import { Nip96, Nip96FileList } from "../upload/nip96";
import { Route96 } from "../upload/admin";
export default function Upload() { export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("nip96"); const [type, setType] = useState<"blossom" | "nip96">("nip96");
const [noCompress, setNoCompress] = useState(false); const [noCompress, setNoCompress] = useState(false);
const [toUpload, setToUpload] = useState<File>(); const [toUpload, setToUpload] = useState<File>();
const [self, setSelf] = useState<{ is_admin: boolean }>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<object>>([]); const [results, setResults] = useState<Array<object>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>(); const [listedFiles, setListedFiles] = useState<Nip96FileList>();
const [adminListedFiles, setAdminListedFiles] = useState<Nip96FileList>();
const [listedPage, setListedPage] = useState(0); const [listedPage, setListedPage] = useState(0);
const [adminListedPage, setAdminListedPage] = useState(0);
const login = useLogin(); const login = useLogin();
const pub = usePublisher(); const pub = usePublisher();
const url = `${location.protocol}//${location.host}`; const url = `${location.protocol}//${location.host}`;
//const url = "https://files.v0l.io"; //const url = "http://localhost:8000";
async function doUpload() { async function doUpload() {
if (!pub) return; if (!pub) return;
if (!toUpload) return; if (!toUpload) return;
@ -67,10 +71,39 @@ export default function Upload() {
} }
} }
async function listAllUploads(n: number) {
if (!pub) return;
try {
setError(undefined);
const uploader = new Route96(url, pub);
const result = await uploader.listFiles(n, 12);
setAdminListedFiles(result);
} 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(() => { useEffect(() => {
listUploads(listedPage); listUploads(listedPage);
}, [listedPage]); }, [listedPage]);
useEffect(() => {
listAllUploads(adminListedPage);
}, [adminListedPage]);
useEffect(() => {
if (pub && !self) {
const r96 = new Route96(url, pub);
r96.getSelf().then((v) => setSelf(v.data));
}
}, [pub, self]);
return ( 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-700 p-8 rounded-xl w-full">
<h1 className="text-lg font-bold"> <h1 className="text-lg font-bold">
@ -121,6 +154,7 @@ export default function Upload() {
<Button disabled={login === undefined} onClick={() => listUploads(0)}> <Button disabled={login === undefined} onClick={() => listUploads(0)}>
List Uploads List Uploads
</Button> </Button>
{listedFiles && ( {listedFiles && (
<FileList <FileList
files={listedFiles.files} files={listedFiles.files}
@ -129,6 +163,21 @@ export default function Upload() {
onPage={(x) => setListedPage(x)} onPage={(x) => setListedPage(x)}
/> />
)} )}
{self?.is_admin && (
<>
<h3>Admin File List:</h3>
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
{adminListedFiles && (
<FileList
files={adminListedFiles.files}
pages={adminListedFiles.total / adminListedFiles.count}
page={adminListedFiles.page}
onPage={(x) => setAdminListedPage(x)}
/>
)}
</>
)}
{error && <b className="text-red-500">{error}</b>} {error && <b className="text-red-500">{error}</b>}
<pre className="text-xs font-monospace overflow-wrap"> <pre className="text-xs font-monospace overflow-wrap">
{JSON.stringify(results, undefined, 2)} {JSON.stringify(results, undefined, 2)}