do some slonking
This commit is contained in:
parent
6df145eb3a
commit
3705d412b4
11 changed files with 509 additions and 367 deletions
26
Cargo.toml
26
Cargo.toml
|
@ -4,12 +4,19 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "fat"
|
#strip = true
|
||||||
strip = true
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["db"]
|
default = ["db", "metrics", "tracing"]
|
||||||
db = ["sqlx"]
|
db = ["dep:sqlx"]
|
||||||
|
metrics = ["tracing"]
|
||||||
|
tracing = [
|
||||||
|
"dep:tracing",
|
||||||
|
"dep:tracing-subscriber",
|
||||||
|
"tower-http/trace",
|
||||||
|
"axum/tracing",
|
||||||
|
"axum/matched-path",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
askama = { version = "0.12.1", features = ["with-axum", "markdown"] }
|
askama = { version = "0.12.1", features = ["with-axum", "markdown"] }
|
||||||
|
@ -18,9 +25,12 @@ axum = "0.7.4"
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
tokio = { version = "1.36.0", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1.36.0", features = ["rt-multi-thread", "macros"] }
|
||||||
toml = "0.8.10"
|
toml = "0.8.10"
|
||||||
tower-http = { version = "0.5.2", features = ["trace", "fs"] }
|
tower-http = { version = "0.5.2", features = ["fs"] }
|
||||||
tracing = "0.1.40"
|
tracing = { version = "0.1.40", optional = true }
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["json", "env-filter"] }
|
tracing-subscriber = { version = "0.3.18", optional = true, features = [
|
||||||
|
"json",
|
||||||
|
"env-filter",
|
||||||
|
] }
|
||||||
sqlx = { version = "0.7.3", optional = true, features = [
|
sqlx = { version = "0.7.3", optional = true, features = [
|
||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
"postgres",
|
"postgres",
|
||||||
|
@ -31,5 +41,5 @@ reqwest = { version = "0.11.25", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
] }
|
] }
|
||||||
html5ever = "0.26.0"
|
html5ever = "0.26.0"
|
||||||
url = { version = "2.5.0", features = ["serde"] }
|
|
||||||
markup5ever_rcdom = "0.2.0"
|
markup5ever_rcdom = "0.2.0"
|
||||||
|
url = { version = "2.5.0", features = ["serde"] }
|
||||||
|
|
|
@ -6,7 +6,8 @@ still very under construction
|
||||||
|
|
||||||
## Todo
|
## Todo
|
||||||
|
|
||||||
- logging
|
- [x] logging
|
||||||
- docs
|
- [ ] docs
|
||||||
- split the 500+ line main.rs into multiple files
|
- [x] split the 500+ line main.rs into multiple files
|
||||||
- styling? (i probably wont ever make this look good, pr if u want to)
|
- [ ] styling? (i probably wont ever make this look good, pr if u want to)
|
||||||
|
- [ ] `cache_age`
|
||||||
|
|
1
migrations/20240313162104_remove_url.sql
Normal file
1
migrations/20240313162104_remove_url.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE posts DROP COLUMN url;
|
|
@ -1,11 +1,10 @@
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
env,
|
||||||
net::{IpAddr, Ipv4Addr},
|
net::{IpAddr, Ipv4Addr},
|
||||||
path::Path,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
@ -19,6 +18,7 @@ pub struct Config {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub db_url: Option<String>,
|
pub db_url: Option<String>,
|
||||||
|
pub json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
@ -31,19 +31,51 @@ impl Default for Config {
|
||||||
title: "biter".into(),
|
title: "biter".into(),
|
||||||
description: "biter twitter proxy".into(),
|
description: "biter twitter proxy".into(),
|
||||||
db_url: None,
|
db_url: None,
|
||||||
|
json: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load(file: impl AsRef<Path>) -> Result<Config, Box<dyn Error>> {
|
pub async fn load() -> Config {
|
||||||
let mut buf = String::new();
|
let config_file = env::var(format!("{}_CONFIG", env!("CARGO_BIN_NAME")))
|
||||||
|
.unwrap_or(String::from("config.toml"));
|
||||||
tokio::fs::OpenOptions::new()
|
match tokio::fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.open(&file)
|
.open(&config_file)
|
||||||
.await?
|
.await
|
||||||
.read_to_string(&mut buf)
|
{
|
||||||
.await?;
|
Ok(mut file) => {
|
||||||
|
let mut buf = String::new();
|
||||||
Ok(toml::from_str(&buf)?)
|
file.read_to_string(&mut buf)
|
||||||
|
.await
|
||||||
|
.expect("couldn't read configuration file");
|
||||||
|
toml::from_str(&buf)
|
||||||
|
.unwrap_or_else(|err| panic!("couldn't parse configuration:\n{}", err))
|
||||||
|
}
|
||||||
|
Err(err) => match err.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => {
|
||||||
|
let config = Config::default();
|
||||||
|
println!("configuration file doesn't exist, creating");
|
||||||
|
match tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(&config_file)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut file) => file
|
||||||
|
.write_all(
|
||||||
|
toml::to_string_pretty(&config)
|
||||||
|
.expect("couldn't serialize configuration")
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| eprintln!("couldn't write configuration: {}", err)),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("couldn't open file {:?} for writing: {}", &config_file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
_ => panic!("couldn't open config file: {}", err),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
83
src/error.rs
Normal file
83
src/error.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
use askama_axum::Template;
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(transparent)]
|
||||||
|
ReqwestError(#[from] reqwest::Error),
|
||||||
|
#[cfg(feature = "db")]
|
||||||
|
#[error(transparent)]
|
||||||
|
SqlxError(#[from] sqlx::Error),
|
||||||
|
#[error("database not configured")]
|
||||||
|
NoDb,
|
||||||
|
#[error(transparent)]
|
||||||
|
AskamaError(#[from] askama::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
UrlParseError(#[from] url::ParseError),
|
||||||
|
#[error("error while parsing html: {0}")]
|
||||||
|
HtmlParseError(&'static str),
|
||||||
|
#[error("couldn't parse API response: {0}")]
|
||||||
|
APIParseError(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "error.html")]
|
||||||
|
struct ErrorTemplate<'a> {
|
||||||
|
status_code: StatusCode,
|
||||||
|
reason: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Error {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
error!("error while handling request: {}", &self);
|
||||||
|
#[cfg(not(feature = "tracing"))]
|
||||||
|
eprintln!("error while handling request: {}", &self);
|
||||||
|
|
||||||
|
let (status_code, reason) = match self {
|
||||||
|
Self::ReqwestError(err) => {
|
||||||
|
if err.is_status() {
|
||||||
|
let status = err.status().unwrap();
|
||||||
|
match status.as_u16() {
|
||||||
|
404 => (StatusCode::NOT_FOUND, "Tweet not found"),
|
||||||
|
_ => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Error response from twitter",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to contact twitter API",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "db")]
|
||||||
|
Self::SqlxError(err) => match err {
|
||||||
|
_ => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"There was an issue with the database",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Self::NoDb => (StatusCode::INTERNAL_SERVER_ERROR, "Database not configured"),
|
||||||
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown error"),
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status_code,
|
||||||
|
ErrorTemplate {
|
||||||
|
status_code,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
99
src/fetch_media.rs
Normal file
99
src/fetch_media.rs
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
use html5ever::{
|
||||||
|
parse_document,
|
||||||
|
tendril::{SliceExt, TendrilSink},
|
||||||
|
};
|
||||||
|
use markup5ever_rcdom::{NodeData, RcDom};
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::{reqwest_client, Config, Error, Result};
|
||||||
|
|
||||||
|
pub async fn fetch_media(id: i64, config: &Config) -> Result<Option<(String, Option<String>)>> {
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
info!(metric = "fetch_media", %id, "fetching media for post");
|
||||||
|
|
||||||
|
let mut tweet_url = config.TWITTER_BASE_URL.clone();
|
||||||
|
tweet_url.set_path(&format!("twitter/status/{}", id));
|
||||||
|
|
||||||
|
let body = reqwest_client().get(tweet_url).send().await?.text().await?;
|
||||||
|
|
||||||
|
let document = parse_document(RcDom::default(), Default::default())
|
||||||
|
.one(body)
|
||||||
|
.document;
|
||||||
|
|
||||||
|
let children = document.children.borrow();
|
||||||
|
|
||||||
|
let html = children
|
||||||
|
.iter()
|
||||||
|
.nth(1)
|
||||||
|
.ok_or(Error::HtmlParseError("html not found (what)"))?
|
||||||
|
.children
|
||||||
|
.borrow();
|
||||||
|
|
||||||
|
let head = html
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.ok_or(Error::HtmlParseError("head not found (what??)"))?
|
||||||
|
.children
|
||||||
|
.borrow();
|
||||||
|
|
||||||
|
let og_image = match head.iter().find(|x| match &x.data {
|
||||||
|
NodeData::Element { name, attrs, .. } => {
|
||||||
|
&name.local == "meta"
|
||||||
|
&& attrs
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|y| &y.name.local == "property" && y.value == "og:image".to_tendril())
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}) {
|
||||||
|
Some(val) => val,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut url = match &og_image.data {
|
||||||
|
NodeData::Element { attrs, .. } => attrs
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|attr| &attr.name.local == "content")
|
||||||
|
.ok_or(Error::HtmlParseError("og:image content attr not found"))?
|
||||||
|
.value
|
||||||
|
.to_string(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if url.ends_with(":large") {
|
||||||
|
url = url.split_at(url.len() - 6).0.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
url += "?name=4096x4096";
|
||||||
|
|
||||||
|
let image_alt = match head.iter().find(|x| match &x.data {
|
||||||
|
NodeData::Element { name, attrs, .. } => {
|
||||||
|
&name.local == "meta"
|
||||||
|
&& attrs
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|y| &y.name.local == "property" && y.value == "og:image:alt".to_tendril())
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}) {
|
||||||
|
Some(x) => match &x.data {
|
||||||
|
NodeData::Element { attrs, .. } => Some(
|
||||||
|
attrs
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|y| &y.name.local == "content")
|
||||||
|
.ok_or(Error::HtmlParseError("og:image:alt content attr not found"))?
|
||||||
|
.value
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
_ => unreachable!(),
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some((url, image_alt)))
|
||||||
|
}
|
145
src/fetch_post.rs
Normal file
145
src/fetch_post.rs
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
use askama::filters::{escape, urlencode_strict};
|
||||||
|
use html5ever::{parse_fragment, tendril::TendrilSink, QualName};
|
||||||
|
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
||||||
|
use serde::Deserialize;
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
use tracing::info;
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
use tracing::warn;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{fetch_media::fetch_media, reqwest_client, Config, Error, Post, Result};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OembedTweetResponse {
|
||||||
|
author_name: String, // Display name
|
||||||
|
author_url: String,
|
||||||
|
html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(handle: &Handle) -> Result<(String, Vec<String>)> {
|
||||||
|
let mut html = String::new();
|
||||||
|
let mut media = Vec::new();
|
||||||
|
|
||||||
|
for child in handle.children.borrow().iter() {
|
||||||
|
match &child.data {
|
||||||
|
NodeData::Text { contents } => {
|
||||||
|
html += &escape(askama::Html, contents.borrow())?.to_string()
|
||||||
|
}
|
||||||
|
NodeData::Element { name, attrs, .. } => {
|
||||||
|
if "a" == &name.local {
|
||||||
|
let children = child.children.borrow();
|
||||||
|
if let Some(handle) = children.iter().next() {
|
||||||
|
match &handle.data {
|
||||||
|
NodeData::Text { contents } => {
|
||||||
|
let contents = contents.borrow();
|
||||||
|
if contents.starts_with("pic.twitter.com") {
|
||||||
|
media.push(
|
||||||
|
Url::parse(&format!("https://{}", contents))?.to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
html += &format!(
|
||||||
|
"<a href={:?}>{}</a>",
|
||||||
|
attrs
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.find(|x| &x.name.local == "href")
|
||||||
|
.ok_or(Error::HtmlParseError("Kill yourself"))?
|
||||||
|
.value
|
||||||
|
.to_string(),
|
||||||
|
contents
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(Error::HtmlParseError("expected text node"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(Error::HtmlParseError("expected anchor tag"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(Error::HtmlParseError("expected text node or element"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((html, media))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_html(html: &str) -> Result<(String, Vec<String>)> {
|
||||||
|
let handle = parse_fragment(
|
||||||
|
RcDom::default(),
|
||||||
|
Default::default(),
|
||||||
|
QualName::new(None, ns!(html), local_name!("body")),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.one(html)
|
||||||
|
.document;
|
||||||
|
|
||||||
|
let root = handle.children.borrow();
|
||||||
|
|
||||||
|
let elem_html = root.iter().next().unwrap().children.borrow();
|
||||||
|
|
||||||
|
let elem_blockquote = elem_html
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.ok_or(Error::HtmlParseError("couldn't get blockquote"))?
|
||||||
|
.children
|
||||||
|
.borrow();
|
||||||
|
|
||||||
|
let elem_p = elem_blockquote
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.ok_or(Error::HtmlParseError("couldn't get paragraph"))?;
|
||||||
|
|
||||||
|
walk(&elem_p)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_post(id: i64, config: &Config) -> Result<Post> {
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
|
info!(metric = "fetch", %id);
|
||||||
|
|
||||||
|
let mut url = config.FUCKING_ENDPOINT.clone();
|
||||||
|
let mut tweet_url = config.TWITTER_BASE_URL.clone();
|
||||||
|
tweet_url.set_path(&format!("twitter/status/{}", id));
|
||||||
|
|
||||||
|
url.set_query(Some(&format!(
|
||||||
|
"url={}&omit_script=1&lang=en",
|
||||||
|
urlencode_strict(tweet_url)?
|
||||||
|
)));
|
||||||
|
let res: OembedTweetResponse = reqwest_client().get(url).send().await?.json().await?;
|
||||||
|
|
||||||
|
let author_url = Url::parse(&res.author_url)?;
|
||||||
|
let handle = author_url
|
||||||
|
.path_segments()
|
||||||
|
.and_then(|x| x.last())
|
||||||
|
.ok_or(Error::APIParseError("couldn't parse author_url"))?;
|
||||||
|
|
||||||
|
let (body, media) = parse_html(&res.html)?;
|
||||||
|
|
||||||
|
let mut image = None;
|
||||||
|
let mut alt = None;
|
||||||
|
|
||||||
|
if media.len() > 0 {
|
||||||
|
if let Some((link, alt_text)) = fetch_media(id, config).await? {
|
||||||
|
image = Some(link);
|
||||||
|
alt = alt_text;
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
warn!("couldn't fetch media");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Post {
|
||||||
|
id,
|
||||||
|
handle: handle.to_owned(),
|
||||||
|
name: res.author_name,
|
||||||
|
body,
|
||||||
|
media,
|
||||||
|
image,
|
||||||
|
alt,
|
||||||
|
})
|
||||||
|
}
|
394
src/main.rs
394
src/main.rs
|
@ -1,96 +1,44 @@
|
||||||
mod config;
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod fetch_media;
|
||||||
|
mod fetch_post;
|
||||||
#[cfg(feature = "db")]
|
#[cfg(feature = "db")]
|
||||||
mod query;
|
mod query;
|
||||||
|
|
||||||
use crate::config::Config;
|
use askama_axum::Template;
|
||||||
#[cfg(feature = "db")]
|
#[cfg(feature = "tracing")]
|
||||||
use crate::query::{INSERT_POST, SELECT_POST};
|
use axum::{extract::MatchedPath, http::Request, response::Response};
|
||||||
use html5ever::tendril::{SliceExt, TendrilSink};
|
|
||||||
use html5ever::{parse_document, QualName};
|
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::{net::SocketAddr, time::Duration};
|
|
||||||
|
|
||||||
#[macro_use]
|
|
||||||
extern crate html5ever;
|
|
||||||
|
|
||||||
use askama::filters::{escape, urlencode_strict};
|
|
||||||
use askama_axum::{IntoResponse, Template};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{MatchedPath, Path, State},
|
extract::{Path, State},
|
||||||
http::{Request, StatusCode},
|
response::IntoResponse,
|
||||||
response::Response,
|
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use html5ever::parse_fragment;
|
use reqwest::Client;
|
||||||
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
use serde::Serialize;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
#[cfg(feature = "db")]
|
#[cfg(feature = "db")]
|
||||||
use sqlx::{FromRow, PgPool};
|
use sqlx::{FromRow, PgPool};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
use std::time::Duration;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::{error, info, info_span, warn, Span};
|
#[cfg(feature = "tracing")]
|
||||||
use url::Url;
|
use tracing::{info, info_span, level_filters::LevelFilter, Span};
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
pub use crate::config::Config;
|
||||||
#[allow(unused)]
|
pub use crate::error::{Error, Result};
|
||||||
enum AppError {
|
use crate::fetch_post::fetch_post;
|
||||||
#[error(transparent)]
|
#[cfg(feature = "db")]
|
||||||
ReqwestError(#[from] reqwest::Error),
|
use crate::query::{INSERT_POST, SELECT_POST};
|
||||||
#[cfg(feature = "db")]
|
|
||||||
#[error(transparent)]
|
|
||||||
SqlxError(#[from] sqlx::Error),
|
|
||||||
#[error("database not configured")]
|
|
||||||
NoDb,
|
|
||||||
#[error(transparent)]
|
|
||||||
AskamaError(#[from] askama::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
UrlParseError(#[from] url::ParseError),
|
|
||||||
#[error("error while parsing html: {0}")]
|
|
||||||
HtmlParseError(&'static str),
|
|
||||||
#[error("couldn't parse API response: {0}")]
|
|
||||||
APIParseError(&'static str),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
#[macro_use]
|
||||||
fn into_response(self) -> Response {
|
extern crate html5ever;
|
||||||
error!("{}", &self);
|
|
||||||
match self {
|
|
||||||
Self::ReqwestError(err) => {
|
|
||||||
if err.is_status() {
|
|
||||||
let status = StatusCode::from_u16(err.status().unwrap().as_u16()).unwrap();
|
|
||||||
(status, format!("Error response from twitter: {}", status)).into_response()
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to contact twitter API",
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(feature = "db")]
|
|
||||||
Self::SqlxError(err) => match err {
|
|
||||||
sqlx::Error::RowNotFound => {
|
|
||||||
(StatusCode::NOT_FOUND, "just fucking not found").into_response()
|
|
||||||
}
|
|
||||||
_ => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"There was an issue with the database",
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
},
|
|
||||||
Self::NoDb => {
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Database not configured").into_response()
|
|
||||||
}
|
|
||||||
_ => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppResult<T> = Result<T, AppError>;
|
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
|
@ -112,6 +60,7 @@ struct IndexTemplate<'a> {
|
||||||
struct TweetTemplate<'a> {
|
struct TweetTemplate<'a> {
|
||||||
title: &'a str,
|
title: &'a str,
|
||||||
author: String,
|
author: String,
|
||||||
|
author_url: String,
|
||||||
url: String,
|
url: String,
|
||||||
handle: String,
|
handle: String,
|
||||||
content: String,
|
content: String,
|
||||||
|
@ -120,19 +69,10 @@ struct TweetTemplate<'a> {
|
||||||
alt: Option<String>,
|
alt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct OembedTweetResponse {
|
|
||||||
url: String,
|
|
||||||
author_name: String, // Display name
|
|
||||||
author_url: String,
|
|
||||||
html: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "db", derive(FromRow, Serialize, Clone, Debug))]
|
#[cfg_attr(feature = "db", derive(FromRow, Serialize, Clone, Debug))]
|
||||||
#[cfg_attr(not(feature = "db"), derive(Serialize, Clone, Debug))]
|
#[cfg_attr(not(feature = "db"), derive(Serialize, Clone, Debug))]
|
||||||
struct Post {
|
pub struct Post {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub url: String,
|
|
||||||
pub handle: String,
|
pub handle: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
|
@ -141,7 +81,7 @@ struct Post {
|
||||||
pub alt: Option<String>,
|
pub alt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reqwest_client() -> Client {
|
pub fn reqwest_client() -> Client {
|
||||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||||
CLIENT
|
CLIENT
|
||||||
.get_or_init(|| {
|
.get_or_init(|| {
|
||||||
|
@ -153,229 +93,11 @@ fn reqwest_client() -> Client {
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_image_url_from_tweet_id_lol(
|
|
||||||
id: i64,
|
|
||||||
config: &Config,
|
|
||||||
) -> Result<Option<(String, Option<String>)>, AppError> {
|
|
||||||
info!(metric = "fetch_media", %id, "fetching media for post");
|
|
||||||
|
|
||||||
let mut tweet_url = config.TWITTER_BASE_URL.clone();
|
|
||||||
tweet_url.set_path(&format!("twitter/status/{}", id));
|
|
||||||
|
|
||||||
let body = reqwest_client().get(tweet_url).send().await?.text().await?;
|
|
||||||
|
|
||||||
let document = parse_document(RcDom::default(), Default::default())
|
|
||||||
.one(body)
|
|
||||||
.document;
|
|
||||||
|
|
||||||
let children = document.children.borrow();
|
|
||||||
|
|
||||||
let html = children
|
|
||||||
.iter()
|
|
||||||
.nth(1)
|
|
||||||
.ok_or(AppError::HtmlParseError("html not found (what)"))?
|
|
||||||
.children
|
|
||||||
.borrow();
|
|
||||||
|
|
||||||
let head = html
|
|
||||||
.iter()
|
|
||||||
.next()
|
|
||||||
.ok_or(AppError::HtmlParseError("head not found (what??)"))?
|
|
||||||
.children
|
|
||||||
.borrow();
|
|
||||||
|
|
||||||
let og_image = match head.iter().find(|x| match &x.data {
|
|
||||||
NodeData::Element { name, attrs, .. } => {
|
|
||||||
&name.local == "meta"
|
|
||||||
&& attrs
|
|
||||||
.borrow()
|
|
||||||
.iter()
|
|
||||||
.find(|y| &y.name.local == "property" && y.value == "og:image".to_tendril())
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}) {
|
|
||||||
Some(val) => val,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut url = match &og_image.data {
|
|
||||||
NodeData::Element { attrs, .. } => attrs
|
|
||||||
.borrow()
|
|
||||||
.iter()
|
|
||||||
.find(|attr| &attr.name.local == "content")
|
|
||||||
.ok_or(AppError::HtmlParseError("twitter is actually trolling now"))?
|
|
||||||
.value
|
|
||||||
.to_string(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if url.ends_with(":large") {
|
|
||||||
url = url.split_at(url.len() - 6).0.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
url += "?name=4096x4096";
|
|
||||||
|
|
||||||
let image_alt = match head.iter().find(|x| match &x.data {
|
|
||||||
NodeData::Element { name, attrs, .. } => {
|
|
||||||
&name.local == "meta"
|
|
||||||
&& attrs
|
|
||||||
.borrow()
|
|
||||||
.iter()
|
|
||||||
.find(|y| &y.name.local == "property" && y.value == "og:image:alt".to_tendril())
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}) {
|
|
||||||
Some(x) => match &x.data {
|
|
||||||
NodeData::Element { attrs, .. } => Some(
|
|
||||||
attrs
|
|
||||||
.borrow()
|
|
||||||
.iter()
|
|
||||||
.find(|y| &y.name.local == "content")
|
|
||||||
.ok_or(AppError::HtmlParseError("fuck"))?
|
|
||||||
.value
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
_ => unreachable!(),
|
|
||||||
},
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some((url, image_alt)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk(handle: &Handle) -> Result<(String, Vec<String>), AppError> {
|
|
||||||
let mut html = String::new();
|
|
||||||
let mut media = Vec::new();
|
|
||||||
|
|
||||||
for child in handle.children.borrow().iter() {
|
|
||||||
match &child.data {
|
|
||||||
NodeData::Text { contents } => {
|
|
||||||
html += &escape(askama::Html, contents.borrow())?.to_string()
|
|
||||||
}
|
|
||||||
NodeData::Element { name, attrs, .. } => {
|
|
||||||
if "a" == &name.local {
|
|
||||||
let children = child.children.borrow();
|
|
||||||
if let Some(handle) = children.iter().next() {
|
|
||||||
match &handle.data {
|
|
||||||
NodeData::Text { contents } => {
|
|
||||||
let contents = contents.borrow();
|
|
||||||
if contents.starts_with("pic.twitter.com") {
|
|
||||||
media.push(
|
|
||||||
Url::parse(&format!("https://{}", contents))?.to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
html += &format!(
|
|
||||||
"<a href={:?}>{}</a>",
|
|
||||||
attrs
|
|
||||||
.borrow()
|
|
||||||
.iter()
|
|
||||||
.find(|x| &x.name.local == "href")
|
|
||||||
.ok_or(AppError::HtmlParseError("Kill yourself"))?
|
|
||||||
.value
|
|
||||||
.to_string(),
|
|
||||||
contents
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::HtmlParseError("no"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(AppError::HtmlParseError("AAAA"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(AppError::HtmlParseError("the fuck"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((html, media))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_html(html: &str) -> Result<(String, Vec<String>), AppError> {
|
|
||||||
let handle = parse_fragment(
|
|
||||||
RcDom::default(),
|
|
||||||
Default::default(),
|
|
||||||
QualName::new(None, ns!(html), local_name!("body")),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.one(html)
|
|
||||||
.document;
|
|
||||||
|
|
||||||
let root = handle.children.borrow();
|
|
||||||
|
|
||||||
let elem_html = root.iter().next().unwrap().children.borrow();
|
|
||||||
|
|
||||||
let elem_blockquote = elem_html
|
|
||||||
.iter()
|
|
||||||
.next()
|
|
||||||
.ok_or(AppError::HtmlParseError("couldn't get blockquote"))?
|
|
||||||
.children
|
|
||||||
.borrow();
|
|
||||||
|
|
||||||
let elem_p = elem_blockquote
|
|
||||||
.iter()
|
|
||||||
.next()
|
|
||||||
.ok_or(AppError::HtmlParseError("couldn't get paragraph"))?;
|
|
||||||
|
|
||||||
walk(&elem_p)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_post(id: i64, config: &Config) -> Result<Post, AppError> {
|
|
||||||
info!(metric = "fetch", %id, "fetching post");
|
|
||||||
|
|
||||||
let mut url = config.FUCKING_ENDPOINT.clone();
|
|
||||||
let mut tweet_url = config.TWITTER_BASE_URL.clone();
|
|
||||||
tweet_url.set_path(&format!("twitter/status/{}", id));
|
|
||||||
|
|
||||||
url.set_query(Some(&format!(
|
|
||||||
"url={}&omit_script=1&lang=en",
|
|
||||||
urlencode_strict(tweet_url)?
|
|
||||||
)));
|
|
||||||
let res: OembedTweetResponse = reqwest_client().get(url).send().await?.json().await?;
|
|
||||||
|
|
||||||
let author_url = Url::parse(&res.author_url)?;
|
|
||||||
let handle = author_url
|
|
||||||
.path_segments()
|
|
||||||
.and_then(|x| x.last())
|
|
||||||
.ok_or(AppError::APIParseError("couldn't parse author_url"))?;
|
|
||||||
|
|
||||||
let (body, media) = parse_html(&res.html)?;
|
|
||||||
|
|
||||||
let mut image = None;
|
|
||||||
let mut alt = None;
|
|
||||||
|
|
||||||
if media.len() > 0 {
|
|
||||||
if let Some((shit, fuck)) = get_image_url_from_tweet_id_lol(id, config).await? {
|
|
||||||
image = Some(shit);
|
|
||||||
alt = fuck;
|
|
||||||
} else {
|
|
||||||
warn!("couldn't fetch media");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Post {
|
|
||||||
id,
|
|
||||||
url: res.url,
|
|
||||||
handle: handle.to_owned(),
|
|
||||||
name: res.author_name,
|
|
||||||
body,
|
|
||||||
media,
|
|
||||||
image,
|
|
||||||
alt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn tweet<'a>(
|
async fn tweet<'a>(
|
||||||
#[cfg(feature = "db")] State(AppState { config, db }): State<RefAppState>,
|
#[cfg(feature = "db")] State(AppState { config, db }): State<RefAppState>,
|
||||||
#[cfg(not(feature = "db"))] State(AppState { config }): State<RefAppState>,
|
#[cfg(not(feature = "db"))] State(AppState { config }): State<RefAppState>,
|
||||||
Path((handle, id)): Path<(String, i64)>,
|
#[allow(unused_variables)] Path((handle, id)): Path<(String, i64)>,
|
||||||
) -> AppResult<TweetTemplate<'a>> {
|
) -> Result<TweetTemplate<'a>> {
|
||||||
#[cfg(feature = "db")]
|
#[cfg(feature = "db")]
|
||||||
let post = match match db.as_ref() {
|
let post = match match db.as_ref() {
|
||||||
Some(conn) => match sqlx::query_as::<_, Post>(SELECT_POST)
|
Some(conn) => match sqlx::query_as::<_, Post>(SELECT_POST)
|
||||||
|
@ -384,6 +106,7 @@ async fn tweet<'a>(
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(post) => {
|
Ok(post) => {
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
info!(metric = "post_retrieve", from = "db", %id, "retrieved post from db");
|
info!(metric = "post_retrieve", from = "db", %id, "retrieved post from db");
|
||||||
Some(post)
|
Some(post)
|
||||||
}
|
}
|
||||||
|
@ -397,6 +120,7 @@ async fn tweet<'a>(
|
||||||
Some(post) => post,
|
Some(post) => post,
|
||||||
None => {
|
None => {
|
||||||
let post = fetch_post(id, config).await?;
|
let post = fetch_post(id, config).await?;
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
info!(metric = "post_retrieve", from = "twitter", %id, "retrieved post from twitter");
|
info!(metric = "post_retrieve", from = "twitter", %id, "retrieved post from twitter");
|
||||||
|
|
||||||
if let Some(conn) = db.as_ref() {
|
if let Some(conn) = db.as_ref() {
|
||||||
|
@ -404,7 +128,6 @@ async fn tweet<'a>(
|
||||||
|
|
||||||
sqlx::query(INSERT_POST)
|
sqlx::query(INSERT_POST)
|
||||||
.bind(post.id)
|
.bind(post.id)
|
||||||
.bind(post.url)
|
|
||||||
.bind(post.handle)
|
.bind(post.handle)
|
||||||
.bind(post.name)
|
.bind(post.name)
|
||||||
.bind(post.body)
|
.bind(post.body)
|
||||||
|
@ -413,6 +136,7 @@ async fn tweet<'a>(
|
||||||
.bind(post.alt)
|
.bind(post.alt)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
info!(metric = "post_add", %id, "added post into db");
|
info!(metric = "post_add", %id, "added post into db");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -422,8 +146,10 @@ async fn tweet<'a>(
|
||||||
#[cfg(not(feature = "db"))]
|
#[cfg(not(feature = "db"))]
|
||||||
let post = fetch_post(id, config).await?;
|
let post = fetch_post(id, config).await?;
|
||||||
#[cfg(not(feature = "db"))]
|
#[cfg(not(feature = "db"))]
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
info!(metric = "post_retrieve", from = "twitter", %id, "retrieved post from twitter");
|
info!(metric = "post_retrieve", from = "twitter", %id, "retrieved post from twitter");
|
||||||
|
|
||||||
|
#[cfg(feature = "metrics")]
|
||||||
if handle != "twitter" && handle != post.handle {
|
if handle != "twitter" && handle != post.handle {
|
||||||
info!(metric = "dickhead", %handle, "dickhead found");
|
info!(metric = "dickhead", %handle, "dickhead found");
|
||||||
}
|
}
|
||||||
|
@ -431,12 +157,21 @@ async fn tweet<'a>(
|
||||||
Ok(TweetTemplate {
|
Ok(TweetTemplate {
|
||||||
title: &config.title,
|
title: &config.title,
|
||||||
author: post.name,
|
author: post.name,
|
||||||
|
author_url: {
|
||||||
|
let mut url = config.TWITTER_BASE_URL.clone();
|
||||||
|
url.set_path(&post.handle);
|
||||||
|
url.to_string()
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
let mut url = config.TWITTER_BASE_URL.clone();
|
||||||
|
url.set_path(&format!("{}/status/{}", post.handle, post.id));
|
||||||
|
url.to_string()
|
||||||
|
},
|
||||||
|
handle: post.handle,
|
||||||
content: post.body,
|
content: post.body,
|
||||||
media: post.media,
|
media: post.media,
|
||||||
image: post.image,
|
image: post.image,
|
||||||
alt: post.alt,
|
alt: post.alt,
|
||||||
url: post.url,
|
|
||||||
handle: post.handle,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -449,9 +184,26 @@ async fn index(State(AppState { config, .. }): State<RefAppState>) -> impl IntoR
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt().init();
|
let config = config::load().await;
|
||||||
|
|
||||||
let config = config::load("config.toml").await.unwrap_or_default();
|
#[cfg(feature = "tracing")]
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.with(if config.json {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tracing_subscriber::fmt::layer())
|
||||||
|
})
|
||||||
|
.with(if config.json {
|
||||||
|
Some(tracing_subscriber::fmt::layer().json())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.init();
|
||||||
|
|
||||||
reqwest_client();
|
reqwest_client();
|
||||||
|
|
||||||
|
@ -479,8 +231,10 @@ async fn main() {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest_service("/static", tower_http::services::ServeDir::new("static"))
|
.nest_service("/static", tower_http::services::ServeDir::new("static"))
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/:user/status/:id", get(tweet))
|
.route("/:user/status/:id", get(tweet));
|
||||||
.layer(
|
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
let app = app.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(|request: &Request<_>| {
|
.make_span_with(|request: &Request<_>| {
|
||||||
let matched_path = request
|
let matched_path = request
|
||||||
|
@ -501,14 +255,18 @@ async fn main() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
info!(?status, ?duration);
|
info!(?status, ?duration);
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
.with_state(state);
|
|
||||||
|
let app = app.with_state(state);
|
||||||
|
|
||||||
let listener = TcpListener::bind((state.config.host, state.config.port))
|
let listener = TcpListener::bind((state.config.host, state.config.port))
|
||||||
.await
|
.await
|
||||||
.expect("couldn't listen");
|
.expect("couldn't listen");
|
||||||
let local_addr = listener.local_addr().expect("couldn't get socket address");
|
let local_addr = listener.local_addr().expect("couldn't get socket address");
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
info!("listening on http://{}", local_addr);
|
info!("listening on http://{}", local_addr);
|
||||||
|
#[cfg(not(feature = "tracing"))]
|
||||||
|
eprintln!("listening on http://{}", local_addr);
|
||||||
|
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
pub const SELECT_POST: &str = "SELECT * FROM posts WHERE id = $1";
|
pub const SELECT_POST: &str = "SELECT * FROM posts WHERE id = $1";
|
||||||
pub const INSERT_POST: &str =
|
pub const INSERT_POST: &str =
|
||||||
"INSERT INTO posts (id, url, handle, name, body, media, image, alt) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)";
|
"INSERT INTO posts (id, handle, name, body, media, image, alt) VALUES ($1, $2, $3, $4, $5, $6, $7)";
|
||||||
|
|
15
templates/error.html
Normal file
15
templates/error.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Error</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ status_code }}</h1>
|
||||||
|
<p>{{ reason }}</p>
|
||||||
|
|
||||||
|
<a href="/">main page</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,16 +1,14 @@
|
||||||
<div class="tweet">
|
<div class="tweet">
|
||||||
<div class="author">
|
<div class="author">
|
||||||
<span class="name">{{ author }}</span>
|
<span class="name">{{ author }}</span>
|
||||||
<a class="handle" href="https://twitter.com/{{ handle }}"
|
<a class="handle" href="{{ author_url }}">@{{ handle }}</a>
|
||||||
>@{{ handle }}</a
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>{{ content|safe }}</p>
|
<p>{{ content|safe }}</p>
|
||||||
{% match image %} {% when Some with (link) %}
|
{% match image %} {% when Some with (link) %}
|
||||||
<img
|
<img
|
||||||
src="{{ link }}"
|
src="{{ link }}"
|
||||||
alt="{% match alt %} {% when Some with (text) %} {{ text }} {% when None %}{% endmatch%}"
|
alt="{% match alt %} {% when Some with (text) %} {{ text }} {% when None %}{% endmatch %}"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Reference in a new issue