use super::*;
pub(crate) const INDEX_HTML: &str = include_str!("../../ui/index.html");
pub(crate) const NOT_FOUND_HTML: &str = include_str!("../../ui/404.html");
pub(crate) const APP_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.css"));
pub(crate) const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.js"));
pub(crate) const BOOT_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/boot.js"));
pub(crate) const MMD_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/mmd.js"));
pub(crate) const DESK_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/desk.js"));
pub(crate) const FRAME_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/frame.js"));
pub(crate) const GAME_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/game.js"));
pub(crate) const ABOUT_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/about.js"));
pub(crate) const FIND_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/find.js"));
pub(crate) const KEYS_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/keys.js"));
pub(crate) const MENU_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/menu.js"));
pub(crate) const PALETTE_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/palette.js"));
pub(crate) const LOOK_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/look.js"));
pub(crate) const NAV_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/nav.js"));
pub(crate) const NOTE_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/note.js"));
pub(crate) const TIP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/tip.js"));
pub(crate) const HOME_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/home.js"));
pub(crate) const PEER_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/peer.js"));
pub(crate) const TOAST_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/toast.js"));
pub(crate) const DIFF_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/diff.js"));
pub(crate) const SIDEBARS_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/sidebars.js"));
pub(crate) const BROWSE_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/browse.js"));
pub(crate) const PATHS_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/paths.js"));
pub(crate) const THEMES_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/themes.css"));
pub(crate) const MERMAID_JS_GZ: &[u8] = include_bytes!("../../ui/mermaid.min.js.gz");
pub(crate) const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
pub(crate) const FONTS: &[(&str, &[u8])] = &[
("inter.woff2", include_bytes!("../../ui/fonts/inter.woff2")),
(
"inter-italic.woff2",
include_bytes!("../../ui/fonts/inter-italic.woff2"),
),
(
"jetbrains-mono.woff2",
include_bytes!("../../ui/fonts/jetbrains-mono.woff2"),
),
(
"source-serif.woff2",
include_bytes!("../../ui/fonts/source-serif.woff2"),
),
(
"source-serif-italic.woff2",
include_bytes!("../../ui/fonts/source-serif-italic.woff2"),
),
(
"literata.woff2",
include_bytes!("../../ui/fonts/literata.woff2"),
),
(
"literata-italic.woff2",
include_bytes!("../../ui/fonts/literata-italic.woff2"),
),
(
"atkinson.woff2",
include_bytes!("../../ui/fonts/atkinson.woff2"),
),
(
"atkinson-italic.woff2",
include_bytes!("../../ui/fonts/atkinson-italic.woff2"),
),
(
"symbols-nerd.woff2",
include_bytes!("../../ui/fonts/symbols-nerd.woff2"),
),
];
pub struct Ui {
pub(crate) dir: Option<PathBuf>,
}
impl Ui {
pub(crate) fn from_env() -> Ui {
let dir = std::env::var_os("SNYVI_UI_DIR")
.map(PathBuf::from)
.filter(|d| d.join("app.css").is_file());
Ui { dir }
}
pub fn live(&self) -> bool {
self.dir.is_some()
}
pub(crate) fn text(&self, name: &str, built_in: &'static str) -> Cow<'static, str> {
self.dir
.as_ref()
.and_then(|d| crate::strip::source(d, name).ok())
.map_or(Cow::Borrowed(built_in), Cow::Owned)
}
pub(crate) fn version(&self, built_in: &str) -> String {
let Some(_) = self.dir.as_ref() else {
return built_in.to_string();
};
let mut h = blake3::Hasher::new();
for (name, fallback) in std::iter::once(("index.html", INDEX_HTML))
.chain(ASSETS.iter().map(|(n, b, _)| (*n, *b)))
{
h.update(self.text(name, fallback).as_bytes());
}
h.finalize().to_hex()[..8].to_string()
}
}
pub(crate) const TREE_WORKFLOWS: usize = 10;
pub(crate) const TREE_DOCS: usize = 10;
pub(crate) fn project_rows(
app: &App,
project_id: i64,
workflows: usize,
docs: usize,
whole: Option<i64>,
) -> Vec<crate::store::TreeWorkflow> {
let mut wfs = app
.store
.project_tree(project_id, workflows, docs)
.unwrap_or_default();
if let Some(id) = whole {
if let Ok(Some(full)) = app.store.workflow_tree(id) {
match wfs.iter().position(|w| w.id == full.id) {
Some(at) => wfs[at] = full,
None => wfs.insert(0, full),
}
}
}
wfs
}
pub(crate) fn subtree(app: &App, project_id: i64, whole: Option<i64>) -> serde_json::Value {
let wfs = project_rows(app, project_id, TREE_WORKFLOWS, TREE_DOCS, whole);
let mut m = serde_json::Map::new();
m.insert(
project_id.to_string(),
serde_json::to_value(wfs).unwrap_or_default(),
);
serde_json::Value::Object(m)
}
pub(crate) fn escape_json_for_script(s: &str) -> String {
s.replace("</", "<\\/")
}
pub(crate) async fn not_found(State(app): S, uri: axum::http::Uri) -> Response {
if uri.path().starts_with("/api/") {
return StatusCode::NOT_FOUND.into_response();
}
not_found_page(&app)
}
pub(crate) fn not_found_page(app: &App) -> Response {
(
StatusCode::NOT_FOUND,
[
(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(CSP),
),
(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
],
Html(app.ui.text("404.html", NOT_FOUND_HTML).into_owned()),
)
.into_response()
}
pub(crate) fn shell(
app: &App,
mut boot: serde_json::Value,
initial_html: &str,
title: &str,
) -> Response {
if let Some(o) = boot.as_object_mut() {
o.insert("v".into(), serde_json::Value::String(app.asset_v()));
o.insert(
"queue".into(),
serde_json::to_value(app.store.queue(QUEUE_BOOT).unwrap_or_default())
.unwrap_or_default(),
);
o.insert("waiting".into(), json!(waiting(app)));
o.insert("online".into(), app.online());
o.insert("notes".into(), json!(app.asides.list()));
o.insert("friends".into(), json!(has_friends(app)));
o.insert("layout".into(), layout_json(app));
o.insert("widgets".into(), global_seats_json(app));
}
let page = app
.ui
.text("index.html", INDEX_HTML)
.replace("{{V}}", &app.asset_v())
.replace("{{TITLE}}", &html_escape::encode_text(title))
.replace("{{INITIAL_HTML}}", initial_html)
.replace("{{BOOT_JSON}}", &escape_json_for_script(&boot.to_string()));
(
[
(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(CSP),
),
(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
),
],
Html(page),
)
.into_response()
}
pub fn fmt_time(ts: i64) -> String {
use time::{format_description::FormatItem, macros::format_description, OffsetDateTime};
const F: &[FormatItem] =
format_description!("[month repr:short] [day padding:none], [hour]:[minute]");
let local = OffsetDateTime::from_unix_timestamp(ts)
.map(|t| {
t.to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC))
})
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
local.format(F).unwrap_or_default()
}
#[derive(Default)]
pub(crate) struct Talk {
pub(crate) replies: Vec<crate::peer::Reply>,
pub(crate) reply_to: Option<String>,
}
pub(crate) fn talk(app: &App, doc: &Doc) -> Talk {
let reply_to = (doc.origin == "peer")
.then(|| app.store.peer_frame(&doc.id).ok())
.flatten()
.filter(|(_, _, re)| !re.is_empty())
.and_then(|(key, _, _)| app.store.peer_by_key(&key).ok().flatten())
.filter(|p| p.removed_at == 0)
.map(|p| p.name);
let replies = if doc.origin == "peer" {
Vec::new()
} else {
app.store.peer_replies(&doc.id).unwrap_or_default()
};
Talk { replies, reply_to }
}
pub(crate) fn doc_html(doc: &Doc, body: &str, friends: bool, talk: &Talk) -> String {
let e = html_escape::encode_text;
let q = html_escape::encode_double_quoted_attribute;
let theirs = doc.origin == "peer" && !doc.sender.is_empty();
let mut sub = if theirs {
let filed = if doc.filed {
format!(" ยท in {}", e(&doc.project))
} else {
String::new()
};
let on = doc
.desk
.as_ref()
.filter(|d| !doc.filed || d.name != doc.project)
.map(|d| format!(" ยท on {}", e(&d.name)))
.unwrap_or_default();
format!(
"from {} ยท verified{filed}{on} ยท {}",
e(&doc.sender),
e(&doc.workflow_title)
)
} else {
format!("{} ยท {}", e(&doc.project), e(&doc.workflow_title))
};
if let Some(b) = &doc.branch {
sub.push_str(&format!(" ยท <span class=\"branch\">{}</span>", e(b)));
}
sub.push_str(&format!(" ยท {}", fmt_time(doc.received_at)));
if theirs {
let (act, label) = if doc.desk.is_some() {
("save", "Save into the folder")
} else {
("keep", "Keep on a deskโฆ")
};
sub.push_str(&format!(
" ยท <button type=\"button\" class=\"doc-send uc-link\" data-w=\"send\" data-act=\"{act}\" data-send=\"{}\" data-send-title=\"{}\">{label}</button>",
q(&doc.id),
q(&doc.title)
));
if doc.filed {
sub.push_str(&format!(
" ยท <button type=\"button\" class=\"doc-send uc-link\" data-w=\"send\" data-act=\"unfile\" data-send=\"{}\" data-send-title=\"{}\" data-tip=\"Back to From {}\" data-tip-sub=\"with every version; nothing on disk moves\">Move to From {}</button>",
q(&doc.id),
q(&doc.title),
q(&doc.sender),
e(&doc.sender)
));
}
}
if let Some(to) = &talk.reply_to {
sub.push_str(&format!(
" ยท <button type=\"button\" class=\"doc-send uc-link\" data-w=\"send\" data-act=\"reply\" data-send=\"{}\" data-send-title=\"{}\" data-to=\"{}\">Reply to {}โฆ</button>",
q(&doc.id),
q(&doc.title),
q(to),
e(to)
));
}
if friends {
sub.push_str(&format!(
" ยท <button type=\"button\" class=\"doc-send uc-link\" data-w=\"send\" data-send=\"{}\" data-send-title=\"{}\">Send toโฆ</button>",
q(&doc.id),
q(&doc.title)
));
}
let replies: String = talk
.replies
.iter()
.map(|r| {
format!(
"<li><b>{}</b> {} <time>{}</time></li>",
e(&r.from),
e(&r.text),
fmt_time(r.at)
)
})
.collect();
let replies = if replies.is_empty() {
replies
} else {
format!("<ul class=\"doc-replies\">{replies}</ul>")
};
format!(
"<header class=\"doc-head\"><h1 class=\"doc-title\">{}</h1><p class=\"doc-sub\">{}</p>{}</header><article class=\"prose kind-{}\">{}</article>",
e(&doc.title),
sub,
replies,
doc.kind.as_str(),
render::chunk_code(body)
)
}
pub(crate) fn has_friends(app: &App) -> bool {
app.store
.peers()
.map(|ps| ps.iter().any(|p| p.removed_at == 0))
.unwrap_or(false)
}
pub(crate) async fn shell_home(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let empty = app.store.inbox(1).map(|i| i.is_empty()).unwrap_or(true);
if empty {
return shell_inbox(State(app)).await;
}
let boot = json!({ "view": "home", "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "snyvi")
}
pub(crate) async fn shell_inbox(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let inbox = app.store.inbox(50).unwrap_or_default();
let sub = match tree.as_slice() {
[only] => subtree(&app, only.id, None),
_ => serde_json::Value::Object(Default::default()),
};
let mut boot = json!({ "view": "inbox", "tree": tree, "sub": sub, "inbox": inbox, "browse": app.browse.list(), "version": VERSION });
if inbox.is_empty() {
boot["agents"] = agents_json(&app);
}
shell(&app, boot, "", "snyvi")
}
pub(crate) async fn shell_connect(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "connect", "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION, "agents": agents_json(&app) });
shell(&app, boot, "", "Agents ยท snyvi")
}
pub(crate) async fn shell_start(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "start", "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "How snyvi works ยท snyvi")
}
pub(crate) async fn shell_sidebars(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "sidebars", "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "Sidebars ยท snyvi")
}
pub(crate) async fn shell_welcome(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "welcome", "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "Welcome ยท snyvi")
}
pub(crate) async fn shell_doc(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return not_found_page(&app);
};
let body = app.store.html(&id).unwrap_or_default();
let tree = app.store.projects().unwrap_or_default();
let previous = app.store.previous(&doc).ok().flatten().map(|p| p.id);
let title = doc.title.clone();
let folder = doc_folder(&app, &doc);
let sub = subtree(&app, doc.project_id, Some(doc.workflow_id));
let preview = render::preview_kind(&doc_ext(&doc));
let boot = json!({ "view": "doc", "tree": tree, "sub": sub, "doc": doc, "previous": previous, "folder": folder, "browse": app.browse.list(), "version": VERSION,
"preview": preview, "preview_url": preview.map(|_| format!("/api/docs/{id}/blob")) });
shell(
&app,
boot,
&{
let friends = has_friends(&app);
let talk = if friends {
talk(&app, &doc)
} else {
Talk::default()
};
doc_html(&doc, &body, friends, &talk)
},
&title,
)
}
pub(crate) async fn shell_desk(State(app): S, Path(id): Path<i64>) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "desk", "desk": id, "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "Desk ยท snyvi")
}
pub(crate) async fn shell_desk_list(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "desk", "desk": null, "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "Desks ยท snyvi")
}
pub(crate) fn immutable(content_type: &'static str, body: impl Into<Body>) -> Response {
(
[
(header::CONTENT_TYPE, HeaderValue::from_static(content_type)),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
],
body.into(),
)
.into_response()
}
pub(crate) const JS: &str = "application/javascript; charset=utf-8";
pub(crate) const CSS: &str = "text/css; charset=utf-8";
pub(crate) const ASSETS: &[(&str, &str, &str)] = &[
("app.css", APP_CSS, CSS),
("app.js", APP_JS, JS),
("boot.js", BOOT_JS, JS),
("mmd.js", MMD_JS, JS),
("desk.js", DESK_JS, JS),
("frame.js", FRAME_JS, JS),
("game.js", GAME_JS, JS),
("about.js", ABOUT_JS, JS),
("find.js", FIND_JS, JS),
("keys.js", KEYS_JS, JS),
("menu.js", MENU_JS, JS),
("themes.css", THEMES_CSS, CSS),
("palette.js", PALETTE_JS, JS),
("look.js", LOOK_JS, JS),
("nav.js", NAV_JS, JS),
("note.js", NOTE_JS, JS),
("tip.js", TIP_JS, JS),
("home.js", HOME_JS, JS),
("peer.js", PEER_JS, JS),
("toast.js", TOAST_JS, JS),
("diff.js", DIFF_JS, JS),
("browse.js", BROWSE_JS, JS),
("paths.js", PATHS_JS, JS),
("sidebars.js", SIDEBARS_JS, JS),
];
pub(crate) async fn asset_named(State(app): S, Path(name): Path<String>) -> Response {
match ASSETS.iter().find(|(n, _, _)| *n == name) {
Some((n, built_in, content_type)) => asset(&app, content_type, n, built_in),
None => StatusCode::NOT_FOUND.into_response(),
}
}
pub(crate) fn asset(
app: &App,
content_type: &'static str,
name: &str,
built_in: &'static str,
) -> Response {
let body = app.ui.text(name, built_in).into_owned();
if !app.ui.live() {
return immutable(content_type, body);
}
(
[
(header::CONTENT_TYPE, HeaderValue::from_static(content_type)),
(header::CACHE_CONTROL, HeaderValue::from_static("no-store")),
],
body,
)
.into_response()
}
pub(crate) async fn asset_mermaid() -> Response {
(
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript; charset=utf-8"),
),
(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
],
MERMAID_JS_GZ,
)
.into_response()
}
pub(crate) async fn doc_file(
State(app): S,
Path((id, rel)): Path<(String, String)>,
req: HeaderMap,
) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(src) = doc.source_path.as_deref() else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(dir) = std::path::Path::new(src).parent() else {
return StatusCode::NOT_FOUND.into_response();
};
let target = dir.join(&rel);
let ext = target
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
let media = render::media_kind(&ext).is_some();
if !render::is_image_ext(&ext) && !media {
return StatusCode::FORBIDDEN.into_response();
}
let (Ok(canon), Ok(root)) = (
target.canonicalize(),
crate::project::resolve(dir).root.canonicalize(),
) else {
return StatusCode::NOT_FOUND.into_response();
};
if !canon.starts_with(&root) {
return StatusCode::FORBIDDEN.into_response();
}
if media {
let mut headers = HeaderMap::new();
if let Ok(v) =
HeaderValue::from_str(mime_guess::from_ext(&ext).first_or_octet_stream().as_ref())
{
headers.insert(header::CONTENT_TYPE, v);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=300"),
);
return serve_file(&canon, headers, &req).await;
}
match tokio::fs::read(&canon).await {
Ok(bytes) => {
let mime = mime_guess::from_path(&canon)
.first_or_octet_stream()
.to_string();
(
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "private, max-age=300".to_string()),
],
bytes,
)
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub(crate) async fn asset_font(Path(name): Path<String>) -> Response {
match FONTS.iter().find(|(n, _)| *n == name) {
Some((_, bytes)) => immutable("font/woff2", *bytes),
None => StatusCode::NOT_FOUND.into_response(),
}
}
pub(crate) async fn shell_browse(State(app): S, Path(id): Path<String>) -> Response {
browse_shell(app, id, String::new()).await
}
pub(crate) async fn shell_browse_file(
State(app): S,
Path((id, path)): Path<(String, String)>,
) -> Response {
browse_shell(app, id, path).await
}
pub(crate) async fn browse_shell(app: Arc<App>, id: String, path: String) -> Response {
let Some(root) = app.browse.get(&id) else {
return (
StatusCode::NOT_FOUND,
Html("<h1>That folder is no longer open</h1>"),
)
.into_response();
};
let path = if path.is_empty() {
app.browse.landing(&id).unwrap_or_default()
} else {
path
};
let title = match path.trim_end_matches('/') {
"" => root.name.clone(),
p => p.to_string(),
};
let boot = json!({
"view": "browse",
"tree": app.store.projects().unwrap_or_default(),
"browse": app.browse.list(),
"browseRoot": root,
"browsePath": path,
"version": VERSION,
});
shell(&app, boot, "", &title)
}