Browse / github.com/storytold/artcraft-services / patch
feat(craft_apps): scaffold Flatpak repository endpoints and release metadata
feat(craft_apps): scaffold Flatpak repository endpoints and release metadata ## Summary Adds first-class data models and HTTP endpoints to `artcraft-services` to support self-hosted Flatpak OSTree repositories and GitHub Releases asset direct download redirection for ArtCraft Suite and individual Craft applications, with built-in version tracking and downgrade prevention when repositories lag behind releases: - **Direct Asset Download Redirection (`GET /v1/craft_apps/download/{app}/{kind}` & `GET /v1/craft_apps/{app}/download/{kind}`)**: - Directs clients, browsers, curl/scripts, and launchers straight to the appropriate GitHub Releases asset download URL via `302
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dylanmtaylor (imported from pull request #160, open)
- Base
e57ee3d63ac478a24149384bf1473ef97aa067f8- Size
- +1199 −9 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9a1ce0aaa715ac15b5ff1598
How it went
No other operator has reported using it yet.
Diff
raw · crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/download.rs crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/flatpak.rs crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/mod.rs crates/api_clients/artcraft/artcraft_api_defs/src/craft_apps/release_info.rs crates/service/web/storyteller_web/src/docs/api_doc.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_download_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpak_repo_redirect_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_flatpakrepo_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/get_craft_apps_release_info_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/craft_apps/mod.rs crates/service/web/storyteller_web/src/http_server/routes/application_routes/craft_apps_routes.rs crates/service/web/storyteller_web/src/state/craft_apps_release_cache.rs crates/service/web/storyteller_web/src/threads/poll_craft_app_releases_thread/poll_craft_app_releases.rs
new file mode 100644@@ -0,0 +1,20 @@+use serde::{Deserialize, Serialize};+use utoipa::{IntoParams, ToSchema};++/// Query parameters for downloading a Craft App release asset.+#[derive(Clone, Debug, Default, Deserialize, Serialize, ToSchema, IntoParams)]+pub struct CraftAppDownloadQuery {+ /// Target CPU architecture, e.g. "x86_64", "x64", "aarch64", "arm64", "universal".+ pub arch: Option<String>,+ /// Target operating system, e.g. "linux", "windows", "macos", "freebsd", "web".+ pub os: Option<String>,+}++/// Error response returned when an application or asset cannot be found.+#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]+pub struct CraftAppDownloadErrorResponse {+ pub success: bool,+ pub error: String,+ #[serde(skip_serializing_if = "Option::is_none")]+ pub available_assets: Option<Vec<String>>,+}new file mode 100644@@ -0,0 +1,291 @@+//! Flatpak repository configuration and .flatpakrepo metadata definitions for ArtCraft apps.+//!+//! Provides structured models and `.flatpakrepo` file generation for both the overarching+//! ArtCraft Suite repository and individual Craft application remotes.++use serde_derive::{Deserialize, Serialize};+use utoipa::ToSchema;++pub const DEFAULT_FLATPAK_BRANCH: &str = "stable";+pub const DEFAULT_FLATPAK_ID_PREFIX: &str = "ai.storyteller.";++/// Configuration defining a Flatpak OSTree repository remote.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]+pub struct CraftAppFlatpakRepoConfig {+ /// The app key (e.g. `photocraft`), or `artcraft` for the complete suite repository.+ pub app_key: String,++ /// Flatpak Application ID (e.g. `ai.storyteller.photocraft`).+ pub app_id: String,++ /// Display title for the repository remote (e.g. `PhotoCraft` or `ArtCraft`).+ pub title: String,++ /// Short comment/summary describing the remote.+ pub comment: String,++ /// Longer description of the repository.+ pub description: String,++ /// URL of the OSTree repository (where `summary` and `objects/` are fetched).+ pub repo_url: String,++ /// Public homepage URL.+ pub homepage: String,++ /// Default OSTree branch (usually `stable`).+ pub default_branch: String,++ /// Optional GPG verification key URL or base64 key data.+ pub gpg_key_url: Option<String>,+}++impl CraftAppFlatpakRepoConfig {+ /// Generates the standard INI-formatted `.flatpakrepo` file content parsed by Flatpak.+ pub fn to_flatpakrepo_content(&self) -> String {+ let mut out = String::with_capacity(512);+ out.push_str("[Flatpak Repo]\n");+ out.push_str(&format!("Title={}\n", self.title));+ out.push_str(&format!("Url={}\n", self.repo_url));+ out.push_str(&format!("Homepage={}\n", self.homepage));+ out.push_str(&format!("Comment={}\n", self.comment));+ out.push_str(&format!("Description={}\n", self.description));+ out.push_str(&format!("DefaultBranch={}\n", self.default_branch));+ if let Some(ref gpg_key) = self.gpg_key_url {+ out.push_str(&format!("GPGKey={}\n", gpg_key));+ }+ out+ }++ /// Builds a repository configuration for the complete ArtCraft suite.+ pub fn suite_config(repo_url: &str, homepage: &str, gpg_key_url: Option<String>) -> Self {+ Self { app_key: "artcraft".to_string(), app_id: "ai.storyteller.artcraft".to_string(), title: "ArtCraft".to_string(), comment: "Official ArtCraft Suite releases".to_string(), description: "Official Flatpak repository for ArtCraft suite applications".to_string(), repo_url: repo_url.trim_end_matches('/').to_string() + "/", homepage: homepage.to_string(), default_branch: DEFAULT_FLATPAK_BRANCH.to_string(), gpg_key_url }+ }++ /// Builds a repository configuration for a single Craft application.+ pub fn app_config(app_key: &str, display_name: &str, repo_url: &str, homepage: &str, gpg_key_url: Option<String>) -> Self {+ let clean_key = app_key.trim().to_lowercase();+ let app_id = format!("{DEFAULT_FLATPAK_ID_PREFIX}{clean_key}");+ Self { app_key: clean_key.clone(), app_id, title: display_name.to_string(), comment: format!("Official {display_name} releases"), description: format!("Official Flatpak repository for {display_name}"), repo_url: repo_url.trim_end_matches('/').to_string() + "/", homepage: homepage.to_string(), default_branch: DEFAULT_FLATPAK_BRANCH.to_string(), gpg_key_url }+ }+}++use std::cmp::Ordering;++/// Metadata advertising Flatpak repository availability in release info responses.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]+pub struct CraftAppFlatpakRepoInfo {+ /// Flatpak Application ID (e.g. `ai.storyteller.photocraft`).+ pub app_id: String,++ /// Primary repository URL to add as a remote.+ pub repo_url: String,++ /// Direct URL to download the `.flatpakrepo` configuration file.+ pub flatpakrepo_url: String,++ /// Default tracking branch (e.g. `stable`).+ pub branch: String,++ /// Version of the application currently available in this Flatpak repository.+ ///+ /// Clients and launchers must verify that this version is strictly greater than+ /// the installed version before triggering repo-based updates (`flatpak update`)+ /// or switching remotes, preventing accidental downgrades when the Flatpak repository+ /// lags behind GitHub releases or installed packages.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub version: Option<String>,++ /// Minimum installed version required before attempting a repository update or migration.+ /// If the client has an installed version below `min_version`, or if the repository+ /// version is older than what is currently installed, the client must skip the update.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub min_version: Option<String>,++ /// Whether the Flatpak repository is verified to be up-to-date with `latest_release`.+ /// When `false` or absent while repository version is older, clients should not downgrade.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub is_synced: Option<bool>,++ /// Direct URL to download the repository GPG verification key.+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub gpg_key_url: Option<String>,+}++impl CraftAppFlatpakRepoInfo {+ /// Checks whether updating from `installed_version` to this repository would cause+ /// an accidental downgrade.+ ///+ /// Returns `true` if:+ /// - `version` is known and strictly older than `installed_version`.+ /// - `min_version` is specified and `installed_version` is strictly older than `min_version`.+ ///+ /// Returns `false` if `version` is newer than or equal to `installed_version`,+ /// or if versions cannot be compared.+ pub fn would_downgrade(&self, installed_version: &str) -> bool {+ if let Some(ref min_v) = self.min_version {+ if let Some(ord) = compare_versions(installed_version, min_v) {+ if ord == Ordering::Less {+ return true;+ }+ }+ }++ if let Some(ref repo_v) = self.version {+ if let Some(ord) = compare_versions(repo_v, installed_version) {+ if ord == Ordering::Less {+ return true;+ }+ }+ }++ false+ }++ /// Returns `true` if this repository's version is strictly newer than `installed_version`.+ pub fn is_newer_than(&self, installed_version: &str) -> bool {+ let Some(ref repo_v) = self.version else {+ return false;+ };+ compare_versions(repo_v, installed_version) == Some(Ordering::Greater)+ }+}++/// Parsed representation of a version triplet `(major, minor, patch, Option<prerelease>)`.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct SimpleSemver {+ pub major: u64,+ pub minor: u64,+ pub patch: u64,+ pub prerelease: Option<String>,+}++impl SimpleSemver {+ /// Parses a version string like "v1.2.3", "0.5.0-rc.1", "0.4.1", "1.2".+ pub fn parse(text: &str) -> Option<Self> {+ let t = text.trim();+ let t = t.strip_prefix(['v', 'V']).unwrap_or(t);+ if t.is_empty() {+ return None;+ }+ let (core, pre) = match t.find(['-', '+']) {+ Some(idx) => (&t[..idx], Some(t[idx + 1..].to_string())),+ None => (t, None),+ };+ let parts: Vec<&str> = core.split('.').collect();+ let major = parts.first()?.parse::<u64>().ok()?;+ let minor = parts.get(1).map_or(Ok(0), |p| p.parse::<u64>()).ok()?;+ let patch = parts.get(2).map_or(Ok(0), |p| p.parse::<u64>()).ok()?;+ Some(Self { major, minor, patch, prerelease: pre })+ }++ /// Compares two versions following SemVer precedence rules:+ /// 1. Compare major, minor, patch numerically.+ /// 2. If equal, a version with a prerelease is *older* than a version without one.+ /// 3. If both have a prerelease, compare prerelease strings lexicographically.+ pub fn compare(&self, other: &Self) -> Ordering {+ match self.major.cmp(&other.major) {+ Ordering::Equal => {},+ non_eq => return non_eq,+ }+ match self.minor.cmp(&other.minor) {+ Ordering::Equal => {},+ non_eq => return non_eq,+ }+ match self.patch.cmp(&other.patch) {+ Ordering::Equal => {},+ non_eq => return non_eq,+ }+ match (&self.prerelease, &other.prerelease) {+ (None, None) => Ordering::Equal,+ (Some(_), None) => Ordering::Less,+ (None, Some(_)) => Ordering::Greater,+ (Some(a), Some(b)) => a.cmp(b),+ }+ }+}++/// Compares version string `a` to `b`.+/// Returns `Some(Ordering)` or `None` if either string cannot be parsed.+pub fn compare_versions(a: &str, b: &str) -> Option<Ordering> {+ let v_a = SimpleSemver::parse(a)?;+ let v_b = SimpleSemver::parse(b)?;+ Some(v_a.compare(&v_b))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn serializes_suite_flatpakrepo() {+ let config = CraftAppFlatpakRepoConfig::suite_config("https://repo.getartcraft.com", "https://getartcraft.com", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+ let content = config.to_flatpakrepo_content();+ assert!(content.contains("[Flatpak Repo]"));+ assert!(content.contains("Title=ArtCraft"));+ assert!(content.contains("Url=https://repo.getartcraft.com/"));+ assert!(content.contains("Homepage=https://getartcraft.com"));+ assert!(content.contains("DefaultBranch=stable"));+ assert!(content.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));+ }++ #[test]+ fn serializes_app_flatpakrepo_with_gpg() {+ let config = CraftAppFlatpakRepoConfig::app_config("photocraft", "PhotoCraft", "https://repo.getartcraft.com/photocraft", "https://getartcraft.com/apps/photocraft", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+ let content = config.to_flatpakrepo_content();+ assert!(content.contains("Title=PhotoCraft"));+ assert!(content.contains("Url=https://repo.getartcraft.com/photocraft/"));+ assert!(content.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));+ assert_eq!(config.app_id, "ai.storyteller.photocraft");+ }++ #[test]+ fn semver_comparison_and_downgrade_prevention() {+ assert_eq!(compare_versions("0.5.0", "0.4.0"), Some(Ordering::Greater));+ assert_eq!(compare_versions("0.4.0", "0.5.0"), Some(Ordering::Less));+ assert_eq!(compare_versions("v0.5.0", "0.5.0"), Some(Ordering::Equal));+ assert_eq!(compare_versions("0.5.0", "0.5.0-rc.1"), Some(Ordering::Greater));+ assert_eq!(compare_versions("0.5.0-rc.1", "0.5.0"), Some(Ordering::Less));++ let repo_lagging = CraftAppFlatpakRepoInfo {+ app_id: "ai.storyteller.photocraft".to_string(),+ repo_url: "https://repo.getartcraft.com/photocraft/".to_string(),+ flatpakrepo_url: "https://api.getartcraft.com/v1/craft_apps/flatpak/photocraft.flatpakrepo".to_string(),+ branch: "stable".to_string(),+ version: Some("0.4.0".to_string()),+ min_version: None,+ is_synced: Some(false),+ gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+ };++ // If installed is 0.5.0 and repo is 0.4.0, update would downgrade:+ assert!(repo_lagging.would_downgrade("0.5.0"));+ assert!(!repo_lagging.is_newer_than("0.5.0"));++ // If installed is 0.4.0, repo is equal, not a downgrade:+ assert!(!repo_lagging.would_downgrade("0.4.0"));+ assert!(!repo_lagging.is_newer_than("0.4.0"));++ // If installed is 0.3.0, repo is newer:+ assert!(!repo_lagging.would_downgrade("0.3.0"));+ assert!(repo_lagging.is_newer_than("0.3.0"));++ // Testing min_version constraint:+ let repo_with_min = CraftAppFlatpakRepoInfo {+ app_id: "ai.storyteller.cadcraft".to_string(),+ repo_url: "https://repo.getartcraft.com/cadcraft/".to_string(),+ flatpakrepo_url: "https://api.getartcraft.com/v1/craft_apps/flatpak/cadcraft.flatpakrepo".to_string(),+ branch: "stable".to_string(),+ version: Some("0.6.0".to_string()),+ min_version: Some("0.5.0".to_string()),+ is_synced: Some(true),+ gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+ };+ // Installed is 0.4.0, which is below min_version 0.5.0:+ assert!(repo_with_min.would_downgrade("0.4.0"));+ // Installed is 0.5.0, meets min_version and 0.6.0 is newer:+ assert!(!repo_with_min.would_downgrade("0.5.0"));+ assert!(repo_with_min.is_newer_than("0.5.0"));+ }+}+@@ -1 +1,3 @@+pub mod download;+pub mod flatpak; pub mod release_info;@@ -5,6 +5,8 @@ use serde_derive::{Deserialize, Serialize}; use url::Url; use utoipa::ToSchema; +use crate::craft_apps::flatpak::CraftAppFlatpakRepoInfo;+ // ── GET /v1/craft_apps/release_info ── // // The latest release of every Craft App (PhotoCraft, VectorCraft, …, and ArtCraft Launcher@@ -27,6 +29,10 @@ pub struct GetCraftAppsReleaseInfoResponse { pub repo_renames: Vec<CraftAppRepoRename>, pub important_notices: CraftAppsImportantNotices,++ /// Flatpak repository configuration for the complete ArtCraft Suite (if configured).+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub suite_flatpak_repo: Option<CraftAppFlatpakRepoInfo>, } #[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]@@ -56,6 +62,10 @@ pub struct CraftAppReleaseInfo { /// When this app's release data was last fetched successfully. Absent if it never was; then /// `latest_release` says nothing and clients should ask GitHub themselves. pub app_last_fetched: Option<DateTime<Utc>>,++ /// Flatpak repository configuration for this application (if available).+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub flatpak_repo: Option<CraftAppFlatpakRepoInfo>, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]@@ -162,6 +162,8 @@ use artcraft_api_defs::moderation::user_daily_spends::user_daily_spends_list::*; use artcraft_api_defs::moderation::top_spenders::list::*; use artcraft_api_defs::moderation::user_spend_events::list::*; use artcraft_api_defs::folders::subfolder::*;+use artcraft_api_defs::craft_apps::download::*;+use artcraft_api_defs::craft_apps::flatpak::*; use artcraft_api_defs::craft_apps::release_info::*; use artcraft_api_defs::tags::add_media_file_tags::*; use artcraft_api_defs::tags::bulk_add_tags::*;@@ -737,6 +739,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // Craft Apps crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler,+ crate::http_server::endpoints::craft_apps::get_craft_apps_flatpakrepo_handler::get_craft_apps_flatpakrepo_handler,+ crate::http_server::endpoints::craft_apps::get_craft_apps_flatpak_repo_redirect_handler::get_craft_apps_flatpak_repo_redirect_handler,+ crate::http_server::endpoints::craft_apps::get_craft_apps_download_handler::get_craft_apps_download_handler, // Tags crate::http_server::endpoints::tags::list_tags_handler::list_tags_handler,@@ -1624,6 +1629,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux CraftAppsImportantNotices, CraftAppsAnnouncement, CraftAppsAlert,+ CraftAppFlatpakRepoConfig,+ CraftAppFlatpakRepoInfo,+ CraftAppDownloadErrorResponse,+ CraftAppDownloadQuery, ListUntaggedMediaFilesQueryParams, ListUntaggedMediaFilesSuccessResponse, MediaFileTagsEntry,new file mode 100644@@ -0,0 +1,338 @@+//! Directs clients and browsers to the appropriate GitHub Releases asset download URL+//! via HTTP 302 redirect.++use std::sync::Arc;++use actix_web::http::header::{CacheControl, CacheDirective, LOCATION};+use actix_web::{web, HttpResponse};+use artcraft_api_defs::craft_apps::download::{CraftAppDownloadErrorResponse, CraftAppDownloadQuery};+use artcraft_api_defs::craft_apps::release_info::{CraftAppArch, CraftAppOs, CraftAppPackageType, CraftAppReleaseAsset};+use serde::Deserialize;+use utoipa::ToSchema;++use crate::state::server_state::ServerState;++const REDIRECT_CACHE_SECS: u32 = 60;+const NOT_FOUND_CACHE_SECS: u32 = 30;++#[derive(Debug, Deserialize, ToSchema)]+pub struct CraftAppDownloadPath {+ /// App machine name or alias (e.g. `photocraft`, `cadcraft`, `artcraft-launcher`).+ pub app: String,+ /// Package kind, extension, or exact asset filename (e.g. `flatpak`, `appimage`, `deb`, `msi`, `dmg`).+ pub kind: String,+}++/// Redirects to the direct GitHub Release asset download URL for the requested application+/// and package type, avoiding the need to host binaries on web application pods or R2 storage.+#[utoipa::path(+ get,+ tag = "Craft Apps",+ path = "/v1/craft_apps/download/{app}/{kind}",+ params(+ ("app" = String, Path, description = "Craft application identifier (e.g. 'photocraft', 'cadcraft', 'artcraft-launcher')"),+ ("kind" = String, Path, description = "Package kind, extension, or filename (e.g. 'flatpak', 'appimage', 'deb', 'rpm', 'dmg', 'msi', 'tar.gz', 'zip')"),+ CraftAppDownloadQuery,+ ),+ responses(+ (status = 302, description = "Redirect to GitHub Release asset download URL"),+ (status = 404, description = "Unknown app, no releases published yet, or no matching asset", body = CraftAppDownloadErrorResponse),+ ),+)]+pub async fn get_craft_apps_download_handler(server_state: web::Data<Arc<ServerState>>, path: web::Path<CraftAppDownloadPath>, query: web::Query<CraftAppDownloadQuery>) -> HttpResponse {+ let path = path.into_inner();+ let app_key = path.app.trim();+ let kind = path.kind.trim();++ let cached_app = match server_state.craft_apps_release_cache.get_app(app_key) {+ Some(app) => app,+ None => {+ return HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("Unknown application '{app_key}'"), available_assets: None });+ },+ };++ let release = match cached_app.latest_release {+ Some(rel) => rel,+ None => {+ return HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("No releases currently available for application '{app_key}'"), available_assets: None });+ },+ };++ let arch_filter = query.arch.as_deref();+ let os_filter = query.os.as_deref();++ match find_matching_asset(&release.assets, kind, arch_filter, os_filter) {+ Some(asset) => HttpResponse::Found().insert_header((LOCATION, asset.download_url.clone())).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(REDIRECT_CACHE_SECS)])).finish(),+ None => {+ let available_assets = release.assets.iter().map(|a| a.name.clone()).collect();+ HttpResponse::NotFound().insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(NOT_FOUND_CACHE_SECS)])).json(CraftAppDownloadErrorResponse { success: false, error: format!("No asset found matching '{kind}' for application '{app_key}'"), available_assets: Some(available_assets) })+ },+ }+}++/// Finds the most appropriate asset in `assets` matching the specified `kind`,+/// `arch_filter`, and `os_filter`.+pub fn find_matching_asset<'a>(assets: &'a [CraftAppReleaseAsset], kind: &str, arch_filter: Option<&str>, os_filter: Option<&str>) -> Option<&'a CraftAppReleaseAsset> {+ let kind_trimmed = kind.trim();+ if kind_trimmed.is_empty() {+ return None;+ }++ // 1. Direct exact filename match+ if let Some(exact) = assets.iter().find(|a| a.name.eq_ignore_ascii_case(kind_trimmed)) {+ return Some(exact);+ }++ // 2. Filter candidates matching kind and OS+ let matching_kind: Vec<&'a CraftAppReleaseAsset> = assets.iter().filter(|a| matches_kind(a, kind_trimmed)).filter(|a| os_filter.is_none_or(|os| matches_os(a, os))).collect();++ if matching_kind.is_empty() {+ return None;+ }++ // 3. Filter or score by architecture+ if let Some(arch_str) = arch_filter.map(str::trim).filter(|s| !s.is_empty()) {+ if let Some(target_arch) = parse_arch(arch_str) {+ // Prioritize exact arch match over Universal fallback+ if let Some(exact_arch) = matching_kind.iter().find(|a| a.platform.as_ref().and_then(|p| p.arch).is_some_and(|arch| arch == target_arch)) {+ return Some(*exact_arch);+ }+ if let Some(compatible) = matching_kind.iter().find(|a| matches_arch(a, target_arch)) {+ return Some(*compatible);+ }+ } else {+ let lower = arch_str.to_lowercase();+ if let Some(matched) = matching_kind.iter().find(|a| a.name.to_lowercase().contains(&lower)) {+ return Some(*matched);+ }+ }+ }++ // 4. Default architecture selection when arch is omitted:+ // Prefer x86_64 / universal, then arm64, then x86.+ matching_kind.into_iter().max_by_key(|a| arch_priority_score(a))+}++fn matches_kind(asset: &CraftAppReleaseAsset, kind: &str) -> bool {+ let kind_lower = kind.to_lowercase();+ let k = kind_lower.trim_start_matches('.');+ let name_lower = asset.name.to_lowercase();++ match k {+ "flatpak" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Flatpak) || name_lower.ends_with(".flatpak"),+ "appimage" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::AppImage) || (name_lower.ends_with(".appimage") && !name_lower.ends_with(".appimage.zsync")),+ "zsync" | "appimage.zsync" | "appimagezsync" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::AppImageZsync) || name_lower.ends_with(".appimage.zsync"),+ "deb" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Deb) || name_lower.ends_with(".deb"),+ "rpm" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Rpm) || name_lower.ends_with(".rpm"),+ "dmg" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Dmg) || name_lower.ends_with(".dmg"),+ "msi" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::Msi) || name_lower.ends_with(".msi"),+ "tar.gz" | "targz" | "tar" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::TarGz) || name_lower.ends_with(".tar.gz"),+ "portable" | "portable-zip" | "portable.zip" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::PortableZip) || name_lower.ends_with("-portable.zip"),+ "web" | "wasm" | "webzip" => asset.platform.as_ref().is_some_and(|p| p.package_type == CraftAppPackageType::WebZip) || (name_lower.contains("-web-") && name_lower.ends_with(".zip")),+ "zip" => name_lower.ends_with(".zip"),+ _ => name_lower.ends_with(&format!(".{k}")) || name_lower.ends_with(kind) || name_lower.contains(kind),+ }+}++fn matches_os(asset: &CraftAppReleaseAsset, os_str: &str) -> bool {+ let target_os = match os_str.to_lowercase().as_str() {+ "linux" => CraftAppOs::Linux,+ "windows" | "win" | "win64" | "win32" => CraftAppOs::Windows,+ "macos" | "mac" | "darwin" | "osx" => CraftAppOs::Macos,+ "freebsd" => CraftAppOs::Freebsd,+ "web" | "wasm" => CraftAppOs::Web,+ _ => return false,+ };++ if let Some(platform) = &asset.platform {+ platform.os == target_os+ } else {+ matches_os_name_fallback(&asset.name.to_lowercase(), target_os)+ }+}++fn matches_os_name_fallback(name_lower: &str, target_os: CraftAppOs) -> bool {+ match target_os {+ CraftAppOs::Windows if name_lower.ends_with(".msi") || name_lower.ends_with(".exe") => return true,+ CraftAppOs::Macos if name_lower.ends_with(".dmg") || name_lower.ends_with(".pkg") => return true,+ CraftAppOs::Linux if name_lower.ends_with(".deb") || name_lower.ends_with(".rpm") || name_lower.ends_with(".flatpak") => return true,+ _ => {}+ }++ let tokens: Vec<&str> = name_lower.split(|c: char| !c.is_ascii_alphanumeric()).filter(|s| !s.is_empty()).collect();++ match target_os {+ CraftAppOs::Linux => tokens.contains(&"linux"),+ CraftAppOs::Windows => tokens.iter().any(|&t| t == "windows" || t == "win" || t == "win64" || t == "win32"),+ CraftAppOs::Macos => tokens.iter().any(|&t| t == "macos" || t == "darwin" || t == "osx" || t == "mac"),+ CraftAppOs::Freebsd => tokens.contains(&"freebsd"),+ CraftAppOs::Web => tokens.contains(&"web") || tokens.contains(&"wasm"),+ }+}++fn parse_arch(arch_str: &str) -> Option<CraftAppArch> {+ match arch_str.to_lowercase().as_str() {+ "x64" | "x86_64" | "amd64" => Some(CraftAppArch::X64),+ "x86" | "i386" | "i686" => Some(CraftAppArch::X86),+ "arm64" | "aarch64" => Some(CraftAppArch::Arm64),+ "universal" => Some(CraftAppArch::Universal),+ _ => None,+ }+}++fn matches_arch(asset: &CraftAppReleaseAsset, target_arch: CraftAppArch) -> bool {+ if let Some(platform) = &asset.platform {+ if let Some(arch) = platform.arch {+ if arch == target_arch {+ return true;+ }+ // macOS Universal packages run on both X64 and Arm64+ if arch == CraftAppArch::Universal && (target_arch == CraftAppArch::X64 || target_arch == CraftAppArch::Arm64) {+ return true;+ }+ }+ }+ let name_lower = asset.name.to_lowercase();+ match target_arch {+ CraftAppArch::X64 => name_lower.contains("x86_64") || name_lower.contains("x64") || name_lower.contains("amd64") || name_lower.contains("universal"),+ CraftAppArch::Arm64 => name_lower.contains("aarch64") || name_lower.contains("arm64") || name_lower.contains("universal"),+ CraftAppArch::X86 => name_lower.contains("x86") || name_lower.contains("i386") || name_lower.contains("i686"),+ CraftAppArch::Universal => name_lower.contains("universal"),+ }+}++fn arch_priority_score(asset: &CraftAppReleaseAsset) -> u8 {+ if let Some(platform) = &asset.platform {+ match platform.arch {+ Some(CraftAppArch::X64) | Some(CraftAppArch::Universal) => 10,+ Some(CraftAppArch::Arm64) => 5,+ Some(CraftAppArch::X86) => 2,+ None => 1,+ }+ } else {+ let name = asset.name.to_lowercase();+ if name.contains("x86_64") || name.contains("x64") || name.contains("universal") {+ 8+ } else if name.contains("aarch64") || name.contains("arm64") {+ 4+ } else {+ 1+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ use artcraft_api_defs::craft_apps::release_info::CraftAppAssetPlatform;++ fn test_asset(name: &str, os: CraftAppOs, arch: Option<CraftAppArch>, package_type: CraftAppPackageType) -> CraftAppReleaseAsset {+ CraftAppReleaseAsset { name: name.to_string(), size_bytes: 12345, download_url: format!("https://github.com/storytold/photocraft/releases/download/v0.5.0/{name}"), sha256: None, platform: Some(CraftAppAssetPlatform { os, arch, package_type }) }+ }++ #[test]+ fn exact_filename_match_takes_priority() {+ let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak)];+ let matched = find_matching_asset(&assets, "photocraft-0.5.0-linux-aarch64.flatpak", None, None);+ assert_eq!(matched.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");+ }++ #[test]+ fn default_flatpak_selects_x86_64() {+ let assets = vec![test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak)];+ let matched = find_matching_asset(&assets, "flatpak", None, None);+ assert_eq!(matched.unwrap().name, "photocraft-0.5.0-linux-x86_64.flatpak");+ }++ #[test]+ fn flatpak_respects_arch_filter() {+ let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak), test_asset("photocraft-0.5.0-linux-aarch64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::Arm64), CraftAppPackageType::Flatpak)];+ let matched_arm = find_matching_asset(&assets, "flatpak", Some("aarch64"), None);+ assert_eq!(matched_arm.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");++ let matched_arm64 = find_matching_asset(&assets, "flatpak", Some("arm64"), None);+ assert_eq!(matched_arm64.unwrap().name, "photocraft-0.5.0-linux-aarch64.flatpak");++ let matched_x64 = find_matching_asset(&assets, "flatpak", Some("x86_64"), None);+ assert_eq!(matched_x64.unwrap().name, "photocraft-0.5.0-linux-x86_64.flatpak");+ }++ #[test]+ fn distinguishes_appimage_from_zsync() {+ let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.AppImage.zsync", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImageZsync), test_asset("photocraft-0.5.0-linux-x86_64.AppImage", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::AppImage)];+ let matched_ai = find_matching_asset(&assets, "appimage", None, None);+ assert_eq!(matched_ai.unwrap().name, "photocraft-0.5.0-linux-x86_64.AppImage");++ let matched_zsync = find_matching_asset(&assets, "zsync", None, None);+ assert_eq!(matched_zsync.unwrap().name, "photocraft-0.5.0-linux-x86_64.AppImage.zsync");+ }++ #[test]+ fn universal_macos_dmg_serves_arm64_and_x64() {+ let assets = vec![test_asset("photocraft-0.5.0-macos-universal.dmg", CraftAppOs::Macos, Some(CraftAppArch::Universal), CraftAppPackageType::Dmg), test_asset("photocraft-0.5.0-windows-x64.msi", CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::Msi)];+ let matched_mac = find_matching_asset(&assets, "dmg", None, None);+ assert_eq!(matched_mac.unwrap().name, "photocraft-0.5.0-macos-universal.dmg");++ let matched_arm = find_matching_asset(&assets, "dmg", Some("arm64"), None);+ assert_eq!(matched_arm.unwrap().name, "photocraft-0.5.0-macos-universal.dmg");++ let matched_msi = find_matching_asset(&assets, "msi", None, None);+ assert_eq!(matched_msi.unwrap().name, "photocraft-0.5.0-windows-x64.msi");+ }++ #[test]+ fn package_types_and_tarball_lookup() {+ let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.deb", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Deb), test_asset("photocraft-0.5.0-linux-x86_64.rpm", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Rpm), test_asset("photocraft-0.5.0-linux-x86_64.tar.gz", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::TarGz), test_asset("photocraft-0.5.0-windows-x64-portable.zip", CraftAppOs::Windows, Some(CraftAppArch::X64), CraftAppPackageType::PortableZip)];+ assert_eq!(find_matching_asset(&assets, "deb", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.deb");+ assert_eq!(find_matching_asset(&assets, "rpm", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.rpm");+ assert_eq!(find_matching_asset(&assets, "tar.gz", None, None).unwrap().name, "photocraft-0.5.0-linux-x86_64.tar.gz");+ assert_eq!(find_matching_asset(&assets, "portable", None, None).unwrap().name, "photocraft-0.5.0-windows-x64-portable.zip");+ }++ #[test]+ fn returns_none_on_unknown_kind_or_empty_assets() {+ let assets = vec![test_asset("photocraft-0.5.0-linux-x86_64.flatpak", CraftAppOs::Linux, Some(CraftAppArch::X64), CraftAppPackageType::Flatpak)];+ assert!(find_matching_asset(&assets, "exe", None, None).is_none());+ assert!(find_matching_asset(&[], "flatpak", None, None).is_none());+ assert!(find_matching_asset(&assets, "", None, None).is_none());+ }++ #[test]+ fn os_fallback_distinguishes_windows_and_darwin() {+ let darwin_asset = CraftAppReleaseAsset {+ name: "photocraft-0.5.0-darwin-x86_64.tar.gz".to_string(),+ size_bytes: 12345,+ download_url: "https://example.com/darwin".to_string(),+ sha256: None,+ platform: None,+ };+ let win_asset = CraftAppReleaseAsset {+ name: "photocraft-0.5.0-win-x64.zip".to_string(),+ size_bytes: 12345,+ download_url: "https://example.com/win".to_string(),+ sha256: None,+ platform: None,+ };+ let macro_asset = CraftAppReleaseAsset {+ name: "photocraft-macro-tools-0.5.0-linux.tar.gz".to_string(),+ size_bytes: 12345,+ download_url: "https://example.com/macro".to_string(),+ sha256: None,+ platform: None,+ };++ assert!(matches_os(&darwin_asset, "macos"));+ assert!(matches_os(&darwin_asset, "darwin"));+ assert!(!matches_os(&darwin_asset, "windows"));+ assert!(!matches_os(&darwin_asset, "win"));++ assert!(matches_os(&win_asset, "windows"));+ assert!(matches_os(&win_asset, "win"));+ assert!(!matches_os(&win_asset, "macos"));+ assert!(!matches_os(&win_asset, "darwin"));++ assert!(matches_os(¯o_asset, "linux"));+ assert!(!matches_os(¯o_asset, "macos"));+ assert!(!matches_os(¯o_asset, "mac"));+ }+}new file mode 100644@@ -0,0 +1,64 @@+//! Redirects Flatpak OSTree repository requests to the backing cloud object storage (S3/R2).++use actix_web::http::header::{CacheControl, CacheDirective, LOCATION};+use actix_web::{web, HttpResponse};++const DEFAULT_OBJECT_CACHE_SECS: u32 = 86400;+const METADATA_CACHE_SECS: u32 = 60;++/// Redirects Flatpak repository file requests (summary, refs, objects, deltas)+/// to the backing S3 or Cloudflare R2 object storage CDN.+#[utoipa::path(+ get,+ tag = "Craft Apps",+ path = "/v1/craft_apps/flatpak/repo/{tail:.*}",+ params(+ ("tail" = String, Path, description = "Relative path within the OSTree repository (e.g. 'summary', 'config', 'objects/..')"),+ ),+ responses(+ (status = 307, description = "Redirect to the backing OSTree storage CDN"),+ ),+)]+pub async fn get_craft_apps_flatpak_repo_redirect_handler(tail: web::Path<String>) -> HttpResponse {+ let relative_path = tail.into_inner();+ if relative_path.ends_with(".flatpakrepo") {+ return HttpResponse::NotFound().finish();+ }++ let base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");+ let trimmed_base = base_url.trim_end_matches('/');++ let destination_url = if relative_path.is_empty() { format!("{trimmed_base}/") } else { format!("{trimmed_base}/{}", relative_path.trim_start_matches('/')) };++ let max_age = if relative_path.starts_with("objects/") || relative_path.starts_with("deltas/") { DEFAULT_OBJECT_CACHE_SECS } else { METADATA_CACHE_SECS };++ HttpResponse::TemporaryRedirect().insert_header((LOCATION, destination_url)).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(max_age)])).finish()+}++#[cfg(test)]+mod tests {+ use super::*;+ use actix_web::http::StatusCode;++ #[actix_web::test]+ async fn redirects_with_307_and_location() {+ let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("summary".to_string())).await;+ assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);+ let loc = resp.headers().get(LOCATION).expect("Location header");+ assert_eq!(loc, "https://repo.getartcraft.com/summary");+ }++ #[actix_web::test]+ async fn redirects_objects_path_with_307() {+ let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("objects/01/23.filez".to_string())).await;+ assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);+ let loc = resp.headers().get(LOCATION).expect("Location header");+ assert_eq!(loc, "https://repo.getartcraft.com/objects/01/23.filez");+ }++ #[actix_web::test]+ async fn rejects_flatpakrepo_under_repo_path() {+ let resp = get_craft_apps_flatpak_repo_redirect_handler(web::Path::from("artcraft.flatpakrepo".to_string())).await;+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);+ }+}new file mode 100644@@ -0,0 +1,144 @@+//! Serves .flatpakrepo configuration files for ArtCraft Suite and individual Craft Apps.++use std::sync::Arc;++use actix_web::http::header::{CacheControl, CacheDirective, ContentDisposition, DispositionParam, DispositionType};+use actix_web::{web, HttpResponse};+use artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoConfig;++use crate::state::server_state::ServerState;++const FLATPAK_MIME_TYPE: &str = "application/vnd.flatpak.repo; charset=utf-8";+const CACHE_MAX_AGE_SECS: u32 = 3600;++/// Serves a `.flatpakrepo` configuration file for the entire ArtCraft Suite+/// (`artcraft.flatpakrepo`) or an individual Craft app (`<app>.flatpakrepo`).+#[utoipa::path(+ get,+ tag = "Craft Apps",+ path = "/v1/craft_apps/flatpak/{target}",+ params(+ ("target" = String, Path, description = "Target .flatpakrepo filename, e.g. 'artcraft.flatpakrepo' or 'photocraft.flatpakrepo'"),+ ),+ responses(+ (status = 200, description = "Flatpak repository definition file (.flatpakrepo)"),+ (status = 404, description = "Unknown app or invalid filename extension"),+ ),+)]+pub async fn get_craft_apps_flatpakrepo_handler(server_state: web::Data<Arc<ServerState>>, target: web::Path<String>) -> HttpResponse {+ handle_craft_apps_flatpakrepo(&server_state.craft_apps_release_cache, target.as_str())+}++/// Core logic for handling `.flatpakrepo` file requests.+pub fn handle_craft_apps_flatpakrepo(cache: &crate::state::craft_apps_release_cache::CraftAppsReleaseCache, target_filename: &str) -> HttpResponse {+ if !target_filename.ends_with(".flatpakrepo") {+ return HttpResponse::NotFound().finish();+ }++ let stem = target_filename.strip_suffix(".flatpakrepo").unwrap_or("");+ if !is_valid_stem(stem) {+ return HttpResponse::NotFound().finish();+ }++ let repo_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");+ let website_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_WEBSITE_BASE_URL", "https://getartcraft.com");+ let default_gpg_key = format!("{}/keys/flatpak.gpg", website_base_url.trim_end_matches('/'));+ let gpg_key_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_GPG_KEY_URL", &default_gpg_key);++ let content = if stem == "artcraft" || stem == "suite" {+ CraftAppFlatpakRepoConfig::suite_config(&repo_base_url, &website_base_url, Some(gpg_key_url)).to_flatpakrepo_content()+ } else {+ let (app_key, app) = match cache.get_app_with_key(stem) {+ Some(entry) => entry,+ None => return HttpResponse::NotFound().finish(),+ };++ let app_env_key = app_key.to_uppercase().replace('-', "_");+ let app_gpg_key = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_GPG_KEY_URL_{app_env_key}")).unwrap_or(gpg_key_url);+ let app_repo_url = format!("{}/{}", repo_base_url.trim_end_matches('/'), app_key);+ let app_homepage = format!("{}/apps/{}", website_base_url.trim_end_matches('/'), app_key);+ CraftAppFlatpakRepoConfig::app_config(&app_key, &app.name, &app_repo_url, &app_homepage, Some(app_gpg_key)).to_flatpakrepo_content()+ };++ let disposition = ContentDisposition { disposition: DispositionType::Inline, parameters: vec![DispositionParam::Filename(target_filename.to_string())] };++ HttpResponse::Ok().content_type(FLATPAK_MIME_TYPE).insert_header(CacheControl(vec![CacheDirective::Public, CacheDirective::MaxAge(CACHE_MAX_AGE_SECS)])).insert_header(disposition).body(content)+}++fn is_valid_stem(stem: &str) -> bool {+ !stem.is_empty() && stem.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')+}++#[cfg(test)]+mod tests {+ use super::*;+ use actix_web::http::StatusCode;+ use crate::state::craft_apps_release_cache::CraftAppsReleaseCache;++ #[test]+ fn validates_stems() {+ assert!(is_valid_stem("photocraft"));+ assert!(is_valid_stem("artcraft-launcher"));+ assert!(is_valid_stem("cadcraft-1"));+ assert!(!is_valid_stem(""));+ assert!(!is_valid_stem("PhotoCraft"));+ assert!(!is_valid_stem("photo\ncraft"));+ assert!(!is_valid_stem("photo craft"));+ assert!(!is_valid_stem("photo/craft"));+ assert!(!is_valid_stem("photo%0Acraft"));+ }++ #[test]+ fn generates_suite_and_app_repo_content() {+ let suite = CraftAppFlatpakRepoConfig::suite_config("https://repo.getartcraft.com", "https://getartcraft.com", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+ let suite_ini = suite.to_flatpakrepo_content();+ assert!(suite_ini.contains("[Flatpak Repo]"));+ assert!(suite_ini.contains("Title=ArtCraft"));+ assert!(suite_ini.contains("Url=https://repo.getartcraft.com/"));+ assert!(suite_ini.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));++ let app = CraftAppFlatpakRepoConfig::app_config("cadcraft", "CADCraft", "https://repo.getartcraft.com/cadcraft", "https://getartcraft.com/apps/cadcraft", Some("https://getartcraft.com/keys/flatpak.gpg".to_string()));+ let app_ini = app.to_flatpakrepo_content();+ assert!(app_ini.contains("Title=CADCraft"));+ assert!(app_ini.contains("Url=https://repo.getartcraft.com/cadcraft/"));+ assert!(app_ini.contains("Homepage=https://getartcraft.com/apps/cadcraft"));+ assert!(app_ini.contains("GPGKey=https://getartcraft.com/keys/flatpak.gpg"));+ }++ #[actix_web::test]+ async fn flatpakrepo_suite_and_app_responses() {+ let cache = CraftAppsReleaseCache::new();+ cache.register_apps(&[+ ("photocraft".into(), "PhotoCraft".into(), "storytold/photocraft".into()),+ ("artcraft-launcher".into(), "ArtCraft Launcher".into(), "storytold/craft-launcher".into()),+ ]);++ // Suite returns 200 with GPGKey+ let resp = handle_craft_apps_flatpakrepo(&cache, "artcraft.flatpakrepo");+ assert_eq!(resp.status(), StatusCode::OK);+ let body_bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();+ let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();+ assert!(body_str.contains("Title=ArtCraft"));+ assert!(body_str.contains("GPGKey="));++ // Known app returns 200 with GPGKey and cached app name+ let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft.flatpakrepo");+ assert_eq!(resp.status(), StatusCode::OK);+ let body_bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap();+ let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();+ assert!(body_str.contains("Title=PhotoCraft"));+ assert!(body_str.contains("GPGKey="));++ // Unknown app returns 404+ let resp = handle_craft_apps_flatpakrepo(&cache, "anything.flatpakrepo");+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);++ // Missing .flatpakrepo extension returns 404+ let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft");+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);++ // Newline injection attempt returns 404+ let resp = handle_craft_apps_flatpakrepo(&cache, "photocraft\nGPGKey=evil.flatpakrepo");+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);+ }+}@@ -25,7 +25,24 @@ const CACHE_MAX_AGE_SECS: u32 = 60; pub async fn get_craft_apps_release_info_handler( server_state: web::Data<Arc<ServerState>>, ) -> HttpResponse {- let release_info = server_state.craft_apps_release_cache.snapshot();+ let flatpak_config = server_state.craft_apps_release_cache.load_flatpak_config_from_env();++ let suite_flatpak_repo = if flatpak_config.enabled {+ Some(artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoInfo {+ app_id: "ai.storyteller.artcraft".to_string(),+ repo_url: format!("{}/", flatpak_config.repo_base_url.trim_end_matches('/')),+ flatpakrepo_url: format!("{}/v1/craft_apps/flatpak/artcraft.flatpakrepo", flatpak_config.api_base_url.trim_end_matches('/')),+ branch: "stable".to_string(),+ version: None,+ min_version: None,+ is_synced: None,+ gpg_key_url: flatpak_config.gpg_key_url.clone(),+ })+ } else {+ None+ };++ let release_info = server_state.craft_apps_release_cache.snapshot_with_flatpak_config(&flatpak_config); let cache_control = cache_control_for(&release_info); let response = GetCraftAppsReleaseInfoResponse { success: true,@@ -34,6 +51,7 @@ pub async fn get_craft_apps_release_info_handler( app_renames: Vec::new(), repo_renames: Vec::new(), important_notices: CraftAppsImportantNotices::default(),+ suite_flatpak_repo, }; HttpResponse::Ok() .insert_header(cache_control)@@ -1 +1,4 @@+pub mod get_craft_apps_download_handler;+pub mod get_craft_apps_flatpak_repo_redirect_handler;+pub mod get_craft_apps_flatpakrepo_handler; pub mod get_craft_apps_release_info_handler;@@ -3,6 +3,9 @@ use actix_service::ServiceFactory; use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{web, App, Error, HttpResponse}; +use crate::http_server::endpoints::craft_apps::get_craft_apps_download_handler::get_craft_apps_download_handler;+use crate::http_server::endpoints::craft_apps::get_craft_apps_flatpak_repo_redirect_handler::get_craft_apps_flatpak_repo_redirect_handler;+use crate::http_server::endpoints::craft_apps::get_craft_apps_flatpakrepo_handler::get_craft_apps_flatpakrepo_handler; use crate::http_server::endpoints::craft_apps::get_craft_apps_release_info_handler::get_craft_apps_release_info_handler; pub fn add_craft_apps_routes<T, B>(app: App<T>) -> App<T>@@ -23,6 +26,29 @@ where web::resource("/release_info") .route(web::get().to(get_craft_apps_release_info_handler)) .route(web::head().to(|| HttpResponse::Ok())),+ )+ // Flatpak repository redirection to S3/R2 storage backend+ .service(+ web::resource("/flatpak/repo/{tail:.*}")+ .route(web::get().to(get_craft_apps_flatpak_repo_redirect_handler))+ .route(web::head().to(get_craft_apps_flatpak_repo_redirect_handler)),+ )+ // Flatpak repository definitions (.flatpakrepo) for suite or apps+ .service(+ web::resource("/flatpak/{target}")+ .route(web::get().to(get_craft_apps_flatpakrepo_handler))+ .route(web::head().to(get_craft_apps_flatpakrepo_handler)),+ )+ // Asset download redirection to GitHub Releases+ .service(+ web::resource("/download/{app}/{kind}")+ .route(web::get().to(get_craft_apps_download_handler))+ .route(web::head().to(get_craft_apps_download_handler)),+ )+ .service(+ web::resource("/{app}/download/{kind}")+ .route(web::get().to(get_craft_apps_download_handler))+ .route(web::head().to(get_craft_apps_download_handler)), ), ) }@@ -159,25 +159,163 @@ impl CraftAppsReleaseCache { } } + /// Returns the canonical app key and cached app info for the specified key or alias, if known.+ pub fn get_app_with_key(&self, key: &str) -> Option<(String, CachedCraftApp)> {+ let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);+ let lower = key.to_lowercase();+ if let Some((k, app)) = map.get_key_value(key).or_else(|| map.get_key_value(&lower)) {+ return Some((k.clone(), app.clone()));+ }+ for (k, app) in map.iter() {+ if k.eq_ignore_ascii_case(key)+ || app.name.eq_ignore_ascii_case(key)+ || app.repo.eq_ignore_ascii_case(key)+ || app.repo.split('/').last().is_some_and(|r| r.eq_ignore_ascii_case(key))+ || (key.eq_ignore_ascii_case("launcher") && k == "artcraft-launcher")+ || (key.eq_ignore_ascii_case("craft-launcher") && k == "artcraft-launcher")+ {+ return Some((k.clone(), app.clone()));+ }+ }+ None+ }++ /// Returns the cached app info for the specified key or alias, if known.+ pub fn get_app(&self, key: &str) -> Option<CachedCraftApp> {+ self.get_app_with_key(key).map(|(_, app)| app)+ }++ /// Returns the latest release for the specified app key (e.g. `photocraft`, `artcraft-launcher`),+ /// if fetched.+ pub fn get_app_release(&self, key: &str) -> Option<CraftAppRelease> {+ self.get_app(key).and_then(|app| app.latest_release)+ }++ /// Helper to load Flatpak config from environment once using registered app keys.+ pub fn load_flatpak_config_from_env(&self) -> CraftAppsFlatpakConfig {+ let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);+ CraftAppsFlatpakConfig::from_env(map.keys())+ }+ /// Everything, as the endpoint serves it: `apps` keyed by app key.+ /// Uses default (disabled) flatpak config without reading environment variables. pub fn snapshot(&self) -> CraftAppsReleaseInfo {+ self.snapshot_with_flatpak_config(&CraftAppsFlatpakConfig::default())+ }++ /// Takes explicit flatpak config / overrides rather than reading environment variables+ /// inside `snapshot()`.+ pub fn snapshot_with_flatpak_config(&self, config: &CraftAppsFlatpakConfig) -> CraftAppsReleaseInfo { let map = self.inner.read().unwrap_or_else(PoisonError::into_inner);+ let apps: BTreeMap<String, CraftAppReleaseInfo> = map- .iter()- .map(|(key, app)| {- (key.clone(), CraftAppReleaseInfo {- name: app.name.clone(),- repo: app.repo.clone(),- latest_release: app.latest_release.clone(),- app_last_fetched: app.app_last_fetched,+ .iter()+ .map(|(key, app)| {+ let flatpak_repo = if config.enabled {+ let app_override = config.app_overrides.get(key);+ let app_version_override = app_override.and_then(|o| o.version.clone());+ let min_version = app_override.and_then(|o| o.min_version.clone());+ let latest_v = app.latest_release.as_ref().map(|r| r.version.clone());++ let (version, is_synced) = match (app_version_override, latest_v) {+ (Some(override_v), Some(latest)) => {+ let synced = artcraft_api_defs::craft_apps::flatpak::compare_versions(&override_v, &latest) != Some(std::cmp::Ordering::Less);+ (Some(override_v), Some(synced))+ }+ (Some(override_v), None) => (Some(override_v), None),+ (None, _) => (None, None),+ };++ Some(artcraft_api_defs::craft_apps::flatpak::CraftAppFlatpakRepoInfo {+ app_id: format!("ai.storyteller.{key}"),+ repo_url: format!("{}/{}", config.repo_base_url.trim_end_matches('/'), key),+ flatpakrepo_url: format!("{}/v1/craft_apps/flatpak/{key}.flatpakrepo", config.api_base_url.trim_end_matches('/')),+ branch: "stable".to_string(),+ version,+ min_version,+ is_synced,+ gpg_key_url: config.gpg_key_url.clone(), })+ } else {+ None+ };++ (key.clone(), CraftAppReleaseInfo {+ name: app.name.clone(),+ repo: app.repo.clone(),+ latest_release: app.latest_release.clone(),+ app_last_fetched: app.app_last_fetched,+ flatpak_repo, })- .collect();+ })+ .collect(); let last_fetched = least_recently_fetched(&apps); CraftAppsReleaseInfo { apps, last_fetched } } } +/// Configuration for Flatpak repository advertising in release info responses.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct CraftAppsFlatpakConfig {+ pub enabled: bool,+ pub repo_base_url: String,+ pub api_base_url: String,+ pub gpg_key_url: Option<String>,+ pub app_overrides: BTreeMap<String, CraftAppFlatpakOverride>,+}++#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct CraftAppFlatpakOverride {+ pub version: Option<String>,+ pub min_version: Option<String>,+}++impl Default for CraftAppsFlatpakConfig {+ fn default() -> Self {+ Self {+ enabled: false,+ repo_base_url: "https://repo.getartcraft.com".to_string(),+ api_base_url: "https://api.getartcraft.com".to_string(),+ gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+ app_overrides: BTreeMap::new(),+ }+ }+}++impl CraftAppsFlatpakConfig {+ /// Reads flatpak configuration and per-app overrides from environment variables once,+ /// outside of snapshot().+ pub fn from_env(known_app_keys: impl IntoIterator<Item = impl AsRef<str>>) -> Self {+ let enabled = easyenv::get_env_bool_or_default("CRAFT_APPS_FLATPAK_REPO_ENABLED", false);+ let repo_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_REPO_BASE_URL", "https://repo.getartcraft.com");+ let api_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_API_BASE_URL", "https://api.getartcraft.com");+ let website_base_url = easyenv::get_env_string_or_default("CRAFT_APPS_WEBSITE_BASE_URL", "https://getartcraft.com");+ let default_gpg_key = format!("{}/keys/flatpak.gpg", website_base_url.trim_end_matches('/'));+ let gpg_key_url = Some(easyenv::get_env_string_or_default("CRAFT_APPS_FLATPAK_GPG_KEY_URL", &default_gpg_key));++ let mut app_overrides = BTreeMap::new();+ if enabled {+ for app_key in known_app_keys {+ let key_str = app_key.as_ref();+ let app_env_key = key_str.to_uppercase().replace('-', "_");+ let version = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_VERSION_{app_env_key}"));+ let min_version = easyenv::get_env_string_optional(&format!("CRAFT_APPS_FLATPAK_MIN_VERSION_{app_env_key}"));+ if version.is_some() || min_version.is_some() {+ app_overrides.insert(key_str.to_string(), CraftAppFlatpakOverride { version, min_version });+ }+ }+ }++ Self {+ enabled,+ repo_base_url,+ api_base_url,+ gpg_key_url,+ app_overrides,+ }+ }+}+ impl CachedCraftApp { /// Whether the last request for this app failed (no success since). pub fn is_failing(&self) -> bool {@@ -276,4 +414,130 @@ mod tests { cache.merge_failure("photocraft", t(5), 4); assert_eq!(cache.app("photocraft").unwrap().consecutive_failures, 4); }++ #[test]+ fn snapshot_populates_flatpak_repo_info() {+ let cache = CraftAppsReleaseCache::new();+ let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];+ cache.register_apps(&apps);++ // Default snapshot has flatpak disabled:+ let snapshot = cache.snapshot();+ assert!(snapshot.apps["photocraft"].flatpak_repo.is_none());++ // With flatpak enabled:+ let config = CraftAppsFlatpakConfig {+ enabled: true,+ repo_base_url: "https://repo.getartcraft.com".to_string(),+ api_base_url: "https://api.getartcraft.com".to_string(),+ gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+ app_overrides: BTreeMap::new(),+ };+ let snapshot = cache.snapshot_with_flatpak_config(&config);+ let flatpak = snapshot.apps["photocraft"].flatpak_repo.as_ref().unwrap();+ assert_eq!(flatpak.app_id, "ai.storyteller.photocraft");+ assert!(flatpak.repo_url.contains("photocraft"));+ assert!(flatpak.flatpakrepo_url.ends_with("photocraft.flatpakrepo"));+ assert_eq!(flatpak.branch, "stable");+ assert_eq!(flatpak.gpg_key_url.as_deref(), Some("https://getartcraft.com/keys/flatpak.gpg"));+ }++ #[test]+ fn snapshot_detects_lagging_flatpak_repo() {+ let cache = CraftAppsReleaseCache::new();+ let apps = [("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string())];+ cache.register_apps(&apps);+ let release = artcraft_api_defs::craft_apps::release_info::CraftAppRelease {+ tag: "v0.5.0".to_string(),+ version: "0.5.0".to_string(),+ name: "PhotoCraft 0.5.0".to_string(),+ published_at: None,+ html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".to_string(),+ prerelease: false,+ notes_markdown: String::new(),+ assets: Vec::new(),+ };+ cache.store("photocraft", Some(release), None, Utc::now());++ // Without override, version and is_synced are None (unknown repo state):+ let config_no_override = CraftAppsFlatpakConfig {+ enabled: true,+ repo_base_url: "https://repo.getartcraft.com".to_string(),+ api_base_url: "https://api.getartcraft.com".to_string(),+ gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+ app_overrides: BTreeMap::new(),+ };+ let snapshot = cache.snapshot_with_flatpak_config(&config_no_override);+ let flatpak = snapshot.apps["photocraft"].flatpak_repo.as_ref().unwrap();+ assert_eq!(flatpak.version, None);+ assert_eq!(flatpak.is_synced, None);++ // With explicit override simulating repo lag (0.4.0 < 0.5.0):+ let mut overrides = BTreeMap::new();+ overrides.insert("photocraft".to_string(), CraftAppFlatpakOverride {+ version: Some("0.4.0".to_string()),+ min_version: None,+ });+ let config_lag = CraftAppsFlatpakConfig {+ enabled: true,+ repo_base_url: "https://repo.getartcraft.com".to_string(),+ api_base_url: "https://api.getartcraft.com".to_string(),+ gpg_key_url: Some("https://getartcraft.com/keys/flatpak.gpg".to_string()),+ app_overrides: overrides,+ };+ let snapshot_lag = cache.snapshot_with_flatpak_config(&config_lag);+ let flatpak_lag = snapshot_lag.apps["photocraft"].flatpak_repo.as_ref().unwrap();+ assert_eq!(flatpak_lag.version.as_deref(), Some("0.4.0"));+ assert_eq!(flatpak_lag.is_synced, Some(false));+ assert!(flatpak_lag.would_downgrade("0.5.0"));+ }++ #[test]+ fn get_app_and_release_lookup() {+ let cache = CraftAppsReleaseCache::new();+ let apps = [+ ("photocraft".to_string(), "PhotoCraft".to_string(), "storytold/photocraft".to_string()),+ ("artcraft-launcher".to_string(), "ArtCraft Launcher".to_string(), "storytold/craft-launcher".to_string()),+ ];+ cache.register_apps(&apps);++ // Initial state: app known, but no release+ let app_meta = cache.get_app("photocraft");+ assert!(app_meta.is_some());+ assert_eq!(app_meta.as_ref().unwrap().name, "PhotoCraft");+ assert!(cache.get_app_release("photocraft").is_none());++ let (canonical_key, app_entry) = cache.get_app_with_key("photocraft").unwrap();+ assert_eq!(canonical_key, "photocraft");+ assert_eq!(app_entry.name, "PhotoCraft");++ // Alias lookups+ assert!(cache.get_app("PhotoCraft").is_some());+ assert!(cache.get_app("launcher").is_some());+ assert!(cache.get_app("craft-launcher").is_some());+ let (alias_key, _) = cache.get_app_with_key("craft-launcher").unwrap();+ assert_eq!(alias_key, "artcraft-launcher");+ assert!(cache.get_app("unknown-app").is_none());++ // Once release stored:+ let release = artcraft_api_defs::craft_apps::release_info::CraftAppRelease {+ tag: "v0.5.0".to_string(),+ version: "0.5.0".to_string(),+ name: "PhotoCraft 0.5.0".to_string(),+ published_at: None,+ html_url: "https://github.com/storytold/photocraft/releases/tag/v0.5.0".to_string(),+ prerelease: false,+ notes_markdown: String::new(),+ assets: Vec::new(),+ };+ cache.store("photocraft", Some(release), None, Utc::now());++ let fetched = cache.get_app_release("photocraft");+ assert!(fetched.is_some());+ assert_eq!(fetched.unwrap().version, "0.5.0");++ let fetched_alias = cache.get_app_release("PhotoCraft");+ assert!(fetched_alias.is_some());+ assert_eq!(fetched_alias.unwrap().version, "0.5.0");+ } }@@ -326,6 +326,7 @@ mod tests { app_renames: Vec::new(), repo_renames: Vec::new(), important_notices: CraftAppsImportantNotices::default(),+ suite_flatpak_repo: None, }; let json = serde_json::to_string_pretty(&response).unwrap(); if let Ok(path) = std::env::var("CRAFT_APPS_SAMPLE_OUT") {