aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXe Iaso <me@christine.website>2022-07-11 11:16:54 +0000
committerXe Iaso <me@christine.website>2022-07-11 11:16:54 +0000
commit4e57715b09c064d9729727a6d28847242cdb93a7 (patch)
tree10a9f95033fb0723a78185d1286bececf3fb5840
parentb0a87b890e2f97842ff738166207341ba5f11e58 (diff)
downloadxesite-4e57715b09c064d9729727a6d28847242cdb93a7.tar.xz
xesite-4e57715b09c064d9729727a6d28847242cdb93a7.zip
handlers/notes: create, update, delete, tailauth too
Signed-off-by: Xe Iaso <me@christine.website>
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml3
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/handlers/mod.rs11
-rw-r--r--src/handlers/notes.rs243
-rw-r--r--src/main.rs5
-rw-r--r--templates/header.rs.html2
-rw-r--r--templates/notesindex.rs.html6
8 files changed, 254 insertions, 19 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a2d1948..29407ef 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3399,6 +3399,7 @@ dependencies = [
"tracing",
"tracing-futures",
"tracing-subscriber",
+ "ts_localapi",
"url",
"uuid 0.8.2",
"xe_jsonfeed",
diff --git a/Cargo.toml b/Cargo.toml
index b7427f0..38b36c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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)
}