implement user pages + ui
All checks were successful
ci/woodpecker/push/build Pipeline was successful

This commit is contained in:
JMARyA 2025-01-12 03:58:16 +01:00
parent 67c31725c1
commit 3fabc91438
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
11 changed files with 1021 additions and 264 deletions

View file

@ -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
View 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
View 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
View 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
}