diff --git a/Cargo.lock b/Cargo.lock
index 1c2f608..93d55a5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -307,6 +307,17 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -368,6 +379,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -425,8 +442,10 @@ dependencies = [
"futures",
"http",
"http-body-util",
+ "insta",
"reqwest",
"serde",
+ "serde_json",
"tempfile",
"tokio",
"tower-http 0.7.0",
@@ -827,6 +846,18 @@ dependencies = [
"hashbrown",
]
+[[package]]
+name = "insta"
+version = "1.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
+dependencies = [
+ "console",
+ "once_cell",
+ "similar",
+ "tempfile",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1561,6 +1592,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
[[package]]
name = "slab"
version = "0.4.12"
diff --git a/Cargo.toml b/Cargo.toml
index 5a0fc99..88027ad 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,8 +10,10 @@ clap = { version = "4.6.5", features = ["derive", "env"] }
futures = "0.3.33"
http = "1.5.0"
http-body-util = "0.1.4"
+insta = "1.48.0"
reqwest = "0.13.4"
serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
tempfile = "3.27.0"
tokio = { version = "1.53.1", features = ["full"] }
tower-http = { version = "0.7.0", features = ["compression-gzip", "fs", "trace"] }
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..fda8c28
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+ Flatpak Repository
+
+
+ Applications in repository
+
+
+
+ | Name |
+ App ID |
+ Version |
+ Branch |
+ Runtime |
+ Size (Installed) |
+ Size (Download) |
+ Arch |
+
+
+ {list}
+
+
+
diff --git a/src/index.rs b/src/index.rs
new file mode 100644
index 0000000..1b68e3f
--- /dev/null
+++ b/src/index.rs
@@ -0,0 +1,132 @@
+use axum::{extract::State, response::Html};
+use http::StatusCode;
+use serde::Deserialize;
+use tokio::process::Command;
+
+use crate::SharedState;
+
+#[derive(Clone, Debug, Deserialize)]
+struct FlatpakApp {
+ name: String,
+ application_id: String,
+ version: String,
+ branch: String,
+ runtime: String,
+ installed_size: String,
+ download_size: String,
+ arch: String,
+}
+
+pub async fn index(State(state): State) -> Result, StatusCode> {
+ let output = {
+ let repo = state.repo.lock().await;
+ Command::new("flatpak")
+ .arg("remote-ls")
+ .arg("--json")
+ .arg("--arch=*")
+ .arg("--columns=name,description,application,version,branch,runtime,installed-size,download-size,arch")
+ .arg(format!("file://{}", repo.display()))
+ .output()
+ .await
+ .map_err(|e| {
+ tracing::error!("Failed to execute `flatpak remote-ls`: {e:?}");
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?
+ };
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ tracing::error!("`flatpak remote-ls` returned {}", output.status);
+ tracing::error!("stdout:\n{stdout}\n");
+ tracing::error!("stderr:\n{stderr}\n");
+ }
+
+ let list: Vec = serde_json::from_str(&stdout).map_err(|e| {
+ tracing::error!("Failed to deserialize `flatpak remote-ls --json`'s output: {e:?}");
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+
+ Ok(to_html(&list))
+}
+
+fn to_html(apps: &[FlatpakApp]) -> Html {
+ let list: String = apps
+ .into_iter()
+ .map(|app| {
+ let FlatpakApp {
+ name,
+ application_id,
+ version,
+ branch,
+ runtime,
+ installed_size,
+ download_size,
+ arch,
+ } = app;
+ format!(
+ "\n\t\t
+\t\t\t
+\t\t\t\t| {name} |
+\t\t\t\t{application_id} |
+\t\t\t\t{version} |
+\t\t\t\t{branch} |
+\t\t\t\t{runtime} |
+\t\t\t\t{installed_size} |
+\t\t\t\t{download_size} |
+\t\t\t\t{arch} |
+\t\t\t
+\t\t"
+ )
+ })
+ .collect();
+
+ Html(include_str!("index.html").replace("{list}", &list))
+}
+
+#[cfg(test)]
+mod test {
+ use crate::index::{FlatpakApp, to_html};
+ use axum::response::Html;
+
+ /// Example output from `flatpak remote-ls --json --arch=* --columns=<...>`
+ const REMOTE_LS_OUTPUT: &str = r#"
+ [
+ {
+ "name" : "Foobar",
+ "description" : "An app",
+ "application_id" : "org.example.Foobar",
+ "version" : "",
+ "branch" : "master",
+ "runtime" : "org.freedesktop.Platform/x86_64/25.08",
+ "installed_size" : "103.6 MB",
+ "download_size" : "43.0 MB",
+ "arch" : "x86_64"
+ },
+ {
+ "name" : "Barfoo",
+ "description" : "",
+ "application_id" : "com.example.Barfoo",
+ "version" : "1.2.3",
+ "branch" : "master",
+ "runtime" : "org.freedesktop.Platform/aarch64/25.08",
+ "installed_size" : "14.8 MB",
+ "download_size" : "6.2 MB",
+ "arch" : "aarch64"
+ }
+ ]
+ "#;
+
+ #[test]
+ fn deserialize_remote_ls() {
+ let list: Vec = serde_json::from_str(&REMOTE_LS_OUTPUT).unwrap();
+ assert_eq!(&list[0].name, "Foobar");
+ }
+
+ #[test]
+ fn render_html() {
+ let list: Vec = serde_json::from_str(&REMOTE_LS_OUTPUT).unwrap();
+ let Html(html) = to_html(&list);
+ insta::assert_snapshot!(html);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 2e5d966..3e44861 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,11 +13,13 @@ use tower_http::{compression::CompressionLayer, services::ServeDir, trace::Trace
use tracing_subscriber::EnvFilter;
mod flatpak;
+mod index;
mod upload;
mod util;
#[derive(Default)]
struct SrvState {
+ /// Absolute path to the flatpak ostree repository
repo: Mutex,
api_key: String,
gpg_key_id: String,
@@ -59,7 +61,10 @@ async fn main() -> anyhow::Result<()> {
// ostree init --repo=~/my-apps --mode=archive-z2
// Sanity check that opt.repo looks like a valid ostree dir
- let repo = opt.repo;
+ let repo = opt
+ .repo
+ .canonicalize()
+ .context("Failed to canonicalize repo dir")?;
let repo_looks_valid =
repo.is_dir() && repo.join("objects").is_dir() && repo.join("refs").is_dir();
if !repo_looks_valid {
@@ -76,6 +81,8 @@ async fn main() -> anyhow::Result<()> {
let state: SharedState = Arc::new(state);
let app = Router::new()
+ .route("/", get(index::index))
+ .route("/index.html", get(index::index))
.route("/flatpak-bundle/upload", post(upload::flatpak_bundle))
// Explicitly deny access to internal OSTree directories.
.route("/state/{*path}", get(|| ready(StatusCode::NOT_FOUND)))
diff --git a/src/snapshots/flatpak_repo__index__test__render_html.snap b/src/snapshots/flatpak_repo__index__test__render_html.snap
new file mode 100644
index 0000000..7f5205c
--- /dev/null
+++ b/src/snapshots/flatpak_repo__index__test__render_html.snap
@@ -0,0 +1,96 @@
+---
+source: src/index.rs
+expression: html
+---
+
+
+
+
+
+
+ Flatpak Repository
+
+
+ Applications in repository
+
+
+
+ | Name |
+ App ID |
+ Version |
+ Branch |
+ Runtime |
+ Size (Installed) |
+ Size (Download) |
+ Arch |
+
+
+
+
+
+ | Foobar |
+ org.example.Foobar |
+ |
+ master |
+ org.freedesktop.Platform/x86_64/25.08 |
+ 103.6 MB |
+ 43.0 MB |
+ x86_64 |
+
+
+
+
+ | Barfoo |
+ com.example.Barfoo |
+ 1.2.3 |
+ master |
+ org.freedesktop.Platform/aarch64/25.08 |
+ 14.8 MB |
+ 6.2 MB |
+ aarch64 |
+
+
+
+
+