Compare commits

...

34 commits
v0.1.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
7758385b51 chore(release): prepare for v0.2.0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-02 00:27:08 +02:00
ad184190cd chore(ci): publish release on gitea 2023-04-02 00:26:24 +02:00
5c38036ef1 fix: release script 2023-04-01 23:51:44 +02:00
1765d390bd bump version -> 0.2.0 2023-04-01 23:47:14 +02:00
381d535540 fix: smaller menu button on mobile 2023-04-01 23:45:13 +02:00
77bcbebacc fix: change version info icon 2023-04-01 23:26:17 +02:00
166e6c1738 feat: add website icons
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-01 23:18:46 +02:00
67db47f053 fix: delete versions from db when upload failed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-01 20:10:11 +02:00
fc939c7f9b fix: revert svelte-preprocess update
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-01 18:14:25 +02:00
863fcabf0f fix npm workflow
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-04-01 17:32:48 +02:00
f993c3a4af feat: add instance info dialog
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-04-01 17:30:08 +02:00
65 changed files with 2562 additions and 2391 deletions

2
.gitignore vendored
View file

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

View file

@ -11,3 +11,12 @@ repos:
args: ["--all"] args: ["--all"]
- id: cargo-clippy - id: cargo-clippy
args: ["--all", "--all-features", "--", "-D", "warnings"] args: ["--all", "--all-features", "--", "-D", "warnings"]
- repo: local
hooks:
- id: ui-menu
name: ui/menu lint+fmt
language: system
files: ^ui/menu/
pass_filenames: false
entry: npm run --prefix ui/menu pc

View file

@ -4,8 +4,9 @@ pipeline:
environment: environment:
- VERSION=${CI_COMMIT_TAG} - VERSION=${CI_COMMIT_TAG}
commands: commands:
- npm ci --prefix ui/talon-client - npm install --prefix ui/talon-client
- npm ci --prefix ui/menu - npm install --prefix ui/menu
- npm run ci --prefix ui/menu
- npm run build --prefix ui/menu - npm run build --prefix ui/menu
test-server: test-server:
@ -48,6 +49,16 @@ pipeline:
event: tag event: tag
tag: v* tag: v*
compress-release:
image: debian:latest
commands:
- mkdir target/upload
- gzip -c target/x86_64-unknown-linux-musl/release/talon > target/upload/talon-amd64.gz
- gzip -c target/aarch64-unknown-linux-musl/release/talon > target/upload/talon-aarch64.gz
when:
event: tag
tag: v*
publish-release: publish-release:
group: publish group: publish
image: woodpeckerci/plugin-gitea-release:latest image: woodpeckerci/plugin-gitea-release:latest
@ -57,6 +68,9 @@ pipeline:
from_secret: GITEA_KEY from_secret: GITEA_KEY
title: "Talon ${CI_COMMIT_TAG}" title: "Talon ${CI_COMMIT_TAG}"
note: "./target/CHANGES.md" note: "./target/CHANGES.md"
files:
- "target/upload/talon-amd64.gz"
- "target/upload/talon-aarch64.gz"
when: when:
event: tag event: tag
tag: v* tag: v*

View file

@ -2,6 +2,78 @@
All notable changes to this project will be documented in this file. 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
- Revert svelte-preprocess update
- Delete versions from db when upload failed
- Change version info icon
- Smaller menu button on mobile
- Release script
### Features
- Add instance info dialog
- Add website icons
### Miscellaneous Tasks
- Publish release on gitea
## [0.1.0] - 2023-03-31 ## [0.1.0] - 2023-03-31
### Bug Fixes ### Bug Fixes

1478
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -18,12 +18,13 @@ oai-doc:
cargo run --bin openapi cargo run --bin openapi
# Generate the JS API client # Generate the JS API client
oai-client: oai-client: && npm-setup
rm -rf ui/menu/node_modules/talon-client
openapi-generator-cli generate -i openapi.json -g typescript-fetch -o ui/talon-client -p "npmName=talon-client" openapi-generator-cli generate -i openapi.json -g typescript-fetch -o ui/talon-client -p "npmName=talon-client"
npm-setup: npm-setup:
npm ci --prefix ui/talon-client npm install --prefix ui/talon-client
npm ci --prefix ui/menu npm install --prefix ui/menu
# Start the dev server for the sidebar menu # Start the dev server for the sidebar menu
menu-dev: menu-dev:
@ -37,7 +38,7 @@ release:
if [ -n "$(git status --porcelain)" ]; then echo "Workdir must be clean"; exit 1; fi if [ -n "$(git status --porcelain)" ]; then echo "Workdir must be clean"; exit 1; fi
if git rev-parse "v{{version}}" >/dev/null 2>&1; then echo "Version tag v{{version}} already exists"; exit 1; fi if git rev-parse "v{{version}}" >/dev/null 2>&1; then echo "Version tag v{{version}} already exists"; exit 1; fi
if `file CHANGELOG.md`; then git-cliff --tag "v{{version}}" --unreleased --prepend CHANGELOG.md; else git-cliff --tag "v{{version}}" --unreleased --output CHANGELOG.md; fi if [ -f "CHANGELOG.md" ]; then git-cliff --tag "v{{version}}" --unreleased --prepend CHANGELOG.md; else git-cliff --tag "v{{version}}" --unreleased --output CHANGELOG.md; fi
git add CHANGELOG.md git add CHANGELOG.md
git commit -m "chore(release): prepare for v{{version}}" git commit -m "chore(release): prepare for v{{version}}"

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

View file

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

View file

@ -135,6 +135,70 @@
] ]
} }
}, },
"/website/{subdomain}/icon": {
"put": {
"summary": "Upload a website icon",
"description": "Supported image formats: png, jpeg, gif, bmp, ico, tiff,\nwebp, pnm, dds, tga, openexr, farbfeld\n\nMaximum upload size: 5MB, 4000 pixels\n\nIcons are resized to 32x32 pixels.",
"parameters": [
{
"name": "subdomain",
"schema": {
"type": "string"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"ApiKeyAuthorization": []
}
]
},
"delete": {
"summary": "Delete a website icon",
"parameters": [
{
"name": "subdomain",
"schema": {
"type": "string"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"ApiKeyAuthorization": []
}
]
}
},
"/websites": { "/websites": {
"get": { "get": {
"summary": "Get all publicly listed websites", "summary": "Get all publicly listed websites",
@ -522,7 +586,7 @@
"required": [ "required": [
"stats", "stats",
"version", "version",
"uptime" "start_time"
], ],
"properties": { "properties": {
"stats": { "stats": {
@ -545,10 +609,10 @@
} }
] ]
}, },
"uptime": { "start_time": {
"type": "integer", "type": "string",
"format": "uint64", "format": "date-time",
"description": "Current uptime of the server in seconds" "description": "Start time of the server"
} }
} }
}, },
@ -693,7 +757,8 @@
"subdomain", "subdomain",
"name", "name",
"created_at", "created_at",
"visibility" "visibility",
"has_icon"
], ],
"properties": { "properties": {
"subdomain": { "subdomain": {
@ -741,6 +806,10 @@
"description": "Icon for the source link" "description": "Icon for the source link"
} }
] ]
},
"has_icon": {
"type": "boolean",
"description": "Does the website have an icon?"
} }
} }
}, },

View file

@ -41,7 +41,8 @@ for arch in "${ARCHITECTURES[@]}"; do
# Finalize container # Finalize container
buildah umount "$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 commit "$container" "$IMAGE:$arch-$TAG"
buildah manifest add "$REGISTRY/$IMAGE:$TAG" "$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, InvalidArchiveType,
#[error("invalid color")] #[error("invalid color")]
InvalidColor, InvalidColor,
#[error("join error: {0}")]
TokioJoin(#[from] tokio::task::JoinError),
} }
impl ResponseError for ApiError { impl ResponseError for ApiError {
@ -72,7 +74,8 @@ impl ResponseError for ApiError {
| ApiError::InvalidFallback(_) | ApiError::InvalidFallback(_)
| ApiError::InvalidArchiveType | ApiError::InvalidArchiveType
| ApiError::InvalidColor => StatusCode::BAD_REQUEST, | ApiError::InvalidColor => StatusCode::BAD_REQUEST,
ApiError::NoAccess => StatusCode::UNAUTHORIZED, ApiError::NoAccess => StatusCode::FORBIDDEN,
ApiError::TokioJoin(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
} }
@ -99,11 +102,17 @@ impl TalonApi {
&self, &self,
talon: Data<&Talon>, talon: Data<&Talon>,
subdomain: Path<String>, subdomain: Path<String>,
) -> Result<Json<Website>> { ) -> Result<Response<Json<Website>>> {
talon talon
.db .db
.get_website(&subdomain) .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) .map_err(Error::from)
} }
@ -144,6 +153,64 @@ impl TalonApi {
Ok(()) Ok(())
} }
/// Upload a website icon
///
/// Supported image formats: png, jpeg, gif, bmp, ico, tiff,
/// webp, pnm, dds, tga, openexr, farbfeld
///
/// Maximum upload size: 5MB, 4000 pixels
///
/// Icons are resized to 32x32 pixels.
#[oai(path = "/website/:subdomain/icon", method = "put")]
async fn website_add_icon(
&self,
auth: ApiKeyAuthorization,
talon: Data<&Talon>,
subdomain: Path<String>,
/// Icon data.
data: Binary<Vec<u8>>,
) -> Result<()> {
auth.check_subdomain(&subdomain, Access::Modify)?;
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 {
has_icon: Some(true),
..Default::default()
},
)?;
Ok(())
}
/// Delete a website icon
#[oai(path = "/website/:subdomain/icon", method = "delete")]
async fn website_delete_icon(
&self,
auth: ApiKeyAuthorization,
talon: Data<&Talon>,
subdomain: Path<String>,
) -> Result<()> {
auth.check_subdomain(&subdomain, Access::Modify)?;
talon.icons.delete_icon(&subdomain)?;
talon.db.update_website(
&subdomain,
db::model::WebsiteUpdate {
has_icon: Some(false),
..Default::default()
},
)?;
Ok(())
}
/// Delete website /// Delete website
#[oai(path = "/website/:subdomain", method = "delete")] #[oai(path = "/website/:subdomain", method = "delete")]
async fn website_delete( async fn website_delete(
@ -199,7 +266,8 @@ impl TalonApi {
/// Mimimum visibility of the websites /// Mimimum visibility of the websites
#[oai(default)] #[oai(default)]
visibility: Query<Visibility>, visibility: Query<Visibility>,
) -> Result<Json<Vec<Website>>> { ) -> Result<Response<Json<Vec<Website>>>> {
let modified = talon.db.websites_last_update()?;
talon talon
.db .db
.get_websites() .get_websites()
@ -217,7 +285,10 @@ impl TalonApi {
Err(_) => true, Err(_) => true,
}) })
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map(Json) .map(|data| {
Response::new(Json(data))
.header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
})
.map_err(Error::from) .map_err(Error::from)
} }
@ -227,15 +298,19 @@ impl TalonApi {
&self, &self,
talon: Data<&Talon>, talon: Data<&Talon>,
subdomain: Path<String>, subdomain: Path<String>,
) -> Result<Json<Vec<Version>>> { ) -> Result<Response<Json<Vec<Version>>>> {
talon.db.website_exists(&subdomain)?; let website = talon.db.get_website(&subdomain)?;
talon let mut versions = talon
.db .db
.get_website_versions(&subdomain) .get_website_versions(&subdomain)
.map(|r| r.map(Version::from)) .map(|r| r.map(Version::from))
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()?;
.map(Json) versions.sort_by_key(|v| v.id);
.map_err(Error::from)
Ok(Response::new(Json(versions)).header(
header::LAST_MODIFIED,
httpdate::fmt_http_date(website.updated_at.into()),
))
} }
/// Get version /// Get version
@ -245,11 +320,17 @@ impl TalonApi {
talon: Data<&Talon>, talon: Data<&Talon>,
subdomain: Path<String>, subdomain: Path<String>,
version: Path<u32>, version: Path<u32>,
) -> Result<Json<Version>> { ) -> Result<Response<Json<Version>>> {
talon talon
.db .db
.get_version(&subdomain, *version) .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) .map_err(Error::from)
} }
@ -260,14 +341,19 @@ impl TalonApi {
talon: Data<&Talon>, talon: Data<&Talon>,
subdomain: Path<String>, subdomain: Path<String>,
version: Path<u32>, version: Path<u32>,
) -> Result<Json<Vec<VersionFile>>> { ) -> Result<Response<Json<Vec<VersionFile>>>> {
talon.db.version_exists(&subdomain, *version)?; let v = talon.db.get_version(&subdomain, *version)?;
talon talon
.db .db
.get_version_files(&subdomain, *version) .get_version_files(&subdomain, *version)
.map(|r| r.map(VersionFile::from)) .map(|r| r.map(VersionFile::from))
.collect::<Result<Vec<_>, _>>() .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) .map_err(Error::from)
} }
@ -325,31 +411,50 @@ impl TalonApi {
}, },
)?; )?;
// Try to store the uploaded website
// If this fails, the new version needs to be deleted
fn try_insert(
talon: &Talon,
data: Binary<Vec<u8>>,
subdomain: &str,
version: u32,
fallback: Option<String>,
) -> Result<()> {
if data.starts_with(&hex!("1f8b")) { if data.starts_with(&hex!("1f8b")) {
talon talon
.storage .storage
.insert_tgz_archive(data.as_slice(), &subdomain, version)?; .insert_tgz_archive(data.as_slice(), subdomain, version)?;
} else if data.starts_with(&hex!("504b0304")) { } else if data.starts_with(&hex!("504b0304")) {
talon talon.storage.insert_zip_archive(
.storage Cursor::new(data.as_slice()),
.insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?; subdomain,
version,
)?;
} else { } else {
return Err(ApiError::InvalidArchiveType.into()); return Err(Error::from(ApiError::InvalidArchiveType));
} }
// Validata fallback path // Validata fallback path
if let Some(fallback) = &fallback.0 { if let Some(fallback) = &fallback {
if let Err(e) = if let Err(e) =
talon talon
.storage .storage
.get_file(&subdomain, version, fallback, &Default::default()) .get_file(subdomain, version, fallback, &Default::default())
{ {
// Remove the bad version return Err(Error::from(ApiError::InvalidFallback(e.to_string())));
let _ = talon.db.delete_version(&subdomain, version, false);
return Err(ApiError::InvalidFallback(e.to_string()).into());
} }
} }
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( talon.db.update_website(
&subdomain, &subdomain,
db::model::WebsiteUpdate { db::model::WebsiteUpdate {
@ -359,6 +464,14 @@ impl TalonApi {
)?; )?;
Ok(()) Ok(())
} }
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);
Err(e)
}
}
}
/// Retrieve a file /// Retrieve a file
#[oai(path = "/file/:hash", method = "get")] #[oai(path = "/file/:hash", method = "get")]

View file

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

View file

@ -30,10 +30,8 @@ struct DbInner {
pub enum DbError { pub enum DbError {
#[error("sled db error: {0}")] #[error("sled db error: {0}")]
Sled(#[from] sled::Error), Sled(#[from] sled::Error),
#[error("msgpack serialization error: {0}")] #[error("cbor serialization error: {0}")]
Serialize(#[from] rmp_serde::encode::Error), Serialize(#[from] serde_cbor::Error),
#[error("msgpack deserialization error: {0}")]
Deserialize(#[from] rmp_serde::decode::Error),
#[error("json serialization error: {0}")] #[error("json serialization error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("{0} with id `{1}` already exists")] #[error("{0} with id `{1}` already exists")]
@ -100,7 +98,7 @@ impl Db {
for item in self.i.websites.iter() { for item in self.i.websites.iter() {
let (k, v) = item?; let (k, v) = item?;
let key = Self::key_to_string(k.to_vec())?; 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 }; let dataset = ExportDataset::Website { key, value };
@ -111,7 +109,7 @@ impl Db {
for item in self.i.versions.iter() { for item in self.i.versions.iter() {
let (k, v) = item?; let (k, v) = item?;
let key = Self::key_to_string(k.to_vec())?; 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 }; let dataset = ExportDataset::Version { key, value };
serde_json::to_writer(&mut writer, &dataset)?; serde_json::to_writer(&mut writer, &dataset)?;
@ -152,11 +150,11 @@ impl Db {
fn import_dataset(&self, ds: ExportDataset) -> Result<()> { fn import_dataset(&self, ds: ExportDataset) -> Result<()> {
match ds { match ds {
ExportDataset::Website { key, value } => { ExportDataset::Website { key, value } => {
let data = rmp_serde::to_vec(&value)?; let data = serde_cbor::to_vec(&value)?;
self.i.websites.insert(key, data)?; self.i.websites.insert(key, data)?;
} }
ExportDataset::Version { key, value } => { ExportDataset::Version { key, value } => {
let data = rmp_serde::to_vec(&value)?; let data = serde_cbor::to_vec(&value)?;
self.i.versions.insert(key, data)?; self.i.versions.insert(key, data)?;
} }
ExportDataset::File { key, value } => { ExportDataset::File { key, value } => {
@ -178,13 +176,13 @@ impl Db {
/// Get a website from the database /// Get a website from the database
pub fn get_website(&self, subdomain: &str) -> Result<Website> { pub fn get_website(&self, subdomain: &str) -> Result<Website> {
let data = self.i.websites.get(subdomain)?; 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())) .ok_or_else(|| DbError::NotExists("website", subdomain.to_owned()))
} }
/// Insert a new website into the database /// Insert a new website into the database
pub fn insert_website(&self, subdomain: &str, website: &Website) -> Result<()> { 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 self.i
.websites .websites
.compare_and_swap(subdomain, None::<&[u8]>, Some(data))? .compare_and_swap(subdomain, None::<&[u8]>, Some(data))?
@ -198,7 +196,7 @@ impl Db {
.i .i
.websites .websites
.update_and_fetch(subdomain, |data| match data { .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) => { Ok(mut w) => {
let website = website.clone(); let website = website.clone();
w.name = website.name.unwrap_or(w.name); w.name = website.name.unwrap_or(w.name);
@ -207,8 +205,10 @@ impl Db {
w.visibility = website.visibility.unwrap_or(w.visibility); w.visibility = website.visibility.unwrap_or(w.visibility);
w.source_url = website.source_url.unwrap_or(w.source_url); w.source_url = website.source_url.unwrap_or(w.source_url);
w.source_icon = website.source_icon.unwrap_or(w.source_icon); 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, Err(_) => None,
}, },
@ -250,7 +250,7 @@ impl Db {
self.i.websites.iter().map(|r| { self.i.websites.iter().map(|r| {
r.map_err(DbError::from).and_then(|(k, v)| { r.map_err(DbError::from).and_then(|(k, v)| {
let subdomain = Self::key_to_string(k.to_vec())?; 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)) Ok((subdomain, website))
}) })
}) })
@ -299,7 +299,7 @@ impl Db {
let key = Self::version_key(subdomain, id); let key = Self::version_key(subdomain, id);
let data = self.i.versions.get(&key)?; 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)) .ok_or_else(|| DbError::NotExists("version", key))
} }
@ -311,16 +311,16 @@ impl Db {
.i .i
.websites .websites
.update_and_fetch(subdomain, |data| match data { .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) => { Ok(mut w) => {
w.vid_count += 1; w.vid_count += 1;
rmp_serde::to_vec(&w).ok() serde_cbor::to_vec(&w).ok()
} }
Err(_) => None, Err(_) => None,
}, },
None => 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 { let id = match ws {
Some(ws) => ws.vid_count, Some(ws) => ws.vid_count,
@ -328,7 +328,7 @@ impl Db {
}; };
let key = Self::version_key(subdomain, id); let key = Self::version_key(subdomain, id);
let data = rmp_serde::to_vec(version)?; let data = serde_cbor::to_vec(version)?;
self.i self.i
.versions .versions
.compare_and_swap(&key, None::<&[u8]>, Some(data))? .compare_and_swap(&key, None::<&[u8]>, Some(data))?
@ -336,6 +336,27 @@ impl Db {
Ok(id) Ok(id)
} }
/// Decrement the version id counter after a failed insertion.
///
/// Does not decrement the counter if it does not equal the given value.
pub fn decrement_vid(&self, subdomain: &str, version: u32) -> Result<()> {
self.i
.websites
.update_and_fetch(subdomain, |data| match data {
Some(data) => match serde_cbor::from_slice::<Website>(data) {
Ok(mut w) => {
if w.vid_count == version {
w.vid_count -= 1;
}
serde_cbor::to_vec(&w).ok()
}
Err(_) => None,
},
None => None,
})?;
Ok(())
}
/// internal method for deleting a version from the database /// internal method for deleting a version from the database
/// ///
/// this method does not lock the db or update the associated website /// this method does not lock the db or update the associated website
@ -395,7 +416,7 @@ impl Db {
self.i.versions.scan_prefix(key).map(|r| { self.i.versions.scan_prefix(key).map(|r| {
r.map_err(DbError::from).and_then(|(k, v)| { r.map_err(DbError::from).and_then(|(k, v)| {
let (_, id) = Self::split_version_key(k.to_vec())?; 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)) Ok((id, version))
}) })
}) })

View file

@ -12,6 +12,8 @@ pub struct Website {
pub name: String, pub name: String,
/// Website creation date /// Website creation date
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
/// Website update date
pub updated_at: OffsetDateTime,
/// Latest version ID /// Latest version ID
pub latest_version: Option<u32>, pub latest_version: Option<u32>,
/// Color of the page icon /// Color of the page icon
@ -26,19 +28,25 @@ pub struct Website {
/// ///
/// value + 1 will be the next version ID /// value + 1 will be the next version ID
pub vid_count: u32, pub vid_count: u32,
/// Does the website have an icon?
pub has_icon: bool,
} }
impl Default for Website { impl Default for Website {
fn default() -> Self { fn default() -> Self {
let created_at = OffsetDateTime::now_utc();
Self { Self {
name: Default::default(), name: Default::default(),
created_at: OffsetDateTime::now_utc(), created_at,
updated_at: created_at,
latest_version: Default::default(), latest_version: Default::default(),
color: Default::default(), color: Default::default(),
visibility: Default::default(), visibility: Default::default(),
source_url: Default::default(), source_url: Default::default(),
source_icon: Default::default(), source_icon: Default::default(),
vid_count: Default::default(), vid_count: Default::default(),
has_icon: Default::default(),
} }
} }
} }
@ -60,6 +68,8 @@ pub struct WebsiteUpdate {
pub source_url: Option<Option<String>>, pub source_url: Option<Option<String>>,
/// Icon for the source link /// Icon for the source link
pub source_icon: Option<Option<SourceIcon>>, pub source_icon: Option<Option<SourceIcon>>,
/// Does the website have an icon?
pub has_icon: Option<bool>,
} }
/// Website version stored in the database /// Website version stored in the database

110
src/icons.rs Normal file
View file

@ -0,0 +1,110 @@
use std::{
io::{BufReader, Read, Seek},
path::PathBuf,
};
use image::io::Reader as ImageReader;
use poem::{
error::ResponseError,
handler,
http::StatusCode,
web::{Data, Path, StaticFileRequest, StaticFileResponse},
FromRequest, IntoResponse, Request, Response,
};
use crate::Talon;
pub struct Icons {
path: PathBuf,
}
#[derive(thiserror::Error, Debug)]
pub enum ImagesError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("image error: {0}")]
Image(#[from] image::ImageError),
#[error("image for `{0}` not found")]
NotFound(String),
}
impl ResponseError for ImagesError {
fn status(&self) -> StatusCode {
match self {
ImagesError::NotFound(_) => StatusCode::NOT_FOUND,
ImagesError::Io(_) | ImagesError::Image(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
const IMAGE_SIZE: u32 = 48;
const MAX_IMAGE_SIZE: u32 = 4000;
type Result<T> = std::result::Result<T, ImagesError>;
impl Icons {
/// Create a new image storage
pub fn new<P: Into<PathBuf>>(path: P) -> Self {
Self { path: path.into() }
}
fn icon_path(&self, subdomain: &str) -> PathBuf {
self.path.join(format!("{subdomain}.png"))
}
pub fn insert_icon(&self, reader: impl Read + Seek, subdomain: &str) -> Result<()> {
let mut img_reader = ImageReader::new(BufReader::new(reader));
let mut limits = image::io::Limits::default();
limits.max_image_height = Some(MAX_IMAGE_SIZE);
limits.max_image_width = Some(MAX_IMAGE_SIZE);
img_reader.limits(limits);
let img = img_reader.with_guessed_format()?.decode()?;
let img = img.resize(
IMAGE_SIZE,
IMAGE_SIZE,
image::imageops::FilterType::Lanczos3,
);
img.save(self.icon_path(subdomain))?;
Ok(())
}
pub fn delete_icon(&self, subdomain: &str) -> Result<()> {
let path = self.get_icon(subdomain)?;
std::fs::remove_file(path)?;
Ok(())
}
pub fn get_icon(&self, subdomain: &str) -> Result<PathBuf> {
let path = self.icon_path(subdomain);
if path.is_file() {
Ok(path)
} else {
Err(ImagesError::NotFound(subdomain.to_owned()))
}
}
pub async fn get_icon_response(
&self,
subdomain: &str,
request: &Request,
) -> poem::Result<StaticFileResponse> {
let path = self.icon_path(subdomain);
Ok(StaticFileRequest::from_request_without_body(request)
.await?
.create_response(path, false)?)
}
}
#[handler]
pub async fn icon(
request: &Request,
talon: Data<&Talon>,
subdomain: Path<String>,
) -> poem::Result<Response> {
talon
.icons
.get_icon_response(&subdomain, request)
.await
.map(|r| r.into_response())
}

View file

@ -4,6 +4,7 @@ pub mod api;
pub mod assets; pub mod assets;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod icons;
pub mod model; pub mod model;
pub mod server; pub mod server;
pub mod storage; pub mod storage;

View file

@ -29,6 +29,8 @@ pub struct Website {
pub source_url: Option<String>, pub source_url: Option<String>,
/// Icon for the source link /// Icon for the source link
pub source_icon: Option<SourceIcon>, pub source_icon: Option<SourceIcon>,
/// Does the website have an icon?
pub has_icon: bool,
} }
/// Create a new website /// Create a new website
@ -98,8 +100,9 @@ pub struct Info {
pub stats: Stats, pub stats: Stats,
/// Version information /// Version information
pub version: VersionInfo, pub version: VersionInfo,
/// Current uptime of the server in seconds /// Start time of the server
pub uptime: u64, #[serde(with = "time::serde::rfc3339")]
pub start_time: OffsetDateTime,
} }
/// Information about a Talon version /// Information about a Talon version
@ -134,7 +137,7 @@ pub struct Stats {
/// Embedded site config /// Embedded site config
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TalonConfig<'a> { pub struct TalonConfig<'a> {
pub api: &'a str, pub internal: &'a str,
pub version: &'a str, pub version: &'a str,
pub root_domain: &'a str, pub root_domain: &'a str,
} }
@ -175,6 +178,7 @@ impl From<(String, db::model::Website)> for Website {
visibility: w.visibility, visibility: w.visibility,
source_url: w.source_url, source_url: w.source_url,
source_icon: w.source_icon, source_icon: w.source_icon,
has_icon: w.has_icon,
} }
} }
} }
@ -214,6 +218,7 @@ impl TryFrom<WebsiteUpdate> for db::model::WebsiteUpdate {
visibility: value.visibility, visibility: value.visibility,
source_url: value.source_url, source_url: value.source_url,
source_icon: value.source_icon, source_icon: value.source_icon,
has_icon: None,
}) })
} }
} }

View file

@ -1,14 +1,16 @@
use std::{ops::Deref, path::Path, sync::Arc}; use std::{ops::Deref, path::Path, sync::Arc, time::Duration};
use crate::{ use crate::{
assets, assets,
config::Config, config::Config,
db::Db, db::Db,
icons::{icon, Icons},
model::{Info, VersionInfo}, model::{Info, VersionInfo},
page::page, page::page,
storage::Storage, storage::Storage,
util, util,
}; };
use clokwerk::{Interval, Scheduler};
use path_macro::path; use path_macro::path;
use poem::{ use poem::{
http::header, listener::TcpListener, middleware, Endpoint, EndpointExt, Route, RouteDomain, http::header, listener::TcpListener, middleware, Endpoint, EndpointExt, Route, RouteDomain,
@ -26,6 +28,7 @@ pub struct TalonInner {
pub cfg: Config, pub cfg: Config,
pub db: Db, pub db: Db,
pub storage: Storage, pub storage: Storage,
pub icons: Icons,
pub start_time: OffsetDateTime, pub start_time: OffsetDateTime,
} }
@ -55,23 +58,44 @@ impl Talon {
pub fn new<P: AsRef<Path>>(workdir: P) -> Result<Self> { pub fn new<P: AsRef<Path>>(workdir: P) -> Result<Self> {
let db_dir = path!(workdir / "db"); let db_dir = path!(workdir / "db");
let storage_dir = path!(workdir / "storage"); let storage_dir = path!(workdir / "storage");
let icons_dir = path!(workdir / "icons");
util::create_dir_ne(&workdir)?; util::create_dir_ne(&workdir)?;
util::create_dir_ne(&db_dir)?; util::create_dir_ne(&db_dir)?;
util::create_dir_ne(&storage_dir)?; util::create_dir_ne(&storage_dir)?;
util::create_dir_ne(&icons_dir)?;
let cfg = Config::from_file(path!(workdir / "config.toml"))?; let cfg = Config::from_file(path!(workdir / "config.toml"))?;
let db = Db::new(db_dir)?; let db = Db::new(db_dir)?;
let storage = Storage::new(storage_dir, db.clone(), cfg.clone()); let storage = Storage::new(storage_dir, db.clone(), cfg.clone());
let icons = Icons::new(icons_dir);
Ok(Self { let talon = Self {
i: TalonInner { i: TalonInner {
cfg, cfg,
db, db,
storage, storage,
icons,
start_time: OffsetDateTime::now_utc(), start_time: OffsetDateTime::now_utc(),
} }
.into(), .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 { pub fn endpoint(&self) -> impl Endpoint {
@ -96,7 +120,8 @@ impl Talon {
"/api/spec", "/api/spec",
poem::endpoint::make_sync(move |_| spec.clone()), poem::endpoint::make_sync(move |_| spec.clone()),
) )
.at("/assets/menu/*path", assets::menu_assets); .at("/assets/menu/*path", assets::menu_assets)
.at("/icons/:subdomain", icon);
let internal_domain = format!( let internal_domain = format!(
"{}.{}", "{}.{}",
@ -122,6 +147,9 @@ impl Talon {
} }
pub async fn launch(&self) -> Result<()> { 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)) Server::new(TcpListener::bind(&self.i.cfg.server.address))
.run_with_graceful_shutdown(self.endpoint(), Self::shutdown_signal(), None) .run_with_graceful_shutdown(self.endpoint(), Self::shutdown_signal(), None)
.await?; .await?;
@ -132,10 +160,7 @@ impl Talon {
Ok(Info { Ok(Info {
stats: self.storage.stats()?, stats: self.storage.stats()?,
version: VersionInfo::get(), version: VersionInfo::get(),
uptime: (OffsetDateTime::now_utc() - self.start_time) start_time: self.start_time,
.whole_seconds()
.try_into()
.unwrap_or_default(),
}) })
} }
} }

View file

@ -120,7 +120,7 @@ impl Storage {
pub fn new<P: Into<PathBuf>>(path: P, db: Db, cfg: Config) -> Self { pub fn new<P: Into<PathBuf>>(path: P, db: Db, cfg: Config) -> Self {
// Build the string to inject into html pages // Build the string to inject into html pages
let talon_cfg = TalonConfig { let talon_cfg = TalonConfig {
api: &format!("{}/api", cfg.server.internal_url), internal: &cfg.server.internal_url,
version: crate::build::PKG_VERSION, version: crate::build::PKG_VERSION,
root_domain: &cfg.server.root_domain, root_domain: &cfg.server.root_domain,
}; };

View file

@ -46,6 +46,7 @@ pub const HASH_SPA_FALLBACK: [u8; 32] =
pub const API_KEY_ROOT: &str = "c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47"; pub const API_KEY_ROOT: &str = "c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47";
pub const API_KEY_2: &str = "21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b"; pub const API_KEY_2: &str = "21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b";
// pub const API_KEY_3: &str = "04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4"; // pub const API_KEY_3: &str = "04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4";
pub const API_KEY_RO: &str = "48691ad9f42bb12e61e259b5e90dc941a293cfae11af18c9e6557f92557f0086";
pub struct DbTest { pub struct DbTest {
db: Db, db: Db,
@ -74,6 +75,7 @@ fn insert_websites(db: &Db) {
&Website { &Website {
name: "ThetaDev".to_owned(), name: "ThetaDev".to_owned(),
created_at: datetime!(2023-02-18 16:30 +0), created_at: datetime!(2023-02-18 16:30 +0),
updated_at: datetime!(2023-02-18 16:30 +0),
latest_version: Some(2), latest_version: Some(2),
color: Some(2068974), color: Some(2068974),
visibility: talon::model::Visibility::Featured, visibility: talon::model::Visibility::Featured,
@ -86,6 +88,7 @@ fn insert_websites(db: &Db) {
&Website { &Website {
name: "Spotify-Gender-Ex".to_owned(), name: "Spotify-Gender-Ex".to_owned(),
created_at: datetime!(2023-02-18 16:30 +0), created_at: datetime!(2023-02-18 16:30 +0),
updated_at: datetime!(2023-02-18 16:30 +0),
latest_version: Some(1), latest_version: Some(1),
color: Some(1947988), color: Some(1947988),
visibility: talon::model::Visibility::Featured, visibility: talon::model::Visibility::Featured,
@ -100,6 +103,7 @@ fn insert_websites(db: &Db) {
&Website { &Website {
name: "RustyPipe".to_owned(), name: "RustyPipe".to_owned(),
created_at: datetime!(2023-02-20 18:30 +0), created_at: datetime!(2023-02-20 18:30 +0),
updated_at: datetime!(2023-02-20 18:30 +0),
latest_version: Some(1), latest_version: Some(1),
color: Some(7943647), color: Some(7943647),
visibility: talon::model::Visibility::Featured, visibility: talon::model::Visibility::Featured,
@ -114,6 +118,7 @@ fn insert_websites(db: &Db) {
&Website { &Website {
name: "SvelteKit SPA".to_owned(), name: "SvelteKit SPA".to_owned(),
created_at: datetime!(2023-03-03 22:00 +0), created_at: datetime!(2023-03-03 22:00 +0),
updated_at: datetime!(2023-03-03 22:00 +0),
latest_version: Some(1), latest_version: Some(1),
color: Some(16727552), color: Some(16727552),
visibility: talon::model::Visibility::Hidden, visibility: talon::model::Visibility::Hidden,

View file

@ -12,6 +12,7 @@ expression: websites
visibility: featured, visibility: featured,
source_url: None, source_url: None,
source_icon: None, source_icon: None,
has_icon: false,
), ),
Website( Website(
subdomain: "rustypipe", subdomain: "rustypipe",
@ -22,6 +23,7 @@ expression: websites
visibility: featured, visibility: featured,
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
source_icon: Some(gitea), source_icon: Some(gitea),
has_icon: false,
), ),
Website( Website(
subdomain: "spotify-gender-ex", subdomain: "spotify-gender-ex",
@ -32,5 +34,6 @@ expression: websites
visibility: featured, visibility: featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"), source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
source_icon: Some(github), source_icon: Some(github),
has_icon: false,
), ),
] ]

View file

@ -12,6 +12,7 @@ expression: websites
visibility: featured, visibility: featured,
source_url: None, source_url: None,
source_icon: None, source_icon: None,
has_icon: false,
), ),
Website( Website(
subdomain: "rustypipe", subdomain: "rustypipe",
@ -22,6 +23,7 @@ expression: websites
visibility: featured, visibility: featured,
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
source_icon: Some(gitea), source_icon: Some(gitea),
has_icon: false,
), ),
Website( Website(
subdomain: "spa", subdomain: "spa",
@ -32,6 +34,7 @@ expression: websites
visibility: hidden, visibility: hidden,
source_url: None, source_url: None,
source_icon: None, source_icon: None,
has_icon: false,
), ),
Website( Website(
subdomain: "spotify-gender-ex", subdomain: "spotify-gender-ex",
@ -42,5 +45,6 @@ expression: websites
visibility: featured, visibility: featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"), source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
source_icon: Some(github), source_icon: Some(github),
has_icon: false,
), ),
] ]

View file

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

View file

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

View file

@ -2,9 +2,9 @@
source: tests/tests.rs source: tests/tests.rs
expression: data 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}} {"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],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1}} {"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],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1}} {"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":"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":"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}} {"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 source: tests/tests.rs
expression: data 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}} {"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],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1}} {"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],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1}} {"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],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1}} {"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":"-: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":"-: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}} {"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,31 +6,37 @@ expression: "vec![ws1, ws2, ws3]"
Website( Website(
name: "ThetaDev", name: "ThetaDev",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), 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), latest_version: Some(2),
color: Some(2068974), color: Some(2068974),
visibility: featured, visibility: featured,
source_url: None, source_url: None,
source_icon: None, source_icon: None,
vid_count: 2, vid_count: 2,
has_icon: false,
), ),
Website( Website(
name: "Spotify-Gender-Ex", name: "Spotify-Gender-Ex",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), 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), latest_version: Some(1),
color: Some(1947988), color: Some(1947988),
visibility: featured, visibility: featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"), source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
source_icon: Some(github), source_icon: Some(github),
vid_count: 1, vid_count: 1,
has_icon: false,
), ),
Website( Website(
name: "RustyPipe", name: "RustyPipe",
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0), 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), latest_version: Some(1),
color: Some(7943647), color: Some(7943647),
visibility: featured, visibility: featured,
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
source_icon: Some(gitea), source_icon: Some(gitea),
vid_count: 1, vid_count: 1,
has_icon: false,
), ),
] ]

View file

@ -6,41 +6,49 @@ expression: websites
("-", Website( ("-", Website(
name: "ThetaDev", name: "ThetaDev",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), 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), latest_version: Some(2),
color: Some(2068974), color: Some(2068974),
visibility: featured, visibility: featured,
source_url: None, source_url: None,
source_icon: None, source_icon: None,
vid_count: 2, vid_count: 2,
has_icon: false,
)), )),
("rustypipe", Website( ("rustypipe", Website(
name: "RustyPipe", name: "RustyPipe",
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0), 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), latest_version: Some(1),
color: Some(7943647), color: Some(7943647),
visibility: featured, visibility: featured,
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
source_icon: Some(gitea), source_icon: Some(gitea),
vid_count: 1, vid_count: 1,
has_icon: false,
)), )),
("spa", Website( ("spa", Website(
name: "SvelteKit SPA", name: "SvelteKit SPA",
created_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0), 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), latest_version: Some(1),
color: Some(16727552), color: Some(16727552),
visibility: hidden, visibility: hidden,
source_url: None, source_url: None,
source_icon: None, source_icon: None,
vid_count: 1, vid_count: 1,
has_icon: false,
)), )),
("spotify-gender-ex", Website( ("spotify-gender-ex", Website(
name: "Spotify-Gender-Ex", name: "Spotify-Gender-Ex",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), 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), latest_version: Some(1),
color: Some(1947988), color: Some(1947988),
visibility: featured, visibility: featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"), source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
source_icon: Some(github), source_icon: Some(github),
vid_count: 1, vid_count: 1,
has_icon: false,
)), )),
] ]

View file

@ -5,10 +5,12 @@ expression: website
Website( Website(
name: "ThetaDev2", name: "ThetaDev2",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: "[date]",
latest_version: Some(2), latest_version: Some(2),
color: Some(1000), color: Some(1000),
visibility: hidden, visibility: hidden,
source_url: Some("https://example.com"), source_url: Some("https://example.com"),
source_icon: Some(link), source_icon: Some(link),
vid_count: 2, vid_count: 2,
has_icon: false,
) )

View file

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

View file

@ -8,6 +8,8 @@ use rstest::rstest;
use fixtures::*; use fixtures::*;
use talon::db::{Db, DbError}; use talon::db::{Db, DbError};
const ICON_SIZE: u32 = 48;
mod database { mod database {
use super::*; use super::*;
@ -83,7 +85,7 @@ mod database {
.unwrap(); .unwrap();
let website = db.get_website(SUBDOMAIN_1).unwrap(); let website = db.get_website(SUBDOMAIN_1).unwrap();
insta::assert_ron_snapshot!(website); insta::assert_ron_snapshot!(website, {".updated_at" => "[date]"});
} }
#[rstest] #[rstest]
@ -592,6 +594,50 @@ mod storage {
} }
} }
mod icons {
use talon::icons::Icons;
use super::*;
use image::io::Reader as ImageReader;
#[test]
fn insert_icon() {
let temp = temp_testdir::TempDir::default();
let icons = Icons::new(temp.to_path_buf());
let icon = File::open(path!("assets" / "icon.png")).unwrap();
icons.insert_icon(icon, "talon").unwrap();
let stored_path = path!(temp / "talon.png");
let got_path = icons.get_icon("talon").unwrap();
assert_eq!(stored_path, got_path);
assert!(stored_path.is_file());
let stored_img = ImageReader::open(&stored_path).unwrap().decode().unwrap();
assert_eq!(stored_img.height(), ICON_SIZE);
assert_eq!(stored_img.width(), ICON_SIZE);
}
#[test]
fn delete_icon() {
let temp = temp_testdir::TempDir::default();
let icons = Icons::new(temp.to_path_buf());
let icon = File::open(path!("assets" / "icon.png")).unwrap();
icons.insert_icon(icon, "talon").unwrap();
icons.delete_icon("talon").unwrap();
let stored_path = path!(temp / "talon.png");
assert!(!stored_path.exists());
assert!(matches!(
icons.get_icon("talon"),
Err(talon::icons::ImagesError::NotFound(_))
));
}
}
mod config { mod config {
use talon::config::Config; use talon::config::Config;
@ -743,8 +789,11 @@ mod page {
} }
mod api { mod api {
use std::io::{Cursor, Read};
use hex::ToHex; use hex::ToHex;
use hex_literal::hex; use hex_literal::hex;
use image::io::Reader as ImageReader;
use poem::{ use poem::{
http::{header, Method, StatusCode}, http::{header, Method, StatusCode},
test::TestClient, test::TestClient,
@ -773,6 +822,7 @@ mod api {
visibility: Visibility::Featured, visibility: Visibility::Featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()), source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()),
source_icon: Some(SourceIcon::Github), source_icon: Some(SourceIcon::Github),
has_icon: false,
})); }));
} }
@ -808,16 +858,18 @@ mod api {
resp.assert_status_is_ok(); resp.assert_status_is_ok();
let ws = tln.db.get_website("test").unwrap(); 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( Website(
name: "Test", name: "Test",
created_at: "[date]", created_at: "[date]",
updated_at: "[date]",
latest_version: None, latest_version: None,
color: Some(1000), color: Some(1000),
visibility: searchable, visibility: searchable,
source_url: Some("example.com"), source_url: Some("example.com"),
source_icon: Some(git), source_icon: Some(git),
vid_count: 0, vid_count: 0,
has_icon: false,
) )
"###); "###);
} }
@ -862,16 +914,18 @@ mod api {
resp.assert_status_is_ok(); resp.assert_status_is_ok();
let ws = tln.db.get_website("-").unwrap(); let ws = tln.db.get_website("-").unwrap();
insta::assert_ron_snapshot!(ws, @r###" insta::assert_ron_snapshot!(ws, {".updated_at" => "[date]"}, @r###"
Website( Website(
name: "Test", name: "Test",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
updated_at: "[date]",
latest_version: Some(2), latest_version: Some(2),
color: Some(1000), color: Some(1000),
visibility: searchable, visibility: searchable,
source_url: Some("example.com"), source_url: Some("example.com"),
source_icon: Some(git), source_icon: Some(git),
vid_count: 2, vid_count: 2,
has_icon: false,
) )
"###); "###);
} }
@ -896,6 +950,111 @@ mod api {
resp.assert_status(StatusCode::NOT_FOUND); resp.assert_status(StatusCode::NOT_FOUND);
} }
#[rstest]
fn website_get_icon(tln: TalonTest) {
let icon = File::open(path!("assets" / "icon.png")).unwrap();
tln.icons.insert_icon(icon, "-").unwrap();
let mut resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/icons/-")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
resp.assert_header(header::CONTENT_TYPE, "image/png");
let icon_bts = tokio_test::block_on(resp.0.take_body().into_bytes()).unwrap();
let got_icon = ImageReader::new(BufReader::new(Cursor::new(icon_bts)))
.with_guessed_format()
.unwrap()
.decode()
.unwrap();
assert_eq!(got_icon.height(), ICON_SIZE);
assert_eq!(got_icon.width(), ICON_SIZE);
}
#[rstest]
fn website_get_icon_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/icons/-")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn website_upload_icon(tln: TalonTest) {
let mut icon = File::open(path!("assets" / "icon.png")).unwrap();
let mut icon_bts = Vec::new();
icon.read_to_end(&mut icon_bts).unwrap();
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.put("http://talon.localhost:3000/api/website/-/icon")
.header(header::HOST, "talon.localhost:3000")
.header(header::CONTENT_TYPE, "application/octet-stream")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body(icon_bts)
.send(),
);
resp.assert_status_is_ok();
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/-")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
tokio_test::block_on(resp.assert_json(Website {
subdomain: "-".to_string(),
name: "ThetaDev".to_owned(),
created_at: datetime!(2023-02-18 16:30 +0),
latest_version: Some(2),
color: Some("#1f91ee".to_owned()),
visibility: talon::model::Visibility::Featured,
source_icon: None,
source_url: None,
has_icon: true,
}));
}
#[rstest]
fn website_delete_icon(tln: TalonTest) {
let icon = File::open(path!("assets" / "icon.png")).unwrap();
tln.icons.insert_icon(icon, "-").unwrap();
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/-/icon")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
}
#[rstest]
fn website_delete_icon_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/-/icon")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest] #[rstest]
fn website_delete(tln: TalonTest) { fn website_delete(tln: TalonTest) {
let resp = tokio_test::block_on( let resp = tokio_test::block_on(
@ -1172,6 +1331,10 @@ mod api {
.send(), .send(),
); );
resp.assert_status(StatusCode::BAD_REQUEST); resp.assert_status(StatusCode::BAD_REQUEST);
// Check that no zombie version remains
assert_eq!(tln.db.get_website_versions("rustypipe").count(), 1);
assert_eq!(tln.db.get_website("rustypipe").unwrap().vid_count, 1);
} }
#[rstest] #[rstest]
@ -1190,6 +1353,10 @@ mod api {
.send(), .send(),
); );
resp.assert_status(StatusCode::BAD_REQUEST); resp.assert_status(StatusCode::BAD_REQUEST);
// Check that no zombie version remains
assert_eq!(tln.db.get_website_versions("rustypipe").count(), 1);
assert_eq!(tln.db.get_website("rustypipe").unwrap().vid_count, 1);
} }
#[rstest] #[rstest]
@ -1219,11 +1386,13 @@ mod api {
#[case::websites_all("websitesAll", Method::GET)] #[case::websites_all("websitesAll", Method::GET)]
#[case::version_delete("website/test/version/1", Method::DELETE)] #[case::version_delete("website/test/version/1", Method::DELETE)]
#[case::version_upload("website/test/upload", Method::POST)] #[case::version_upload("website/test/upload", Method::POST)]
#[case::icon_upload("website/test/icon", Method::PUT)]
#[case::icon_delete("website/test/icon", Method::DELETE)]
fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) { fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) {
let resp = tokio_test::block_on( let resp = tokio_test::block_on(
TestClient::new(tln.endpoint()) TestClient::new(tln.endpoint())
.request( .request(
method, method.clone(),
format!("http://talon.localhost:3000/api/{endpoint}"), format!("http://talon.localhost:3000/api/{endpoint}"),
) )
.header(header::HOST, "talon.localhost:3000") .header(header::HOST, "talon.localhost:3000")
@ -1231,5 +1400,34 @@ mod api {
.send(), .send(),
); );
resp.assert_status(StatusCode::UNAUTHORIZED); resp.assert_status(StatusCode::UNAUTHORIZED);
let resp = match method {
Method::POST => tokio_test::block_on(
TestClient::new(tln.endpoint())
.request(
method.clone(),
format!("http://talon.localhost:3000/api/{endpoint}"),
)
.header(header::HOST, "talon.localhost:3000")
.header(header::CONTENT_TYPE, "application/octet-stream")
.header("x-api-key", API_KEY_RO)
.data(tln.clone())
.send(),
),
Method::GET | Method::PUT | Method::PATCH => return,
_ => tokio_test::block_on(
TestClient::new(tln.endpoint())
.request(
method.clone(),
format!("http://talon.localhost:3000/api/{endpoint}"),
)
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_RO)
.data(tln.clone())
.body_json(&serde_json::Value::Null)
.send(),
),
};
resp.assert_status(StatusCode::FORBIDDEN);
} }
} }

View file

@ -10,6 +10,7 @@
*/ */
"importsNotUsedAsValues": "error", "importsNotUsedAsValues": "error",
"isolatedModules": true, "isolatedModules": true,
// for TS5 "verbatimModuleSyntax": true,
/** /**
* To have warnings / errors of the Svelte compiler at the * To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default. * correct position, enable source maps by default.

View file

@ -1,40 +0,0 @@
module.exports = {
extends: ["eslint:recommended"],
env: { browser: true, es6: true, node: true },
parserOptions: {
sourceType: "module",
},
overrides: [
{
files: ["*.ts", "*.svelte"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
],
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
},
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
},
plugins: ["@typescript-eslint"],
},
{
files: ["*.svelte"],
processor: "svelte3/svelte3",
parserOptions: {
extraFileExtensions: [".svelte"],
},
plugins: ["svelte3", "@typescript-eslint"],
settings: {
"svelte3/typescript": true,
"svelte3/ignore-styles": () => true,
},
},
],
rules: {},
ignorePatterns: [".rollup/**", "public/**", "dist/**"],
};

1440
ui/menu/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,13 +6,11 @@
"scripts": { "scripts": {
"dev": "rollup -c -w", "dev": "rollup -c -w",
"build": "rollup -c", "build": "rollup -c",
"start": "sirv public --single", "start": "sirv public -p 5000 --single",
"lint": "eslint .",
"fix": "eslint . --fix",
"check": "svelte-check --tsconfig ../../tsconfig.json", "check": "svelte-check --tsconfig ../../tsconfig.json",
"format": "prettier --plugin=./node_modules/prettier-plugin-svelte --write .", "format": "prettier --plugin=./node_modules/prettier-plugin-svelte --write .",
"pc": "npm run fix & npm run check & npm run format", "pc": "npm run check & npm run format",
"ci": "npm run lint & npm run check & prettier --plugin=./node_modules/prettier-plugin-svelte --check ." "ci": "npm run check & prettier --plugin=./node_modules/prettier-plugin-svelte --check ."
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.15.5", "@babel/core": "^7.15.5",
@ -25,12 +23,8 @@
"@rollup/plugin-typescript": "^8.2.5", "@rollup/plugin-typescript": "^8.2.5",
"@rollup/pluginutils": "^4.1.1", "@rollup/pluginutils": "^4.1.1",
"@tsconfig/svelte": "^2.0.1", "@tsconfig/svelte": "^2.0.1",
"@typescript-eslint/eslint-plugin": "^4.31.2", "prettier": "^2.8.7",
"@typescript-eslint/parser": "^4.31.2", "prettier-plugin-svelte": "^2.10.0",
"eslint": "^7.32.0",
"eslint-plugin-svelte3": "^3.2.1",
"prettier": "^2.2.1",
"prettier-plugin-svelte": "^1.2.0",
"rollup": "^2.57.0", "rollup": "^2.57.0",
"rollup-plugin-brotli": "^3.1.0", "rollup-plugin-brotli": "^3.1.0",
"rollup-plugin-copy": "^3.4.0", "rollup-plugin-copy": "^3.4.0",
@ -39,11 +33,11 @@
"rollup-plugin-svelte": "^7.1.0", "rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"sass": "^1.42.1", "sass": "^1.42.1",
"sirv-cli": "^1.0.14", "sirv-cli": "^2.0.2",
"svelte": "^3.43.0", "svelte": "^3.43.0",
"svelte-check": "^2.2.6", "svelte-check": "^3.1.4",
"svelte-keydown": "^0.3.1", "svelte-keydown": "^0.6.0",
"svelte-modals": "^1.0.4", "svelte-modals": "^1.2.1",
"svelte-preprocess": "^4.9.5", "svelte-preprocess": "^4.9.5",
"tslib": "^2.5.0", "tslib": "^2.5.0",
"typescript": "^4.9.5" "typescript": "^4.9.5"

View file

@ -190,7 +190,7 @@
<script id="talon-config" type="application/json"> <script id="talon-config" type="application/json">
{ {
"api": "http://talon.localhost:3000/api", "internal": "http://talon.localhost:3000",
"version": "0.1.0", "version": "0.1.0",
"root_domain": "localhost:3000" "root_domain": "localhost:3000"
} }

View file

@ -9,7 +9,6 @@
let currentWebsite: Website; let currentWebsite: Website;
currentWebsiteStore.subscribe((ws) => { currentWebsiteStore.subscribe((ws) => {
console.log("current ws changed", ws);
currentWebsite = ws; currentWebsite = ws;
}); });
@ -23,19 +22,8 @@
color = "#7935df"; color = "#7935df";
} }
} }
</script> </script>
<style lang="sass">
.backdrop
position: fixed
top: 0
bottom: 0
right: 0
left: 0
background: rgba(0, 0, 0, 0.6)
</style>
<div class="wrapper" style={`--talon-color: ${color}`}> <div class="wrapper" style={`--talon-color: ${color}`}>
{#if currentWebsite} {#if currentWebsite}
<Menu /> <Menu />
@ -46,3 +34,13 @@
<div class="backdrop" slot="backdrop" on:click={closeModal} /> <div class="backdrop" slot="backdrop" on:click={closeModal} />
</Modals> </Modals>
</div> </div>
<style lang="sass">
.backdrop
position: fixed
top: 0
bottom: 0
right: 0
left: 0
background: rgba(0, 0, 0, 0.6)
</style>

View file

@ -1,21 +1,32 @@
<script lang="ts"> <script lang="ts">
import { isMobile } from "../util/functions";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
export let hide = false; export let hide = false;
</script> </script>
<button class:hide on:click>
<Icon iconName="menu" size={22} />
</button>
<style lang="sass"> <style lang="sass">
@use "../style/values" @use "../style/values"
@import "../style/mixin"
button button
position: fixed position: fixed
bottom: 25px bottom: 25px
right: 25px right: 25px
height: 56px height: 56px
width: 56px width: 56px
@include mobile
bottom: 0px
right: 0px
height: 35px
width: 35px
border-radius: 50% 0 0
display: flex display: flex
align-items: center align-items: center
justify-content: center justify-content: center
@ -31,7 +42,3 @@
bottom: -56px bottom: -56px
opacity: 0 opacity: 0
</style> </style>
<button class:hide on:click>
<Icon iconName="menu" size={25} />
</button>

View file

@ -9,9 +9,29 @@
let icon: [number, number, string]; let icon: [number, number, string];
$: icon = icons[iconName] ?? [0, 0, ""]; $: icon = icons[iconName] ?? [0, 0, ""];
</script> </script>
<span class="icon" style="width: {size}px; height: {size}px;">
{#if icon}
<span
style="width: {(size * scale * 4) / 3}px; height: {(size * scale * 4) / 3}px"
class:transparent
>
<svg
aria-hidden="true"
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
width={size * scale}
height={size * scale}
viewBox="0 0 {icon[0]} {icon[1]}"
>
<path d={icon[2]} fill={color} />
</svg>
</span>
{/if}
</span>
<style lang="sass"> <style lang="sass">
@use "../style/values" @use "../style/values"
@ -22,22 +42,3 @@
&.transparent &.transparent
background: none background: none
</style> </style>
<span class="icon" style="width: {size}px; height: {size}px;">
{#if icon}
<span
style="width: {(size * scale * 4) / 3}px; height: {(size * scale * 4) / 3}px"
class:transparent>
<svg
aria-hidden="true"
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
width={size * scale}
height={size * scale}
viewBox="0 0 {icon[0]} {icon[1]}">
<path d={icon[2]} fill={color} />
</svg>
</span>
{/if}
</span>

View file

@ -4,9 +4,25 @@
export let scale = 1; export let scale = 1;
export let alt = "??"; export let alt = "??";
export let color = "#4b228a"; export let color = "#4b228a";
</script> </script>
<span class="icon" style="width: {size}px; height: {size}px;">
{#if imageSrc}
<span
style="width: {size * scale}px; height: {size *
scale}px; background-image: url('{imageSrc}')"
/>
{:else}
<span
style="width: {size * scale}px; height: {size * scale}px; font-size: {size *
scale *
0.55}px; background: {color}"
>
{alt}
</span>
{/if}
</span>
<style lang="sass"> <style lang="sass">
@use "../style/values" @use "../style/values"
@ -17,15 +33,3 @@
text-transform: uppercase text-transform: uppercase
border-radius: 50% border-radius: 50%
</style> </style>
<span class="icon" style="width: {size}px; height: {size}px;">
{#if imageSrc}
<span
style="width: {size * scale}px; height: {size * scale}px; background-image: url('{imageSrc}')" />
{:else}
<span
style="width: {size * scale}px; height: {size * scale}px; font-size: {size * scale * 0.55}px; background: {color}">
{alt}
</span>
{/if}
</span>

View file

@ -1,176 +0,0 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { closeModal } from "svelte-modals";
import Keydown from "svelte-keydown";
import PageIcon from "./PageIcon.svelte";
import Icon from "./Icon.svelte";
import { formatDate, getWebsiteVersionUrl } from "../util/functions";
import InlineIcon from "./InlineIcon.svelte";
import Tag from "./Tag.svelte";
import type { Version, Website } from "talon-client";
import { client, currentWebsiteStore } from "../util/api";
import { talonConfig } from "../util/talonData";
let currentWebsite: Website;
currentWebsiteStore.subscribe((ws) => {
currentWebsite = ws;
});
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];
}
});
}
}
let versions: Version[] = [];
let currentVersion: Version = null;
</script>
<style lang="sass">
@use "../style/values"
.modal
position: fixed
top: 0
bottom: 0
right: 0
left: 0
display: flex
justify-content: center
align-items: center
pointer-events: none
> div
position: relative
overflow-y: auto
overflow-x: hidden
margin: 75px auto
padding: 20px
width: 600px
max-width: 90%
max-height: 90%
background: values.$color-base
border-radius: 15px
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5)
pointer-events: auto
.tag
display: flex
align-items: center
span
font-size: 2em
margin-left: 0.25em
button
position: absolute
right: 5px
top: 5px
background: none
border: none
&:hover
filter: brightness(50%)
.dhead
width: 100%
font-size: 1.4em
border-style: solid
border-image-source: linear-gradient(to right, values.$color-base-1, values.$color-base-2, values.$color-base-1)
border-image-slice: 0 0 1 0
border-image-width: 2px
.smalltag
margin: 0.3em 0
display: flex
> *
display: flex
a
filter: none
a:hover
text-decoration: none
filter: brightness(130%)
span
padding: 0.4em
background-color: values.$color-base-1
span:first-child
font-weight: bold
background-color: var(--talon-color)
</style>
<Keydown paused={!isOpen} on:Escape={closeModal} />
{#if isOpen && currentWebsite}
<div class="modal" role="dialog" transition:fly={{ y: 50 }} on:introstart on:outroend>
<div>
<div class="tag">
<PageIcon website={currentWebsite} size={60} scale={0.8} />
<span>{currentWebsite.name}</span>
</div>
{#if currentVersion}
<p class="dhead">
<InlineIcon iconName="question" />
Current version #{currentVersion.id}
</p>
<Tag key="Upload date" value={formatDate(currentVersion.createdAt)} />
<!--<Tag key="Uploaded by" value={currentVersion.user} />-->
{#each Object.entries(currentVersion.data) as [key, value]}
<Tag {key} {value} />
{/each}
{#if versions && versions.length}
<p class="dhead">
<InlineIcon iconName="history" />
History
</p>
{#each versions as version}
<p class="smalltag">
<a href={getWebsiteVersionUrl(currentWebsite.subdomain, version.id)}>
<span>#{version.id}</span>
<span>{formatDate(version.createdAt)}</span>
</a>
</p>
{/each}
{/if}
{/if}
<p class="dhead" />
<div>
Powered by
<a
href="https://code.thetadev.de/ThetaDev/Talon/src/tag/{talonConfig.version}"
target="_blank"
rel="noreferrer"
referrerpolicy="no-referrer">Talon
{talonConfig.version}</a>
</div>
<!--<p><a href="" target="_blank">View licenses</a></p>-->
<button on:click={closeModal}>
<Icon iconName="close" size={40} scale={0.6} transparent={true} />
</button>
</div>
</div>
{/if}

View file

@ -7,16 +7,8 @@
let icon: [number, number, string]; let icon: [number, number, string];
$: icon = icons[iconName] ?? [0, 0, ""]; $: icon = icons[iconName] ?? [0, 0, ""];
</script> </script>
<style lang="sass">
@use "../style/values"
span
display: inline-block
</style>
<span class="icon"> <span class="icon">
<svg <svg
aria-hidden="true" aria-hidden="true"
@ -25,7 +17,15 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 {icon[0]} {icon[1]}"> viewBox="0 0 {icon[0]} {icon[1]}"
>
<path d={icon[2]} fill={color} /> <path d={icon[2]} fill={color} />
</svg> </svg>
</span> </span>
<style lang="sass">
@use "../style/values"
span
display: inline-block
</style>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { talonConfig } from "../util/talonData";
import Modal from "./Modal.svelte";
import type { Info } from "talon-client";
import { client } from "../util/api";
import Tag from "./Tag.svelte";
import { formatDate, trimCommit } from "../util/functions";
import InlineIcon from "./InlineIcon.svelte";
export let isOpen = false;
$: {
if (isOpen) {
client.infoGet().then((info) => {
instanceInfo = info;
});
}
}
let instanceInfo: Info | null = null;
const repoBaseUrl = "https://code.thetadev.de/ThetaDev/Talon";
let repoUrl = `${repoBaseUrl}/src/tag/v${talonConfig.version}`;
</script>
<Modal {isOpen}>
{#if instanceInfo}
<p class="divider">
<InlineIcon iconName="question" />
Instance info
</p>
<Tag key="Number of files" value={instanceInfo.stats.nFiles.toString()} />
<Tag key="Number of websites" value={instanceInfo.stats.nWebsites.toString()} />
<Tag key="Start time" value={formatDate(instanceInfo.startTime)} />
<p class="divider">
<InlineIcon iconName="cog" />
Version info
</p>
<Tag key="Version" value={talonConfig.version} href={repoUrl} external={true} />
<Tag key="Build mode" value={instanceInfo.version.buildMode} />
<Tag key="Build target" value={instanceInfo.version.buildTarget} />
<Tag
key="Commit"
value={trimCommit(instanceInfo.version.commit)}
href={repoBaseUrl + "/src/commit/" + instanceInfo.version.commit}
external={true}
/>
<Tag key="Commit date" value={formatDate(instanceInfo.version.commitDate)} />
<Tag key="Rust version" value={instanceInfo.version.rustVersion} />
{:else}
<p>Loading instance info...</p>
<p class="divider" />
<a href={repoUrl} target="_blank" rel="noreferrer" referrerpolicy="no-referrer"
>Talon
{talonConfig.version}</a
>
{/if}
</Modal>
<style lang="sass">
@use "../style/values"
</style>

View file

@ -4,7 +4,7 @@
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import MenuItem from "./MenuItem.svelte"; import MenuItem from "./MenuItem.svelte";
import MenuItemPage from "./MenuItemPage.svelte"; import MenuItemPage from "./MenuItemPage.svelte";
import InfoModal from "./InfoModal.svelte"; import PageInfoModal from "./PageInfoModal.svelte";
import FloatingButton from "./FloatingButton.svelte"; import FloatingButton from "./FloatingButton.svelte";
import type { Focusable } from "../util/types"; import type { Focusable } from "../util/types";
@ -12,7 +12,7 @@
import MenuItemInput from "./MenuItemInput.svelte"; import MenuItemInput from "./MenuItemInput.svelte";
import { currentWebsiteStore, websitesStore } from "../util/api"; import { currentWebsiteStore, websitesStore } from "../util/api";
import { Visibility, Website } from "talon-client"; import { Visibility, Website } from "talon-client";
import { getWebsiteUrl } from "../util/functions"; import { getWebsiteUrl, isMobile } from "../util/functions";
let currentWebsite: Website; let currentWebsite: Website;
let websites: Website[]; let websites: Website[];
@ -32,10 +32,6 @@
sidebarShown = false; sidebarShown = false;
} }
function isMobile(): boolean {
return window.innerWidth < 768;
}
function openSearch(): void { function openSearch(): void {
searchOpen = true; searchOpen = true;
searchInput.focus(); searchInput.focus();
@ -53,7 +49,7 @@
closeSearch(); closeSearch();
} }
function searchKeypress(e: KeyboardEvent) { function searchKeyup(e: KeyboardEvent) {
switch (e.key) { switch (e.key) {
case "Enter": case "Enter":
if (!searchText) { if (!searchText) {
@ -70,8 +66,8 @@
} }
} }
function openInfo() { function openPageInfo() {
openModal(InfoModal); openModal(PageInfoModal, null, { replace: true });
} }
let sidebarShown = !isMobile(); let sidebarShown = !isMobile();
@ -92,9 +88,46 @@
} }
return ws.visibility === Visibility.Featured; return ws.visibility === Visibility.Featured;
}); });
</script> </script>
<nav class:hide={!sidebarShown}>
<div>
<MenuItemInput
active={searchOpen || Boolean(searchText).valueOf()}
on:click={openSearch}
on:focusout={closeSearch}
on:keyup={searchKeyup}
bind:input={searchInput}
bind:text={searchText}
/>
</div>
<div>
{#each displayedWebsites as website, i}
<MenuItemPage {website} active={searchOpen && searchText && i === 0} />
{/each}
</div>
<div>
{#if currentWebsite && currentWebsite.sourceUrl}
<MenuItem
text="View source"
link={currentWebsite.sourceUrl}
newTab={true}
privacy={true}
>
<Icon iconName={currentWebsite.sourceIcon} size={40} scale={0.6} />
</MenuItem>
{/if}
<MenuItem text="Info" on:click={openPageInfo}>
<PageIcon website={currentWebsite} />
</MenuItem>
<MenuItem text="Hide sidebar" on:click={hideSidebar}>
<Icon iconName="arrowRight" size={40} scale={0.6} />
</MenuItem>
</div>
</nav>
<FloatingButton hide={sidebarShown} on:click={showSidebar} />
<style lang="sass"> <style lang="sass">
@use "../style/values" @use "../style/values"
@use "../style/mixin" @use "../style/mixin"
@ -106,7 +139,7 @@
height: 100% height: 100%
z-index: 999999 z-index: 999999
padding: 1em 0.4em padding: 3em 0.4em 0.4em
display: flex display: flex
flex-direction: column flex-direction: column
@ -132,39 +165,3 @@
nav nav
display: none display: none
</style> </style>
<nav class:hide={!sidebarShown}>
<div>
<MenuItemInput
active={searchOpen || Boolean(searchText).valueOf()}
on:click={openSearch}
on:focusout={closeSearch}
on:keyup={searchKeypress}
bind:input={searchInput}
bind:text={searchText} />
</div>
<div>
{#each displayedWebsites as website, i}
<MenuItemPage {website} active={searchOpen && searchText && i === 0} />
{/each}
</div>
<div>
{#if currentWebsite && currentWebsite.sourceUrl}
<MenuItem
text="View source"
link={currentWebsite.sourceUrl}
newTab={true}
privacy={true}>
<Icon iconName={currentWebsite.sourceIcon} size={40} scale={0.6} />
</MenuItem>
{/if}
<MenuItem text="Info" on:click={openInfo}>
<PageIcon website={currentWebsite} />
</MenuItem>
<MenuItem text="Hide sidebar" on:click={hideSidebar}>
<Icon iconName="arrowRight" size={40} scale={0.6} />
</MenuItem>
</div>
</nav>
<FloatingButton hide={sidebarShown} on:click={showSidebar} />

View file

@ -4,9 +4,21 @@
export let active = false; export let active = false;
export let newTab = false; export let newTab = false;
export let privacy = false; export let privacy = false;
</script> </script>
<div>
<a
class:active
href={link}
target={newTab ? "_blank" : null}
rel={privacy ? "noopener noreferrer" : null}
on:click
>
<span>{text}</span>
<slot />
</a>
</div>
<style lang="sass"> <style lang="sass">
@use "../style/values" @use "../style/values"
@ -46,15 +58,3 @@
> span, > :global(*) > span, > :global(*)
display: flex display: flex
</style> </style>
<div>
<a
class:active
href={link}
target={newTab ? '_blank' : null}
rel={privacy ? 'noopener noreferrer' : null}
on:click>
<span>{text}</span>
<slot />
</a>
</div>

View file

@ -18,9 +18,22 @@
inputElm.blur(); inputElm.blur();
}, },
}; };
</script> </script>
<MenuItem {active} on:click>
<input
placeholder="Search..."
bind:this={inputElm}
bind:value={text}
on:focusout
on:keypress|stopPropagation
on:keydown|stopPropagation
on:keyup|stopPropagation
use:selectTextOnFocus
/>
<Icon iconName="search" size={40} scale={0.6} />
</MenuItem>
<style lang="sass"> <style lang="sass">
input input
background: none background: none
@ -34,14 +47,3 @@
&:focus &:focus
border-bottom: solid #fff 2px border-bottom: solid #fff 2px
</style> </style>
<MenuItem {active} on:click>
<input
placeholder="Search..."
bind:this={inputElm}
bind:value={text}
on:focusout
on:keyup
use:selectTextOnFocus />
<Icon iconName="search" size={40} scale={0.6} />
</MenuItem>

View file

@ -14,7 +14,6 @@
website.name.length > MAX_TEXT_LEN website.name.length > MAX_TEXT_LEN
? website.name.substring(0, 20).trim() + "..." ? website.name.substring(0, 20).trim() + "..."
: website.name; : website.name;
</script> </script>
<MenuItem {active} {text} link={getWebsiteUrl(website.subdomain)}> <MenuItem {active} {text} link={getWebsiteUrl(website.subdomain)}>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { closeModal } from "svelte-modals";
import Keydown from "svelte-keydown";
import Icon from "./Icon.svelte";
export let isOpen = false;
</script>
<Keydown paused={!isOpen} on:Escape={closeModal} />
{#if isOpen}
<div class="modal" role="dialog" transition:fly={{ y: 50 }} on:introstart on:outroend>
<div>
<slot />
<button on:click={closeModal}>
<Icon iconName="close" size={40} scale={0.6} transparent={true} />
</button>
</div>
</div>
{/if}
<style lang="sass">
@use "../style/values"
.modal
position: fixed
top: 0
bottom: 0
right: 0
left: 0
z-index: 1000000
display: flex
justify-content: center
align-items: center
pointer-events: none
> div
position: relative
overflow-y: auto
overflow-x: hidden
margin: 75px auto
padding: 20px
width: 600px
max-width: 90%
max-height: 90%
background: values.$color-base
border-radius: 15px
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5)
pointer-events: auto
button
position: absolute
right: 5px
top: 5px
background: none
border: none
&:hover
filter: brightness(50%)
</style>

View file

@ -1,21 +1,25 @@
<script lang="ts"> <script lang="ts">
import ImageIcon from "./ImageIcon.svelte"; import ImageIcon from "./ImageIcon.svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import type { Website } from "ui/apiclient"; import type { Website } from "talon-client";
import { talonConfig } from "../util/talonData";
import { getAbbreviation } from "../util/functions";
export let website: Website; export let website: Website;
export let size = 40; export let size = 40;
export let scale = 0.8; export let scale = 0.8;
</script> </script>
{#if website} {#if website}
<ImageIcon <ImageIcon
imageSrc="" imageSrc={website.hasIcon
? `${talonConfig.internal}/icons/${website.subdomain}`
: null}
color={website.color} color={website.color}
alt={website.name.substring(0, 2)} alt={getAbbreviation(website.name)}
{size} {size}
{scale} /> {scale}
/>
{:else} {:else}
<Icon iconName="question" {size} scale={scale * 0.75} /> <Icon iconName="question" {size} scale={scale * 0.75} />
{/if} {/if}

View file

@ -0,0 +1,142 @@
<script lang="ts">
import PageIcon from "./PageIcon.svelte";
import {
formatDate,
getSubdomainAndVersion,
getWebsiteUrl,
getWebsiteVersionUrl,
isUrl,
trimCommit,
} from "../util/functions";
import InlineIcon from "./InlineIcon.svelte";
import Tag from "./Tag.svelte";
import type { Version, Website } from "talon-client";
import { client, currentWebsiteStore } from "../util/api";
import { talonConfig } from "../util/talonData";
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;
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 | undefined;
let latestVersion: Version | undefined;
function getVersionAttr(version: Version): string | null {
return (
version.data["version"] ||
version.data["Version"] ||
trimCommit(version.data["commit"]) ||
trimCommit(version.data["Commit"]) ||
null
);
}
function openInstanceInfo() {
openModal(InstanceInfoModal, null, { replace: true });
}
</script>
<Modal {isOpen}>
{#if currentWebsite}
<div class="tag">
<PageIcon website={currentWebsite} size={60} scale={0.8} />
<span>{currentWebsite.name}</span>
</div>
{#if currentVersion}
<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)} />
{#each Object.entries(currentVersion.data) as [key, value]}
<Tag {key} {value} href={isUrl(value) ? value : null} external={true} />
{/each}
{#if versions && versions.length}
<p class="divider">
<InlineIcon iconName="history" />
History
</p>
{#each { length: versions.length } as _, index}
{@const reverseIndex = versions.length - 1 - index}
{@const version = versions[reverseIndex]}
<Tag
href={getWebsiteVersionUrl(currentWebsite.subdomain, version.id)}
key={"#" + version.id}
value={formatDate(version.createdAt)}
value2={getVersionAttr(version)}
/>
{/each}
{/if}
{:else}
<p>Loading website...</p>
{/if}
<p class="divider" />
<div>
Powered by
<button class="link" on:click={openInstanceInfo}
>Talon {talonConfig.version}</button
>
</div>
{/if}
</Modal>
<style lang="sass">
@use "../style/values"
.tag
display: flex
align-items: center
span
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

@ -1,10 +1,30 @@
<script lang="ts"> <script lang="ts">
export let key = ""; export let key = "";
export let value = ""; export let value = "";
export let href: string = null; export let value2: string | null = null;
export let href: string | null = null;
export let external = false;
</script> </script>
<p>
{#if href}
<a
{href}
target={external ? "blank" : null}
rel={external ? "noreferrer" : null}
referrerpolicy={external ? "no-referrer" : null}
>
<span>{key}</span>
{#if value2}<span class="val2">{value2}</span>{/if}
<span>{value}</span>
</a>
{:else}
<span>{key}</span>
{#if value2}<span class="val2">{value2}</span>{/if}
<span>{value}</span>
{/if}
</p>
<style lang="sass"> <style lang="sass">
@use "../style/values" @use "../style/values"
@ -15,6 +35,7 @@
a a
filter: none filter: none
display: flex display: flex
max-width: 100%
a:hover a:hover
text-decoration: none text-decoration: none
@ -23,14 +44,14 @@
span span
padding: 0.4em padding: 0.4em
background-color: values.$color-base-1 background-color: values.$color-base-1
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
span:first-child span:first-child
font-weight: bold font-weight: bold
background-color: var(--talon-color) background-color: var(--talon-color)
</style>
<p> .val2
{#if href} background-color: values.$color-base-2
<a {href}> <span>{key}</span> <span>{value}</span> </a> </style>
{:else}<span>{key}</span> <span>{value}</span>{/if}
</p>

View file

@ -6,11 +6,18 @@
font-family: sans-serif font-family: sans-serif
color: values.$color-text color: values.$color-text
a a, .link
display: inline display: inline
text-decoration: none
cursor: pointer
background: none
border: none
box-shadow: none
padding: 0
.link
color: var(--talon-color) color: var(--talon-color)
filter: brightness(150%) filter: brightness(150%)
text-decoration: none
&:hover &:hover
text-decoration: underline text-decoration: underline
@ -19,9 +26,6 @@ a
p p
margin: 0.8em 0 margin: 0.8em 0
button
cursor: pointer
::placeholder, ::-webkit-input-placeholder ::placeholder, ::-webkit-input-placeholder
color: values.$color-text-1 color: values.$color-text-1
opacity: 1 opacity: 1
@ -30,3 +34,11 @@ button
display: flex display: flex
align-items: center align-items: center
justify-content: center justify-content: center
.divider
width: 100%
font-size: 1.4em
border-style: solid
border-image-source: linear-gradient(to right, values.$color-base-1, values.$color-base-2, values.$color-base-1)
border-image-slice: 0 0 1 0
border-image-width: 2px

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-light: color.scale($color-primary, $lightness: 15%)
$color-primary-dark: 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-1: color.scale($color-text, $lightness: -15%)
$color-text-d1: color.scale($color-base, $lightness: -20%)

View file

@ -1,13 +1,15 @@
import { Writable, writable } from "svelte/store"; import { type Writable, writable } from "svelte/store";
import { Configuration, DefaultApi, Website } from "talon-client"; import { Configuration, DefaultApi, type Website } from "talon-client";
import { getSubdomain } from "./functions"; import { getSubdomain } from "./functions";
import { talonConfig } from "./talonData"; import { talonConfig } from "./talonData";
export const websitesStore: Writable<Website[]> = writable([]); export const websitesStore: Writable<Website[]> = writable([]);
export const currentWebsiteStore: Writable<Website> = writable(null); export const currentWebsiteStore: Writable<Website> = writable(null);
export const client = new DefaultApi(new Configuration({ basePath: talonConfig.api })); export const client = new DefaultApi(
new Configuration({ basePath: `${talonConfig.internal}/api` })
);
export function fetchWebsites(): void { export function fetchWebsites(): void {
client.websitesGet().then((ws) => { client.websitesGet().then((ws) => {

View file

@ -21,6 +21,29 @@ export function getSubdomain(): string {
return "-"; 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 { export function getWebsiteUrl(subdomain: string): string {
const proto = window.location.protocol; const proto = window.location.protocol;
@ -41,3 +64,40 @@ export function getWebsiteVersionUrl(subdomain: string, version: number): string
return `${proto}//${subdomain}${v}.${talonConfig.root_domain}`; return `${proto}//${subdomain}${v}.${talonConfig.root_domain}`;
} }
} }
export function isUrl(url: string): boolean {
return /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/.test(
url
);
}
export function trimCommit(commit: string | undefined): string | undefined {
if (commit) {
return commit.slice(0, 9);
}
return 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);
}

View file

@ -6,6 +6,7 @@ import { svgPathData as faHistory } from "@fortawesome/free-solid-svg-icons/faHi
import { svgPathData as faArrowRight } from "@fortawesome/free-solid-svg-icons/faChevronRight"; import { svgPathData as faArrowRight } from "@fortawesome/free-solid-svg-icons/faChevronRight";
import { svgPathData as faClose } from "@fortawesome/free-solid-svg-icons/faTimes"; import { svgPathData as faClose } from "@fortawesome/free-solid-svg-icons/faTimes";
import { svgPathData as faMenu } from "@fortawesome/free-solid-svg-icons/faTh"; import { svgPathData as faMenu } from "@fortawesome/free-solid-svg-icons/faTh";
import { svgPathData as faCog } from "@fortawesome/free-solid-svg-icons/faCog";
import { svgPathData as faGit } from "@fortawesome/free-brands-svg-icons/faGitAlt"; import { svgPathData as faGit } from "@fortawesome/free-brands-svg-icons/faGitAlt";
import { svgPathData as faGithub } from "@fortawesome/free-brands-svg-icons/faGithub"; import { svgPathData as faGithub } from "@fortawesome/free-brands-svg-icons/faGithub";
@ -21,6 +22,7 @@ const icons: { [key: string]: [number, number, string] } = {
arrowRight: [320, 512, faArrowRight], arrowRight: [320, 512, faArrowRight],
close: [352, 512, faClose], close: [352, 512, faClose],
menu: [512, 512, faMenu], menu: [512, 512, faMenu],
cog: [512, 512, faCog],
git: [496, 512, faGit], git: [496, 512, faGit],
github: [496, 512, faGithub], github: [496, 512, faGithub],

View file

@ -1,5 +1,5 @@
export interface TalonConfig { export interface TalonConfig {
api: string; internal: string;
version: string; version: string;
root_domain: string; root_domain: string;
} }

View file

@ -1,6 +1,5 @@
.gitignore .gitignore
.npmignore .npmignore
.openapi-generator-ignore
README.md README.md
package.json package.json
src/apis/DefaultApi.ts src/apis/DefaultApi.ts

View file

@ -1,12 +1,12 @@
{ {
"name": "talon-client", "name": "talon-client",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "talon-client", "name": "talon-client",
"version": "0.1.0", "version": "0.2.0",
"devDependencies": { "devDependencies": {
"typescript": "^4.0" "typescript": "^4.0"
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "talon-client", "name": "talon-client",
"version": "0.1.0", "version": "0.2.0",
"description": "OpenAPI client for talon-client", "description": "OpenAPI client for talon-client",
"author": "OpenAPI-Generator", "author": "OpenAPI-Generator",
"repository": { "repository": {

View file

@ -52,6 +52,15 @@ export interface WebsiteSubdomainGetRequest {
subdomain: string; subdomain: string;
} }
export interface WebsiteSubdomainIconDeleteRequest {
subdomain: string;
}
export interface WebsiteSubdomainIconPutRequest {
subdomain: string;
body: Blob;
}
export interface WebsiteSubdomainPatchRequest { export interface WebsiteSubdomainPatchRequest {
subdomain: string; subdomain: string;
websiteUpdate: WebsiteUpdate; websiteUpdate: WebsiteUpdate;
@ -221,6 +230,81 @@ export class DefaultApi extends runtime.BaseAPI {
return await response.value(); return await response.value();
} }
/**
* Delete a website icon
*/
async websiteSubdomainIconDeleteRaw(requestParameters: WebsiteSubdomainIconDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {
if (requestParameters.subdomain === null || requestParameters.subdomain === undefined) {
throw new runtime.RequiredError('subdomain','Required parameter requestParameters.subdomain was null or undefined when calling websiteSubdomainIconDelete.');
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.apiKey) {
headerParameters["X-API-Key"] = this.configuration.apiKey("X-API-Key"); // ApiKeyAuthorization authentication
}
const response = await this.request({
path: `/website/{subdomain}/icon`.replace(`{${"subdomain"}}`, encodeURIComponent(String(requestParameters.subdomain))),
method: 'DELETE',
headers: headerParameters,
query: queryParameters,
}, initOverrides);
return new runtime.VoidApiResponse(response);
}
/**
* Delete a website icon
*/
async websiteSubdomainIconDelete(requestParameters: WebsiteSubdomainIconDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {
await this.websiteSubdomainIconDeleteRaw(requestParameters, initOverrides);
}
/**
* Supported image formats: png, jpeg, gif, bmp, ico, tiff, webp, pnm, dds, tga, openexr, farbfeld Maximum upload size: 5MB, 4000 pixels Icons are resized to 32x32 pixels.
* Upload a website icon
*/
async websiteSubdomainIconPutRaw(requestParameters: WebsiteSubdomainIconPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {
if (requestParameters.subdomain === null || requestParameters.subdomain === undefined) {
throw new runtime.RequiredError('subdomain','Required parameter requestParameters.subdomain was null or undefined when calling websiteSubdomainIconPut.');
}
if (requestParameters.body === null || requestParameters.body === undefined) {
throw new runtime.RequiredError('body','Required parameter requestParameters.body was null or undefined when calling websiteSubdomainIconPut.');
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters['Content-Type'] = 'application/octet-stream';
if (this.configuration && this.configuration.apiKey) {
headerParameters["X-API-Key"] = this.configuration.apiKey("X-API-Key"); // ApiKeyAuthorization authentication
}
const response = await this.request({
path: `/website/{subdomain}/icon`.replace(`{${"subdomain"}}`, encodeURIComponent(String(requestParameters.subdomain))),
method: 'PUT',
headers: headerParameters,
query: queryParameters,
body: requestParameters.body as any,
}, initOverrides);
return new runtime.VoidApiResponse(response);
}
/**
* Supported image formats: png, jpeg, gif, bmp, ico, tiff, webp, pnm, dds, tga, openexr, farbfeld Maximum upload size: 5MB, 4000 pixels Icons are resized to 32x32 pixels.
* Upload a website icon
*/
async websiteSubdomainIconPut(requestParameters: WebsiteSubdomainIconPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {
await this.websiteSubdomainIconPutRaw(requestParameters, initOverrides);
}
/** /**
* Update website * Update website
*/ */

View file

@ -45,11 +45,11 @@ export interface Info {
*/ */
version: InfoVersion; version: InfoVersion;
/** /**
* Current uptime of the server in seconds * Start time of the server
* @type {number} * @type {Date}
* @memberof Info * @memberof Info
*/ */
uptime: number; startTime: Date;
} }
/** /**
@ -59,7 +59,7 @@ export function instanceOfInfo(value: object): boolean {
let isInstance = true; let isInstance = true;
isInstance = isInstance && "stats" in value; isInstance = isInstance && "stats" in value;
isInstance = isInstance && "version" in value; isInstance = isInstance && "version" in value;
isInstance = isInstance && "uptime" in value; isInstance = isInstance && "startTime" in value;
return isInstance; return isInstance;
} }
@ -76,7 +76,7 @@ export function InfoFromJSONTyped(json: any, ignoreDiscriminator: boolean): Info
'stats': InfoStatsFromJSON(json['stats']), 'stats': InfoStatsFromJSON(json['stats']),
'version': InfoVersionFromJSON(json['version']), 'version': InfoVersionFromJSON(json['version']),
'uptime': json['uptime'], 'startTime': (new Date(json['start_time'])),
}; };
} }
@ -91,6 +91,6 @@ export function InfoToJSON(value?: Info | null): any {
'stats': InfoStatsToJSON(value.stats), 'stats': InfoStatsToJSON(value.stats),
'version': InfoVersionToJSON(value.version), 'version': InfoVersionToJSON(value.version),
'uptime': value.uptime, 'start_time': (value.startTime.toISOString()),
}; };
} }

View file

@ -82,6 +82,12 @@ export interface Website {
* @memberof Website * @memberof Website
*/ */
sourceIcon?: SourceIcon; sourceIcon?: SourceIcon;
/**
* Does the website have an icon?
* @type {boolean}
* @memberof Website
*/
hasIcon: boolean;
} }
/** /**
@ -93,6 +99,7 @@ export function instanceOfWebsite(value: object): boolean {
isInstance = isInstance && "name" in value; isInstance = isInstance && "name" in value;
isInstance = isInstance && "createdAt" in value; isInstance = isInstance && "createdAt" in value;
isInstance = isInstance && "visibility" in value; isInstance = isInstance && "visibility" in value;
isInstance = isInstance && "hasIcon" in value;
return isInstance; return isInstance;
} }
@ -115,6 +122,7 @@ export function WebsiteFromJSONTyped(json: any, ignoreDiscriminator: boolean): W
'visibility': VisibilityFromJSON(json['visibility']), 'visibility': VisibilityFromJSON(json['visibility']),
'sourceUrl': !exists(json, 'source_url') ? undefined : json['source_url'], 'sourceUrl': !exists(json, 'source_url') ? undefined : json['source_url'],
'sourceIcon': !exists(json, 'source_icon') ? undefined : SourceIconFromJSON(json['source_icon']), 'sourceIcon': !exists(json, 'source_icon') ? undefined : SourceIconFromJSON(json['source_icon']),
'hasIcon': json['has_icon'],
}; };
} }
@ -135,5 +143,6 @@ export function WebsiteToJSON(value?: Website | null): any {
'visibility': VisibilityToJSON(value.visibility), 'visibility': VisibilityToJSON(value.visibility),
'source_url': value.sourceUrl, 'source_url': value.sourceUrl,
'source_icon': SourceIconToJSON(value.sourceIcon), 'source_icon': SourceIconToJSON(value.sourceIcon),
'has_icon': value.hasIcon,
}; };
} }