implement user pages + ui
All checks were successful
ci/woodpecker/push/build Pipeline was successful
All checks were successful
ci/woodpecker/push/build Pipeline was successful
This commit is contained in:
parent
67c31725c1
commit
3fabc91438
11 changed files with 1021 additions and 264 deletions
49
src/main.rs
49
src/main.rs
|
@ -1,40 +1,9 @@
|
|||
// TODO :
|
||||
// - Base
|
||||
// - API
|
||||
// - UI
|
||||
// - PkgDB Abstraction
|
||||
// - Pkg Abstraction
|
||||
|
||||
use based::auth::User;
|
||||
use based::get_pg;
|
||||
use based::page::{Shell, render_page};
|
||||
use based::request::{RequestContext, StringResponse};
|
||||
use maud::html;
|
||||
use pacco::pkg::Repository;
|
||||
use rocket::get;
|
||||
use rocket::routes;
|
||||
|
||||
pub mod routes;
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index_page(ctx: RequestContext) -> StringResponse {
|
||||
let repos: Vec<String> = Repository::list();
|
||||
|
||||
let content = html!(
|
||||
h1 { "Repositories" };
|
||||
@for repo in repos {
|
||||
p { (repo) };
|
||||
};
|
||||
);
|
||||
|
||||
render_page(
|
||||
content,
|
||||
"Repositories",
|
||||
ctx,
|
||||
&Shell::new(html! {}, html! {}, Some(String::new())),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[rocket::launch]
|
||||
async fn launch() -> _ {
|
||||
env_logger::init();
|
||||
|
@ -42,9 +11,21 @@ async fn launch() -> _ {
|
|||
let pg = get_pg!();
|
||||
sqlx::migrate!("./migrations").run(pg).await.unwrap();
|
||||
|
||||
let _ = User::create("admin".to_string(), "admin", based::auth::UserRole::Admin).await;
|
||||
|
||||
rocket::build().mount("/", routes![
|
||||
index_page,
|
||||
based::htmx::htmx_script_route,
|
||||
routes::index_page,
|
||||
routes::pkg_route,
|
||||
routes::upload_pkg
|
||||
routes::push::upload_pkg,
|
||||
routes::user::login,
|
||||
routes::user::login_post,
|
||||
routes::user::account_page,
|
||||
routes::ui::pkg_ui,
|
||||
routes::ui::repo_ui,
|
||||
routes::user::new_api_key,
|
||||
routes::user::end_session,
|
||||
routes::user::change_password,
|
||||
routes::user::change_password_post
|
||||
])
|
||||
}
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Cursor, Read},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tar::Archive;
|
||||
|
||||
use super::{Repository, arch::Architecture};
|
||||
|
||||
|
@ -6,11 +12,11 @@ use super::{Repository, arch::Architecture};
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct Package {
|
||||
/// Repository of the package
|
||||
repo: String,
|
||||
pub repo: String,
|
||||
/// `Architecture` of the package
|
||||
arch: Architecture,
|
||||
pub arch: Architecture,
|
||||
/// Name of the package
|
||||
name: String,
|
||||
pub name: String,
|
||||
/// Version of the package
|
||||
version: Option<String>,
|
||||
}
|
||||
|
@ -20,12 +26,78 @@ impl Package {
|
|||
pub fn new(repo: &str, arch: Architecture, pkg_name: &str, version: &str) -> Self {
|
||||
Package {
|
||||
repo: repo.to_string(),
|
||||
arch: arch,
|
||||
arch,
|
||||
name: pkg_name.to_string(),
|
||||
version: Some(version.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_script(&self) -> Option<String> {
|
||||
let pkg = self.base_path().join(self.file_name());
|
||||
read_file_tar(&pkg, ".INSTALL")
|
||||
}
|
||||
|
||||
pub fn file_list(&self) -> Vec<String> {
|
||||
list_tar_file(&self.base_path().join(self.file_name())).unwrap()
|
||||
}
|
||||
|
||||
pub fn binaries(&self) -> Vec<String> {
|
||||
list_tar_file(&self.base_path().join(self.file_name()))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
let mut paths: Vec<_> = x.split("/").collect();
|
||||
paths.reverse();
|
||||
let parent = paths.get(1)?;
|
||||
if (*parent == "bin" || *parent == "sbin") && !x.ends_with("/") {
|
||||
return Some(x);
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pkginfo(&self) -> Vec<(String, String)> {
|
||||
let content = read_file_tar(&self.base_path().join(self.file_name()), ".PKGINFO").unwrap();
|
||||
let mut ret: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
for line in content.split("\n") {
|
||||
if line.starts_with('#') || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (key, val) = line.split_once(" = ").unwrap();
|
||||
|
||||
if let Some(e) = ret.iter_mut().find(|x| x.0 == key) {
|
||||
e.1.push(val.to_string());
|
||||
} else {
|
||||
ret.push((key.to_string(), vec![val.to_string()]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut ret: Vec<_> = ret.into_iter().map(|x| (x.0, x.1.join(" "))).collect();
|
||||
ret.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn arch(&self) -> Vec<Architecture> {
|
||||
let mut ret = Vec::new();
|
||||
for a in [
|
||||
Architecture::x86_64,
|
||||
Architecture::aarch64,
|
||||
Architecture::any,
|
||||
] {
|
||||
let check_pkg = self.switch_arch(a.clone());
|
||||
if check_pkg.exists() {
|
||||
ret.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
/// Extract values from a package filename
|
||||
///
|
||||
/// # Example
|
||||
|
@ -47,10 +119,12 @@ impl Package {
|
|||
/// ```
|
||||
pub fn extract_pkg_name(file_name: &str) -> Option<(String, String, String, Architecture)> {
|
||||
// Extract (assuming the filename is "<pkg_name>-<version>-<relation>-<arch>.pkg.tar.zst")
|
||||
let file_name = file_name.trim_end_matches(".sig").to_string();
|
||||
|
||||
let mut splitted = file_name.split('-').collect::<Vec<&str>>();
|
||||
|
||||
let arch = splitted.pop()?;
|
||||
assert!(arch.ends_with(".pkg.tar.zst"));
|
||||
assert!(arch.ends_with(".pkg.tar.zst"), "{file_name}");
|
||||
let arch = arch.trim_end_matches(".pkg.tar.zst");
|
||||
|
||||
let relation = splitted.pop()?;
|
||||
|
@ -58,12 +132,12 @@ impl Package {
|
|||
|
||||
let pkg_name = splitted.join("-");
|
||||
|
||||
return Some((
|
||||
Some((
|
||||
pkg_name,
|
||||
version.to_string(),
|
||||
relation.to_string(),
|
||||
Architecture::parse(arch)?,
|
||||
));
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse a pkg filename
|
||||
|
@ -78,25 +152,52 @@ impl Package {
|
|||
}
|
||||
|
||||
/// Find a package with latest version
|
||||
pub fn find(repo: &str, arch: &str, pkg_name: &str) -> Self {
|
||||
pub fn find(repo: &str, arch: Architecture, pkg_name: &str) -> Option<Self> {
|
||||
let mut base = Package {
|
||||
repo: repo.to_string(),
|
||||
arch: Architecture::parse(arch).unwrap(),
|
||||
arch,
|
||||
name: pkg_name.to_string(),
|
||||
version: None,
|
||||
};
|
||||
|
||||
let versions = base.versions();
|
||||
let ver = versions.first().unwrap();
|
||||
let ver = versions.first()?;
|
||||
|
||||
base.version = Some(ver.clone());
|
||||
|
||||
base
|
||||
Some(base)
|
||||
}
|
||||
|
||||
pub fn systemd_units(&self) -> Vec<String> {
|
||||
// TODO : Extract unit infos
|
||||
list_tar_file(&self.base_path().join(self.file_name()))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
let ext = x.split(".").last().unwrap();
|
||||
ext == "service" || ext == "timer" || ext == "mount"
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pacman_hooks(&self) -> Vec<(String, String)> {
|
||||
let pkg_file = self.base_path().join(self.file_name());
|
||||
let files = list_tar_file(&pkg_file).unwrap_or_default();
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
x.starts_with("etc/pacman.d/hooks/") || x.starts_with("usr/share/libalpm/hooks/")
|
||||
})
|
||||
.map(|x| {
|
||||
let content = read_file_tar(&pkg_file, &x).unwrap();
|
||||
(x, content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save a new package to repository
|
||||
pub fn save(&self, pkg: Vec<u8>, sig: Option<Vec<u8>>) {
|
||||
let pkg_file = self.base_path().join(&self.file_name());
|
||||
let pkg_file = self.base_path().join(self.file_name());
|
||||
let sig_file = self.base_path().join(format!("{}.sig", self.file_name()));
|
||||
|
||||
std::fs::write(&pkg_file, pkg).unwrap();
|
||||
|
@ -106,7 +207,7 @@ impl Package {
|
|||
|
||||
let db_file = PathBuf::from("./data")
|
||||
.join(&self.repo)
|
||||
.join(&self.arch.to_string())
|
||||
.join(self.arch.to_string())
|
||||
.join(format!("{}.db.tar.gz", self.repo));
|
||||
|
||||
repo_add(db_file.to_str().unwrap(), pkg_file.to_str().unwrap());
|
||||
|
@ -122,7 +223,7 @@ impl Package {
|
|||
|
||||
let db_file = PathBuf::from("./data")
|
||||
.join(&self.repo)
|
||||
.join(&arch.to_string())
|
||||
.join(arch.to_string())
|
||||
.join(format!("{}.db.tar.gz", self.repo));
|
||||
|
||||
repo_add(db_file.to_str().unwrap(), pkg_file.to_str().unwrap());
|
||||
|
@ -134,16 +235,16 @@ impl Package {
|
|||
// <repo>/<arch>/<pkg>/
|
||||
let p = Path::new("./data")
|
||||
.join(&self.repo)
|
||||
.join(&self.arch.to_string())
|
||||
.join(self.arch.to_string())
|
||||
.join(&self.name);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Switch the `Architecture` of the package
|
||||
pub fn switch_arch(&self, arch: &str) -> Self {
|
||||
pub fn switch_arch(&self, arch: Architecture) -> Self {
|
||||
let mut new = self.clone();
|
||||
new.arch = Architecture::parse(arch).unwrap();
|
||||
new.arch = arch;
|
||||
new
|
||||
}
|
||||
|
||||
|
@ -236,3 +337,44 @@ pub fn run_command(cmd: Vec<&str>) {
|
|||
pub fn repo_add(db_file: &str, pkg_file: &str) {
|
||||
run_command(vec!["repo-add", db_file, pkg_file]);
|
||||
}
|
||||
|
||||
pub fn read_file_tar(tar: &PathBuf, file_path: &str) -> Option<String> {
|
||||
let mut file = File::open(tar).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).unwrap();
|
||||
let uncompressed = zstd::decode_all(Cursor::new(buf)).unwrap();
|
||||
let content = Cursor::new(uncompressed);
|
||||
let mut a = Archive::new(content);
|
||||
|
||||
for e in a.entries().ok()? {
|
||||
let mut e = e.ok()?;
|
||||
let path = e.path().unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
if path == file_path {
|
||||
let mut file_content = Vec::new();
|
||||
e.read_to_end(&mut file_content).unwrap();
|
||||
return String::from_utf8(file_content).ok();
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn list_tar_file(tar: &PathBuf) -> Option<Vec<String>> {
|
||||
let mut file = File::open(tar).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).unwrap();
|
||||
let uncompressed = zstd::decode_all(Cursor::new(buf)).unwrap();
|
||||
let content = Cursor::new(uncompressed);
|
||||
let mut a = Archive::new(content);
|
||||
let mut paths = Vec::new();
|
||||
|
||||
for e in a.entries().ok()? {
|
||||
let e = e.ok()?;
|
||||
let path = e.path().ok()?;
|
||||
let path = path.to_str()?;
|
||||
paths.push(path.to_string());
|
||||
}
|
||||
|
||||
Some(paths)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use std::path::PathBuf;
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
|
||||
use super::{Package, arch::Architecture};
|
||||
|
||||
|
@ -68,7 +68,7 @@ impl Repository {
|
|||
pub fn base_path(&self, arch: Architecture) -> PathBuf {
|
||||
PathBuf::from("./data")
|
||||
.join(&self.name)
|
||||
.join(&arch.to_string())
|
||||
.join(arch.to_string())
|
||||
}
|
||||
|
||||
/// Get the `.db.tar.gz` content for the repository of `arch`
|
||||
|
@ -89,23 +89,42 @@ impl Repository {
|
|||
.ok()
|
||||
}
|
||||
|
||||
pub fn get_pkg(&self, pkg_name: &str) -> Option<Package> {
|
||||
// Normalize name
|
||||
let pkg_name = if pkg_name.ends_with(".sig") {
|
||||
pkg_name.trim_end_matches(".sig").to_string()
|
||||
} else {
|
||||
pkg_name.to_string()
|
||||
};
|
||||
pub fn list_pkg(&self) -> Vec<String> {
|
||||
let mut packages = HashSet::new();
|
||||
|
||||
for arch in self.arch() {
|
||||
for entry in std::fs::read_dir(self.base_path(arch)).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = path.file_name().unwrap().to_str().unwrap().to_string();
|
||||
|
||||
if entry.metadata().unwrap().is_dir() {
|
||||
packages.insert(file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pkg: Vec<_> = packages.into_iter().collect();
|
||||
pkg.sort();
|
||||
|
||||
pkg
|
||||
}
|
||||
|
||||
pub fn get_pkg_by_name(&self, pkg_name: &str) -> Option<Package> {
|
||||
for arch in self.arch() {
|
||||
if let Some(pkg) = Package::find(&self.name, arch, pkg_name) {
|
||||
return Some(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_pkg(&self, pkg_name: &str) -> Option<Package> {
|
||||
// Find package
|
||||
let (name, version, _, arch) = Package::extract_pkg_name(&pkg_name).unwrap();
|
||||
let (name, version, _, arch) = Package::extract_pkg_name(pkg_name).unwrap();
|
||||
let pkg = Package::new(&self.name, arch, &name, &version);
|
||||
|
||||
// Return if exists
|
||||
if pkg.exists() {
|
||||
return Some(pkg);
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if pkg.exists() { Some(pkg) } else { None }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,71 +1,44 @@
|
|||
use based::request::api::{FallibleApiResponse, api_error};
|
||||
use based::request::{RawResponse, RequestContext, respond_with};
|
||||
use based::auth::MaybeUser;
|
||||
use based::page::{Shell, htmx_link, render_page};
|
||||
use based::request::{RawResponse, RequestContext, StringResponse, respond_with};
|
||||
use maud::{PreEscaped, html};
|
||||
use rocket::get;
|
||||
use rocket::http::{ContentType, Status};
|
||||
use rocket::tokio::io::AsyncReadExt;
|
||||
use rocket::{FromForm, get, post};
|
||||
use serde_json::json;
|
||||
|
||||
use pacco::pkg::Repository;
|
||||
use pacco::pkg::arch::Architecture;
|
||||
use pacco::pkg::{Package, Repository};
|
||||
|
||||
// /pkg/<repo>/<arch>/<pkg_name>
|
||||
// /pkg/<repo>/<arch>/
|
||||
pub mod push;
|
||||
pub mod ui;
|
||||
pub mod user;
|
||||
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::TempFile;
|
||||
#[get("/")]
|
||||
pub async fn index_page(ctx: RequestContext, user: MaybeUser) -> StringResponse {
|
||||
let repos: Vec<String> = Repository::list();
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct PkgUpload<'r> {
|
||||
name: String,
|
||||
arch: String,
|
||||
version: String,
|
||||
pkg: TempFile<'r>,
|
||||
sig: Option<TempFile<'r>>,
|
||||
}
|
||||
|
||||
pub async fn tmp_file_to_vec<'r>(tmp: &TempFile<'r>) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(tmp.len() as usize);
|
||||
tmp.open()
|
||||
.await
|
||||
.unwrap()
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[post("/pkg/<repo>/upload", data = "<upload>")]
|
||||
pub async fn upload_pkg(
|
||||
repo: &str,
|
||||
upload: Form<PkgUpload<'_>>,
|
||||
user: based::auth::APIUser,
|
||||
) -> FallibleApiResponse {
|
||||
// TODO : Permission System
|
||||
if !user.0.is_admin() {
|
||||
return Err(api_error("Forbidden"));
|
||||
}
|
||||
|
||||
let pkg = Package::new(
|
||||
repo,
|
||||
Architecture::parse(&upload.arch).ok_or_else(|| api_error("Invalid architecture"))?,
|
||||
&upload.name,
|
||||
&upload.version,
|
||||
let content = html!(
|
||||
div class="flex justify-between" {
|
||||
h1 class="text-4xl font-bold pb-6" { "Repositories" };
|
||||
@if let Some(user) = user.take_user() {
|
||||
a class="text-lg" href="/account" { (user.username) };
|
||||
};
|
||||
};
|
||||
div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" {
|
||||
@for repo in repos {
|
||||
(htmx_link(&format!("/{repo}"), "flex items-center gap-4 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg shadow-md transition hover:bg-gray-200 dark:hover:bg-gray-600", "", html! {
|
||||
p class="font-medium flex-1 text-gray-800 dark:text-gray-100" {
|
||||
(repo)
|
||||
};
|
||||
}))
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
pkg.save(
|
||||
tmp_file_to_vec(&upload.pkg).await,
|
||||
if let Some(sig) = &upload.sig {
|
||||
Some(tmp_file_to_vec(sig).await)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
|
||||
Ok(json!({"ok": format!("Added '{}' to '{}'", pkg.file_name(), repo)}))
|
||||
render(content, "Repositories", ctx).await
|
||||
}
|
||||
|
||||
#[get("/pkg/<repo>/<arch>/<pkg_name>")]
|
||||
pub async fn pkg_route(repo: &str, arch: &str, pkg_name: &str, ctx: RequestContext) -> RawResponse {
|
||||
pub async fn pkg_route(repo: &str, arch: &str, pkg_name: &str) -> RawResponse {
|
||||
let arch = Architecture::parse(arch).unwrap();
|
||||
|
||||
if let Some(repo) = Repository::new(repo) {
|
||||
|
@ -112,3 +85,25 @@ pub async fn pkg_route(repo: &str, arch: &str, pkg_name: &str, ctx: RequestConte
|
|||
"Not found".as_bytes().to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn render(
|
||||
content: PreEscaped<String>,
|
||||
title: &str,
|
||||
ctx: RequestContext,
|
||||
) -> StringResponse {
|
||||
render_page(
|
||||
content,
|
||||
title,
|
||||
ctx,
|
||||
&Shell::new(
|
||||
html! {
|
||||
script src="https://cdn.tailwindcss.com" {};
|
||||
script src="/assets/htmx.min.js" {};
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
},
|
||||
html! {},
|
||||
Some("bg-gray-900 text-gray-200 min-h-screen p-10".to_string()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
63
src/routes/push.rs
Normal file
63
src/routes/push.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
use based::request::api::{FallibleApiResponse, api_error};
|
||||
use rocket::tokio::io::AsyncReadExt;
|
||||
use rocket::{FromForm, post};
|
||||
use serde_json::json;
|
||||
|
||||
use pacco::pkg::Package;
|
||||
use pacco::pkg::arch::Architecture;
|
||||
|
||||
// /pkg/<repo>/<arch>/<pkg_name>
|
||||
// /pkg/<repo>/<arch>/
|
||||
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::TempFile;
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct PkgUpload<'r> {
|
||||
name: String,
|
||||
arch: String,
|
||||
version: String,
|
||||
pkg: TempFile<'r>,
|
||||
sig: Option<TempFile<'r>>,
|
||||
}
|
||||
|
||||
pub async fn tmp_file_to_vec<'r>(tmp: &TempFile<'r>) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(tmp.len() as usize);
|
||||
tmp.open()
|
||||
.await
|
||||
.unwrap()
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[post("/pkg/<repo>/upload", data = "<upload>")]
|
||||
pub async fn upload_pkg(
|
||||
repo: &str,
|
||||
upload: Form<PkgUpload<'_>>,
|
||||
user: based::auth::APIUser,
|
||||
) -> FallibleApiResponse {
|
||||
// TODO : Permission System
|
||||
if !user.0.is_admin() {
|
||||
return Err(api_error("Forbidden"));
|
||||
}
|
||||
|
||||
let pkg = Package::new(
|
||||
repo,
|
||||
Architecture::parse(&upload.arch).ok_or_else(|| api_error("Invalid architecture"))?,
|
||||
&upload.name,
|
||||
&upload.version,
|
||||
);
|
||||
|
||||
pkg.save(
|
||||
tmp_file_to_vec(&upload.pkg).await,
|
||||
if let Some(sig) = &upload.sig {
|
||||
Some(tmp_file_to_vec(sig).await)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
|
||||
Ok(json!({"ok": format!("Added '{}' to '{}'", pkg.file_name(), repo)}))
|
||||
}
|
292
src/routes/ui.rs
Normal file
292
src/routes/ui.rs
Normal file
|
@ -0,0 +1,292 @@
|
|||
use based::{
|
||||
page::htmx_link,
|
||||
request::{RequestContext, StringResponse},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
use rocket::get;
|
||||
|
||||
use pacco::pkg::Repository;
|
||||
|
||||
use super::render;
|
||||
|
||||
// TODO : API
|
||||
|
||||
#[get("/<repo>/<pkg_name>")]
|
||||
pub async fn pkg_ui(repo: &str, pkg_name: &str, ctx: RequestContext) -> Option<StringResponse> {
|
||||
// TODO : Implement pkg UI
|
||||
// pkgmeta display
|
||||
|
||||
let repo = Repository::new(repo).unwrap();
|
||||
let pkg = repo.get_pkg_by_name(pkg_name)?;
|
||||
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 = html! {
|
||||
// Package Name
|
||||
div class="flex flex-wrap gap-2 justify-center items-center" {
|
||||
(htmx_link(&format!("/{}", repo.name), "text-3xl font-bold text-gray-800 dark:text-gray-100", "", html! {
|
||||
(repo.name)
|
||||
}))
|
||||
p class="font-bold p-2 text-gray-400" { "/" };
|
||||
h1 class="text-3xl font-bold text-gray-800 dark:text-gray-100 pr-2" {
|
||||
(pkg.name)
|
||||
};
|
||||
|
||||
@if pkg.is_signed() {
|
||||
div class="flex items-center gap-2 text-slate-300 pr-4" {
|
||||
span class="text-2xl font-bold" {
|
||||
"✓"
|
||||
}
|
||||
span class="text-sm font-medium" { "Signed" }
|
||||
// TODO : Add more info: Who signed? + Public Key recv
|
||||
}
|
||||
}
|
||||
|
||||
@for arch in arch {
|
||||
span class="px-3 py-1 text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full" {
|
||||
(arch.to_string())
|
||||
};
|
||||
}
|
||||
|
||||
a href=(format!("/pkg/{}/{}/{}", pkg.repo, pkg.arch.to_string(), pkg.file_name())) class="ml-4 inline-flex items-center px-2 py-2 bg-gray-200 text-black font-xs rounded-lg shadow-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" {
|
||||
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" { "Download" };
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
div class="flex pt-6" {
|
||||
div class="space-y-2 p-4 bg-gray-50 dark:bg-gray-800 shadow-lg rounded-lg transition flex-1 mr-4" {
|
||||
h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 underline" { "Info" };
|
||||
|
||||
@if let Some(desc) = take_out(&mut pkginfo, |x| { x.0 == "pkgdesc" }).1 {
|
||||
(build_info(desc.0, desc.1))
|
||||
}
|
||||
|
||||
@if let Some(desc) = take_out(&mut pkginfo, |x| { x.0 == "packager" }).1 {
|
||||
(build_info(desc.0, desc.1))
|
||||
}
|
||||
|
||||
@if let Some(desc) = take_out(&mut pkginfo, |x| { x.0 == "url" }).1 {
|
||||
(build_info(desc.0, desc.1))
|
||||
}
|
||||
|
||||
@if let Some(desc) = take_out(&mut pkginfo, |x| { x.0 == "size" }).1 {
|
||||
(build_info(desc.0, desc.1))
|
||||
}
|
||||
|
||||
@for (key, val) in pkginfo {
|
||||
(build_info(key, val))
|
||||
};
|
||||
};
|
||||
|
||||
div class="space-y-2 max-w-80 p-4 bg-gray-50 dark:bg-gray-800 shadow-lg rounded-lg transition" {
|
||||
h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300" { "Versions" }
|
||||
ul class="space-y-1" {
|
||||
@for version in versions {
|
||||
// TODO : Implement page per version ?version=
|
||||
li class="text-gray-800 dark:text-gray-100 hover:text-blue-500 dark:hover:text-blue-400 transition" {
|
||||
(version)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
div class="flex flex-wrap pt-6" {
|
||||
div class="space-y-2" {
|
||||
h2 class="text-xl font-bold text-gray-700 dark:text-gray-300" { "Content" }
|
||||
|
||||
div class="flex" {
|
||||
@if !systemd_units.is_empty() {
|
||||
div class="space-y-2 p-4 bg-gray-50 dark:bg-gray-800 shadow-lg rounded-lg transition mr-4" {
|
||||
h3 class="text-lg font-medium text-gray-800 dark:text-gray-100 underline" { "Systemd Units" }
|
||||
ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1" {
|
||||
@for unit in systemd_units {
|
||||
li { (unit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if !pacman_hooks.is_empty() {
|
||||
div class="space-y-2 p-4 bg-gray-50 dark:bg-gray-800 shadow-lg rounded-lg transition mr-4" {
|
||||
h3 class="text-lg font-medium text-gray-800 dark:text-gray-100 underline" { "Pacman Hooks" }
|
||||
ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1" {
|
||||
@for hook in pacman_hooks {
|
||||
h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300" { (hook.0) }
|
||||
|
||||
pre class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg text-gray-800 dark:text-gray-100 overflow-x-auto text-sm" {
|
||||
(hook.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if !binaries.is_empty() {
|
||||
div class="space-y-2 p-4 bg-gray-50 dark:bg-gray-800 shadow-lg rounded-lg transition mr-4" {
|
||||
h3 class="text-lg font-medium text-gray-800 dark:text-gray-100 underline" { "Binaries" }
|
||||
ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1" {
|
||||
@for binary in binaries {
|
||||
li { (binary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div class="space-y-2 p-4 bg-gray-50 dark:bg-gray-800 shadow-lg rounded-lg transition mr-4" {
|
||||
h3 class="text-lg font-medium text-gray-800 dark:text-gray-100 underline" { "Package Files" }
|
||||
ul class="list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1" {
|
||||
@for file in pkg.file_list() {
|
||||
li { (file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Install Scripts
|
||||
@if let Some(install_script) = install_script {
|
||||
div class="space-y-4 pt-6" {
|
||||
h2 class="text-3xl font-semibold text-gray-700 dark:text-gray-300" { "Install Scripts" }
|
||||
pre class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg text-gray-800 dark:text-gray-100 overflow-x-auto text-sm" {
|
||||
(install_script)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Some(render(content, pkg_name, ctx).await)
|
||||
}
|
||||
|
||||
#[get("/<repo>")]
|
||||
pub async fn repo_ui(repo: &str, ctx: RequestContext) -> StringResponse {
|
||||
// TODO : Repo UI
|
||||
// permissions
|
||||
// pkg list
|
||||
|
||||
let repo = Repository::new(repo).unwrap();
|
||||
let architectures: Vec<_> = repo.arch().into_iter().map(|x| x.to_string()).collect();
|
||||
let packages = repo.list_pkg();
|
||||
|
||||
let content = html! {
|
||||
// Repository name and architectures
|
||||
div class="flex flex-wrap items-center justify-center mb-6" {
|
||||
h1 class="text-3xl font-bold text-gray-800 dark:text-gray-100 pr-4" {
|
||||
(repo.name)
|
||||
};
|
||||
|
||||
div class="flex gap-2 mt-2 md:mt-0" {
|
||||
@for arch in architectures {
|
||||
span class="px-3 py-1 text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full" {
|
||||
(arch)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Package list
|
||||
ul class="space-y-4" {
|
||||
@for pkg in packages {
|
||||
(htmx_link(&format!("/{}/{pkg}", repo.name), "flex items-center gap-4 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg shadow-md transition hover:bg-gray-200 dark:hover:bg-gray-600", "", html! {
|
||||
div class="w-10 h-10 flex items-center justify-center rounded-full bg-blue-500 text-white font-semibold" {
|
||||
{(pkg.chars().next().unwrap_or_default().to_uppercase())}
|
||||
};
|
||||
p class="font-medium flex-1 text-gray-800 dark:text-gray-100" {
|
||||
(pkg)
|
||||
};
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render(content, &repo.name, ctx).await
|
||||
}
|
||||
|
||||
pub fn build_info(key: String, value: String) -> PreEscaped<String> {
|
||||
match key.as_str() {
|
||||
"pkgname" => {}
|
||||
"xdata" => {}
|
||||
"arch" => {}
|
||||
"pkbase" => {}
|
||||
"pkgver" => {}
|
||||
"pkgdesc" => {
|
||||
return key_value("Description".to_string(), value);
|
||||
}
|
||||
"packager" => {
|
||||
return key_value("Packager".to_string(), value);
|
||||
}
|
||||
"url" => {
|
||||
return html! {
|
||||
div class="flex" {
|
||||
span class="font-bold w-32" { (format!("Website: ")) };
|
||||
a class="ml-2 text-blue-400" href=(value) { (value) };
|
||||
};
|
||||
};
|
||||
}
|
||||
"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" => {
|
||||
// TODO : Find package link
|
||||
return key_value("Depends on".to_string(), value);
|
||||
}
|
||||
"makedepend" => {
|
||||
// TODO : Find package link
|
||||
return key_value("Build Dependencies".to_string(), 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<String> {
|
||||
html! {
|
||||
div class="flex" {
|
||||
span class="font-bold w-32" { (format!("{key}: ")) };
|
||||
span class="ml-2" { (value) };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_out<T>(v: &mut Vec<T>, f: impl Fn(&T) -> bool) -> (&mut Vec<T>, Option<T>) {
|
||||
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)
|
||||
}
|
222
src/routes/user.rs
Normal file
222
src/routes/user.rs
Normal file
|
@ -0,0 +1,222 @@
|
|||
use based::{
|
||||
auth::{Session, Sessions, User, csrf::CSRF},
|
||||
page::htmx_link,
|
||||
request::{RequestContext, StringResponse, api::to_uuid, respond_html},
|
||||
};
|
||||
use maud::{PreEscaped, html};
|
||||
use rocket::{
|
||||
FromForm,
|
||||
form::Form,
|
||||
get,
|
||||
http::{Cookie, CookieJar},
|
||||
post,
|
||||
response::Redirect,
|
||||
};
|
||||
|
||||
use super::render;
|
||||
|
||||
#[get("/login")]
|
||||
pub async fn login(ctx: RequestContext) -> StringResponse {
|
||||
let content = html! {
|
||||
div class="min-w-screen justify-center flex items-center" {
|
||||
div class="bg-gray-800 shadow-lg rounded-lg p-8 max-w-sm w-full" {
|
||||
h2 class="text-2xl font-bold text-center text-gray-100 mb-6" { "Login" };
|
||||
form action="/login" method="POST" {
|
||||
div class="mb-4" {
|
||||
label for="email" class="block text-sm font-medium text-gray-400" { "Email" };
|
||||
input type="text" id="username" name="username" placeholder="Username" class="w-full mt-1 px-4 py-2 border border-gray-700 bg-gray-700 text-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" {};
|
||||
};
|
||||
div class="mb-4" {
|
||||
label for="password" class="block text-sm font-medium text-gray-400" { "Password" };
|
||||
input type="password" id="password" name="password" placeholder="Password" class="w-full mt-1 px-4 py-2 border border-gray-700 bg-gray-700 text-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" {};
|
||||
};
|
||||
button type="submit" class="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400" { "Login" };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
render(content, "Login", ctx).await
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[post("/login", data = "<form>")]
|
||||
pub async fn login_post(form: Form<LoginForm>, cookies: &CookieJar<'_>) -> Redirect {
|
||||
if let Some(user) = User::login(&form.username, &form.password).await {
|
||||
let session_cookie = Cookie::build(("session", user.0.token.to_string()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.max_age(rocket::time::Duration::days(7))
|
||||
.build();
|
||||
|
||||
cookies.add(session_cookie);
|
||||
|
||||
Redirect::to("/")
|
||||
} else {
|
||||
Redirect::to("/login")
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/end_session/<session_id>?<csrf>")]
|
||||
pub async fn end_session(session_id: &str, csrf: &str, user: User) -> StringResponse {
|
||||
if user.verify_csrf(csrf).await {
|
||||
user.end_session(&to_uuid(session_id).unwrap()).await;
|
||||
respond_html(
|
||||
html! {
|
||||
(user.update_csrf().await)
|
||||
}
|
||||
.into_string(),
|
||||
)
|
||||
} else {
|
||||
respond_html(html! { p { "Invalid CSRF" }}.into_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/new_api_key?<csrf>&<session_name>")]
|
||||
pub async fn new_api_key(user: User, csrf: &str, session_name: &str) -> StringResponse {
|
||||
if user.verify_csrf(csrf).await {
|
||||
if session_name.is_empty() {
|
||||
return respond_html(
|
||||
html! {
|
||||
div id="next_session" {};
|
||||
(user.update_csrf().await)
|
||||
}
|
||||
.into_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let api = user.api_key(session_name).await;
|
||||
respond_html(
|
||||
html! {
|
||||
li class="justify-between items-center bg-gray-50 p-4 rounded shadow" {
|
||||
span class="text-gray-700" { (api.name.unwrap_or_default()) };
|
||||
br {};
|
||||
span class="text-red-500" { (api.token) };
|
||||
};
|
||||
|
||||
div id="next_session" {};
|
||||
(user.update_csrf().await)
|
||||
}
|
||||
.into_string(),
|
||||
)
|
||||
} else {
|
||||
respond_html(
|
||||
html! {
|
||||
p { "CSRF!" };
|
||||
}
|
||||
.into_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/account")]
|
||||
pub async fn account_page(user: User, ctx: RequestContext) -> StringResponse {
|
||||
let sessions = user.list_sessions().await;
|
||||
|
||||
let content = html! {
|
||||
main class="w-full mt-6 rounded-lg shadow-md p-6" {
|
||||
section class="mb-6" {
|
||||
h2 class="text-xl font-semibold mb-2" { (user.username) };
|
||||
};
|
||||
|
||||
(htmx_link("/passwd", "mb-6 bg-green-500 text-white py-2 px-6 rounded hover:bg-green-600", "", html! { "Change Password" }))
|
||||
|
||||
section class="mb-6 mt-6" {
|
||||
h3 class="text-lg font-semibold mb-4" { "Active Sessions" };
|
||||
ul class="space-y-4" id="sessions-list" {
|
||||
|
||||
@for ses in sessions {
|
||||
(build_session_block(&ses, &user).await)
|
||||
};
|
||||
|
||||
div id="next_session" {};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
form class="flex" hx-get="/new_api_key" hx-target="#next_session"
|
||||
hx-swap="outerHTML" {
|
||||
input type="text" name="session_name" placeholder="API Key Name" class="text-black bg-gray-100 px-4 py-2 rounded mr-4" {};
|
||||
input type="hidden" class="csrf" name="csrf" value=(user.get_csrf().await) {};
|
||||
button class="bg-green-500 text-white py-2 px-6 rounded hover:bg-green-600" { "New API Key" };
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
render(content, "Account", ctx).await
|
||||
}
|
||||
|
||||
pub async fn build_session_block(ses: &Session, user: &User) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex justify-between items-center bg-gray-50 p-4 rounded shadow" {
|
||||
span class="text-gray-700" { (ses.name.clone().unwrap_or("Session".to_string())) };
|
||||
|
||||
form hx-target="closest li" hx-swap="outerHTML" hx-get=(format!("/end_session/{}", ses.id)) {
|
||||
input type="hidden" class="csrf" name="csrf" value=(user.get_csrf().await) {};
|
||||
button class="bg-red-500 text-white py-1 px-4 rounded hover:bg-red-600"
|
||||
{ "Kill" };
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct PasswordChangeForm {
|
||||
password_old: String,
|
||||
password_new: String,
|
||||
password_new_repeat: String,
|
||||
csrf: String,
|
||||
}
|
||||
|
||||
// TODO : Change password pages
|
||||
|
||||
#[post("/passwd", data = "<form>")]
|
||||
pub async fn change_password_post(form: Form<PasswordChangeForm>, user: User) -> Redirect {
|
||||
if form.password_new != form.password_new_repeat {
|
||||
return Redirect::to("/passwd");
|
||||
}
|
||||
|
||||
if !user.verify_csrf(&form.csrf).await {
|
||||
return Redirect::to("/passwd");
|
||||
}
|
||||
|
||||
user.passwd(&form.password_old, &form.password_new)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Redirect::to("/account")
|
||||
}
|
||||
|
||||
#[get("/passwd")]
|
||||
pub async fn change_password(ctx: RequestContext, user: User) -> StringResponse {
|
||||
let content = html! {
|
||||
div class="min-w-screen justify-center flex items-center" {
|
||||
div class="bg-gray-800 shadow-lg rounded-lg p-8 max-w-sm w-full" {
|
||||
h2 class="text-2xl font-bold text-center text-gray-100 mb-6" { "Change Password" };
|
||||
form action="/passwd" method="POST" {
|
||||
div class="mb-4" {
|
||||
label for="password_old" class="block text-sm font-medium text-gray-400" { "Old Password" };
|
||||
input type="password" id="password_old" name="password_old" placeholder="Old Password" class="w-full mt-1 px-4 py-2 border border-gray-700 bg-gray-700 text-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" {};
|
||||
};
|
||||
div class="mb-4" {
|
||||
label for="password_new" class="block text-sm font-medium text-gray-400" { "New Password" };
|
||||
input type="password" id="password_new" name="password_new" placeholder="Password" class="w-full mt-1 px-4 py-2 border border-gray-700 bg-gray-700 text-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" {};
|
||||
};
|
||||
div class="mb-4" {
|
||||
label for="password_new_repeat" class="block text-sm font-medium text-gray-400" { "Repeat new Password" };
|
||||
input type="password" id="password_new_repeat" name="password_new_repeat" placeholder="Password" class="w-full mt-1 px-4 py-2 border border-gray-700 bg-gray-700 text-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400" {};
|
||||
};
|
||||
input type="hidden" class="csrf" name="csrf" value=(user.get_csrf().await) {};
|
||||
button type="submit" class="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400" { "Login" };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
render(content, "Change Password", ctx).await
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue