Initial commit

This commit is contained in:
bad 2021-07-04 22:12:33 +02:00
commit ad8cca7cad
9 changed files with 2359 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/journals
result

1818
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

25
Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "write-only-journal"
version = "0.1.0"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.41"
chrono = { version = "0.4.19", features = ["serde"] }
hotwatch = "0.4.5"
log = "0.4.14"
mime_guess = "2.0.3"
notify = "5.0.0-pre.10"
once_cell = "1.8.0"
pretty_env_logger = "0.4.0"
rand = "0.8.4"
serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"
tera = "1.11.0"
tokio = { version = "1.7.1", features = ["full"] }
warp = "0.3.1"
[features]
hotreload = []

123
flake.lock Normal file
View file

@ -0,0 +1,123 @@
{
"nodes": {
"fenix": {
"inputs": {
"naersk": "naersk",
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1625365380,
"narHash": "sha256-ZZoyEar0KUg42yS9sBXxQWhla9qvIez2p6GB/0Yv2mY=",
"owner": "nix-community",
"repo": "fenix",
"rev": "1c1cff44267adc2be329107f7e44e4cf29d12967",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"locked": {
"lastModified": 1623875721,
"narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f7e004a55b120c02ecb6219596820fcd32ca8772",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"naersk": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1623927034,
"narHash": "sha256-sGxlmfp5eXL5sAMNqHSb04Zq6gPl+JeltIZ226OYN0w=",
"owner": "nmattia",
"repo": "naersk",
"rev": "e09c320446c5c2516d430803f7b19f5833781337",
"type": "github"
},
"original": {
"owner": "nmattia",
"repo": "naersk",
"type": "github"
}
},
"naersk_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1623927034,
"narHash": "sha256-sGxlmfp5eXL5sAMNqHSb04Zq6gPl+JeltIZ226OYN0w=",
"owner": "nmattia",
"repo": "naersk",
"rev": "e09c320446c5c2516d430803f7b19f5833781337",
"type": "github"
},
"original": {
"owner": "nmattia",
"repo": "naersk",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1625286343,
"narHash": "sha256-bTzz52TDRqjFR7/xKyoJxNz90bIHdXQXhOc5BBRFNAM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7918dc5148d7ce7b7e011a1186051693e14e1a4c",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixpkgs-unstable",
"type": "indirect"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"naersk": "naersk_2",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1625348713,
"narHash": "sha256-w8QLXxTdEODrHrpsSkMIqTGvfe6964BrWrElx2DfARo=",
"owner": "rust-analyzer",
"repo": "rust-analyzer",
"rev": "266636450d816c74fbf5103e09cb2959fb708759",
"type": "github"
},
"original": {
"owner": "rust-analyzer",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

50
flake.nix Normal file
View file

@ -0,0 +1,50 @@
{ inputs = {
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
naersk = {
url = "github:nmattia/naersk";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "nixpkgs/nixpkgs-unstable";
};
outputs = { self, fenix, flake-utils, naersk, nixpkgs }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
target = "x86_64-unknown-linux-musl";
toolchain = with fenix.packages.${system};
combine [
minimal.rustc
minimal.cargo
targets.${target}.latest.rust-std
];
in
{
write-only-journal = (naersk.lib.${system}.override {
cargo = toolchain;
rustc = toolchain;
}).buildPackage {
src = ./.;
CARGO_BUILD_TARGET = target;
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER =
"${pkgs.pkgsCross.aarch64-multiplatform.stdenv.cc}/bin/${target}-gcc";
};
docker = pkgs.dockerTools.buildImage {
name = "write-only-journal";
tag = "latest";
contents = [ self.write-only-journal.${system} ];
config = {
Cmd = [ "write-only-journal" ];
Volumes = { "/journals" = { }; };
User = "1000";
ExposedPorts = {
"8080"={};
};
};
};
});
}

67
src/journal.rs Normal file
View file

@ -0,0 +1,67 @@
use std::{fs::{create_dir_all, read_to_string}, path::{Path, PathBuf}};
use chrono::{DateTime, FixedOffset};
use serde::{Serialize, Deserialize};
use tera::escape_html;
use std::str::FromStr;
use std::io::Result as IoRes;
const JOURNAL_PATH: &str = "./journals";
#[derive(Serialize, Deserialize, Debug)]
pub struct Line {
pub line: String,
pub uploaded_date: DateTime<FixedOffset>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Journal {
pub id: String,
pub edit_key: String,
pub lines: Vec<Line>,
}
impl Journal {
pub fn new(id: String, edit_key: String) -> Self {
Self {
id,
edit_key,
lines: Vec::new(),
}
}
pub fn load_or_create(id: String, key: String) -> Self {
match Self::load(&id) {
Some(journal) => journal,
None => Self::new(id, key),
}
}
pub fn load(id: &str) -> Option<Self> {
ensure_journal_path_exists().unwrap();
let journal_path = get_journal_path(&id);
match read_to_string(&journal_path) {
Ok(v) => Some(serde_json::from_str(&v).unwrap()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(_e) => unimplemented!()
}
}
pub fn save(&self) -> IoRes<()> {
ensure_journal_path_exists().unwrap();
let journal_path = get_journal_path(&self.id);
let data = serde_json::to_string(self).unwrap();
std::fs::write(journal_path, data)
}
}
fn get_journal_path(id: &str) -> PathBuf {
let file = escape_html(id);
PathBuf::from_str(JOURNAL_PATH).unwrap().join(Path::new(&file))
}
fn ensure_journal_path_exists() -> IoRes<()> {
create_dir_all(Path::new(JOURNAL_PATH))
}

167
src/main.rs Normal file
View file

@ -0,0 +1,167 @@
mod journal;
use chrono::TimeZone;
use once_cell::sync::Lazy;
use serde::{Deserialize, Deserializer, Serialize};
use tera::{Context, Tera};
use warp::{Filter, Rejection, Reply, filters::method, http::Response, query, reply::html};
use crate::journal::{Journal, Line};
#[cfg(feature = "hotreload")]
use {tokio::{spawn, sync::RwLock, task::spawn_blocking}, hotwatch::{blocking::Hotwatch, Event}};
#[cfg(feature = "hotreload")]
static TERA: Lazy<RwLock<Tera>> =
Lazy::new(|| RwLock::new(Tera::new("www/*").expect("Failed to compile templates")));
#[cfg(not(feature = "hotreload"))]
static TERA: Lazy<Tera> =
Lazy::new(|| {
let mut tera = Tera::default();
tera.add_raw_template("index.html", include_str!("../www/index.html")).expect("Failed to compile template");
tera.add_raw_template("view.html", include_str!("../www/view.html")).expect("Failed to compile template");
tera
});
async fn get_tera() -> impl std::ops::Deref<Target=Tera> {
#[cfg(feature = "hotreload")]
return TERA.read().await;
#[cfg(not(feature = "hotreload"))]
&(*TERA)
}
async fn serve_index() -> Result<impl Reply, Rejection> {
Ok(html(
get_tera().await.render("index.html", &Context::new())
.unwrap(),
))
}
#[derive(Serialize, Deserialize, Debug)]
struct ViewQuery {
journal: String,
#[serde(default)]
#[serde(deserialize_with = "de_emptystring_none")]
key: Option<String>,
}
fn de_emptystring_none<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
let s = String::deserialize(deserializer)?;
if s.is_empty() {
Ok(None)
} else {
Ok(Some(s))
}
}
#[derive(Serialize, Deserialize, Debug)]
struct ViewRenderContext {
journal: Journal,
password_matches: bool,
}
async fn serve_view(query: ViewQuery) -> Result<impl Reply, Rejection> {
let ctx = match Journal::load(&query.journal) {
Some(j) => {
let password_matches = query.key.map(|k| k == j.edit_key).unwrap_or(false);
ViewRenderContext {
journal: j,
password_matches,
}
}
None => {
if let Some(k) = query.key {
ViewRenderContext {
journal: Journal::new(query.journal, k),
password_matches: true,
}
} else {
return Err(warp::reject());
}
}
};
Ok(html(
get_tera()
.await
.render("view.html", &Context::from_serialize(ctx).unwrap())
.unwrap(),
))
}
#[derive(Serialize, Deserialize, Debug)]
struct AppendQuery {
journal: String,
key: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct AppendRequest {
line: String,
timezone_offset: i32,
}
async fn append_line(query: AppendQuery, req: AppendRequest) -> Result<impl Reply, Rejection> {
let mut journal = Journal::load_or_create(query.journal.clone(), query.key.clone());
if journal.edit_key == query.key {
let tz = chrono::FixedOffset::west_opt(req.timezone_offset*60).ok_or_else(warp::reject)?;
let time = tz.from_utc_datetime(&chrono::Utc::now().naive_utc());
journal.lines.push(Line {
line: req.line,
uploaded_date: time,
});
journal.save().unwrap();
Ok(
Response::builder()
.header(
"location",
format!(
"/view?journal={}&key={}",
&query.journal,
query.key
),
)
.status(303)
.body(""),
)
} else {
Ok(Response::builder().status(403).body("Invalid password"))
}
}
#[tokio::main]
async fn main() {
pretty_env_logger::init();
#[cfg(feature = "hotreload")]
watch_tera();
let index = warp::path::end().and_then(serve_index);
let view = warp::path("view").and(query()).and(method::get()).and_then(serve_view);
let append = warp::path("view").and(query()).and(method::post()).and(warp::filters::body::form()).and_then(append_line);
let routes = index.or(view).or(append);
warp::serve(routes).run(([0, 0, 0, 0], 8080)).await;
}
#[cfg(feature = "hotreload")]
fn watch_tera() {
spawn_blocking(|| {
let mut hotwatch = Hotwatch::new().expect("hotwatch failed to initialize!");
hotwatch
.watch("www", |event: Event| {
tokio::spawn(async {
match TERA.write().await.full_reload() {
Ok(_) => println!("Reloaded tera templates"),
Err(e) => println!("Tera error compiling templates {}", e),
}
});
hotwatch::blocking::Flow::Continue
})
.expect("failed to watch file!");
println!("Starting live tera template reloading!");
hotwatch.run();
});
}

46
www/index.html Normal file
View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Write only journal</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"
/>
</head>
<body>
<main class="container">
<h1 class="title">Write only journal</h1>
<div>
<form action="view" method="get">
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
type="text"
id="journal"
name="journal"
placeholder="Journal name"
required
/>
</div>
<div class="control is-expanded">
<input
class="input"
autocomplete="current-password"
name="key"
id="edit_password"
placeholder="Edit password"
/>
</div>
<div class="control">
<button class="button is-link">Open</button>
</div>
</div>
</div>
</form>
</div>
</main>
</body>
</html>

60
www/view.html Normal file
View file

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>View Journal</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"
/>
</head>
<body>
<main class="container">
{% if password_matches %}
<form method="POST">
<div class="field has-addons">
<div class="control is-expanded">
<textarea required class="textarea is-primary has-fixed-size" type="text" name="line"> </textarea>
</div>
<div class="control">
<button style="height: 100%" class="button is-primary" type="submit"> Add </button>
</div>
</div>
<input
hidden="1"
type="number"
value="0"
name="timezone_offset"
id="utc_offset"
/>
</form>
{% endif %}
<table class="table is-fullwidth">
<thead>
<tr>
<th style="">Date</th>
<th style="width: 100%">Note</th>
</tr>
</thead>
<tbody>
<tr>
{% for line in journal.lines | reverse %}
<td>
<time datetime="{{ line.uploaded_date }}">
{{ line.uploaded_date | date(format="%Y-%m-%d %H:%M") }}
</time>
</td>
<td>{{ line.line }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</main>
<script>
let offset = new Date().getTimezoneOffset();
document.getElementById("utc_offset").value = offset;
</script>
</body>
</html>