From f94ec335dd0a14f0f0c13493de19f766b2b423d7 Mon Sep 17 00:00:00 2001 From: Kenneth Jao Date: Mon, 2 Jun 2025 03:47:42 -0400 Subject: [PATCH] feat: added navigation of uploaded files and browsing of inner sites; refactor: HTML/CSS/JS is now templated; --- Cargo.lock | 214 ++++++--- Cargo.toml | 12 +- README.md | 46 +- src/api.rs | 411 +++++++++++++--- src/compile.rs | 151 ++++-- src/main.rs | 41 +- src/util.rs | 125 ++--- templates/css/contentDefault.css | 172 +++++++ templates/css/contentFolder.css | 135 ++++++ templates/{index.css => css/mod/base.css} | 448 +++++------------- templates/css/notFound.css | 1 + templates/css/refresh.css | 1 + templates/html/contentDefault.html | 11 +- templates/html/contentFolder.html | 61 +++ templates/html/{ => mod}/base.html | 30 +- templates/html/{ => mod}/labelBot.svg | 0 templates/html/{ => mod}/labelTop.html | 18 +- templates/html/{ => mod}/svgTape.svg | 0 templates/html/notFound.html | 11 + templates/html/refresh.html | 9 + .../js/{default.js => contentDefault.js} | 73 +-- templates/js/contentFolder.js | 42 ++ templates/js/{ => mod}/base.js | 24 +- templates/js/mod/confirm.js | 22 + templates/js/mod/middleware.js | 19 + templates/js/{ => mod}/token.js | 3 +- templates/js/notFound.js | 1 + templates/js/refresh.js | 11 + 28 files changed, 1418 insertions(+), 674 deletions(-) create mode 100644 templates/css/contentDefault.css create mode 100644 templates/css/contentFolder.css rename templates/{index.css => css/mod/base.css} (51%) create mode 100644 templates/css/notFound.css create mode 100644 templates/css/refresh.css create mode 100644 templates/html/contentFolder.html rename templates/html/{ => mod}/base.html (53%) rename templates/html/{ => mod}/labelBot.svg (100%) rename templates/html/{ => mod}/labelTop.html (83%) rename templates/html/{ => mod}/svgTape.svg (100%) create mode 100644 templates/html/notFound.html create mode 100644 templates/html/refresh.html rename templates/js/{default.js => contentDefault.js} (74%) create mode 100644 templates/js/contentFolder.js rename templates/js/{ => mod}/base.js (72%) create mode 100644 templates/js/mod/confirm.js create mode 100644 templates/js/mod/middleware.js rename templates/js/{ => mod}/token.js (93%) create mode 100644 templates/js/notFound.js create mode 100644 templates/js/refresh.js diff --git a/Cargo.lock b/Cargo.lock index c748e26..f9bd510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -59,7 +74,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.100", + "syn", ] [[package]] @@ -74,6 +89,20 @@ dependencies = [ "winnow", ] +[[package]] +name = "async-compression" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -88,9 +117,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core", "bytes", @@ -122,13 +151,24 @@ dependencies = [ ] [[package]] -name = "axum-core" -version = "0.5.0" +name = "axum-auth" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +checksum = "93495037c01c639b198ecb926b58f7f1c0d61ae663edcd61b2dd679f2a0bffe6" +dependencies = [ + "axum-core", + "base64", + "http", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", @@ -141,6 +181,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -177,6 +240,27 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "brotli" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -189,6 +273,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytesize" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" + [[package]] name = "cc" version = "1.2.19" @@ -204,6 +294,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -220,6 +321,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -252,7 +362,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -264,16 +374,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "env_filter" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" -dependencies = [ - "log", - "regex", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -296,6 +396,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -659,7 +769,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -890,7 +1000,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -924,10 +1034,10 @@ dependencies = [ "anyhow", "askama", "axum", + "axum-auth", + "axum-extra", "bytes", - "env_filter", - "log", - "mime", + "bytesize", "rand", "reqwest", "serde", @@ -938,11 +1048,11 @@ dependencies = [ "tokio", "tower", "tower-http", - "trace", + "tower-service", "tracing", "tracing-appender", "tracing-subscriber", - "url", + "urlencoding", ] [[package]] @@ -1264,7 +1374,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -1400,17 +1510,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.100" @@ -1439,7 +1538,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -1502,7 +1601,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -1513,7 +1612,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -1591,7 +1690,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -1649,8 +1748,10 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", @@ -1680,17 +1781,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "trace" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ad0c048e114d19d1140662762bfdb10682f3bc806d8be18af846600214dd9af" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "tracing" version = "0.1.41" @@ -1723,7 +1813,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -1819,6 +1909,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -1895,7 +1991,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.100", + "syn", "wasm-bindgen-shared", ] @@ -1930,7 +2026,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2207,7 +2303,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", "synstructure", ] @@ -2228,7 +2324,7 @@ checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] [[package]] @@ -2248,7 +2344,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", "synstructure", ] @@ -2277,5 +2373,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn", ] diff --git a/Cargo.toml b/Cargo.toml index cc7b8a1..67a63a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,10 @@ authors = ["Kenneth Jao"] anyhow = "1.0.98" askama = { version = "0.14.0", features = ["blocks"] } axum = { version = "0.8.1", features = ["multipart"] } +axum-auth = "0.8.1" +axum-extra = { version = "0.10.1", features = ["cookie"] } bytes = "1.10.1" -env_filter = "0.1.3" -log = "0.4.27" -mime = "0.3.17" +bytesize = "2.0.1" rand = { version = "0.9.1", features = ["alloc"] } reqwest = { version = "0.12.15", features = ["json"] } serde = { version= "1.0.219", features = ["derive"] } @@ -21,9 +21,9 @@ sqlite = "0.37.0" thiserror = "2.0.12" tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } tower = "0.5.2" -tower-http = { version = "0.6.2", features = ["fs", "trace"] } -trace = "0.1.7" +tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "fs", "trace"] } +tower-service = "0.3.3" tracing = "0.1.41" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } -url = "2.5.4" +urlencoding = "2.1.3" diff --git a/README.md b/README.md index 27f851b..9c96972 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,46 @@ # pack -A web server written in [rust](https://www.rust-lang.org/) with [axum](https://docs.rs/axum/latest/axum/) for hosting built packages as a -destination endpoint for packages in a continuous integration workflow. It is designed around Gitea, using it for its authentication -as well as integrating with Gitea actions. +Pack is web server written in [rust](https://www.rust-lang.org/) with +[axum](https://docs.rs/axum/latest/axum/) for hosting built packages as a +destination endpoint for packages in a continuous integration workflow. It is +designed around Gitea, using it for its authentication as well as integrating +with Gitea actions. +## Setup +To setup the server, rename `pack.yml.template` to `pack.yml` and fill in the details +for your configuration. Place the built executable in a folder with properly configured +permissions, and make sure the upload path has permissions `750`. Indeed, all +subdirectories are created with this permissions, and all files are created with +`640`. + + +## API +The only API endpoint intended to be exposed is `/api/files/{owner}/{repo}/{*path}`, which +supports `GET` and `DELETE`. Every request requires an Bearer Authorization token, +which should be exactly the Gitea OAuth2 token. This API is intended for scripted management of uploaded +packages in the contiguous integration (CI) chain. For instance, automatically pulling +latest versions into a production environment. + +### GET +In the case, of GET, a JSON of the entries in a directory is returned. The format +is shown below. + +```json +{ + "entries": [ + { + name: "...", + name_uri: "...", + icon: "...", + size: "...", + }, + ... + ] +``` + +If the requested path is a file, it will redirect to download the file. If using `curl`, +make sure the `-L` flag is enabled to follow redirects. + +### DELETE +This will delete the file at the destination, or delete the *entire* directory. +Use with caution. diff --git a/src/api.rs b/src/api.rs index 0c68e89..7c18a2d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,22 +1,42 @@ -use std::{fs, fmt, path, slice::Iter}; +use std::{ + cmp::Ordering, + fs::{self, Permissions}, + fmt, + os::unix::fs::PermissionsExt, + path, + slice::Iter, + time::{Duration}, +}; use bytes::Bytes; +use bytesize::ByteSize; use std::process::Command; use rand::distr::{Alphanumeric, SampleString}; use thiserror::Error; use axum::{ body::Body, extract::{Path, Json, State, Multipart}, - http::{StatusCode, header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}}, - response::{IntoResponse, Response}, + http::{ + StatusCode, + header::{HeaderMap, CONTENT_TYPE, AUTHORIZATION}, + Uri, + }, + response::{IntoResponse, Response, Redirect, Html}, + middleware::Next, }; +use axum_auth::AuthBearer; +use axum_extra::extract::CookieJar; use reqwest::{ Client, Method, Request, RequestBuilder, Url }; use serde::{Serialize, Deserialize}; use serde_json::Value; +use tower_http::services::ServeFile; use crate::{query, ServerState}; +static FOLDER_PERM: u32 = 0o750; +static FILE_PERM: u32 = 0o640; + #[derive(Debug, Clone, Copy)] #[repr(u8)] pub enum RepoFeature { @@ -48,7 +68,7 @@ impl From for String { } impl TryFrom<&str> for RepoFeature { - type Error = APIError; + type Error = ApiError; fn try_from(value: &str) -> Result { for feature in RepoFeature::iter() { @@ -56,13 +76,30 @@ impl TryFrom<&str> for RepoFeature { return Ok(*feature); } } - Err(APIError::InvalidFeature { feature: value.to_owned() }) + Err(ApiError::InvalidFeature { feature: value.to_owned() }) } } +#[derive(Clone, Copy, Debug)] +#[repr(u16)] +pub enum ErrorCode { + InvalidToken = 0, // Invalid OAuth token. + External = 1, // Error originates from an external location/server. + Filesystem = 2, // Error originates from filesystem operation. + Sql = 3, // Error originates from SQL. + BadRequest = 4, // Error originates from client request. +} -#[derive(Error, Debug)] -pub enum APIError { +#[derive(Clone, Copy, Debug, Error)] +pub struct CustomError(&'static str, ErrorCode); +impl fmt::Display for CustomError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}, {:?}", self.0, self.1) + } +} + +#[derive(Debug, Error)] +pub enum ApiError { #[error("Request error: {0}")] RequestError(#[from] reqwest::Error), #[error("SQL error: {0}")] @@ -81,18 +118,18 @@ pub enum APIError { Unauthorized { repo: String }, #[error("A token was not provided or is malformed")] Tokenless, - #[error("{msg}")] - Other { msg: String }, + #[error("{0}")] + CustomError(#[from] CustomError) } -#[repr(u16)] -pub enum ErrorCode { - InvalidToken = 0, // Invalid OAuth token. - External = 1, // Error originates from an external location/server. - Filesystem = 2, // Error originates from filesystem operation. - Sql = 3, // Error originates from SQL. - BadRequest = 4, // Error originates from client request. -} +static ERR_GITEA_CONTENT_TYPE: CustomError = CustomError("Content-Type in Gitea response invalid", ErrorCode::External); +static ERR_MISSING_PARENT: CustomError = CustomError("Could not find parent in repo directory. Should be impossible...", ErrorCode::Filesystem); +static ERR_UNTAR: CustomError = CustomError("Failed to complete untar", ErrorCode::Filesystem); +static ERR_OS_STR_UTF: CustomError = CustomError("OsString failed to parse into UTF-8", ErrorCode::Filesystem); +static ERR_FILE_CALL_UTF: CustomError = CustomError("file command output failed to parse into UTF-8", ErrorCode::Filesystem); +static ERR_FILE_CALL_UNEXPECTED: CustomError = CustomError("file command output had unexpected output", ErrorCode::Filesystem); +pub static ERR_CONTENT_FOLDER_RENDER: CustomError = CustomError("Unable to process 'ContentFolder' template", ErrorCode::Filesystem); +static ERR_IMPOSSIBLE_ENTRY: CustomError = CustomError("Entry is neither file, directory or symlink", ErrorCode::Filesystem); #[derive(Serialize)] pub struct ErrorResponse { @@ -101,11 +138,11 @@ pub struct ErrorResponse { message: String, } -type Result = core::result::Result; -impl IntoResponse for APIError { +pub type Result = core::result::Result; +impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, code) = match self { - APIError::RequestError(ref err) => { + ApiError::RequestError(ref err) => { let status = err.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); let action = match status { StatusCode::FORBIDDEN => ErrorCode::InvalidToken, @@ -113,15 +150,15 @@ impl IntoResponse for APIError { }; (status, action) }, - APIError::SQLError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Sql), - APIError::IOError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Filesystem), - APIError::MultipartError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::BadRequest), - APIError::InvalidJson{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External), - APIError::InvalidFeature{..} => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest), - APIError::DisabledFeature{..} => (StatusCode::NOT_FOUND, ErrorCode::BadRequest), - APIError::Unauthorized{..} => (StatusCode::FORBIDDEN, ErrorCode::BadRequest), - APIError::Tokenless => (StatusCode::FORBIDDEN, ErrorCode::InvalidToken), - APIError::Other{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External), + ApiError::SQLError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Sql), + ApiError::IOError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Filesystem), + ApiError::MultipartError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::BadRequest), + ApiError::InvalidJson{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External), + ApiError::InvalidFeature{..} => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest), + ApiError::DisabledFeature{..} => (StatusCode::NOT_FOUND, ErrorCode::BadRequest), + ApiError::Unauthorized{..} => (StatusCode::FORBIDDEN, ErrorCode::BadRequest), + ApiError::Tokenless => (StatusCode::FORBIDDEN, ErrorCode::InvalidToken), + ApiError::CustomError(ref e) => (StatusCode::INTERNAL_SERVER_ERROR, e.1), }; (status, Json(ErrorResponse{ status: u16::from(status), @@ -160,7 +197,7 @@ async fn gitea_api(url: &str, method: Method, payload: &T, token: &str) -> content_type = res.headers().get(CONTENT_TYPE) .unwrap() .to_str() - .map_err(|_| APIError::Other { msg: "Content-Type in Gitea response invalid".to_owned() })? + .map_err(|_| ERR_GITEA_CONTENT_TYPE )? .to_owned(); } @@ -171,7 +208,7 @@ async fn gitea_api(url: &str, method: Method, payload: &T, token: &str) -> } } -async fn authorize(host: &str, token: &str, repo: &str) -> Result> { +async fn authorize(host: &str, token: &str, repo: &str, kind: &str) -> Result> { // Use repos API call to check admin permission. Return the repo JSON as // well in case it needs to be used later. let url = format!("{host}/api/v1/repos/{repo}"); @@ -179,15 +216,15 @@ async fn authorize(host: &str, token: &str, repo: &str) -> Result> { let json: Value = gitea_api(&url, Method::GET, &data, token).await?; - // If permission is not admin level, return error. + // If 'kind' permission is not true, return error. json.get("permissions") - .ok_or(APIError::InvalidJson{ msg: "Couldn't find 'permissions' key.".to_owned() })? - .get("admin") - .ok_or(APIError::InvalidJson{ msg: "Couldn't find 'admin' key.".to_owned() })? + .ok_or(ApiError::InvalidJson{ msg: "Couldn't find 'permissions' key.".to_owned() })? + .get(kind) + .ok_or(ApiError::InvalidJson{ msg: format!("Couldn't find '{kind}' key.") })? .as_bool() - .ok_or(APIError::InvalidJson{ msg: "Value in 'admin' is not bool.".to_owned() })? + .ok_or(ApiError::InvalidJson{ msg: format!("Value in '{kind}' is not bool.") })? .then_some(0) - .ok_or(APIError::Unauthorized{ repo: repo.to_owned() })?; + .ok_or(ApiError::Unauthorized{ repo: repo.to_owned() })?; Ok(Json(json)) } @@ -264,26 +301,17 @@ pub struct RepoResponse { features: FeatureList, } -fn extract_token(headers: HeaderMap) -> Result { - Ok(headers.get(AUTHORIZATION) - .ok_or(APIError::Tokenless)? - .to_str() - .map_err(|_| APIError::Tokenless)? - .to_owned()) -} - pub async fn get_repo(State(state): State>, Path((owner, repo)): Path<(String, String)>, - headers: HeaderMap) -> Result> { + AuthBearer(token): AuthBearer) -> Result> { let repo = format!("{owner}/{repo}"); - let token = extract_token(headers)?; // Pull repository information from Gitea. - let json = authorize(&state.config.gitea_host, &token, &repo).await?; + let json = authorize(&state.config.gitea_host, &token, &repo, "admin").await?; let description = json.get("description") - .ok_or(APIError::InvalidJson{ msg: "Couldn't find 'description' key.".to_owned() })? + .ok_or(ApiError::InvalidJson{ msg: "Couldn't find 'description' key.".to_owned() })? .as_str() - .ok_or(APIError::InvalidJson{ msg: "Value in 'description' is not String.".to_owned() })? + .ok_or(ApiError::InvalidJson{ msg: "Value in 'description' is not String.".to_owned() })? .to_owned(); // Check if entry exists and return features. @@ -309,11 +337,10 @@ pub struct GiteaSetSecret { pub async fn create_repo(State(state): State>, Path((owner, repo)): Path<(String, String)>, - headers: HeaderMap, + AuthBearer(token): AuthBearer, ) -> Result<()> { let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. - let token = extract_token(headers)?; - let _ = authorize(&state.config.gitea_host, &token, &repo).await?; + let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?; // Create secret and insert new repository into database. let secret = Alphanumeric.sample_string(&mut rand::rng(), 48); @@ -325,10 +352,20 @@ pub async fn create_repo(State(state): State>, (3, secret.clone().into()), ])? .map(|_| 0) - .sum(); // Evalute statement. + .sum(); // Evaluate statement. // Make associated folder. - fs::create_dir_all(path::Path::new(&state.config.upload_path).join(&repo))?; + let owner_dir = path::Path::new(&state.config.upload_path).join(&owner); + let repo_dir = path::Path::new(&state.config.upload_path).join(&repo); + if !fs::exists(&owner_dir)? { + fs::create_dir(&owner_dir)?; + fs::set_permissions(owner_dir, Permissions::from_mode(FOLDER_PERM))?; + } + + if !fs::exists(&repo_dir)? { + fs::create_dir(&repo_dir)?; + fs::set_permissions(repo_dir, Permissions::from_mode(FOLDER_PERM))?; + } // Add secret to Gitea secrets. let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, &repo); @@ -340,23 +377,22 @@ pub async fn create_repo(State(state): State>, } pub async fn delete_repo(State(state): State>, Path((owner, repo)): Path<(String, String)>, - headers: HeaderMap, + AuthBearer(token): AuthBearer, ) -> Result<()> { let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. - let token = extract_token(headers)?; - let _ = authorize(&state.config.gitea_host, &token, &repo).await?; + let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?; let _: i32 = state.sql.prepare(crate::query::DELETE_REPO)? .iter() .bind((1, repo.as_str()))? .map(|_| 0) - .sum(); // Evalute statement. + .sum(); // Evaluate statement. // Remove entire directory and its parent if its empty. let dir = path::Path::new(&state.config.upload_path).join(&repo); let parent = dir.as_path() .parent() - .ok_or(APIError::Other { msg: "Could not find parent in repo directory. Should be impossible...".to_owned() })?; + .ok_or(ERR_MISSING_PARENT)?; fs::remove_dir_all(&dir)?; if fs::read_dir(parent)?.next().is_none() { @@ -385,7 +421,7 @@ async fn update_secret(state: &ServerState<'_>, repo: &str, token: &str) -> Resu (2, repo.into()), ])? .map(|_| 0) - .sum(); // Evalute statement. + .sum(); // Evaluate statement. let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, repo); let data = GiteaSetSecret { data: secret }; @@ -406,13 +442,14 @@ async fn update_feature(state: &ServerState<'_>, repo: &str, feature: &str) -> R .map(|row| -> Result { Ok(row?.read::("features") as u64) }) - .collect::>>()?[0]; // Evalute statement. + .collect::>>()?[0]; // Evaluate statement. // Check added or removed and update folders accordingly. let added = (features & feat_num) == feat_num; let dir = path::Path::new(&state.config.upload_path).join(repo).join(feature); if added { - fs::create_dir(dir)?; + fs::create_dir(&dir)?; + fs::set_permissions(dir, Permissions::from_mode(FOLDER_PERM))?; } else { fs::remove_dir_all(dir)?; } @@ -421,11 +458,10 @@ async fn update_feature(state: &ServerState<'_>, repo: &str, feature: &str) -> R pub async fn patch_repo(State(state): State>, Path((owner, repo)): Path<(String, String)>, - headers: HeaderMap, + AuthBearer(token): AuthBearer, Json(payload): Json) -> Result<()> { let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. - let token = extract_token(headers)?; - let _ = authorize(&state.config.gitea_host, &token, &repo).await?; + let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?; if payload.secret { return update_secret(&state, &repo, &token).await; @@ -433,6 +469,119 @@ pub async fn patch_repo(State(state): State>, update_feature(&state, &repo, &payload.feature).await } +#[derive(Serialize)] +pub struct Entry { + pub name: String, + pub name_uri: String, + pub icon: &'static str, + pub size: String, +} + +fn entry_from_dir_entry(entry: std::io::Result) -> Result { + let path = entry?.path(); + let name = path.file_name().unwrap().to_str().ok_or(ERR_OS_STR_UTF)?.to_owned(); + let name_uri = urlencoding::encode(&name).to_string(); + let metadata = path.metadata()?; + + if metadata.is_dir() { + return Ok(Entry{ + name, name_uri, icon: "folder", size: "—".to_owned(), + }); + } + let size = ByteSize::b(metadata.len()).display().iec().to_string(); + + // Get file mime data. + let out = Command::new("file").args(["-b", "--mime", path.to_str().ok_or(ERR_OS_STR_UTF)?]).output()?; + let out = String::from_utf8(out.stdout).map_err(|_| ERR_FILE_CALL_UTF)?; + let out = out.trim().split("; ").collect::>(); + + // Example expected output: text/plain; charset=us=ascii + (out.len() == 2).then_some(0).ok_or(ERR_FILE_CALL_UNEXPECTED)?; + let mime = out[0]; + let charset = &out[1][8..]; + + let icon = if &mime[..4] == "text" { + "text" + } else if charset == "binary" { + match mime { + "application/gzip" => "archive", + "application/zip" => "archive", + _ => "binary" + } + } else { + "" + }; + + Ok(Entry{ name, name_uri, icon, size }) +} + +fn sort_entry(a: &Entry, b: &Entry) -> Ordering { + // Sort list by alphabetical folder lowest (so it appears at the top). + let (a_fol, b_fol) = (a.icon == "folder", b.icon == "folder"); + match (a_fol, b_fol) { + (true, true) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + (false, false) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } +} + + +#[derive(Serialize)] +pub struct GetEntryResponse { + entries: Vec, +} + +pub async fn get_entry(State(state): State>, + Path((owner, repo, path)): Path<(String, String, String)>, + AuthBearer(token): AuthBearer) -> Result { + let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. + let target = format!("{repo}/{path}"); + + let _ = authorize(&state.config.gitea_host, &token, &repo, "pull").await?; + + let dir = path::Path::new(&state.config.upload_path).join(&target); + + let metadata = match fs::metadata(&dir) { + Ok(x) => x, + Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()), + }; + + if metadata.is_dir() { // Return JSON. + let dir = fs::read_dir(&dir)?; + let mut entries = dir.map(entry_from_dir_entry).collect::>>()?; + entries.sort_by(sort_entry); + Ok(Json(entries).into_response()) + } else { // Redirect to file. + Ok(Redirect::temporary(format!("/r/{target}").as_str()).into_response()) + } +} + +pub async fn delete_entry(State(state): State>, + Path((owner, repo,path)): Path<(String, String, String)>, + AuthBearer(token): AuthBearer) -> Result { + let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. + let target = format!("{repo}/{path}"); + + let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?; + + let dir = path::Path::new(&state.config.upload_path).join(target); + let metadata = match fs::metadata(&dir) { + Ok(x) => x, + Err(_) => return Ok(StatusCode::NOT_FOUND), + }; + + if metadata.is_dir() { + fs::remove_dir_all(&dir)?; + } else if metadata.is_file() || metadata.is_symlink() { + fs::remove_file(&dir)?; + } else { + return Err(ERR_IMPOSSIBLE_ENTRY)?; + } + + Ok(StatusCode::OK) +} + #[derive(Default)] struct UploadFile { name: String, @@ -442,11 +591,10 @@ struct UploadFile { pub async fn upload(State(state): State>, Path((owner, repo, feature)): Path<(String, String, String)>, - headers: HeaderMap, + AuthBearer(user_secret): AuthBearer, mut multipart: Multipart) -> Result<()> { let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. let feature = feature.to_lowercase(); - let user_secret = extract_token(headers)?; // Authorization should be the secret this time. let (secret, features) = &state.sql.prepare(crate::query::UPLOAD_QUERY)? .iter() @@ -461,10 +609,10 @@ pub async fn upload(State(state): State>, .collect::>>()?[0]; (user_secret == *secret).then_some(0) - .ok_or(APIError::Unauthorized{ repo: repo.to_owned() })?; + .ok_or(ApiError::Unauthorized{ repo: repo.to_owned() })?; features.contains(&feature).then_some(0) - .ok_or(APIError::DisabledFeature{ feature: feature.clone() })?; + .ok_or(ApiError::DisabledFeature{ feature: feature.clone() })?; // Process multipart. let mut file = UploadFile{ folder: "".to_owned(), ..Default::default() }; @@ -483,7 +631,10 @@ pub async fn upload(State(state): State>, } let dir = path::Path::new(&state.config.upload_path).join(&repo).join(feature); - fs::write(dir.join(&file.name), file.data)?; + let filepath = dir.join(&file.name); + fs::write(&filepath, file.data)?; + fs::set_permissions(&filepath, Permissions::from_mode(FILE_PERM))?; + let tardir = dir.join(&file.folder); if file.folder != *"" { if fs::exists(&tardir)? { @@ -491,15 +642,127 @@ pub async fn upload(State(state): State>, } fs::create_dir(&tardir)?; - let output = Command::new("tar").args(["-xf", dir.join(&file.name).to_str().unwrap(), "-C", tardir.to_str().unwrap()]) + fs::set_permissions(&tardir, Permissions::from_mode(FOLDER_PERM))?; + let tardir_s = tardir.to_str().unwrap(); + let output = Command::new("tar").args(["-xf", filepath.to_str().unwrap(), "-C", tardir_s]) .output()?; // If there was an error, remove everything because the operation was unsucessful. if !output.status.success() { fs::remove_dir_all(&tardir)?; - fs::remove_file(dir.join(file.name))?; - return Err(APIError::Other{ msg: "Failed to complete untar".to_owned() }) + fs::remove_file(&filepath)?; + return Err(ERR_UNTAR)? } + + // Run chmod to set extracted folder permissions. + Command::new("find").args([tardir_s, "-type", "f", "-exec", "chmod", "0640", "{}", "+"]) + .output()?; + Command::new("find").args([tardir_s, "-type", "d", "-exec", "chmod", "0750", "{}", "+"]) + .output()?; } Ok(()) } + +pub async fn status_to_404(request: axum::extract::Request, next: Next) -> Response { + let mut response = next.run(request).await; + *response.status_mut() = StatusCode::NOT_FOUND; + response +} + +pub async fn get_not_found(request: axum::extract::Request) -> Response { + let resp = ServeFile::new("static/404.html").try_call(request).await.unwrap(); + let mut resp = resp.map(Body::new); + *resp.status_mut() = StatusCode::NOT_FOUND; + resp +} + +pub async fn auth_file_routing(State(state): State>, + Path((owner, path)): Path<(String, String)>, + headers: HeaderMap, + jar: CookieJar, + mut request: axum::extract::Request, + next: Next) -> Result { + *request.uri_mut() = format!("/{owner}/{path}").parse::().unwrap(); + let target = format!("{owner}/{path}"); + + let mut path_s: Vec<_> = path.split("/").collect(); + let repo = path_s.remove(0); + let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. + let no_delete = path_s.is_empty() || path_s[0].is_empty(); // We must be on the feature-selection page. + + // If we find an authorization, it is probably a redirect from /api/files + let token = if let Some(x) = headers.get(AUTHORIZATION) { + x.to_str() + .map_err(|_| ApiError::Tokenless)? + .to_owned() + } else { // Otherwise, this is regular browsing, if no cookie token, user needs to login. + match jar.get("token") { + Some(x) => x.value_trimmed().to_owned(), + None => return Ok(Redirect::temporary("/").into_response()), + } + }; + + // We use a timed hash map to temporarily store tokens. This is to prevent + // flooding Gitea with authorization requests for each small HTTP request + // when viewing inner sites. (Ex: loading assets for docs/) + let is_admin = match state.token_map.get(&token) { + Some(x) => x, // If it returns, then continue. + None => { + // Check if user can view repo. + match authorize(&state.config.gitea_host, &token, &repo, "pull").await { + Ok(json) => { + let perm = json.get("permissions").unwrap().get("admin").unwrap().as_bool().unwrap(); + // Store this in the timed hash map for 5 minutes. + state.token_map.insert(token, perm, Duration::new(300, 0)); + perm + }, + Err(e) => { + let e = match e { + ApiError::RequestError(x) => x, + _ => return Err(e) // Some other error, return it. + }; + + // If the error is unauthorized, try refreshing the token. + // If it's not found, return not found. + // Otherwise, it's unknown and we send back to "/". + let refresh = format!("/refresh?target=/r/{target}"); + return match e.status() { + Some(StatusCode::UNAUTHORIZED) => Ok(Redirect::temporary(&refresh).into_response()), + Some(StatusCode::NOT_FOUND) => Ok(get_not_found(request).await), + _ => Ok(Redirect::temporary("/").into_response()), + }; + } + } + } + }; + + + let dir = path::Path::new(&state.config.upload_path).join(&target); + let mut dir = match fs::read_dir(&dir) { + Ok(x) => { + match fs::exists(dir.join("index.html"))? { + true => { + if !path.ends_with('/') { + // Need to make sure for a website given to ServeDir, this needs to end in a /, othewrise + // ServerDir will issue a redirect with a {uri}/, but this is at the wrong location, + // since this does not contain the /r/ prefix. + *request.uri_mut() = format!("/{owner}/{path}/").parse::().unwrap(); + } + return Ok(next.run(request).await); // index.html exists, so pass to ServeDir. + } + false => x.peekable(), // We need to get the directories. + } + }, + Err(_) => return Ok(next.run(request).await), // Not a directory or file or not found, so pass to ServeDir. + }; + + if dir.peek().is_none() { + return Ok(get_not_found(request).await); + } + + // At this point, we should send an page with the list of files. + let mut entries = dir.map(entry_from_dir_entry).collect::>>()?; + entries.sort_by(sort_entry); + + Ok(Html(crate::compile::content_folder(&state.config, target, &entries, is_admin, no_delete)?).into_response()) +} diff --git a/src/compile.rs b/src/compile.rs index 94eb1c3..7134222 100644 --- a/src/compile.rs +++ b/src/compile.rs @@ -14,73 +14,64 @@ struct WebStates<'a> { confirm_on: &'a str, } +static STATES: WebStates = WebStates { + auth: "auth", + error: "error", + loading: "loading", + repo_selected: "repoSelected", + repo_created: "repoCreated", + button_on: "enabled", + feature_on: "fEnabled", + confirm_on: "enabled", +}; + #[derive(Template)] #[template( ext = "html", path = "html/contentDefault.html", whitespace = "suppress" )] -struct Default<'a> { - config: crate::ServerConfig, +struct ContentDefault<'a> { + config: &'a crate::ServerConfig, features: Vec, content_text: String, + force_msg: bool, state: WebStates<'a>, } #[derive(Template)] -#[template(ext = "html", path = "html/base.html", whitespace = "suppress")] -struct NotFound { - config: crate::ServerConfig, +#[template(ext = "html", path = "html/notFound.html", whitespace = "suppress")] +struct NotFound<'a> { + config: &'a crate::ServerConfig, content_text: String, -} - -#[derive(Template)] -#[template(path = "index.css", escape = "txt", whitespace = "suppress")] -struct Css<'a> { + force_msg: bool, state: WebStates<'a>, } -pub fn make_templates(config: &crate::ServerConfig) -> Result<()> { - let states = WebStates { - auth: "auth", - error: "error", - loading: "loading", - repo_selected: "repoSelected", - repo_created: "repoCreated", - button_on: "enabled", - feature_on: "fEnabled", - confirm_on: "enabled", - }; +#[derive(Template)] +#[template(ext = "html", path = "html/refresh.html", whitespace = "suppress")] +struct Refresh<'a> { + config: &'a crate::ServerConfig, + content_text: String, + force_msg: bool, + state: WebStates<'a>, +} - let default = Default { - config: config.clone(), - features: crate::api::RepoFeature::iter() - .map(|f| f.to_string()) - .collect(), - content_text: "awaiting authorization...".to_owned(), - state: states, - } - .render() - .context("Unable to process 'Default' template")?; - - fs::write("static/index.html", default).context("Could not write to static/index.html")?; - - let not_found = NotFound { - config: config.clone(), - content_text: "404: not found...".to_owned(), - } - .render() - .context("Unable to process 'NotFound' template.")?; - - fs::write("static/404.html", not_found).context("Could not write to static/404.html")?; - - let css = Css { state: states } - .render() - .context("Unable to process 'CSS' template.")?; - - fs::write("static/index.css", css).context("Could not write to static/index.css")?; - - Ok(()) +#[derive(Template)] +#[template( + ext = "html", + path = "html/contentFolder.html", + whitespace = "suppress" +)] +struct ContentFolder<'a> { + config: &'a crate::ServerConfig, + content_text: String, + force_msg: bool, + state: WebStates<'a>, + target: String, + entries: &'a Vec, + is_admin: bool, + no_delete: bool, } fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> io::Result<()> { @@ -98,7 +89,65 @@ fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> io::Result<()> } pub fn copy_assets() -> Result<()> { - fs::create_dir("static").context("Could not create directory 'static'.")?; + fs::create_dir_all("static").context("Could not create directory 'static'.")?; copy_dir_all("assets", "static").context("Could not copy 'assets' into 'static'.")?; Ok(()) } + +pub fn content_folder( + config: &crate::ServerConfig, + target: String, + entries: &Vec, + is_admin: bool, + no_delete: bool, +) -> crate::api::Result { + Ok(ContentFolder { + config, + content_text: "awaiting authorization...".to_owned(), + force_msg: false, + state: STATES, + target, + entries, + is_admin, + no_delete, + } + .render() + .map_err(|_| crate::api::ERR_CONTENT_FOLDER_RENDER)?) +} + +pub fn make_templates(config: &crate::ServerConfig) -> Result<()> { + let default = ContentDefault { + config, + features: crate::api::RepoFeature::iter() + .map(|f| f.to_string()) + .collect(), + force_msg: false, + content_text: "awaiting authorization...".to_owned(), + state: STATES, + } + .render() + .context("Unable to process 'Default' template")?; + fs::write("static/index.html", default).context("Could not write to static/index.html")?; + + let not_found = NotFound { + config, + content_text: "404: not found...".to_owned(), + force_msg: true, + state: STATES, + } + .render() + .context("Unable to process 'NotFound' template.")?; + fs::write("static/404.html", not_found).context("Could not write to static/404.html")?; + + let refresh = Refresh { + config, + content_text: "refreshing...".to_owned(), + force_msg: true, + state: STATES, + } + .render() + .context("Unable to process 'Refresh' template.")?; + fs::write("static/refresh.html", refresh).context("Could not write to static/refresh.html")?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index c5ad183..11977ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::{ + convert::Infallible, fs, net::SocketAddr, }; @@ -6,10 +7,15 @@ use anyhow::{Context, Result}; use axum::{ body::Body, extract::{ConnectInfo, Request, DefaultBodyLimit}, + response::{IntoResponse, Response}, routing::{get, post}, + middleware::{from_fn, from_fn_with_state}, Router, + http::StatusCode, }; +use tower::{ServiceBuilder, service_fn}; use tower_http::{ + compression::CompressionLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; @@ -29,7 +35,7 @@ mod util; type SQLConn = sqlite::ConnectionThreadSafe; -// type RepoMap = util::TimedHashMap>; +type TokenMap = util::TimedHashMap; #[derive(Clone, Deserialize)] struct ServerConfig { @@ -48,6 +54,7 @@ struct ServerConfig { struct ServerState<'a> { config: ServerConfig, sql: &'a SQLConn, + token_map: &'a TokenMap, } mod query { @@ -91,7 +98,8 @@ async fn main() -> Result<()> { compile::make_templates(&config)?; let sql: &'static sqlite::ConnectionThreadSafe = Box::leak(Box::new(sql)); - let server_state = ServerState { config, sql }; + let token_map: &'static TokenMap = Box::leak(Box::new(TokenMap::new())); + let server_state = ServerState { config, sql, token_map }; let env_filter = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new("info"))?; @@ -122,22 +130,47 @@ async fn main() -> Result<()> { "request", method = format!("{}", request.method()), version = format!("{:?}", request.version()), + path = request.uri().to_string(), remote_ip = addr, ) }); + let auth_browse_service = ServiceBuilder::new() + .layer(from_fn_with_state(server_state.clone(), api::auth_file_routing)) + .service(ServeDir::new(upload_path)); + + let not_found_only = ServiceBuilder::new() + .layer(from_fn(api::status_to_404)) + .service(service_fn(async |_| -> core::result::Result { + Ok(StatusCode::NOT_FOUND.into_response()) + })); + + let not_found_file = ServiceBuilder::new() + .layer(from_fn(api::status_to_404)) + .service(ServeFile::new("static/404.html")); + + let compression_layer = CompressionLayer::new() + .gzip(true) + .br(true); + // Build routes let api_routes = Router::new() .route("/token", post(api::token).patch(api::refresh_token)) .route("/repo/{owner}/{repo}", get(api::get_repo).post(api::create_repo).patch(api::patch_repo).delete(api::delete_repo)) .route("/upload/{owner}/{repo}/{feature}", post(api::upload).layer(DefaultBodyLimit::max(usize::pow(1024, 3)))) + .route("/files/{owner}/{repo}/{*path}", get(api::get_entry).delete(api::delete_entry)) + .fallback_service(not_found_only) .with_state(server_state); let app = Router::new() .nest("/api", api_routes) - .route_service("/", ServeFile::new("static/index.html")) .nest_service("/assets", ServeDir::new("static")) - .fallback_service(ServeDir::new(upload_path)) + .route_service("/", ServeFile::new("static/index.html")) + .route_service("/favicon.ico", ServeFile::new("static/favicon.ico")) // For inner sites. + .route_service("/refresh", ServeFile::new("static/refresh.html")) + .route_service("/r/{owner}/{*path}", auth_browse_service) + .fallback_service(not_found_file) + .layer(compression_layer) .layer(trace); // Run server. diff --git a/src/util.rs b/src/util.rs index 241c588..173f440 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,65 +1,74 @@ -// use std::{ -// collections::HashMap, -// hash::Hash, -// marker::Send, -// sync::mpsc::{channel, Sender, TryRecvError}, -// sync::{Arc, Mutex}, -// thread, -// time::{Duration, SystemTime}, -// }; +use std::{ + collections::HashMap, + hash::Hash, + marker::Send, + sync::mpsc::{channel, Sender, TryRecvError}, + sync::{Arc, Mutex}, + thread, + time::{Duration, SystemTime}, +}; -// pub struct TimedHashMap { -// map: Arc>>, -// duration: Arc>, -// tx: Sender, -// } +pub struct TimedHashMap { + map: Arc>>, + duration: Arc>, + _tx: Sender, // Needs to stay alive for the duration of the struct. +} -// // Insert and cleanup send message to a main thread -// // the main looping thread which reads messages to know its next action +// Insert and cleanup send message to a main thread +// the main looping thread which reads messages to know its next action -// impl TimedHashMap -// where -// K: std::cmp::Eq + Hash + Send + 'static, -// V: Send + 'static, -// { -// pub fn new() -> Self { -// let (tx, rx) = channel(); -// let thp = Self { -// map: Arc::new(Mutex::new(HashMap::::new())), -// duration: Arc::new(Mutex::new(Duration::new(60, 0))), -// tx, -// }; +impl TimedHashMap +where + K: std::cmp::Eq + Hash + Send + 'static, + V: Send + Clone + 'static, +{ + pub fn new() -> Self { + let (tx, rx) = channel(); + let thp = Self { + map: Arc::new(Mutex::new(HashMap::::new())), + duration: Arc::new(Mutex::new(Duration::new(30, 0))), + _tx: tx, + }; -// let map2 = Arc::clone(&thp.map); -// let dur2 = Arc::clone(&thp.duration); -// // Cleanup thread. -// thread::spawn(move || loop { -// thread::sleep(*dur2.lock().unwrap()); -// map2.lock().unwrap().retain(|_, v| v.0 < SystemTime::now()); -// match rx.try_recv() { -// // Thread should get killed when tx drops. -// Ok(_) | Err(TryRecvError::Disconnected) => break, -// Err(TryRecvError::Empty) => {} -// } -// }); + let map2 = Arc::clone(&thp.map); + let dur2 = Arc::clone(&thp.duration); + // Cleanup thread. + thread::spawn(move || loop { + thread::sleep(*dur2.lock().unwrap()); + map2.lock().unwrap().retain(|_, v| v.0 < SystemTime::now()); + match rx.try_recv() { + // Thread should get killed when tx drops. + Ok(_) | Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {} + } + }); -// thp -// } + thp + } -// pub fn insert(&mut self, key: K, value: V, time: Duration) -> Option { -// let map2 = Arc::clone(&self.map); -// match map2 -// .lock() -// .unwrap() -// .insert(key, (SystemTime::now() + time, value)) -// { -// Some(x) => Some(x.1), -// None => None, -// } -// } + pub fn insert(&self, key: K, value: V, time: Duration) -> Option { + let map2 = Arc::clone(&self.map); + match map2 + .lock() + .unwrap() + .insert(key, (SystemTime::now() + time, value)) + { + Some(x) => Some(x.1), + None => None, + } + } -// pub fn set_interval(&mut self, duration: Duration) { -// let dur2 = Arc::clone(&self.duration); -// *dur2.lock().unwrap() = duration -// } -// } + pub fn get(&self, key: &K) -> Option { + let map2 = Arc::clone(&self.map); + map2.lock() + .unwrap() + .get(key) + .as_ref() + .map(|x| (x.1).clone()) + } + + // pub fn set_interval(&mut self, duration: Duration) { + // let dur2 = Arc::clone(&self.duration); + // *dur2.lock().unwrap() = duration + // } +} diff --git a/templates/css/contentDefault.css b/templates/css/contentDefault.css new file mode 100644 index 0000000..b8cc339 --- /dev/null +++ b/templates/css/contentDefault.css @@ -0,0 +1,172 @@ +{% include "css/mod/base.css" %} + +#packageLabelContent { + > div:nth-of-type(1) { + padding: 7% 7% 0 7%; + color: var(--c-text); + } + + h1 { + margin: 0; + line-height: 100%; + } + + input { + background: none; + color: var(--c-text); + border: 1px solid var(--c-lines); + padding: 2%; + margin: 2% 0 2% 0; + font-size: 150%; + font-family: 'Courier'; + + &.{{ state.error }} { outline: 2px solid red !important; } + &:focus { outline: none; } + } + + input[type=submit] { + cursor: pointer; + transition: background-color 0.15s ease-in-out; + + &:hover { background-color: var(--c-login-hover); } + + /* Button off. */ + color: var(--c-null-text); + border-style: dashed; + pointer-events: none; + + &.{{ state.button_on }} { + color: var(--c-text); + border-style: solid; + pointer-events: auto; + } + + &.{{ state.loading }} { + border: 1px solid transparent; + background: linear-gradient(var(--c-label), var(--c-label)) padding-box, conic-gradient( + from var(--angle), + var(--c-lines) 0% 30%, + var(--c-loading) 30% 50%, + var(--c-lines) 50% 80%, + var(--c-loading) 80% 100% + ) border-box; + animation: 1.5s rotate linear infinite; + cursor: not-allowed; + } + } + + #repoSelect { + border-bottom: 2px solid var(--c-lines); + + #repoSelectInput { margin-bottom: 0; } + #repoSelectButton { float: right; } + + #repoSelectError { + line-height: 100%; + height: 3rem; + margin: 2% 0 2% 0; + } + } + + #repoInfo { + margin-top: 2%; + transition: opacity 0.15s ease-in-out; + + > h1:first-child { margin-top: 4%;} + + #repoActions { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + #repoLinkInput::selection { background-color: transparent; } + #repoLinkButton { float: right; } + + #repoFeatures { + margin-top: 2%; + transition: opacity 0.15s ease-in-out; + + #featureBox { + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + align-items: center; + + > .feature { + margin-right: 2%; + font-weight: 800; + pointer-events: auto; + + /* Feature off */ + color: var(--c-null-text); + border-style: dashed; + + &.{{ state.feature_on }} { + color: var(--c-text); + border-style: solid; + } + } + } + } + } + + #confirmOverlay { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + + position: absolute; + left: 0; + z-index: 10; + + transition: opacity 0.15s ease-in-out; + + /* Confirm off */ + pointer-events: none; + top: 100%; + opacity: 0; + + &.{{ state.confirm_on }} { + pointer-events: auto; + top: 0; + opacity: 1; + } + + > div { + margin: 30% auto 0 auto; + + width: 80rem; + height: 30rem; + + outline-width: 5rem; + outline-style: solid; + outline-color: var(--c-label); + + border-radius: 12px; + background-color: var(--c-label); + border: 0.5rem solid var(--c-lines); + + transition: background-color 0.15s ease-in-out, + border 0.15s ease-in-out, + outline-color 0.15s ease-in-out; + + > div { + padding: 7%; + + > input[type=submit] { float: right; } + } + } + + } + + /* Repo selected and created states. */ + #repoInfo, #repoFeatures { + opacity: 0; + } + + #repoInfo.{{ state.repo_selected }}, #repoFeatures.{{ state.repo_created }} { + opacity: 1 + } +} diff --git a/templates/css/contentFolder.css b/templates/css/contentFolder.css new file mode 100644 index 0000000..fe02473 --- /dev/null +++ b/templates/css/contentFolder.css @@ -0,0 +1,135 @@ +{% include "css/mod/base.css" %} + +#packageLabelContent { + > div { + padding: 0; + width: 100%; + } + + svg { + padding: 0 5% 0 5%; + > path { pointer-events: none; } + } + + table { + border-spacing: 0; + font-size: 150%; + width: 100%; + + th { + height: 6rem; + + > svg { + width: 2.5rem; + padding-right: 6%; + cursor: pointer; + fill: var(--c-back-button); + transition: opacity 0.15s ease-in-out; + + &:hover { opacity: 0.6 } + } + + &:first-child { + text-align: left; + display: flex; + align-items: center; + } + } + + tr { + height: 6rem; + transition: background-color 0.15s ease-in-out; + + &:first-child { background-color: var(--c-table-header); } + /* Excluding header */ + &:nth-child(2n+3) { background-color: var(--c-table-odd); } + /* Excluding header */ + &:nth-child(n+1):hover { background-color: var(--c-table-hover); } + } + + td { + height: 6rem; + + &:nth-of-type(1) { + width: 100%; + text-align: left; + + display: flex; + align-items: center; + + > svg { width: 3rem; } + > a { color: var(--c-text); } + } + + &:nth-of-type(2) { + width: 20%; + text-align: center; + } + + &:nth-of-type(3) { + width: 16%; + text-align: center; + + > svg { + width: 2.3rem; + margin-top: 5%; + cursor: pointer; + transition: fill 0.15s ease-in-out; + + &:hover { fill: #ff3f3f; } + } + } + } + } + + #confirmOverlay { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + + position: absolute; + left: 0; + z-index: 10; + + transition: opacity 0.15s ease-in-out; + + /* Confirm off */ + pointer-events: none; + top: 100%; + opacity: 0; + + &.{{ state.confirm_on }} { + pointer-events: auto; + top: 0; + opacity: 1; + } + + > div { + margin: 40% auto 0 auto; + + width: 80rem; + height: 20rem; + + outline-width: 5rem; + outline-style: solid; + outline-color: var(--c-label); + + border-radius: 12px; + background-color: var(--c-label); + border: 0.5rem solid var(--c-lines); + + transition: background-color 0.15s ease-in-out, + border 0.15s ease-in-out, + outline-color 0.15s ease-in-out; + + > div { + padding: 7%; + + > input[type=submit]:first-child { + float: right; + margin-bottom: 5%; + } + } + } + } +} diff --git a/templates/index.css b/templates/css/mod/base.css similarity index 51% rename from templates/index.css rename to templates/css/mod/base.css index 035df91..8087ddf 100644 --- a/templates/index.css +++ b/templates/css/mod/base.css @@ -12,6 +12,10 @@ --cl-lines: black; --cl-login-hover: rgba(255, 255, 255, 0.8); --cl-null-text: #9b9b9b; + --cl-table-header: #d6f0a7; + --cl-table-odd: #dadada; + --cl-table-hover: #c6dc9e; + --cl-back-button: rgba(0, 0, 0, 0.3); /* Dark Colors */ --cd-bg: #111; @@ -24,6 +28,10 @@ --cd-lines: #4d4d4d; --cd-login-hover: rgba(0, 0, 0, 0.5); --cd-null-text: #9b9b9b; + --cd-table-header: #314a48; + --cd-table-odd: #2d2d2d; + --cd-table-hover: #344543; + --cd-back-button: rgba(255, 255, 255, 0.3); --c-loading: #d6d6d6; @@ -38,6 +46,10 @@ --c-lines: var(--cl-lines); --c-login-hover: var(--cl-login-hover); --c-null-text: var(--cl-null-text); + --c-table-header: var(--cl-table-header); + --c-table-odd: var(--cl-table-odd); + --c-table-hover: var(--cl-table-hover); + --c-back-button: var(--cl-back-button); --p-theme-switch-0: 0; --p-theme-switch-1: 1; @@ -54,6 +66,10 @@ --c-lines: var(--cd-lines); --c-login-hover: var(--cd-login-hover); --c-null-text: var(--cd-null-text); + --c-table-header: var(--cd-table-header); + --c-table-odd: var(--cd-table-odd); + --c-table-hover: var(--cd-table-hover); + --c-back-button: var(--cd-back-button); --p-theme-switch-0: 1; --p-theme-switch-1: 0; @@ -74,6 +90,10 @@ --c-lines: var(--cl-lines); --c-login-hover: var(--cl-login-hover); --c-null-text: var(--cl-null-text); + --c-table-header: var(--cl-table-header); + --c-table-odd: var(--cl-table-odd); + --c-table-hover: var(--cl-table-hover); + --c-back-button: var(--cl-back-button); } @media (prefers-color-scheme: light) { @@ -87,6 +107,10 @@ --c-lines: var(--cd-lines); --c-login-hover: var(--cd-login-hover); --c-null-text: var(--cd-null-text); + --c-table-header: var(--cd-table-header); + --c-table-odd: var(--cd-table-odd); + --c-table-hover: var(--cd-table-hover); + --c-back-button: var(--cd-back-button); } } @@ -139,76 +163,70 @@ body { --w: 6deg; --spokes: 10; - input { + > input { width: 100%; height: 100%; position: absolute; opacity: 0; cursor: pointer; + + &:hover ~ span:nth-child(2) { + filter: brightness(110%); + } + + &:checked ~ span { + &:nth-of-type(1) { background-color: var(--night-color); } + &:nth-of-type(2) { opacity: var(--p-theme-switch-0); } + &:nth-of-type(3) { opacity: var(--p-theme-switch-1); } + } } - span { + > span { transition: opacity 0.15s ease-in-out; position: absolute; pointer-events: none; - } - span:nth-child(2) { - width: 100%; height: 100%; - top: 0; left: 0; - background-color: var(--day-color); - filter: brightness(100%); - transition: background-color 0.15s ease-in-out, - filter 0.15s ease-in-out; - } + &:nth-of-type(1) { + width: 100%; height: 100%; + top: 0; left: 0; + background-color: var(--day-color); + filter: brightness(100%); + transition: background-color 0.15s ease-in-out, + filter 0.15s ease-in-out; + } - span:nth-child(3) { - width: 4rem; height: 4rem; - position: absolute; - top: 3rem; left: 3rem; - background-color: var(--sun-color); - border-radius: 50%; - opacity: var(--p-theme-switch-1); - } + &:nth-of-type(2) { + width: 4rem; height: 4rem; + position: absolute; + top: 3rem; left: 3rem; + background-color: var(--sun-color); + border-radius: 50%; + opacity: var(--p-theme-switch-1); - span:nth-child(3)::before { - content: ""; - width: 7rem; height: 7rem; - position: absolute; - top: -1.5rem; left: -1.5rem; - border-radius: inherit; - background-image: repeating-conic-gradient( - from calc(-1*var(--w)/2), var(--sun-color) 0 calc(var(--w) - 2deg), - transparent calc(var(--w)) calc(360deg/var(--spokes) - 2deg), - var(--sun-color) calc(360deg/var(--spokes)) - ); - } + &::before { + content: ""; + width: 7rem; height: 7rem; + position: absolute; + top: -1.5rem; left: -1.5rem; + border-radius: inherit; + background-image: repeating-conic-gradient( + from calc(-1*var(--w)/2), var(--sun-color) 0 calc(var(--w) - 2deg), + transparent calc(var(--w)) calc(360deg/var(--spokes) - 2deg), + var(--sun-color) calc(360deg/var(--spokes)) + ); + } + } - span:nth-child(4) { - width: 6rem; height: 6rem; - top: 1.75rem; left: 1.75rem; - background-color: var(--moon-color); - border-radius: 50%; - opacity: var(--p-theme-switch-0); - mask-image: radial-gradient(circle, rgba(0,0,0,0) 33%, black 34%); - mask-position: -3rem -3rem; - mask-repeat: no-repeat; - mask-size: 166.66% 166.66%; - } - - input:hover ~ span:nth-child(2) { - filter: brightness(110%); - } - - input:checked ~ span:nth-child(2) { - background-color: var(--night-color); - } - - input:checked ~ span:nth-child(3) { - opacity: var(--p-theme-switch-0); - } - - input:checked ~ span:nth-child(4) { - opacity: var(--p-theme-switch-1); + &:nth-of-type(3) { + width: 6rem; height: 6rem; + top: 1.75rem; left: 1.75rem; + background-color: var(--moon-color); + border-radius: 50%; + opacity: var(--p-theme-switch-0); + mask-image: radial-gradient(circle, rgba(0,0,0,0) 33%, black 34%); + mask-position: -3rem -3rem; + mask-repeat: no-repeat; + mask-size: 166.66% 166.66%; + } } } @@ -230,29 +248,29 @@ body { z-index: 1; user-select: none; - img { + > img { display: block; position: absolute; - } - :nth-child(1) { - width: 30rem; - top: 6rem; left: 263rem; - } + &:nth-of-type(1) { + width: 30rem; + top: 6rem; left: 263rem; + } - :nth-child(2) { - width: 30rem; - top: 6rem; left: 228rem; - } + &:nth-of-type(2) { + width: 30rem; + top: 6rem; left: 228rem; + } - :nth-child(3) { - width: 35rem; - top: 162rem; left: 257rem; - } + &:nth-of-type(3) { + width: 35rem; + top: 162rem; left: 257rem; + } - :nth-child(4) { - width: 25rem; - top: 165rem; left: 10rem; + &:nth-of-type(4) { + width: 25rem; + top: 165rem; left: 10rem; + } } } @@ -262,7 +280,7 @@ body { position: absolute; z-index: 1; - text { + > text { font: 1.5px var(--font-family); fill: rgba(255, 255, 255, 0.4); user-select: none; @@ -300,48 +318,46 @@ body { grid-template-columns: 1fr 3fr; border-bottom: 0.5rem solid var(--c-lines); - > div:nth-child(1) { + > div:nth-of-type(1) { width: 100%; display: flex; justify-content: center; align-items: center; border-right: 0.5rem solid var(--c-lines); - svg { + > svg { width: 80%; height: 80%; } } - > div:nth-child(2) { + > div:nth-of-type(2) { padding: 3%; display: grid; grid-template-rows: 1fr 1fr; - > div:nth-child(1) { + > div:nth-of-type(1) { display: block; color: var(--c-dec-text); user-select: none; - p { + > p { margin: 0; font-size: 150%; - } - p:nth-child(1) { - display: inline-block; - } + &:nth-of-type(1) { display: inline-block; } + &:nth-of-type(2) { + display: inline-block; + float: right; + } - p:nth-child(2) { - display: inline-block; - float: right; } } - > div:nth-child(2) { + > div:nth-of-type(2) { width: 80%; position: relative; - svg { + > svg { position: absolute; left: 0; top: 0; width: 35rem; height: 7rem; @@ -379,7 +395,7 @@ body { border-bottom: 0.5rem solid var(--c-lines); user-select: none; - h1 { + > h1 { margin: 1%; font-size: 430%; } @@ -396,9 +412,9 @@ body { } @property --angle { - syntax: ""; - initial-value: 0deg; - inherits: false; + syntax: ""; + initial-value: 0deg; + inherits: false; } #packageLabelContent { @@ -417,213 +433,18 @@ body { animation: 1.5s infinite normal cursorWait; content: "hello"; } - - > div:nth-child(2) { - padding: 7% 7% 0 7%; - color: var(--c-text); - } - - h1 { - margin: 0; - line-height: 100%; - } - - input { - background: none; - color: var(--c-text); - border: 1px solid var(--c-lines); - padding: 2%; - margin: 2% 0 2% 0; - font-size: 150%; - font-family: 'Courier'; - } - - input.{{ state.error }} { - outline: 2px solid red !important; - } - - input[type=submit] { - cursor: pointer; - transition: background-color 0.15s ease-in-out; - } - - /* Button enabled/disabled. */ - input[type=submit] { - color: var(--c-null-text); - border-style: dashed; - pointer-events: none; - } - - input[type=submit].{{ state.button_on }} { - color: var(--c-text); - border-style: solid; - pointer-events: auto; - } - - input[type=submit]:hover { - background-color: var(--c-login-hover); - } - - input:focus { - outline: none; - } - - input[type=submit].{{ state.loading }} { - border: 1px solid transparent; - background: linear-gradient(var(--c-label), var(--c-label)) padding-box, conic-gradient( - from var(--angle), - var(--c-lines) 0% 30%, - var(--c-loading) 30% 50%, - var(--c-lines) 50% 80%, - var(--c-loading) 80% 100% - ) border-box; - animation: 1.5s rotate linear infinite; - cursor: not-allowed; - } - - #repoSelect { - border-bottom: 2px solid var(--c-lines); - - #repoSelectInput { - margin-bottom: 0; - } - - #repoSelectButton { - float: right; - } - - #repoSelectError { - line-height: 100%; - height: 3rem; - margin: 2% 0 2% 0; - } - } - - #repoInfo { - margin-top: 2%; - transition: opacity 0.15s ease-in-out; - - > h1:first-child { - margin-top: 4%; - } - - #repoActions { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } - - #repoLinkInput::selection { - background-color: transparent; - } - - #repoLinkButton { - float: right; - } - - #repoFeatures { - margin-top: 2%; - transition: opacity 0.15s ease-in-out; - - #featureBox { - display: flex; - flex-flow: row wrap; - justify-content: flex-start; - align-items: center; - - .feature { - margin-right: 2%; - font-weight: 800; - pointer-events: auto; - } - - /* Feature states. */ - .feature { - color: var(--c-null-text); - border-style: dashed; - } - - .feature.{{ state.feature_on }} { - color: var(--c-text); - border-style: solid; - - } - } - } - } - - #confirmOverlay { - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - - position: absolute; - left: 0; - z-index: 10; - - transition: opacity 0.15s ease-in-out; - - > div { - margin: 30% auto 0 auto; - - width: 80rem; - height: 30rem; - - outline-width: 5rem; - outline-style: solid; - outline-color: var(--c-label); - - border-radius: 12px; - background-color: var(--c-label); - border: 0.5rem solid var(--c-lines); - - transition: background-color 0.15s ease-in-out, - border 0.15s ease-in-out, - outline-color 0.15s ease-in-out; - - > div { - padding: 7%; - - input[type=submit] { - float: right; - } - } - } - } - - #confirmOverlay { - pointer-events: none; - top: 100%; - opacity: 0; - } - - #confirmOverlay.{{ state.confirm_on }} { - pointer-events: auto; - top: 0; - opacity: 1; - } - - /* Repo selected and created states. */ - #repoInfo, #repoFeatures { - opacity: 0; - } - - #repoInfo.{{ state.repo_selected }}, #repoFeatures.{{ state.repo_created }} { - opacity: 1 - } } #packageLabelBot { display: grid; grid-template-rows: 6fr 1fr; - svg { + > svg { margin: 3% auto 0 auto; width: 80%; height: 85%; } - a { + > a { margin: auto; margin-bottom: 1%; font-size: 2rem; @@ -631,60 +452,41 @@ body { text-decoration: none; transition: filter 0.15s ease-in-out; filter: brightness(100%); - } - a:hover { - filter: brightness(120%); + &:hover { filter: brightness(120%); } } } body { /* Default no authentication */ - #packageLabelTitle h1::before { - content: "PRIORITY PACKAGING" - } + #packageLabelTitle > h1::before { content: "PRIORITY PACKAGING" } - #packageLabelContent > div { - display: none; - } + #packageLabelContent > div { display: none; } #login { background: var(--c-label); color: var(--c-text); - } - #login::before { - content: "LOGIN"; - } - - #login:hover { - background-color: var(--c-login-hover); + &::before { content: "LOGIN"; } + &:hover { background-color: var(--c-login-hover); } } } body.{{ state.auth }} { - #packageLabelTitle h1::before { - content: "PACKAGE INFO" - } + #packageLabelTitle h1::before { content: "PACKAGE INFO" } - #packageLabelContent > div { - display: block; - } - - #packageLabelContent > h1 { - display: none; + #packageLabelContent { + > div { display: block; } + > h1 { display: none; } } #login { color: transparent; background:transparent; - } - #login::before { - content: "LOGOUT"; - } - - #login:hover { - background: var(--c-label); - color: var(--c-text); + &::before { content: "LOGOUT"; } + &:hover { + background: var(--c-label); + color: var(--c-text); + } } } diff --git a/templates/css/notFound.css b/templates/css/notFound.css new file mode 100644 index 0000000..9a3fd84 --- /dev/null +++ b/templates/css/notFound.css @@ -0,0 +1 @@ +{% include "css/mod/base.css" %} diff --git a/templates/css/refresh.css b/templates/css/refresh.css new file mode 100644 index 0000000..9a3fd84 --- /dev/null +++ b/templates/css/refresh.css @@ -0,0 +1 @@ +{% include "css/mod/base.css" %} diff --git a/templates/html/contentDefault.html b/templates/html/contentDefault.html index 01dc52e..6f671ec 100644 --- a/templates/html/contentDefault.html +++ b/templates/html/contentDefault.html @@ -1,4 +1,8 @@ -{% extends "base.html" %} +{% extends "mod/base.html" %} + +{% block style %} +{% include "css/contentDefault.css" %} +{% endblock %} {% block content %}
@@ -22,7 +26,7 @@

FEATURES:

{% for feature in features %} - + {% endfor %}
@@ -42,6 +46,5 @@ {% endblock %} {% block script %} -{% include "js/token.js" %} -{% include "js/default.js" %} +{% include "js/contentDefault.js" %} {% endblock %} diff --git a/templates/html/contentFolder.html b/templates/html/contentFolder.html new file mode 100644 index 0000000..10e3489 --- /dev/null +++ b/templates/html/contentFolder.html @@ -0,0 +1,61 @@ +{% extends "mod/base.html" %} + +{% block title %}Pack - /r/{{ target }}{% endblock %} + +{% block style %} +{% include "css/contentFolder.css" %} +{% endblock %} + +{% block content %} +
+ + + + + {% if is_admin && !no_delete %} + + {% endif %} + + {% for entry in entries %} + + + + {% if is_admin && !no_delete %} + + {% endif %} + + {% endfor %} +
+ + Name + Size
+ {% match entry.icon %} + {% when "folder" %} + {% when "text" %} + {% when "archive" %} + {% when "binary" %} + {% else %} + {% endmatch %} + {% match entry.icon %} + {% when "folder" %} {{ entry.name }} + {% else %} {{ entry.name }} + {% endmatch %} + {{ entry.size }} + +
+
+
+
+
+

CONFIRMATION:

+

This will (irreversibly) delete the file! Are you sure?

+ + +
+
+
+{% endblock %} + +{% block script %} +{% include "js/contentFolder.js" %} +{% endblock %} diff --git a/templates/html/base.html b/templates/html/mod/base.html similarity index 53% rename from templates/html/base.html rename to templates/html/mod/base.html index 99e74d7..9019559 100644 --- a/templates/html/base.html +++ b/templates/html/mod/base.html @@ -2,11 +2,12 @@ - Pack - - + {% block title %}Pack{% endblock %} + + +
@@ -14,29 +15,30 @@
- - - - + + + +
- {% include "svgTape.svg" %} + {% include "html/mod/svgTape.svg" %}
-
{% include "labelTop.html" %}
+
{% include "html/mod/labelTop.html" %}

+ {% if force_msg %} +

{{ content_text | upper }}

+ {% else %}

{{ content_text | upper }}

+ {% endif %} {% block content %}{% endblock %}
- {% include "labelBot.svg" %} + {% include "html/mod/labelBot.svg" %} {{ config.domain }}
- + diff --git a/templates/html/labelBot.svg b/templates/html/mod/labelBot.svg similarity index 100% rename from templates/html/labelBot.svg rename to templates/html/mod/labelBot.svg diff --git a/templates/html/labelTop.html b/templates/html/mod/labelTop.html similarity index 83% rename from templates/html/labelTop.html rename to templates/html/mod/labelTop.html index 8773b2c..e9aa023 100644 --- a/templates/html/labelTop.html +++ b/templates/html/mod/labelTop.html @@ -1,14 +1,14 @@
- - - - - - - - - + + + + + + + + +
diff --git a/templates/html/svgTape.svg b/templates/html/mod/svgTape.svg similarity index 100% rename from templates/html/svgTape.svg rename to templates/html/mod/svgTape.svg diff --git a/templates/html/notFound.html b/templates/html/notFound.html new file mode 100644 index 0000000..4bf5d40 --- /dev/null +++ b/templates/html/notFound.html @@ -0,0 +1,11 @@ +{% extends "mod/base.html" %} + +{% block title %}Pack - 404{% endblock %} + +{% block style %} +{% include "css/notFound.css" %} +{% endblock %} + +{% block script %} +{% include "js/notFound.js" %} +{% endblock %} diff --git a/templates/html/refresh.html b/templates/html/refresh.html new file mode 100644 index 0000000..d833730 --- /dev/null +++ b/templates/html/refresh.html @@ -0,0 +1,9 @@ +{% extends "mod/base.html" %} + +{% block style %} +{% include "css/refresh.css" %} +{% endblock %} + +{% block script %} +{% include "js/refresh.js" %} +{% endblock %} diff --git a/templates/js/default.js b/templates/js/contentDefault.js similarity index 74% rename from templates/js/default.js rename to templates/js/contentDefault.js index 184a835..46df706 100644 --- a/templates/js/default.js +++ b/templates/js/contentDefault.js @@ -1,50 +1,9 @@ -const STATES = { // Either the non-d - auth: "{{ state.auth }}", - error: "{{ state.error }}", - loading: "{{ state.loading }}", - repoSelected: "{{ state.repo_selected }}", - repoCreated: "{{ state.repo_created }}", - buttonOn: "{{ state.button_on }}", - featureOn: "{{ state.feature_on }}", - confirmOn: "{{ state.confirm_on }}", -} +{% include "js/mod/base.js" %} +{% include "js/mod/token.js" %} +{% include "js/mod/middleware.js" %} +{% include "js/mod/confirm.js" %} -var G_REPO_VALUE = "", - G_CONFIRM_VALUE = "", - G_CONFIRM = -1; - -// UTILITY // - -// Parse a query string into an object -function parseQueryString(string) { - if(string == "") { return {}; } - var segments = string.split("&").map(s => s.split("=") ); - var queryString = {}; - segments.forEach(s => queryString[s[0]] = s[1]); - return queryString; -} - -// BUTTON ONCLICK MIDDLEWARE // - -// Refresh token if necessary. -function tryAuth(f) { - return async (e) => { - e.preventDefault(); - if (!G_AUTH) return; - await tryRefresh(); - await f(e); - } -} - -// Adds loading classes for UI while awaiting. -function doLoading(f) { - return async (e) => { - if (e.target.hasClass("loading")) return; - e.target.addClass(STATES["loading"]); - await f(e); - e.target.delClass(STATES["loading"]); - } -} +var G_REPO_VALUE = ""; // BUTTON ONCLICK HELPERS // @@ -64,26 +23,6 @@ function setRepoActions(s) { get("#repoFeatures").setClass(STATES["repoCreated"], s); } -async function getConfirm() { - get("#confirmOverlay").addClass(STATES["confirmOn"]); - return new Promise((resolve, reject) => { - const check = function() { - if (G_CONFIRM == -1) { - setTimeout(check, 100); - } else { - get("#confirmOverlay").delClass(STATES["confirmOn"]); - resolve(G_CONFIRM); - G_CONFIRM = -1; - } - } - check(); - }); -} - -document.on("keydown", (e) => { - if (e.keyCode == 27) get("#confirmOverlay").delClass(STATES["confirmOn"]); -}); - // BUTTON ONCLICK FUNCTIONS // get("#repoSelectInput").on("keydown", (e) => { @@ -125,7 +64,7 @@ async function selectRepo(e) { }); } - get("#repoLinkInput").value = CONFIG["redirect_uri"] + repo; + get("#repoLinkInput").value = CONFIG["redirect_uri"] + "r/" + repo; get("#repoInfo").addClass(STATES["repoSelected"]); }) diff --git a/templates/js/contentFolder.js b/templates/js/contentFolder.js new file mode 100644 index 0000000..01f6a11 --- /dev/null +++ b/templates/js/contentFolder.js @@ -0,0 +1,42 @@ +{% include "js/mod/base.js" %} +{% include "js/mod/token.js" %} +{% include "js/mod/middleware.js" %} +{% include "js/mod/confirm.js" %} + +get("#confirmButtonNo").on("click", (e) => G_CONFIRM = false ); +get("#confirmButtonYes").on("click", (e) => G_CONFIRM = true ); + +get(".delete").forEach((el) => el.on("click", tryAuth(deleteEntry))); +async function deleteEntry(e) { + let target = e.target.parentElement.parentElement.children[0].children[1]; + target = target.href.replace("/r", "/api/files"); + if (!(await getConfirm())) return; + await jfetch(target, "DELETE") + .then((json) => { + e.target.parentElement.parentElement.remove(); + }) + .catch(getErr((err) => { + console.log(err); + alert("An unexpected error occurred! Check console for more details."); + })); +} + +get("#back").on("click", tryAuth(goBack)); +async function goBack(e) { + let path = window.location.pathname.split("/"); + if (path.length === 5) { // Looking at repo features, so back goes to home. + window.location = "/"; + } else { + window.location = path.slice(0, -2).join("/"); + } +} + +function init() { + let url = window.location.toString(); + if (url[url.length-1] !== "/") { + window.history.pushState({}, "", url + "/"); + } +} + +init(); + diff --git a/templates/js/base.js b/templates/js/mod/base.js similarity index 72% rename from templates/js/base.js rename to templates/js/mod/base.js index 15df7dc..90181a3 100644 --- a/templates/js/base.js +++ b/templates/js/mod/base.js @@ -6,7 +6,16 @@ const CONFIG = { requested_scopes: "read:repository,write:repository" }; -var G_AUTH = window.localStorage.getItem("token") !== null; +const STATES = { + auth: "{{ state.auth }}", + error: "{{ state.error }}", + loading: "{{ state.loading }}", + repoSelected: "{{ state.repo_selected }}", + repoCreated: "{{ state.repo_created }}", + buttonOn: "{{ state.button_on }}", + featureOn: "{{ state.feature_on }}", + confirmOn: "{{ state.confirm_on }}", +} document.on = document.addEventListener; function sugar(obj) { @@ -44,10 +53,20 @@ function getErr(f) { } } +// Parse a query string into an object +function parseQueryString(string) { + if(string == "") { return {}; } + var segments = string.split("&").map(s => s.split("=") ); + var queryString = {}; + segments.forEach(s => queryString[s[0]] = s[1]); + return queryString; +} + get("#login").on("click", async (e) => { e.preventDefault(); if (G_AUTH) { window.localStorage.clear(); + document.cookie = ""; window.location = "/"; } else { // Build the authorization URL @@ -61,3 +80,6 @@ get("#login").on("click", async (e) => { window.location = url; } }); + +var G_AUTH = window.localStorage.getItem("token") !== null; +get("body").setClass(STATES["auth"], G_AUTH); diff --git a/templates/js/mod/confirm.js b/templates/js/mod/confirm.js new file mode 100644 index 0000000..2ab7ee2 --- /dev/null +++ b/templates/js/mod/confirm.js @@ -0,0 +1,22 @@ +var G_CONFIRM_VALUE = "", + G_CONFIRM = -1; + +async function getConfirm() { + get("#confirmOverlay").addClass(STATES["confirmOn"]); + return new Promise((resolve, reject) => { + const check = function() { + if (G_CONFIRM == -1) { + setTimeout(check, 100); + } else { + get("#confirmOverlay").delClass(STATES["confirmOn"]); + resolve(G_CONFIRM); + G_CONFIRM = -1; + } + } + check(); + }); +} + +document.on("keydown", (e) => { + if (e.keyCode == 27) get("#confirmOverlay").delClass(STATES["confirmOn"]); +}); diff --git a/templates/js/mod/middleware.js b/templates/js/mod/middleware.js new file mode 100644 index 0000000..974f6b4 --- /dev/null +++ b/templates/js/mod/middleware.js @@ -0,0 +1,19 @@ +// Refresh token if necessary. +function tryAuth(f) { + return async (e) => { + e.preventDefault(); + if (!G_AUTH) return; + await tryRefresh(); + await f(e); + } +} + +// Adds loading classes for UI while awaiting. +function doLoading(f) { + return async (e) => { + if (e.target.hasClass("loading")) return; + e.target.addClass(STATES["loading"]); + await f(e); + e.target.delClass(STATES["loading"]); + } +} diff --git a/templates/js/token.js b/templates/js/mod/token.js similarity index 93% rename from templates/js/token.js rename to templates/js/mod/token.js index b78ddab..25fcfb1 100644 --- a/templates/js/token.js +++ b/templates/js/mod/token.js @@ -3,7 +3,7 @@ async function jfetch(url, method, obj={}) { let request = { headers: { "Content-Type": "application/json", - "Authorization": window.localStorage.getItem("token"), + "Authorization": `Bearer ${window.localStorage.getItem("token")}`, }, method: method, }; @@ -39,6 +39,7 @@ async function getToken() { window.localStorage.setItem("token", response.access_token); window.localStorage.setItem("token_expiry", (Date.now() + response.expires_in*1000).toString()); window.localStorage.setItem("refresh_token", response.refresh_token); + document.cookie = `token=${response.access_token}`; } async function tryRefresh() { diff --git a/templates/js/notFound.js b/templates/js/notFound.js new file mode 100644 index 0000000..6d5f0c5 --- /dev/null +++ b/templates/js/notFound.js @@ -0,0 +1 @@ +{% include "js/mod/base.js" %} diff --git a/templates/js/refresh.js b/templates/js/refresh.js new file mode 100644 index 0000000..48ad9f9 --- /dev/null +++ b/templates/js/refresh.js @@ -0,0 +1,11 @@ +{% include "js/mod/base.js" %} +{% include "js/mod/token.js" %} + +async function init() { + if (!G_QUERY.target) window.location = "/"; + await tryRefresh(); + window.location = G_QUERY.target; +} + +var G_QUERY = parseQueryString(window.location.search.substring(1)); +init();