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:
nvrl committed 2026-09-05 05:39:25 +02:00
commit 4002a35305
41 files changed
+6195

No files matched your search

+96
View File
@@ -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)
}
}
}
+75
View File
@@ -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")
}
}
+43
View File
@@ -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
View File
@@ -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");
}
+259
View File
@@ -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,
})
}
+224
View File
@@ -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()
}
+28
View File
@@ -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))
}
+50
View File
@@ -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 }))
}
+121
View File
@@ -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
}
+11
View File
@@ -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>,
}
+45
View File
@@ -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()
}