Compare commits

...

23 commits
v0.2.0 ... main

Author SHA1 Message Date
f1ed826eee chore(release): prepare for v0.4.2
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-22 04:55:09 +02:00
f1e5388db1 chore(release) update deps, bump version -> 0.4.2 2023-07-22 04:54:50 +02:00
ba4086bac9 fix: website version ordering
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-07-22 04:46:57 +02:00
067dae1356 fix: container entrypoint
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-18 12:09:10 +02:00
0c8d70d6e1 chore: fix npm pre-commit hook
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-07 18:27:26 +02:00
e8bb51d388 chore(release): prepare for v0.4.1
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-05 12:38:20 +02:00
b38eabb27b chore(release): bump version -> 0.4.1 2023-04-05 12:37:58 +02:00
1e7718865c fix: use better abbreviations for page names 2023-04-05 12:37:06 +02:00
44fc06cd4b fix(script): detect CI commit SHA
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-03 13:57:07 +02:00
f700b484be fix: remove version prefix from "latest" tag
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-03 13:36:33 +02:00
0cec19e682 fix(script): use sh
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-03 12:58:04 +02:00
3be7f2795f feat: add upload script
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-03 00:38:49 +02:00
97a8e9a2ba fix: stop propagation of key events on menu search 2023-04-03 00:37:54 +02:00
63738518a3 chore(release): prepare for v0.4.0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-02 18:23:13 +02:00
3dfdc4c44e chore(git-cliff): hide version-bump messages 2023-04-02 18:22:44 +02:00
a30cb5087b chore(release): bump version -> 0.4.0 2023-04-02 18:21:11 +02:00
d76e7a49ed feat!: switch database format to CBOR (not compatible with previous format)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-04-02 18:19:34 +02:00
e53a8ba92b chore(release): prepare for v0.3.0 2023-04-02 17:37:22 +02:00
4421dec657 chore(release): bump version -> 0.3.0 2023-04-02 17:36:58 +02:00
36b80bbbd9 feat: purge file storage daily
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-02 17:36:01 +02:00
4cb4a34f39 chore: update cargo dependencies 2023-04-02 17:04:37 +02:00
c94915e351 feat: add last-modified date to all GET responses
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add last-version tag to PageInfoModal
fix: set icon size to 48px
2023-04-02 16:52:50 +02:00
12ee90c793 fix(menu): move menu bar down 2023-04-02 14:07:34 +02:00
32 changed files with 1071 additions and 684 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
/target
*.snap.new
*.pending-snap

View file

@ -18,4 +18,5 @@ repos:
name: ui/menu lint+fmt
language: system
files: ^ui/menu/
entry: sh -c "npm run --prefix ui/menu pc"
pass_filenames: false
entry: npm run --prefix ui/menu pc

View file

@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file.
## [0.4.2] - 2023-07-22
### Bug Fixes
- Container entrypoint
- Website version ordering
### Miscellaneous Tasks
- Fix npm pre-commit hook
## [0.4.1] - 2023-04-05
### Bug Fixes
- Stop propagation of key events on menu search
- Use sh
- Remove version prefix from "latest" tag
- Detect CI commit SHA
- Use better abbreviations for page names
### Features
- Add upload script
## [0.4.0] - 2023-04-02
### Features
- [**breaking**] Switch database format to CBOR (not compatible with previous format)
### Miscellaneous Tasks
- Hide version-bump messages
## [0.3.0] - 2023-04-02
### Bug Fixes
- Move menu bar down
- Set icon size to 48px
### Features
- Add last-modified date to all GET responses
- Add last-version tag to PageInfoModal
- Purge file storage daily
### Miscellaneous Tasks
- Update cargo dependencies
- Bump version -> 0.3.0
## [0.2.0] - 2023-04-01
### Bug Fixes

1252
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "talon"
version = "0.2.0"
version = "0.4.2"
edition = "2021"
authors = ["ThetaDev <t.testboy@gmail.com>"]
license = "MIT"
@ -16,7 +16,7 @@ tokio = { version = "1.25.0", features = ["rt-multi-thread", "fs", "signal"] }
sled = "0.34.7"
serde = "1.0.152"
serde_json = "1.0.93"
rmp-serde = "1.1.1"
serde_cbor = "0.11.2"
toml = "0.7.2"
thiserror = "1.0.38"
time = { version = "0.3.15", features = [
@ -27,7 +27,7 @@ time = { version = "0.3.15", features = [
httpdate = "1.0.2"
sha2 = "0.10.6"
path_macro = "1.0.0"
hex-literal = "0.3.4"
hex-literal = "0.4.0"
hex = { version = "0.4.3", features = ["serde"] }
temp-dir = "0.1.11"
zip = { version = "0.6.4", default-features = false, features = [
@ -54,6 +54,7 @@ shadow-rs = "0.21.0"
walkdir = "2.3.2"
rust-embed = { version = "6.6.1", features = ["poem-ex"] }
image = "0.24.6"
clokwerk = { version = "0.4.0", default-features = false }
[dev-dependencies]
rstest = "0.17.0"

View file

@ -48,7 +48,7 @@ commit_parsers = [
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore\\(release\\):", skip = true},
{ message = "(^chore)|(^ci)", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
]

View file

@ -41,7 +41,8 @@ for arch in "${ARCHITECTURES[@]}"; do
# Finalize container
buildah umount "$container"
buildah config --entrypoint "/talon" --cmd "run -d /data" --arch "$arch" --port 3000 --author "ThetaDev" "$container"
# entrypoint syntax: see issue https://github.com/containers/buildah/issues/1768
buildah config --entrypoint '["/talon"]' --cmd "run -d /data" --arch "$arch" --port 3000 --author "ThetaDev" "$container"
buildah commit "$container" "$IMAGE:$arch-$TAG"
buildah manifest add "$REGISTRY/$IMAGE:$TAG" "$IMAGE:$arch-$TAG"

75
scripts/upload.sh Executable file
View file

@ -0,0 +1,75 @@
#!/bin/sh
set -e
# Check for dependencies
which curl > /dev/null
which jq > /dev/null
# Assert required variables
if [ -z "$TALON_KEY" ]; then echo "TALON_KEY unset"; exit 1; fi
if [ -z "$TALON_URL" ]; then echo "TALON_URL unset"; exit 1; fi
if [ -z "$SUBDOMAIN" ]; then echo "SUBDOMAIN unset"; exit 1; fi
API_URL="$TALON_URL/api"
API_KEY_H="x-api-key: $TALON_KEY"
# Check if the website already exists
WEBSITE_STATUS=$(curl --head -o /dev/null -s -w "%{http_code}" "$API_URL/website/$SUBDOMAIN")
if [ "$WEBSITE_STATUS" = "200" ]; then
echo "Website '$SUBDOMAIN' found"
else
# Create the website if it does not exist
if [ -z "$WEBSITE_NAME" ]; then echo "WEBSITE_NAME unset"; exit 1; fi
CREATE_BODY=$(jq -c --null-input --arg name "$WEBSITE_NAME" --arg color "$WEBSITE_COLOR" \
--arg visibility "$WEBSITE_VISIBILITY" --arg source_url "$WEBSITE_SOURCE_URL" \
--arg source_icon "$WEBSITE_SOURCE_ICON" \
'{"name": $name, "color": $color, "visibility": $visibility, "source_url": $source_url, "source_icon": $source_icon} | delpaths([path(.[]| select(.==""))])')
echo "Creating website '$SUBDOMAIN': $CREATE_BODY"
curl -Ss --fail -X "PUT" -H "$API_KEY_H" -H "content-type: application/json" --data "$CREATE_BODY" "$API_URL/website/$SUBDOMAIN"
fi
# Check the upload directory
if [ ! -d "$1" ]; then echo "Upload directory does not exist"; exit 1; fi
if [ ! -f "$1/index.html" ]; then echo "Upload directory does not contain index.html"; exit 1; fi
# Validate fallback page param
if [ "$FALLBACK" ] && [ ! -f "$1/$FALLBACK" ]; then echo "fallback page $FALLBACK does not exist"; exit 1; fi
# Automatically detect fallback pages
if [ -z "$SPA" ] && [ -z "$FALLBACK" ]; then
if [ -f "$1/404.html" ]; then FALLBACK="404.html"; fi
if [ -f "$1/200.html" ]; then SPA=true; FALLBACK="200.html"; fi
fi
push_arg() {
if [ "$UPLOAD_ARGS" ]; then UPLOAD_ARGS="$UPLOAD_ARGS&"; fi
UPLOAD_ARGS="$UPLOAD_ARGS$1"
}
if [ "$FALLBACK" ]; then push_arg "fallback=$FALLBACK"; fi
if [ "$SPA" = "true" ]; then push_arg "spa=true"; fi
if [ "$UPLOAD_ARGS" ]; then UPLOAD_ARGS="?$UPLOAD_ARGS"; fi
if [ "$CI_COMMIT_SHA" ]; then
echo "Git commit: $CI_COMMIT_SHA"
push_arg "commit=$CI_COMMIT_SHA"
elif GIT_COMMIT=$(git rev-parse HEAD 2> /dev/null); then
echo "Git commit: $GIT_COMMIT"
push_arg "commit=$GIT_COMMIT"
fi
# Compress website
ARCHIVE=$(mktemp)
tar -cz --directory "$1" --file "$ARCHIVE" .
# Upload website
echo "Version params: $UPLOAD_ARGS"
echo "Uploading..."
curl --fail -X "POST" -H "$API_KEY_H" -H "content-type: application/octet-stream" --data-binary "@$ARCHIVE" "$API_URL/website/$SUBDOMAIN/upload$UPLOAD_ARGS"
rm "$ARCHIVE"
echo "Website uploaded ;-)"

View file

@ -63,6 +63,8 @@ pub enum ApiError {
InvalidArchiveType,
#[error("invalid color")]
InvalidColor,
#[error("join error: {0}")]
TokioJoin(#[from] tokio::task::JoinError),
}
impl ResponseError for ApiError {
@ -73,6 +75,7 @@ impl ResponseError for ApiError {
| ApiError::InvalidArchiveType
| ApiError::InvalidColor => StatusCode::BAD_REQUEST,
ApiError::NoAccess => StatusCode::FORBIDDEN,
ApiError::TokioJoin(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
@ -99,11 +102,17 @@ impl TalonApi {
&self,
talon: Data<&Talon>,
subdomain: Path<String>,
) -> Result<Json<Website>> {
) -> Result<Response<Json<Website>>> {
talon
.db
.get_website(&subdomain)
.map(|w| Json(Website::from((subdomain.0, w))))
.map(|website| {
let modified = website.updated_at;
Response::new(Json(Website::from((subdomain.0, website)))).header(
header::LAST_MODIFIED,
httpdate::fmt_http_date(modified.into()),
)
})
.map_err(Error::from)
}
@ -163,9 +172,14 @@ impl TalonApi {
) -> Result<()> {
auth.check_subdomain(&subdomain, Access::Modify)?;
talon
.icons
.insert_icon(Cursor::new(data.as_slice()), &subdomain)?;
let t2 = talon.clone();
let sd = subdomain.clone();
tokio::task::spawn_blocking(move || {
t2.icons.insert_icon(Cursor::new(data.as_slice()), &sd)
})
.await
.map_err(ApiError::from)??;
talon.db.update_website(
&subdomain,
db::model::WebsiteUpdate {
@ -252,7 +266,8 @@ impl TalonApi {
/// Mimimum visibility of the websites
#[oai(default)]
visibility: Query<Visibility>,
) -> Result<Json<Vec<Website>>> {
) -> Result<Response<Json<Vec<Website>>>> {
let modified = talon.db.websites_last_update()?;
talon
.db
.get_websites()
@ -270,7 +285,10 @@ impl TalonApi {
Err(_) => true,
})
.collect::<Result<Vec<_>, _>>()
.map(Json)
.map(|data| {
Response::new(Json(data))
.header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
})
.map_err(Error::from)
}
@ -280,15 +298,19 @@ impl TalonApi {
&self,
talon: Data<&Talon>,
subdomain: Path<String>,
) -> Result<Json<Vec<Version>>> {
talon.db.website_exists(&subdomain)?;
talon
) -> Result<Response<Json<Vec<Version>>>> {
let website = talon.db.get_website(&subdomain)?;
let mut versions = talon
.db
.get_website_versions(&subdomain)
.map(|r| r.map(Version::from))
.collect::<Result<Vec<_>, _>>()
.map(Json)
.map_err(Error::from)
.collect::<Result<Vec<_>, _>>()?;
versions.sort_by_key(|v| v.id);
Ok(Response::new(Json(versions)).header(
header::LAST_MODIFIED,
httpdate::fmt_http_date(website.updated_at.into()),
))
}
/// Get version
@ -298,11 +320,17 @@ impl TalonApi {
talon: Data<&Talon>,
subdomain: Path<String>,
version: Path<u32>,
) -> Result<Json<Version>> {
) -> Result<Response<Json<Version>>> {
talon
.db
.get_version(&subdomain, *version)
.map(|v| Json(Version::from((*version, v))))
.map(|v| {
let create_date = v.created_at;
Response::new(Json(Version::from((*version, v)))).header(
header::LAST_MODIFIED,
httpdate::fmt_http_date(create_date.into()),
)
})
.map_err(Error::from)
}
@ -313,14 +341,19 @@ impl TalonApi {
talon: Data<&Talon>,
subdomain: Path<String>,
version: Path<u32>,
) -> Result<Json<Vec<VersionFile>>> {
talon.db.version_exists(&subdomain, *version)?;
) -> Result<Response<Json<Vec<VersionFile>>>> {
let v = talon.db.get_version(&subdomain, *version)?;
talon
.db
.get_version_files(&subdomain, *version)
.map(|r| r.map(VersionFile::from))
.collect::<Result<Vec<_>, _>>()
.map(Json)
.map(|r| {
Response::new(Json(r)).header(
header::LAST_MODIFIED,
httpdate::fmt_http_date(v.created_at.into()),
)
})
.map_err(Error::from)
}
@ -380,15 +413,21 @@ impl TalonApi {
// Try to store the uploaded website
// If this fails, the new version needs to be deleted
let try_insert = || {
fn try_insert(
talon: &Talon,
data: Binary<Vec<u8>>,
subdomain: &str,
version: u32,
fallback: Option<String>,
) -> Result<()> {
if data.starts_with(&hex!("1f8b")) {
talon
.storage
.insert_tgz_archive(data.as_slice(), &subdomain, version)?;
.insert_tgz_archive(data.as_slice(), subdomain, version)?;
} else if data.starts_with(&hex!("504b0304")) {
talon.storage.insert_zip_archive(
Cursor::new(data.as_slice()),
&subdomain,
subdomain,
version,
)?;
} else {
@ -396,20 +435,26 @@ impl TalonApi {
}
// Validata fallback path
if let Some(fallback) = &fallback.0 {
if let Some(fallback) = &fallback {
if let Err(e) =
talon
.storage
.get_file(&subdomain, version, fallback, &Default::default())
.get_file(subdomain, version, fallback, &Default::default())
{
return Err(Error::from(ApiError::InvalidFallback(e.to_string())));
}
}
Ok(())
};
}
match try_insert() {
Ok(()) => {
let t2 = talon.clone();
let sd = subdomain.clone();
match tokio::task::spawn_blocking(move || try_insert(&t2, data, &sd, version, fallback.0))
.await
.map_err(|e| Error::from(ApiError::from(e)))
{
Ok(Ok(())) => {
talon.db.update_website(
&subdomain,
db::model::WebsiteUpdate {
@ -419,7 +464,7 @@ impl TalonApi {
)?;
Ok(())
}
Err(e) => {
Err(e) | Ok(Err(e)) => {
// Remove the bad version and decrement the id counter
let _ = talon.db.delete_version(&subdomain, version, false);
let _ = talon.db.decrement_vid(&subdomain, version);

View file

@ -80,10 +80,26 @@ impl Config {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerCfg {
/// Address to bind the server to
///
/// Default: `0.0.0.0:3000`
pub address: String,
/// Root domain (and port if non-standard) under which Talon is available
///
/// Default: `localhost:3000`
pub root_domain: String,
/// Subdomain used for Talon internals (API, assets)
///
/// Default: `talon`
pub internal_subdomain: String,
/// URL under which the internals are available
///
/// Default: `http://talon.localhost:3000`
pub internal_url: String,
/// Interval in minutes between file storage purges
///
/// Default: 1440 (24h)
pub purge_interval: u32,
}
impl Default for ServerCfg {
@ -93,6 +109,7 @@ impl Default for ServerCfg {
root_domain: "localhost:3000".to_owned(),
internal_subdomain: "talon".to_owned(),
internal_url: "http://talon.localhost:3000".to_owned(),
purge_interval: 1440,
}
}
}

View file

@ -30,10 +30,8 @@ struct DbInner {
pub enum DbError {
#[error("sled db error: {0}")]
Sled(#[from] sled::Error),
#[error("msgpack serialization error: {0}")]
Serialize(#[from] rmp_serde::encode::Error),
#[error("msgpack deserialization error: {0}")]
Deserialize(#[from] rmp_serde::decode::Error),
#[error("cbor serialization error: {0}")]
Serialize(#[from] serde_cbor::Error),
#[error("json serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("{0} with id `{1}` already exists")]
@ -100,7 +98,7 @@ impl Db {
for item in self.i.websites.iter() {
let (k, v) = item?;
let key = Self::key_to_string(k.to_vec())?;
let value = rmp_serde::from_slice::<Website>(&v)?;
let value = serde_cbor::from_slice::<Website>(&v)?;
let dataset = ExportDataset::Website { key, value };
@ -111,7 +109,7 @@ impl Db {
for item in self.i.versions.iter() {
let (k, v) = item?;
let key = Self::key_to_string(k.to_vec())?;
let value = rmp_serde::from_slice::<Version>(&v)?;
let value = serde_cbor::from_slice::<Version>(&v)?;
let dataset = ExportDataset::Version { key, value };
serde_json::to_writer(&mut writer, &dataset)?;
@ -152,11 +150,11 @@ impl Db {
fn import_dataset(&self, ds: ExportDataset) -> Result<()> {
match ds {
ExportDataset::Website { key, value } => {
let data = rmp_serde::to_vec(&value)?;
let data = serde_cbor::to_vec(&value)?;
self.i.websites.insert(key, data)?;
}
ExportDataset::Version { key, value } => {
let data = rmp_serde::to_vec(&value)?;
let data = serde_cbor::to_vec(&value)?;
self.i.versions.insert(key, data)?;
}
ExportDataset::File { key, value } => {
@ -178,13 +176,13 @@ impl Db {
/// Get a website from the database
pub fn get_website(&self, subdomain: &str) -> Result<Website> {
let data = self.i.websites.get(subdomain)?;
data.and_then(|data| rmp_serde::from_slice::<Website>(data.as_ref()).ok())
data.and_then(|data| serde_cbor::from_slice::<Website>(data.as_ref()).ok())
.ok_or_else(|| DbError::NotExists("website", subdomain.to_owned()))
}
/// Insert a new website into the database
pub fn insert_website(&self, subdomain: &str, website: &Website) -> Result<()> {
let data = rmp_serde::to_vec(website)?;
let data = serde_cbor::to_vec(website)?;
self.i
.websites
.compare_and_swap(subdomain, None::<&[u8]>, Some(data))?
@ -198,7 +196,7 @@ impl Db {
.i
.websites
.update_and_fetch(subdomain, |data| match data {
Some(data) => match rmp_serde::from_slice::<Website>(data) {
Some(data) => match serde_cbor::from_slice::<Website>(data) {
Ok(mut w) => {
let website = website.clone();
w.name = website.name.unwrap_or(w.name);
@ -208,8 +206,9 @@ impl Db {
w.source_url = website.source_url.unwrap_or(w.source_url);
w.source_icon = website.source_icon.unwrap_or(w.source_icon);
w.has_icon = website.has_icon.unwrap_or(w.has_icon);
w.updated_at = OffsetDateTime::now_utc();
rmp_serde::to_vec(&w).ok()
serde_cbor::to_vec(&w).ok()
}
Err(_) => None,
},
@ -251,7 +250,7 @@ impl Db {
self.i.websites.iter().map(|r| {
r.map_err(DbError::from).and_then(|(k, v)| {
let subdomain = Self::key_to_string(k.to_vec())?;
let website = rmp_serde::from_slice::<Website>(&v)?;
let website = serde_cbor::from_slice::<Website>(&v)?;
Ok((subdomain, website))
})
})
@ -300,7 +299,7 @@ impl Db {
let key = Self::version_key(subdomain, id);
let data = self.i.versions.get(&key)?;
data.and_then(|data| rmp_serde::from_slice::<Version>(data.as_ref()).ok())
data.and_then(|data| serde_cbor::from_slice::<Version>(data.as_ref()).ok())
.ok_or_else(|| DbError::NotExists("version", key))
}
@ -312,16 +311,16 @@ impl Db {
.i
.websites
.update_and_fetch(subdomain, |data| match data {
Some(data) => match rmp_serde::from_slice::<Website>(data) {
Some(data) => match serde_cbor::from_slice::<Website>(data) {
Ok(mut w) => {
w.vid_count += 1;
rmp_serde::to_vec(&w).ok()
serde_cbor::to_vec(&w).ok()
}
Err(_) => None,
},
None => None,
})?
.and_then(|data| rmp_serde::from_slice::<Website>(&data).ok());
.and_then(|data| serde_cbor::from_slice::<Website>(&data).ok());
let id = match ws {
Some(ws) => ws.vid_count,
@ -329,7 +328,7 @@ impl Db {
};
let key = Self::version_key(subdomain, id);
let data = rmp_serde::to_vec(version)?;
let data = serde_cbor::to_vec(version)?;
self.i
.versions
.compare_and_swap(&key, None::<&[u8]>, Some(data))?
@ -344,12 +343,12 @@ impl Db {
self.i
.websites
.update_and_fetch(subdomain, |data| match data {
Some(data) => match rmp_serde::from_slice::<Website>(data) {
Some(data) => match serde_cbor::from_slice::<Website>(data) {
Ok(mut w) => {
if w.vid_count == version {
w.vid_count -= 1;
}
rmp_serde::to_vec(&w).ok()
serde_cbor::to_vec(&w).ok()
}
Err(_) => None,
},
@ -417,7 +416,7 @@ impl Db {
self.i.versions.scan_prefix(key).map(|r| {
r.map_err(DbError::from).and_then(|(k, v)| {
let (_, id) = Self::split_version_key(k.to_vec())?;
let version = rmp_serde::from_slice::<Version>(&v)?;
let version = serde_cbor::from_slice::<Version>(&v)?;
Ok((id, version))
})
})

View file

@ -12,6 +12,8 @@ pub struct Website {
pub name: String,
/// Website creation date
pub created_at: OffsetDateTime,
/// Website update date
pub updated_at: OffsetDateTime,
/// Latest version ID
pub latest_version: Option<u32>,
/// Color of the page icon
@ -27,15 +29,17 @@ pub struct Website {
/// value + 1 will be the next version ID
pub vid_count: u32,
/// Does the website have an icon?
#[serde(default)]
pub has_icon: bool,
}
impl Default for Website {
fn default() -> Self {
let created_at = OffsetDateTime::now_utc();
Self {
name: Default::default(),
created_at: OffsetDateTime::now_utc(),
created_at,
updated_at: created_at,
latest_version: Default::default(),
color: Default::default(),
visibility: Default::default(),

View file

@ -37,7 +37,7 @@ impl ResponseError for ImagesError {
}
}
const IMAGE_SIZE: u32 = 32;
const IMAGE_SIZE: u32 = 48;
const MAX_IMAGE_SIZE: u32 = 4000;
type Result<T> = std::result::Result<T, ImagesError>;

View file

@ -1,4 +1,4 @@
use std::{ops::Deref, path::Path, sync::Arc};
use std::{ops::Deref, path::Path, sync::Arc, time::Duration};
use crate::{
assets,
@ -10,6 +10,7 @@ use crate::{
storage::Storage,
util,
};
use clokwerk::{Interval, Scheduler};
use path_macro::path;
use poem::{
http::header, listener::TcpListener, middleware, Endpoint, EndpointExt, Route, RouteDomain,
@ -68,7 +69,7 @@ impl Talon {
let storage = Storage::new(storage_dir, db.clone(), cfg.clone());
let icons = Icons::new(icons_dir);
Ok(Self {
let talon = Self {
i: TalonInner {
cfg,
db,
@ -77,7 +78,24 @@ impl Talon {
start_time: OffsetDateTime::now_utc(),
}
.into(),
})
};
Ok(talon)
}
fn scheduler(&self) -> Scheduler {
let mut scheduler = Scheduler::new();
let talon = self.clone();
scheduler
.every(Interval::Minutes(self.cfg.server.purge_interval))
.run(move || {
log::info!("Starting purge");
match talon.storage.purge() {
Ok((files, freed)) => log::info!("{files} files purged, {freed} bytes freed"),
Err(e) => log::error!("purge error: {e}"),
}
});
scheduler
}
pub fn endpoint(&self) -> impl Endpoint {
@ -129,6 +147,9 @@ impl Talon {
}
pub async fn launch(&self) -> Result<()> {
let scheduler = self.scheduler();
let _scheduler_handle = scheduler.watch_thread(Duration::from_secs(1));
Server::new(TcpListener::bind(&self.i.cfg.server.address))
.run_with_graceful_shutdown(self.endpoint(), Self::shutdown_signal(), None)
.await?;

View file

@ -75,6 +75,7 @@ fn insert_websites(db: &Db) {
&Website {
name: "ThetaDev".to_owned(),
created_at: datetime!(2023-02-18 16:30 +0),
updated_at: datetime!(2023-02-18 16:30 +0),
latest_version: Some(2),
color: Some(2068974),
visibility: talon::model::Visibility::Featured,
@ -87,6 +88,7 @@ fn insert_websites(db: &Db) {
&Website {
name: "Spotify-Gender-Ex".to_owned(),
created_at: datetime!(2023-02-18 16:30 +0),
updated_at: datetime!(2023-02-18 16:30 +0),
latest_version: Some(1),
color: Some(1947988),
visibility: talon::model::Visibility::Featured,
@ -101,6 +103,7 @@ fn insert_websites(db: &Db) {
&Website {
name: "RustyPipe".to_owned(),
created_at: datetime!(2023-02-20 18:30 +0),
updated_at: datetime!(2023-02-20 18:30 +0),
latest_version: Some(1),
color: Some(7943647),
visibility: talon::model::Visibility::Featured,
@ -115,6 +118,7 @@ fn insert_websites(db: &Db) {
&Website {
name: "SvelteKit SPA".to_owned(),
created_at: datetime!(2023-03-03 22:00 +0),
updated_at: datetime!(2023-03-03 22:00 +0),
latest_version: Some(1),
color: Some(16727552),
visibility: talon::model::Visibility::Hidden,

View file

@ -8,6 +8,7 @@ ConfigInner(
root_domain: "example.com",
internal_subdomain: "talon-i",
internal_url: "http://talon-i.example.com",
purge_interval: 60,
),
compression: CompressionCfg(
gzip_en: true,

View file

@ -8,6 +8,7 @@ ConfigInner(
root_domain: "localhost:3000",
internal_subdomain: "talon",
internal_url: "http://talon.localhost:3000",
purge_interval: 1440,
),
compression: CompressionCfg(
gzip_en: true,

View file

@ -2,9 +2,9 @@
source: tests/tests.rs
expression: data
---
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1,"has_icon":false}}
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1,"has_icon":false}}
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1,"has_icon":false}}
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"updated_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1,"has_icon":false}}
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"updated_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1,"has_icon":false}}
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"updated_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1,"has_icon":false}}
{"type":"version","key":"rustypipe:1","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
{"type":"version","key":"spa:1","value":{"created_at":[2023,62,22,0,0,0,0,0,0],"data":{},"fallback":"200.html","spa":true}}
{"type":"version","key":"spotify-gender-ex:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}

View file

@ -2,10 +2,10 @@
source: tests/tests.rs
expression: data
---
{"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"color":2068974,"visibility":"featured","source_url":null,"source_icon":null,"vid_count":2,"has_icon":false}}
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1,"has_icon":false}}
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1,"has_icon":false}}
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1,"has_icon":false}}
{"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"updated_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"color":2068974,"visibility":"featured","source_url":null,"source_icon":null,"vid_count":2,"has_icon":false}}
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"updated_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1,"has_icon":false}}
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"updated_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1,"has_icon":false}}
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"updated_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1,"has_icon":false}}
{"type":"version","key":"-:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1352014628","Version":"v0.1.0"},"fallback":null,"spa":false}}
{"type":"version","key":"-:2","value":{"created_at":[2023,49,16,52,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1354755231","Version":"v0.1.1"},"fallback":null,"spa":false}}
{"type":"version","key":"rustypipe:1","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}

View file

@ -6,6 +6,7 @@ expression: "vec![ws1, ws2, ws3]"
Website(
name: "ThetaDev",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
latest_version: Some(2),
color: Some(2068974),
visibility: featured,
@ -17,6 +18,7 @@ expression: "vec![ws1, ws2, ws3]"
Website(
name: "Spotify-Gender-Ex",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
latest_version: Some(1),
color: Some(1947988),
visibility: featured,
@ -28,6 +30,7 @@ expression: "vec![ws1, ws2, ws3]"
Website(
name: "RustyPipe",
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
updated_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
latest_version: Some(1),
color: Some(7943647),
visibility: featured,

View file

@ -6,6 +6,7 @@ expression: websites
("-", Website(
name: "ThetaDev",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
latest_version: Some(2),
color: Some(2068974),
visibility: featured,
@ -17,6 +18,7 @@ expression: websites
("rustypipe", Website(
name: "RustyPipe",
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
updated_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
latest_version: Some(1),
color: Some(7943647),
visibility: featured,
@ -28,6 +30,7 @@ expression: websites
("spa", Website(
name: "SvelteKit SPA",
created_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0),
updated_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0),
latest_version: Some(1),
color: Some(16727552),
visibility: hidden,
@ -39,6 +42,7 @@ expression: websites
("spotify-gender-ex", Website(
name: "Spotify-Gender-Ex",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
latest_version: Some(1),
color: Some(1947988),
visibility: featured,

View file

@ -5,6 +5,7 @@ expression: website
Website(
name: "ThetaDev2",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: "[date]",
latest_version: Some(2),
color: Some(1000),
visibility: hidden,

View file

@ -3,6 +3,7 @@ address = "127.0.0.1:3000"
root_domain = "example.com"
internal_subdomain = "talon-i"
internal_url = "http://talon-i.example.com"
purge_interval = 60
# Talon compresses files when they are uploaded
# Here you can configure compression algorithms and levels

View file

@ -8,6 +8,8 @@ use rstest::rstest;
use fixtures::*;
use talon::db::{Db, DbError};
const ICON_SIZE: u32 = 48;
mod database {
use super::*;
@ -83,7 +85,7 @@ mod database {
.unwrap();
let website = db.get_website(SUBDOMAIN_1).unwrap();
insta::assert_ron_snapshot!(website);
insta::assert_ron_snapshot!(website, {".updated_at" => "[date]"});
}
#[rstest]
@ -613,8 +615,8 @@ mod icons {
assert!(stored_path.is_file());
let stored_img = ImageReader::open(&stored_path).unwrap().decode().unwrap();
assert_eq!(stored_img.height(), 32);
assert_eq!(stored_img.width(), 32);
assert_eq!(stored_img.height(), ICON_SIZE);
assert_eq!(stored_img.width(), ICON_SIZE);
}
#[test]
@ -856,10 +858,11 @@ mod api {
resp.assert_status_is_ok();
let ws = tln.db.get_website("test").unwrap();
insta::assert_ron_snapshot!(ws, {".created_at" => "[date]"}, @r###"
insta::assert_ron_snapshot!(ws, {".created_at" => "[date]", ".updated_at" => "[date]"}, @r###"
Website(
name: "Test",
created_at: "[date]",
updated_at: "[date]",
latest_version: None,
color: Some(1000),
visibility: searchable,
@ -911,10 +914,11 @@ mod api {
resp.assert_status_is_ok();
let ws = tln.db.get_website("-").unwrap();
insta::assert_ron_snapshot!(ws, @r###"
insta::assert_ron_snapshot!(ws, {".updated_at" => "[date]"}, @r###"
Website(
name: "Test",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: "[date]",
latest_version: Some(2),
color: Some(1000),
visibility: searchable,
@ -967,8 +971,8 @@ mod api {
.unwrap()
.decode()
.unwrap();
assert_eq!(got_icon.height(), 32);
assert_eq!(got_icon.width(), 32);
assert_eq!(got_icon.height(), ICON_SIZE);
assert_eq!(got_icon.width(), ICON_SIZE);
}
#[rstest]

View file

@ -9,7 +9,6 @@
let currentWebsite: Website;
currentWebsiteStore.subscribe((ws) => {
console.log("current ws changed", ws);
currentWebsite = ws;
});

View file

@ -49,7 +49,7 @@
closeSearch();
}
function searchKeypress(e: KeyboardEvent) {
function searchKeyup(e: KeyboardEvent) {
switch (e.key) {
case "Enter":
if (!searchText) {
@ -96,7 +96,7 @@
active={searchOpen || Boolean(searchText).valueOf()}
on:click={openSearch}
on:focusout={closeSearch}
on:keyup={searchKeypress}
on:keyup={searchKeyup}
bind:input={searchInput}
bind:text={searchText}
/>
@ -139,7 +139,7 @@
height: 100%
z-index: 999999
padding: 1em 0.4em
padding: 3em 0.4em 0.4em
display: flex
flex-direction: column

View file

@ -26,7 +26,9 @@
bind:this={inputElm}
bind:value={text}
on:focusout
on:keyup
on:keypress|stopPropagation
on:keydown|stopPropagation
on:keyup|stopPropagation
use:selectTextOnFocus
/>
<Icon iconName="search" size={40} scale={0.6} />

View file

@ -3,6 +3,7 @@
import Icon from "./Icon.svelte";
import type { Website } from "talon-client";
import { talonConfig } from "../util/talonData";
import { getAbbreviation } from "../util/functions";
export let website: Website;
export let size = 40;
@ -15,7 +16,7 @@
? `${talonConfig.internal}/icons/${website.subdomain}`
: null}
color={website.color}
alt={website.name.substring(0, 2)}
alt={getAbbreviation(website.name)}
{size}
{scale}
/>

View file

@ -2,6 +2,8 @@
import PageIcon from "./PageIcon.svelte";
import {
formatDate,
getSubdomainAndVersion,
getWebsiteUrl,
getWebsiteVersionUrl,
isUrl,
trimCommit,
@ -14,28 +16,37 @@
import Modal from "./Modal.svelte";
import { openModal } from "svelte-modals";
import InstanceInfoModal from "./InstanceInfoModal.svelte";
import { onMount } from "svelte";
let currentWebsite: Website;
currentWebsiteStore.subscribe((ws) => {
currentWebsite = ws;
});
const currentVid: number | null = getSubdomainAndVersion()[1];
export let isOpen: boolean;
$: {
if (isOpen && currentWebsite) {
client
.websiteSubdomainVersionsGet({ subdomain: currentWebsite.subdomain })
.then((v) => {
versions = v;
if (v && v.length > 0) {
currentVersion = v[v.length - 1];
}
});
onMount(async () => {
const v = await client.websiteSubdomainVersionsGet({
subdomain: currentWebsite.subdomain,
});
versions = v;
if (v && v.length > 0) {
latestVersion = v[v.length - 1];
if (currentVid !== null) {
currentVersion = v.find((v) => v.id == currentVid);
} else {
currentVersion = latestVersion;
}
}
}
});
let versions: Version[] = [];
let currentVersion: Version = null;
let currentVersion: Version | undefined;
let latestVersion: Version | undefined;
function getVersionAttr(version: Version): string | null {
return (
@ -63,6 +74,12 @@
<p class="divider">
<InlineIcon iconName="question" />
Current version #{currentVersion.id}
{#if latestVersion && latestVersion !== currentVersion}
<a class="latest-tag" href={getWebsiteUrl(currentWebsite.subdomain)}
>Latest: #{latestVersion.id}</a
>
{/if}
</p>
<Tag key="Upload date" value={formatDate(currentVersion.createdAt)} />
@ -95,7 +112,9 @@
<div>
Powered by
<button on:click={openInstanceInfo}>Talon {talonConfig.version}</button>
<button class="link" on:click={openInstanceInfo}
>Talon {talonConfig.version}</button
>
</div>
{/if}
</Modal>
@ -111,4 +130,13 @@
font-size: 2em
margin-left: 0.25em
.latest-tag
background-color: lime
color: values.$color-text-d1
margin: 0 1em
overflow: hidden
white-space: nowrap
padding: 0 0.4em
border-radius: 1em
</style>

View file

@ -6,15 +6,16 @@
font-family: sans-serif
color: values.$color-text
a, button
a, .link
display: inline
text-decoration: none
cursor: pointer
background: none
border: none
box-shadow: none
padding: 0
a
.link
color: var(--talon-color)
filter: brightness(150%)

View file

@ -13,3 +13,4 @@ $color-base-2: color.scale($color-base, $lightness: 20%)
$color-primary-light: color.scale($color-primary, $lightness: 15%)
$color-primary-dark: color.scale($color-primary, $lightness: -15%)
$color-text-1: color.scale($color-text, $lightness: -15%)
$color-text-d1: color.scale($color-base, $lightness: -20%)

View file

@ -21,6 +21,29 @@ export function getSubdomain(): string {
return "-";
}
export function getSubdomainAndVersion(): [string, number | null] {
const hn = window.location.hostname;
const rd_noport = talonConfig.root_domain.split(":", 1)[0];
if (hn.endsWith("." + rd_noport)) {
const subdomainSplit = hn
.substring(0, hn.length - rd_noport.length - 1)
.split("--", 2);
const subdomain = subdomainSplit[0];
let version =
subdomainSplit.length > 1 ? parseInt(subdomainSplit[1].replace(/^v/, "")) : null;
if (Number.isNaN(version)) version = null;
if (subdomain === "x") {
return ["-", version];
} else {
return [subdomain, version];
}
}
return ["-", null];
}
export function getWebsiteUrl(subdomain: string): string {
const proto = window.location.protocol;
@ -58,3 +81,23 @@ export function trimCommit(commit: string | undefined): string | undefined {
export function isMobile(): boolean {
return window.innerWidth < 768;
}
/**
* Get a 2-letter abbreviation of the website name.
*
* If the name consists of multiple words
* (separated by spaces, underscores or CamelCase), output
* the first letters of these words.
*
* Otherwise output the first letters of the name.
*/
export function getAbbreviation(name: string): string {
const split_sep = name
.replace(/([a-z])([A-Z])/g, "$1_$2")
.split(/[ ,.;_-]/)
.filter((x) => x.length > 0);
if (split_sep.length >= 2) {
return split_sep[0].charAt(0) + split_sep[1].charAt(0);
}
return name.substring(0, 2);
}