1
0
Fork 0

We have a web UI now, I guess

This commit is contained in:
Honbra 2024-06-09 18:29:07 +02:00
parent 75b87e7bac
commit 331423d3f6
Signed by: honbra
GPG key ID: B61CC9ADABE2D952
28 changed files with 1471 additions and 494 deletions

View file

@ -1,252 +0,0 @@
use std::path::PathBuf;
use axum::{
body::Body,
extract::{Path, Query, State},
routing::{delete, get, post},
Json, Router,
};
use axum_extra::TypedHeader;
use futures_util::TryStreamExt;
use headers::ContentType;
use http::StatusCode;
use mime::Mime;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::query;
use tokio::{fs, io};
use tokio_util::io::StreamReader;
use tracing::{error, field, info, instrument};
use ulid::Ulid;
use uuid::Uuid;
use crate::{app::SharedState, error::AppError};
pub fn resource() -> Router<SharedState> {
Router::new()
.route("/files", post(upload_file))
.route("/files/:file_id", get(get_file_info))
.route("/files/:file_id", delete(delete_file))
.route("/files/:file_id/keys/", post(create_file_key))
.route("/files/:file_id/keys/:key_id", delete(delete_file_key))
}
#[derive(Serialize)]
struct File {
id: Ulid,
hash: String,
mime: String,
keys: Vec<Ulid>,
}
#[derive(Serialize)]
struct NewFile {
id: Ulid,
hash: String,
mime: String,
key: Option<Ulid>,
}
#[derive(Deserialize)]
struct UploadFileOptions {
#[serde(default)]
create_key: bool,
}
#[instrument(skip_all)]
async fn upload_file(
State(SharedState { db, config }): State<SharedState>,
Query(UploadFileOptions { create_key }): Query<UploadFileOptions>,
TypedHeader(content_type): TypedHeader<ContentType>,
body: Body,
) -> Result<Json<NewFile>, AppError> {
let id = Ulid::new();
let path_temp = config.file_temp_dir.join(id.to_string());
let mut hasher = Sha256::new();
{
let mut file_temp = fs::File::create(&path_temp).await?;
let better_body = body
.into_data_stream()
.inspect_ok(|b| hasher.update(b))
.map_err(|err| io::Error::new(io::ErrorKind::Other, err));
let mut reader = StreamReader::new(better_body);
if let Err(err) = io::copy(&mut reader, &mut file_temp).await {
error!(
err = field::display(&err),
file_path = field::debug(&path_temp),
"failed to copy file, removing",
);
drop(file_temp);
if let Err(err) = fs::remove_file(path_temp).await {
error!(
err = field::display(err),
"failed to remove failed upload file",
);
}
return Err(err.into());
}
}
let hash = hasher.finalize();
let hash_hex = hex::encode(hash);
let path_hash = PathBuf::from("files").join(&hash_hex);
if fs::try_exists(&path_hash).await? {
info!(hash = hash_hex, "file already exists");
if let Err(err) = fs::remove_file(&path_temp).await {
error!(err = field::display(&err), "failed to remove temp file");
}
} else if let Err(err) = fs::rename(&path_temp, &path_hash).await {
error!(err = field::display(&err), "failed to move finished file");
if let Err(err) = fs::remove_file(&path_temp).await {
error!(
err = field::display(&err),
"failed to remove file after failed move",
);
}
return Err(err.into());
}
let mime = Into::<Mime>::into(content_type);
let mime_str = mime.to_string();
let mut tx = db.begin().await?;
match query!(
"INSERT INTO file (id, hash, mime) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
Uuid::from(id),
&hash[..],
mime_str,
)
.execute(&mut *tx)
.await?
.rows_affected()
{
0 | 1 => {}
rows => return Err(AppError::ImpossibleAffectedRows(rows)),
}
// `ON CONFLICT DO NOTHING RETURNING id` only works when there *isn't* a
// conflict
let id = query!("SELECT id FROM file WHERE hash = $1", &hash[..])
.fetch_one(&mut *tx)
.await?
.id
.into();
let mut key_opt = None;
if create_key {
let key = Ulid::new();
key_opt = Some(key);
match query!(
"INSERT INTO file_key (id, file_id) VALUES ($1, $2)",
Uuid::from(key),
Uuid::from(id),
)
.execute(&mut *tx)
.await?
.rows_affected()
{
1 => {}
0 => return Err(AppError::UlidConflict(key)),
rows => return Err(AppError::ImpossibleAffectedRows(rows)),
}
}
tx.commit().await?;
Ok(Json(NewFile {
id,
hash: hash_hex,
mime: mime_str,
key: key_opt,
}))
}
async fn get_file_info(
State(SharedState { db, .. }): State<SharedState>,
Path(id): Path<Ulid>,
) -> Result<Json<File>, AppError> {
let (file, keys) = tokio::try_join!(
query!(
"SELECT id, hash, mime FROM file WHERE id = $1",
Uuid::from(id),
)
.fetch_optional(&db),
query!("SELECT id FROM file_key WHERE file_id = $1", Uuid::from(id)).fetch_all(&db),
)?;
match file {
Some(r) => Ok(Json(File {
id,
hash: hex::encode(r.hash),
mime: r.mime,
keys: keys.into_iter().map(|r| r.id.into()).collect(),
})),
None => Err(AppError::FileNotFound(id)),
}
}
#[instrument(skip_all)]
async fn delete_file(
State(SharedState { db, config }): State<SharedState>,
Path(file_id): Path<Ulid>,
) -> Result<StatusCode, AppError> {
let file_hash = query!(
"DELETE FROM file WHERE id = $1 RETURNING hash",
Uuid::from(file_id)
)
.fetch_optional(&db)
.await?
.ok_or(AppError::FileNotFound(file_id))?
.hash;
let file_path = config.file_store_dir.join(hex::encode(file_hash));
if let Err(err) = fs::remove_file(file_path).await {
error!(err = field::display(err), "failed to remove file");
}
Ok(StatusCode::NO_CONTENT)
}
async fn create_file_key(
State(SharedState { db, .. }): State<SharedState>,
Path(file_id): Path<Ulid>,
) -> Result<(StatusCode, Json<Ulid>), AppError> {
let key_id = Ulid::new();
match query!(
"INSERT INTO file_key (id, file_id) VALUES ($1, $2)",
Uuid::from(key_id),
Uuid::from(file_id),
)
.execute(&db)
.await?
.rows_affected()
{
1 => Ok((StatusCode::CREATED, Json(key_id))),
rows => Err(AppError::ImpossibleAffectedRows(rows)),
}
}
async fn delete_file_key(
State(SharedState { db, .. }): State<SharedState>,
Path((file_id, key_id)): Path<(Ulid, Ulid)>,
) -> Result<StatusCode, AppError> {
match query!(
"DELETE FROM file_key WHERE id = $1 AND file_id = $2",
Uuid::from(key_id),
Uuid::from(file_id),
)
.execute(&db)
.await?
.rows_affected()
{
1 => Ok(StatusCode::NO_CONTENT),
0 => Err(AppError::FileKeyNotFound(key_id)),
rows => Err(AppError::ImpossibleAffectedRows(rows)),
}
}

View file

@ -1,4 +1,3 @@
mod files;
mod links;
use axum::Router;
@ -6,7 +5,5 @@ use axum::Router;
use super::SharedState;
pub fn router() -> Router<SharedState> {
Router::new()
.merge(files::resource())
.merge(links::resource())
Router::new().merge(links::resource())
}

View file

@ -1,10 +1,12 @@
mod api;
mod pages;
mod root;
use std::sync::Arc;
use axum::{body::Body, Router};
use http::Request;
use memory_serve::{load_assets, MemoryServe};
use sqlx::{postgres::PgConnectOptions, PgPool};
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tracing::{field, span, Level};
@ -14,7 +16,7 @@ use crate::config::Config;
#[derive(Clone)]
struct SharedState {
db: PgPool,
config: Arc<Config>,
_config: Arc<Config>,
}
pub async fn build_app(config: Config) -> eyre::Result<Router> {
@ -27,11 +29,16 @@ pub async fn build_app(config: Config) -> eyre::Result<Router> {
)
.await?;
Ok(root::router()
let memory_router = MemoryServe::new(load_assets!("./static/")).into_router();
Ok(Router::new()
.merge(memory_router)
.merge(pages::router())
.nest("/api", api::router())
.merge(root::router())
.with_state(SharedState {
db,
config: Arc::new(config),
_config: Arc::new(config),
})
.layer(
TraceLayer::new_for_http()

View file

@ -0,0 +1,27 @@
mod pastes;
use axum::{routing::get, Router};
use maud::{html, Markup};
use super::{page, Breadcrumb, BC_INDEX};
use crate::app::SharedState;
pub(super) fn router() -> Router<SharedState> {
Router::new()
.route("/", get(show_admin_page()))
.nest("/pastes", pastes::router())
}
const BC_ADMIN: Breadcrumb = Breadcrumb::new_static("Admin", "/admin");
fn show_admin_page() -> Markup {
page(
"Admin",
&[BC_INDEX],
html! {
"meow :3"
p { a.tl href="/admin/pastes" { "Pastes" } }
p { a.tl href="/admin/pastes/new" { "Create Paste" } }
},
)
}

View file

@ -0,0 +1,113 @@
use axum::{
extract::{Query, State},
response::Redirect,
routing::get,
Form, Router,
};
use maud::{html, Markup};
use serde::Deserialize;
use sqlx::query;
use ulid::Ulid;
use uuid::Uuid;
use super::BC_ADMIN;
use crate::{
app::{
pages::{dt_iso, page, Breadcrumb, BC_INDEX},
SharedState,
},
error::AppError,
};
pub(super) fn router() -> Router<SharedState> {
Router::new()
.route("/", get(show_pastes).post(create_paste))
.route("/new", get(show_create_paste))
}
const BC_PASTES: Breadcrumb = Breadcrumb::new_static("Pastes", "/admin/pastes");
async fn show_pastes(
State(SharedState { db, .. }): State<SharedState>,
) -> Result<Markup, AppError> {
Ok(page(
"Pastes",
&[BC_INDEX, BC_ADMIN],
html! {
table class="w-full" {
thead {
tr {
th { "Name" }
th { "Created at" }
th { "Actions" }
}
}
tbody {
@for p in query!("SELECT id, title FROM paste").fetch_all(&db).await? {
tr {
td { a.tl href=(format!("/p/{}", Ulid::from(p.id))) { (p.title)} }
td { (dt_iso(Ulid::from(p.id).datetime())) }
td class="w-min-content" {
div class="flex gap-2" {
a.tl href="#" { "Edit" }
a.tl href="#" { "Delete" }
}
}
}
}
}
}
},
))
}
#[derive(Deserialize)]
struct CreatePasteFieldsOptional {
title: Option<String>,
content: Option<String>,
}
async fn show_create_paste(
Query(CreatePasteFieldsOptional { title, content }): Query<CreatePasteFieldsOptional>,
) -> Markup {
page(
"Create Paste",
&[BC_INDEX, BC_ADMIN, BC_PASTES],
html! {
form method="post" action="/admin/pastes" {
fieldset {
legend { "Paste" }
input class="w-full" type="text" name="title" placeholder="Paste title" required value=(title.unwrap_or_default());
textarea class="w-full min-h-32" name="content" placeholder="Paste content" required { (content.unwrap_or_default()) }
input type="submit" value="Create";
}
}
},
)
}
#[derive(Deserialize)]
struct CreatePasteFields {
title: String,
content: String,
}
async fn create_paste(
State(SharedState { db, .. }): State<SharedState>,
Form(CreatePasteFields { title, content }): Form<CreatePasteFields>,
) -> Result<Redirect, AppError> {
let id = Ulid::new();
match query!(
"INSERT INTO paste (id, title, content) VALUES ($1, $2, $3)",
Uuid::from(id),
title,
content,
)
.execute(&db)
.await?
.rows_affected()
{
1 => Ok(Redirect::temporary(&format!("/p/{id}"))),
r => Err(AppError::ImpossibleAffectedRows(r)),
}
}

78
src/app/pages/mod.rs Normal file
View file

@ -0,0 +1,78 @@
mod admin;
mod pastes_public;
use std::{borrow::Cow, time::SystemTime};
use axum::Router;
use chrono::{DateTime, Utc};
use maud::{html, Markup, DOCTYPE};
use super::SharedState;
pub fn router() -> Router<SharedState> {
Router::new()
.nest("/p", pastes_public::router())
.nest("/admin", admin::router())
}
const BC_INDEX: Breadcrumb = Breadcrumb::new_static("NCPN", "/");
struct Breadcrumb<'a> {
title: Cow<'a, str>,
href: Cow<'a, str>,
}
impl<'a> Breadcrumb<'a> {
fn new(title: impl Into<Cow<'a, str>>, href: impl Into<Cow<'a, str>>) -> Self {
Breadcrumb {
title: title.into(),
href: href.into(),
}
}
}
impl Breadcrumb<'static> {
const fn new_static(title: &'static str, href: &'static str) -> Self {
Breadcrumb {
title: Cow::Borrowed(title),
href: Cow::Borrowed(href),
}
}
}
fn page(title: &str, breadcrumbs: &[Breadcrumb], content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
head {
meta charset="utf-8";
title { (format!("{title} | NCPN")) }
link rel="stylesheet" href="/style.css";
script src="/script.js" defer {}
}
body {
header {
div {
@for b in breadcrumbs {
a.tl href=(b.href) { (b.title) }
span { "/" }
}
}
h1 { (title) }
}
(content)
footer {
"© 2024 mrrp meow :3"
}
}
}
}
}
fn dt_iso(t: SystemTime) -> Markup {
let dt: DateTime<Utc> = t.into();
let iso = dt.to_rfc3339();
html! {
time datetime=(iso) { (iso) }
}
}

View file

@ -0,0 +1,42 @@
// 01HZT59RTH4R6P1TYE6NMAFYEP 018FF454-E351-260D-60EB-CE3568A7F9D6
use axum::{
extract::{Path, State},
routing::get,
Router,
};
use maud::{html, Markup};
use sqlx::query;
use ulid::Ulid;
use uuid::Uuid;
use super::{page, Breadcrumb, BC_INDEX};
use crate::{app::SharedState, error::AppError};
pub(super) fn router() -> Router<SharedState> {
Router::new().route("/:id", get(show_paste))
}
const BC_PASTES_PUBLIC: Breadcrumb = Breadcrumb::new_static("Pastes", "/p");
async fn show_paste(
Path(id): Path<Ulid>,
State(SharedState { db, .. }): State<SharedState>,
) -> Result<Markup, AppError> {
match query!(
"SELECT title, content FROM paste WHERE id = $1",
Uuid::from(id)
)
.fetch_optional(&db)
.await?
{
Some(r) => Ok(page(
&r.title,
&[BC_INDEX, BC_PASTES_PUBLIC],
html! {
pre class="p-4 border border-bd-base rounded-xl" { (r.content) }
},
)),
None => Err(AppError::PasteNotFound(id)),
}
}

View file

@ -1,26 +1,16 @@
use axum::{
body::Body,
extract::{Path, State},
response::Redirect,
routing::get,
BoxError, Router,
Router,
};
use bytes::Bytes;
use http::{Request, Response};
use http_body_util::{combinators::UnsyncBoxBody, BodyExt};
use mime::Mime;
use sqlx::query;
use tower_http::services::ServeFile;
use ulid::Ulid;
use uuid::Uuid;
use super::SharedState;
use crate::error::AppError;
pub fn router() -> Router<SharedState> {
Router::new()
.route("/:slug", get(redirect_link))
.route("/f/:key", get(download_file))
Router::new().route("/:slug", get(redirect_link))
}
async fn redirect_link(
@ -35,27 +25,3 @@ async fn redirect_link(
None => Err(AppError::LinkNotFoundSlug(slug)),
}
}
async fn download_file(
State(SharedState { db, config }): State<SharedState>,
Path(key): Path<Ulid>,
request: Request<Body>,
) -> Result<Response<UnsyncBoxBody<Bytes, BoxError>>, AppError> {
let file = query!(
"SELECT hash, mime FROM file_key JOIN file ON file_id = file.id WHERE file_key.id = $1",
Uuid::from(key),
)
.fetch_optional(&db)
.await?
.ok_or(AppError::FileKeyNotFound(key))?;
let mime: Option<Mime> = file.mime.parse().ok();
let path = config.file_store_dir.join(hex::encode(file.hash));
let mut sf = match mime {
Some(mime) => ServeFile::new_with_mime(path, &mime),
None => ServeFile::new(path),
};
match sf.try_call(request).await {
Ok(response) => Ok(response.map(|body| body.map_err(Into::into).boxed_unsync())),
Err(err) => Err(AppError::Io(err)),
}
}

View file

@ -1,5 +1,3 @@
use std::path::PathBuf;
use axum::{body::Body, response::IntoResponse};
use http::StatusCode;
use tracing::{error, field};
@ -13,12 +11,8 @@ pub enum AppError {
LinkNotFoundId(Ulid),
#[error("link not found ({0})")]
LinkNotFoundSlug(String),
#[error("file not found ({0})")]
FileNotFound(Ulid),
#[error("file key not found ({0})")]
FileKeyNotFound(Ulid),
#[error("file is missing ({0})")]
FileMissing(PathBuf),
#[error("paste not found ({0})")]
PasteNotFound(Ulid),
#[error("database returned an impossible number of affected rows ({0})")]
ImpossibleAffectedRows(u64),
#[error("ulid conflict")]
@ -39,9 +33,7 @@ impl IntoResponse for AppError {
Self::LinkNotFoundId(_) | Self::LinkNotFoundSlug(_) => {
(StatusCode::NOT_FOUND, "Link not found")
}
Self::FileNotFound(_) => (StatusCode::NOT_FOUND, "File not found"),
Self::FileKeyNotFound(_) => (StatusCode::NOT_FOUND, "File key not found"),
Self::FileMissing(_) => (StatusCode::INTERNAL_SERVER_ERROR, "File is missing"),
Self::PasteNotFound(_) => (StatusCode::NOT_FOUND, "Paste not found"),
Self::ImpossibleAffectedRows(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Database returned an impossible number of affected rows",

50
src/style.scss Normal file
View file

@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply flex flex-col gap-8 px-4 py-16 mx-auto max-w-4xl bg-slate-900 text-fg-base max-lg:py-8;
}
header {
div {
@apply flex gap-2 mb-1 text-lg text-fg-deemphasized;
}
h1 {
@apply text-5xl font-extrabold text-fg-headings;
}
}
footer {
@apply text-center text-fg-deemphasized;
}
a.tl {
@apply underline hover:no-underline hover:text-white focus-visible:no-underline focus-visible:text-white;
}
table {
@apply table-auto border-collapse border border-bd-base;
thead tr th {
@apply border border-bd-base px-2 py-1 text-left;
}
tbody tr td {
@apply border border-bd-base px-2 py-1;
}
}
form fieldset {
@apply flex flex-col p-4 pt-2 gap-4 border border-bd-base rounded-xl;
input[type="text"],
textarea {
@apply px-3 py-2 rounded-lg bg-transparent border border-bd-base hover:border-bd-highlighted focus-visible:border-bd-highlighted;
}
input[type="submit"] {
@apply ml-auto px-3 py-2 rounded-lg cursor-pointer border border-bd-base hover:border-bd-highlighted focus-visible:border-bd-highlighted;
}
}