This commit is contained in:
h 2023-10-27 22:20:49 -04:00
commit 5b97755da8
10 changed files with 1902 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1571
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "linky"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.6.20", features = ["macros"] }
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0.75"
minijinja = "1.0.8"
reqwest = { version = "0.11.22"}
regex = "1.10.0"
tl = "0.7.7"
axum-extra = { version = "0.8.0", features = ["cookie-signed", "cookie"] }
serde = { version = "1.0.189", features = ["derive"] }

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM rust:1.73.0 as builder
WORKDIR /build
COPY ./src/ /build/src
COPY ./Cargo.toml /build/Cargo.toml
RUN rustup update nightly && rustup default nightly && \
cargo clean && cargo build --release
FROM debian:buster-slim
WORKDIR /app
COPY --from=builder /build/target/release/linky /app/linky
RUN mkdir /app/service
RUN mkdir /store
COPY ./templates/ /app/templates
CMD [ "/app/linky /store/store.xmlfrag" ]

165
src/main.rs Normal file
View File

@ -0,0 +1,165 @@
#![feature(lazy_cell)]
mod writer;
use axum::{
extract::State,
http::{HeaderMap, Request, StatusCode},
middleware,
middleware::Next,
response::{Html, Redirect, Response, Result},
routing::{get, post},
Form, Router,
};
use axum_extra::extract::{
cookie::{Cookie, Key},
SignedCookieJar,
};
use minijinja::{context, Environment};
use serde::Deserialize;
use std::fs;
use std::sync::{Arc, LazyLock};
use std::{env, process::exit};
use tokio::sync::Mutex;
use writer::LinkFile;
static SECRET_KEY: LazyLock<Key> = LazyLock::new(Key::generate);
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
println!("Need file argument.");
exit(1);
}
let store = Arc::new(Mutex::new(LinkFile::new(&args[1])));
let authenticated = Router::new()
.route("/rss.xml", get(get_rss))
.route("/sendlink", post(send_link))
.with_state(Arc::clone(&store))
.layer(middleware::from_fn(authenticate));
let app = Router::new()
.route("/", get(get_index))
.route("/login", get(get_login).post(post_login))
.with_state(store)
.merge(authenticated);
axum::Server::bind(&"0.0.0.0:8000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
async fn get_index(
State(_state): State<Arc<Mutex<LinkFile>>>,
headers: HeaderMap,
) -> Result<Html<String>, Redirect> {
let jar = SignedCookieJar::from_headers(&headers, LazyLock::force(&SECRET_KEY).clone());
if let Some(x) = jar.get("auth") {
if x.value() == "yeppers" {
return Ok(Html(
fs::read_to_string("templates/send_link.html").unwrap(),
));
}
}
Err(Redirect::to("/login"))
}
async fn get_login(State(_state): State<Arc<Mutex<LinkFile>>>) -> Result<Html<String>, Redirect> {
Ok(Html(fs::read_to_string("templates/login.html").unwrap()))
}
async fn get_rss(State(state): State<Arc<Mutex<LinkFile>>>) -> Result<String> {
let template = fs::read_to_string("templates/rss.xml").unwrap();
let linkfile = state.lock().await;
let xmlfrag = fs::read_to_string(linkfile.get_path()).unwrap();
let link = match env::var("LINKY_LINK") {
Ok(v) => v,
Err(_) => "https://localhost:8000".to_string(),
};
let mut env = Environment::new();
env.add_template("rss_xml", &template).unwrap();
let template = env.get_template("rss_xml").unwrap();
Ok(template
.render(context! {
xmlfrag => xmlfrag,
link => link,
})
.unwrap())
}
#[derive(Deserialize)]
struct LinkData {
pub linkdata: String,
}
async fn send_link(
State(state): State<Arc<Mutex<LinkFile>>>,
Form(form): Form<LinkData>,
) -> Result<Redirect> {
let string = form.linkdata;
let mut linkfile = state.lock().await;
for str in string.split_whitespace() {
linkfile.add_link(str).await.map_err(|e| {
println!("ERROR: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
println!("Added: {}", str);
}
Ok(Redirect::to("/"))
}
#[derive(Deserialize)]
struct LoginInfo {
pub password: String,
}
async fn post_login(
State(_state): State<Arc<Mutex<LinkFile>>>,
headers: HeaderMap,
Form(form): Form<LoginInfo>,
) -> Result<(SignedCookieJar, Redirect)> {
let mut jar = SignedCookieJar::from_headers(&headers, LazyLock::force(&SECRET_KEY).clone());
if let Some(x) = jar.get("auth") {
if x.value() != "yeppers" {
jar = jar.remove(x);
} else {
return Ok((jar, Redirect::to("/")));
}
}
let pswd = match env::var("LINKY_PASSWORD") {
Ok(v) => v,
Err(_) => "linkypassword".to_string(),
};
if form.password == pswd {
if let Some(x) = jar.get("auth") {
jar = jar.remove(x);
}
jar = jar.add(Cookie::new("auth", "yeppers"));
}
Ok((jar, Redirect::to("/")))
}
async fn authenticate<B>(
headers: HeaderMap,
request: Request<B>,
next: Next<B>,
) -> Result<Response, Redirect> {
let jar = SignedCookieJar::from_headers(&headers, LazyLock::force(&SECRET_KEY).clone());
if let Some(x) = jar.get("auth") {
if x.value() == "yeppers" {
return Ok(next.run(request).await);
}
}
Err(Redirect::to("/login"))
}

89
src/writer.rs Normal file
View File

@ -0,0 +1,89 @@
use anyhow::{Context, Result};
use minijinja::{context, Environment};
use reqwest::Client;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const TEMPLATE_STRING: &str = "
<item>
<title> {{ title }} </title>
<link> {{ link }} </link>
</item>
";
pub struct LinkFile {
file: File,
client: Client,
path: PathBuf,
}
async fn get_link_title(client: &Client, link: &str) -> Result<String> {
let res = timeout(
Duration::from_secs(120),
timeout(Duration::from_secs(120), client.get(link).send())
.await??
.text(),
)
.await??;
let dom = tl::parse(&res, tl::ParserOptions::default())?;
let elem = dom
.query_selector("title")
.and_then(|mut iter| iter.next())
.context("No title")?;
let node = elem.get(dom.parser()).context("No title")?;
Ok(node.inner_text(dom.parser()).to_string())
}
impl LinkFile {
pub fn new(path: impl AsRef<Path> + Clone) -> Self {
let file = OpenOptions::new().append(true).open(path.clone()).unwrap();
let client = Client::new();
Self {
file,
client,
path: path.as_ref().to_path_buf(),
}
}
pub async fn add_link(&mut self, link: &str) -> Result<()> {
let title = match get_link_title(&self.client, link).await {
Ok(s) => s,
Err(_) => link.to_string(),
};
let mut env = Environment::new();
env.add_template("rss_fragment", TEMPLATE_STRING)?;
let template = env.get_template("rss_fragment")?;
self.file.write_all(
template
.render(context! {
title => title,
link => link,
})?
.as_bytes(),
)?;
Ok(())
}
pub fn get_path(&self) -> PathBuf {
self.path.clone()
}
}
#[cfg(test)]
mod tests {
use crate::writer::LinkFile;
#[test]
fn test_linkfile() {
let mut linkfile = LinkFile::new("./store.xmlfrag");
linkfile.add_link("https://evilmuff.in").unwrap();
}
}

1
store.xmlfrag Normal file
View File

@ -0,0 +1 @@

11
templates/login.html Normal file
View File

@ -0,0 +1,11 @@
<html>
<head><title>LINKYLINKYLINKYLINKYLINKY</title></head>
<body>
Login:
<form method="post" action="/login">
<input type="password" id="password" name="password"/>
<input type="submit" value="Submit/>
</form>
</body>
</html>

13
templates/rss.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>LINKYLINKYLINKYLINKYLINKY</title>
<description>Linky.</description>
<link>{{ link }}</link>
<atom:link href="{{ link }}/rss.xml" rel="self" type="application/rss+xml" />
<language>en</language>
{{ xmlfrag }}
</channel>
</rss>

11
templates/send_link.html Normal file
View File

@ -0,0 +1,11 @@
<html>
<head><title>LINKYLINKYLINKYLINKYLINKY</title></head>
<body>
Send link:
<form method="post" action="/sendlink">
<textarea name="linkdata" id="linkdata" cols="50" rows="10"></textarea>
<input type="submit" value="Submit"/>
</form>
</body>
</html>