diff options
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 | ||||
| -rw-r--r-- | src/handlers/mod.rs | 11 | ||||
| -rw-r--r-- | src/handlers/notes.rs | 243 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | templates/header.rs.html | 2 | ||||
| -rw-r--r-- | templates/notesindex.rs.html | 6 |
8 files changed, 254 insertions, 19 deletions
@@ -3399,6 +3399,7 @@ dependencies = [ "tracing", "tracing-futures", "tracing-subscriber", + "ts_localapi", "url", "uuid 0.8.2", "xe_jsonfeed", @@ -52,9 +52,10 @@ uuid = { version = "0.8", features = ["serde", "v4"] } # workspace dependencies cfcache = { path = "./lib/cfcache" } -xe_jsonfeed = { path = "./lib/jsonfeed" } mi = { path = "./lib/mi" } patreon = { path = "./lib/patreon" } +ts_localapi = { path = "./lib/ts_localapi" } +xe_jsonfeed = { path = "./lib/jsonfeed" } xesite_types = { path = "./lib/xesite_types" } bb8-rusqlite = { git = "https://github.com/pleshevskiy/bb8-rusqlite", branch = "bump-rusqlite" } diff --git a/src/app/mod.rs b/src/app/mod.rs index 24eabc5..8ebe152 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -77,7 +77,7 @@ pub async fn init(cfg: PathBuf) -> Result<State> { let talks = crate::post::load(cfg.clone(), "talks").await?; let mut everything: Vec<Post> = vec![]; let mgr = RusqliteConnectionManager::new( - env::var("DATABASE_URL").unwrap_or("./var/waifud.db".to_string()), + env::var("DATABASE_URL").unwrap_or("./xesite.db".to_string()), ); let pool = bb8::Pool::builder().build(mgr).await?; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 87175ca..c3264ec 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -8,7 +8,7 @@ use axum::{ use chrono::{Datelike, Timelike, Utc, Weekday}; use lazy_static::lazy_static; use prometheus::{opts, register_int_counter_vec, IntCounterVec}; -use std::sync::Arc; +use std::{net::AddrParseError, sync::Arc}; use tracing::instrument; pub mod api; @@ -172,6 +172,15 @@ pub enum Error { #[error("database pool error: {0}")] SQLitePool(#[from] bb8_rusqlite::Error), + #[error("address parse error: {0}")] + AddrParse(#[from] AddrParseError), + + #[error("Tailscale localapi error: {0}")] + TSLocalAPI(#[from] ts_localapi::Error), + + #[error("{0}")] + Eyre(#[from] color_eyre::eyre::ErrReport), + #[error("other error: {0}")] Catchall(String), } diff --git a/src/handlers/notes.rs b/src/handlers/notes.rs index 56e0f90..60d82f7 100644 --- a/src/handlers/notes.rs +++ b/src/handlers/notes.rs @@ -1,10 +1,14 @@ -use crate::templates; -use axum::{extract::Path, response::Html, Json}; +use std::{net::SocketAddr, sync::Arc}; + +use crate::{app::State, templates}; +use axum::{extract::Path, http::HeaderMap, response::Html, Extension, Json}; use chrono::prelude::*; use maud::{html, Markup, PreEscaped}; use rusqlite::params; use serde::{Deserialize, Serialize}; +use super::Error; + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Note { pub id: u64, @@ -16,15 +20,49 @@ pub struct Note { pub reply_to: Option<String>, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NewNote { + pub content: String, + pub reply_to: Option<String>, +} + +impl Default for Note { + fn default() -> Self { + Self { + id: 0, + content: "".into(), + content_html: "".into(), + created_at: Utc::now(), + updated_at: None, + deleted_at: None, + reply_to: None, + } + } +} + impl Note { pub fn to_html(&self) -> Markup { html! { article."h-entry" { + a href={"/notes/" (self.id)} { + "🔗" + } + " " time."dt-published" datetime=(self.created_at) { {(self.detrytemci())} } - a href={"/notes/" (self.id)} { - "🔗" + " " + @if let Some(_updated_at) = &self.updated_at { + "📝 " + (self.update_detrytemci().unwrap()) + } + + @if let Some(deleted_at) = &self.deleted_at { + p { + " ⚠️ This post was deleted at " + (deleted_at.to_rfc3339()) + ". Please do not treat this note as a genuine expression of my views or opinions." + } } @if let Some(reply_to) = &self.reply_to { @@ -43,7 +81,21 @@ impl Note { } pub fn detrytemci(&self) -> String { - self.created_at.format("M%m %d %Y %M:%H").to_string() + self.created_at.format("M%m %d %Y %H:%M").to_string() + } + + pub fn update_detrytemci(&self) -> Option<String> { + if self.updated_at.is_none() { + return None; + } + + Some( + self.updated_at + .as_ref() + .unwrap() + .format("M%m %d %Y %H:%M") + .to_string(), + ) } } @@ -70,9 +122,9 @@ impl Into<xe_jsonfeed::Item> for Note { } } -#[instrument(err)] -pub async fn index() -> super::Result { - let conn = crate::establish_connection()?; +#[instrument(err, skip(state))] +pub async fn index(Extension(state): Extension<Arc<State>>) -> super::Result { + let conn = state.pool.get().await?; let mut stmt = conn.prepare("SELECT id, content, content_html, created_at, updated_at, deleted_at, reply_to FROM notes WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 25")?; let notes = stmt @@ -96,9 +148,11 @@ pub async fn index() -> super::Result { Ok(Html(result)) } -#[instrument(err)] -pub async fn feed() -> super::Result<Json<xe_jsonfeed::Feed>> { - let conn = crate::establish_connection()?; +#[instrument(err, skip(state))] +pub async fn feed( + Extension(state): Extension<Arc<State>>, +) -> super::Result<Json<xe_jsonfeed::Feed>> { + let conn = state.pool.get().await?; let mut stmt = conn.prepare("SELECT id, content, content_html, created_at, updated_at, deleted_at, reply_to FROM notes WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 25")?; let notes = stmt @@ -135,9 +189,9 @@ pub async fn feed() -> super::Result<Json<xe_jsonfeed::Feed>> { Ok(Json(feed.build())) } -#[instrument(err)] -pub async fn view(Path(id): Path<u64>) -> super::Result { - let conn = crate::establish_connection()?; +#[instrument(err, skip(state))] +pub async fn view(Extension(state): Extension<Arc<State>>, Path(id): Path<u64>) -> super::Result { + let conn = state.pool.get().await?; let mut stmt = conn.prepare( "SELECT id, content, content_html, created_at, updated_at, deleted_at, reply_to FROM notes WHERE id = ?1" @@ -159,3 +213,164 @@ pub async fn view(Path(id): Path<u64>) -> super::Result { templates::notepost_html(&mut result, note)?; Ok(Html(result)) } + +#[instrument(err, skip(state, headers))] +pub async fn delete( + Extension(state): Extension<Arc<State>>, + Path(id): Path<u64>, + headers: HeaderMap, +) -> super::Result<String> { + let conn = state.pool.get().await?; + + let ip = headers.get("X-Real-Ip").clone(); + + if ip.is_none() { + return Err(Error::Catchall("Cannot get X-Real-Ip header".into())); + } + + let ip: SocketAddr = (ip.unwrap().to_str()?.to_owned() + ":0").parse()?; + let whois = ts_localapi::whois(ip).await?; + + if whois.user_profile.login_name != "Xe@github" { + return Err(Error::Catchall(format!( + "expected Tailscale user Xe@github, got: {}", + whois.user_profile.login_name + ))); + } + + conn.execute( + "UPDATE notes SET deleted_at=?2 WHERE id=?1", + params![id, Utc::now().to_rfc3339()], + )?; + + Ok("deleted".into()) +} + +#[instrument(err, skip(state, headers))] +pub async fn update( + Extension(state): Extension<Arc<State>>, + Path(id): Path<u64>, + headers: HeaderMap, + data: Json<NewNote>, +) -> super::Result<Json<Note>> { + let conn = state.pool.get().await?; + + let ip = headers.get("X-Real-Ip").clone(); + + if ip.is_none() { + return Err(Error::Catchall("Cannot get X-Real-Ip header".into())); + } + + let ip: SocketAddr = (ip.unwrap().to_str()?.to_owned() + ":0").parse()?; + let whois = ts_localapi::whois(ip).await?; + + if whois.user_profile.login_name != "Xe@github" { + return Err(Error::Catchall(format!( + "expected Tailscale user Xe@github, got: {}", + whois.user_profile.login_name + ))); + } + + info!( + "authenticated as {} from machine {}", + whois.user_profile.login_name, whois.node.hostinfo.hostname, + ); + + let content_html = crate::app::markdown::render(state.clone().cfg.clone(), &data.content)?; + + let mut stmt = conn.prepare( + "SELECT id, content, content_html, created_at, updated_at, deleted_at, reply_to FROM notes WHERE id = ?1" + )?; + + let old_note = stmt.query_row(params![id], |row| { + Ok(Note { + id: row.get(0)?, + content: row.get(1)?, + content_html: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + deleted_at: row.get(5)?, + reply_to: row.get(6)?, + }) + })?; + + let mut note = Note { + content: data.content.clone(), + content_html, + created_at: old_note.created_at, + updated_at: Some(Utc::now()), + reply_to: old_note.reply_to, + ..Default::default() + }; + + conn.execute( + "UPDATE notes SET (content, content_html, created_at, updated_at, deleted_at, reply_to) VALUES(?, ?, ?, ?, ?, ?)", + params![ + note.content, + note.content_html, + note.created_at, + note.updated_at, + note.deleted_at, + note.reply_to + ], + )?; + + note.id = conn.last_insert_rowid() as u64; + + Ok(Json(note)) +} + +#[instrument(err, skip(state, headers))] +pub async fn create( + Extension(state): Extension<Arc<State>>, + headers: HeaderMap, + data: Json<NewNote>, +) -> super::Result<Json<Note>> { + let conn = state.pool.get().await?; + + let ip = headers.get("X-Real-Ip").clone(); + + if ip.is_none() { + return Err(Error::Catchall("Cannot get X-Real-Ip header".into())); + } + + let ip: SocketAddr = (ip.unwrap().to_str()?.to_owned() + ":0").parse()?; + let whois = ts_localapi::whois(ip).await?; + + if whois.user_profile.login_name != "Xe@github" { + return Err(Error::Catchall(format!( + "expected Tailscale user Xe@github, got: {}", + whois.user_profile.login_name + ))); + } + + info!( + "authenticated as {} from machine {}", + whois.user_profile.login_name, whois.node.hostinfo.hostname, + ); + + let content_html = crate::app::markdown::render(state.clone().cfg.clone(), &data.content)?; + + let mut note = Note { + content: data.content.clone(), + content_html, + reply_to: data.reply_to.clone(), + ..Default::default() + }; + + conn.execute( + "INSERT INTO notes(content, content_html, created_at, updated_at, deleted_at, reply_to) VALUES(?, ?, ?, ?, ?, ?)", + params![ + note.content, + note.content_html, + note.created_at, + note.updated_at, + note.deleted_at, + note.reply_to + ], + )?; + + note.id = conn.last_insert_rowid() as u64; + + Ok(Json(note)) +} diff --git a/src/main.rs b/src/main.rs index 76196a8..736bbfc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use axum::{ extract::Extension, http::header::{self, HeaderValue, CONTENT_TYPE}, response::{Html, Response}, - routing::{get, get_service}, + routing::{delete, get, get_service, post}, Router, }; use color_eyre::eyre::Result; @@ -199,6 +199,9 @@ async fn main() -> Result<()> { .route("/talks/", get(handlers::talks::index)) .route("/talks/:name", get(handlers::talks::post_view)) // notes + .route("/api/notes/create", post(handlers::notes::create)) + .route("/api/notes/:id", delete(handlers::notes::delete)) + .route("/api/notes/:id/update", post(handlers::notes::update)) .route("/notes", get(handlers::notes::index)) .route("/notes.json", get(handlers::notes::feed)) .route("/notes/:id", get(handlers::notes::view)) diff --git a/templates/header.rs.html b/templates/header.rs.html index 11b16d4..a81e9fa 100644 --- a/templates/header.rs.html +++ b/templates/header.rs.html @@ -93,7 +93,7 @@ la budza pu cusku lu <div class="container"> <header> <span class="logo"></span> - <nav><a href="/">Xe</a> - <a href="/blog">Blog</a> - <a href="/contact">Contact</a> - <a href="/resume">Resume</a> - <a href="/talks">Talks</a> - <a href="/signalboost">Signal Boost</a> - <a href="/feeds">Feeds</a> | <a target="_blank" rel="noopener noreferrer" href="https://graphviz.christine.website">GraphViz</a> - <a target="_blank" rel="noopener noreferrer" href="https://when-then-zen.christine.website/">When Then Zen</a></nav> + <nav><a href="/">Xe</a> - <a href="/blog">Blog</a> - <a href="/notes">Notes</a> - <a href="/contact">Contact</a> - <a href="/resume">Resume</a> - <a href="/talks">Talks</a> - <a href="/signalboost">Signal Boost</a> - <a href="/feeds">Feeds</a> | <a target="_blank" rel="noopener noreferrer" href="https://graphviz.christine.website">GraphViz</a> - <a target="_blank" rel="noopener noreferrer" href="https://when-then-zen.christine.website/">When Then Zen</a></nav> </header> <br /> diff --git a/templates/notesindex.rs.html b/templates/notesindex.rs.html index c1fa08a..9b1dca5 100644 --- a/templates/notesindex.rs.html +++ b/templates/notesindex.rs.html @@ -7,6 +7,12 @@ <h1>Notes</h1> +<p>Notes are what I use for shorter form content. Think what you might see on Twitter or Mastodon. Most of my notes will be shorter form replies to articles and other things that I'd normally have lost to Hacker News or lobste.rs comments. I'm trying to use this to <a href="http://www.alwaysownyourplatform.com">own my platform</a>.</p> + +<p>Let's see how long I can keep up this experiment.</p> + +<p>If you want to subscribe to my notes, you can use <a href="/notes.json">this dedicated feed</a>.</p> + @for note in notes { @Html(note.to_html().0) } |
