first
This commit is contained in:
commit
5b97755da8
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1571
Cargo.lock
generated
Normal file
1571
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
23
Dockerfile
Normal 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
165
src/main.rs
Normal 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
89
src/writer.rs
Normal 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
1
store.xmlfrag
Normal file
|
@ -0,0 +1 @@
|
||||||
|
|
11
templates/login.html
Normal file
11
templates/login.html
Normal 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
13
templates/rss.xml
Normal 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
11
templates/send_link.html
Normal 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>
|
Loading…
Reference in New Issue
Block a user