We have a web UI now, I guess
This commit is contained in:
parent
75b87e7bac
commit
331423d3f6
28 changed files with 1471 additions and 494 deletions
|
@ -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)),
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
27
src/app/pages/admin/mod.rs
Normal file
27
src/app/pages/admin/mod.rs
Normal 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" } }
|
||||
},
|
||||
)
|
||||
}
|
113
src/app/pages/admin/pastes.rs
Normal file
113
src/app/pages/admin/pastes.rs
Normal 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
78
src/app/pages/mod.rs
Normal 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) }
|
||||
}
|
||||
}
|
42
src/app/pages/pastes_public.rs
Normal file
42
src/app/pages/pastes_public.rs
Normal 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)),
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
14
src/error.rs
14
src/error.rs
|
@ -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
50
src/style.scss
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue