39 Commits

Author SHA1 Message Date
bab7f916d3 chore: bump to 0.2.0 2025-07-10 18:58:33 -05:00
ffedc60ed1 feat: add Server header with version 2025-07-10 18:58:24 -05:00
97d4ad57b4 docs: track feature progression in README 2025-07-10 18:54:56 -05:00
b427c9d094 feat: favicon ico conversion 2025-07-10 18:48:07 -05:00
696f18af3f feat: dynamic png-based clock favicon 2025-07-10 18:36:29 -05:00
1a7e9e3414 docs: cleanup README 2025-07-10 18:22:08 -05:00
96dcbcc318 docs: add comprehensive documentation 2025-07-10 18:08:43 -05:00
1b3f6c8864 feat: enhance duration parsing and error handling, add utility functions 2025-07-10 18:08:43 -05:00
5afffcaf07 feat: add rendering module and integrate into routes 2025-07-10 17:41:22 -05:00
4694fd6632 feat: re-implement route rendering, use duration parsing, absolute timestamps 2025-07-10 17:27:20 -05:00
279dc043d4 tests: clippy warnings as warn, no deny 2025-07-10 17:26:46 -05:00
1e36db1ff7 doc: detail 'auto' tz value logic 2025-07-10 17:26:46 -05:00
52fb1b2854 refactor: rename abbr/relative modules 2025-07-10 17:10:54 -05:00
7a6b304213 chore: configure clippy to ignore dead/unused code 2025-07-10 17:00:23 -05:00
94e5fccc40 fix: simple clippy recommendations 2025-07-10 17:00:23 -05:00
3bf37b936f fix: regex greedy pattern stops early when parsing shorter unit markers 2025-07-10 16:50:04 -05:00
a6fe3995c5 tests: use direct duration value comparisons for parse_duration tests 2025-07-10 16:45:44 -05:00
b1ea1b3957 doc: request path documentation 2025-07-10 16:34:27 -05:00
00aabfc692 feat: add justfile 2025-07-10 15:26:09 -05:00
850a399fe0 fix: solve easy clippy warnings 2025-07-10 15:26:04 -05:00
4d7f58af43 feat: fix development build path patterns for templates/fonts 2025-07-10 12:43:51 -05:00
01e71d2ff5 doc: dynamic favicon concept 2025-07-10 12:37:52 -05:00
4cf4b626de chore: smarter zoom out raster method, simpler svg 2025-07-10 12:37:45 -05:00
32b55c918c chore: update all dependencies to latest 2025-07-10 12:37:13 -05:00
babae191a4 feat: calculate size of rasterized png using content area 2025-07-10 11:53:43 -05:00
430a6ca7ac chore: cargo fmt 2025-07-10 11:40:35 -05:00
a4d0898b26 chore: update phf_codegen 2025-07-10 11:13:47 -05:00
23d09a3235 chore: reformatting files, remove parse module, move split_on_extension 2025-07-10 11:10:36 -05:00
3f57389f6c feat: improve dockerfile, better stages 2025-07-10 11:01:09 -05:00
fc5602f4c8 refactor: better error flow in parse_timezone_line 2025-07-10 11:00:10 -05:00
614cb6401d chore: update dependencies where possible 2025-07-10 10:06:58 -05:00
9d248a7c23 feat: improve build script, error handling, logging 2025-07-10 10:06:44 -05:00
56777038a0 chore: edit README, feature planning 2025-07-10 10:06:04 -05:00
0cb32482fa Update rust to 1.81 2025-02-18 14:20:41 -06:00
7207a25aef Relative time parsing format with RegEx + testing (partial) 2023-07-23 05:19:48 -05:00
3a6c4172dc Add fallback route handler 2023-07-22 18:20:29 -05:00
4e0e6f1d83 Add route for index, redirect to relative of current epoch time 2023-07-22 18:15:15 -05:00
d963ce623d Use RasterizeError for invalid extension in handle_rasterize 2023-07-22 18:06:47 -05:00
f58b18e6bb Optimize imports 2023-07-22 18:06:04 -05:00
20 changed files with 2105 additions and 905 deletions

1217
Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
View File

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

231
build.rs
View File

@@ -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");
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!( // Tell Cargo to re-run this build script if the timezone file changes
&mut file, println!("cargo:rerun-if-changed=src/abbr_tz");
"static TIMEZONES: phf::Map<&'static str, &'static str> = {}", }
builder.build()
)
.unwrap();
write!(&mut file, ";\n").unwrap();
}

115
justfile Normal file
View 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!"

View File

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

View File

@@ -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,10 +59,14 @@ 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,
Environment::Development => Level::DEBUG, Environment::Development => Level::DEBUG,
} }
} }
} }

326
src/duration.rs Normal file
View 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()
);
}
}

View File

@@ -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(),
}),
),
}
}

View File

@@ -1,43 +1,66 @@
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, 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("/{path}", get(implicit_handler))
.route("/absolute/:path", get(absolute_handler)) .route("/rel/{path}", get(relative_handler))
.route("/abs/:path", get(absolute_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(),
.await app.into_make_service_with_connect_info::<SocketAddr>(),
.unwrap(); )
} .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
}

View File

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

View File

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

View File

@@ -1,92 +1,89 @@
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(),
} }
} }
/// Fallback handler for unmatched routes.
pub async fn fallback_handler() -> impl IntoResponse {
get_error_response(TimeBannerError::NotFound).into_response()
}

View File

@@ -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();
let formatter = Formatter::new();
template_context.insert("text", match context.output_form { match context.output_form {
OutputForm::Relative => formatter.convert_chrono(context.value, Utc::now()), OutputForm::Relative => {
OutputForm::Absolute => context.value.to_rfc3339() let formatter = Formatter::new();
}.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);
TEMPLATES.render("basic.svg", &template_context) // 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)
}
}
} }

View File

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