use super::parts::{R, rows_csv, sheet_row};
use super::*;
use crate::error::Error;
use crate::query::{Query, dimensions, is_dimension};
use crate::runlight::RETENTION_MONTHS;
use crate::store::ShareRow;
use crate::time::{buckets, is_timezone, local_weekday_hour};
use std::future::Future;
use std::task::Poll;
impl Routes {
pub(crate) async fn api(&self, request: &Request, path: &str, url: &mut Url, call: &Call) -> R {
let rl = self.rl().clone();
let method = request.method.as_str();
let scoped_account = self.session_scope(request).await?;
if let Some(user) = &scoped_account
&& let Some(site) = &user.site
{
rl.init().await?;
if rl.site(Some(site)).is_none() {
return Ok(coded("Unknown site", "unknown_site", 404, None));
}
let params = url.search_params();
let site_path = path.strip_prefix("/api/sites/").filter(|s| !s.contains('/'));
if params.get("site").is_some_and(|s| s != site)
|| site_path.is_some_and(|s| crate::sources::decode_uri_component(s).as_deref() != Some(site))
{
return Ok(coded("Unknown site", "unknown_site", 404, None));
}
let read = method == "GET" && shared_path(path);
let assistant =
(path == "/api/assistant" && method == "GET") || (path == "/api/assistant/chat" && method == "POST");
let manage = user.role == crate::accounts::Role::Member && manage_path(method, path);
if !read && !assistant && !manage {
return Ok(coded("Your account only has access to its assigned site", "site_access", 403, None));
}
if path == "/api/assistant/chat"
&& !request.json().is_ok_and(|body| body.get("site") == Some(&Value::String(site.clone())))
{
return Ok(coded("Unknown site", "unknown_site", 404, None));
}
if site_path.is_some()
&& is_json(request)
&& request.json().is_ok_and(|body| body.get("hostnames").is_some())
{
return Ok(coded("Your account cannot change a site's domains", "site_domains", 403, None));
}
let mut params = url.search_params();
params.set("site", site);
url.set_search_params(¶ms);
}
if request.headers.get(EMBED_HEADER).is_some() && !(method == "GET" && shared_path(path)) {
return Ok(coded("Not available on a shared dashboard", "share_not_available", 403, None));
}
if !["GET", "HEAD", "OPTIONS", "DELETE"].contains(&method) && bearer(request).is_empty() && !is_json(request) {
return Ok(coded("Send JSON", "send_json", 415, None));
}
if path == "/api" && method == "GET" {
return Ok(json(
&obj! { "name" => "runlight", "version" => glue::VERSION, "api" => glue::API_VERSION, "library" => "runlight", "language" => "rust" },
200,
&[],
));
}
if path == "/api/token" && method == "GET" {
let Some(token) = self.api_token(request).await? else { return Ok(self.denied(CanRead::No)) };
return Ok(json(&obj! { "scope" => token.scope, "site" => token.site }, 200, &[]));
}
if path == "/api/token" && method == "DELETE" {
let Some(token) = self.api_token(request).await? else { return Ok(self.denied(CanRead::No)) };
rl.store().delete_token(&token.id).await?;
return Ok(json(&obj! { "ok" => true }, 200, &[]));
}
if path == "/api/sites/connect" && method == "POST" {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
rl.init().await?;
if !rl.managed_sites() {
return Ok(coded("Sites are set in code", "sites_in_code", 400, None));
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
let done = format!("{}{}/api/sites/connect/done", url.origin(), self.0.base);
return glue::start_connect(self, &body, &done).await;
}
if path == "/api/sites/connect/done" && method == "GET" {
let home = if self.0.base.is_empty() { "/".to_string() } else { self.0.base.clone() };
let access = self.can_read(request, call).await;
let see_other = |to: String| {
Response::new(Vec::new(), 303, Headers::new().with("location", to).with("cache-control", "no-store"))
};
if access != CanRead::Yes {
return Ok(see_other(home));
}
rl.init().await?;
let to = match glue::finish_connect(self, url).await? {
Ok(id) => {
format!("{home}?site={}&settings=general&connected=1", crate::sources::encode_uri_component(&id))
}
Err(code) => format!("{home}?connect_error={code}"),
};
return Ok(see_other(to));
}
if path == "/api/embed" && method == "POST" {
let Some(token) = self.api_token(request).await? else { return Ok(self.denied(CanRead::No)) };
if token.scope != "embed" {
return Ok(coded("Use the site's dashboard key, made in Settings, Install", "embed_token", 403, None));
}
let Some(site) = rl.site(Some(&token.site)) else {
return Ok(coded("Unknown site", "unknown_site", 404, None));
};
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
let origin = js::str_or_empty(body.get("origin"));
let parsed = (test(js_re!(r"^https?://[^/?#\s]+$"), &origin)
&& !origin.chars().any(js::is_space)
&& js::len16(&origin) <= 200)
.then(|| Url::parse(&origin))
.flatten()
.filter(|u| u.origin() == origin)
.filter(|u| {
matches!(u.hostname().as_str(), "localhost" | "127.0.0.1" | "[::1]")
|| (u.protocol() == "https:" && u.port().is_empty())
});
let Some(parsed) = parsed else {
return Ok(coded(
"Send the admin page's origin, such as https://example.com",
"embed_origin",
400,
None,
));
};
let hostname = parsed.hostname();
let host = hostname.strip_prefix("www.").unwrap_or(&hostname).to_string();
let domains = rl.remote(&site.id).map_or(site.hostnames.clone(), |r| r.hostnames);
if !domains.iter().any(|d| host_name(d) == host) {
return Ok(coded(
&format!(
"{host} is not one of this site's domains. Add it to the site's domains in Runlight's settings."
),
"embed_host",
400,
Some(&[("host", host.as_str())]),
));
}
let (ticket, expires_at) = self.embed_ticket(&origin, &token.id).await?;
let path = format!("{}/embed?ticket={ticket}", self.0.base);
return Ok(json(
&obj! { "ticket" => ticket, "site" => site.id.clone(), "expiresAt" => expires_at, "path" => path },
201,
&[],
));
}
let token = if bearer(request).starts_with(TOKEN_PREFIX) { self.api_token(request).await? } else { None };
if token.as_ref().is_some_and(|t| t.scope == "embed") {
return Ok(coded("This key only opens the dashboard inside a CMS", "token_embed_only", 403, None));
}
if let Some(t) = &token
&& t.scope == "manage"
&& manage_path(method, path)
{
let asked = url.search_params().get("site").map(str::to_string);
let site_match = crate::re::group(js_re!(r"^/api/sites/([^/]+)$"), path, 1);
let named = site_match.as_deref().map(crate::sources::decode_uri_component);
if asked.as_deref().is_some_and(|a| !a.is_empty() && a != t.site)
|| named.is_some_and(|n| n.as_deref() != Some(t.site.as_str()))
{
return Ok(coded("Unknown site", "unknown_site", 404, None));
}
if site_match.is_some()
&& is_json(request)
&& let Ok(body) = request.json()
&& body.is_object()
&& body.get("hostnames").is_some()
{
return Ok(coded("A connected hub cannot change a site's domains", "hub_domains", 403, None));
}
let mut params = url.search_params();
params.set("site", &t.site);
url.set_search_params(¶ms);
*call.managed.lock().unwrap_or_else(|e| e.into_inner()) = Some(t.clone());
}
if let Some(t) = &token
&& !call.is_managed()
&& !["GET", "HEAD", "OPTIONS"].contains(&method)
{
return Ok(if t.scope == "manage" {
coded("A manage token changes only its own site's settings", "token_manage_only", 403, None)
} else {
coded("API tokens can only read", "token_read_only", 403, None)
});
}
if path == "/api/observe" && method == "POST" {
return self.observe_api(request, call).await;
}
if path == "/api/check" && (method == "POST" || method == "GET") {
let given = bearer(request);
let by_secret =
self.0.cron_secret.as_deref().is_some_and(|s| !given.is_empty() && constant_time_equal(&given, s));
if !by_secret && self.can_read(request, call).await != CanRead::Yes {
return Ok(coded("Unauthorized", "unauthorized", 401, None));
}
return Ok(json(&rl.check().await?, 200, &[]));
}
let asked = url.search_params().get("site").map(str::to_string);
let connected = asked.as_deref().filter(|a| !a.is_empty()).and_then(|a| rl.remote(a));
if let Some(remote) = &connected {
if remote.scope.as_deref() == Some("manage")
&& manage_path(method, path)
&& !(method == "GET" && shared_path(path))
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
if method != "GET" {
rl.forget_remote_info(asked.as_deref().unwrap_or(""));
}
return self.pass_through(remote, path, url, Some(request)).await;
}
if !(method == "GET" && (shared_path(path) || path == "/api/links")) {
return Ok(coded(
"This site is counted by its own Runlight. Connect it again from its settings to change it from here.",
"site_remote",
400,
None,
));
}
}
if (path == "/api/import/umami/websites" || path == "/api/import/umami/visits") && method == "POST" {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
return glue::umami_import(self, path, url, &body).await;
}
if path == "/api/import/csv/visits" && method == "POST" {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
rl.init().await?;
let site = match self.query_site(url) {
Ok(s) => s,
Err(r) => return Ok(r),
};
return glue::csv_import(self, &site.id, &body).await;
}
if (path == "/api/observe-key" && method == "GET") || (path == "/api/observe-key/new" && method == "POST") {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
rl.init().await?;
let site = match self.query_site(url) {
Ok(s) => s,
Err(r) => return Ok(r),
};
let key = crate::oauth::agents_key(&rl, &site.id, path.ends_with("/new")).await?;
return Ok(json(&obj! { "key" => key }, 200, &[]));
}
if (path == "/api/funnels" && method == "POST")
|| (test(js_re!(r"^/api/funnels/[a-f0-9]{24}$"), path) && (method == "PATCH" || method == "DELETE"))
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
rl.init().await?;
let site = match self.query_site(url) {
Ok(s) => s,
Err(r) => return Ok(r),
};
let existing = rl.store().funnels(&site.id).await?;
let id = if path == "/api/funnels" { None } else { Some(path["/api/funnels/".len()..].to_string()) };
if let Some(id) = &id
&& !existing.iter().any(|f| &f.id == id)
{
return Ok(coded("Unknown funnel", "unknown_funnel", 404, None));
}
if method == "DELETE" {
rl.store().delete_funnel(id.as_deref().unwrap_or("")).await?;
return Ok(json(&obj! { "ok" => true }, 200, &[]));
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
return Ok(match crate::funnels::funnel_from(&body, &site.id, &existing, rl.now(), id.as_deref()) {
Ok(funnel) => {
rl.store().save_funnel(&funnel).await?;
json(&obj! { "funnel" => funnel.to_value() }, if id.is_some() { 200 } else { 201 }, &[])
}
Err(e) => refused(&e, 400),
});
}
if path.starts_with("/api/assistant")
&& let Some(answer) = glue::assistant_api(self, request, path, url, call).await?
{
return Ok(answer);
}
if path == "/api/tokens" || path.starts_with("/api/tokens/") {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
return self.tokens_api(request, path).await;
}
if let Some(remote) = &connected
&& path == "/api/links"
{
let reader = self.reader(request, call).await?;
if matches!(reader, Reader::No | Reader::Unconfigured) {
return Ok(self.denied_reader(&reader));
}
if let Reader::Token(t) = &reader
&& !t.site.is_empty()
&& Some(t.site.as_str()) != asked.as_deref()
{
return Ok(coded("Unknown site", "unknown_site", 404, None));
}
return self.pass_through(remote, path, url, None).await;
}
if method == "GET" && (path == "/api/links" || test(js_re!(r"^/api/links/[a-f0-9]+$"), path)) {
let reader = self.reader(request, call).await?;
if matches!(reader, Reader::No | Reader::Unconfigured) {
return Ok(self.denied_reader(&reader));
}
if let Reader::Token(t) = &reader {
rl.init().await?;
let wanted = url
.search_params()
.get("site")
.map(str::to_string)
.or_else(|| (!t.site.is_empty()).then(|| t.site.clone()));
let site = rl.site(wanted.as_deref());
let Some(site) = site.filter(|s| t.site.is_empty() || s.id == t.site) else {
return Ok(coded("Unknown site", "unknown_site", 404, None));
};
let mut scoped = url.clone();
let mut params = scoped.search_params();
params.set("site", &site.id);
scoped.set_search_params(¶ms);
return self.links_api(request, path, &scoped, call).await;
}
}
if path == "/api/links"
|| path.starts_with("/api/links/")
|| path == "/api/link-domains"
|| path.starts_with("/api/link-domains/")
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
return self.links_api(request, path, url, call).await;
}
if path == "/api/mail"
|| path == "/api/mail/test"
|| path == "/api/reports"
|| path.starts_with("/api/reports/")
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
return self.mail_api(request, path, url, call).await;
}
if path == "/api/pick" && method == "POST" {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
rl.init().await?;
let site = match self.query_site(url) {
Ok(s) => s,
Err(r) => return Ok(r),
};
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
let origin = js::str_or_empty(body.get("origin"));
if !test(js_re!(r"^https?://[^/?#\s]+$"), &origin) || origin.chars().any(js::is_space) {
return Ok(coded(
"Send the dashboard's origin, such as https://stats.example.com",
"pick_origin",
400,
None,
));
}
let back = if body.get("return").is_some() || body.get("nonce").is_some() {
let address = js::str_or_empty(body.get("return"));
let nonce = js::str_or_empty(body.get("nonce"));
if address.len() > 1024
|| !crate::routes::is_pick_return(&address, &origin)
|| !test(js_re!(r"^[a-f0-9]{32}$"), &nonce)
{
return Ok(coded(
"Send the dashboard's address with /pick-return on the end, on its origin, and a nonce of 32 hex digits",
"pick_return",
400,
None,
));
}
Some((address, nonce))
} else {
None
};
if let Some(hub) = call.managed()
&& rl.store().setting(&format!("token-origin:{}", hub.id)).await?.as_deref() != Some(origin.as_str())
{
return Ok(coded(
"This hub's address is not the one it connected from. Connect the site again from here.",
"pick_hub",
403,
None,
));
}
let back = back.as_ref().map(|(address, nonce)| (address.as_str(), nonce.as_str()));
return Ok(json(&obj! { "ticket" => self.pick_ticket(&origin, &site.id, back).await? }, 200, &[]));
}
if (path == "/api/goals" && method == "POST")
|| (test(js_re!(r"^/api/goals/[^/]+$"), path) && (method == "PATCH" || method == "DELETE"))
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
return self.goal_writes(request, path, url).await;
}
if path == "/api/shares" || path.starts_with("/api/shares/") {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
return self.shares_api(request, path, url).await;
}
if path == "/api/sites" && method == "POST" {
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
return Ok(match rl.add_site(&body).await {
Ok(site) => json(&obj! { "site" => site.to_value() }, 201, &[]),
Err(Error::Settings(e)) => refused(&e, 400),
Err(Error::Range(m)) => refused_plain(&m, "site_invalid", 400),
Err(e) => return Err(e),
});
}
let site_match = crate::re::group(js_re!(r"^/api/sites/([^/]+)$"), path, 1);
if let Some(raw) = &site_match
&& method == "DELETE"
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
let Some(id) = crate::sources::decode_uri_component(raw) else {
return Ok(coded("Unknown site", "unknown_site", 404, None));
};
if let Some(web) = &self.0.web
&& self.session_scope(request).await?.is_some()
&& let Some(refused) = web.confirm_password(request).await?
{
return Ok(refused);
}
return Ok(match rl.delete_site(&id).await {
Ok(()) => {
if let Some(web) = &self.0.web {
web.accounts().forget_site(&id).await?;
}
json(&obj! { "ok" => true }, 200, &[])
}
Err(Error::Settings(e)) if e.message == "Unknown site" => coded(&e.message, "unknown_site", 404, None),
Err(Error::Settings(e)) => refused(&e, 400),
Err(e) => return Err(e),
});
}
if let Some(raw) = &site_match
&& method == "PATCH"
{
let access = self.can_read(request, call).await;
if access != CanRead::Yes {
return Ok(self.denied(access));
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
return self.patch_site(request, raw, &body, url).await;
}
if method != "GET" {
return Ok(coded("Method not allowed", "method_not_allowed", 405, None));
}
rl.init().await?;
let share_id = request.headers.get(SHARE_HEADER);
let mut shared: Option<ShareRow> = None;
let mut only: Option<String> = scoped_account.as_ref().and_then(|u| u.site.clone());
if let Some(share_id) = share_id {
shared = if test(js_re!(r"^[a-f0-9]{32}$"), &share_id) {
rl.store().share_by_id(&share_id).await?
} else {
None
};
let Some(share) = &shared else {
return Ok(coded("This share link no longer works", "share_gone", 404, None));
};
if !shared_path(path) {
return Ok(coded("Not available on a shared dashboard", "share_not_available", 403, None));
}
only = Some(share.site.clone());
} else if let Some(session) = request.headers.get(EMBED_HEADER) {
let Some(token) = self.embed_reader(&session).await? else {
return Ok(coded(
"This dashboard has expired. Reload the page to open it again.",
"embed_expired",
401,
None,
));
};
shared = Some(ShareRow { id: String::new(), site: token.site.clone(), name: String::new(), created_at: 0 });
only = Some(token.site);
} else {
let reader = self.reader(request, call).await?;
match &reader {
Reader::No | Reader::Unconfigured => return Ok(self.denied_reader(&reader)),
Reader::Token(t) => {
if !shared_path(path) {
return Ok(coded("API tokens can only read", "token_read_only", 403, None));
}
only = scoped_account
.as_ref()
.and_then(|u| u.site.clone())
.or_else(|| (!t.site.is_empty()).then(|| t.site.clone()));
}
Reader::Owner => {}
}
}
if path == "/api/sites" {
return self
.sites_list(
only.as_deref(),
shared.is_some(),
scoped_account.as_ref().is_some_and(|u| u.site.is_some()),
)
.await;
}
let site = if let Some(share) = &shared {
rl.site(Some(&share.site))
} else if let Some(only) = &only {
rl.site(Some(url.search_params().get("site").unwrap_or(only)))
} else {
match self.query_site(url) {
Ok(s) => Some(s),
Err(r) => return Ok(r),
}
};
let Some(site) = site.filter(|s| only.as_ref().is_none_or(|o| &s.id == o)) else {
return Ok(coded("Unknown site", "unknown_site", 404, None));
};
let unfeed = |mut live: Value| {
if shared.is_some()
&& let Value::Object(fields) = &mut live
{
fields.set("recent", Value::Array(vec![]));
}
live
};
if let Some(remote) = rl.remote(&site.id) {
let answer = self.pass_through(&remote, path, url, Some(request)).await?;
if path != "/api/realtime" || shared.is_none() || !answer.ok() {
return Ok(answer);
}
return Ok(match answer.json_body() {
Ok(live @ Value::Object(_)) => json(&unfeed(live), 200, &[]),
_ => {
let host = Url::parse(&remote.url).map(|u| u.host()).unwrap_or_default();
coded(&format!("Could not reach {host}"), "unreachable", 502, Some(&[("host", &host)]))
}
});
}
if path == "/api/icon" {
let icon = match site.hostnames.first() {
Some(host) => glue::fetch_icon(&rl, &format!("https://{host}")).await,
None => None,
};
let Some((body, kind)) = icon else {
return Ok(coded_with(
"No icon",
"icon_none",
404,
None,
&[("cache-control", "private, max-age=3600")],
));
};
return Ok(Response::new(
body,
200,
Headers::new()
.with("content-type", kind)
.with("cache-control", "private, max-age=86400")
.with("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
.with("x-content-type-options", "nosniff"),
));
}
if path == "/api/realtime" {
return Ok(json(&unfeed(rl.store().realtime(&site.id, rl.now()).await?), 200, &[]));
}
rl.freshen_statistics().await;
let read = match self.read_query(url, &site).await? {
Ok(r) => r,
Err(r) => return Ok(r),
};
let query = &read.query;
let range = &read.range;
let compared = &read.compared;
let range_out = obj! { "from" => range.from_date.clone(), "to" => range.to_date.clone(), "interval" => range.interval, "timezone" => site.timezone.clone() };
let compare_out = compared.as_ref().map(|c| obj! { "from" => c.from_date.clone(), "to" => c.to_date.clone() });
let before = |c: &crate::time::Range| Query { from: c.from, to: c.to, ..query.clone() };
let store = rl.store();
if path == "/api/stats" {
let earlier = compared.as_ref().map(before);
let (stats, previous) = both(store.stats(query), async {
match &earlier {
Some(q) => store.stats(q).await.map(Some),
None => Ok(None),
}
})
.await;
let (stats, previous) = (stats?, previous?);
return Ok(json(
&with_optional(
&site.id,
range_out,
compare_out,
vec![("stats", Some(stats.to_value())), ("previous", previous.map(|p| p.to_value()))],
),
200,
&[],
));
}
if path == "/api/goals" {
if url.search_params().get("definitions") == Some("1") {
let definitions = store.goals(Some(&site.id)).await?;
return Ok(json(
&obj! { "site" => site.id.clone(), "goals" => Value::Array(definitions.iter().map(|item| item.to_value()).collect()) },
200,
&[],
));
}
let goals = store.goals(Some(&site.id)).await?;
let earlier = compared.as_ref().map(before);
let ((visitors, previous_visitors), (now_all, before_all)) = both(
both(store.visitors(query), async {
match &earlier {
Some(q) => store.visitors(q).await,
None => Ok(0.0),
}
}),
both(store.goal_totals_all(query, &goals), async {
match &earlier {
Some(q) => store.goal_totals_all(q, &goals).await.map(Some),
None => Ok(None),
}
}),
)
.await;
let (visitors, previous_visitors, now_all, before_all) =
(visitors?, previous_visitors?, now_all?, before_all?);
let rows: Vec<Value> = goals
.iter()
.map(|goal| {
let now = now_all.get(&goal.id).copied().unwrap_or_default();
let Value::Object(mut o) = goal.to_value() else { unreachable!() };
o.set("conversions", now.conversions);
o.set("visitors", now.visitors);
o.set("revenue", now.revenue);
o.set("rate", if visitors != 0.0 { now.visitors / visitors } else { 0.0 });
if let Some(b) = before_all.as_ref().and_then(|m| m.get(&goal.id)) {
o.set(
"previous",
obj! {
"conversions" => b.conversions, "visitors" => b.visitors, "revenue" => b.revenue,
"rate" => if previous_visitors != 0.0 { b.visitors / previous_visitors } else { 0.0 },
},
);
}
Value::Object(o)
})
.collect();
let mut out = with_optional(&site.id, range_out, compare_out, vec![]);
let o = out.as_object_mut().expect("an object");
o.set("visitors", visitors);
o.set("goals", Value::Array(rows));
return Ok(json(&out, 200, &[]));
}
if let Some(goal_id) = crate::re::group(js_re!(r"^/api/goals/([a-f0-9]{24})$"), path, 1) {
let goal = store.goal_by_id(&goal_id).await?;
let Some(goal) = goal.filter(|g| g.site == site.id) else {
return Ok(coded("Unknown goal", "unknown_goal", 404, None));
};
let visitors = store.visitors(query).await?;
let totals = store.goal_totals(query, &goal).await?;
let series = store.goal_series(&query.site, &query.filters, &goal, &buckets(range, &site.timezone)).await?;
let by = |rows: Vec<(String, crate::store::GoalTotals)>| {
Value::Array(
rows.into_iter()
.map(|(value, t)| obj! { "value" => value, "conversions" => t.conversions, "visitors" => t.visitors, "revenue" => t.revenue })
.collect(),
)
};
let sources = by(store.goal_breakdown(query, &goal, "source", 10).await?);
let channels = by(store.goal_breakdown(query, &goal, "channel", 10).await?);
let pages = by(store.goal_breakdown(query, &goal, "path", 10).await?);
return Ok(json(
&obj! {
"site" => site.id.clone(),
"range" => range_out,
"goal" => goal.to_value(),
"totals" => obj! {
"conversions" => totals.conversions, "visitors" => totals.visitors, "revenue" => totals.revenue,
"rate" => if visitors != 0.0 { totals.visitors / visitors } else { 0.0 },
},
"series" => Value::Array(series.into_iter().map(|(start, conversions, revenue)| obj! { "start" => start, "conversions" => conversions, "revenue" => revenue }).collect()),
"sources" => sources,
"channels" => channels,
"pages" => pages,
},
200,
&[],
));
}
if path == "/api/series" {
let (points, earlier) =
both(store.series(&query.site, &query.filters, &buckets(range, &site.timezone)), async {
match compared {
Some(c) => {
store.series(&query.site, &query.filters, &buckets(c, &site.timezone)).await.map(Some)
}
None => Ok(None),
}
})
.await;
let points = points?;
let previous = earlier?.map(|mut p| {
p.truncate(points.len());
Value::Array(p)
});
return Ok(json(
&with_optional(
&site.id,
range_out,
compare_out,
vec![("points", Some(Value::Array(points))), ("previous", previous)],
),
200,
&[],
));
}
if path == "/api/rhythm" {
let mut grid = [[0.0f64; 24]; 7];
let mut cells = [[[0.0f64; 4]; 24]; 7];
for (quarter, visits, visitors, pageviews, bounced) in store.hourly(query).await? {
let (weekday, h) = local_weekday_hour(quarter * 900_000, &site.timezone);
let (w, h) = (weekday as usize, h as usize);
grid[w][h] += visits;
cells[w][h][0] += visits;
cells[w][h][1] += visitors;
cells[w][h][2] += pageviews;
cells[w][h][3] += bounced;
}
let grid: Vec<Value> =
grid.iter().map(|day| Value::Array(day.iter().map(|n| Value::Number(*n)).collect())).collect();
let details: Vec<Value> = cells
.iter()
.map(|day| {
Value::Array(
day.iter()
.map(|c| obj! { "visits" => c[0], "visitors" => c[1], "pageviews" => c[2], "bounceRate" => if c[0] != 0.0 { c[3] / c[0] } else { 0.0 } })
.collect(),
)
})
.collect();
return Ok(json(
&obj! { "site" => site.id.clone(), "range" => range_out, "grid" => Value::Array(grid), "cells" => Value::Array(details) },
200,
&[],
));
}
if path == "/api/journeys" {
let q = url.search_params();
let through = js_re!(r"^(\d+):([^\n\r]+)$").captures(q.get("through").unwrap_or("").as_bytes()).map(|c| {
(js::text_number(&String::from_utf8_lossy(&c[1])), String::from_utf8_lossy(&c[2]).into_owned())
});
let (rows, sampled) = store.journey_pages(query, crate::journeys::PAGES_PER_VISIT).await?;
let options = crate::journeys::JourneyOptions {
steps: q.get("steps").map_or(5.0, js::text_number),
start: q.get("start").filter(|s| !s.is_empty()).map(recorded),
end: q.get("end").filter(|s| !s.is_empty()).map(recorded),
through: through.map(|(step, value)| (step, recorded(&value))),
};
let answer = crate::journeys::journeys(&rows, &options);
let mut out = obj! { "site" => site.id.clone(), "range" => range_out };
let o = out.as_object_mut().expect("an object");
if let Some(a) = answer.as_object() {
for (k, v) in a.iter() {
o.set(k, v.clone());
}
}
if sampled {
o.set("sampled", crate::store::JOURNEY_VISITS);
}
return Ok(json(&out, 200, &[]));
}
if path == "/api/funnels" {
if url.search_params().get("definitions") == Some("1") {
let definitions = store.funnels(&site.id).await?;
return Ok(json(
&obj! { "site" => site.id.clone(), "funnels" => Value::Array(definitions.iter().map(|item| item.to_value()).collect()) },
200,
&[],
));
}
let mut rows = Vec::new();
for funnel in store.funnels(&site.id).await? {
let counts = store.funnel_counts(query, &funnel).await?;
let steps: Vec<Value> = funnel
.steps
.iter()
.enumerate()
.map(|(i, s)| obj! { "kind" => s.kind.clone(), "match" => s.match_.clone(), "visits" => counts.get(i).copied() })
.collect();
let Value::Object(mut o) = funnel.to_value() else { unreachable!() };
o.set("steps", Value::Array(steps));
rows.push(Value::Object(o));
}
return Ok(json(
&obj! { "site" => site.id.clone(), "range" => range_out, "funnels" => Value::Array(rows) },
200,
&[],
));
}
if path == "/api/event-props" {
let params = url.search_params();
let event = params.get("event").unwrap_or("").to_string();
if event.is_empty() {
return Ok(coded("Name the event", "event_needed", 400, None));
}
let keys = store.event_prop_keys(query, &event).await?;
let asked = params.get("key").map(str::to_string);
let safe = |name: &str| (1..=64).contains(&js::len16(name)) && !name.contains(['"', '\\']);
if asked.as_deref().is_some_and(|a| !safe(a)) {
return Ok(coded("Bad property name", "property_bad", 400, None));
}
let key = asked.or_else(|| keys.iter().find(|k| safe(&k.0)).map(|k| k.0.clone()));
let limit = whole(params.get("limit"), 100.0, 1000.0);
let rows = match &key {
Some(k) if !k.is_empty() => store.event_prop_values(query, &event, k, limit as i64).await?,
_ => vec![],
};
return Ok(json(
&obj! {
"site" => site.id.clone(),
"range" => range_out,
"event" => event,
"keys" => Value::Array(keys.into_iter().map(|(key, events)| obj! { "key" => key, "events" => events }).collect()),
"key" => key,
"rows" => Value::Array(rows.into_iter().map(|(value, events, visitors)| obj! { "value" => value, "events" => events, "visitors" => visitors }).collect()),
},
200,
&[],
));
}
if path == "/api/breakdown" {
let params = url.search_params();
let dimension = params.get("dimension").unwrap_or("").to_string();
if !is_dimension(&dimension) {
return Ok(coded(
&format!("Unknown dimension \"{dimension}\""),
"unknown_dimension",
400,
Some(&[("dimension", &dimension)]),
));
}
let limit = whole(params.get("limit"), 10.0, 1000.0);
let page = whole(params.get("page"), 1.0, 100_000.0);
if dimension == "hostname" && params.get("suggestions") == Some("1") && params.get("format") != Some("csv")
{
let rows = store.hostname_suggestions(query, (limit as i64).min(50)).await?;
return Ok(json(
&obj! { "site" => site.id.clone(), "range" => range_out, "dimension" => dimension, "rows" => Value::Array(rows) },
200,
&[],
));
}
let rows = store.breakdown(query, &dimension, limit as i64, ((page - 1.0) * limit) as i64).await?;
if params.get("format") == Some("csv") {
return Ok(download(
&format!("{}-{dimension}-{}-{}.csv", site.id, range.from_date, range.to_date),
rows_csv(&rows, &site.timezone, None, Some(&dimension)).into_bytes(),
"text/csv; charset=utf-8",
));
}
return Ok(json(
&obj! { "site" => site.id.clone(), "range" => range_out, "dimension" => dimension, "rows" => Value::Array(rows) },
200,
&[],
));
}
if path == "/api/export" {
let mut files: Vec<(String, String)> = Vec::new();
let stats = store.stats(query).await?;
let previous = match compared {
Some(c) => Some(store.stats(&before(c)).await?),
None => None,
};
let now = sheet_row(&stats.to_value(), &site.timezone, None, None);
let was = previous.map(|p| sheet_row(&p.to_value(), &site.timezone, None, None));
let mut header = vec!["metric", "value"];
if was.is_some() {
header.push("previous");
}
let rows: Vec<Vec<String>> = now
.iter()
.map(|(m, v)| {
let mut row = vec![m.to_string(), crate::zip::cell(v)];
if let Some(w) = &was {
row.push(w.get(m).map_or_else(String::new, crate::zip::cell));
}
row
})
.collect();
files.push(("overview.csv".into(), crate::zip::csv(&header, &rows)));
let points = store.series(&query.site, &query.filters, &buckets(range, &site.timezone)).await?;
files.push(("over-time.csv".into(), rows_csv(&points, &site.timezone, Some(range.interval), None)));
for dimension in dimensions() {
let rows = store.breakdown(query, dimension, 1000, 0).await?;
if !rows.is_empty() {
files.push((format!("{dimension}.csv"), rows_csv(&rows, &site.timezone, None, Some(dimension))));
}
}
let goals = store.goals(Some(&site.id)).await?;
if !goals.is_empty() {
let totals = store.goal_totals_all(query, &goals).await?;
let rows: Vec<Vec<String>> = goals
.iter()
.map(|g| {
let t = totals.get(&g.id).copied().unwrap_or_default();
vec![
g.name.clone(),
js::format_number(t.conversions),
js::format_number(t.visitors),
js::format_number(t.revenue),
g.currency.clone(),
]
})
.collect();
files.push((
"goals.csv".into(),
crate::zip::csv(&["goal", "conversions", "visitors", "revenue", "currency"], &rows),
));
}
return Ok(download(
&format!("{}-{}-{}.zip", site.id, range.from_date, range.to_date),
crate::zip::zip(&files, rl.now()),
"application/zip",
));
}
Ok(coded("Not found", "not_found", 404, None))
}
async fn sites_list(&self, only: Option<&str>, shared: bool, scoped: bool) -> R {
let rl = self.rl();
let visible: Vec<crate::store::SiteRow> =
rl.sites().into_iter().filter(|s| only.is_none_or(|o| s.id == o)).collect();
let mut sites = Vec::new();
for site in visible {
let Value::Object(mut o) = site.to_value() else { unreachable!() };
let remote = rl.remote(&site.id);
if let Some(r) = remote.as_ref().filter(|_| !shared) {
o.set("remote", r.url.clone());
o.set("remoteSite", r.site.clone());
o.set("manage", r.scope.as_deref() == Some("manage"));
o.set("hostnames", Value::Array(r.hostnames.iter().map(|h| Value::from(h.as_str())).collect()));
}
if shared {
o.set("hostnames", Value::Array(vec![]));
}
let last_seen: Value = if remote.is_some() {
rl.remote_last_seen(&site.id).await.into()
} else {
rl.store().last_seen(&site.id).await?.into()
};
o.set("lastSeen", last_seen);
if !shared {
if remote.is_some() {
if let Some(Some(months)) = rl.remote_info(&site.id).await.and_then(|i| i.retention_months) {
o.set("retentionMonths", months);
} else if let Some(Some(None)) = rl.remote_info(&site.id).await.map(|i| i.retention_months) {
o.set("retentionMonths", Value::Null);
}
} else {
o.set("retentionMonths", rl.retention(&site.id).await?);
}
}
if remote.is_some()
&& !shared
&& let Some(info) = rl.remote_info(&site.id).await
{
o.set("connection", info.connection);
}
sites.push(Value::Object(o));
}
Ok(json(
&if shared {
obj! { "sites" => Value::Array(sites) }
} else {
obj! { "sites" => Value::Array(sites), "managed" => !scoped && rl.managed_sites() }
},
200,
&[],
))
}
async fn patch_site(&self, request: &Request, raw: &str, body: &Value, url: &Url) -> R {
let rl = self.rl();
rl.init().await?;
if let Some(name) = body.get("name") {
let n = js::trim(&js::js_string(name)).to_string();
if n.is_empty() || js::len16(&n) > 80 {
return Ok(coded("A site name is 1 to 80 characters", "site_name", 400, None));
}
}
if let Some(tz) = body.get("timezone") {
let tz = js::js_string(tz);
if !is_timezone(&tz) {
return Ok(coded(
&format!("Unknown timezone \"{tz}\""),
"unknown_timezone",
400,
Some(&[("timezone", &tz)]),
));
}
}
let retention = body.get("retentionMonths");
if let Some(r) = retention
&& !r.is_null()
&& !RETENTION_MONTHS.iter().any(|m| *m as f64 == js::js_number(r))
{
return Ok(coded(
"Keep visits for 6, 12, 24, 36, 60 months, or forever",
"retention_bad",
400,
Some(&[("months", "6, 12, 24, 36, 60")]),
));
}
let Some(id) = crate::sources::decode_uri_component(raw) else {
return Ok(coded("Unknown site", "unknown_site", 404, None));
};
let result = async {
let remote = rl.remote(&id);
let mut forward = js::Object::new();
if let Some(r) = retention {
forward.set("retentionMonths", r.clone());
}
if let Some(tz) = body.get("timezone")
&& Some(js::js_string(tz)) != rl.site(Some(&id)).map(|s| s.timezone)
{
forward.set("timezone", js::js_string(tz));
}
if let Some(remote) = &remote
&& !forward.is_empty()
{
if remote.scope.as_deref() != Some("manage") {
return Ok(Err(coded(
"Connect this site again to change it from here",
"connect_again",
400,
None,
)));
}
let forwarded = Request::new("PATCH", request.url.clone())
.header("content-type", "application/json")
.body(forward.to_json());
let answer = self
.pass_through(
remote,
&format!("/api/sites/{}", crate::sources::encode_uri_component(&remote.site)),
url,
Some(&forwarded),
)
.await?;
if !answer.ok() {
return Ok(Err(answer));
}
rl.forget_remote_info(&id);
}
let mut patch = js::Object::new();
if remote.is_none()
&& let Some(r) = retention
{
patch.set("retentionMonths", r.clone());
}
if let Some(name) = body.get("name") {
patch.set("name", js::js_string(name));
}
if let Some(tz) = body.get("timezone") {
patch.set("timezone", js::js_string(tz));
}
if let Some(h) = body.get("hostnames")
&& rl.managed_sites()
{
patch.set("hostnames", h.clone());
}
let site = rl.update_site(&id, &Value::Object(patch)).await?;
let Value::Object(mut o) = site.to_value() else { unreachable!() };
if let Some(r) = &remote {
o.set("remote", r.url.clone());
o.set("remoteSite", r.site.clone());
o.set("manage", r.scope.as_deref() == Some("manage"));
o.set("hostnames", Value::Array(r.hostnames.iter().map(|h| Value::from(h.as_str())).collect()));
}
Ok::<Result<Response, Response>, Error>(Ok(json(&obj! { "site" => Value::Object(o) }, 200, &[])))
}
.await;
match result {
Ok(Ok(r)) | Ok(Err(r)) => Ok(r),
Err(Error::Settings(e)) if e.message == "Unknown site" => Ok(coded(&e.message, "unknown_site", 404, None)),
Err(Error::Settings(e)) => Ok(refused(&e, 400)),
Err(Error::Range(m)) if m == "Unknown site" => Ok(coded(&m, "unknown_site", 404, None)),
Err(e) => Err(e),
}
}
async fn observe_api(&self, request: &Request, call: &Call) -> R {
let rl = self.rl();
let given = bearer(request);
let any_site =
self.0.observe_key.as_deref().is_some_and(|k| !given.is_empty() && constant_time_equal(&given, k))
|| self.can_read(request, call).await == CanRead::Yes;
if !any_site && given.is_empty() {
return Ok(coded("Unauthorized", "unauthorized", 401, None));
}
let mut key_site: Option<String> = None;
if !any_site {
if !given.starts_with("rlo_") {
return Ok(coded("Unauthorized", "unauthorized", 401, None));
}
rl.init().await?;
for (key, value) in rl.store().settings_starting_with("observe-key:").await? {
let id = &key["observe-key:".len()..];
let opened = crate::oauth::open_agents_key(rl, &value);
if !id.is_empty()
&& opened.is_some_and(|o| constant_time_equal(&given, &o))
&& rl.site(Some(id)).is_some()
{
key_site = Some(id.to_string());
}
}
if key_site.is_none() {
return Ok(coded("Unauthorized", "unauthorized", 401, None));
}
}
let body = match read_json(request) {
Ok(b) => Value::Object(b),
Err(r) => return Ok(r),
};
let batch = body.get("fetches").is_some_and(Value::is_array);
let list: Vec<Value> = match body.get("fetches") {
Some(Value::Array(a)) => a.clone(),
_ => vec![body.clone()],
};
if list.len() > 500 {
return Ok(coded("Send at most 500 fetches at a time", "observe_many", 413, None));
}
let mut pages: Vec<(Url, String, Option<f64>)> = Vec::new();
for item in &list {
if item.is_null() {
return Ok(coded("Send the page's url", "observe_url", 400, None));
}
let Some(page) = Url::parse(&js::str_or_empty(item.get("url"))) else {
return Ok(coded("Send the page's url", "observe_url", 400, None));
};
if page.protocol() != "https:" && page.protocol() != "http:" {
return Ok(coded("Send the page's url", "observe_url", 400, None));
}
let at = match item.get("at") {
Some(Value::Number(n)) => Some(*n),
Some(Value::String(s)) => Some(date_parse(s)),
_ => None,
}
.filter(|a| a.is_finite());
pages.push((page, js::head16(&js::str_or_empty(item.get("userAgent")), 500), at));
}
rl.init().await?;
let mut keep: Vec<&(Url, String, Option<f64>)> = pages.iter().collect();
if let Some(key_site) = key_site {
keep.retain(|p| rl.site_for(&p.0.hostname(), None).is_some_and(|s| s.id == key_site));
if !batch && keep.is_empty() {
return Ok(coded("Unauthorized", "unauthorized", 401, None));
}
}
let mut recorded = 0;
for (page, ua, at) in keep {
let observed = Request::get(page.href()).header("user-agent", ua);
if rl.observe(&observed, *at).await {
recorded += 1;
}
}
if !batch {
return Ok(Response::status(204));
}
Ok(json(&obj! { "recorded" => recorded, "skipped" => pages.len() - recorded }, 200, &[]))
}
}
fn recorded(text: &str) -> String {
crate::sources::recorded_path(text).unwrap_or_else(|| text.to_string())
}
fn whole(text: Option<&str>, fallback: f64, max: f64) -> f64 {
let n = text.map_or(0.0, js::text_number);
if n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0 && n >= 1.0 { n.min(max) } else { fallback }
}
fn with_optional(site: &str, range: Value, compare: Option<Value>, rest: Vec<(&str, Option<Value>)>) -> Value {
let mut o = js::Object::new();
o.set("site", site);
o.set("range", range);
if let Some(c) = compare {
o.set("compare", c);
}
for (k, v) in rest {
if let Some(v) = v {
o.set(k, v);
}
}
Value::Object(o)
}
pub(crate) fn date_parse(text: &str) -> f64 {
let t = js::trim(text);
if let Ok(ts) = t.parse::<jiff::Timestamp>() {
return ts.as_millisecond() as f64;
}
if let Ok(dt) = t.parse::<jiff::civil::DateTime>() {
let zone = if t.len() <= 10 { jiff::tz::TimeZone::UTC } else { jiff::tz::TimeZone::system() };
if let Ok(z) = dt.to_zoned(zone) {
return z.timestamp().as_millisecond() as f64;
}
}
if let Ok(d) = t.parse::<jiff::civil::Date>()
&& let Ok(z) = d.to_zoned(jiff::tz::TimeZone::UTC)
{
return z.timestamp().as_millisecond() as f64;
}
f64::NAN
}
async fn both<A, B>(a: impl Future<Output = A>, b: impl Future<Output = B>) -> (A, B) {
let (mut a, mut b) = (Box::pin(a), Box::pin(b));
let (mut done_a, mut done_b) = (None, None);
std::future::poll_fn(|cx| {
if done_a.is_none()
&& let Poll::Ready(v) = a.as_mut().poll(cx)
{
done_a = Some(v);
}
if done_b.is_none()
&& let Poll::Ready(v) = b.as_mut().poll(cx)
{
done_b = Some(v);
}
if done_a.is_some() && done_b.is_some() { Poll::Ready(()) } else { Poll::Pending }
})
.await;
(done_a.expect("a finished"), done_b.expect("b finished"))
}