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