mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user