Initial commit

This commit is contained in:
2026-08-01 20:56:44 +02:00
commit 7840f38a7b
9 changed files with 2628 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
*.flatpak
Dockerfile
+2
View File
@@ -0,0 +1,2 @@
/target
*.flatpak
Generated
+2303
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "flatpak-repo"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["http2"] }
clap = { version = "4.6.5", features = ["derive", "env"] }
futures = "0.3.33"
http = "1.5.0"
http-body-util = "0.1.4"
reqwest = "0.13.4"
serde = { version = "1.0.229", features = ["derive"] }
tempfile = "3.27.0"
tokio = { version = "1.53.1", features = ["full"] }
tower-http = { version = "0.7.0", features = ["compression-gzip", "fs", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+50
View File
@@ -0,0 +1,50 @@
##################
### BASE STAGE ###
##################
FROM rust:1.97.1 AS base
ARG TARGETARCH # https://docs.docker.com/build/building/multi-platform/
RUN if [ "$TARGETARCH" = "" ]; then echo "x86_64-unknown-linux-musl" | tee /rust-target; fi
RUN if [ "$TARGETARCH" = "amd64" ]; then echo "x86_64-unknown-linux-musl" | tee /rust-target; fi
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64-unknown-linux-musl" | tee /rust-target; fi
# Install build dependencies
RUN rustup target add $(cat /rust-target)
# required by "ring"
RUN apt-get update && apt-get install -y musl-tools
RUN mkdir /out
WORKDIR /app
###################
### BUILD STAGE ###
###################
FROM base AS build
COPY . .
RUN cargo build --release --locked --target $(cat /rust-target) -p flatpak-repo
RUN mv /app/target/$(cat /rust-target)/release/flatpak-repo /out/flatpak-repo
RUN strip /out/flatpak-repo
########################
### PRODUCTION STAGE ###
########################
FROM archlinux:latest
RUN pacman -Syu --noconfirm && pacman -S --noconfirm flatpak ostree
ENV FR_BIND="0.0.0.0:3000"
EXPOSE 3000/tcp
ENV FR_REPO_PATH="/flatpak"
ENV RUST_LOG="info"
WORKDIR /
# Copy application binary
COPY --from=build /out/flatpak-repo /bin/
CMD ["/bin/flatpak-repo"]
+56
View File
@@ -0,0 +1,56 @@
use std::path::PathBuf;
use axum::{
body::Body,
extract::Request,
middleware::Next,
response::{IntoResponse, Response},
};
use http::header;
pub async fn flatpak_headers_middleware(req: Request<Body>, next: Next) -> Response<Body> {
let path = req.uri().path().to_string();
let mut response = next.run(req).await;
// Add content-type for Flatpak metadata files.
if path.ends_with(".flatpakrepo") || path.ends_with(".flatpakref") || path == "/config" {
response.headers_mut().insert(
header::CONTENT_TYPE,
"application/octet-stream".parse().unwrap(),
);
}
// Add cache-control based on OSTree path semantics.
let cache_control = if path.starts_with("/objects/")
|| path.starts_with("/deltas/")
|| path.starts_with("/extensions/")
{
// Content-addressed and immutable.
"public, immutable, max-age=31536000"
} else if path.starts_with("/refs/") {
// Refs may change on update.
"public, must-revalidate, max-age=60"
} else {
return response;
};
response
.headers_mut()
.insert(header::CACHE_CONTROL, cache_control.parse().unwrap());
response
}
pub async fn serve_flatpakrepo(repo_path: PathBuf) -> impl IntoResponse {
let path = repo_path.join("example.flatpakrepo");
match tokio::fs::read_to_string(&path).await {
Ok(contents) => (
[(header::CONTENT_TYPE, "application/octet-stream")],
contents,
),
Err(_) => (
[(header::CONTENT_TYPE, "text/plain")],
"No .flatpakrepo file found".to_string(),
),
}
}
+93
View File
@@ -0,0 +1,93 @@
use std::{future::ready, net::SocketAddr, path::PathBuf, sync::Arc};
use anyhow::{Context, bail};
use axum::{
Router,
http::StatusCode,
middleware::{self},
routing::{get, post},
};
use clap::Parser;
use tokio::sync::Mutex;
use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer};
use tracing_subscriber::EnvFilter;
mod flatpak;
mod upload;
mod util;
#[derive(Default)]
struct SrvState {
repo: Mutex<PathBuf>,
}
type SharedState = Arc<SrvState>;
#[derive(Parser)]
struct Opt {
/// IP and port to bind to.
#[clap(long, env = "FR_BIND", default_value = "127.0.0.1:3000")]
bind: SocketAddr,
/// Path to the ostree repository directory
#[clap(long, env = "FR_REPO_PATH")]
repo: PathBuf,
#[clap(long, env = "RUST_LOG", default_value = "debug")]
log_level: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let opt = Opt::parse();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(&opt.log_level))
.init();
// TODO: Create an empty archive-z2 repository
// 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_looks_valid =
repo.is_dir() && repo.join("objects").is_dir() && repo.join("refs").is_dir();
if !repo_looks_valid {
bail!("{repo:?} must be a valid ostree directory.")
}
let serve_dir = ServeDir::new(&repo);
let state = SrvState {
repo: Mutex::new(repo.clone()),
};
let state: SharedState = Arc::new(state);
let app = Router::new()
.route("/flatpak-bundle/upload", post(upload::flatpak_bundle))
// Explicitly deny access to internal OSTree directories.
.route("/state/{*path}", get(|| ready(StatusCode::NOT_FOUND)))
.route("/tmp/{*path}", get(|| ready(StatusCode::NOT_FOUND)))
// Optional: serve a .flatpakrepo descriptor at a known endpoint.
.route(
"/repo.flatpakrepo",
get(move || flatpak::serve_flatpakrepo(repo.clone())),
)
// Everything else is served from the repo directory.
.fallback_service(serve_dir)
// Add Flatpak-specific cache headers and content types.
.layer(middleware::from_fn(flatpak::flatpak_headers_middleware))
// Gzip compression for refs and small files.
.layer(CompressionLayer::new())
// Request logging.
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&opt.bind)
.await
.context("Failed to bind tcp listener")?;
tracing::info!("Listening on http://{}", opt.bind);
axum::serve(listener, app).await?;
Ok(())
}
+91
View File
@@ -0,0 +1,91 @@
use anyhow::Context;
use axum::{body::Body, extract::State, http::StatusCode};
use futures::{StreamExt, future::ready, stream};
use tempfile::NamedTempFile;
use tokio::{io::AsyncWriteExt, process::Command, task::block_in_place};
use crate::{SharedState, util::e};
pub async fn flatpak_bundle(
State(state): State<SharedState>,
body: Body,
) -> Result<(), StatusCode> {
let mut stream = body.into_data_stream();
// Get first chunks
let Some(Ok(first_chunk)) = stream.next().await else {
tracing::warn!("Unexpected EOF");
return Err(StatusCode::BAD_REQUEST);
};
// Sanity check that it looks like a flatpak
// Technically not correct to require that the first chunk is 7 bytes or more, but who cares.
if !first_chunk.starts_with(b"flatpak") {
tracing::warn!("Not a valid flatpak bundle. Missing 'flatpak' magic string.");
return Err(StatusCode::BAD_REQUEST);
}
// Create a tmpfile
let (tmp_path, mut tmp_file) = block_in_place(|| {
let mut file = NamedTempFile::new()?;
file.disable_cleanup(true);
let path = file.path().to_path_buf();
let file = tokio::fs::File::from_std(file.into_file());
std::io::Result::Ok((path, file))
})
.context("Failed to create tmpfile")
.map_err(e(StatusCode::BAD_REQUEST))?;
// Write bundle to tmpfile
tracing::info!("Downloading flatpak to {tmp_path:?}");
let mut stream = stream::once(ready(Ok(first_chunk))).chain(stream);
loop {
let Some(chunk) = stream.next().await else {
break;
};
let chunk = chunk
.context("Stream error")
.map_err(e(StatusCode::BAD_REQUEST))?;
tmp_file
.write_all(&chunk)
.await
.context("Tmpfile write error")
.map_err(e(StatusCode::INTERNAL_SERVER_ERROR))?;
}
let repo = state.repo.lock().await;
// Import bundle into repository
tracing::info!("Importing {tmp_path:?} into repo {:?}", &*repo);
let output = Command::new("flatpak")
.arg("build-import-bundle")
.args([&*repo, &tmp_path])
.output()
.await
.context("Failed to execute flatpak build-import-bundle")
.map_err(e(StatusCode::INTERNAL_SERVER_ERROR))?;
if !output.status.success() {
tracing::error!("flatpak build-import-bundle failed");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stdout.lines() {
tracing::error!("[stdout] {line}");
}
for line in stderr.lines() {
tracing::error!("[stderr] {line}");
}
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
drop(tmp_file);
if let Err(e) = tokio::fs::remove_file(&tmp_path).await {
tracing::warn!("Failed to remove tmpfile at {tmp_path:?}: {e}");
}
tracing::info!("Success");
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
use std::fmt::Display;
use http::StatusCode;
#[track_caller]
pub fn e<E: Display>(code: StatusCode) -> impl FnOnce(E) -> StatusCode {
move |e| {
tracing::error!("{e}");
code
}
}