mirror of
https://github.com/Xevion/time-banner.git
synced 2025-12-06 13:16:44 -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]
|
||||
name = "time-banner"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
resvg = "0.34.1"
|
||||
axum = "0.6.18"
|
||||
resvg = "0.45.1"
|
||||
axum = "0.8.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.68"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
serde_json = "1.0.140"
|
||||
tokio = { version = "1.46", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
futures = "0.3.28"
|
||||
png = "0.17.9"
|
||||
futures = "0.3.31"
|
||||
png = "0.17.16"
|
||||
dotenvy = "0.15.7"
|
||||
envy = "0.4.2"
|
||||
tera = "1.19.0"
|
||||
lazy_static = "1.4.0"
|
||||
timeago = "0.4.1"
|
||||
chrono-tz = "0.8.3"
|
||||
phf = { version = "0.11.2", features = ["macros"] }
|
||||
phf_codegen = "0.11.1"
|
||||
chrono = "0.4.26"
|
||||
regex = "1.8.4"
|
||||
tera = "1.20.0"
|
||||
lazy_static = "1.5.0"
|
||||
timeago = "0.4.2"
|
||||
chrono-tz = "0.10.3"
|
||||
phf = { version = "0.12.1", features = ["macros"] }
|
||||
phf_codegen = "0.12.1"
|
||||
chrono = "0.4.41"
|
||||
regex = "1.11.1"
|
||||
ico = "0.4.0"
|
||||
|
||||
[build-dependencies]
|
||||
chrono = "0.4.26"
|
||||
regex = "1.8.4"
|
||||
phf = { version = "0.11.1", default-features = false }
|
||||
phf_codegen = "0.11.1"
|
||||
lazy_static = "1.4.0"
|
||||
chrono = "0.4.41"
|
||||
regex = "1.11.1"
|
||||
phf = { version = "0.12.1", default-features = false }
|
||||
phf_codegen = "0.12.1"
|
||||
lazy_static = "1.5.0"
|
||||
|
||||
63
Dockerfile
63
Dockerfile
@@ -1,45 +1,70 @@
|
||||
# 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
|
||||
WORKDIR ./time-banner
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
|
||||
COPY ./Cargo.toml ./Cargo.toml
|
||||
WORKDIR /usr/src/time-banner
|
||||
|
||||
# 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
|
||||
RUN cargo build --release
|
||||
|
||||
# Build web app with own code
|
||||
RUN rm src/*.rs
|
||||
ADD . ./
|
||||
COPY ./src ./src
|
||||
RUN rm ./target/release/deps/time_banner*
|
||||
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_USER=appuser
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata
|
||||
|
||||
ENV TZ=Etc/UTC \
|
||||
APP_USER=appuser
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
RUN groupadd $APP_USER \
|
||||
&& useradd -g $APP_USER $APP_USER \
|
||||
# Create user with specific UID/GID
|
||||
RUN addgroup -g $GID -S $APP_USER \
|
||||
&& adduser -u $UID -D -S -G $APP_USER $APP_USER \
|
||||
&& mkdir -p ${APP}
|
||||
|
||||
COPY --from=builder /time-banner/target/release/time-banner ${APP}/time-banner
|
||||
COPY --from=builder /time-banner/src/fonts ${APP}/fonts
|
||||
COPY --from=builder /time-banner/src/templates ${APP}/templates
|
||||
# Copy application files
|
||||
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/target/release/time-banner ${APP}/time-banner
|
||||
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
|
||||
WORKDIR ${APP}
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
# Use ARG for build-time configuration, ENV for runtime
|
||||
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"]
|
||||
137
README.md
137
README.md
@@ -1,68 +1,85 @@
|
||||
# time-banner
|
||||
|
||||
My first Rust project, intended to offer a simple way to display the current time relative, in an image format.
|
||||
|
||||
## 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)
|
||||
Dynamically generated timestamp images
|
||||
|
||||
## Routes
|
||||
|
||||
```shell
|
||||
/{time}[.{ext}]
|
||||
/{rel|relative}/{time}[.{ext}]
|
||||
/{abs|absolute}/{time}[.{ext}]
|
||||
`GET /[relative|absolute]/[value]{.ext}?tz={timezone}&format={format_string}&now={timestamp}&static={boolean}`
|
||||
|
||||
- [x] `{relative|absolute}` - The display format of the time.
|
||||
- [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
|
||||
|
||||
- `format` - Specify the format of the time string
|
||||
- `tz` - Specify the timezone of the time string. May be ignored if the time string contains a timezone/offset.
|
||||
|
||||
## Structure
|
||||
|
||||
1. Routing
|
||||
- Handle different input formats at the route layer
|
||||
2. Parsing
|
||||
- Module for parsing input
|
||||
3. Cache Layer
|
||||
- Given all route options, provide a globally available cache for the next layer
|
||||
4. SVG Template Rendering
|
||||
- Template rendering based on parsed input
|
||||
5. (Optional) Rasterization
|
||||
- If rasterization is requested, render SVG to PNG
|
||||
6. (Catch-all) Error Handling
|
||||
- All errors/panics will be caught in separate middleware
|
||||
|
||||
## Input Parsing
|
||||
|
||||
- Date formatting will be guesswork, but can be specified with `?format=` parameter.
|
||||
- To avoid abuse, it will be limited to a subset of the `chrono` formatting options.
|
||||
- The assumed extension when not specified is `.svg` for performance sake.
|
||||
- `.png` is also available. `.jpeg` and `.webp` are planned.
|
||||
- Time is not required, but will default each value to 0 (except HOUR, which is the minimum specified value).
|
||||
- Millisecond precision is allowed, but will be ignored in most outputs. Periods or commas are allowed as separators.
|
||||
- Timezones can be qualified in a number of ways, but will default to UTC if not specified.
|
||||
- Fully qualified TZ identifiers like "America/Chicago" are specified using the `tz` query parameter.
|
||||
- Abbreviated TZ identifiers like "CST" are specified inside the time string, after the time, separated by a dash.
|
||||
- Abbreviated terms are incredibly ambiguous, and should be avoided if possible. For ease of use, they are
|
||||
available, but several of them are ambiguous, and the preferred TZ has been specified in code.
|
||||
- Full table available in [`abbr_tz`](./src/abbr_tz). Comments designated with `#`. Preferred interpretation
|
||||
designated arbitrarily by me. Table sourced
|
||||
from [Wikipedia](https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations)
|
||||
- Frontend with React for Demo
|
||||
- Refetch favicon every 10 minutes
|
||||
- Click to copy image URLs
|
||||
- Dynamic Examples
|
||||
- Dynamic light/dark mode
|
||||
- `?theme={auto|light|dark}`, default `light`
|
||||
- Customizable SVG templates
|
||||
- Dynamic favicon generation
|
||||
- Clock svg optimized for favicon size
|
||||
- Move hands to the current time
|
||||
- Use geolocation of request IP to determine timezone
|
||||
- Advanced: create sun/moon SVG based on local time
|
||||
- Support for different timezone formats in query parameters or headers
|
||||
- `?tz=...` or `X-Timezone: ...`
|
||||
- `CST` or `America/Chicago` or `UTC-6` or `GMT-6` or `-0600`
|
||||
- Automatically guessed based on geolocation of source IP address
|
||||
- Complex caching abilities
|
||||
- Multi-level caching (disk with max size, memory)
|
||||
- Automatic expiry of relative items
|
||||
- Use browser cache headers
|
||||
- Detect force refreshs and allow cache busting
|
||||
- `Accept` header support
|
||||
- IP-based rate limiting
|
||||
- Multi-domain rate limiting factors
|
||||
- Computational cost: 1ms = 1 token, max 100 tokens per minute
|
||||
- Base rate: 3 requests per second
|
||||
- Cached Conversions
|
||||
- If PNG is cached, then JPEG/WEBP/etc. can be converted from cached PNG
|
||||
- Additional date input formats
|
||||
- 2025-07-10-12:01:14
|
||||
- 2025-07-10-12:01
|
||||
- 2025-07-10-12:01:14-06:00
|
||||
- 2025-07-10-12:01:14-06
|
||||
- 2025-07-10-12:01:14-06:00:00
|
||||
- 2025-07-10-12:01:14-06:00:00.000
|
||||
- 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::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use regex::Regex;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
lazy_static! {
|
||||
static ref FULL_PATTERN: Regex = Regex::new(r"([A-Z]+)\s\t.+\s\tUTC([−+±]\d{2}(?::\d{2})?)").unwrap();
|
||||
static ref OFFSET_PATTERN: Regex = Regex::new(r"([−+±])(\d{2}(?::\d{2})?)").unwrap();
|
||||
/// Error types for build script failures
|
||||
#[derive(Debug)]
|
||||
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 {
|
||||
let capture = OFFSET_PATTERN.captures(raw_offset).expect("RegEx failed to match offset");
|
||||
println!("{}: {}", raw_offset, capture.get(1).expect("First group capture failed").as_str());
|
||||
impl From<std::io::Error> for BuildError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
BuildError::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
let is_west = capture.get(1).unwrap().as_str() == "−";
|
||||
let time = capture.get(2).expect("Second group capture failed").as_str();
|
||||
let (hours, minutes) = if time.contains(':') {
|
||||
let mut split = time.split(':');
|
||||
let hours = split.next().unwrap().parse::<u32>().unwrap();
|
||||
let minutes = split.next().unwrap().parse::<u32>().unwrap();
|
||||
impl From<env::VarError> for BuildError {
|
||||
fn from(error: env::VarError) -> Self {
|
||||
BuildError::Env(error)
|
||||
}
|
||||
}
|
||||
|
||||
(hours, minutes)
|
||||
} else {
|
||||
// Minutes not specified, assume 0
|
||||
(time.parse::<u32>().unwrap(), 0)
|
||||
lazy_static! {
|
||||
/// Regex to match timezone lines: "ABBR \t Description \t UTC±HH:MM"
|
||||
static ref TIMEZONE_PATTERN: Regex =
|
||||
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);
|
||||
return if is_west { value as i32 * -1 } else { value as i32 };
|
||||
let offset = parse_utc_offset(raw_offset)?;
|
||||
|
||||
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() {
|
||||
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("codegen.rs");
|
||||
let raw_tz = BufReader::new(File::open("./src/abbr_tz").unwrap());
|
||||
|
||||
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;
|
||||
if let Err(e) = generate_timezone_map() {
|
||||
panic!("Build script failed: {}", e);
|
||||
}
|
||||
|
||||
let capture = FULL_PATTERN.captures(&line).expect("RegEx failed to match line");
|
||||
|
||||
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();
|
||||
// Tell Cargo to re-run this build script if the timezone file changes
|
||||
println!("cargo:rerun-if-changed=src/abbr_tz");
|
||||
}
|
||||
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 tracing::Level;
|
||||
|
||||
/// Application environment configuration.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Environment {
|
||||
@@ -8,6 +9,11 @@ pub enum Environment {
|
||||
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)]
|
||||
pub struct Configuration {
|
||||
#[serde(default = "default_env")]
|
||||
@@ -26,6 +32,10 @@ fn default_env() -> Environment {
|
||||
}
|
||||
|
||||
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] {
|
||||
match self.env {
|
||||
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 {
|
||||
match self.env {
|
||||
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};
|
||||
use axum::Json;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Application-specific errors that can occur during request processing.
|
||||
#[derive(Debug)]
|
||||
pub enum TimeBannerError {
|
||||
/// Input parsing errors (invalid time formats, bad parameters, etc.)
|
||||
ParseError(String),
|
||||
/// Template rendering failures
|
||||
RenderError(String),
|
||||
/// SVG to PNG conversion failures
|
||||
RasterizeError(String),
|
||||
/// 404 Not Found
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
/// JSON error response format for HTTP clients.
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
code: u16,
|
||||
error: 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>) {
|
||||
let (code, message) = match error {
|
||||
TimeBannerError::RenderError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("RenderError :: {}", msg)),
|
||||
TimeBannerError::ParseError(msg) => (StatusCode::BAD_REQUEST, format!("ParserError :: {}", msg)),
|
||||
TimeBannerError::RasterizeError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("RasertizeError :: {}", msg))
|
||||
};
|
||||
|
||||
(code, Json(ErrorResponse { code: code.as_u16(), message }))
|
||||
match error {
|
||||
TimeBannerError::ParseError(msg) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "ParseError".to_string(),
|
||||
message: msg,
|
||||
}),
|
||||
),
|
||||
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 axum::{Router, routing::get};
|
||||
use dotenvy::dotenv;
|
||||
use crate::routes::{
|
||||
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 crate::routes::{relative_handler, implicit_handler, absolute_handler};
|
||||
use dotenvy::dotenv;
|
||||
|
||||
mod abbr_tz;
|
||||
mod config;
|
||||
mod raster;
|
||||
mod abbr;
|
||||
mod routes;
|
||||
mod parse;
|
||||
mod template;
|
||||
mod duration;
|
||||
mod error;
|
||||
|
||||
mod raster;
|
||||
mod render;
|
||||
mod routes;
|
||||
mod template;
|
||||
mod utils;
|
||||
|
||||
#[tokio::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();
|
||||
|
||||
// envy uses our Configuration struct to parse environment variables
|
||||
let config = envy::from_env::<Configuration>().expect("Please provide PORT env var");
|
||||
// Envy uses our Configuration struct to parse environment variables
|
||||
let config = envy::from_env::<Configuration>().expect("Failed to parse environment variables");
|
||||
|
||||
// initialize tracing
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
// With the log_level from our config
|
||||
.with_max_level(config.log_level())
|
||||
.init();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/:path", get(implicit_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));
|
||||
.route("/", get(index_handler))
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
.route("/favicon.png", get(favicon_png_handler))
|
||||
.route("/{path}", get(implicit_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));
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
axum::serve(
|
||||
tokio::net::TcpListener::bind(addr).await.unwrap(),
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.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::usvg::{fontdb, TreeParsing, TreeTextToPath};
|
||||
|
||||
/// Errors that can occur during SVG rasterization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderError {
|
||||
pub message: Option<String>,
|
||||
@@ -21,34 +22,53 @@ pub struct Rasterizer {
|
||||
}
|
||||
|
||||
impl Rasterizer {
|
||||
/// Creates a new rasterizer and loads available fonts.
|
||||
pub fn new() -> Self {
|
||||
let mut fontdb = fontdb::Database::new();
|
||||
fontdb.load_system_fonts();
|
||||
fontdb.load_fonts_dir("./fonts");
|
||||
fontdb.load_fonts_dir(if cfg!(debug_assertions) {
|
||||
"src/fonts"
|
||||
} else {
|
||||
"fonts"
|
||||
});
|
||||
|
||||
Self {
|
||||
font_db: fontdb
|
||||
}
|
||||
Self { font_db: fontdb }
|
||||
}
|
||||
|
||||
/// Converts SVG data to PNG.
|
||||
pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> {
|
||||
let tree = {
|
||||
let opt = usvg::Options::default();
|
||||
let mut 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 opt = usvg::Options {
|
||||
fontdb: std::sync::Arc::new(self.font_db.clone()),
|
||||
..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.convert_text(&self.font_db);
|
||||
|
||||
resvg::Tree::from_usvg(&tree)
|
||||
tree_result.unwrap()
|
||||
};
|
||||
|
||||
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();
|
||||
tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
||||
|
||||
pixmap
|
||||
.encode_png()
|
||||
.map_err(|_| RenderError { message: Some("Failed to encode".to_string()) })
|
||||
// Calculate center point for scaling
|
||||
let center_x = pixmap_size.width() as f32 / 2.0;
|
||||
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 axum::body::{Bytes, Full};
|
||||
use axum::extract::{Path};
|
||||
use axum::http::{header};
|
||||
use axum::response::Response;
|
||||
use chrono::{DateTime, NaiveDateTime, Offset, Utc};
|
||||
use crate::duration::parse_time_value;
|
||||
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;
|
||||
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)))
|
||||
}
|
||||
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
|
||||
}
|
||||
|
||||
/// Handles `/relative/{time}` - displays time in relative format ("2 hours ago").
|
||||
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 (raw_time, extension) = parse_path(path.as_str());
|
||||
}
|
||||
|
||||
|
||||
// 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 time = match parse_time_value(raw_time) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return get_error_response(e).into_response(),
|
||||
};
|
||||
|
||||
let rendered_template = render_template(context);
|
||||
render_time_response(time, OutputForm::Relative, extension).into_response()
|
||||
}
|
||||
|
||||
if rendered_template.is_err() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Full::from(
|
||||
format!("Template Could Not Be Rendered :: {}", rendered_template.err().unwrap())
|
||||
))
|
||||
.unwrap().into_response();
|
||||
}
|
||||
/// Handles `/absolute/{time}` - displays time in absolute format ("2025-01-17 14:30:00 UTC").
|
||||
pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
let (raw_time, extension) = parse_path(&path);
|
||||
|
||||
let rasterize_result = handle_rasterize(rendered_template.unwrap(), extension);
|
||||
match rasterize_result {
|
||||
Ok((mime_type, bytes)) => {
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], bytes).into_response()
|
||||
}
|
||||
Err(e) => get_error_response(e).into_response()
|
||||
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 `/{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 timeago::Formatter;
|
||||
use tera::{Context, Tera};
|
||||
use chrono::{DateTime, Timelike, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use tera::{Context, Tera};
|
||||
use timeago::Formatter;
|
||||
|
||||
use crate::render::OutputFormat;
|
||||
|
||||
lazy_static! {
|
||||
/// Global Tera template engine instance.
|
||||
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) => {
|
||||
let names: Vec<&str> = t.get_template_names().collect();
|
||||
println!("{} templates found ([{}]).", names.len(), names.join(", "));
|
||||
t
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Parsing error(s): {}", e);
|
||||
::std::process::exit(1);
|
||||
}
|
||||
};
|
||||
_tera
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Display format for time values.
|
||||
pub enum OutputForm {
|
||||
/// Relative display: "2 hours ago", "in 3 days"
|
||||
Relative,
|
||||
/// Absolute display: "2025-01-17 14:30:00 UTC"
|
||||
Absolute,
|
||||
/// Clock display: analog clock with hands showing the time
|
||||
Clock,
|
||||
}
|
||||
|
||||
pub struct RenderContext<'a> {
|
||||
pub output_form: OutputForm,
|
||||
/// Timezone specification formats (currently unused but reserved for future features).
|
||||
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 tz_offset: FixedOffset,
|
||||
pub tz_name: &'a str,
|
||||
pub view: &'a str,
|
||||
pub output_form: OutputForm,
|
||||
pub output_format: OutputFormat,
|
||||
/// 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> {
|
||||
let mut template_context = Context::new();
|
||||
|
||||
match context.output_form {
|
||||
OutputForm::Relative => {
|
||||
let formatter = Formatter::new();
|
||||
|
||||
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());
|
||||
|
||||
template_context.insert("text", &formatter.convert_chrono(context.value, Utc::now()));
|
||||
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">
|
||||
<text x="8" y="27">{{ text }}</text>
|
||||
<style>
|
||||
text
|
||||
</style>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" font-family="Roboto Mono"
|
||||
font-size="27">
|
||||
<text text-anchor="start" dy="24">
|
||||
{{ text }}
|
||||
</text>
|
||||
</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