mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
Terminal-themed markdown diary
Single-user diary served as one Rust binary with the Svelte frontend embedded. - axum 0.8 + SQLite (sqlx), migrations run at startup - stateless HMAC session cookie; credentials from .env, compared in constant time - FTS5 full-text search, user input rewritten into quoted prefix terms - media uploads linked to entries by scanning bodies for /api/media/<id>, which scopes share-token media access and entry deletion - unguessable share links (192-bit token) serving a public read-only page - vim keybindings via CodeMirror plus a modal browse layer and : command line; disabled on touch devices
This commit is contained in:
commit
4002a35305
41 files changed
+6195
No files matched your search
+96
@@ -0,0 +1,96 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{header, request::Parts},
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::{config::Config, error::AppError, state::AppState};
|
||||
|
||||
pub const COOKIE_NAME: &str = "diary_session";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
fn sign(secret: &[u8], payload: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
|
||||
mac.update(payload.as_bytes());
|
||||
B64.encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
|
||||
/// server-side session store: rotating `DIARY_SECRET` invalidates every session.
|
||||
pub fn issue_token(config: &Config) -> String {
|
||||
let expires = crate::now() + config.session_days * 86_400;
|
||||
let payload = B64.encode(expires.to_string());
|
||||
let signature = sign(&config.secret, &payload);
|
||||
format!("{payload}.{signature}")
|
||||
}
|
||||
|
||||
pub fn verify_token(config: &Config, token: &str) -> bool {
|
||||
let Some((payload, signature)) = token.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let expected = sign(&config.secret, payload);
|
||||
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) {
|
||||
return false;
|
||||
}
|
||||
let Ok(decoded) = B64.decode(payload) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expires) = String::from_utf8_lossy(&decoded).parse::<i64>() else {
|
||||
return false;
|
||||
};
|
||||
expires > crate::now()
|
||||
}
|
||||
|
||||
pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool {
|
||||
let user_ok = username.as_bytes().ct_eq(config.username.as_bytes());
|
||||
let pass_ok = password.as_bytes().ct_eq(config.password.as_bytes());
|
||||
bool::from(user_ok & pass_ok)
|
||||
}
|
||||
|
||||
pub fn session_cookie(config: &Config, token: &str) -> String {
|
||||
let max_age = config.session_days * 86_400;
|
||||
let secure = if config.secure_cookie { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_cookie(config: &Config) -> String {
|
||||
let secure = if config.secure_cookie { "; Secure" } else { "" };
|
||||
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}")
|
||||
}
|
||||
|
||||
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
|
||||
parts
|
||||
.headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.find(|(k, _)| k.trim() == name)
|
||||
.map(|(_, v)| v.trim())
|
||||
}
|
||||
|
||||
/// Extractor that rejects the request unless a valid session cookie is present.
|
||||
pub struct Session;
|
||||
|
||||
impl FromRequestParts<AppState> for Session {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?;
|
||||
if verify_token(&state.config, token) {
|
||||
Ok(Session)
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Runtime configuration. Everything comes from the environment (`.env` is
|
||||
/// loaded on startup), because this is a deliberately single-user application.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub secret: Vec<u8>,
|
||||
pub bind: SocketAddr,
|
||||
pub data_dir: PathBuf,
|
||||
pub session_days: i64,
|
||||
pub max_upload_bytes: usize,
|
||||
pub secure_cookie: bool,
|
||||
}
|
||||
|
||||
fn var(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let username = var("DIARY_USER").context("DIARY_USER must be set (see .env.example)")?;
|
||||
let password =
|
||||
var("DIARY_PASSWORD").context("DIARY_PASSWORD must be set (see .env.example)")?;
|
||||
let secret = var("DIARY_SECRET")
|
||||
.context("DIARY_SECRET must be set (see .env.example)")?
|
||||
.into_bytes();
|
||||
if secret.len() < 16 {
|
||||
anyhow::bail!("DIARY_SECRET must be at least 16 characters");
|
||||
}
|
||||
|
||||
let bind = var("DIARY_BIND")
|
||||
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
|
||||
.parse()
|
||||
.context("DIARY_BIND must look like 127.0.0.1:4242")?;
|
||||
|
||||
let data_dir = PathBuf::from(var("DIARY_DATA_DIR").unwrap_or_else(|| "./data".into()));
|
||||
|
||||
let session_days = var("DIARY_SESSION_DAYS")
|
||||
.map(|v| v.parse())
|
||||
.transpose()
|
||||
.context("DIARY_SESSION_DAYS must be a number")?
|
||||
.unwrap_or(30);
|
||||
|
||||
let max_upload_bytes = var("DIARY_MAX_UPLOAD_MB")
|
||||
.map(|v| v.parse::<usize>())
|
||||
.transpose()
|
||||
.context("DIARY_MAX_UPLOAD_MB must be a number")?
|
||||
.unwrap_or(64)
|
||||
* 1024
|
||||
* 1024;
|
||||
|
||||
let secure_cookie = var("DIARY_SECURE_COOKIE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Self {
|
||||
username,
|
||||
password,
|
||||
secret,
|
||||
bind,
|
||||
data_dir,
|
||||
session_days,
|
||||
max_upload_bytes,
|
||||
secure_cookie,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn uploads_dir(&self) -> PathBuf {
|
||||
self.data_dir.join("uploads")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
Unauthorized,
|
||||
NotFound,
|
||||
BadRequest(String),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
|
||||
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
|
||||
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||
AppError::Internal(e) => {
|
||||
tracing::error!("internal error: {e:#}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for AppError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
AppError::Internal(err.into())
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod state;
|
||||
mod static_files;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use crate::{config::Config, state::AppState};
|
||||
|
||||
/// Seconds since the Unix epoch. Every timestamp in the database uses this.
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock is after 1970")
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_env("DIARY_LOG").unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
tokio::fs::create_dir_all(config.uploads_dir())
|
||||
.await
|
||||
.with_context(|| format!("could not create {}", config.uploads_dir().display()))?;
|
||||
|
||||
let db = SqlitePoolOptions::new()
|
||||
.max_connections(8)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::new()
|
||||
.filename(config.data_dir.join("diary.db"))
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.foreign_keys(true),
|
||||
)
|
||||
.await
|
||||
.context("could not open the diary database")?;
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
.await
|
||||
.context("could not apply database migrations")?;
|
||||
|
||||
let bind = config.bind;
|
||||
let max_upload = config.max_upload_bytes;
|
||||
let state = AppState {
|
||||
db,
|
||||
config: Arc::new(config),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", routes::api_router())
|
||||
.fallback(static_files::handler)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(max_upload + 1024 * 1024))
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind)
|
||||
.await
|
||||
.with_context(|| format!("could not bind {bind}"))?;
|
||||
|
||||
tracing::info!("narl-diary listening on http://{bind}");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutting down");
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::media,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EntrySummary {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub excerpt: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Entry {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EntryInput {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
/// Optional override so an entry can be backdated to the day it is about.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// A short, plain-ish preview of the body for the entry list.
|
||||
fn excerpt(body: &str) -> String {
|
||||
let flat: String = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
|
||||
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut out: String = flat.chars().take(180).collect();
|
||||
if flat.chars().count() > 180 {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turn free text into an FTS5 prefix query, discarding anything that could be
|
||||
/// read as FTS syntax.
|
||||
fn fts_query(raw: &str) -> Option<String> {
|
||||
let terms: Vec<String> = raw
|
||||
.split_whitespace()
|
||||
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
|
||||
.flat_map(|term| {
|
||||
term.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|term| format!("\"{term}\"*"))
|
||||
.collect();
|
||||
(!terms.is_empty()).then(|| terms.join(" AND "))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<EntrySummary>>> {
|
||||
let limit = params.limit.unwrap_or(200).clamp(1, 1000);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
|
||||
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
|
||||
let rows = match search.and_then(fts_query) {
|
||||
Some(query) => {
|
||||
sqlx::query(
|
||||
"SELECT e.id, e.title, e.body, e.created_at, e.updated_at, e.share_token
|
||||
FROM entries_fts
|
||||
JOIN entries e ON e.id = entries_fts.rowid
|
||||
WHERE entries_fts MATCH ?1
|
||||
ORDER BY rank
|
||||
LIMIT ?2 OFFSET ?3",
|
||||
)
|
||||
.bind(query)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query(
|
||||
"SELECT id, title, body, created_at, updated_at, share_token
|
||||
FROM entries
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?1 OFFSET ?2",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let entries = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let body: String = row.get("body");
|
||||
let share_token: Option<String> = row.get("share_token");
|
||||
EntrySummary {
|
||||
id: row.get("id"),
|
||||
title: row.get("title"),
|
||||
excerpt: excerpt(&body),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
shared: share_token.is_some(),
|
||||
share_token,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Entry>> {
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<EntryInput>,
|
||||
) -> AppResult<Json<Entry>> {
|
||||
let now = crate::now();
|
||||
let created_at = input.created_at.unwrap_or(now);
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO entries (title, body, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(&input.title)
|
||||
.bind(&input.body)
|
||||
.bind(created_at)
|
||||
.bind(now)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
media::link_to_entry(&state.db, id, &input.body).await?;
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<EntryInput>,
|
||||
) -> AppResult<Json<Entry>> {
|
||||
let affected = sqlx::query(
|
||||
"UPDATE entries
|
||||
SET title = ?1, body = ?2, updated_at = ?3, created_at = COALESCE(?4, created_at)
|
||||
WHERE id = ?5",
|
||||
)
|
||||
.bind(&input.title)
|
||||
.bind(&input.body)
|
||||
.bind(crate::now())
|
||||
.bind(input.created_at)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
media::link_to_entry(&state.db, id, &input.body).await?;
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let media_ids: Vec<String> = sqlx::query("SELECT id FROM media WHERE entry_id = ?1")
|
||||
.bind(id)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| row.get("id"))
|
||||
.collect();
|
||||
|
||||
let affected = sqlx::query("DELETE FROM entries WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
for media_id in media_ids {
|
||||
media::delete_media(&state, &media_id).await?;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: i64) -> AppResult<Entry> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, title, body, created_at, updated_at, share_token FROM entries WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let share_token: Option<String> = row.get("share_token");
|
||||
Ok(Entry {
|
||||
id: row.get("id"),
|
||||
title: row.get("title"),
|
||||
body: row.get("body"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
shared: share_token.is_some(),
|
||||
share_token,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Multipart, Path, State},
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Media referenced from an entry body always looks like `/api/media/<uuid>`,
|
||||
/// which is how an entry claims ownership of the files it embeds.
|
||||
static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")
|
||||
.expect("static regex is valid")
|
||||
});
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Media {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub mime: String,
|
||||
pub size: i64,
|
||||
pub created_at: i64,
|
||||
pub url: String,
|
||||
pub entry_id: Option<i64>,
|
||||
}
|
||||
|
||||
fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
|
||||
let id: String = row.get("id");
|
||||
Media {
|
||||
url: format!("/api/media/{id}"),
|
||||
id,
|
||||
filename: row.get("filename"),
|
||||
mime: row.get("mime"),
|
||||
size: row.get("size"),
|
||||
created_at: row.get("created_at"),
|
||||
entry_id: row.get("entry_id"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<Vec<Media>>> {
|
||||
let mut uploaded = Vec::new();
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))?
|
||||
{
|
||||
let filename = field
|
||||
.file_name()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "upload".to_string());
|
||||
let mime = field
|
||||
.content_type()
|
||||
.map(str::to_string)
|
||||
.filter(|m| !m.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
mime_guess::from_path(&filename)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("could not read upload: {e}")))?;
|
||||
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if data.len() > state.config.max_upload_bytes {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{filename} is larger than the {} MB limit",
|
||||
state.config.max_upload_bytes / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
tokio::fs::write(state.config.uploads_dir().join(&id), &data).await?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO media (id, filename, mime, size, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
RETURNING id, entry_id, filename, mime, size, created_at",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&filename)
|
||||
.bind(&mime)
|
||||
.bind(data.len() as i64)
|
||||
.bind(crate::now())
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
uploaded.push(row_to_media(&row));
|
||||
}
|
||||
|
||||
if uploaded.is_empty() {
|
||||
return Err(AppError::BadRequest("no files in upload".into()));
|
||||
}
|
||||
Ok(Json(uploaded))
|
||||
}
|
||||
|
||||
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, entry_id, filename, mime, size, created_at
|
||||
FROM media ORDER BY created_at DESC LIMIT 500",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(rows.iter().map(row_to_media).collect()))
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
stream_media(&state, &id).await
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
delete_media(&state, &id).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Shared by the private and the public-share media handlers.
|
||||
pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
|
||||
let row = sqlx::query("SELECT mime, filename, size FROM media WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let mime: String = row.get("mime");
|
||||
let filename: String = row.get("filename");
|
||||
let size: i64 = row.get("size");
|
||||
|
||||
let file = tokio::fs::File::open(state.config.uploads_dir().join(id))
|
||||
.await
|
||||
.map_err(|_| AppError::NotFound)?;
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, mime),
|
||||
(header::CONTENT_LENGTH, size.to_string()),
|
||||
(header::CACHE_CONTROL, "private, max-age=31536000".into()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("inline; filename*=UTF-8''{}", urlencode(&filename)),
|
||||
),
|
||||
],
|
||||
Body::from_stream(ReaderStream::new(file)),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> {
|
||||
sqlx::query("DELETE FROM media WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let path = state.config.uploads_dir().join(id);
|
||||
if let Err(e) = tokio::fs::remove_file(&path).await {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attach every media file the body embeds to this entry, and detach the ones
|
||||
/// it no longer mentions so they stop being reachable through its share link.
|
||||
pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppResult<()> {
|
||||
let referenced: Vec<String> = MEDIA_REF
|
||||
.captures_iter(body)
|
||||
.map(|c| c[1].to_lowercase())
|
||||
.collect();
|
||||
|
||||
sqlx::query("UPDATE media SET entry_id = NULL WHERE entry_id = ?1")
|
||||
.bind(entry_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
for id in referenced {
|
||||
sqlx::query("UPDATE media SET entry_id = ?1 WHERE id = ?2")
|
||||
.bind(entry_id)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn urlencode(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
pub mod entries;
|
||||
pub mod media;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/me", get(session::me))
|
||||
.route("/login", post(session::login))
|
||||
.route("/logout", post(session::logout))
|
||||
.route("/entries", get(entries::list).post(entries::create))
|
||||
.route("/entries/{id}", get(entries::get))
|
||||
.route("/entries/{id}", put(entries::update))
|
||||
.route("/entries/{id}", delete(entries::remove))
|
||||
.route("/entries/{id}/share", post(share::enable))
|
||||
.route("/entries/{id}/share", delete(share::disable))
|
||||
.route("/media", post(media::upload).get(media::list))
|
||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||
.route("/share/{token}", get(share::read))
|
||||
.route("/share/{token}/media/{id}", get(share::serve_media))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
auth::{self, Session},
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
if !auth::credentials_match(&state.config, &body.username, &body.password) {
|
||||
return Err(AppError::BadRequest("invalid credentials".into()));
|
||||
}
|
||||
let token = auth::issue_token(&state.config);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(
|
||||
header::SET_COOKIE,
|
||||
auth::session_cookie(&state.config, &token),
|
||||
)],
|
||||
Json(json!({ "user": state.config.username })),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<AppState>) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::SET_COOKIE, auth::clear_cookie(&state.config))],
|
||||
Json(json!({ "ok": true })),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse {
|
||||
Json(json!({ "user": state.config.username }))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::Response,
|
||||
Json,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::media,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SharedEntry {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
fn new_token() -> String {
|
||||
let mut bytes = [0u8; 24];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
B64.encode(bytes)
|
||||
}
|
||||
|
||||
pub async fn enable(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let existing: Option<Option<String>> = sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(|row| row.get("share_token"));
|
||||
|
||||
let token = match existing {
|
||||
None => return Err(AppError::NotFound),
|
||||
Some(Some(token)) => token,
|
||||
Some(None) => {
|
||||
let token = new_token();
|
||||
sqlx::query("UPDATE entries SET share_token = ?1 WHERE id = ?2")
|
||||
.bind(&token)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
token
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "token": token, "path": format!("/s/{token}") })))
|
||||
}
|
||||
|
||||
pub async fn disable(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let affected = sqlx::query("UPDATE entries SET share_token = NULL WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Public: no session required, the unguessable token is the credential.
|
||||
pub async fn read(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<SharedEntry>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT title, body, created_at, updated_at FROM entries WHERE share_token = ?1",
|
||||
)
|
||||
.bind(&token)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
Ok(Json(SharedEntry {
|
||||
title: row.get("title"),
|
||||
body: row.get("body"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Public: serves a media file only while it is embedded in a shared entry.
|
||||
pub async fn serve_media(
|
||||
State(state): State<AppState>,
|
||||
Path((token, id)): Path<(String, String)>,
|
||||
) -> AppResult<Response> {
|
||||
let allowed = sqlx::query(
|
||||
"SELECT 1 AS ok
|
||||
FROM media m
|
||||
JOIN entries e ON e.id = m.entry_id
|
||||
WHERE m.id = ?1 AND e.share_token = ?2",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&token)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.is_some();
|
||||
|
||||
if !allowed {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
media::stream_media(&state, &id).await
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub config: Arc<Config>,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use axum::{
|
||||
http::{header, StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::Embed;
|
||||
|
||||
/// The built Svelte app. In debug builds rust-embed reads from disk, so a
|
||||
/// `vite build --watch` next to `cargo run` is enough for iteration.
|
||||
#[derive(Embed)]
|
||||
#[folder = "$CARGO_MANIFEST_DIR/web/dist"]
|
||||
struct Assets;
|
||||
|
||||
pub async fn handler(uri: Uri) -> Response {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
match Assets::get(path) {
|
||||
Some(file) => serve(path, file),
|
||||
// Unknown paths are client-side routes; hand them the shell.
|
||||
None => match Assets::get("index.html") {
|
||||
Some(index) => serve("index.html", index),
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
"frontend not built - run `bun run build` in web/",
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(path: &str, file: rust_embed::EmbeddedFile) -> Response {
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
// Vite fingerprints everything under /assets/, so it is safe to cache hard.
|
||||
let cache = if path.starts_with("assets/") {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, mime.as_ref()),
|
||||
(header::CACHE_CONTROL, cache),
|
||||
],
|
||||
file.data,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Reference in new issue
Block a user