mirror of
https://github.com/Xevion/time-banner.git
synced 2025-12-06 15:16:47 -06:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5a8cf91f4 | |||
| 5af4e3b9b7 | |||
| bab7f916d3 | |||
| ffedc60ed1 | |||
| 97d4ad57b4 | |||
| b427c9d094 | |||
| 696f18af3f | |||
| 1a7e9e3414 | |||
| 96dcbcc318 | |||
| 1b3f6c8864 | |||
| 5afffcaf07 | |||
| 4694fd6632 | |||
| 279dc043d4 | |||
| 1e36db1ff7 | |||
| 52fb1b2854 | |||
| 7a6b304213 | |||
| 94e5fccc40 | |||
| 3bf37b936f | |||
| a6fe3995c5 | |||
| b1ea1b3957 | |||
| 00aabfc692 | |||
| 850a399fe0 | |||
| 4d7f58af43 | |||
| 01e71d2ff5 | |||
| 4cf4b626de | |||
| 32b55c918c | |||
| babae191a4 | |||
| 430a6ca7ac | |||
| a4d0898b26 | |||
| 23d09a3235 | |||
| 3f57389f6c | |||
| fc5602f4c8 | |||
| 614cb6401d | |||
| 9d248a7c23 | |||
| 56777038a0 | |||
| 0cb32482fa | |||
| 7207a25aef | |||
| 3a6c4172dc | |||
| 4e0e6f1d83 | |||
| d963ce623d | |||
| f58b18e6bb |
121
.github/workflows/ci.yml
vendored
Normal file
121
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, develop]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
clippy:
|
||||||
|
name: Clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo index
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-index-
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-build-target-
|
||||||
|
|
||||||
|
- name: Run clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo index
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-index-
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-build-target-
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --verbose
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build (Linux Debug)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/registry
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache cargo index
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-index-
|
||||||
|
|
||||||
|
- name: Cache cargo build
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-build-target-
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: cargo build --verbose
|
||||||
22
.github/workflows/format.yml
vendored
Normal file
22
.github/workflows/format.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Format Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
name: Format Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
1217
Cargo.lock
generated
1217
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@@ -1,34 +1,35 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "time-banner"
|
name = "time-banner"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
resvg = "0.34.1"
|
resvg = "0.45.1"
|
||||||
axum = "0.6.18"
|
axum = "0.8.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0.68"
|
serde_json = "1.0.140"
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.46", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
futures = "0.3.28"
|
futures = "0.3.31"
|
||||||
png = "0.17.9"
|
png = "0.17.16"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
envy = "0.4.2"
|
envy = "0.4.2"
|
||||||
tera = "1.19.0"
|
tera = "1.20.0"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.5.0"
|
||||||
timeago = "0.4.1"
|
timeago = "0.4.2"
|
||||||
chrono-tz = "0.8.3"
|
chrono-tz = "0.10.3"
|
||||||
phf = { version = "0.11.2", features = ["macros"] }
|
phf = { version = "0.12.1", features = ["macros"] }
|
||||||
phf_codegen = "0.11.1"
|
phf_codegen = "0.12.1"
|
||||||
chrono = "0.4.26"
|
chrono = "0.4.41"
|
||||||
regex = "1.8.4"
|
regex = "1.11.1"
|
||||||
|
ico = "0.4.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
chrono = "0.4.26"
|
chrono = "0.4.41"
|
||||||
regex = "1.8.4"
|
regex = "1.11.1"
|
||||||
phf = { version = "0.11.1", default-features = false }
|
phf = { version = "0.12.1", default-features = false }
|
||||||
phf_codegen = "0.11.1"
|
phf_codegen = "0.12.1"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.5.0"
|
||||||
|
|||||||
63
Dockerfile
63
Dockerfile
@@ -1,45 +1,70 @@
|
|||||||
# Build Stage
|
# Build Stage
|
||||||
FROM rust:1.68.0 as builder
|
FROM rust:1.81.0-alpine as builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
musl-dev \
|
||||||
|
pkgconfig \
|
||||||
|
openssl-dev
|
||||||
|
|
||||||
|
WORKDIR /usr/src
|
||||||
RUN USER=root cargo new --bin time-banner
|
RUN USER=root cargo new --bin time-banner
|
||||||
WORKDIR ./time-banner
|
WORKDIR /usr/src/time-banner
|
||||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
|
|
||||||
COPY ./Cargo.toml ./Cargo.toml
|
# Copy dependency files for better layer caching
|
||||||
|
COPY ./Cargo.toml ./Cargo.lock* ./build.rs ./
|
||||||
|
# Copy the timezone data file needed by build.rs
|
||||||
|
COPY ./src/abbr_tz ./src/abbr_tz
|
||||||
|
|
||||||
# Build empty app with downloaded dependencies to produce a stable image layer for next build
|
# Build empty app with downloaded dependencies to produce a stable image layer for next build
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
# Build web app with own code
|
# Build web app with own code
|
||||||
RUN rm src/*.rs
|
RUN rm src/*.rs
|
||||||
ADD . ./
|
COPY ./src ./src
|
||||||
RUN rm ./target/release/deps/time_banner*
|
RUN rm ./target/release/deps/time_banner*
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Strip the binary to reduce size
|
||||||
|
RUN strip target/release/time-banner
|
||||||
|
|
||||||
FROM debian:bullseye-slim
|
# Runtime Stage - Alpine for smaller size and musl compatibility
|
||||||
|
FROM alpine:3.19
|
||||||
ARG APP=/usr/src/app
|
ARG APP=/usr/src/app
|
||||||
|
ARG APP_USER=appuser
|
||||||
|
ARG UID=1000
|
||||||
|
ARG GID=1000
|
||||||
|
|
||||||
RUN apt-get update \
|
# Install runtime dependencies
|
||||||
&& apt-get install -y ca-certificates tzdata \
|
RUN apk add --no-cache \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
ca-certificates \
|
||||||
|
tzdata
|
||||||
|
|
||||||
ENV TZ=Etc/UTC \
|
ENV TZ=Etc/UTC
|
||||||
APP_USER=appuser
|
|
||||||
|
|
||||||
RUN groupadd $APP_USER \
|
# Create user with specific UID/GID
|
||||||
&& useradd -g $APP_USER $APP_USER \
|
RUN addgroup -g $GID -S $APP_USER \
|
||||||
|
&& adduser -u $UID -D -S -G $APP_USER $APP_USER \
|
||||||
&& mkdir -p ${APP}
|
&& mkdir -p ${APP}
|
||||||
|
|
||||||
COPY --from=builder /time-banner/target/release/time-banner ${APP}/time-banner
|
# Copy application files
|
||||||
COPY --from=builder /time-banner/src/fonts ${APP}/fonts
|
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/target/release/time-banner ${APP}/time-banner
|
||||||
COPY --from=builder /time-banner/src/templates ${APP}/templates
|
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/src/fonts ${APP}/fonts
|
||||||
|
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/src/templates ${APP}/templates
|
||||||
|
|
||||||
RUN chown -R $APP_USER:$APP_USER ${APP}
|
# Set proper permissions
|
||||||
|
RUN chmod +x ${APP}/time-banner
|
||||||
|
|
||||||
USER $APP_USER
|
USER $APP_USER
|
||||||
WORKDIR ${APP}
|
WORKDIR ${APP}
|
||||||
|
|
||||||
EXPOSE 3000
|
# Use ARG for build-time configuration, ENV for runtime
|
||||||
ENV PORT 3000
|
ARG PORT=3000
|
||||||
|
ENV PORT=${PORT}
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Add health check (using wget since curl isn't in Alpine by default)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost:${PORT}/health || exit 1
|
||||||
|
|
||||||
CMD ["./time-banner"]
|
CMD ["./time-banner"]
|
||||||
137
README.md
137
README.md
@@ -1,68 +1,85 @@
|
|||||||
# time-banner
|
# time-banner
|
||||||
|
|
||||||
My first Rust project, intended to offer a simple way to display the current time relative, in an image format.
|
Dynamically generated timestamp images
|
||||||
|
|
||||||
## Planned Features
|
|
||||||
|
|
||||||
- Dynamic light/dark mode
|
|
||||||
- Via Query Parameters for Raster or SVG
|
|
||||||
- Via CSS for SVG
|
|
||||||
- Relative or Absolute Format
|
|
||||||
- Dynamic Formats
|
|
||||||
- Caching Abilities
|
|
||||||
- Relative caching for up to 59 seconds, purged on the minute
|
|
||||||
- Absolute caching for up to 50MB, purged on an LRU basis
|
|
||||||
- Flexible & Dynamic Browser API
|
|
||||||
- Allow users to play with format in numerous ways to query API
|
|
||||||
- Examples
|
|
||||||
- `/svg/2023-06-14-3PM-CST`
|
|
||||||
- `2023-06-14-3PM-CST.svg`
|
|
||||||
- `/jpeg/2023.06.14.33` (14th of June, 2023, 2:33 PM UTC)
|
|
||||||
- `/jpeg/2023.06.14.33T-5` (14th of June, 2023, 2:33 PM UTC-5)
|
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
```shell
|
`GET /[relative|absolute]/[value]{.ext}?tz={timezone}&format={format_string}&now={timestamp}&static={boolean}`
|
||||||
/{time}[.{ext}]
|
|
||||||
/{rel|relative}/{time}[.{ext}]
|
- [x] `{relative|absolute}` - The display format of the time.
|
||||||
/{abs|absolute}/{time}[.{ext}]
|
- [x] `{value}` - The time value to work with. Relative values can be specified by prefixing with `+` or `-`.
|
||||||
|
- [ ] Relative values are relative to the value acquired from the `now` parameter (which defaults to the current time).
|
||||||
|
- Whether the value is relative or absolute has nothing to do with the display format.
|
||||||
|
- [x] `/absolute/+0` will display the current time in UTC.
|
||||||
|
- [ ] Note: The `now` parameter is returned in the `Date` response header.
|
||||||
|
- [x] `Accept` or `{.ext}` - Determines the output format. If not specified, `.svg` is assumed.
|
||||||
|
- [ ] `Accept` requires a valid MIME type.
|
||||||
|
- [x] `.ext` requires a valid extension.
|
||||||
|
- [ ] Supported values: `.png`/`image/png`,
|
||||||
|
- [ ] `X-Timezone` or `?tz={timezone}` - The timezone to display the time in. If not specified, UTC is assumed.
|
||||||
|
- [ ] `auto` will attempt to determine the timezone from the client's IP address. Depending on currently unknown factors, this may be disregarded.
|
||||||
|
- [ ] Return detected timezone in `X-Timezone` response header.
|
||||||
|
- [ ] `?format={format}` - The format of the time to display. If not specified, `%Y-%m-%d %H:%M:%S %Z` is assumed.
|
||||||
|
- [ ] Only relevant for `absolute` values.
|
||||||
|
- [ ] `X-Date-Now` or `?now={timestamp}` - The timestamp to use for relative time calculations. If not specified, the current time is used.
|
||||||
|
- [ ] `?static={boolean}` - Whether to redirect to a static version of the URL. Useful for creating specific URLs manually.
|
||||||
|
- [ ] If a value is not passed, (`?static`), `true` is assumed. Anything other than `true`, `1` or `yes` (case-insensitive) is considered `false`.
|
||||||
|
- [ ] Some header values will be translated to query parameters if provided (not `Accept`).
|
||||||
|
- [ ] e.g. `/rel/+3600.png?static&now=1752170474` will redirect to `/relative/1752174074.png`
|
||||||
|
- [x] `/favicon.ico` - Returns a dynamic favicon of an analog clock.
|
||||||
|
- [ ] Uses naive timestamp from `X-Date-Now` if provided.
|
||||||
|
- [ ] Uses timezone from `X-Timezone` if provided.
|
||||||
|
- [ ] Uses IP geolocation to determine timezone if neither is provided.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
/1752170474 => 2025-07-10 12:01:14 UTC
|
||||||
|
/abs/1752170474 => 2025-07-10 12:01:14 UTC
|
||||||
|
/absolute/+3600 => 2025-07-10 13:01:14 UTC
|
||||||
|
/abs/-1800 => 2025-07-10 11:01:14 UTC
|
||||||
|
/rel/1752170474 => 15 minutes ago
|
||||||
|
/rel/+3600 => 1 hour from now
|
||||||
|
/relative/-1800 => 30 minutes ago
|
||||||
|
/relative/1752170474.png?tz=America/Chicago => 2025-07-10 06:01:14 CDT
|
||||||
|
/relative/1752170474?type=relative => 2081-01-17 12:02:28 PM
|
||||||
```
|
```
|
||||||
|
|
||||||
- If relative or absolute is not specified, it will be the opposite of the time string's format.
|
## Ideas
|
||||||
|
|
||||||
### Query Parameters
|
- Frontend with React for Demo
|
||||||
|
- Refetch favicon every 10 minutes
|
||||||
- `format` - Specify the format of the time string
|
- Click to copy image URLs
|
||||||
- `tz` - Specify the timezone of the time string. May be ignored if the time string contains a timezone/offset.
|
- Dynamic Examples
|
||||||
|
- Dynamic light/dark mode
|
||||||
## Structure
|
- `?theme={auto|light|dark}`, default `light`
|
||||||
|
- Customizable SVG templates
|
||||||
1. Routing
|
- Dynamic favicon generation
|
||||||
- Handle different input formats at the route layer
|
- Clock svg optimized for favicon size
|
||||||
2. Parsing
|
- Move hands to the current time
|
||||||
- Module for parsing input
|
- Use geolocation of request IP to determine timezone
|
||||||
3. Cache Layer
|
- Advanced: create sun/moon SVG based on local time
|
||||||
- Given all route options, provide a globally available cache for the next layer
|
- Support for different timezone formats in query parameters or headers
|
||||||
4. SVG Template Rendering
|
- `?tz=...` or `X-Timezone: ...`
|
||||||
- Template rendering based on parsed input
|
- `CST` or `America/Chicago` or `UTC-6` or `GMT-6` or `-0600`
|
||||||
5. (Optional) Rasterization
|
- Automatically guessed based on geolocation of source IP address
|
||||||
- If rasterization is requested, render SVG to PNG
|
- Complex caching abilities
|
||||||
6. (Catch-all) Error Handling
|
- Multi-level caching (disk with max size, memory)
|
||||||
- All errors/panics will be caught in separate middleware
|
- Automatic expiry of relative items
|
||||||
|
- Use browser cache headers
|
||||||
## Input Parsing
|
- Detect force refreshs and allow cache busting
|
||||||
|
- `Accept` header support
|
||||||
- Date formatting will be guesswork, but can be specified with `?format=` parameter.
|
- IP-based rate limiting
|
||||||
- To avoid abuse, it will be limited to a subset of the `chrono` formatting options.
|
- Multi-domain rate limiting factors
|
||||||
- The assumed extension when not specified is `.svg` for performance sake.
|
- Computational cost: 1ms = 1 token, max 100 tokens per minute
|
||||||
- `.png` is also available. `.jpeg` and `.webp` are planned.
|
- Base rate: 3 requests per second
|
||||||
- Time is not required, but will default each value to 0 (except HOUR, which is the minimum specified value).
|
- Cached Conversions
|
||||||
- Millisecond precision is allowed, but will be ignored in most outputs. Periods or commas are allowed as separators.
|
- If PNG is cached, then JPEG/WEBP/etc. can be converted from cached PNG
|
||||||
- Timezones can be qualified in a number of ways, but will default to UTC if not specified.
|
- Additional date input formats
|
||||||
- Fully qualified TZ identifiers like "America/Chicago" are specified using the `tz` query parameter.
|
- 2025-07-10-12:01:14
|
||||||
- Abbreviated TZ identifiers like "CST" are specified inside the time string, after the time, separated by a dash.
|
- 2025-07-10-12:01
|
||||||
- Abbreviated terms are incredibly ambiguous, and should be avoided if possible. For ease of use, they are
|
- 2025-07-10-12:01:14-06:00
|
||||||
available, but several of them are ambiguous, and the preferred TZ has been specified in code.
|
- 2025-07-10-12:01:14-06
|
||||||
- Full table available in [`abbr_tz`](./src/abbr_tz). Comments designated with `#`. Preferred interpretation
|
- 2025-07-10-12:01:14-06:00:00
|
||||||
designated arbitrarily by me. Table sourced
|
- 2025-07-10-12:01:14-06:00:00.000
|
||||||
from [Wikipedia](https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations)
|
- 2025-07-10-12:01:14-06:00:00Z-06:00
|
||||||
|
|||||||
229
build.rs
229
build.rs
@@ -1,71 +1,194 @@
|
|||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use regex::Regex;
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
|
|
||||||
lazy_static! {
|
/// Error types for build script failures
|
||||||
static ref FULL_PATTERN: Regex = Regex::new(r"([A-Z]+)\s\t.+\s\tUTC([−+±]\d{2}(?::\d{2})?)").unwrap();
|
#[derive(Debug)]
|
||||||
static ref OFFSET_PATTERN: Regex = Regex::new(r"([−+±])(\d{2}(?::\d{2})?)").unwrap();
|
enum BuildError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Regex(String),
|
||||||
|
Parse(String),
|
||||||
|
Env(env::VarError),
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR: u32 = 3600;
|
impl fmt::Display for BuildError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
BuildError::Io(e) => write!(f, "IO error: {}", e),
|
||||||
|
BuildError::Regex(msg) => write!(f, "Regex error: {}", msg),
|
||||||
|
BuildError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||||
|
BuildError::Env(e) => write!(f, "Environment error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_offset(raw_offset: &str) -> i32 {
|
impl From<std::io::Error> for BuildError {
|
||||||
let capture = OFFSET_PATTERN.captures(raw_offset).expect("RegEx failed to match offset");
|
fn from(error: std::io::Error) -> Self {
|
||||||
println!("{}: {}", raw_offset, capture.get(1).expect("First group capture failed").as_str());
|
BuildError::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let is_west = capture.get(1).unwrap().as_str() == "−";
|
impl From<env::VarError> for BuildError {
|
||||||
let time = capture.get(2).expect("Second group capture failed").as_str();
|
fn from(error: env::VarError) -> Self {
|
||||||
let (hours, minutes) = if time.contains(':') {
|
BuildError::Env(error)
|
||||||
let mut split = time.split(':');
|
}
|
||||||
let hours = split.next().unwrap().parse::<u32>().unwrap();
|
}
|
||||||
let minutes = split.next().unwrap().parse::<u32>().unwrap();
|
|
||||||
|
|
||||||
(hours, minutes)
|
lazy_static! {
|
||||||
} else {
|
/// Regex to match timezone lines: "ABBR \t Description \t UTC±HH:MM"
|
||||||
// Minutes not specified, assume 0
|
static ref TIMEZONE_PATTERN: Regex =
|
||||||
(time.parse::<u32>().unwrap(), 0)
|
Regex::new(r"([A-Z]+)\s\t.+\s\tUTC([−+±]\d{2}(?::\d{2})?)").unwrap();
|
||||||
|
|
||||||
|
/// Regex to parse UTC offset format: "±HH:MM" or "±HH"
|
||||||
|
static ref OFFSET_PATTERN: Regex =
|
||||||
|
Regex::new(r"([−+±])(\d{2})(?::(\d{2}))?").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECONDS_PER_HOUR: i32 = 3600;
|
||||||
|
const SECONDS_PER_MINUTE: i32 = 60;
|
||||||
|
|
||||||
|
/// Parse a UTC offset string (e.g., "+05:30", "-08", "±00") into seconds from UTC
|
||||||
|
fn parse_utc_offset(raw_offset: &str) -> Result<i32, BuildError> {
|
||||||
|
let captures = OFFSET_PATTERN.captures(raw_offset).ok_or_else(|| {
|
||||||
|
BuildError::Regex(format!("Failed to match offset pattern: {}", raw_offset))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Handle ± (variable offset) as UTC
|
||||||
|
let sign = captures.get(1).unwrap().as_str();
|
||||||
|
if sign == "±" {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hours_str = captures.get(2).unwrap().as_str();
|
||||||
|
let minutes_str = captures.get(3).map(|m| m.as_str()).unwrap_or("0");
|
||||||
|
|
||||||
|
let hours: i32 = hours_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| BuildError::Parse(format!("Invalid hours '{}': {}", hours_str, e)))?;
|
||||||
|
|
||||||
|
let minutes: i32 = minutes_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| BuildError::Parse(format!("Invalid minutes '{}': {}", minutes_str, e)))?;
|
||||||
|
|
||||||
|
// Validate ranges
|
||||||
|
if hours > 23 {
|
||||||
|
return Err(BuildError::Parse(format!("Hours out of range: {}", hours)));
|
||||||
|
}
|
||||||
|
if minutes > 59 {
|
||||||
|
return Err(BuildError::Parse(format!(
|
||||||
|
"Minutes out of range: {}",
|
||||||
|
minutes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_seconds = (hours * SECONDS_PER_HOUR) + (minutes * SECONDS_PER_MINUTE);
|
||||||
|
|
||||||
|
// Apply sign (− is west/negative, + is east/positive)
|
||||||
|
Ok(match sign {
|
||||||
|
"−" => -total_seconds,
|
||||||
|
"+" => total_seconds,
|
||||||
|
_ => unreachable!("Regex should only match +, −, or ±"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single timezone line and extract abbreviation and offset
|
||||||
|
fn parse_timezone_line(line: &str) -> Result<Option<(String, i32)>, BuildError> {
|
||||||
|
// Skip comment lines
|
||||||
|
if line.trim().starts_with('#') || line.trim().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let captures = TIMEZONE_PATTERN
|
||||||
|
.captures(line)
|
||||||
|
.ok_or_else(|| BuildError::Regex(format!("Failed to match timezone pattern: {}", line)))?;
|
||||||
|
|
||||||
|
let abbreviation = match captures.get(1) {
|
||||||
|
Some(m) => m.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
return Err(BuildError::Regex(format!(
|
||||||
|
"Failed to extract abbreviation from line: {}",
|
||||||
|
line
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let raw_offset = match captures.get(2) {
|
||||||
|
Some(m) => m.as_str(),
|
||||||
|
None => {
|
||||||
|
return Err(BuildError::Regex(format!(
|
||||||
|
"Failed to extract offset from line: {}",
|
||||||
|
line
|
||||||
|
)))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let value = (hours * HOUR) + (minutes * 60);
|
let offset = parse_utc_offset(raw_offset)?;
|
||||||
return if is_west { value as i32 * -1 } else { value as i32 };
|
|
||||||
|
Ok(Some((abbreviation, offset)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate the PHF map code for timezone abbreviations to UTC offsets
|
||||||
|
fn generate_timezone_map() -> Result<(), BuildError> {
|
||||||
|
let out_dir = env::var("OUT_DIR")?;
|
||||||
|
let output_path = Path::new(&out_dir).join("timezone_map.rs");
|
||||||
|
|
||||||
|
let tz_path = Path::new("./src/abbr_tz");
|
||||||
|
let tz_file = File::open(tz_path)?;
|
||||||
|
let reader = BufReader::new(tz_file);
|
||||||
|
|
||||||
|
let mut out_file = BufWriter::new(File::create(&output_path)?);
|
||||||
|
let mut builder = phf_codegen::Map::<String>::new();
|
||||||
|
|
||||||
|
let mut processed_count = 0;
|
||||||
|
let mut skipped_count = 0;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = line?;
|
||||||
|
|
||||||
|
match parse_timezone_line(&line)? {
|
||||||
|
Some((abbreviation, offset)) => {
|
||||||
|
builder.entry(abbreviation.clone(), offset.to_string());
|
||||||
|
processed_count += 1;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
skipped_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the PHF map
|
||||||
|
writeln!(
|
||||||
|
&mut out_file,
|
||||||
|
"/// Auto-generated timezone abbreviation to UTC offset (in seconds) mapping"
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
&mut out_file,
|
||||||
|
"/// Generated from {} timezone definitions ({} processed, {} skipped)",
|
||||||
|
processed_count + skipped_count,
|
||||||
|
processed_count,
|
||||||
|
skipped_count
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
&mut out_file,
|
||||||
|
"pub static TIMEZONE_OFFSETS: phf::Map<&'static str, i32> = {};",
|
||||||
|
builder.build()
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"cargo:warning=Generated timezone map with {} entries",
|
||||||
|
processed_count
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("codegen.rs");
|
if let Err(e) = generate_timezone_map() {
|
||||||
let raw_tz = BufReader::new(File::open("./src/abbr_tz").unwrap());
|
panic!("Build script failed: {}", e);
|
||||||
|
|
||||||
let mut file = BufWriter::new(File::create(&path).unwrap());
|
|
||||||
|
|
||||||
let mut builder: phf_codegen::Map<String> = phf_codegen::Map::new();
|
|
||||||
|
|
||||||
for line in raw_tz.lines() {
|
|
||||||
let line = line.unwrap();
|
|
||||||
if line.starts_with('#') {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let capture = FULL_PATTERN.captures(&line).expect("RegEx failed to match line");
|
// Tell Cargo to re-run this build script if the timezone file changes
|
||||||
|
println!("cargo:rerun-if-changed=src/abbr_tz");
|
||||||
let abbreviation = capture.get(1).unwrap().as_str();
|
|
||||||
let raw_offset = capture.get(2).unwrap().as_str();
|
|
||||||
|
|
||||||
let offset = if !raw_offset.starts_with('±') {
|
|
||||||
parse_offset(raw_offset)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
builder.entry(String::from(abbreviation), &format!("\"{}\"", offset).to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
write!(
|
|
||||||
&mut file,
|
|
||||||
"static TIMEZONES: phf::Map<&'static str, &'static str> = {}",
|
|
||||||
builder.build()
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
write!(&mut file, ";\n").unwrap();
|
|
||||||
}
|
}
|
||||||
115
justfile
Normal file
115
justfile
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Variables
|
||||||
|
image_name := "time-banner"
|
||||||
|
container_name := "time-banner-dev"
|
||||||
|
port := "3000"
|
||||||
|
|
||||||
|
# Default recipe
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# Development server with hot reload
|
||||||
|
dev:
|
||||||
|
@echo "🚀 Starting development server..."
|
||||||
|
cargo watch -x "run --bin time-banner"
|
||||||
|
|
||||||
|
# Simple development server (no hot reload)
|
||||||
|
run:
|
||||||
|
@echo "🚀 Starting server..."
|
||||||
|
cargo run --bin time-banner
|
||||||
|
|
||||||
|
# Comprehensive check pipeline
|
||||||
|
check: format lint build test docker-build
|
||||||
|
@echo "✅ All checks passed!"
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
format:
|
||||||
|
@echo "🎨 Formatting code..."
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
# Check formatting
|
||||||
|
format-check:
|
||||||
|
@echo "🔍 Checking formatting..."
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
|
||||||
|
# Lint with clippy
|
||||||
|
lint:
|
||||||
|
@echo "🔍 Running clippy..."
|
||||||
|
cargo clippy --all-targets --all-features --
|
||||||
|
|
||||||
|
# Build project
|
||||||
|
build:
|
||||||
|
@echo "🔨 Building project..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
@echo "🧪 Running tests..."
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker-build:
|
||||||
|
@echo "🐳 Building Docker image..."
|
||||||
|
docker build -t {{image_name}}:latest .
|
||||||
|
|
||||||
|
# Run Docker container
|
||||||
|
docker-run: docker-build
|
||||||
|
@echo "🚀 Running Docker container..."
|
||||||
|
docker run --rm -d --name {{container_name}} -p {{port}}:{{port}} {{image_name}}:latest
|
||||||
|
@echo "Container started at http://localhost:{{port}}"
|
||||||
|
|
||||||
|
# Stop Docker container
|
||||||
|
docker-stop:
|
||||||
|
@echo "🛑 Stopping Docker container..."
|
||||||
|
docker stop {{container_name}} || true
|
||||||
|
|
||||||
|
# Docker logs
|
||||||
|
docker-logs:
|
||||||
|
@echo "📋 Showing Docker logs..."
|
||||||
|
docker logs {{container_name}}
|
||||||
|
|
||||||
|
# Follow Docker logs
|
||||||
|
docker-logs-follow:
|
||||||
|
@echo "📋 Following Docker logs..."
|
||||||
|
docker logs -f {{container_name}}
|
||||||
|
|
||||||
|
# Clean Docker artifacts
|
||||||
|
docker-clean: docker-stop
|
||||||
|
@echo "🧹 Cleaning Docker artifacts..."
|
||||||
|
docker rmi {{image_name}}:latest || true
|
||||||
|
|
||||||
|
# Full Docker development cycle
|
||||||
|
docker-dev: docker-clean docker-run
|
||||||
|
@echo "🐳 Docker development environment ready!"
|
||||||
|
|
||||||
|
# Clean cargo artifacts
|
||||||
|
clean:
|
||||||
|
@echo "🧹 Cleaning cargo artifacts..."
|
||||||
|
cargo clean
|
||||||
|
|
||||||
|
# Install development dependencies
|
||||||
|
install-deps:
|
||||||
|
@echo "📦 Installing development dependencies..."
|
||||||
|
cargo install cargo-watch
|
||||||
|
|
||||||
|
# Security audit
|
||||||
|
audit:
|
||||||
|
@echo "🔒 Running security audit..."
|
||||||
|
cargo audit
|
||||||
|
|
||||||
|
# Check dependencies for updates
|
||||||
|
outdated:
|
||||||
|
@echo "📅 Checking for outdated dependencies..."
|
||||||
|
cargo outdated
|
||||||
|
|
||||||
|
# Release build with optimizations
|
||||||
|
release:
|
||||||
|
@echo "🚀 Building release version..."
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Full CI pipeline (like what would run in CI)
|
||||||
|
ci: format-check lint build test docker-build
|
||||||
|
@echo "🎯 CI pipeline completed!"
|
||||||
|
|
||||||
|
# Quick development check (faster than full check)
|
||||||
|
quick: format lint
|
||||||
|
@echo "⚡ Quick check completed!"
|
||||||
31
src/abbr.rs
31
src/abbr.rs
@@ -1,31 +0,0 @@
|
|||||||
use chrono::FixedOffset;
|
|
||||||
|
|
||||||
// Generated by build.rs, phf_codegen
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
|
|
||||||
|
|
||||||
/*
|
|
||||||
Parse an abbreviation of a timezone into a UTC offset.
|
|
||||||
Note: This is not standardized at all and is simply built on a reference of Time Zone abbreviations
|
|
||||||
from Wikipedia (as of 2023-7-20).
|
|
||||||
*/
|
|
||||||
pub fn parse_abbreviation(abbreviation: &str) -> Result<FixedOffset, String> {
|
|
||||||
let offset_integer_string = TIMEZONES.get(abbreviation);
|
|
||||||
if offset_integer_string.is_none() {
|
|
||||||
return Err("Failed to find abbreviation".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = FixedOffset::east_opt(offset_integer_string.unwrap().parse().expect("Failed to parse stored offset"));
|
|
||||||
return offset.ok_or("Failed to parse offset".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use chrono::FixedOffset;
|
|
||||||
use crate::abbr::parse_abbreviation;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_offset() {
|
|
||||||
assert_eq!(parse_abbreviation("CST").unwrap(), FixedOffset::west_opt(6 * 3600).unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
92
src/abbr_tz.rs
Normal file
92
src/abbr_tz.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use chrono::FixedOffset;
|
||||||
|
|
||||||
|
// Generated by build.rs - timezone abbreviation to UTC offset mapping
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/timezone_map.rs"));
|
||||||
|
|
||||||
|
/// Parse a timezone abbreviation into a UTC offset.
|
||||||
|
///
|
||||||
|
/// This uses a pre-generated map of timezone abbreviations to their UTC offsets
|
||||||
|
/// in seconds. The mapping is based on the Wikipedia reference of timezone
|
||||||
|
/// abbreviations (as of 2023-07-20).
|
||||||
|
///
|
||||||
|
/// Note: Timezone abbreviations are not standardized and can be ambiguous.
|
||||||
|
/// This implementation uses preferred interpretations for conflicting abbreviations.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `abbreviation` - The timezone abbreviation (e.g., "CST", "EST", "PST")
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Ok(FixedOffset)` - The UTC offset for the timezone
|
||||||
|
/// * `Err(String)` - Error message if abbreviation is not found or invalid
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
/// ```
|
||||||
|
/// use chrono::FixedOffset;
|
||||||
|
///
|
||||||
|
/// let cst = parse_abbreviation("CST").unwrap();
|
||||||
|
/// assert_eq!(cst, FixedOffset::west_opt(6 * 3600).unwrap());
|
||||||
|
/// ```
|
||||||
|
pub fn parse_abbreviation(abbreviation: &str) -> Result<FixedOffset, String> {
|
||||||
|
let offset_seconds = TIMEZONE_OFFSETS
|
||||||
|
.get(abbreviation)
|
||||||
|
.ok_or_else(|| format!("Unknown timezone abbreviation: {}", abbreviation))?;
|
||||||
|
|
||||||
|
// Convert seconds to FixedOffset
|
||||||
|
// Positive offsets are east of UTC, negative are west
|
||||||
|
let offset = if *offset_seconds >= 0 {
|
||||||
|
FixedOffset::east_opt(*offset_seconds)
|
||||||
|
} else {
|
||||||
|
FixedOffset::west_opt(-*offset_seconds)
|
||||||
|
};
|
||||||
|
|
||||||
|
offset.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Invalid offset for timezone {}: {} seconds",
|
||||||
|
abbreviation, offset_seconds
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::abbr_tz::parse_abbreviation;
|
||||||
|
use chrono::FixedOffset;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_cst() {
|
||||||
|
// CST (Central Standard Time) is UTC-6
|
||||||
|
let cst = parse_abbreviation("CST").unwrap();
|
||||||
|
assert_eq!(cst, FixedOffset::west_opt(6 * 3600).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_est() {
|
||||||
|
// EST (Eastern Standard Time) is UTC-5
|
||||||
|
let est = parse_abbreviation("EST").unwrap();
|
||||||
|
assert_eq!(est, FixedOffset::west_opt(5 * 3600).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_utc() {
|
||||||
|
// UTC should be zero offset
|
||||||
|
let utc = parse_abbreviation("UTC").unwrap();
|
||||||
|
assert_eq!(utc, FixedOffset::east_opt(0).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_unknown() {
|
||||||
|
// Unknown abbreviation should return error
|
||||||
|
let result = parse_abbreviation("INVALID");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("Unknown timezone abbreviation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_positive_offset() {
|
||||||
|
// JST (Japan Standard Time) is UTC+9
|
||||||
|
let jst = parse_abbreviation("JST").unwrap();
|
||||||
|
assert_eq!(jst, FixedOffset::east_opt(9 * 3600).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
|
|
||||||
|
/// Application environment configuration.
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Environment {
|
pub enum Environment {
|
||||||
@@ -8,6 +9,11 @@ pub enum Environment {
|
|||||||
Development,
|
Development,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Main configuration struct parsed from environment variables.
|
||||||
|
///
|
||||||
|
/// Environment variables:
|
||||||
|
/// - `ENV`: "production" or "development" (default: development)
|
||||||
|
/// - `PORT`: TCP port number (default: 3000)
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct Configuration {
|
pub struct Configuration {
|
||||||
#[serde(default = "default_env")]
|
#[serde(default = "default_env")]
|
||||||
@@ -26,6 +32,10 @@ fn default_env() -> Environment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Configuration {
|
impl Configuration {
|
||||||
|
/// Returns the socket address to bind to based on environment.
|
||||||
|
///
|
||||||
|
/// - Production: 0.0.0.0 (all interfaces)
|
||||||
|
/// - Development: 127.0.0.1 (localhost only)
|
||||||
pub fn socket_addr(&self) -> [u8; 4] {
|
pub fn socket_addr(&self) -> [u8; 4] {
|
||||||
match self.env {
|
match self.env {
|
||||||
Environment::Production => {
|
Environment::Production => {
|
||||||
@@ -49,6 +59,10 @@ impl Configuration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the appropriate log level for the environment.
|
||||||
|
///
|
||||||
|
/// - Production: INFO
|
||||||
|
/// - Development: DEBUG
|
||||||
pub fn log_level(&self) -> Level {
|
pub fn log_level(&self) -> Level {
|
||||||
match self.env {
|
match self.env {
|
||||||
Environment::Production => Level::INFO,
|
Environment::Production => Level::INFO,
|
||||||
|
|||||||
326
src/duration.rs
Normal file
326
src/duration.rs
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
//! Human-readable duration parsing with support for mixed time units.
|
||||||
|
//!
|
||||||
|
//! Parses strings like "1y2mon3w4d5h6m7s", "+1year", or "-3h30m" into chrono Duration objects.
|
||||||
|
//! Time units can appear in any order and use various abbreviations.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::error::TimeBannerError;
|
||||||
|
|
||||||
|
/// Extends chrono::Duration with month support using approximate calendar math.
|
||||||
|
pub trait Months {
|
||||||
|
fn months(count: i32) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Months for Duration {
|
||||||
|
/// Creates a duration representing the given number of months.
|
||||||
|
/// Uses 365.25/12 ≈ 30.44 days per month for approximation.
|
||||||
|
fn months(count: i32) -> Self {
|
||||||
|
Duration::milliseconds(
|
||||||
|
(Duration::days(1).num_milliseconds() as f64 * (365.25f64 / 12f64)) as i64,
|
||||||
|
) * count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
/// Regex pattern matching duration strings with flexible ordering and abbreviations.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - Optional +/- sign
|
||||||
|
/// - Years: y, yr, yrs, year, years
|
||||||
|
/// - Months: mon, month, months
|
||||||
|
/// - Weeks: w, wk, wks, week, weeks
|
||||||
|
/// - Days: d, day, days
|
||||||
|
/// - Hours: h, hr, hrs, hour, hours
|
||||||
|
/// - Minutes: m, min, mins, minute, minutes
|
||||||
|
/// - Seconds: s, sec, secs, second, seconds
|
||||||
|
///
|
||||||
|
/// Time units must appear in descending order of magnitude, e.g. "1y2d" is valid, "1d2y" is not.
|
||||||
|
static ref FULL_RELATIVE_PATTERN: Regex = Regex::new(concat!(
|
||||||
|
"(?<sign>[-+])?",
|
||||||
|
r"(?:(?<year>\d+)\s?(?:years?|yrs?|y)\s*)?",
|
||||||
|
r"(?:(?<month>\d+)\s?(?:months?|mon)\s*)?",
|
||||||
|
r"(?:(?<week>\d+)\s?(?:weeks?|wks?|w)\s*)?",
|
||||||
|
r"(?:(?<day>\d+)\s?(?:days?|d)\s*)?",
|
||||||
|
r"(?:(?<hour>\d+)\s?(?:hours?|hrs?|h)\s*)?",
|
||||||
|
r"(?:(?<minute>\d+)\s?(?:minutes?|mins?|m)\s*)?",
|
||||||
|
r"(?:(?<second>\d+)\s?(?:seconds?|secs?|s)\s*)?"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a human-readable duration string into a chrono Duration.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - `"1y2d"` → 1 year + 2 days
|
||||||
|
/// - `"+3h30m"` → +3.5 hours
|
||||||
|
/// - `"-1week"` → -7 days
|
||||||
|
/// - `"2months4days"` → ~2.03 months
|
||||||
|
///
|
||||||
|
/// Years include leap year compensation (+6 hours per year).
|
||||||
|
/// Empty strings return zero duration.
|
||||||
|
pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
||||||
|
let capture = FULL_RELATIVE_PATTERN.captures(str).unwrap();
|
||||||
|
let mut value = Duration::zero();
|
||||||
|
|
||||||
|
if let Some(raw_year) = capture.name("year") {
|
||||||
|
value += match raw_year.as_str().parse::<i64>() {
|
||||||
|
Ok(year) => {
|
||||||
|
Duration::days(year * 365)
|
||||||
|
+ (if year > 0 {
|
||||||
|
Duration::hours(6) * year as i32 // Leap year compensation
|
||||||
|
} else {
|
||||||
|
Duration::zero()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse year from {} ({})",
|
||||||
|
raw_year.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_month) = capture.name("month") {
|
||||||
|
value += match raw_month.as_str().parse::<i32>() {
|
||||||
|
Ok(month) => Duration::months(month),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse month from {} ({})",
|
||||||
|
raw_month.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_week) = capture.name("week") {
|
||||||
|
value += match raw_week.as_str().parse::<i64>() {
|
||||||
|
Ok(week) => Duration::days(7) * week as i32,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse week from {} ({})",
|
||||||
|
raw_week.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_day) = capture.name("day") {
|
||||||
|
value += match raw_day.as_str().parse::<i64>() {
|
||||||
|
Ok(day) => Duration::days(day),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse day from {} ({})",
|
||||||
|
raw_day.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_hour) = capture.name("hour") {
|
||||||
|
value += match raw_hour.as_str().parse::<i64>() {
|
||||||
|
Ok(hour) => Duration::hours(hour),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse hour from {} ({})",
|
||||||
|
raw_hour.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_minute) = capture.name("minute") {
|
||||||
|
value += match raw_minute.as_str().parse::<i64>() {
|
||||||
|
Ok(minute) => Duration::minutes(minute),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse minute from {} ({})",
|
||||||
|
raw_minute.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_second) = capture.name("second") {
|
||||||
|
value += match raw_second.as_str().parse::<i64>() {
|
||||||
|
Ok(second) => Duration::seconds(second),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Could not parse second from {} ({})",
|
||||||
|
raw_second.as_str(),
|
||||||
|
e
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_sign) = capture.name("sign") {
|
||||||
|
match raw_sign.as_str() {
|
||||||
|
"-" => value = -value,
|
||||||
|
"+" => (),
|
||||||
|
_ => return Err(format!("Could not parse sign from {}", raw_sign.as_str())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts Unix epoch timestamp to UTC DateTime.
|
||||||
|
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
|
||||||
|
DateTime::from_timestamp(epoch, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses various time value formats into a UTC datetime.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - Relative offsets: "+3600", "-1800" (seconds from now)
|
||||||
|
/// - Duration strings: "+1y2d", "-3h30m" (using duration parser)
|
||||||
|
/// - Epoch timestamps: "1752170474" (Unix timestamp)
|
||||||
|
pub fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
|
||||||
|
// Handle relative time values (starting with + or -, or duration strings like "1y2d")
|
||||||
|
if raw_time.starts_with('+') || raw_time.starts_with('-') {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
// Try parsing as simple offset seconds first
|
||||||
|
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
|
||||||
|
return Ok(now + Duration::seconds(offset_seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as duration string (e.g., "+1y2d", "-3h30m")
|
||||||
|
if let Ok(duration) = parse_duration(raw_time) {
|
||||||
|
return Ok(now + duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(TimeBannerError::ParseError(format!(
|
||||||
|
"Could not parse relative time: {}",
|
||||||
|
raw_time
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as epoch timestamp
|
||||||
|
if let Ok(epoch) = raw_time.parse::<i64>() {
|
||||||
|
return parse_epoch_into_datetime(epoch)
|
||||||
|
.ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(TimeBannerError::ParseError(format!(
|
||||||
|
"Could not parse time value: {}",
|
||||||
|
raw_time
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::duration::{parse_duration, Months};
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty() {
|
||||||
|
assert_eq!(parse_duration(""), Ok(Duration::zero()));
|
||||||
|
assert_eq!(parse_duration(" "), Ok(Duration::zero()));
|
||||||
|
assert_eq!(parse_duration(" "), Ok(Duration::zero()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_composite() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_duration("1y2mon3w4d5h6m7s"),
|
||||||
|
Ok(Duration::days(365)
|
||||||
|
+ Duration::hours(6) // leap year compensation
|
||||||
|
+ Duration::months(2)
|
||||||
|
+ Duration::weeks(3)
|
||||||
|
+ Duration::days(4)
|
||||||
|
+ Duration::hours(5)
|
||||||
|
+ Duration::minutes(6)
|
||||||
|
+ Duration::seconds(7)),
|
||||||
|
"1y2mon3w4d5h6m7s"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_duration("19year33weeks4d9min"),
|
||||||
|
Ok(Duration::days(365 * 19)
|
||||||
|
+ Duration::hours(6 * 19)
|
||||||
|
+ Duration::days(33 * 7 + 4)
|
||||||
|
+ Duration::minutes(9)),
|
||||||
|
"19year33weeks4d9min"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_year() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_duration("1y"),
|
||||||
|
Ok(Duration::days(365) + Duration::hours(6))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_duration("2year"),
|
||||||
|
Ok(Duration::days(365 * 2) + Duration::hours(6 * 2))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_duration("144years"),
|
||||||
|
Ok(Duration::days(365 * 144) + Duration::hours(6 * 144))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_month() {
|
||||||
|
assert_eq!(Duration::zero(), parse_duration("0mon").unwrap());
|
||||||
|
assert_eq!(Duration::months(3), parse_duration("3mon").unwrap());
|
||||||
|
assert_eq!(Duration::months(-14), parse_duration("-14mon").unwrap());
|
||||||
|
assert_eq!(Duration::months(144), parse_duration("+144months").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_week() {
|
||||||
|
assert_eq!(Duration::zero(), parse_duration("0w").unwrap());
|
||||||
|
assert_eq!(Duration::weeks(7), parse_duration("7w").unwrap());
|
||||||
|
assert_eq!(Duration::weeks(19), parse_duration("19week").unwrap());
|
||||||
|
assert_eq!(Duration::weeks(433), parse_duration("433weeks").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_day() {
|
||||||
|
assert_eq!(Duration::zero(), parse_duration("0d").unwrap());
|
||||||
|
assert_eq!(Duration::days(9), parse_duration("9d").unwrap());
|
||||||
|
assert_eq!(Duration::days(43), parse_duration("43day").unwrap());
|
||||||
|
assert_eq!(Duration::days(969), parse_duration("969days").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hour() {
|
||||||
|
assert_eq!(Duration::zero(), parse_duration("0h").unwrap());
|
||||||
|
assert_eq!(Duration::hours(4), parse_duration("4h").unwrap());
|
||||||
|
assert_eq!(Duration::hours(150), parse_duration("150hour").unwrap());
|
||||||
|
assert_eq!(Duration::hours(777), parse_duration("777hours").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_minute() {
|
||||||
|
assert_eq!(Duration::zero(), parse_duration("0m").unwrap());
|
||||||
|
assert_eq!(Duration::minutes(5), parse_duration("5m").unwrap());
|
||||||
|
assert_eq!(Duration::minutes(60), parse_duration("60min").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
Duration::minutes(999),
|
||||||
|
parse_duration("999minutes").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_second() {
|
||||||
|
assert_eq!(Duration::zero(), parse_duration("0s").unwrap());
|
||||||
|
assert_eq!(Duration::seconds(6), parse_duration("6s").unwrap());
|
||||||
|
assert_eq!(Duration::minutes(1), parse_duration("60sec").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
Duration::seconds(999),
|
||||||
|
parse_duration("999seconds").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/error.rs
62
src/error.rs
@@ -1,27 +1,61 @@
|
|||||||
use axum::body::Full;
|
use axum::{http::StatusCode, response::Json};
|
||||||
use axum::http::{StatusCode};
|
use serde::Serialize;
|
||||||
use axum::Json;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
|
|
||||||
|
/// Application-specific errors that can occur during request processing.
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum TimeBannerError {
|
pub enum TimeBannerError {
|
||||||
|
/// Input parsing errors (invalid time formats, bad parameters, etc.)
|
||||||
ParseError(String),
|
ParseError(String),
|
||||||
|
/// Template rendering failures
|
||||||
RenderError(String),
|
RenderError(String),
|
||||||
|
/// SVG to PNG conversion failures
|
||||||
RasterizeError(String),
|
RasterizeError(String),
|
||||||
|
/// 404 Not Found
|
||||||
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
/// JSON error response format for HTTP clients.
|
||||||
|
#[derive(Serialize)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
code: u16,
|
error: String,
|
||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts application errors into standardized HTTP responses with JSON bodies.
|
||||||
|
///
|
||||||
|
/// Returns appropriate status codes:
|
||||||
|
/// - 400 Bad Request: ParseError
|
||||||
|
/// - 500 Internal Server Error: RenderError, RasterizeError
|
||||||
|
/// - 404 Not Found: NotFound
|
||||||
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
|
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
let (code, message) = match error {
|
match error {
|
||||||
TimeBannerError::RenderError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("RenderError :: {}", msg)),
|
TimeBannerError::ParseError(msg) => (
|
||||||
TimeBannerError::ParseError(msg) => (StatusCode::BAD_REQUEST, format!("ParserError :: {}", msg)),
|
StatusCode::BAD_REQUEST,
|
||||||
TimeBannerError::RasterizeError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("RasertizeError :: {}", msg))
|
Json(ErrorResponse {
|
||||||
};
|
error: "ParseError".to_string(),
|
||||||
|
message: msg,
|
||||||
(code, Json(ErrorResponse { code: code.as_u16(), message }))
|
}),
|
||||||
|
),
|
||||||
|
TimeBannerError::RenderError(msg) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "RenderError".to_string(),
|
||||||
|
message: msg,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
TimeBannerError::RasterizeError(msg) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "RasterizeError".to_string(),
|
||||||
|
message: msg,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
TimeBannerError::NotFound => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "NotFound".to_string(),
|
||||||
|
message: "The requested resource was not found".to_string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
64
src/main.rs
64
src/main.rs
@@ -1,43 +1,67 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use axum::{Router, routing::get};
|
use crate::routes::{
|
||||||
use dotenvy::dotenv;
|
absolute_handler, fallback_handler, favicon_handler, favicon_png_handler, implicit_handler,
|
||||||
|
index_handler, relative_handler,
|
||||||
|
};
|
||||||
|
use axum::{http::HeaderValue, response::Response, routing::get, Router};
|
||||||
use config::Configuration;
|
use config::Configuration;
|
||||||
use crate::routes::{relative_handler, implicit_handler, absolute_handler};
|
use dotenvy::dotenv;
|
||||||
|
|
||||||
|
mod abbr_tz;
|
||||||
mod config;
|
mod config;
|
||||||
mod raster;
|
mod duration;
|
||||||
mod abbr;
|
|
||||||
mod routes;
|
|
||||||
mod parse;
|
|
||||||
mod template;
|
|
||||||
mod error;
|
mod error;
|
||||||
|
mod raster;
|
||||||
|
mod render;
|
||||||
|
mod routes;
|
||||||
|
mod template;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Parse dotenv files and expose them as environment variables
|
// Development-only: Parse dotenv files and expose them as environment variables
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
// envy uses our Configuration struct to parse environment variables
|
// Envy uses our Configuration struct to parse environment variables
|
||||||
let config = envy::from_env::<Configuration>().expect("Please provide PORT env var");
|
let config = envy::from_env::<Configuration>().expect("Failed to parse environment variables");
|
||||||
|
|
||||||
// initialize tracing
|
// Initialize tracing
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
// With the log_level from our config
|
// With the log_level from our config
|
||||||
.with_max_level(config.log_level())
|
.with_max_level(config.log_level())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/:path", get(implicit_handler))
|
.route("/", get(index_handler))
|
||||||
.route("/rel/:path", get(relative_handler))
|
.route("/favicon.ico", get(favicon_handler))
|
||||||
.route("/relative/:path", get(relative_handler))
|
.route("/favicon.png", get(favicon_png_handler))
|
||||||
.route("/absolute/:path", get(absolute_handler))
|
.route("/{path}", get(implicit_handler))
|
||||||
.route("/abs/:path", get(absolute_handler));
|
.route("/rel/{path}", get(relative_handler))
|
||||||
|
.route("/relative/{path}", get(relative_handler))
|
||||||
|
.route("/absolute/{path}", get(absolute_handler))
|
||||||
|
.route("/abs/{path}", get(absolute_handler))
|
||||||
|
.fallback(fallback_handler)
|
||||||
|
.layer(axum::middleware::map_response(add_server_header));
|
||||||
|
|
||||||
let addr = SocketAddr::from((config.socket_addr(), config.port));
|
let addr = SocketAddr::from((config.socket_addr(), config.port));
|
||||||
axum::Server::bind(&addr)
|
axum::serve(
|
||||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
tokio::net::TcpListener::bind(addr).await.unwrap(),
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Middleware to add server header with application version
|
||||||
|
async fn add_server_header(mut response: Response) -> Response {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
let server_header = format!("time-banner/{}", version);
|
||||||
|
|
||||||
|
if let Ok(header_value) = HeaderValue::from_str(&server_header) {
|
||||||
|
response.headers_mut().insert("Server", header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|||||||
26
src/parse.rs
26
src/parse.rs
@@ -1,26 +0,0 @@
|
|||||||
use chrono::{DateTime, FixedOffset, Utc};
|
|
||||||
|
|
||||||
/// Split a path into a tuple of the preceding path and the extension.
|
|
||||||
/// Can handle paths with multiple dots (period characters).
|
|
||||||
/// Returns None if there is no extension.
|
|
||||||
/// Returns None if the preceding path is empty (for example, dotfiles like ".env").
|
|
||||||
pub fn split_on_extension(path: &str) -> Option<(&str, &str)> {
|
|
||||||
let split = path.rsplit_once('.');
|
|
||||||
if split.is_none() { return None; }
|
|
||||||
|
|
||||||
// Check that the file is not a dotfile (.env)
|
|
||||||
if split.unwrap().0.len() == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(split.unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_absolute(raw: String) -> Result<(DateTime<Utc>, FixedOffset), String> {
|
|
||||||
let datetime_with_offset = DateTime::parse_from_rfc3339(&raw);
|
|
||||||
if datetime_with_offset.is_err() {
|
|
||||||
return Err("Failed to parse datetime".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((datetime_with_offset.unwrap().with_timezone(&Utc), *(datetime_with_offset.unwrap().offset())))
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
use resvg::usvg::fontdb;
|
||||||
use resvg::{tiny_skia, usvg};
|
use resvg::{tiny_skia, usvg};
|
||||||
use resvg::usvg::{fontdb, TreeParsing, TreeTextToPath};
|
|
||||||
|
|
||||||
|
/// Errors that can occur during SVG rasterization.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RenderError {
|
pub struct RenderError {
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
@@ -21,34 +22,53 @@ pub struct Rasterizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Rasterizer {
|
impl Rasterizer {
|
||||||
|
/// Creates a new rasterizer and loads available fonts.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut fontdb = fontdb::Database::new();
|
let mut fontdb = fontdb::Database::new();
|
||||||
fontdb.load_system_fonts();
|
fontdb.load_system_fonts();
|
||||||
fontdb.load_fonts_dir("./fonts");
|
fontdb.load_fonts_dir(if cfg!(debug_assertions) {
|
||||||
|
"src/fonts"
|
||||||
|
} else {
|
||||||
|
"fonts"
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self { font_db: fontdb }
|
||||||
font_db: fontdb
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts SVG data to PNG.
|
||||||
pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> {
|
pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> {
|
||||||
let tree = {
|
let tree = {
|
||||||
let opt = usvg::Options::default();
|
let opt = usvg::Options {
|
||||||
let mut tree_result = usvg::Tree::from_data(&*svg_data, &opt);
|
fontdb: std::sync::Arc::new(self.font_db.clone()),
|
||||||
if tree_result.is_err() { return Err(RenderError { message: Some("Failed to parse".to_string()) }); }
|
..Default::default()
|
||||||
|
};
|
||||||
|
let tree_result = usvg::Tree::from_data(&svg_data, &opt);
|
||||||
|
if tree_result.is_err() {
|
||||||
|
return Err(RenderError {
|
||||||
|
message: Some("Failed to parse".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let tree = tree_result.as_mut().unwrap();
|
tree_result.unwrap()
|
||||||
tree.convert_text(&self.font_db);
|
|
||||||
|
|
||||||
resvg::Tree::from_usvg(&tree)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let pixmap_size = tree.size.to_int_size();
|
let pixmap_size = tree.size().to_int_size();
|
||||||
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
|
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
|
||||||
tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
|
||||||
|
|
||||||
pixmap
|
// Calculate center point for scaling
|
||||||
.encode_png()
|
let center_x = pixmap_size.width() as f32 / 2.0;
|
||||||
.map_err(|_| RenderError { message: Some("Failed to encode".to_string()) })
|
let center_y = pixmap_size.height() as f32 / 2.0;
|
||||||
|
|
||||||
|
// Create transform that scales from center: translate to center, scale, translate back
|
||||||
|
let zoom = 0.90; // 10% zoom out from center
|
||||||
|
let render_ts = tiny_skia::Transform::from_translate(-center_x, -center_y)
|
||||||
|
.post_scale(zoom, zoom)
|
||||||
|
.post_translate(center_x, center_y);
|
||||||
|
|
||||||
|
resvg::render(&tree, render_ts, &mut pixmap.as_mut());
|
||||||
|
|
||||||
|
pixmap.encode_png().map_err(|_| RenderError {
|
||||||
|
message: Some("Failed to encode".to_string()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
153
src/render.rs
Normal file
153
src/render.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
use crate::error::{get_error_response, TimeBannerError};
|
||||||
|
use crate::raster::Rasterizer;
|
||||||
|
use crate::template::{render_template, OutputForm, RenderContext};
|
||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
/// Output format for rendered time banners.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
Svg,
|
||||||
|
Png,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputFormat {
|
||||||
|
/// Determines output format from file extension. Defaults to SVG for unknown extensions.
|
||||||
|
pub fn from_extension(ext: &str) -> Self {
|
||||||
|
match ext {
|
||||||
|
"png" => OutputFormat::Png,
|
||||||
|
_ => OutputFormat::Svg, // Default to SVG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_mime_type(mime_type: &str) -> Self {
|
||||||
|
// TODO: Support mime types dynamically, proper header parsing
|
||||||
|
match mime_type {
|
||||||
|
"image/svg+xml" => OutputFormat::Svg,
|
||||||
|
"image/png" => OutputFormat::Png,
|
||||||
|
_ => OutputFormat::Svg, // Default to SVG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the appropriate MIME type for HTTP responses.
|
||||||
|
pub fn mime_type(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
OutputFormat::Svg => "image/svg+xml",
|
||||||
|
OutputFormat::Png => "image/png",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts SVG to the requested format. PNG requires rasterization.
|
||||||
|
pub fn handle_rasterize(data: String, format: &OutputFormat) -> Result<Bytes, TimeBannerError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Svg => Ok(Bytes::from(data)),
|
||||||
|
OutputFormat::Png => {
|
||||||
|
let renderer = Rasterizer::new();
|
||||||
|
let raw_image = renderer.render(data.into_bytes());
|
||||||
|
if let Err(err) = raw_image {
|
||||||
|
return Err(TimeBannerError::RasterizeError(
|
||||||
|
err.message.unwrap_or_else(|| "Unknown error".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Bytes::from(raw_image.unwrap()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main rendering pipeline: template → SVG → optional rasterization → HTTP response.
|
||||||
|
///
|
||||||
|
/// Takes a timestamp, display format, and file extension, then:
|
||||||
|
/// 1. Renders the time using a template
|
||||||
|
/// 2. Converts to the requested format (SVG or PNG)
|
||||||
|
/// 3. Returns an HTTP response with appropriate headers
|
||||||
|
pub fn render_time_response(
|
||||||
|
time: DateTime<Utc>,
|
||||||
|
output_form: OutputForm,
|
||||||
|
extension: &str,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let output_format = OutputFormat::from_extension(extension);
|
||||||
|
|
||||||
|
// Build context for rendering
|
||||||
|
let context = RenderContext {
|
||||||
|
value: time,
|
||||||
|
output_form,
|
||||||
|
output_format: output_format.clone(),
|
||||||
|
timezone: None, // Default to UTC for now
|
||||||
|
format: None, // Use default format
|
||||||
|
now: None, // Use current time
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render template
|
||||||
|
let rendered_template = match render_template(context) {
|
||||||
|
Ok(template) => template,
|
||||||
|
Err(e) => {
|
||||||
|
return get_error_response(TimeBannerError::RenderError(format!(
|
||||||
|
"Template rendering failed: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle rasterization
|
||||||
|
match handle_rasterize(rendered_template, &output_format) {
|
||||||
|
Ok(bytes) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, output_format.mime_type())],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => get_error_response(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates PNG bytes for the favicon clock.
|
||||||
|
pub fn generate_favicon_png_bytes(time: DateTime<Utc>) -> Result<Vec<u8>, TimeBannerError> {
|
||||||
|
// Build context for rendering
|
||||||
|
let context = RenderContext {
|
||||||
|
value: time,
|
||||||
|
output_form: OutputForm::Clock,
|
||||||
|
output_format: OutputFormat::Png,
|
||||||
|
timezone: None,
|
||||||
|
format: None,
|
||||||
|
now: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render template to SVG
|
||||||
|
let rendered_template = render_template(context)
|
||||||
|
.map_err(|e| TimeBannerError::RenderError(format!("Template rendering failed: {}", e)))?;
|
||||||
|
|
||||||
|
// Convert SVG to PNG
|
||||||
|
let png_bytes = handle_rasterize(rendered_template, &OutputFormat::Png)?;
|
||||||
|
|
||||||
|
Ok(png_bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts PNG bytes to ICO format using the ico crate.
|
||||||
|
pub fn convert_png_to_ico(png_bytes: &[u8]) -> Result<Bytes, String> {
|
||||||
|
// Create a new, empty icon collection
|
||||||
|
let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon);
|
||||||
|
|
||||||
|
// Read PNG data from bytes
|
||||||
|
let cursor = Cursor::new(png_bytes);
|
||||||
|
let image =
|
||||||
|
ico::IconImage::read_png(cursor).map_err(|e| format!("Failed to read PNG data: {}", e))?;
|
||||||
|
|
||||||
|
// Add the image to the icon collection
|
||||||
|
icon_dir.add_entry(
|
||||||
|
ico::IconDirEntry::encode(&image)
|
||||||
|
.map_err(|e| format!("Failed to encode icon entry: {}", e))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write ICO data to a buffer
|
||||||
|
let mut ico_buffer = Vec::new();
|
||||||
|
icon_dir
|
||||||
|
.write(&mut ico_buffer)
|
||||||
|
.map_err(|e| format!("Failed to write ICO data: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Bytes::from(ico_buffer))
|
||||||
|
}
|
||||||
176
src/routes.rs
176
src/routes.rs
@@ -1,92 +1,110 @@
|
|||||||
use axum::{http::StatusCode, response::IntoResponse};
|
use crate::duration::parse_time_value;
|
||||||
use axum::body::{Bytes, Full};
|
|
||||||
use axum::extract::{Path};
|
|
||||||
use axum::http::{header};
|
|
||||||
use axum::response::Response;
|
|
||||||
use chrono::{DateTime, NaiveDateTime, Offset, Utc};
|
|
||||||
use crate::error::{get_error_response, TimeBannerError};
|
use crate::error::{get_error_response, TimeBannerError};
|
||||||
|
use crate::render::{convert_png_to_ico, generate_favicon_png_bytes, render_time_response};
|
||||||
|
use crate::template::OutputForm;
|
||||||
|
use crate::utils::parse_path;
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
/// Root handler - redirects to current time in relative format.
|
||||||
|
pub async fn index_handler() -> impl IntoResponse {
|
||||||
|
let epoch_now = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
use crate::parse::split_on_extension;
|
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
|
||||||
use crate::raster::Rasterizer;
|
|
||||||
use crate::template::{OutputForm, render_template, RenderContext};
|
|
||||||
|
|
||||||
|
|
||||||
fn parse_path(path: &str) -> (&str, &str) {
|
|
||||||
split_on_extension(path)
|
|
||||||
.or_else(|| Some((path, "svg")))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_rasterize(data: String, extension: &str) -> Result<(&str, Bytes), TimeBannerError> {
|
|
||||||
match extension {
|
|
||||||
"svg" => Ok(("image/svg+xml", Bytes::from(data))),
|
|
||||||
"png" => {
|
|
||||||
let renderer = Rasterizer::new();
|
|
||||||
let raw_image = renderer.render(data.into_bytes());
|
|
||||||
if raw_image.is_err() {
|
|
||||||
return Err(TimeBannerError::RasterizeError(raw_image.unwrap_err().message.unwrap_or("Unknown error".to_string())));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(("image/x-png", Bytes::from(raw_image.unwrap())))
|
|
||||||
}
|
|
||||||
_ => Err(TimeBannerError::ParseError(format!("Unsupported extension: {}", extension)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles `/relative/{time}` - displays time in relative format ("2 hours ago").
|
||||||
pub async fn relative_handler(Path(path): Path<String>) -> impl IntoResponse {
|
pub async fn relative_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||||
let (raw_time, extension) = parse_path(path.as_str());
|
let (raw_time, extension) = parse_path(&path);
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
|
let time = match parse_time_value(raw_time) {
|
||||||
let (raw_time, extension) = parse_path(path.as_str());
|
Ok(t) => t,
|
||||||
}
|
Err(e) => return get_error_response(e).into_response(),
|
||||||
|
|
||||||
|
|
||||||
// basic handler that responds with a static string
|
|
||||||
pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
|
|
||||||
// Get extension if available
|
|
||||||
let (raw_time, extension) = parse_path(path.as_str());
|
|
||||||
|
|
||||||
// Parse epoch
|
|
||||||
let parsed_epoch = raw_time.parse::<i64>();
|
|
||||||
if parsed_epoch.is_err() {
|
|
||||||
return get_error_response(TimeBannerError::ParseError("Input could not be parsed into integer.".to_string())).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert epoch to DateTime
|
|
||||||
let naive_time = NaiveDateTime::from_timestamp_opt(parsed_epoch.unwrap(), 0);
|
|
||||||
if naive_time.is_none() {
|
|
||||||
return get_error_response(TimeBannerError::ParseError("Input was not a valid DateTime".to_string())).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let utc_time = DateTime::<Utc>::from_utc(naive_time.unwrap(), Utc);
|
|
||||||
|
|
||||||
// Build context for rendering
|
|
||||||
let context = RenderContext {
|
|
||||||
output_form: OutputForm::Relative,
|
|
||||||
value: utc_time,
|
|
||||||
tz_offset: utc_time.offset().fix(),
|
|
||||||
tz_name: "UTC",
|
|
||||||
view: "basic",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let rendered_template = render_template(context);
|
render_time_response(time, OutputForm::Relative, extension).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
if rendered_template.is_err() {
|
/// Handles `/absolute/{time}` - displays time in absolute format ("2025-01-17 14:30:00 UTC").
|
||||||
return Response::builder()
|
pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
let (raw_time, extension) = parse_path(&path);
|
||||||
.body(Full::from(
|
|
||||||
format!("Template Could Not Be Rendered :: {}", rendered_template.err().unwrap())
|
|
||||||
))
|
|
||||||
.unwrap().into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let rasterize_result = handle_rasterize(rendered_template.unwrap(), extension);
|
let time = match parse_time_value(raw_time) {
|
||||||
match rasterize_result {
|
Ok(t) => t,
|
||||||
Ok((mime_type, bytes)) => {
|
Err(e) => return get_error_response(e).into_response(),
|
||||||
(StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], bytes).into_response()
|
};
|
||||||
}
|
|
||||||
Err(e) => get_error_response(e).into_response()
|
render_time_response(time, OutputForm::Absolute, extension).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles `/{time}` - implicit absolute time display (same as absolute_handler).
|
||||||
|
pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||||
|
let (raw_time, extension) = parse_path(&path);
|
||||||
|
|
||||||
|
let time = match parse_time_value(raw_time) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => return get_error_response(e).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render_time_response(time, OutputForm::Absolute, extension).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles `/favicon.ico` - generates a dynamic clock favicon showing the current time.
|
||||||
|
///
|
||||||
|
/// Logs the client IP address and returns an ICO image of an analog clock.
|
||||||
|
pub async fn favicon_handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> impl IntoResponse {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
// Log the IP address for the favicon request
|
||||||
|
tracing::info!("Favicon request from IP: {}", addr.ip());
|
||||||
|
|
||||||
|
// Generate PNG bytes directly for conversion
|
||||||
|
let png_bytes = match generate_favicon_png_bytes(now) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => return get_error_response(e).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert PNG to ICO using the ico crate
|
||||||
|
match convert_png_to_ico(&png_bytes) {
|
||||||
|
Ok(ico_bytes) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "image/x-icon")],
|
||||||
|
ico_bytes,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => get_error_response(TimeBannerError::RenderError(format!(
|
||||||
|
"Failed to convert PNG to ICO: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles `/favicon.png` - generates a dynamic clock favicon showing the current time.
|
||||||
|
///
|
||||||
|
/// Logs the client IP address and returns a PNG image of an analog clock.
|
||||||
|
pub async fn favicon_png_handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> impl IntoResponse {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
// Log the IP address for the favicon request
|
||||||
|
tracing::info!("Favicon PNG request from IP: {}", addr.ip());
|
||||||
|
|
||||||
|
// Generate PNG bytes directly
|
||||||
|
match generate_favicon_png_bytes(now) {
|
||||||
|
Ok(png_bytes) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "image/png")],
|
||||||
|
png_bytes,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => get_error_response(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback handler for unmatched routes.
|
||||||
|
pub async fn fallback_handler() -> impl IntoResponse {
|
||||||
|
get_error_response(TimeBannerError::NotFound).into_response()
|
||||||
|
}
|
||||||
|
|||||||
120
src/template.rs
120
src/template.rs
@@ -1,47 +1,129 @@
|
|||||||
use chrono::{DateTime, FixedOffset, Utc};
|
use chrono::{DateTime, Timelike, Utc};
|
||||||
use timeago::Formatter;
|
|
||||||
use tera::{Context, Tera};
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
use tera::{Context, Tera};
|
||||||
|
use timeago::Formatter;
|
||||||
|
|
||||||
|
use crate::render::OutputFormat;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
|
/// Global Tera template engine instance.
|
||||||
static ref TEMPLATES: Tera = {
|
static ref TEMPLATES: Tera = {
|
||||||
let mut _tera = match Tera::new("templates/**/*.svg") {
|
let template_pattern = if cfg!(debug_assertions) {
|
||||||
|
// Development: templates are in src/templates
|
||||||
|
"src/templates/**/*.svg"
|
||||||
|
} else {
|
||||||
|
// Production: templates are in /usr/src/app/templates (relative to working dir)
|
||||||
|
"templates/**/*.svg"
|
||||||
|
};
|
||||||
|
|
||||||
|
match Tera::new(template_pattern) {
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
let names: Vec<&str> = t.get_template_names().collect();
|
let names: Vec<&str> = t.get_template_names().collect();
|
||||||
println!("{} templates found ([{}]).", names.len(), names.join(", "));
|
println!("{} templates found ([{}]).", names.len(), names.join(", "));
|
||||||
t
|
t
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Parsing error(s): {}", e);
|
println!("Parsing error(s): {}", e);
|
||||||
::std::process::exit(1);
|
::std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
_tera
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display format for time values.
|
||||||
pub enum OutputForm {
|
pub enum OutputForm {
|
||||||
|
/// Relative display: "2 hours ago", "in 3 days"
|
||||||
Relative,
|
Relative,
|
||||||
|
/// Absolute display: "2025-01-17 14:30:00 UTC"
|
||||||
Absolute,
|
Absolute,
|
||||||
|
/// Clock display: analog clock with hands showing the time
|
||||||
|
Clock,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RenderContext<'a> {
|
/// Timezone specification formats (currently unused but reserved for future features).
|
||||||
pub output_form: OutputForm,
|
pub enum TzForm {
|
||||||
|
Abbreviation(String), // e.g. "CST"
|
||||||
|
Iso(String), // e.g. "America/Chicago"
|
||||||
|
Offset(i32), // e.g. "-0600" as -21600
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context passed to template renderer containing all necessary data.
|
||||||
|
pub struct RenderContext {
|
||||||
pub value: DateTime<Utc>,
|
pub value: DateTime<Utc>,
|
||||||
pub tz_offset: FixedOffset,
|
pub output_form: OutputForm,
|
||||||
pub tz_name: &'a str,
|
pub output_format: OutputFormat,
|
||||||
pub view: &'a str,
|
/// Target timezone (not yet implemented - defaults to UTC)
|
||||||
|
pub timezone: Option<TzForm>,
|
||||||
|
/// Custom time format string (not yet implemented)
|
||||||
|
pub format: Option<String>,
|
||||||
|
/// Reference time for relative calculations (not yet implemented - uses current time)
|
||||||
|
pub now: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates clock hand positions for a given time.
|
||||||
|
///
|
||||||
|
/// Returns (hour_x, hour_y, minute_x, minute_y) coordinates for SVG rendering.
|
||||||
|
/// Clock center is at (16, 16) with appropriate hand lengths for a 32x32 favicon.
|
||||||
|
fn calculate_clock_hands(time: DateTime<Utc>) -> (f64, f64, f64, f64) {
|
||||||
|
let hour = time.hour() as f64;
|
||||||
|
let minute = time.minute() as f64;
|
||||||
|
|
||||||
|
// Calculate angles (12 o'clock = 0°, clockwise)
|
||||||
|
let hour_angle = ((hour % 12.0) + minute / 60.0) * 30.0; // 30° per hour
|
||||||
|
let minute_angle = minute * 6.0; // 6° per minute
|
||||||
|
|
||||||
|
// Convert to radians and adjust for SVG coordinate system (0° at top)
|
||||||
|
let hour_rad = (hour_angle - 90.0).to_radians();
|
||||||
|
let minute_rad = (minute_angle - 90.0).to_radians();
|
||||||
|
|
||||||
|
// Clock center and hand lengths
|
||||||
|
let center_x = 16.0;
|
||||||
|
let center_y = 16.0;
|
||||||
|
let hour_length = 7.0; // Shorter hour hand
|
||||||
|
let minute_length = 11.0; // Longer minute hand
|
||||||
|
|
||||||
|
// Calculate end positions
|
||||||
|
let hour_x = center_x + hour_length * hour_rad.cos();
|
||||||
|
let hour_y = center_y + hour_length * hour_rad.sin();
|
||||||
|
let minute_x = center_x + minute_length * minute_rad.cos();
|
||||||
|
let minute_y = center_y + minute_length * minute_rad.sin();
|
||||||
|
|
||||||
|
(hour_x, hour_y, minute_x, minute_y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a time value using the appropriate template.
|
||||||
|
///
|
||||||
|
/// Uses different templates based on output form:
|
||||||
|
/// - Relative/Absolute: "basic.svg" with text content
|
||||||
|
/// - Clock: "clock.svg" with calculated hand positions
|
||||||
pub fn render_template(context: RenderContext) -> Result<String, tera::Error> {
|
pub fn render_template(context: RenderContext) -> Result<String, tera::Error> {
|
||||||
let mut template_context = Context::new();
|
let mut template_context = Context::new();
|
||||||
|
|
||||||
|
match context.output_form {
|
||||||
|
OutputForm::Relative => {
|
||||||
let formatter = Formatter::new();
|
let formatter = Formatter::new();
|
||||||
|
template_context.insert("text", &formatter.convert_chrono(context.value, Utc::now()));
|
||||||
template_context.insert("text", match context.output_form {
|
|
||||||
OutputForm::Relative => formatter.convert_chrono(context.value, Utc::now()),
|
|
||||||
OutputForm::Absolute => context.value.to_rfc3339()
|
|
||||||
}.as_str());
|
|
||||||
|
|
||||||
TEMPLATES.render("basic.svg", &template_context)
|
TEMPLATES.render("basic.svg", &template_context)
|
||||||
}
|
}
|
||||||
|
OutputForm::Absolute => {
|
||||||
|
template_context.insert("text", &context.value.to_rfc3339());
|
||||||
|
TEMPLATES.render("basic.svg", &template_context)
|
||||||
|
}
|
||||||
|
OutputForm::Clock => {
|
||||||
|
let (hour_x, hour_y, minute_x, minute_y) = calculate_clock_hands(context.value);
|
||||||
|
|
||||||
|
// Format to 2 decimal places to avoid precision issues
|
||||||
|
let hour_x_str = format!("{:.2}", hour_x);
|
||||||
|
let hour_y_str = format!("{:.2}", hour_y);
|
||||||
|
let minute_x_str = format!("{:.2}", minute_x);
|
||||||
|
let minute_y_str = format!("{:.2}", minute_y);
|
||||||
|
|
||||||
|
template_context.insert("hour_x", &hour_x_str);
|
||||||
|
template_context.insert("hour_y", &hour_y_str);
|
||||||
|
template_context.insert("minute_x", &minute_x_str);
|
||||||
|
template_context.insert("minute_y", &minute_y_str);
|
||||||
|
|
||||||
|
TEMPLATES.render("clock.svg", &template_context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<svg width="512" height="34" xmlns="http://www.w3.org/2000/svg" font-family="Roboto Mono" font-size="27">
|
<svg xmlns="http://www.w3.org/2000/svg" font-family="Roboto Mono"
|
||||||
<text x="8" y="27">{{ text }}</text>
|
font-size="27">
|
||||||
<style>
|
<text text-anchor="start" dy="24">
|
||||||
text
|
{{ text }}
|
||||||
</style>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 191 B After Width: | Height: | Size: 162 B |
37
src/templates/clock.svg
Normal file
37
src/templates/clock.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Clock face -->
|
||||||
|
<circle cx="16" cy="16" r="15" fill="#ffffff" stroke="#000000" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- Hour markers -->
|
||||||
|
<g stroke="#000000" stroke-width="1">
|
||||||
|
<!-- 12 o'clock -->
|
||||||
|
<line x1="16" y1="2" x2="16" y2="5" />
|
||||||
|
<!-- 3 o'clock -->
|
||||||
|
<line x1="30" y1="16" x2="27" y2="16" />
|
||||||
|
<!-- 6 o'clock -->
|
||||||
|
<line x1="16" y1="30" x2="16" y2="27" />
|
||||||
|
<!-- 9 o'clock -->
|
||||||
|
<line x1="2" y1="16" x2="5" y2="16" />
|
||||||
|
|
||||||
|
<!-- Minor markers -->
|
||||||
|
<line x1="24.5" y1="4.5" x2="23.5" y2="5.5" />
|
||||||
|
<line x1="27.5" y1="7.5" x2="26.5" y2="8.5" />
|
||||||
|
<line x1="27.5" y1="24.5" x2="26.5" y2="23.5" />
|
||||||
|
<line x1="24.5" y1="27.5" x2="23.5" y2="26.5" />
|
||||||
|
<line x1="7.5" y1="27.5" x2="8.5" y2="26.5" />
|
||||||
|
<line x1="4.5" y1="24.5" x2="5.5" y2="23.5" />
|
||||||
|
<line x1="4.5" y1="7.5" x2="5.5" y2="8.5" />
|
||||||
|
<line x1="7.5" y1="4.5" x2="8.5" y2="5.5" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Hour hand -->
|
||||||
|
<line x1="16" y1="16" x2="{{ hour_x }}" y2="{{ hour_y }}" stroke="#000000" stroke-width="2"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
|
||||||
|
<!-- Minute hand -->
|
||||||
|
<line x1="16" y1="16" x2="{{ minute_x }}" y2="{{ minute_y }}" stroke="#000000"
|
||||||
|
stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
|
||||||
|
<!-- Center dot -->
|
||||||
|
<circle cx="16" cy="16" r="1.5" fill="#000000" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
49
src/utils.rs
Normal file
49
src/utils.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! General utility functions used across the application.
|
||||||
|
|
||||||
|
/// Splits a path on the last dot to extract filename and extension.
|
||||||
|
/// Returns None for dotfiles (paths starting with a dot).
|
||||||
|
pub fn split_on_extension(path: &str) -> Option<(&str, &str)> {
|
||||||
|
let split = path.rsplit_once('.')?;
|
||||||
|
|
||||||
|
// Check that the file is not a dotfile (.env)
|
||||||
|
if split.0.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(split)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses path into (filename, extension). Defaults to "svg" if no extension found.
|
||||||
|
pub fn parse_path(path: &str) -> (&str, &str) {
|
||||||
|
split_on_extension(path).unwrap_or((path, "svg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_on_extension() {
|
||||||
|
assert_eq!(split_on_extension("file.txt"), Some(("file", "txt")));
|
||||||
|
assert_eq!(
|
||||||
|
split_on_extension("path/to/file.png"),
|
||||||
|
Some(("path/to/file", "png"))
|
||||||
|
);
|
||||||
|
assert_eq!(split_on_extension("noextension"), None);
|
||||||
|
assert_eq!(split_on_extension(".dotfile"), None); // dotfiles return None
|
||||||
|
assert_eq!(split_on_extension("file."), Some(("file", "")));
|
||||||
|
assert_eq!(
|
||||||
|
split_on_extension("file.name.ext"),
|
||||||
|
Some(("file.name", "ext"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_path() {
|
||||||
|
assert_eq!(parse_path("file.txt"), ("file", "txt"));
|
||||||
|
assert_eq!(parse_path("path/to/file.png"), ("path/to/file", "png"));
|
||||||
|
assert_eq!(parse_path("noextension"), ("noextension", "svg")); // default to svg
|
||||||
|
assert_eq!(parse_path(".dotfile"), (".dotfile", "svg")); // dotfiles get svg default
|
||||||
|
assert_eq!(parse_path("file."), ("file", ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user