feat: added navigation of uploaded files and browsing of inner sites; refactor: HTML/CSS/JS is now templated;

This commit is contained in:
Kenneth Jao 2025-06-02 03:47:42 -04:00
parent e460f88a7f
commit f94ec335dd
28 changed files with 1418 additions and 674 deletions

214
Cargo.lock generated
View File

@ -26,6 +26,21 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.98" version = "1.0.98"
@ -59,7 +74,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"serde", "serde",
"serde_derive", "serde_derive",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -74,6 +89,20 @@ dependencies = [
"winnow", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -88,9 +117,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.1" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"bytes", "bytes",
@ -122,13 +151,24 @@ dependencies = [
] ]
[[package]] [[package]]
name = "axum-core" name = "axum-auth"
version = "0.5.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"bytes", "bytes",
"futures-util", "futures-core",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@ -141,6 +181,29 @@ dependencies = [
"tracing", "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]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.74" version = "0.3.74"
@ -177,6 +240,27 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.17.0" version = "3.17.0"
@ -189,6 +273,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "bytesize"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.19" version = "1.2.19"
@ -204,6 +294,17 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -220,6 +321,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@ -252,7 +362,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -264,16 +374,6 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -296,6 +396,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -659,7 +769,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -890,7 +1000,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -924,10 +1034,10 @@ dependencies = [
"anyhow", "anyhow",
"askama", "askama",
"axum", "axum",
"axum-auth",
"axum-extra",
"bytes", "bytes",
"env_filter", "bytesize",
"log",
"mime",
"rand", "rand",
"reqwest", "reqwest",
"serde", "serde",
@ -938,11 +1048,11 @@ dependencies = [
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
"trace", "tower-service",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"url", "urlencoding",
] ]
[[package]] [[package]]
@ -1264,7 +1374,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -1400,17 +1510,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 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]] [[package]]
name = "syn" name = "syn"
version = "2.0.100" version = "2.0.100"
@ -1439,7 +1538,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -1502,7 +1601,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -1513,7 +1612,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -1591,7 +1690,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -1649,8 +1748,10 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
dependencies = [ dependencies = [
"async-compression",
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
@ -1680,17 +1781,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.41" version = "0.1.41"
@ -1723,7 +1813,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -1819,6 +1909,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf16_iter" name = "utf16_iter"
version = "1.0.5" version = "1.0.5"
@ -1895,7 +1991,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -1930,7 +2026,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -2207,7 +2303,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
"synstructure", "synstructure",
] ]
@ -2228,7 +2324,7 @@ checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]
[[package]] [[package]]
@ -2248,7 +2344,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
"synstructure", "synstructure",
] ]
@ -2277,5 +2373,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.100", "syn",
] ]

View File

@ -8,10 +8,10 @@ authors = ["Kenneth Jao"]
anyhow = "1.0.98" anyhow = "1.0.98"
askama = { version = "0.14.0", features = ["blocks"] } askama = { version = "0.14.0", features = ["blocks"] }
axum = { version = "0.8.1", features = ["multipart"] } axum = { version = "0.8.1", features = ["multipart"] }
axum-auth = "0.8.1"
axum-extra = { version = "0.10.1", features = ["cookie"] }
bytes = "1.10.1" bytes = "1.10.1"
env_filter = "0.1.3" bytesize = "2.0.1"
log = "0.4.27"
mime = "0.3.17"
rand = { version = "0.9.1", features = ["alloc"] } rand = { version = "0.9.1", features = ["alloc"] }
reqwest = { version = "0.12.15", features = ["json"] } reqwest = { version = "0.12.15", features = ["json"] }
serde = { version= "1.0.219", features = ["derive"] } serde = { version= "1.0.219", features = ["derive"] }
@ -21,9 +21,9 @@ sqlite = "0.37.0"
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["fs", "trace"] } tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "fs", "trace"] }
trace = "0.1.7" tower-service = "0.3.3"
tracing = "0.1.41" tracing = "0.1.41"
tracing-appender = "0.2.3" tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
url = "2.5.4" urlencoding = "2.1.3"

View File

@ -1,6 +1,46 @@
# pack # 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 Pack is web server written in [rust](https://www.rust-lang.org/) with
destination endpoint for packages in a continuous integration workflow. It is designed around Gitea, using it for its authentication [axum](https://docs.rs/axum/latest/axum/) for hosting built packages as a
as well as integrating with Gitea actions. 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.

View File

@ -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 bytes::Bytes;
use bytesize::ByteSize;
use std::process::Command; use std::process::Command;
use rand::distr::{Alphanumeric, SampleString}; use rand::distr::{Alphanumeric, SampleString};
use thiserror::Error; use thiserror::Error;
use axum::{ use axum::{
body::Body, body::Body,
extract::{Path, Json, State, Multipart}, extract::{Path, Json, State, Multipart},
http::{StatusCode, header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}}, http::{
response::{IntoResponse, Response}, 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::{ use reqwest::{
Client, Method, Request, RequestBuilder, Url Client, Method, Request, RequestBuilder, Url
}; };
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use serde_json::Value; use serde_json::Value;
use tower_http::services::ServeFile;
use crate::{query, ServerState}; use crate::{query, ServerState};
static FOLDER_PERM: u32 = 0o750;
static FILE_PERM: u32 = 0o640;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
#[repr(u8)] #[repr(u8)]
pub enum RepoFeature { pub enum RepoFeature {
@ -48,7 +68,7 @@ impl From<RepoFeature> for String {
} }
impl TryFrom<&str> for RepoFeature { impl TryFrom<&str> for RepoFeature {
type Error = APIError; type Error = ApiError;
fn try_from(value: &str) -> Result<Self> { fn try_from(value: &str) -> Result<Self> {
for feature in RepoFeature::iter() { for feature in RepoFeature::iter() {
@ -56,13 +76,30 @@ impl TryFrom<&str> for RepoFeature {
return Ok(*feature); 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)] #[derive(Clone, Copy, Debug, Error)]
pub enum APIError { 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}")] #[error("Request error: {0}")]
RequestError(#[from] reqwest::Error), RequestError(#[from] reqwest::Error),
#[error("SQL error: {0}")] #[error("SQL error: {0}")]
@ -81,18 +118,18 @@ pub enum APIError {
Unauthorized { repo: String }, Unauthorized { repo: String },
#[error("A token was not provided or is malformed")] #[error("A token was not provided or is malformed")]
Tokenless, Tokenless,
#[error("{msg}")] #[error("{0}")]
Other { msg: String }, CustomError(#[from] CustomError)
} }
#[repr(u16)] static ERR_GITEA_CONTENT_TYPE: CustomError = CustomError("Content-Type in Gitea response invalid", ErrorCode::External);
pub enum ErrorCode { static ERR_MISSING_PARENT: CustomError = CustomError("Could not find parent in repo directory. Should be impossible...", ErrorCode::Filesystem);
InvalidToken = 0, // Invalid OAuth token. static ERR_UNTAR: CustomError = CustomError("Failed to complete untar", ErrorCode::Filesystem);
External = 1, // Error originates from an external location/server. static ERR_OS_STR_UTF: CustomError = CustomError("OsString failed to parse into UTF-8", ErrorCode::Filesystem);
Filesystem = 2, // Error originates from filesystem operation. static ERR_FILE_CALL_UTF: CustomError = CustomError("file command output failed to parse into UTF-8", ErrorCode::Filesystem);
Sql = 3, // Error originates from SQL. static ERR_FILE_CALL_UNEXPECTED: CustomError = CustomError("file command output had unexpected output", ErrorCode::Filesystem);
BadRequest = 4, // Error originates from client request. 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)] #[derive(Serialize)]
pub struct ErrorResponse { pub struct ErrorResponse {
@ -101,11 +138,11 @@ pub struct ErrorResponse {
message: String, message: String,
} }
type Result<T, E = APIError> = core::result::Result<T, E>; pub type Result<T, E = ApiError> = core::result::Result<T, E>;
impl IntoResponse for APIError { impl IntoResponse for ApiError {
fn into_response(self) -> Response<Body> { fn into_response(self) -> Response<Body> {
let (status, code) = match self { let (status, code) = match self {
APIError::RequestError(ref err) => { ApiError::RequestError(ref err) => {
let status = err.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); let status = err.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let action = match status { let action = match status {
StatusCode::FORBIDDEN => ErrorCode::InvalidToken, StatusCode::FORBIDDEN => ErrorCode::InvalidToken,
@ -113,15 +150,15 @@ impl IntoResponse for APIError {
}; };
(status, action) (status, action)
}, },
APIError::SQLError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Sql), ApiError::SQLError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Sql),
APIError::IOError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Filesystem), ApiError::IOError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Filesystem),
APIError::MultipartError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::BadRequest), ApiError::MultipartError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::BadRequest),
APIError::InvalidJson{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External), ApiError::InvalidJson{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External),
APIError::InvalidFeature{..} => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest), ApiError::InvalidFeature{..} => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest),
APIError::DisabledFeature{..} => (StatusCode::NOT_FOUND, ErrorCode::BadRequest), ApiError::DisabledFeature{..} => (StatusCode::NOT_FOUND, ErrorCode::BadRequest),
APIError::Unauthorized{..} => (StatusCode::FORBIDDEN, ErrorCode::BadRequest), ApiError::Unauthorized{..} => (StatusCode::FORBIDDEN, ErrorCode::BadRequest),
APIError::Tokenless => (StatusCode::FORBIDDEN, ErrorCode::InvalidToken), ApiError::Tokenless => (StatusCode::FORBIDDEN, ErrorCode::InvalidToken),
APIError::Other{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External), ApiError::CustomError(ref e) => (StatusCode::INTERNAL_SERVER_ERROR, e.1),
}; };
(status, Json(ErrorResponse{ (status, Json(ErrorResponse{
status: u16::from(status), status: u16::from(status),
@ -160,7 +197,7 @@ async fn gitea_api<T, U>(url: &str, method: Method, payload: &T, token: &str) ->
content_type = res.headers().get(CONTENT_TYPE) content_type = res.headers().get(CONTENT_TYPE)
.unwrap() .unwrap()
.to_str() .to_str()
.map_err(|_| APIError::Other { msg: "Content-Type in Gitea response invalid".to_owned() })? .map_err(|_| ERR_GITEA_CONTENT_TYPE )?
.to_owned(); .to_owned();
} }
@ -171,7 +208,7 @@ async fn gitea_api<T, U>(url: &str, method: Method, payload: &T, token: &str) ->
} }
} }
async fn authorize(host: &str, token: &str, repo: &str) -> Result<Json<Value>> { async fn authorize(host: &str, token: &str, repo: &str, kind: &str) -> Result<Json<Value>> {
// Use repos API call to check admin permission. Return the repo JSON as // Use repos API call to check admin permission. Return the repo JSON as
// well in case it needs to be used later. // well in case it needs to be used later.
let url = format!("{host}/api/v1/repos/{repo}"); let url = format!("{host}/api/v1/repos/{repo}");
@ -179,15 +216,15 @@ async fn authorize(host: &str, token: &str, repo: &str) -> Result<Json<Value>> {
let json: Value = gitea_api(&url, Method::GET, &data, token).await?; 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") json.get("permissions")
.ok_or(APIError::InvalidJson{ msg: "Couldn't find 'permissions' key.".to_owned() })? .ok_or(ApiError::InvalidJson{ msg: "Couldn't find 'permissions' key.".to_owned() })?
.get("admin") .get(kind)
.ok_or(APIError::InvalidJson{ msg: "Couldn't find 'admin' key.".to_owned() })? .ok_or(ApiError::InvalidJson{ msg: format!("Couldn't find '{kind}' key.") })?
.as_bool() .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) .then_some(0)
.ok_or(APIError::Unauthorized{ repo: repo.to_owned() })?; .ok_or(ApiError::Unauthorized{ repo: repo.to_owned() })?;
Ok(Json(json)) Ok(Json(json))
} }
@ -264,26 +301,17 @@ pub struct RepoResponse {
features: FeatureList, features: FeatureList,
} }
fn extract_token(headers: HeaderMap) -> Result<String> {
Ok(headers.get(AUTHORIZATION)
.ok_or(APIError::Tokenless)?
.to_str()
.map_err(|_| APIError::Tokenless)?
.to_owned())
}
pub async fn get_repo(State(state): State<ServerState<'_>>, pub async fn get_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>, Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap) -> Result<Json<RepoResponse>> { AuthBearer(token): AuthBearer) -> Result<Json<RepoResponse>> {
let repo = format!("{owner}/{repo}"); let repo = format!("{owner}/{repo}");
let token = extract_token(headers)?;
// Pull repository information from Gitea. // 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") 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() .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(); .to_owned();
// Check if entry exists and return features. // Check if entry exists and return features.
@ -309,11 +337,10 @@ pub struct GiteaSetSecret {
pub async fn create_repo(State(state): State<ServerState<'_>>, pub async fn create_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>, Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap, AuthBearer(token): AuthBearer,
) -> Result<()> { ) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let token = extract_token(headers)?; let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?;
let _ = authorize(&state.config.gitea_host, &token, &repo).await?;
// Create secret and insert new repository into database. // Create secret and insert new repository into database.
let secret = Alphanumeric.sample_string(&mut rand::rng(), 48); let secret = Alphanumeric.sample_string(&mut rand::rng(), 48);
@ -325,10 +352,20 @@ pub async fn create_repo(State(state): State<ServerState<'_>>,
(3, secret.clone().into()), (3, secret.clone().into()),
])? ])?
.map(|_| 0) .map(|_| 0)
.sum(); // Evalute statement. .sum(); // Evaluate statement.
// Make associated folder. // 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. // Add secret to Gitea secrets.
let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, &repo); 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<ServerState<'_>>,
} }
pub async fn delete_repo(State(state): State<ServerState<'_>>, pub async fn delete_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>, Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap, AuthBearer(token): AuthBearer,
) -> Result<()> { ) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let token = extract_token(headers)?; let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?;
let _ = authorize(&state.config.gitea_host, &token, &repo).await?;
let _: i32 = state.sql.prepare(crate::query::DELETE_REPO)? let _: i32 = state.sql.prepare(crate::query::DELETE_REPO)?
.iter() .iter()
.bind((1, repo.as_str()))? .bind((1, repo.as_str()))?
.map(|_| 0) .map(|_| 0)
.sum(); // Evalute statement. .sum(); // Evaluate statement.
// Remove entire directory and its parent if its empty. // Remove entire directory and its parent if its empty.
let dir = path::Path::new(&state.config.upload_path).join(&repo); let dir = path::Path::new(&state.config.upload_path).join(&repo);
let parent = dir.as_path() let parent = dir.as_path()
.parent() .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)?; fs::remove_dir_all(&dir)?;
if fs::read_dir(parent)?.next().is_none() { 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()), (2, repo.into()),
])? ])?
.map(|_| 0) .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 secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, repo);
let data = GiteaSetSecret { data: secret }; let data = GiteaSetSecret { data: secret };
@ -406,13 +442,14 @@ async fn update_feature(state: &ServerState<'_>, repo: &str, feature: &str) -> R
.map(|row| -> Result<u64> { .map(|row| -> Result<u64> {
Ok(row?.read::<i64, _>("features") as u64) Ok(row?.read::<i64, _>("features") as u64)
}) })
.collect::<Result<Vec<u64>>>()?[0]; // Evalute statement. .collect::<Result<Vec<u64>>>()?[0]; // Evaluate statement.
// Check added or removed and update folders accordingly. // Check added or removed and update folders accordingly.
let added = (features & feat_num) == feat_num; let added = (features & feat_num) == feat_num;
let dir = path::Path::new(&state.config.upload_path).join(repo).join(feature); let dir = path::Path::new(&state.config.upload_path).join(repo).join(feature);
if added { if added {
fs::create_dir(dir)?; fs::create_dir(&dir)?;
fs::set_permissions(dir, Permissions::from_mode(FOLDER_PERM))?;
} else { } else {
fs::remove_dir_all(dir)?; 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<ServerState<'_>>, pub async fn patch_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>, Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap, AuthBearer(token): AuthBearer,
Json(payload): Json<PatchRepoRequest>) -> Result<()> { Json(payload): Json<PatchRepoRequest>) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let token = extract_token(headers)?; let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?;
let _ = authorize(&state.config.gitea_host, &token, &repo).await?;
if payload.secret { if payload.secret {
return update_secret(&state, &repo, &token).await; return update_secret(&state, &repo, &token).await;
@ -433,6 +469,119 @@ pub async fn patch_repo(State(state): State<ServerState<'_>>,
update_feature(&state, &repo, &payload.feature).await 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<fs::DirEntry>) -> Result<Entry> {
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::<Vec<_>>();
// 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<Entry>,
}
pub async fn get_entry(State(state): State<ServerState<'_>>,
Path((owner, repo, path)): Path<(String, String, String)>,
AuthBearer(token): AuthBearer) -> Result<Response> {
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::<Result<Vec<Entry>>>()?;
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<ServerState<'_>>,
Path((owner, repo,path)): Path<(String, String, String)>,
AuthBearer(token): AuthBearer) -> Result<StatusCode> {
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)] #[derive(Default)]
struct UploadFile { struct UploadFile {
name: String, name: String,
@ -442,11 +591,10 @@ struct UploadFile {
pub async fn upload(State(state): State<ServerState<'_>>, pub async fn upload(State(state): State<ServerState<'_>>,
Path((owner, repo, feature)): Path<(String, String, String)>, Path((owner, repo, feature)): Path<(String, String, String)>,
headers: HeaderMap, AuthBearer(user_secret): AuthBearer,
mut multipart: Multipart) -> Result<()> { mut multipart: Multipart) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive. let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let feature = feature.to_lowercase(); 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)? let (secret, features) = &state.sql.prepare(crate::query::UPLOAD_QUERY)?
.iter() .iter()
@ -461,10 +609,10 @@ pub async fn upload(State(state): State<ServerState<'_>>,
.collect::<Result<Vec<(String, FeatureList)>>>()?[0]; .collect::<Result<Vec<(String, FeatureList)>>>()?[0];
(user_secret == *secret).then_some(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) features.contains(&feature).then_some(0)
.ok_or(APIError::DisabledFeature{ feature: feature.clone() })?; .ok_or(ApiError::DisabledFeature{ feature: feature.clone() })?;
// Process multipart. // Process multipart.
let mut file = UploadFile{ folder: "".to_owned(), ..Default::default() }; let mut file = UploadFile{ folder: "".to_owned(), ..Default::default() };
@ -483,7 +631,10 @@ pub async fn upload(State(state): State<ServerState<'_>>,
} }
let dir = path::Path::new(&state.config.upload_path).join(&repo).join(feature); 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); let tardir = dir.join(&file.folder);
if file.folder != *"" { if file.folder != *"" {
if fs::exists(&tardir)? { if fs::exists(&tardir)? {
@ -491,15 +642,127 @@ pub async fn upload(State(state): State<ServerState<'_>>,
} }
fs::create_dir(&tardir)?; 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()?; .output()?;
// If there was an error, remove everything because the operation was unsucessful. // If there was an error, remove everything because the operation was unsucessful.
if !output.status.success() { if !output.status.success() {
fs::remove_dir_all(&tardir)?; fs::remove_dir_all(&tardir)?;
fs::remove_file(dir.join(file.name))?; fs::remove_file(&filepath)?;
return Err(APIError::Other{ msg: "Failed to complete untar".to_owned() }) 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(()) 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<ServerState<'_>>,
Path((owner, path)): Path<(String, String)>,
headers: HeaderMap,
jar: CookieJar,
mut request: axum::extract::Request,
next: Next) -> Result<Response> {
*request.uri_mut() = format!("/{owner}/{path}").parse::<Uri>().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::<Uri>().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::<Result<Vec<Entry>>>()?;
entries.sort_by(sort_entry);
Ok(Html(crate::compile::content_folder(&state.config, target, &entries, is_admin, no_delete)?).into_response())
}

View File

@ -14,34 +14,7 @@ struct WebStates<'a> {
confirm_on: &'a str, confirm_on: &'a str,
} }
#[derive(Template)] static STATES: WebStates = WebStates {
#[template(
ext = "html",
path = "html/contentDefault.html",
whitespace = "suppress"
)]
struct Default<'a> {
config: crate::ServerConfig,
features: Vec<String>,
content_text: String,
state: WebStates<'a>,
}
#[derive(Template)]
#[template(ext = "html", path = "html/base.html", whitespace = "suppress")]
struct NotFound {
config: crate::ServerConfig,
content_text: String,
}
#[derive(Template)]
#[template(path = "index.css", escape = "txt", whitespace = "suppress")]
struct Css<'a> {
state: WebStates<'a>,
}
pub fn make_templates(config: &crate::ServerConfig) -> Result<()> {
let states = WebStates {
auth: "auth", auth: "auth",
error: "error", error: "error",
loading: "loading", loading: "loading",
@ -52,35 +25,53 @@ pub fn make_templates(config: &crate::ServerConfig) -> Result<()> {
confirm_on: "enabled", confirm_on: "enabled",
}; };
let default = Default { #[derive(Template)]
config: config.clone(), #[template(
features: crate::api::RepoFeature::iter() ext = "html",
.map(|f| f.to_string()) path = "html/contentDefault.html",
.collect(), whitespace = "suppress"
content_text: "awaiting authorization...".to_owned(), )]
state: states, struct ContentDefault<'a> {
config: &'a crate::ServerConfig,
features: Vec<String>,
content_text: String,
force_msg: bool,
state: WebStates<'a>,
} }
.render()
.context("Unable to process 'Default' template")?;
fs::write("static/index.html", default).context("Could not write to static/index.html")?; #[derive(Template)]
#[template(ext = "html", path = "html/notFound.html", whitespace = "suppress")]
let not_found = NotFound { struct NotFound<'a> {
config: config.clone(), config: &'a crate::ServerConfig,
content_text: "404: not found...".to_owned(), content_text: String,
force_msg: bool,
state: WebStates<'a>,
} }
.render()
.context("Unable to process 'NotFound' template.")?;
fs::write("static/404.html", not_found).context("Could not write to static/404.html")?; #[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 css = Css { state: states } #[derive(Template)]
.render() #[template(
.context("Unable to process 'CSS' template.")?; ext = "html",
path = "html/contentFolder.html",
fs::write("static/index.css", css).context("Could not write to static/index.css")?; whitespace = "suppress"
)]
Ok(()) struct ContentFolder<'a> {
config: &'a crate::ServerConfig,
content_text: String,
force_msg: bool,
state: WebStates<'a>,
target: String,
entries: &'a Vec<crate::api::Entry>,
is_admin: bool,
no_delete: bool,
} }
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> { fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
@ -98,7 +89,65 @@ fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()>
} }
pub fn copy_assets() -> 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'.")?; copy_dir_all("assets", "static").context("Could not copy 'assets' into 'static'.")?;
Ok(()) Ok(())
} }
pub fn content_folder(
config: &crate::ServerConfig,
target: String,
entries: &Vec<crate::api::Entry>,
is_admin: bool,
no_delete: bool,
) -> crate::api::Result<String> {
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(())
}

View File

@ -1,4 +1,5 @@
use std::{ use std::{
convert::Infallible,
fs, fs,
net::SocketAddr, net::SocketAddr,
}; };
@ -6,10 +7,15 @@ use anyhow::{Context, Result};
use axum::{ use axum::{
body::Body, body::Body,
extract::{ConnectInfo, Request, DefaultBodyLimit}, extract::{ConnectInfo, Request, DefaultBodyLimit},
response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
middleware::{from_fn, from_fn_with_state},
Router, Router,
http::StatusCode,
}; };
use tower::{ServiceBuilder, service_fn};
use tower_http::{ use tower_http::{
compression::CompressionLayer,
services::{ServeDir, ServeFile}, services::{ServeDir, ServeFile},
trace::TraceLayer, trace::TraceLayer,
}; };
@ -29,7 +35,7 @@ mod util;
type SQLConn = sqlite::ConnectionThreadSafe; type SQLConn = sqlite::ConnectionThreadSafe;
// type RepoMap = util::TimedHashMap<String, Vec<String>>; type TokenMap = util::TimedHashMap<String, bool>;
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
struct ServerConfig { struct ServerConfig {
@ -48,6 +54,7 @@ struct ServerConfig {
struct ServerState<'a> { struct ServerState<'a> {
config: ServerConfig, config: ServerConfig,
sql: &'a SQLConn, sql: &'a SQLConn,
token_map: &'a TokenMap,
} }
mod query { mod query {
@ -91,7 +98,8 @@ async fn main() -> Result<()> {
compile::make_templates(&config)?; compile::make_templates(&config)?;
let sql: &'static sqlite::ConnectionThreadSafe = Box::leak(Box::new(sql)); 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() let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))?; .or_else(|_| EnvFilter::try_new("info"))?;
@ -122,22 +130,47 @@ async fn main() -> Result<()> {
"request", "request",
method = format!("{}", request.method()), method = format!("{}", request.method()),
version = format!("{:?}", request.version()), version = format!("{:?}", request.version()),
path = request.uri().to_string(),
remote_ip = addr, 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<Response, Infallible> {
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 // Build routes
let api_routes = Router::new() let api_routes = Router::new()
.route("/token", post(api::token).patch(api::refresh_token)) .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("/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("/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); .with_state(server_state);
let app = Router::new() let app = Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
.route_service("/", ServeFile::new("static/index.html"))
.nest_service("/assets", ServeDir::new("static")) .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); .layer(trace);
// Run server. // Run server.

View File

@ -1,65 +1,74 @@
// use std::{ use std::{
// collections::HashMap, collections::HashMap,
// hash::Hash, hash::Hash,
// marker::Send, marker::Send,
// sync::mpsc::{channel, Sender, TryRecvError}, sync::mpsc::{channel, Sender, TryRecvError},
// sync::{Arc, Mutex}, sync::{Arc, Mutex},
// thread, thread,
// time::{Duration, SystemTime}, time::{Duration, SystemTime},
// }; };
// pub struct TimedHashMap<K, V> { pub struct TimedHashMap<K, V> {
// map: Arc<Mutex<HashMap<K, (SystemTime, V)>>>, map: Arc<Mutex<HashMap<K, (SystemTime, V)>>>,
// duration: Arc<Mutex<Duration>>, duration: Arc<Mutex<Duration>>,
// tx: Sender<i32>, _tx: Sender<i32>, // Needs to stay alive for the duration of the struct.
// } }
// // Insert and cleanup send message to a main thread // Insert and cleanup send message to a main thread
// // the main looping thread which reads messages to know its next action // the main looping thread which reads messages to know its next action
// impl<K, V> TimedHashMap<K, V> impl<K, V> TimedHashMap<K, V>
// where where
// K: std::cmp::Eq + Hash + Send + 'static, K: std::cmp::Eq + Hash + Send + 'static,
// V: Send + 'static, V: Send + Clone + 'static,
// { {
// pub fn new() -> Self { pub fn new() -> Self {
// let (tx, rx) = channel(); let (tx, rx) = channel();
// let thp = Self { let thp = Self {
// map: Arc::new(Mutex::new(HashMap::<K, (SystemTime, V)>::new())), map: Arc::new(Mutex::new(HashMap::<K, (SystemTime, V)>::new())),
// duration: Arc::new(Mutex::new(Duration::new(60, 0))), duration: Arc::new(Mutex::new(Duration::new(30, 0))),
// tx, _tx: tx,
// }; };
// let map2 = Arc::clone(&thp.map); let map2 = Arc::clone(&thp.map);
// let dur2 = Arc::clone(&thp.duration); let dur2 = Arc::clone(&thp.duration);
// // Cleanup thread. // Cleanup thread.
// thread::spawn(move || loop { thread::spawn(move || loop {
// thread::sleep(*dur2.lock().unwrap()); thread::sleep(*dur2.lock().unwrap());
// map2.lock().unwrap().retain(|_, v| v.0 < SystemTime::now()); map2.lock().unwrap().retain(|_, v| v.0 < SystemTime::now());
// match rx.try_recv() { match rx.try_recv() {
// // Thread should get killed when tx drops. // Thread should get killed when tx drops.
// Ok(_) | Err(TryRecvError::Disconnected) => break, Ok(_) | Err(TryRecvError::Disconnected) => break,
// Err(TryRecvError::Empty) => {} Err(TryRecvError::Empty) => {}
// } }
// }); });
// thp thp
// } }
// pub fn insert(&mut self, key: K, value: V, time: Duration) -> Option<V> { pub fn insert(&self, key: K, value: V, time: Duration) -> Option<V> {
// let map2 = Arc::clone(&self.map); let map2 = Arc::clone(&self.map);
// match map2 match map2
// .lock() .lock()
// .unwrap() .unwrap()
// .insert(key, (SystemTime::now() + time, value)) .insert(key, (SystemTime::now() + time, value))
// { {
// Some(x) => Some(x.1), Some(x) => Some(x.1),
// None => None, None => None,
// } }
// } }
pub fn get(&self, key: &K) -> Option<V> {
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) { // pub fn set_interval(&mut self, duration: Duration) {
// let dur2 = Arc::clone(&self.duration); // let dur2 = Arc::clone(&self.duration);
// *dur2.lock().unwrap() = duration // *dur2.lock().unwrap() = duration
// } // }
// } }

View File

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

View File

@ -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%;
}
}
}
}
}

View File

@ -12,6 +12,10 @@
--cl-lines: black; --cl-lines: black;
--cl-login-hover: rgba(255, 255, 255, 0.8); --cl-login-hover: rgba(255, 255, 255, 0.8);
--cl-null-text: #9b9b9b; --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 */ /* Dark Colors */
--cd-bg: #111; --cd-bg: #111;
@ -24,6 +28,10 @@
--cd-lines: #4d4d4d; --cd-lines: #4d4d4d;
--cd-login-hover: rgba(0, 0, 0, 0.5); --cd-login-hover: rgba(0, 0, 0, 0.5);
--cd-null-text: #9b9b9b; --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; --c-loading: #d6d6d6;
@ -38,6 +46,10 @@
--c-lines: var(--cl-lines); --c-lines: var(--cl-lines);
--c-login-hover: var(--cl-login-hover); --c-login-hover: var(--cl-login-hover);
--c-null-text: var(--cl-null-text); --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-0: 0;
--p-theme-switch-1: 1; --p-theme-switch-1: 1;
@ -54,6 +66,10 @@
--c-lines: var(--cd-lines); --c-lines: var(--cd-lines);
--c-login-hover: var(--cd-login-hover); --c-login-hover: var(--cd-login-hover);
--c-null-text: var(--cd-null-text); --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-0: 1;
--p-theme-switch-1: 0; --p-theme-switch-1: 0;
@ -74,6 +90,10 @@
--c-lines: var(--cl-lines); --c-lines: var(--cl-lines);
--c-login-hover: var(--cl-login-hover); --c-login-hover: var(--cl-login-hover);
--c-null-text: var(--cl-null-text); --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) { @media (prefers-color-scheme: light) {
@ -87,6 +107,10 @@
--c-lines: var(--cd-lines); --c-lines: var(--cd-lines);
--c-login-hover: var(--cd-login-hover); --c-login-hover: var(--cd-login-hover);
--c-null-text: var(--cd-null-text); --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,20 +163,29 @@ body {
--w: 6deg; --w: 6deg;
--spokes: 10; --spokes: 10;
input { > input {
width: 100%; height: 100%; width: 100%; height: 100%;
position: absolute; position: absolute;
opacity: 0; opacity: 0;
cursor: pointer; cursor: pointer;
&:hover ~ span:nth-child(2) {
filter: brightness(110%);
} }
span { &: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 {
transition: opacity 0.15s ease-in-out; transition: opacity 0.15s ease-in-out;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
}
span:nth-child(2) { &:nth-of-type(1) {
width: 100%; height: 100%; width: 100%; height: 100%;
top: 0; left: 0; top: 0; left: 0;
background-color: var(--day-color); background-color: var(--day-color);
@ -161,16 +194,15 @@ body {
filter 0.15s ease-in-out; filter 0.15s ease-in-out;
} }
span:nth-child(3) { &:nth-of-type(2) {
width: 4rem; height: 4rem; width: 4rem; height: 4rem;
position: absolute; position: absolute;
top: 3rem; left: 3rem; top: 3rem; left: 3rem;
background-color: var(--sun-color); background-color: var(--sun-color);
border-radius: 50%; border-radius: 50%;
opacity: var(--p-theme-switch-1); opacity: var(--p-theme-switch-1);
}
span:nth-child(3)::before { &::before {
content: ""; content: "";
width: 7rem; height: 7rem; width: 7rem; height: 7rem;
position: absolute; position: absolute;
@ -182,8 +214,9 @@ body {
var(--sun-color) calc(360deg/var(--spokes)) var(--sun-color) calc(360deg/var(--spokes))
); );
} }
}
span:nth-child(4) { &:nth-of-type(3) {
width: 6rem; height: 6rem; width: 6rem; height: 6rem;
top: 1.75rem; left: 1.75rem; top: 1.75rem; left: 1.75rem;
background-color: var(--moon-color); background-color: var(--moon-color);
@ -194,21 +227,6 @@ body {
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: 166.66% 166.66%; 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);
} }
} }
@ -230,31 +248,31 @@ body {
z-index: 1; z-index: 1;
user-select: none; user-select: none;
img { > img {
display: block; display: block;
position: absolute; position: absolute;
}
:nth-child(1) { &:nth-of-type(1) {
width: 30rem; width: 30rem;
top: 6rem; left: 263rem; top: 6rem; left: 263rem;
} }
:nth-child(2) { &:nth-of-type(2) {
width: 30rem; width: 30rem;
top: 6rem; left: 228rem; top: 6rem; left: 228rem;
} }
:nth-child(3) { &:nth-of-type(3) {
width: 35rem; width: 35rem;
top: 162rem; left: 257rem; top: 162rem; left: 257rem;
} }
:nth-child(4) { &:nth-of-type(4) {
width: 25rem; width: 25rem;
top: 165rem; left: 10rem; top: 165rem; left: 10rem;
} }
} }
}
#packageTape { #packageTape {
margin: auto; margin: auto;
@ -262,7 +280,7 @@ body {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
text { > text {
font: 1.5px var(--font-family); font: 1.5px var(--font-family);
fill: rgba(255, 255, 255, 0.4); fill: rgba(255, 255, 255, 0.4);
user-select: none; user-select: none;
@ -300,48 +318,46 @@ body {
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
border-bottom: 0.5rem solid var(--c-lines); border-bottom: 0.5rem solid var(--c-lines);
> div:nth-child(1) { > div:nth-of-type(1) {
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-right: 0.5rem solid var(--c-lines); border-right: 0.5rem solid var(--c-lines);
svg { > svg {
width: 80%; height: 80%; width: 80%; height: 80%;
} }
} }
> div:nth-child(2) { > div:nth-of-type(2) {
padding: 3%; padding: 3%;
display: grid; display: grid;
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
> div:nth-child(1) { > div:nth-of-type(1) {
display: block; display: block;
color: var(--c-dec-text); color: var(--c-dec-text);
user-select: none; user-select: none;
p { > p {
margin: 0; margin: 0;
font-size: 150%; font-size: 150%;
}
p:nth-child(1) { &:nth-of-type(1) { display: inline-block; }
display: inline-block; &:nth-of-type(2) {
}
p:nth-child(2) {
display: inline-block; display: inline-block;
float: right; float: right;
} }
}
} }
> div:nth-child(2) { > div:nth-of-type(2) {
width: 80%; width: 80%;
position: relative; position: relative;
svg { > svg {
position: absolute; position: absolute;
left: 0; top: 0; left: 0; top: 0;
width: 35rem; height: 7rem; width: 35rem; height: 7rem;
@ -379,7 +395,7 @@ body {
border-bottom: 0.5rem solid var(--c-lines); border-bottom: 0.5rem solid var(--c-lines);
user-select: none; user-select: none;
h1 { > h1 {
margin: 1%; margin: 1%;
font-size: 430%; font-size: 430%;
} }
@ -417,213 +433,18 @@ body {
animation: 1.5s infinite normal cursorWait; animation: 1.5s infinite normal cursorWait;
content: "hello"; 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 { #packageLabelBot {
display: grid; display: grid;
grid-template-rows: 6fr 1fr; grid-template-rows: 6fr 1fr;
svg { > svg {
margin: 3% auto 0 auto; margin: 3% auto 0 auto;
width: 80%; height: 85%; width: 80%; height: 85%;
} }
a { > a {
margin: auto; margin: auto;
margin-bottom: 1%; margin-bottom: 1%;
font-size: 2rem; font-size: 2rem;
@ -631,60 +452,41 @@ body {
text-decoration: none; text-decoration: none;
transition: filter 0.15s ease-in-out; transition: filter 0.15s ease-in-out;
filter: brightness(100%); filter: brightness(100%);
}
a:hover { &:hover { filter: brightness(120%); }
filter: brightness(120%);
} }
} }
body { /* Default no authentication */ body { /* Default no authentication */
#packageLabelTitle h1::before { #packageLabelTitle > h1::before { content: "PRIORITY PACKAGING" }
content: "PRIORITY PACKAGING"
}
#packageLabelContent > div { #packageLabelContent > div { display: none; }
display: none;
}
#login { #login {
background: var(--c-label); background: var(--c-label);
color: var(--c-text); color: var(--c-text);
}
#login::before { &::before { content: "LOGIN"; }
content: "LOGIN"; &:hover { background-color: var(--c-login-hover); }
}
#login:hover {
background-color: var(--c-login-hover);
} }
} }
body.{{ state.auth }} { body.{{ state.auth }} {
#packageLabelTitle h1::before { #packageLabelTitle h1::before { content: "PACKAGE INFO" }
content: "PACKAGE INFO"
}
#packageLabelContent > div { #packageLabelContent {
display: block; > div { display: block; }
} > h1 { display: none; }
#packageLabelContent > h1 {
display: none;
} }
#login { #login {
color: transparent; color: transparent;
background:transparent; background:transparent;
}
#login::before { &::before { content: "LOGOUT"; }
content: "LOGOUT"; &:hover {
}
#login:hover {
background: var(--c-label); background: var(--c-label);
color: var(--c-text); color: var(--c-text);
} }
} }
}

View File

@ -0,0 +1 @@
{% include "css/mod/base.css" %}

View File

@ -0,0 +1 @@
{% include "css/mod/base.css" %}

View File

@ -1,4 +1,8 @@
{% extends "base.html" %} {% extends "mod/base.html" %}
{% block style %}
{% include "css/contentDefault.css" %}
{% endblock %}
{% block content %} {% block content %}
<div> <div>
@ -42,6 +46,5 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{% include "js/token.js" %} {% include "js/contentDefault.js" %}
{% include "js/default.js" %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,61 @@
{% extends "mod/base.html" %}
{% block title %}Pack - /r/{{ target }}{% endblock %}
{% block style %}
{% include "css/contentFolder.css" %}
{% endblock %}
{% block content %}
<div>
<table>
<tr>
<th>
<svg id="back" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg>
Name
</th>
<th>Size</th>
{% if is_admin && !no_delete %}
<th></th>
{% endif %}
</tr>
{% for entry in entries %}
<tr>
<td>
{% match entry.icon %}
{% when "folder" %} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M64 480H448c35.3 0 64-28.7 64-64V160c0-35.3-28.7-64-64-64H288c-10.1 0-19.6-4.7-25.6-12.8L243.2 57.6C231.1 41.5 212.1 32 192 32H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64z"/></svg>
{% when "text" %} <svg style="height:3rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM112 256l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z"/></svg>
{% when "archive" %} <svg style="height:3rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM96 48c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm-6.3 71.8c3.7-14 16.4-23.8 30.9-23.8l14.8 0c14.5 0 27.2 9.7 30.9 23.8l23.5 88.2c1.4 5.4 2.1 10.9 2.1 16.4c0 35.2-28.8 63.7-64 63.7s-64-28.5-64-63.7c0-5.5 .7-11.1 2.1-16.4l23.5-88.2zM112 336c-8.8 0-16 7.2-16 16s7.2 16 16 16l32 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-32 0z"/></svg>
{% when "binary" %} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm144 4c-24.3 0-44 19.7-44 44l0 48c0 24.3 19.7 44 44 44l32 0c24.3 0 44-19.7 44-44l0-48c0-24.3-19.7-44-44-44l-32 0zm-4 44c0-2.2 1.8-4 4-4l32 0c2.2 0 4 1.8 4 4l0 48c0 2.2-1.8 4-4 4l-32 0c-2.2 0-4-1.8-4-4l0-48zm140-44c-11 0-20 9-20 20c0 9.7 6.9 17.7 16 19.6l0 76.4c0 11 9 20 20 20s20-9 20-20l0-96c0-11-9-20-20-20l-16 0zM132 296c0 9.7 6.9 17.7 16 19.6l0 76.4c0 11 9 20 20 20s20-9 20-20l0-96c0-11-9-20-20-20l-16 0c-11 0-20 9-20 20zm96 24l0 48c0 24.3 19.7 44 44 44l32 0c24.3 0 44-19.7 44-44l0-48c0-24.3-19.7-44-44-44l-32 0c-24.3 0-44 19.7-44 44zm44-4l32 0c2.2 0 4 1.8 4 4l0 48c0 2.2-1.8 4-4 4l-32 0c-2.2 0-4-1.8-4-4l0-48c0-2.2 1.8-4 4-4z"/></svg>
{% else %} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128z"/></svg>
{% endmatch %}
{% match entry.icon %}
{% when "folder" %} <a href="./{{ entry.name_uri }}/">{{ entry.name }}</a>
{% else %} <a href="./{{ entry.name_uri }}">{{ entry.name }}</a>
{% endmatch %}
</td>
<td>{{ entry.size }}</td>
{% if is_admin && !no_delete %}
<td>
<svg class="delete" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg>
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
<div id="confirmOverlay">
<div>
<div>
<h1>CONFIRMATION:</h1>
<p>This will (irreversibly) delete the file! Are you sure?</p>
<input class="enabled" id="confirmButtonYes" type="submit" value="Confirm">
<input class="enabled" id="confirmButtonNo" type="submit" value="Cancel">
</div>
</div>
</div>
{% endblock %}
{% block script %}
{% include "js/contentFolder.js" %}
{% endblock %}

View File

@ -2,11 +2,12 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="chrome=1"> <meta http-equiv="X-UA-Compatible" content="chrome=1">
<title>Pack</title> <title>{% block title %}Pack{% endblock %}</title>
<link rel="icon" href="assets/favicon.ico?v=2"> <link rel="icon" href="/assets/favicon.ico?v=2">
<link rel="stylesheet" href="assets/index.css"> <!-- <link rel="stylesheet" href="/assets/index.css"> -->
<link href="https://fonts.googleapis.com/css?family=Quicksand:300,400" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Quicksand:300,400" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>{% block style %}{% endblock %}</style>
</head> </head>
<body> <body>
<div id="themeSwitchContainer"> <div id="themeSwitchContainer">
@ -14,29 +15,30 @@
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
<div id="package"> <div id="package">
<img src="assets/fragile.svg"> <img src="/assets/fragile.svg">
<img src="assets/keep_dry.svg"> <img src="/assets/keep_dry.svg">
<img src="assets/recycle.svg"> <img src="/assets/recycle.svg">
<img src="assets/qr_code.svg"> <img src="/assets/qr_code.svg">
</div> </div>
{% include "svgTape.svg" %} {% include "html/mod/svgTape.svg" %}
<div id="packageLabel"> <div id="packageLabel">
<div id="packageLabelTop">{% include "labelTop.html" %}</div> <div id="packageLabelTop">{% include "html/mod/labelTop.html" %}</div>
<div id="packageLabelTitle"> <div id="packageLabelTitle">
<h1><b></b></h1> <h1><b></b></h1>
</div> </div>
<div id="packageLabelContent"> <div id="packageLabelContent">
{% if force_msg %}
<h1 style="display: block !important;">{{ content_text | upper }}</h1>
{% else %}
<h1>{{ content_text | upper }}</h1> <h1>{{ content_text | upper }}</h1>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<div id="packageLabelBot"> <div id="packageLabelBot">
{% include "labelBot.svg" %} {% include "html/mod/labelBot.svg" %}
<a href="/">{{ config.domain }}</a> <a href="/">{{ config.domain }}</a>
</div> </div>
</div> </div>
</body> </body>
<script> <script>{% block script %}{% endblock %}</script>
{% include "js/base.js" %}
{% block script %}{% endblock %}
</script>
</html> </html>

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

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

View File

@ -0,0 +1,9 @@
{% extends "mod/base.html" %}
{% block style %}
{% include "css/refresh.css" %}
{% endblock %}
{% block script %}
{% include "js/refresh.js" %}
{% endblock %}

View File

@ -1,50 +1,9 @@
const STATES = { // Either the non-d {% include "js/mod/base.js" %}
auth: "{{ state.auth }}", {% include "js/mod/token.js" %}
error: "{{ state.error }}", {% include "js/mod/middleware.js" %}
loading: "{{ state.loading }}", {% include "js/mod/confirm.js" %}
repoSelected: "{{ state.repo_selected }}",
repoCreated: "{{ state.repo_created }}",
buttonOn: "{{ state.button_on }}",
featureOn: "{{ state.feature_on }}",
confirmOn: "{{ state.confirm_on }}",
}
var G_REPO_VALUE = "", 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"]);
}
}
// BUTTON ONCLICK HELPERS // // BUTTON ONCLICK HELPERS //
@ -64,26 +23,6 @@ function setRepoActions(s) {
get("#repoFeatures").setClass(STATES["repoCreated"], 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 // // BUTTON ONCLICK FUNCTIONS //
get("#repoSelectInput").on("keydown", (e) => { 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"]); get("#repoInfo").addClass(STATES["repoSelected"]);
}) })

View File

@ -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();

View File

@ -6,7 +6,16 @@ const CONFIG = {
requested_scopes: "read:repository,write:repository" 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; document.on = document.addEventListener;
function sugar(obj) { 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) => { get("#login").on("click", async (e) => {
e.preventDefault(); e.preventDefault();
if (G_AUTH) { if (G_AUTH) {
window.localStorage.clear(); window.localStorage.clear();
document.cookie = "";
window.location = "/"; window.location = "/";
} else { } else {
// Build the authorization URL // Build the authorization URL
@ -61,3 +80,6 @@ get("#login").on("click", async (e) => {
window.location = url; window.location = url;
} }
}); });
var G_AUTH = window.localStorage.getItem("token") !== null;
get("body").setClass(STATES["auth"], G_AUTH);

View File

@ -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"]);
});

View File

@ -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"]);
}
}

View File

@ -3,7 +3,7 @@ async function jfetch(url, method, obj={}) {
let request = { let request = {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": window.localStorage.getItem("token"), "Authorization": `Bearer ${window.localStorage.getItem("token")}`,
}, },
method: method, method: method,
}; };
@ -39,6 +39,7 @@ async function getToken() {
window.localStorage.setItem("token", response.access_token); window.localStorage.setItem("token", response.access_token);
window.localStorage.setItem("token_expiry", (Date.now() + response.expires_in*1000).toString()); window.localStorage.setItem("token_expiry", (Date.now() + response.expires_in*1000).toString());
window.localStorage.setItem("refresh_token", response.refresh_token); window.localStorage.setItem("refresh_token", response.refresh_token);
document.cookie = `token=${response.access_token}`;
} }
async function tryRefresh() { async function tryRefresh() {

1
templates/js/notFound.js Normal file
View File

@ -0,0 +1 @@
{% include "js/mod/base.js" %}

11
templates/js/refresh.js Normal file
View File

@ -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();