diff --git a/migrations/0001_pkg_meta.sql b/migrations/0001_pkg_meta.sql new file mode 100644 index 0000000..6dd71c2 --- /dev/null +++ b/migrations/0001_pkg_meta.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS package_meta ( + repo TEXT NOT NULL, + name TEXT NOT NULL, + arch TEXT NOT NULL, + version TEXT NOT NULL, + rel INTEGER NOT NULL, + download_count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (repo, name, arch, version, rel) +); diff --git a/src/pkg/package.rs b/src/pkg/package.rs index 3ecc9b5..c7cec6b 100644 --- a/src/pkg/package.rs +++ b/src/pkg/package.rs @@ -4,6 +4,8 @@ use std::{ path::{Path, PathBuf}, }; +use based::get_pg; +use sqlx::FromRow; use tar::Archive; use super::{Repository, arch::Architecture}; @@ -17,14 +19,14 @@ pub struct Package { pub arch: Architecture, /// Name of the package pub name: String, - pub rel: u64, + pub rel: i32, /// Version of the package pub version: Option, } impl Package { /// Create a new package - pub fn new(repo: &str, arch: Architecture, pkg_name: &str, version: &str, rel: u64) -> Self { + pub fn new(repo: &str, arch: Architecture, pkg_name: &str, version: &str, rel: i32) -> Self { let pkg = Package { repo: repo.to_string(), arch, @@ -37,7 +39,7 @@ impl Package { pkg } - pub fn version(ver: &str) -> (String, u64) { + pub fn version(ver: &str) -> (String, i32) { let mut splitted = ver.split('-').collect::>(); let rel = splitted.pop().unwrap(); let ver = splitted.join("-"); @@ -504,3 +506,42 @@ pub fn list_tar_file(tar: &PathBuf) -> Option> { Some(paths) } + +#[derive(Debug, Clone, FromRow)] +pub struct PackageMeta { + pub repo: String, + pub name: String, + pub arch: String, + pub version: String, + pub rel: String, + pub download_count: i32, +} + +pub trait PackageMetaInfo { + fn download_amount(&self) -> impl std::future::Future; + fn increase_download_count(&self) -> impl std::future::Future; +} + +impl PackageMetaInfo for Package { + async fn download_amount(&self) -> i32 { + let res: Option<(i32,)> = sqlx::query_as("SELECT download_count FROM package_meta WHERE repo = $1 AND name = $2 AND version = $3 AND arch = $4 AND rel = $5") + .bind(&self.repo) + .bind(&self.name) + .bind(self.version.as_ref().unwrap()) + .bind(&self.arch.to_string()) + .bind(&self.rel) + .fetch_optional(get_pg!()).await.unwrap(); + + res.map(|x| x.0).unwrap_or(0) + } + + async fn increase_download_count(&self) { + sqlx::query("INSERT INTO package_meta (repo, name, arch, version, rel) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (repo, name, arch, version, rel) DO UPDATE SET download_count = package_meta.download_count + 1") + .bind(&self.repo) + .bind(&self.name) + .bind(&self.arch.to_string()) + .bind(&self.version.as_ref().map(|x| x.to_string()).unwrap_or_default()) + .bind(&self.rel) + .execute(get_pg!()).await.unwrap(); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8e7356b..a5ddee3 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -4,6 +4,7 @@ use based::ui::components::Shell; use based::ui::prelude::*; use maud::{Render, html}; use pacco::pkg::mirror::MirrorRepository; +use pacco::pkg::package::PackageMetaInfo; use rocket::http::{ContentType, Status}; use rocket::{State, get}; @@ -99,6 +100,7 @@ pub async fn pkg_route( .unwrap(); if pkg_name.ends_with("pkg.tar.zst") { + pkg.increase_download_count().await; return respond_with( Status::Ok, ContentType::new("application", "tar"), diff --git a/src/routes/ui.rs b/src/routes/ui.rs new file mode 100644 index 0000000..6599aba --- /dev/null +++ b/src/routes/ui.rs @@ -0,0 +1,571 @@ +use based::request::{RequestContext, StringResponse}; +use based::ui::primitives::flex::Strategy; +use based::ui::primitives::space::SpaceBetweenWidget; +use based::ui::primitives::text::{Code, TextWidget}; +use based::ui::{UIWidget, prelude::*}; +use maud::{PreEscaped, Render, html}; +use pacco::pkg::package::PackageMetaInfo; +use rocket::{State, get}; + +use pacco::pkg::{Package, Repository, arch::Architecture, find_package_by_name}; + +use crate::config::Config; + +use super::render; + +// TODO : API + +#[get("//?")] +pub async fn pkg_ui( + repo: &str, + pkg_name: &str, + ctx: RequestContext, + ver: Option<&str>, +) -> Option { + let repo = Repository::new(repo).unwrap(); + let mut pkg = repo.get_pkg_by_name(pkg_name)?; + + if let Some(ver) = ver { + let (version, rel) = Package::version(ver); + pkg = pkg.get_version(&version); + pkg.rel = rel; + } + + let dl_count = pkg.download_amount().await; + + let versions = pkg.versions(); + let arch = pkg.arch(); + let install_script = pkg.install_script(); + let systemd_units = pkg.systemd_units(); + let pacman_hooks = pkg.pacman_hooks(); + let binaries = pkg.binaries(); + let mut pkginfo = pkg.pkginfo(); + + let content = Div().vanish() + .push( + Flex( + // Package Name + Div().vanish() + .push( + Link(&format!("/{}", repo.name), + Text(&repo.name).bold().color(&Gray::_100)._3xl() + ).use_htmx() + ) + .push(Padding(Text("/").bold().color(&Gray::_400)).all(ScreenValue::_2)) + .push( + Padding(Text(&pkg.name)._3xl().bold().color(&Gray::_100)).right(ScreenValue::_2) + ) + .push_if(pkg.is_signed(), || { + Flex( + Padding( + Div().vanish() + .push(Span("✓")._2xl().bold().color(&Slate::_300)) + .push(Span("Signed").sm().medium().color(&Slate::_300)) + // TODO : Add more info: Who signed? + Public Key recv + ).right(ScreenValue::_4) + ).items_center().gap(ScreenValue::_2) + }) + .push_for_each(&arch, |arch: &Architecture| { + Rounded( + Padding( + Background(Gray::_700, + Span(&arch.to_string()).medium().sm()) + ).x(ScreenValue::_3).y(ScreenValue::_1) + ).size(Size::Full) + }) + .push(Margin( + Flex( + Padding( + Hover(Background(Gray::_300, Nothing())).on(Background(Gray::_200, + Rounded(Shadow::medium( + Link(&format!("/pkg/{}/{}/{}", pkg.repo, pkg.arch.to_string(), pkg.file_name()), + Paragraph( + html! { + svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" { + path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m7-7l-7 7-7-7" {}; + }; + p class="ml-2 text-black" { "Download" }; + } + ).black().xs()))).size(Size::Large) + )) + ).x(ScreenValue::_2).y(ScreenValue::_2) + ).items_center() + ).left(ScreenValue::_4) + ) + ).wrap(Wrap::Wrap).gap(ScreenValue::_2).full_center().group() + ) + .push( + Padding( + Flex( + Div().vanish().push( + Context( + FlexGrow(Strategy::Grow, + Animated(Background(Gray::_800, Shadow::large(Rounded(Margin(Padding(SpaceBetween( + Div() + .push( + Context(Text("Info").xl().semibold().color(&Gray::_300).underlined()) + ) + .push_some( + take_out(&mut pkginfo, |x| { x.0 == "pkgdesc" }).1, + |x: (String, String)| build_info(x.0, x.1) + ) + .push_some( + take_out(&mut pkginfo, |x| { x.0 == "packager" }).1, + |x: (String, String)| build_info(x.0, x.1) + ) + .push_some( + take_out(&mut pkginfo, |x| { x.0 == "url" }).1, + |x: (String, String)| build_info(x.0, x.1) + ) + .push_some( + take_out(&mut pkginfo, |x| { x.0 == "size" }).1, + |x: (String, String)| build_info(x.0, x.1) + ) + .push( + build_info("download_amount".to_string(), dl_count.to_string()) + ) + .push_for_each(&pkginfo, |(key, val)| build_info(key.clone(), val.clone())) + ).y(ScreenValue::_2)).all(ScreenValue::_4)).all(ScreenValue::_2)).size(Size::Large))))) + )).push( + SpaceBetween( + Padding( + Background(Gray::_800, + Shadow::large( + Rounded( + Margin( + Screen::medium(FlexGrow(Strategy::NoGrow, Nothing())).on( + FlexGrow(Strategy::Grow, + Div().vanish() + .push( + Text("Versions").xl().semibold().color(&Gray::_300) + ) + .push( + SpaceBetween( + Div().vanish() + .push_for_each(&versions, |version: &String| { + Animated(Link(&format!("/{}/{}?ver={version}", repo.name, &pkg.name), + if pkg.version.as_ref() + .map(|x| *x == Package::version(&version).0).unwrap_or_default() + { Text(&version).color(&Blue::_500).render() } else { + Hover(Text("").color(&Blue::_400)).on( + Text(&version).color(&Gray::_100)).render() + } + ).use_htmx()) + }) + ).y(ScreenValue::_1) + ) + )) + ).all(ScreenValue::_2) + ).size(Size::Large) + ) + ) + ).all(ScreenValue::_4) + ).y(ScreenValue::_2) + ) + ).wrap(Wrap::Wrap).group() + ).top(ScreenValue::_6) + ).push( + Padding(Flex( + SpaceBetween( + Div().vanish() + .push( + Text("Content").xl().bold().color(&Gray::_300) + ) + .push( + Flex( + Div().vanish() + .push_if(!systemd_units.is_empty(), || { + InfoCard( + Div().vanish() + .push( + CardTitle("Systemd Units") + ) + .push( + ListElements(&systemd_units) + ) + ) + }) + .push_if(!pacman_hooks.is_empty(), || { + InfoCard( + Div().vanish() + .push(CardTitle("Pacman Hooks")) + .push( + Paragraph( + SpaceBetween( + Div().vanish() + .push_for_each(&pacman_hooks, |hook: &(String, String)| { + Div().vanish() + .push( + Text(&hook.0).xl().semibold().color(&Gray::_300) + ) + .push( + Padding(Rounded(Background(Gray::_700, Code(&hook.1).sm().color(&Gray::_100))).size(Size::Large)).all(ScreenValue::_4) + ) + }) + .push( + html! { + @for unit in &systemd_units { + li { (unit) } + } + }) + ).y(ScreenValue::_1) + ).list_style(ListStyle::Disc).color(&Gray::_300) + ) + ) + }) + .push_if(!binaries.is_empty(), + || InfoCard( + Div().vanish() + .push( + CardTitle("Binaries") + ).push( + ListElements(&binaries) + ) + ) + ) + .push( + InfoCard( + Div().vanish().push( + CardTitle("Package Files") + ) + .push( + ListElements(&pkg.file_list()) + ) + ) + ) + ).group().wrap(Wrap::Wrap) + ) + ).y(ScreenValue::_2) + ).wrap(Wrap::Wrap).group()).top(ScreenValue::_6) + ).push_some(install_script.as_ref(), |install_script: &String| { + SpaceBetween( + Margin( + Div() + .push(Text("Install Script")._3xl().semibold().color(&Gray::_300).indentation(ScreenValue::_2)) + .push(Rounded(Padding(Background(Gray::_700, + Code(install_script.trim()) + .color(&Gray::_100).sm() + .indentation(ScreenValue::_0) + )).all(ScreenValue::_2)).size(Size::Large)) + ).top(ScreenValue::_6) + ).y(ScreenValue::_4) + }).render(); + + Some(render(content, pkg_name, ctx).await) +} + +pub fn arch_card(a: &str, repo_name: &str, current: bool) -> PreEscaped { + let url = if current { + &format!("/{repo_name}") + } else { + &format!("/{repo_name}?arch={a}") + }; + + let link = Link(&url, Text(&a).sm().medium().color(&Gray::_300)).use_htmx(); + + Rounded( + Padding(if current { + Background(Blue::_500, link) + } else { + Background(Gray::_700, link) + }) + .x(ScreenValue::_3) + .y(ScreenValue::_1), + ) + .size(Size::Full) + .render() +} + +#[get("/?")] +pub async fn repo_ui( + repo: &str, + ctx: RequestContext, + arch: Option<&str>, + config: &State, +) -> StringResponse { + let arch = arch.map(|x| Architecture::parse(x).unwrap_or(Architecture::any)); + + let repo = Repository::new(repo).unwrap(); + let architectures: Vec<_> = repo.arch().into_iter().map(|x| x.to_string()).collect(); + let packages = if let Some(arch) = arch.clone() { + repo.list_pkg_arch(arch) + } else { + repo.list_pkg() + }; + + let repo_info = Margin( + // Repository name and architectures + Flex( + Div() + .vanish() + .push(Text(&repo.name)._3xl().bold().color(&Gray::_100)) + .push_if(config.is_mirrored_repo(&repo.name), || { + Background( + Blue::_500, + Rounded( + Margin( + Padding(Text("Mirrored").sm().medium().white()) + .x(ScreenValue::_3) + .y(ScreenValue::_1), + ) + .left(ScreenValue::_4), + ) + .size(Size::Full), + ) + }) + .push( + Screen::medium(Margin(Nothing()).top(ScreenValue::_0)).on(Margin( + Flex(Div().vanish().push_for_each(&architectures, |a: &String| { + html! { + @if let Some(arch) = arch.as_ref() { + @if arch.to_string() == *a { + (arch_card(&a, &repo.name, true)) + } @else { + (arch_card(&a, &repo.name, false)) + } + } @else { + (arch_card(&a, &repo.name, false)) + } + } + })) + .group() + .gap(ScreenValue::_2), + ) + .left(ScreenValue::_4) + .top(ScreenValue::_2)), + ), + ) + .wrap(Wrap::Wrap) + .full_center() + .group(), + ) + .bottom(ScreenValue::_6); + + let package_list = SpaceBetween(Div().vanish().push_for_each(&packages, |pkg| { + Animated( + Flex( + Padding( + Hover(Background(Gray::_600, Nothing())).on(Background( + Gray::_700, + Rounded(Shadow::medium( + Link( + &format!("/{}/{pkg}", repo.name), + Div() + .vanish() + .push(Context(Sized( + ScreenValue::_10, + ScreenValue::_10, + Rounded(Background( + Blue::_500, + Flex( + Text( + &pkg.chars() + .next() + .unwrap_or_default() + .to_uppercase() + .to_string(), + ) + .white() + .semibold(), + ) + .full_center() + .group(), + )) + .size(Size::Full), + ))) + .push(Context(Width( + ScreenValue::fit, + Text(&pkg).medium().color(&Gray::_100), + ))) + .push(Context( + Padding( + Flex( + Div() + .vanish() + .push( + Span("✓")._2xl().bold().color(&Slate::_300), + ) + .push( + Span("Signed") + .sm() + .medium() + .color(&Slate::_300), + ), + ) + .items_center() + .gap(ScreenValue::_2) + .group(), + ) + .right(ScreenValue::_4), + )), + ) + .use_htmx(), + )) + .size(Size::Large), + )), + ) + .all(ScreenValue::_4), + ) + .items_center() + .gap(ScreenValue::_4), + ) + })) + .y(ScreenValue::_4); + + let content = Div().vanish().push(repo_info).push(package_list).render(); + + render(content, &repo.name, ctx).await +} + +pub fn build_info(key: String, value: String) -> PreEscaped { + match key.as_str() { + "download_amount" => { + return key_value("Downloads".to_string(), value); + } + "pkgname" => {} + "xdata" => {} + "arch" => {} + "pkbase" => {} + "pkgver" => {} + "pkgdesc" => { + return key_value("Description".to_string(), value); + } + "packager" => { + return key_value("Packager".to_string(), value); + } + "url" => { + return Flex( + Div() + .vanish() + .push(Width(ScreenValue::_32, Span("Website: ").bold())) + .push( + Margin(Link(&value, Span(&value).color(&Blue::_400))).left(ScreenValue::_6), + ), + ) + .group() + .render(); + } + "builddate" => { + let date = chrono::DateTime::from_timestamp(value.parse().unwrap(), 0).unwrap(); + return key_value( + "Build at".to_string(), + date.format("%d.%m.%Y %H:%M").to_string(), + ); + } + "depend" => { + return pkg_list_info("Depends on", &value); + } + "makedepend" => { + return pkg_list_info("Build Dependencies", &value); + } + "license" => { + return key_value("License".to_string(), value); + } + "size" => { + return key_value( + "Size".to_string(), + bytesize::to_string(value.parse().unwrap(), false), + ); + } + _ => { + log::warn!("Unhandled PKGINFO {key} = {value}"); + } + } + + html! {} +} + +pub fn key_value(key: String, value: String) -> PreEscaped { + Flex( + Div() + .vanish() + .push(Width(ScreenValue::_32, Span(&format!("{key}: ")).bold())) + .push(Margin(Span(&value)).left(ScreenValue::_6)), + ) + .items_center() + .group() + .render() +} + +pub fn take_out(v: &mut Vec, f: impl Fn(&T) -> bool) -> (&mut Vec, Option) { + let mut index = -1; + + for (i, e) in v.iter().enumerate() { + if f(e) { + index = i as i64; + } + } + + if index != -1 { + let e = v.remove(index as usize); + return (v, Some(e)); + } + + (v, None) +} + +pub fn find_pkg_url(pkg: &str) -> Option { + if let Some(pkg) = find_package_by_name(pkg) { + return Some(format!("/{}/{}", pkg.repo, pkg.name)); + } + + None +} + +pub fn pkg_list_info(key: &str, value: &str) -> PreEscaped { + let pkgs = value.split_whitespace().map(|pkg| { + if let Some(pkg_url) = find_pkg_url(pkg) { + Margin(Link(&pkg_url, Text(&pkg).color(&Blue::_400)).use_htmx()).left(ScreenValue::_6) + } else { + Margin(Span(&pkg)).left(ScreenValue::_6) + } + }); + + html! { + (Flex( + Div().vanish() + .push(Width(ScreenValue::_32, Span(&format!("{key}: ")).bold())) + .push(Flex( + html! { + @for pkg in pkgs { + (pkg) + } + } + ).group().wrap(Wrap::Wrap)) + ).items_center().group()) + } +} + +#[allow(non_snake_case)] +pub fn ListElements(el: &[String]) -> TextWidget { + Paragraph( + SpaceBetween(html! { + @for unit in el { + li { (unit) } + } + }) + .y(ScreenValue::_1), + ) + .list_style(ListStyle::Disc) + .color(&Gray::_300) +} + +#[allow(non_snake_case)] +pub fn CardTitle(title: &str) -> PreEscaped { + Context(Text(title).large().medium().color(&Gray::_100).underlined()) +} + +#[allow(non_snake_case)] +pub fn InfoCard(inner: T) -> SpaceBetweenWidget { + SpaceBetween( + Padding(Background( + Gray::_800, + Shadow::large( + Rounded(Margin(FlexGrow(Strategy::Grow, inner)).all(ScreenValue::_2)) + .size(Size::Large), + ), + )) + .all(ScreenValue::_4), + ) + .y(ScreenValue::_2) +} diff --git a/src/routes/ui/pkg.rs b/src/routes/ui/pkg.rs index 5c06b3e..749f981 100644 --- a/src/routes/ui/pkg.rs +++ b/src/routes/ui/pkg.rs @@ -5,6 +5,7 @@ use based::ui::primitives::space::SpaceBetweenWidget; use based::ui::primitives::text::{Code, TextWidget}; use based::ui::{UIWidget, prelude::*}; use maud::{PreEscaped, Render, html}; +use pacco::pkg::package::PackageMetaInfo; use rocket::{State, get}; use pacco::pkg::{Package, Repository, arch::Architecture, find_package_by_name}; @@ -28,6 +29,8 @@ pub async fn pkg_ui( pkg.rel = rel; } + let dl_count = pkg.download_amount().await; + let versions = pkg.versions(); let arch = pkg.arch(); let install_script = pkg.install_script(); @@ -132,6 +135,9 @@ pub async fn pkg_ui( take_out(&mut pkginfo, |x| x.0 == "size").1, |x: (String, String)| build_info(x.0, x.1), ) + .push( + build_info("download_amount".to_string(), dl_count.to_string()) + ) .push_for_each(&pkginfo, |(key, val)| { build_info(key.clone(), val.clone()) }), @@ -450,6 +456,9 @@ pub async fn pkg_ui( pub fn build_info(key: String, value: String) -> PreEscaped { match key.as_str() { + "download_amount" => { + return key_value("Downloads".to_string(), value); + } "pkgname" => {} "pkgbase" => {} "xdata" => {}