feat: serve Vacationplanner calendar subscriptions

This commit is contained in:
2026-09-10 19:10:58 +04:00
parent 2a942e7831
commit 2f7ffea5f2
4 changed files with 2216 additions and 0 deletions
Generated
+1810
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "vacationplanner2ics"
version = "0.1.0"
edition = "2024"
rust-version = "1.98"
license = "MIT"
description = "A small Vacationplanner to iCalendar subscription service"
[dependencies]
axum = { version = "0.8.9", default-features = false, features = ["http1", "query", "tokio"] }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "serde"] }
icalendar = { version = "0.17.13", default-features = false }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal", "net", "sync", "time"] }
[profile.release]
lto = "thin"
strip = true
+90
View File
@@ -0,0 +1,90 @@
use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate, Utc};
use icalendar::{Calendar, Class, Component, Event, EventLike, EventStatus};
use reqwest::Url;
use serde::Deserialize;
use crate::Error;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vacation {
id: u64,
worker_name: String,
date_start: NaiveDate,
date_end: NaiveDate,
#[serde(default)]
department_name: Option<String>,
#[serde(default)]
comment: Option<String>,
#[serde(default)]
moderation_status: Option<String>,
}
fn text(value: &str) -> String {
value
.replace("\r\n", "\n")
.replace('\r', "\n")
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.collect()
}
pub fn render(vacations: Vec<Vacation>, origin: &Url) -> Result<String, Error> {
let mut unique = BTreeMap::new();
for vacation in vacations {
if vacation.id == 0
|| vacation.worker_name.trim().is_empty()
|| vacation.date_start > vacation.date_end
|| !(1..=9998).contains(&vacation.date_start.year())
|| !(1..=9998).contains(&vacation.date_end.year())
{
return Err(Error::upstream());
}
// The same cross-year vacation may be returned by both year endpoints.
unique.insert(vacation.id, vacation);
}
let mut calendar = Calendar::new();
calendar.name("Vacationplanner");
let timestamp = Utc::now();
for vacation in unique.into_values() {
// Vacationplanner's final day is inclusive; iCalendar's DTEND is exclusive.
let end = vacation.date_end.succ_opt().ok_or_else(Error::upstream)?;
let uid = format!(
"vacation-{}@{}",
vacation.id,
origin.origin().ascii_serialization().replace("://", "-")
);
let mut description = Vec::new();
if let Some(department) = vacation.department_name.filter(|value| !value.is_empty()) {
description.push(format!("Department: {}", text(&department)));
}
if let Some(comment) = vacation.comment.filter(|value| !value.is_empty()) {
description.push(text(&comment));
}
let status = match vacation.moderation_status.as_deref() {
Some("approved") => Some(EventStatus::Confirmed),
Some("on_moderation") => Some(EventStatus::Tentative),
Some("rejected") => Some(EventStatus::Cancelled),
_ => None,
};
if let Some(moderation) = vacation.moderation_status {
description.push(format!("Moderation: {}", text(&moderation)));
}
let mut event = Event::new();
event
.uid(&uid)
.timestamp(timestamp)
.starts(vacation.date_start)
.ends(end)
.summary(&format!("{} — Vacation", text(&vacation.worker_name)))
.description(&description.join("\n"))
.class(Class::Private);
if let Some(status) = status {
event.status(status);
}
calendar.push(event);
}
Ok(calendar.to_string())
}
+296
View File
@@ -0,0 +1,296 @@
mod calendar;
use std::{collections::BTreeSet, env, net::SocketAddr, sync::Arc, time::Duration};
use axum::{
Router,
extract::{Query, State, rejection::QueryRejection},
http::{HeaderValue, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
};
use chrono::{Datelike, Utc};
use reqwest::{Client, Url, redirect::Policy};
use serde::Deserialize;
use tokio::{net::TcpListener, sync::Semaphore, time::timeout};
const DEFAULT_API: &str = "https://api.vacationplanner.ru";
const MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024;
#[derive(Clone)]
struct App {
client: Client,
origin: Url,
requests: Arc<Semaphore>,
}
// Deliberately no Debug implementation: query parameters contain credentials.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Parameters {
token: Option<String>,
years: Option<String>,
}
struct Error(StatusCode, &'static str);
impl IntoResponse for Error {
fn into_response(self) -> Response {
(self.0, self.1).into_response()
}
}
impl Error {
fn invalid(message: &'static str) -> Self {
Self(StatusCode::BAD_REQUEST, message)
}
fn upstream() -> Self {
Self(
StatusCode::BAD_GATEWAY,
"Vacationplanner returned an invalid response",
)
}
}
fn origin(value: &str) -> Result<Url, Error> {
let url = Url::parse(value).map_err(|_| Error::invalid("url must be an API origin"))?;
if !matches!(url.scheme(), "https" | "http")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(Error::invalid(
"url must contain only a scheme, host and optional port",
));
}
Ok(url)
}
fn years(parameters: &Parameters) -> Result<BTreeSet<i32>, Error> {
if let Some(value) = parameters.years.as_ref() {
let years: BTreeSet<i32> = value
.split(',')
.map(|year| {
year.trim()
.parse::<i32>()
.ok()
.filter(|year| (1..=9998).contains(year))
.ok_or_else(|| {
Error::invalid("years must be comma-separated numbers from 1 to 9998")
})
})
.collect::<Result<_, _>>()?;
if years.len() > 10 {
return Err(Error::invalid("at most 10 distinct years are supported"));
}
Ok(years)
} else {
let current = Utc::now().year();
Ok(BTreeSet::from([current, current + 1]))
}
}
fn authorization(parameters: &Parameters) -> Result<HeaderValue, Error> {
let token = parameters.token.as_deref();
let token = token
.filter(|value| {
!value.is_empty()
&& value.len() <= 4096
&& value.bytes().all(|byte| byte.is_ascii_graphic())
})
.ok_or(Error(
StatusCode::UNAUTHORIZED,
"a nonempty API token is required",
))?;
let mut value = HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|_| Error::invalid("invalid token"))?;
value.set_sensitive(true);
Ok(value)
}
async fn fetch_year(
app: &App,
base: &Url,
auth: &HeaderValue,
year: i32,
) -> Result<Vec<calendar::Vacation>, Error> {
let mut url = base.clone();
url.set_path(&format!("/v1/vacations/year/{year}"));
let mut response = app
.client
.get(url)
.header(header::AUTHORIZATION, auth.clone())
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(upstream_error)?;
match response.status() {
StatusCode::OK => {}
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
return Err(Error(
response.status(),
"Vacationplanner rejected the API token",
));
}
StatusCode::TOO_MANY_REQUESTS => {
return Err(Error(
StatusCode::SERVICE_UNAVAILABLE,
"Vacationplanner rate limit reached",
));
}
_ => return Err(Error::upstream()),
}
if response
.content_length()
.is_some_and(|size| size > MAX_RESPONSE_BYTES as u64)
{
return Err(Error::upstream());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(upstream_error)? {
if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
return Err(Error::upstream());
}
bytes.extend_from_slice(&chunk);
}
serde_json::from_slice(&bytes).map_err(|_| Error::upstream())
}
fn upstream_error(error: reqwest::Error) -> Error {
// Never expose reqwest errors: they can contain upstream URLs or response data.
if error.is_timeout() {
Error(
StatusCode::GATEWAY_TIMEOUT,
"Vacationplanner request timed out",
)
} else {
Error::upstream()
}
}
async fn calendar(
State(app): State<App>,
parameters: Result<Query<Parameters>, QueryRejection>,
) -> Result<Response, Error> {
let Query(parameters) = parameters.map_err(|_| Error::invalid("invalid query parameters"))?;
let auth = authorization(&parameters)?;
let years = years(&parameters)?;
let _permit = app.requests.try_acquire().map_err(|_| {
Error(
StatusCode::SERVICE_UNAVAILABLE,
"service is busy; retry later",
)
})?;
let body = timeout(Duration::from_secs(60), async {
let mut vacations = Vec::new();
for year in years {
vacations.extend(fetch_year(&app, &app.origin, &auth, year).await?);
}
calendar::render(vacations, &app.origin)
})
.await
.map_err(|_| Error(StatusCode::GATEWAY_TIMEOUT, "calendar request timed out"))??;
Ok((
[
(header::CONTENT_TYPE, "text/calendar; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"inline; filename=vacations.ics",
),
],
body,
)
.into_response())
}
async fn private_response(request: axum::extract::Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response
}
async fn shutdown() {
let interrupt = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut signal) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
signal.recv().await;
} else {
std::future::pending::<()>().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { _ = interrupt => {}, _ = terminate => {} }
}
async fn run() -> Result<(), &'static str> {
let bind: SocketAddr = env::var("BIND_ADDR")
.unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()
.map_err(|_| "invalid BIND_ADDR")?;
let timeout_seconds: u64 = env::var("UPSTREAM_TIMEOUT_SECONDS")
.unwrap_or_else(|_| "15".into())
.parse()
.ok()
.filter(|seconds| (1..=60).contains(seconds))
.ok_or("UPSTREAM_TIMEOUT_SECONDS must be between 1 and 60")?;
let origin = origin(&env::var("API_URL").unwrap_or_else(|_| DEFAULT_API.into()))
.map_err(|_| "API_URL must contain only a scheme, host and optional port")?;
let client = Client::builder()
.redirect(Policy::none())
.referer(false)
.no_proxy()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(timeout_seconds))
.user_agent(concat!("vacationplanner2ics/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| "could not initialize HTTP client")?;
let app = App {
client,
origin,
requests: Arc::new(Semaphore::new(16)),
};
let router = Router::new()
.route("/calendar.ics", get(calendar))
.route("/healthz", get(|| async { "ok" }))
.layer(middleware::from_fn(private_response))
.with_state(app);
let listener = TcpListener::bind(bind)
.await
.map_err(|_| "could not bind BIND_ADDR")?;
eprintln!("vacationplanner2ics listening on {bind}");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown())
.await
.map_err(|_| "HTTP server failed")
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
match run().await {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(message) => {
eprintln!("{message}");
std::process::ExitCode::FAILURE
}
}
}