snapshot of https://github.com/ActivityWatch/aw-server-rust.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / ubuntu-latest (push) Failing after 5s
Build / Android (push) Failing after 2s
Build / Code coverage (push) Failing after 2s
Lint / format (push) Failing after 3s
Lint / clippy (push) Failing after 3s
Build / macOS-latest (push) Has been cancelled
Build / windows-latest (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:19:17 +05:00
commit da934b693c
120 changed files with 26659 additions and 0 deletions

61
aw-server/Cargo.toml Normal file
View File

@@ -0,0 +1,61 @@
[package]
name = "aw-server"
version = "0.14.0"
authors = ["Johan Bjäreholt <johan@bjareho.lt>", "Erik Bjäreholt <erik@bjareho.lt>"]
edition = "2021"
[lib]
name = "aw_server"
crate-type = ["lib", "cdylib"]
path = "src/lib.rs"
[[bin]]
name = "aw-server"
path = "src/main.rs"
[features]
default = ["bundled"]
# Use bundled SQLite (default, no encryption support)
bundled = ["aw-datastore/bundled"]
# Enable SQLCipher encryption support (requires OpenSSL)
# Build with: cargo build --no-default-features --features encryption
encryption = ["aw-datastore/encryption"]
# Enable SQLCipher encryption with vendored OpenSSL (fully self-contained)
encryption-vendored = ["aw-datastore/encryption-vendored"]
[dependencies]
rocket = { version = "0.5.0", features = ["json"] }
rocket_cors = { version = "0.6.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"
lazy_static = "1.4"
log = "0.4"
fern = { version = "0.7", features = ["colored"] }
toml = "0.8"
regex = "1"
gethostname = "0.4"
uuid = { version = "1.3", features = ["serde", "v4"] }
clap = { version = "4.1", features = ["derive", "cargo"] }
log-panics = { version = "2", features = ["with-backtrace"]}
subtle = "2"
tempfile = "3"
rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] }
aw-datastore = { path = "../aw-datastore", default-features = false }
aw-models = { path = "../aw-models" }
aw-transform = { path = "../aw-transform" }
aw-query = { path = "../aw-query" }
[target.'cfg(target_os="linux")'.dependencies]
sd-notify = "0.4.2"
[target.'cfg(all(target_os="linux", target_arch="x86"))'.dependencies]
jemallocator = "0.5.0"
[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.20", default-features = false }
libc = "0.2"
android_logger = "0.13"
openssl-sys = { version = "0.9.82", features = ["vendored"]}
aw-client-rust = { path = "../aw-client-rust" }

30
aw-server/build.rs Normal file
View File

@@ -0,0 +1,30 @@
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let webui_var = std::env::var("AW_WEBUI_DIR");
let path = if let Ok(var_path) = &webui_var {
std::path::Path::new(var_path)
} else {
let path = std::path::Path::new("../aw-webui/dist");
// ensure folder exists, since macro requires it
std::fs::create_dir_all(path)?;
println!("cargo:rustc-env=AW_WEBUI_DIR={}", path.display());
path
};
let path_index = path.join("index.html");
if !path_index.exists() {
println!(
"cargo:warning=`{}` is not built, compiling without webui",
path.display()
);
}
// Rebuild if the webui directory changes
println!("cargo:rerun-if-env-changed=AW_WEBUI_DIR");
if let Ok(dir) = webui_var {
println!("cargo:rerun-if-changed={dir}");
}
Ok(())
}

View File

@@ -0,0 +1,536 @@
// Based On the following guide from Mozilla:
// https://mozilla.github.io/firefox-browser-architecture/experiments/2017-09-21-rust-on-android.html
extern crate android_logger;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use crate::device_id;
use crate::dirs;
use android_logger::Config;
use rocket::serde::json::json;
#[no_mangle]
pub extern "C" fn rust_greeting(to: *const c_char) -> *mut c_char {
let c_str = unsafe { CStr::from_ptr(to) };
let recipient = match c_str.to_str() {
Err(_) => "there",
Ok(string) => string,
};
CString::new("Hello ".to_owned() + recipient + " (from Rust!)")
.unwrap()
.into_raw()
}
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;
use self::jni::objects::{JClass, JString};
use self::jni::sys::{jdouble, jint, jstring};
use self::jni::JNIEnv;
use super::*;
use crate::panic_guard::catch_panic;
use std::path::PathBuf;
use crate::endpoints;
use crate::endpoints::ServerState;
use aw_client_rust::classes::{classes_from_settings_str, default_classes};
use aw_client_rust::queries::{
build_android_canonical_events, AndroidQueryParams, QueryParamsBase,
};
use aw_datastore::Datastore;
use aw_models::{Bucket, Event, TimeInterval};
static mut DATASTORE: Option<Datastore> = None;
unsafe fn openDatastore() -> Datastore {
match DATASTORE {
Some(ref ds) => ds.clone(),
None => {
let db_dir = dirs::db_path("default")
.expect("Failed to get db path")
.to_str()
.unwrap()
.to_string();
DATASTORE = Some(Datastore::new(db_dir, false));
openDatastore()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_greeting(
env: JNIEnv,
_: JClass,
java_pattern: JString,
) -> jstring {
jni_guard(env, "greeting", || {
// Our Java companion code might pass-in "world" as a string, hence the name.
let world = rust_greeting(
env.get_string(java_pattern)
.expect("invalid pattern string")
.as_ptr(),
);
// Retake pointer so that we can use it below and allow memory to be freed when it goes out of scope.
let world_ptr = CString::from_raw(world);
let output = env
.new_string(world_ptr.to_str().unwrap())
.expect("Couldn't create java string!");
output.into_raw()
})
}
unsafe fn jstring_to_string(env: &JNIEnv, string: JString) -> String {
let jstr = env.get_string(string).expect("Failed to get Java string");
jstr.into()
}
unsafe fn string_to_jstring(env: &JNIEnv, string: String) -> jstring {
env.new_string(string)
.expect("Couldn't create java string")
.into_raw()
}
unsafe fn create_error_object(env: &JNIEnv, msg: String) -> jstring {
let obj = json!({ "error": &msg });
string_to_jstring(&env, obj.to_string())
}
/// Run a `jstring`-returning JNI entry point with panics caught.
///
/// Since Rust 1.81 a panic that unwinds out of an `extern "C"` function
/// aborts the process, so any `unwrap()`/`expect()` reached from one of
/// these natives kills the app with `SIGABRT` instead of failing the call.
/// Those are the `libaw_server.so` → `SIGABRT` clusters in
/// ActivityWatch/aw-android#267. Catching the unwind here returns the same
/// `{"error": "…"}` object the callers already produce for ordinary
/// failures, which `RustInterface` parses as a normal `JSONObject`.
///
/// `log_panics::init()` (installed in `initialize`) still logs the panic and
/// its backtrace to logcat before the unwind is stopped, so nothing is
/// hidden.
unsafe fn jni_guard<F>(env: JNIEnv, name: &str, f: F) -> jstring
where
F: FnOnce() -> jstring,
{
match catch_panic(name, f) {
Ok(result) => result,
Err(msg) => {
error!("{}", msg);
// A panic mid-JNI-call can leave a pending Java exception, which
// would make the NewStringUTF below fail.
let _ = env.exception_clear();
// Building the error object allocates a Java string, which can
// itself fail; never let that second failure unwind out of the
// `extern "C"` frame. A null return surfaces as a Java-level
// NullPointerException, which is recoverable, unlike SIGABRT.
catch_panic(name, || create_error_object(&env, msg))
.unwrap_or_else(|_| std::ptr::null_mut())
}
}
}
/// Run a `void` JNI entry point with panics caught. There is no return value
/// to carry an error, so the panic is logged and swallowed.
unsafe fn jni_guard_void<F>(name: &str, f: F)
where
F: FnOnce(),
{
if let Err(msg) = catch_panic(name, f) {
error!("{}", msg);
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_startServer(
env: JNIEnv,
_: JClass,
) {
jni_guard_void("startServer", || {
info!("Starting server...");
start_server();
info!("Server exited");
});
}
#[rocket::main]
async fn start_server() {
info!("Building server state...");
// FIXME: Why is unsafe needed here? Can we get rid of it?
unsafe {
let server_state: ServerState = endpoints::ServerState {
datastore: openDatastore(),
asset_resolver: endpoints::AssetResolver::new(None),
device_id: device_id::get_device_id(),
};
info!("Using server_state:: device_id: {}", server_state.device_id);
let mut server_config = crate::config::create_config("default", None);
server_config.port = 5600;
endpoints::build_rocket(server_state, server_config)
.launch()
.await;
}
}
static mut INITIALIZED: bool = false;
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_initialize(
env: JNIEnv,
_: JClass,
) {
jni_guard_void("initialize", || {
if !INITIALIZED {
android_logger::init_once(
Config::default()
.with_max_level(log::LevelFilter::Info) // limit log level
.with_tag("aw-server-rust"), // logs will show under mytag tag
//.with_filter( // configure messages for specific crate
// FilterBuilder::new()
// .parse("debug,hello::crate=error")
// .build())
);
// Default panic hook writes to stderr, which Android discards
// (ActivityWatch/aw-android#220). log_panics routes them through
// android_logger so they appear in logcat.
log_panics::init();
info!("Initializing aw-server-rust...");
debug!("Redirected aw-server-rust stdout/stderr to logcat");
} else {
info!("Already initialized");
}
INITIALIZED = true;
// Without this it might not work due to weird error probably arising from Rust optimizing away the JNIEnv:
// JNI DETECTED ERROR IN APPLICATION: use of deleted weak global reference
string_to_jstring(&env, "test".to_string());
});
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_setDataDir(
env: JNIEnv,
_: JClass,
java_dir: JString,
) {
jni_guard_void("setDataDir", || {
let path = &jstring_to_string(&env, java_dir);
debug!("Setting android data dir as {}", path);
dirs::set_android_data_dir(path);
});
}
/// Report the Android app's release version from `/api/0/info` instead of
/// the aw-server-rust package version, which is the version of a component
/// rather than of the app the user installed.
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_setVersionOverride(
env: JNIEnv,
_: JClass,
java_version: JString,
) {
jni_guard_void("setVersionOverride", || {
let version = &jstring_to_string(&env, java_version);
debug!("Setting reported version to {}", version);
crate::version::set_version_override(version);
});
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_getBuckets(
env: JNIEnv,
_: JClass,
) -> jstring {
jni_guard(env, "getBuckets", || {
// Return an error object instead of unwrapping: if the datastore worker
// is gone (it panicked, e.g. the database could not be opened), the
// request fails with SendError/RecvError and a panic here unwinds across
// the JNI boundary on whatever thread called getBuckets — usually main.
match openDatastore().get_buckets() {
Ok(buckets) => string_to_jstring(&env, json!(buckets).to_string()),
Err(e) => create_error_object(&env, format!("Failed to get buckets: {e:?}")),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_createBucket(
env: JNIEnv,
_: JClass,
java_bucket: JString,
) -> jstring {
jni_guard(env, "createBucket", || {
let bucket = jstring_to_string(&env, java_bucket);
let bucket_json: Bucket = match serde_json::from_str(&bucket) {
Ok(json) => json,
Err(err) => return create_error_object(&env, err.to_string()),
};
match openDatastore().create_bucket(&bucket_json) {
Ok(()) => string_to_jstring(&env, "Bucket successfully created".to_string()),
Err(e) => create_error_object(
&env,
format!("Something went wrong when trying to create bucket: {:?}", e),
),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_heartbeat(
env: JNIEnv,
_: JClass,
java_bucket_id: JString,
java_event: JString,
java_pulsetime: jdouble,
) -> jstring {
jni_guard(env, "heartbeat", || {
let bucket_id = jstring_to_string(&env, java_bucket_id);
let event = jstring_to_string(&env, java_event);
let pulsetime = java_pulsetime as f64;
let event_json: Event = match serde_json::from_str(&event) {
Ok(json) => json,
Err(err) => return create_error_object(&env, err.to_string()),
};
match openDatastore().heartbeat(&bucket_id, event_json, pulsetime) {
Ok(_) => string_to_jstring(&env, "Heartbeat successfully received".to_string()),
Err(e) => create_error_object(
&env,
format!(
"Something went wrong when trying to send heartbeat: {:?}",
e
),
),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_getEvents(
env: JNIEnv,
_: JClass,
java_bucket_id: JString,
java_limit: jint,
) -> jstring {
jni_guard(env, "getEvents", || {
let bucket_id = jstring_to_string(&env, java_bucket_id);
let limit = java_limit as u64;
match openDatastore().get_events(&bucket_id, None, None, Some(limit)) {
Ok(events) => string_to_jstring(&env, json!(events).to_string()),
Err(e) => create_error_object(
&env,
format!("Something went wrong when trying to get events: {:?}", e),
),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_migrateHostname(
env: JNIEnv,
_: JClass,
hostname: JString,
) -> jstring {
jni_guard(env, "migrateHostname", || {
let hostname = jstring_to_string(&env, hostname);
if hostname.is_empty() {
return create_error_object(&env, "hostname must not be empty".to_string());
}
match openDatastore().migrate_hostname(&hostname) {
Ok(count) => {
string_to_jstring(&env, format!("Migrated hostname for {} bucket(s)", count))
}
Err(e) => create_error_object(&env, format!("Failed to migrate hostname: {:?}", e)),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_migrateAndroidBucketName(
env: JNIEnv,
_: JClass,
) -> jstring {
jni_guard(env, "migrateAndroidBucketName", || {
match openDatastore().rename_bucket("aw-android-test", "aw-android") {
Ok(()) => string_to_jstring(
&env,
"Renamed bucket 'aw-android-test' to 'aw-android'".to_string(),
),
Err(e) => create_error_object(&env, format!("Failed to rename bucket: {:?}", e)),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_migrateWatcherAndroidBucketNames(
env: JNIEnv,
_: JClass,
) -> jstring {
jni_guard(
env,
"migrateWatcherAndroidBucketNames",
|| match openDatastore().migrate_test_bucket_names() {
Ok(count) => string_to_jstring(
&env,
format!("Migrated {} 'aw-watcher-android-test' bucket(s)", count),
),
Err(e) => create_error_object(
&env,
format!("Failed to migrate watcher bucket names: {:?}", e),
),
},
)
}
/// Return a raw settings JSON value (the datastore body, matching GET /api/0/settings/<key>).
/// Missing or invalid keys return the JSON literal `null`.
///
/// Widget/worker code must use this instead of unauthenticated HTTP: Android
/// enables API-key auth by default, so GET /api/0/settings/... from the
/// widget process 401s and silently falls back to defaults.
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_getSetting(
env: JNIEnv,
_: JClass,
java_key: JString,
) -> jstring {
jni_guard(env, "getSetting", || {
let key = jstring_to_string(&env, java_key);
// Match GET /api/0/settings/<key>: dots are valid (nested-looking
// keys like "foo.bar" store as settings.foo.bar). Reject empty keys
// and path/NUL bytes so JNI cannot smuggle a lookup the HTTP router
// would never pass through.
if key.is_empty() || key.contains('/') || key.contains('\\') || key.contains('\0') {
return string_to_jstring(&env, "null".to_string());
}
let setting_key = match crate::endpoints::settings_datastore_key(&key) {
Ok(k) => k,
Err(_) => return string_to_jstring(&env, "null".to_string()),
};
match openDatastore().get_key_value(&setting_key) {
Ok(value) => string_to_jstring(&env, value),
Err(_) => string_to_jstring(&env, "null".to_string()),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_query(
env: JNIEnv,
_: JClass,
java_query: JString,
java_timeperiods: JString,
) -> jstring {
jni_guard(env, "query", || {
let query_code = jstring_to_string(&env, java_query);
let timeperiods_str = jstring_to_string(&env, java_timeperiods);
let timeperiods: Vec<TimeInterval> = match serde_json::from_str(&timeperiods_str) {
Ok(json) => json,
Err(err) => return create_error_object(&env, err.to_string()),
};
let datastore = openDatastore();
let mut results = Vec::new();
for interval in &timeperiods {
let result = match aw_query::query(&query_code, interval, &datastore) {
Ok(data) => data,
Err(e) => {
return create_error_object(
&env,
format!("Something went wrong when trying to query: {:?}", e),
)
}
};
results.push(result);
}
string_to_jstring(&env, json!(results).to_string())
})
}
#[no_mangle]
pub unsafe extern "C" fn Java_net_activitywatch_android_RustInterface_androidQuery(
env: JNIEnv,
_: JClass,
java_timeperiods: JString,
) -> jstring {
jni_guard(env, "androidQuery", || {
let timeperiods_str = jstring_to_string(&env, java_timeperiods);
let timeperiods: Vec<TimeInterval> = match serde_json::from_str(&timeperiods_str) {
Ok(json) => json,
Err(err) => return create_error_object(&env, err.to_string()),
};
// Hardcoded bucket ID
let bid_android = "aw-watcher-android".to_string();
// Read classes from the datastore directly. Do NOT fetch them over HTTP:
// Android enables API-key auth by default, and androidQuery runs from the
// widget process which does not send a Bearer token. The previous
// AwClient GET /api/0/settings/classes path 401'd (or failed if the
// HTTP server wasn't up) and silently fell back to default_classes(),
// which is why the homescreen widget disagreed with the Activity view
// on per-category time while totals still matched.
// See ActivityWatch/aw-android#142.
let datastore = openDatastore();
let classes = match datastore.get_key_value("settings.classes") {
Ok(raw) => {
info!("Loaded classes from datastore settings.classes");
classes_from_settings_str(&raw)
}
Err(_) => {
info!("settings.classes unset or unreadable, using default classes");
default_classes()
}
};
// Build canonical Android query
let params = AndroidQueryParams {
base: QueryParamsBase {
bid_browsers: Vec::new(),
classes,
filter_classes: Vec::new(),
filter_afk: true,
include_audible: true,
},
bid_android,
};
let query_code = format!(
r#"{}
duration = sum_durations(events);
cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"]));
RETURN = {{"events": events, "duration": duration, "cat_events": cat_events}};"#,
build_android_canonical_events(&params)
);
let mut results = Vec::new();
for interval in &timeperiods {
let result = match aw_query::query(&query_code, interval, &datastore) {
Ok(data) => data,
Err(e) => {
return create_error_object(
&env,
format!("Something went wrong when trying to query: {:?}", e),
)
}
};
results.push(result);
}
string_to_jstring(&env, json!(results).to_string())
})
}
}

278
aw-server/src/config.rs Normal file
View File

@@ -0,0 +1,278 @@
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use rocket::config::Config;
use rocket::data::{Limits, ToByteUnit};
use rocket::log::LogLevel;
use serde::{Deserialize, Serialize};
use crate::dirs;
static PROFILE: OnceLock<String> = OnceLock::new();
/// Return the current profile name (default: "default").
pub fn get_profile() -> &'static str {
PROFILE.get().map(|s| s.as_str()).unwrap_or("default")
}
/// Set the profile. Idempotent for the same value; panics if called with
/// a conflicting value (would silently redirect to a different datastore).
pub fn set_profile(profile: String) {
// Use the atomic OnceLock::set result to avoid a TOCTOU race: two callers
// with different values could both see get()==None before either sets it,
// causing the loser to silently proceed under the wrong profile.
match PROFILE.set(profile.clone()) {
Ok(()) => {} // we set it atomically, done
Err(_) => {
// Already set — verify no conflict (duplicate or concurrent same-value call is fine)
let existing = PROFILE
.get()
.expect("set returned Err but get returned None");
if existing != &profile {
panic!(
"set_profile called with conflicting value: existing={existing:?}, new={profile:?}"
);
}
// same value — idempotent, no action needed
}
}
}
/// True when running in the legacy "testing" profile or in debug builds.
pub fn is_testing() -> bool {
get_profile() == "testing"
}
/// Backwards-compat shim: callers that still use set_testing(bool) convert
/// true → profile "testing" and false → profile "default".
pub fn set_testing(testing: bool) {
let profile = if testing { "testing" } else { "default" };
set_profile(profile.to_string());
}
/// Authentication configuration, serialised as `[auth]` in config.toml.
#[derive(Serialize, Deserialize, Default)]
pub struct AWAuthConfig {
/// Optional API key for Bearer-token authentication.
/// When set, all `/api/*` endpoints except `/api/0/info` require:
/// Authorization: Bearer <api_key>
/// Leave unset (default) to disable authentication.
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct AWConfig {
#[serde(default = "default_address")]
pub address: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(skip, default = "default_testing")]
pub testing: bool, // This is not written to the config file (serde(skip))
#[serde(default = "default_cors")]
pub cors: Vec<String>,
#[serde(default = "default_cors")]
pub cors_regex: Vec<String>,
// Authentication settings — serialised as [auth] section.
#[serde(default)]
pub auth: AWAuthConfig,
// A mapping of watcher names to paths where the
// custom visualizations are located.
#[serde(default = "default_custom_static")]
pub custom_static: std::collections::HashMap<String, String>,
}
impl Default for AWConfig {
fn default() -> AWConfig {
AWConfig {
address: default_address(),
port: default_port(),
testing: default_testing(),
auth: AWAuthConfig::default(),
cors: default_cors(),
cors_regex: default_cors(),
custom_static: default_custom_static(),
}
}
}
impl AWConfig {
pub fn to_rocket_config(&self) -> rocket::Config {
let mut config;
if self.testing {
config = Config::debug_default();
config.log_level = LogLevel::Debug;
} else {
config = Config::release_default()
};
// Needed for bucket imports
let limits = Limits::default()
.limit("json", 1000u64.megabytes())
.limit("data-form", 1000u64.megabytes());
config.address = self.address.parse().unwrap();
config.port = self.port;
config.limits = limits;
config
}
}
fn default_address() -> String {
"127.0.0.1".to_string()
}
fn default_cors() -> Vec<String> {
Vec::<String>::new()
}
fn default_testing() -> bool {
is_testing()
}
fn default_port() -> u16 {
if is_testing() {
5666
} else {
5600
}
}
fn default_custom_static() -> std::collections::HashMap<String, String> {
std::collections::HashMap::new()
}
/// Config filename for a profile.
///
/// Isolated profile roots (including new-style `activitywatch-testing/`) use
/// bare `config.toml` — the directory already isolates. Suffixed
/// `config-testing.toml` remains only in the legacy shared-root layout so
/// existing testing config is not orphaned.
pub fn config_filename(profile: &str) -> String {
crate::dirs::config_filename(profile)
}
fn get_config_path(profile: &str, config_override: Option<&Path>) -> PathBuf {
if let Some(config_path) = config_override {
return config_path.to_path_buf();
}
let mut config_path = dirs::get_config_dir().unwrap();
config_path.push(config_filename(profile));
config_path
}
pub fn create_config(profile: &str, config_override: Option<&Path>) -> AWConfig {
set_profile(profile.to_string());
let config_path = get_config_path(profile, config_override);
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).expect("Unable to create config dir");
}
/* If there is no config file, create a new config file with default values but every value is
* commented out by default in case we would change a default value at some point in the future */
if !config_path.is_file() {
debug!("Writing default commented out config at {:?}", config_path);
let mut wfile = File::create(config_path.clone()).expect("Unable to create config file");
let default_config = AWConfig::default();
let default_config_str =
toml::to_string(&default_config).expect("Failed to convert default config to string");
let mut default_config_str_commented = String::new();
default_config_str_commented.push_str("### DEFAULT SETTINGS ###\n");
for line in default_config_str.lines() {
default_config_str_commented.push_str(&format!("#{line}\n"));
}
wfile
.write_all(&default_config_str_commented.into_bytes())
.expect("Failed to write config to file");
wfile.sync_all().expect("Unable to sync config file");
}
debug!("Reading config at {:?}", config_path);
let mut rfile = File::open(config_path).expect("Failed to open config file for reading");
let mut content = String::new();
rfile
.read_to_string(&mut content)
.expect("Failed to read config as a string");
let aw_config: AWConfig = toml::from_str(&content).expect("Failed to parse config file");
aw_config
}
#[cfg(test)]
mod tests {
use super::create_config;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use uuid::Uuid;
static TEST_LOCK: Mutex<()> = Mutex::new(());
struct TestConfigPath {
root: PathBuf,
config_path: PathBuf,
}
impl TestConfigPath {
fn new(name: &str) -> Self {
let root = std::env::temp_dir()
.join("aw-server-config-tests")
.join(format!("{name}-{}", Uuid::new_v4()));
let config_path = root.join("config.toml");
Self { root, config_path }
}
}
impl Drop for TestConfigPath {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[test]
fn config_filename_isolated_named_profiles_are_bare() {
// default and named isolated roots are always config.toml. Suffixed
// config-testing.toml is filesystem-dependent (legacy shared root) and
// covered in dirs.rs against fake roots.
assert_eq!(super::config_filename("default"), "config.toml");
assert_eq!(super::config_filename("research"), "config.toml");
assert_eq!(super::config_filename("my-profile"), "config.toml");
}
#[test]
fn create_config_uses_override_path() {
// create_config sets the PROFILE OnceLock, so these tests must not overlap.
let _lock = TEST_LOCK.lock().unwrap();
let paths = TestConfigPath::new("override");
fs::create_dir_all(paths.config_path.parent().unwrap()).unwrap();
fs::write(&paths.config_path, "address = \"0.0.0.0\"\nport = 5611\n").unwrap();
let config = create_config("default", Some(paths.config_path.as_path()));
assert_eq!(config.address, "0.0.0.0");
assert_eq!(config.port, 5611);
}
#[test]
fn create_config_creates_missing_override_file() {
let _lock = TEST_LOCK.lock().unwrap();
let paths = TestConfigPath::new("missing");
let config = create_config("default", Some(paths.config_path.as_path()));
assert!(paths.config_path.is_file());
assert_eq!(config.address, "127.0.0.1");
assert_eq!(config.port, 5600);
}
}

View File

@@ -0,0 +1,19 @@
use std::fs;
use uuid::Uuid;
use crate::dirs;
/// Retrieves the device ID, if none exists it generates one (using UUID v4)
pub fn get_device_id() -> String {
// Device IDs are mutable app data, so they live next to the datastore rather than config.
let mut path = dirs::get_data_dir().unwrap();
path.push("device_id");
if path.exists() {
fs::read_to_string(path).unwrap()
} else {
let uuid = Uuid::new_v4().as_hyphenated().to_string();
fs::write(path, &uuid).unwrap();
uuid
}
}

583
aw-server/src/dirs.rs Normal file
View File

@@ -0,0 +1,583 @@
// The public dir helpers use `Result<_, ()>` and are part of the crate API.
#![allow(clippy::result_unit_err)]
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(target_os = "android")]
use std::sync::Mutex;
#[cfg(target_os = "android")]
lazy_static! {
static ref ANDROID_DATA_DIR: Mutex<PathBuf> = Mutex::new(PathBuf::from(
"/data/user/0/net.activitywatch.android/files"
));
}
const DEFAULT_APPNAME: &str = "activitywatch";
const TESTING_PROFILE: &str = "testing";
const TESTING_APPNAME: &str = "activitywatch-testing";
/// Filenames that mark a machine as still using the pre-profile shared-root
/// testing layout (ActivityWatch/activitywatch#1399). Keep this list specific:
/// a false positive would pin a fresh install to the legacy layout forever.
/// Identical to the python list in aw-core so both sides agree on disk state.
const LEGACY_TESTING_FILENAME_MARKERS: &[&str] = &[
"peewee-sqlite-testing",
"sqlite-testing",
"settings-testing",
"config-testing",
"-testing.db",
"-testing.toml",
"-testing.json",
"_testing_",
];
/// Platform "appname" root for the current profile.
///
/// Named profiles use a sibling root (`activitywatch-research`, …). `default`
/// keeps the bare `activitywatch` root. `testing` follows the new-root-plus
/// legacy-fallback rule from ActivityWatch/activitywatch#1399 — see
/// [`using_legacy_testing_root`].
#[cfg(not(target_os = "android"))]
pub fn appname() -> String {
appname_for(crate::config::get_profile())
}
/// Platform appname for a given profile, observing on-disk state for `testing`.
#[cfg(not(target_os = "android"))]
pub fn appname_for(profile: &str) -> String {
match platform_roots() {
Some((data, config, cache)) => appname_for_in(profile, &data, &config, &cache),
None => appname_for_in(profile, Path::new(""), Path::new(""), Path::new("")),
}
}
/// Pure appname resolution against explicit XDG-style parent dirs (testable).
pub fn appname_for_in(profile: &str, data: &Path, config: &Path, cache: &Path) -> String {
if profile.is_empty() || profile == "default" {
return DEFAULT_APPNAME.to_string();
}
if profile == TESTING_PROFILE && using_legacy_testing_root_in(profile, data, config, cache) {
return DEFAULT_APPNAME.to_string();
}
format!("{DEFAULT_APPNAME}-{profile}")
}
fn platform_roots() -> Option<(PathBuf, PathBuf, PathBuf)> {
Some((dirs::data_dir()?, dirs::config_dir()?, dirs::cache_dir()?))
}
fn is_legacy_testing_filename(name: &str) -> bool {
let lower = name.to_lowercase();
LEGACY_TESTING_FILENAME_MARKERS
.iter()
.any(|marker| lower.contains(marker))
}
fn dir_has_legacy_testing_file(dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|entry| {
entry.file_type().map(|t| t.is_file()).unwrap_or(false)
&& is_legacy_testing_filename(&entry.file_name().to_string_lossy())
})
}
/// Walk `activitywatch/` plus one extra level (`activitywatch/aw-server-rust/`).
fn legacy_testing_artifacts_in_app_root(app_root: &Path) -> bool {
if !app_root.is_dir() {
return false;
}
if dir_has_legacy_testing_file(app_root) {
return true;
}
let Ok(entries) = fs::read_dir(app_root) else {
return false;
};
entries.flatten().any(|entry| {
let path = entry.path();
path.is_dir() && dir_has_legacy_testing_file(&path)
})
}
fn new_testing_root_exists_in(data: &Path, config: &Path, cache: &Path) -> bool {
[data, config, cache]
.iter()
.any(|root| root.join(TESTING_APPNAME).is_dir())
}
fn legacy_testing_artifacts_exist_in(data: &Path, config: &Path, cache: &Path) -> bool {
[data, config, cache]
.iter()
.any(|root| legacy_testing_artifacts_in_app_root(&root.join(DEFAULT_APPNAME)))
}
/// Testing-root resolution against explicit parent dirs (testable).
///
/// Rule (ActivityWatch/activitywatch#1399), identical on python and rust:
///
/// 1. If `activitywatch-testing/` already exists: use it (new layout).
/// 2. Else if legacy testing artifacts exist in the bare `activitywatch/`
/// root: stay in legacy mode (old paths, old filenames).
/// 3. Else (fresh setup): create and use `activitywatch-testing/`.
pub fn using_legacy_testing_root_in(
profile: &str,
data: &Path,
config: &Path,
cache: &Path,
) -> bool {
if profile != TESTING_PROFILE {
return false;
}
if new_testing_root_exists_in(data, config, cache) {
return false;
}
legacy_testing_artifacts_exist_in(data, config, cache)
}
/// Whether `profile=testing` should stay on the shared `activitywatch` root.
pub fn using_legacy_testing_root(profile: &str) -> bool {
match platform_roots() {
Some((data, config, cache)) => {
using_legacy_testing_root_in(profile, &data, &config, &cache)
}
None => false,
}
}
/// `"-testing"` only when testing data still shares the default root.
///
/// Isolated profile roots (including new-style `activitywatch-testing/`) use
/// bare filenames: the directory already isolates. Suffixed names remain only
/// in legacy mode so existing `sqlite-testing.db` files keep working.
pub fn legacy_testing_suffix(profile: &str) -> &'static str {
if using_legacy_testing_root(profile) {
"-testing"
} else {
""
}
}
fn db_filename_for_legacy(legacy: bool) -> &'static str {
if legacy {
"sqlite-testing.db"
} else {
"sqlite.db"
}
}
fn config_filename_for_legacy(legacy: bool) -> &'static str {
if legacy {
"config-testing.toml"
} else {
"config.toml"
}
}
/// Database filename for a profile. Isolated roots use `sqlite.db`; legacy
/// testing keeps `sqlite-testing.db`.
pub fn db_filename(profile: &str) -> String {
db_filename_for_legacy(using_legacy_testing_root(profile)).to_string()
}
/// Config filename for a profile. Isolated roots use `config.toml`; legacy
/// testing keeps `config-testing.toml`.
pub fn config_filename(profile: &str) -> String {
config_filename_for_legacy(using_legacy_testing_root(profile)).to_string()
}
#[cfg(not(target_os = "android"))]
pub fn get_config_dir() -> Result<PathBuf, ()> {
let dir = dirs::config_dir()
.ok_or(())?
.join(appname())
.join("aw-server-rust");
fs::create_dir_all(&dir).expect("Unable to create config dir");
Ok(dir)
}
#[cfg(target_os = "android")]
pub fn get_config_dir() -> Result<PathBuf, ()> {
Ok(ANDROID_DATA_DIR.lock().unwrap().to_path_buf())
}
#[cfg(not(target_os = "android"))]
pub fn get_data_dir() -> Result<PathBuf, ()> {
let dir = dirs::data_dir()
.ok_or(())?
.join(appname())
.join("aw-server-rust");
fs::create_dir_all(&dir).expect("Unable to create data dir");
Ok(dir)
}
#[cfg(target_os = "android")]
pub fn get_data_dir() -> Result<PathBuf, ()> {
return Ok(ANDROID_DATA_DIR.lock().unwrap().to_path_buf());
}
#[cfg(not(target_os = "android"))]
pub fn get_cache_dir() -> Result<PathBuf, ()> {
let dir = dirs::cache_dir()
.ok_or(())?
.join(appname())
.join("aw-server-rust");
fs::create_dir_all(&dir).expect("Unable to create cache dir");
Ok(dir)
}
#[cfg(target_os = "android")]
pub fn get_cache_dir() -> Result<PathBuf, ()> {
panic!("not implemented on Android");
}
#[cfg(not(target_os = "android"))]
pub fn get_log_dir(module: &str) -> Result<PathBuf, ()> {
let dir = get_user_log_dir()?.join(module);
fs::create_dir_all(&dir).expect("Unable to create log dir");
Ok(dir)
}
/// Returns the platform-appropriate log directory for ActivityWatch.
///
/// Replicates the behavior of the old `appdirs::user_log_dir("activitywatch")`:
/// - Linux: ~/.cache/activitywatch/log/
/// - macOS: ~/Library/Logs/activitywatch/
/// - Windows: {LOCALAPPDATA}\activitywatch\Logs\
#[cfg(target_os = "linux")]
fn get_user_log_dir() -> Result<PathBuf, ()> {
Ok(dirs::cache_dir().ok_or(())?.join(appname()).join("log"))
}
#[cfg(target_os = "macos")]
fn get_user_log_dir() -> Result<PathBuf, ()> {
Ok(dirs::home_dir()
.ok_or(())?
.join("Library")
.join("Logs")
.join(appname()))
}
#[cfg(target_os = "windows")]
fn get_user_log_dir() -> Result<PathBuf, ()> {
Ok(dirs::data_local_dir()
.ok_or(())?
.join(appname())
.join("Logs"))
}
#[cfg(target_os = "android")]
pub fn get_log_dir(module: &str) -> Result<PathBuf, ()> {
panic!("not implemented on Android");
}
/// Validate a profile name: lowercase alphanumerics plus `-` and `_`, max 32
/// chars, must start with a letter or digit. Returns Err(message) on failure.
pub fn validate_profile(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("profile name must not be empty".into());
}
if name.len() > 32 {
return Err(format!(
"profile name too long ({} chars, max 32)",
name.len()
));
}
let first = name.chars().next().unwrap();
if !first.is_ascii_alphanumeric() {
return Err(format!(
"profile name must start with a letter or digit, got '{first}'"
));
}
for c in name.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
return Err(format!("invalid character '{c}' in profile name"));
}
}
if name != name.to_lowercase() {
return Err("profile name must be lowercase".into());
}
Ok(())
}
/// Data dir for an explicit profile (does not depend on the process-global
/// `OnceLock`). Creates the directory. Used by `db_path` so a caller asking
/// for `research` cannot land in the default root just because `set_profile`
/// has not run yet.
#[cfg(not(target_os = "android"))]
fn get_data_dir_for(profile: &str) -> Result<PathBuf, ()> {
let dir = dirs::data_dir()
.ok_or(())?
.join(appname_for(profile))
.join("aw-server-rust");
fs::create_dir_all(&dir).expect("Unable to create data dir");
Ok(dir)
}
#[cfg(target_os = "android")]
fn get_data_dir_for(_profile: &str) -> Result<PathBuf, ()> {
get_data_dir()
}
pub fn db_path(profile: &str) -> Result<PathBuf, ()> {
let mut db_path = get_data_dir_for(profile)?;
db_path.push(db_filename(profile));
Ok(db_path)
}
#[cfg(target_os = "android")]
pub fn set_android_data_dir(path: &str) {
let mut android_data_dir = ANDROID_DATA_DIR.lock().unwrap();
*android_data_dir = PathBuf::from(path);
}
#[cfg(test)]
fn fake_roots() -> (PathBuf, PathBuf, PathBuf, PathBuf) {
let root = std::env::temp_dir()
.join("aw-testing-root-fallback")
.join(uuid::Uuid::new_v4().to_string());
let data = root.join("data");
let config = root.join("config");
let cache = root.join("cache");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(&config).unwrap();
fs::create_dir_all(&cache).unwrap();
(root, data, config, cache)
}
#[cfg(test)]
fn plant_legacy_testing_db(data: &Path) -> PathBuf {
let aw_server = data.join("activitywatch").join("aw-server-rust");
fs::create_dir_all(&aw_server).unwrap();
let marker = aw_server.join("sqlite-testing.db");
fs::write(&marker, b"").unwrap();
marker
}
#[cfg(not(target_os = "android"))]
#[test]
fn test_appname_root_isolation() {
let (_root, data, config, cache) = fake_roots();
assert_eq!(
appname_for_in("default", &data, &config, &cache),
"activitywatch"
);
// Fresh setup: testing uses the isolated sibling root.
assert_eq!(
appname_for_in("testing", &data, &config, &cache),
"activitywatch-testing"
);
assert_eq!(
appname_for_in("research", &data, &config, &cache),
"activitywatch-research"
);
assert_eq!(
appname_for_in("my-profile", &data, &config, &cache),
"activitywatch-my-profile"
);
let _ = fs::remove_dir_all(_root);
}
#[test]
fn test_testing_root_fresh_setup_uses_new_root() {
let (_root, data, config, cache) = fake_roots();
assert!(!using_legacy_testing_root_in(
"testing", &data, &config, &cache
));
assert_eq!(
appname_for_in("testing", &data, &config, &cache),
"activitywatch-testing"
);
let _ = fs::remove_dir_all(_root);
}
#[test]
fn test_testing_root_legacy_artifacts_keep_shared_root() {
let (_root, data, config, cache) = fake_roots();
plant_legacy_testing_db(&data);
assert!(using_legacy_testing_root_in(
"testing", &data, &config, &cache
));
assert_eq!(
appname_for_in("testing", &data, &config, &cache),
"activitywatch"
);
assert!(!data.join("activitywatch-testing").exists());
let _ = fs::remove_dir_all(_root);
}
#[test]
fn test_testing_root_new_root_wins_over_legacy_artifacts() {
let (_root, data, config, cache) = fake_roots();
plant_legacy_testing_db(&data);
fs::create_dir_all(data.join("activitywatch-testing")).unwrap();
assert!(!using_legacy_testing_root_in(
"testing", &data, &config, &cache
));
assert_eq!(
appname_for_in("testing", &data, &config, &cache),
"activitywatch-testing"
);
let _ = fs::remove_dir_all(_root);
}
#[test]
fn test_config_testing_toml_is_a_legacy_marker() {
let (_root, data, config, cache) = fake_roots();
let cfg = config.join("activitywatch");
fs::create_dir_all(&cfg).unwrap();
fs::write(cfg.join("config-testing.toml"), b"").unwrap();
assert!(using_legacy_testing_root_in(
"testing", &data, &config, &cache
));
assert_eq!(
appname_for_in("testing", &data, &config, &cache),
"activitywatch"
);
let _ = fs::remove_dir_all(_root);
}
#[test]
fn test_named_profile_never_uses_legacy_root() {
let (_root, data, config, cache) = fake_roots();
plant_legacy_testing_db(&data);
assert!(!using_legacy_testing_root_in(
"research", &data, &config, &cache
));
assert_eq!(
appname_for_in("research", &data, &config, &cache),
"activitywatch-research"
);
let _ = fs::remove_dir_all(_root);
}
#[cfg(test)]
fn db_filename_in(profile: &str, data: &Path, config: &Path, cache: &Path) -> &'static str {
db_filename_for_legacy(using_legacy_testing_root_in(profile, data, config, cache))
}
#[cfg(test)]
fn config_filename_in(profile: &str, data: &Path, config: &Path, cache: &Path) -> &'static str {
config_filename_for_legacy(using_legacy_testing_root_in(profile, data, config, cache))
}
#[test]
fn test_filenames_bare_except_legacy_testing() {
assert_eq!(db_filename_for_legacy(false), "sqlite.db");
assert_eq!(db_filename_for_legacy(true), "sqlite-testing.db");
assert_eq!(config_filename_for_legacy(false), "config.toml");
assert_eq!(config_filename_for_legacy(true), "config-testing.toml");
}
#[test]
fn test_filenames_follow_disk_state_rule() {
let (_root, data, config, cache) = fake_roots();
assert_eq!(
db_filename_in("testing", &data, &config, &cache),
"sqlite.db"
);
assert_eq!(
config_filename_in("testing", &data, &config, &cache),
"config.toml"
);
assert_eq!(
db_filename_in("research", &data, &config, &cache),
"sqlite.db"
);
plant_legacy_testing_db(&data);
assert_eq!(
db_filename_in("testing", &data, &config, &cache),
"sqlite-testing.db"
);
assert_eq!(
config_filename_in("testing", &data, &config, &cache),
"config-testing.toml"
);
// Named profiles stay bare even when legacy testing artifacts exist.
assert_eq!(
db_filename_in("research", &data, &config, &cache),
"sqlite.db"
);
assert_eq!(
config_filename_in("research", &data, &config, &cache),
"config.toml"
);
fs::create_dir_all(data.join("activitywatch-testing")).unwrap();
assert_eq!(
db_filename_in("testing", &data, &config, &cache),
"sqlite.db"
);
assert_eq!(
config_filename_in("testing", &data, &config, &cache),
"config.toml"
);
let _ = fs::remove_dir_all(_root);
}
#[test]
fn test_validate_profile() {
assert!(validate_profile("default").is_ok());
assert!(validate_profile("testing").is_ok());
assert!(validate_profile("research").is_ok());
assert!(validate_profile("my-profile").is_ok());
assert!(validate_profile("profile_1").is_ok());
assert!(validate_profile("").is_err());
assert!(
validate_profile("Research").is_err(),
"uppercase should be rejected"
);
assert!(validate_profile("-bad").is_err(), "must start with alnum");
assert!(validate_profile("bad name").is_err(), "spaces not allowed");
assert!(
validate_profile("a/b").is_err(),
"path separator not allowed"
);
assert!(validate_profile(&"a".repeat(33)).is_err(), "too long");
}
#[test]
fn test_get_dirs() {
#[cfg(target_os = "android")]
set_android_data_dir("/test");
get_cache_dir().unwrap();
get_log_dir("aw-server-rust").unwrap();
// Do not call db_path("testing"): on a fresh CI machine that would create
// ~/.local/share/activitywatch-testing and pin later tests to the new root.
db_path("default").unwrap();
}
#[test]
#[cfg(not(target_os = "android"))]
fn test_log_dir_has_log_component() {
let log_dir = get_log_dir("aw-server-rust").unwrap();
let path_str = log_dir.to_string_lossy();
// The log path must contain a log-specific subdirectory, not just the cache dir.
// This guards against the regression from PR #562 where /log was dropped.
#[cfg(target_os = "linux")]
assert!(
path_str.contains("activitywatch/log/"),
"Linux log path should contain activitywatch/log/, got: {}",
path_str
);
#[cfg(target_os = "macos")]
assert!(
path_str.contains("Library/Logs/activitywatch"),
"macOS log path should use Library/Logs, got: {}",
path_str
);
#[cfg(target_os = "windows")]
assert!(
path_str.contains("activitywatch\\Logs\\") || path_str.contains("activitywatch/Logs/"),
"Windows log path should contain activitywatch/Logs, got: {}",
path_str
);
}

View File

@@ -0,0 +1,409 @@
//! API key authentication via Bearer token.
//!
//! When `api_key` is set under `[auth]` in the config, all API endpoints except
//! `/api/0/info` require an `Authorization: Bearer <key>` header. Requests
//! missing or presenting an invalid key receive a 401 Unauthorized response.
//!
//! By default `api_key` is `None`, meaning authentication is disabled.
//! To enable, add to `config.toml`:
//!
//! ```toml
//! [auth]
//! api_key = "your-secret-key-here"
//! ```
//!
//! Exempt paths (always public):
//! - `GET /api/0/info` — health/version endpoint used by clients and the webui
//!
//! CORS preflight requests (OPTIONS) are also passed through unconditionally so
//! the browser can obtain allowed headers before sending the actual request.
//!
//! # Path matching
//!
//! The gate matches on `request.uri().path().segments()` — the same
//! percent-decoded, empty-skipping segment view Rocket's router uses to select
//! a handler (see `rocket::router::collider::paths_match`). Comparing the raw
//! path string instead lets an attacker desynchronize the gate from the router
//! (e.g. `/%61pi/0/buckets/` or `//api/0/buckets/`), skipping auth on a request
//! the router still dispatches to a real `/api/` handler.
use subtle::ConstantTimeEq;
use rocket::fairing::Fairing;
use rocket::http::uri::Origin;
use rocket::http::{Method, Status};
use rocket::route::Outcome;
use rocket::{Data, Request, Rocket, Route};
use crate::config::AWConfig;
use crate::endpoints::HttpErrorJson;
static FAIRING_ROUTE_BASE: &str = "/apikey_fairing";
/// Path segments gated by API key authentication.
const API_SEGMENT: &str = "api";
/// Paths that are always accessible without authentication, as decoded segments.
const PUBLIC_PATHS: &[&[&str]] = &[&["api", "0", "info"]];
pub struct ApiKeyCheck {
api_key: Option<String>,
}
impl ApiKeyCheck {
pub fn new(config: &AWConfig) -> ApiKeyCheck {
let api_key = match &config.auth.api_key {
Some(k) if k.is_empty() => {
warn!("api_key is set to an empty string — authentication is disabled. Set a non-empty key to enable auth.");
None
}
other => other.clone(),
};
ApiKeyCheck { api_key }
}
}
/// Route handler that returns 401 Unauthorized for failed auth checks.
#[derive(Clone)]
struct FairingErrorRoute {}
#[rocket::async_trait]
impl rocket::route::Handler for FairingErrorRoute {
async fn handle<'r>(
&self,
request: &'r Request<'_>,
_: rocket::Data<'r>,
) -> rocket::route::Outcome<'r> {
let err = HttpErrorJson::new(
Status::Unauthorized,
"Missing or invalid API key. Set 'Authorization: Bearer <key>' header.".to_string(),
);
Outcome::from(request, err)
}
}
fn fairing_route() -> Route {
Route::ranked(1, Method::Get, "/", FairingErrorRoute {})
}
fn redirect_unauthorized(request: &mut Request) {
let uri = FAIRING_ROUTE_BASE.to_string();
let origin = Origin::parse_owned(uri).unwrap();
request.set_method(Method::Get);
request.set_uri(origin);
}
#[rocket::async_trait]
impl Fairing for ApiKeyCheck {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "ApiKeyCheck",
kind: rocket::fairing::Kind::Ignite | rocket::fairing::Kind::Request,
}
}
async fn on_ignite(&self, rocket: Rocket<rocket::Build>) -> rocket::fairing::Result {
match &self.api_key {
Some(_) => Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()])),
None => {
debug!("API key authentication is disabled");
Ok(rocket)
}
}
}
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
let api_key = match &self.api_key {
None => return, // auth disabled
Some(k) => k,
};
// Always allow OPTIONS (CORS preflight)
if request.method() == Method::Options {
return;
}
// Match on the same decoded segment view the router uses to dispatch,
// so the gate cannot be desynchronized from the handler it protects.
let segments: Vec<&str> = request.uri().path().segments().collect();
// Only gate API endpoints — static web UI assets are not under /api/
if segments.first() != Some(&API_SEGMENT) {
return;
}
// Always allow public API paths (e.g. /api/0/info for health checks)
if PUBLIC_PATHS.contains(&segments.as_slice()) {
return;
}
// Validate Authorization: Bearer <key>
// Use constant-time comparison to prevent timing attacks.
let auth_header = request.headers().get_one("Authorization");
let valid = match auth_header {
Some(value) => {
if let Some(token) = value.strip_prefix("Bearer ") {
token.as_bytes().ct_eq(api_key.as_bytes()).into()
} else {
false
}
}
None => false,
};
if !valid {
debug!("API key check failed for {}", request.uri());
redirect_unauthorized(request);
}
}
}
#[cfg(test)]
mod tests {
use rocket::http::{ContentType, Header, Status};
use rocket::Rocket;
use crate::config::AWConfig;
use crate::endpoints;
fn setup_testserver(api_key: Option<String>) -> Rocket<rocket::Build> {
let state = endpoints::ServerState {
datastore: aw_datastore::Datastore::new_in_memory(false),
asset_resolver: endpoints::AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let mut aw_config = AWConfig::default();
aw_config.auth.api_key = api_key;
endpoints::build_rocket(state, aw_config)
}
#[test]
fn test_no_api_key_configured() {
// When no api_key is set, all endpoints are accessible without auth.
let server = setup_testserver(None);
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
#[test]
fn test_api_key_required() {
// With api_key set, requests without a key should be rejected.
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
// /api/0/info is always public
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
// Other endpoints require auth
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
// Double slash should also require auth
let res = client
.get("//api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}
/// Percent-encoded paths must not bypass the gate.
///
/// Rocket's router decodes the path before matching, so a fairing that
/// compares the *raw* path string can be desynchronized from the handler
/// it protects: `/%61pi/0/buckets/` reaches the `/api/0/buckets/` handler
/// while a raw `starts_with("/api/")` check sees `/%61pi/...` and skips auth.
#[test]
fn test_api_key_percent_encoded_paths_require_auth() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
// Each of these decodes to the real /api/0/buckets/ route, so each must
// be rejected by the auth gate specifically — asserting "not 200" would
// also pass on a 404, hiding a route miss as an auth success.
for path in [
"/%61pi/0/buckets/", // lowercase hex, first char
"/ap%69/0/buckets/", // encoded char mid-segment
"/%61%70%69/0/buckets/", // fully encoded segment
"//%61pi/0/buckets/", // encoding combined with the #588 double-slash trick
] {
let res = client
.get(path)
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(
res.status(),
Status::Unauthorized,
"expected auth rejection for encoded path {path}"
);
}
// Sanity check on the above: these paths really do reach the buckets
// handler once a valid key is supplied, so the 401s are the gate
// rejecting them rather than the router failing to match.
for path in ["/%61pi/0/buckets/", "//%61pi/0/buckets/"] {
let res = client
.get(path)
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Bearer secret123"))
.dispatch();
assert_eq!(
res.status(),
Status::Ok,
"encoded path {path} does not reach the real handler"
);
}
// Case is significant: /API/ is not a registered route at all, so this
// 404s at the router rather than exercising the gate. Asserted
// explicitly so a future case-insensitive router change would show up
// here instead of silently passing a "not 200" check.
let res = client
.get("/%41PI/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::NotFound);
}
/// Writes must be gated too — a bypass that only blocked GETs would still
/// allow bucket creation / event insertion.
#[test]
fn test_api_key_percent_encoded_write_requires_auth() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.post("/%61pi/0/buckets/authbypass-poc")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.body(r#"{"type":"test","client":"authbypass-poc","hostname":"poc-host"}"#)
.dispatch();
assert_eq!(
res.status(),
Status::Unauthorized,
"write bypassed via encoded path"
);
// And the bucket must not exist afterwards — 404 from an authenticated
// lookup, not merely "some non-200 status".
let res = client
.get("/api/0/buckets/authbypass-poc")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Bearer secret123"))
.dispatch();
assert_eq!(
res.status(),
Status::NotFound,
"bucket was created without auth"
);
}
/// The public-path exemption is matched on decoded segments, so encoded
/// spellings of `/api/0/info` stay public (parity with the router) while
/// nothing else slips through the exemption.
#[test]
fn test_public_path_matched_on_decoded_segments() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/%69nfo")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
// A longer path that merely starts with the public prefix is not public:
// the exemption is an exact segment match, so this hits the gate.
let res = client
.get("/api/0/info/../buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}
#[test]
fn test_api_key_valid() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Bearer secret123"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
#[test]
fn test_api_key_invalid() {
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Bearer wrongkey"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}
#[test]
fn test_api_key_wrong_scheme() {
// Must be Bearer, not Basic or bare key
let server = setup_testserver(Some("secret123".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Authorization", "Basic secret123"))
.dispatch();
assert_eq!(res.status(), Status::Unauthorized);
}
#[test]
fn test_empty_api_key_disables_auth() {
// An empty string key should be treated as disabled (no auth required).
let server = setup_testserver(Some("".to_string()));
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
}

View File

@@ -0,0 +1,233 @@
use std::collections::HashMap;
use gethostname::gethostname;
use rocket::serde::json::Json;
use chrono::DateTime;
use chrono::Utc;
use aw_datastore::DatastoreError;
use aw_models::Bucket;
use aw_models::Event;
use rocket::http::Status;
use rocket::State;
use crate::endpoints::util::BucketsExportRocket;
use crate::endpoints::{HttpErrorJson, ServerState};
#[get("/")]
pub fn buckets_get(
state: &State<ServerState>,
) -> Result<Json<HashMap<String, Bucket>>, HttpErrorJson> {
let datastore = &state.datastore;
match datastore.get_buckets() {
Ok(bucketlist) => Ok(Json(bucketlist)),
Err(err) => Err(err.into()),
}
}
#[get("/<bucket_id>")]
pub fn bucket_get(
bucket_id: &str,
state: &State<ServerState>,
) -> Result<Json<Bucket>, HttpErrorJson> {
let datastore = &state.datastore;
match datastore.get_bucket(bucket_id) {
Ok(bucket) => Ok(Json(bucket)),
Err(e) => Err(e.into()),
}
}
/// Create a new bucket
///
/// If hostname is "!local", the hostname and device_id will be set from the server info.
/// This is useful for watchers which are known/assumed to run locally but might not know their hostname (like aw-watcher-web).
#[post("/<bucket_id>", data = "<message>", format = "application/json")]
pub fn bucket_new(
bucket_id: &str,
message: Json<Bucket>,
state: &State<ServerState>,
) -> Result<(), HttpErrorJson> {
let mut bucket = message.into_inner();
if bucket.id != bucket_id {
bucket.id = bucket_id.to_string();
}
// Reject client-supplied hostnames containing whitespace. Such hostnames come from
// misconfigured clients and produce buckets that are awkward to address and query.
//
// Two deliberate exemptions keep this from breaking working setups:
// - The "!local" sentinel is resolved server-side below and is not validated here,
// so a machine whose own hostname contains a space is unaffected.
// - Buckets that already exist are not validated, so watchers that idempotently
// re-create a pre-existing bucket on startup keep getting the established
// "already exists" response instead of a new hard error.
if bucket.hostname != "!local" && bucket.hostname.contains(char::is_whitespace) {
// Distinguish "bucket genuinely absent" from "datastore unavailable":
// only the former should trigger the whitespace rejection (400). An
// InternalError means the datastore worker is down and must become 500,
// not be silently swallowed and misreported as invalid client input.
match state.datastore.get_bucket(&bucket.id) {
Ok(_) => {} // existing bucket — skip validation; create_bucket returns 304
Err(DatastoreError::NoSuchBucket(_)) => {
// Format the hostname with Debug to escape control characters, so
// untrusted request data cannot inject newlines into the log.
let err_msg = format!(
"Invalid hostname {:?}: hostname may not contain whitespace",
bucket.hostname
);
warn!("{}", err_msg);
return Err(HttpErrorJson::new(Status::BadRequest, err_msg));
}
Err(e) => return Err(e.into()), // datastore failure → 500
}
}
if bucket.hostname == "!local" {
bucket.hostname = gethostname()
.into_string()
.unwrap_or_else(|_| "unknown".to_string());
bucket
.data
.insert("device_id".to_string(), state.device_id.clone().into());
}
let datastore = &state.datastore;
let ret = datastore.create_bucket(&bucket);
match ret {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
#[get("/<bucket_id>/events?<start>&<end>&<limit>")]
pub fn bucket_events_get(
bucket_id: &str,
start: Option<String>,
end: Option<String>,
limit: Option<u64>,
state: &State<ServerState>,
) -> Result<Json<Vec<Event>>, HttpErrorJson> {
let starttime: Option<DateTime<Utc>> = match start {
Some(dt_str) => match DateTime::parse_from_rfc3339(&dt_str) {
Ok(dt) => Some(dt.with_timezone(&Utc)),
Err(e) => {
let err_msg = format!(
"Failed to parse starttime, datetime needs to be in rfc3339 format: {e}"
);
warn!("{}", err_msg);
return Err(HttpErrorJson::new(Status::BadRequest, err_msg));
}
},
None => None,
};
let endtime: Option<DateTime<Utc>> = match end {
Some(dt_str) => match DateTime::parse_from_rfc3339(&dt_str) {
Ok(dt) => Some(dt.with_timezone(&Utc)),
Err(e) => {
let err_msg =
format!("Failed to parse endtime, datetime needs to be in rfc3339 format: {e}");
warn!("{}", err_msg);
return Err(HttpErrorJson::new(Status::BadRequest, err_msg));
}
},
None => None,
};
let datastore = &state.datastore;
let res = datastore.get_events(bucket_id, starttime, endtime, limit);
match res {
Ok(events) => Ok(Json(events)),
Err(err) => Err(err.into()),
}
}
// Needs unused parameter, otherwise there'll be a route collision
// See: https://api.rocket.rs/master/rocket/struct.Route.html#resolving-collisions
#[get("/<bucket_id>/events/<event_id>?<_unused..>")]
pub fn bucket_events_get_single(
bucket_id: &str,
event_id: i64,
_unused: Option<u64>,
state: &State<ServerState>,
) -> Result<Json<Event>, HttpErrorJson> {
let datastore = &state.datastore;
let res = datastore.get_event(bucket_id, event_id);
match res {
Ok(events) => Ok(Json(events)),
Err(err) => Err(err.into()),
}
}
#[post("/<bucket_id>/events", data = "<events>", format = "application/json")]
pub fn bucket_events_create(
bucket_id: &str,
events: Json<Vec<Event>>,
state: &State<ServerState>,
) -> Result<Json<Vec<Event>>, HttpErrorJson> {
let datastore = &state.datastore;
let res = datastore.insert_events(bucket_id, &events);
match res {
Ok(events) => Ok(Json(events)),
Err(err) => Err(err.into()),
}
}
#[post(
"/<bucket_id>/heartbeat?<pulsetime>",
data = "<heartbeat_json>",
format = "application/json"
)]
pub fn bucket_events_heartbeat(
bucket_id: &str,
heartbeat_json: Json<Event>,
pulsetime: f64,
state: &State<ServerState>,
) -> Result<Json<Event>, HttpErrorJson> {
let heartbeat = heartbeat_json.into_inner();
let datastore = &state.datastore;
match datastore.heartbeat(bucket_id, heartbeat, pulsetime) {
Ok(e) => Ok(Json(e)),
Err(err) => Err(err.into()),
}
}
#[get("/<bucket_id>/events/count")]
pub fn bucket_event_count(
bucket_id: &str,
state: &State<ServerState>,
) -> Result<Json<u64>, HttpErrorJson> {
let datastore = &state.datastore;
let res = datastore.get_event_count(bucket_id, None, None);
match res {
Ok(eventcount) => Ok(Json(eventcount as u64)),
Err(err) => Err(err.into()),
}
}
#[delete("/<bucket_id>/events/<event_id>")]
pub fn bucket_events_delete_by_id(
bucket_id: &str,
event_id: i64,
state: &State<ServerState>,
) -> Result<(), HttpErrorJson> {
let datastore = &state.datastore;
match datastore.delete_events_by_id(bucket_id, vec![event_id]) {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
#[get("/<bucket_id>/export")]
pub fn bucket_export(
bucket_id: &str,
state: &State<ServerState>,
) -> Result<BucketsExportRocket, HttpErrorJson> {
BucketsExportRocket::new(&state.datastore, Some(bucket_id))
}
#[delete("/<bucket_id>")]
pub fn bucket_delete(bucket_id: &str, state: &State<ServerState>) -> Result<(), HttpErrorJson> {
let datastore = &state.datastore;
match datastore.delete_bucket(bucket_id) {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}

View File

@@ -0,0 +1,47 @@
use rocket::http::Method;
use rocket_cors::{AllowedHeaders, AllowedOrigins};
use crate::config::AWConfig;
pub fn cors(config: &AWConfig) -> rocket_cors::Cors {
let root_url = format!("http://127.0.0.1:{}", config.port);
let root_url_localhost = format!("http://localhost:{}", config.port);
let mut allowed_exact_origins = vec![root_url, root_url_localhost];
allowed_exact_origins.extend(config.cors.clone());
if config.testing {
allowed_exact_origins.push("http://127.0.0.1:27180".to_string());
allowed_exact_origins.push("http://localhost:27180".to_string());
}
let mut allowed_regex_origins = vec![
"chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string(),
// Every version of a mozilla extension has its own ID to avoid fingerprinting, so we
// unfortunately have to allow all extensions to have access to aw-server.
// The endpoints this wildcard actually reaches are narrowed to the ones
// aw-watcher-web needs by the ExtensionCorsScope fairing — see
// endpoints/extension_cors.rs.
"moz-extension://.*".to_string(),
];
allowed_regex_origins.extend(config.cors_regex.clone());
if config.testing {
allowed_regex_origins.push("chrome-extension://.*".to_string());
}
let allowed_origins = AllowedOrigins::some(&allowed_exact_origins, &allowed_regex_origins);
let allowed_methods = vec![Method::Get, Method::Post, Method::Delete]
.into_iter()
.map(From::from)
.collect();
let allowed_headers = AllowedHeaders::all(); // TODO: is this unsafe?
// You can also deserialize this
rocket_cors::CorsOptions {
allowed_origins,
allowed_methods,
allowed_headers,
allow_credentials: false,
..Default::default()
}
.to_cors()
.expect("Failed to set up CORS")
}

View File

@@ -0,0 +1,9 @@
use rocket::State;
use crate::endpoints::util::BucketsExportRocket;
use crate::endpoints::{HttpErrorJson, ServerState};
#[get("/")]
pub fn buckets_export(state: &State<ServerState>) -> Result<BucketsExportRocket, HttpErrorJson> {
BucketsExportRocket::new(&state.datastore, None)
}

View File

@@ -0,0 +1,521 @@
//! Endpoint scoping for the blanket `moz-extension://` CORS origin.
//!
//! Firefox gives every install of every extension its own random origin, so
//! there is no way to allowlist *only* `aw-watcher-web` up front — hence the
//! `moz-extension://.*` wildcard in [`crate::endpoints::cors`]. The cost is
//! that any installed extension can talk to the local API, with no host
//! permission and therefore no install-time prompt naming ActivityWatch.
//!
//! This fairing narrows that blanket trust to the endpoints `aw-watcher-web`
//! actually uses:
//!
//! - `GET /api/0/info` — hostname/version detection
//! - `POST /api/0/buckets/aw-watcher-web-<id>` — ensure its web-watcher bucket
//! - `POST /api/0/buckets/aw-watcher-web-<id>/heartbeat` — heartbeats
//!
//! # Accepted limitation
//!
//! The bucket prefix is a coarse scope, not an origin-to-bucket ownership
//! boundary. Any wildcard-matched Firefox extension can still create or send
//! heartbeats to another existing `aw-watcher-web-*` bucket. This preserves
//! the official watcher's zero-configuration flow while blocking writes to
//! other watcher types; eliminating web-bucket poisoning requires pairing an
//! extension origin with its bucket in a future protocol.
//!
//! Everything else (`/api/0/export`, `/api/0/import`, `/api/0/query`,
//! `/api/0/settings`, event reads, bucket deletion, ...) is closed to
//! wildcard-matched extension origins. That removes the data-exfiltration half
//! of the problem — app names, window titles, tab URLs, AFK data — without any
//! client change.
//!
//! Origins the *user* allowed explicitly via `cors_regex` in `config.toml`
//! are exempt: an explicit allowlist entry is a deliberate opt-in, unlike the
//! built-in wildcard. (The exact `cors` list cannot express an extension
//! origin — rocket_cors rejects `moz-extension://` there as an opaque origin —
//! so `cors_regex` is the only opt-in path for these.)
//!
//! # Enforcement
//!
//! Two stages, because CORS alone only hides *responses*:
//!
//! - `on_request` rejects disallowed requests with 403 before they reach a
//! handler. Without this, a "simple" cross-origin `POST` (e.g.
//! `text/plain`) is sent by the browser and still executes server-side even
//! though the response is unreadable.
//! - `on_response` strips the `Access-Control-*` headers so preflights for
//! disallowed endpoints fail in the browser.
//!
//! # Path matching
//!
//! Like [`crate::endpoints::apikey`], matching is done on
//! `request.uri().path().segments()` — the same percent-decoded,
//! empty-skipping view Rocket's router dispatches on. Matching the raw path
//! string would let `/%61pi/0/export` desynchronize this gate from the handler
//! it protects (the bug class of #588 and #636).
use regex::RegexSet;
use rocket::fairing::Fairing;
use rocket::http::uri::Origin;
use rocket::http::{Method, Status};
use rocket::route::Outcome;
use rocket::{Data, Request, Response, Rocket, Route};
use crate::config::AWConfig;
use crate::endpoints::HttpErrorJson;
static FAIRING_ROUTE_BASE: &str = "/extension_cors_fairing";
/// Scheme of the origins covered by the built-in wildcard.
const EXTENSION_ORIGIN_SCHEME: &str = "moz-extension://";
/// CORS response headers to strip when an extension origin is out of scope.
const CORS_HEADERS: &[&str] = &[
"Access-Control-Allow-Origin",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age",
];
pub struct ExtensionCorsScope {
/// Regexes from `[server] cors_regex`, matched unanchored like rocket_cors.
/// The exact `cors` list is not consulted: rocket_cors cannot hold an
/// extension origin there, so it can never allow one.
user_regex_origins: Option<RegexSet>,
}
impl ExtensionCorsScope {
pub fn new(config: &AWConfig) -> ExtensionCorsScope {
let user_regex_origins = if config.cors_regex.is_empty() {
None
} else {
match RegexSet::new(&config.cors_regex) {
Ok(set) => Some(set),
Err(e) => {
// rocket_cors will fail on the same input; don't also panic here.
warn!("Invalid cors_regex in config, ignoring for extension scoping: {e}");
None
}
}
};
ExtensionCorsScope { user_regex_origins }
}
/// True if the user explicitly allowed this origin in their config.
fn user_allowed(&self, origin: &str) -> bool {
match &self.user_regex_origins {
Some(set) => set.is_match(origin),
None => false,
}
}
/// True if this request must be blocked for the given origin.
fn is_blocked(&self, request: &Request) -> bool {
let origin = match request.headers().get_one("Origin") {
// No Origin header: not a browser cross-origin request (native
// watchers, curl). CORS is not the relevant control there.
None => return false,
Some(origin) => origin,
};
if !is_extension_origin(origin) || self.user_allowed(origin) {
return false;
}
let method = match effective_method(request) {
// A preflight without Access-Control-Request-Method tells us
// nothing about the real request; fail closed.
None => return true,
Some(method) => method,
};
let segments: Vec<&str> = request.uri().path().segments().collect();
!is_watcher_endpoint(method, &segments)
}
}
/// Origins matched by the built-in `moz-extension://.*` wildcard.
fn is_extension_origin(origin: &str) -> bool {
origin
.to_ascii_lowercase()
.starts_with(EXTENSION_ORIGIN_SCHEME)
}
/// The method the browser will actually use: for a preflight that is the
/// `Access-Control-Request-Method` header, not `OPTIONS`.
fn effective_method(request: &Request) -> Option<Method> {
if request.method() == Method::Options {
request
.headers()
.get_one("Access-Control-Request-Method")
.and_then(|m| m.parse::<Method>().ok())
} else {
Some(request.method())
}
}
/// The endpoints `aw-watcher-web` needs, as decoded path segments.
///
/// The prefix blocks writes to other watcher types, but deliberately does not
/// bind an extension origin to one `aw-watcher-web-*` bucket. See the accepted
/// limitation in the module documentation.
fn is_watcher_endpoint(method: Method, segments: &[&str]) -> bool {
match (method, segments) {
(Method::Get, ["api", "0", "info"]) => true,
(Method::Post, ["api", "0", "buckets", bucket_id])
| (Method::Post, ["api", "0", "buckets", bucket_id, "heartbeat"]) => {
bucket_id.starts_with("aw-watcher-web-")
}
_ => false,
}
}
/// Route handler returning 403 for out-of-scope extension requests.
#[derive(Clone)]
struct FairingErrorRoute {}
#[rocket::async_trait]
impl rocket::route::Handler for FairingErrorRoute {
async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> {
let err = HttpErrorJson::new(
Status::Forbidden,
"This endpoint is not available to browser extension origins. \
Add the origin to 'cors_regex' in config.toml to allow it."
.to_string(),
);
Outcome::from(request, err)
}
}
fn fairing_route() -> Route {
Route::ranked(1, Method::Get, "/", FairingErrorRoute {})
}
fn redirect_forbidden(request: &mut Request) {
let uri = FAIRING_ROUTE_BASE.to_string();
let origin = Origin::parse_owned(uri).unwrap();
request.set_method(Method::Get);
request.set_uri(origin);
}
#[rocket::async_trait]
impl Fairing for ExtensionCorsScope {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "ExtensionCorsScope",
kind: rocket::fairing::Kind::Ignite
| rocket::fairing::Kind::Request
| rocket::fairing::Kind::Response,
}
}
async fn on_ignite(&self, rocket: Rocket<rocket::Build>) -> rocket::fairing::Result {
Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()]))
}
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
// Preflights are answered by rocket_cors; on_response strips the
// CORS headers so the browser rejects the preflight itself.
if request.method() == Method::Options {
return;
}
if self.is_blocked(request) {
debug!(
"Blocking extension origin {:?} from {}",
request.headers().get_one("Origin"),
request.uri()
);
redirect_forbidden(request);
}
}
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
if self.is_blocked(request) {
for header in CORS_HEADERS {
response.remove_header(header);
}
}
}
}
#[cfg(test)]
mod tests {
use rocket::http::{ContentType, Header, Status};
use rocket::local::blocking::Client;
use rocket::Rocket;
use crate::config::AWConfig;
use crate::endpoints;
const EXT_ORIGIN: &str = "moz-extension://3f2b1c9d-dead-beef-cafe-000000000000";
const WEBUI_ORIGIN: &str = "http://127.0.0.1:5600";
fn setup_testserver(cors_regex: Vec<String>) -> Rocket<rocket::Build> {
let state = endpoints::ServerState {
datastore: aw_datastore::Datastore::new_in_memory(false),
asset_resolver: endpoints::AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let mut aw_config = AWConfig::default();
// Pin the port: the default is derived from a mutable global that other
// tests flip via create_config(), which would otherwise make the
// allowed webui origin depend on test execution order.
aw_config.port = 5600;
aw_config.cors_regex = cors_regex;
endpoints::build_rocket(state, aw_config)
}
fn client() -> Client {
Client::tracked(setup_testserver(vec![])).expect("valid instance")
}
fn allow_origin(res: &rocket::local::blocking::LocalResponse) -> Option<String> {
res.headers()
.get_one("Access-Control-Allow-Origin")
.map(str::to_string)
}
/// The reported exfiltration path: an extension reading the full export.
#[test]
fn test_extension_origin_blocked_from_export() {
let client = client();
let res = client
.get("/api/0/export")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(res.status(), Status::Forbidden);
assert_eq!(allow_origin(&res), None, "export readable by any extension");
}
/// The other half of the report: writing events through /api/0/import.
#[test]
fn test_extension_origin_blocked_from_import_preflight() {
let client = client();
let res = client
.options("/api/0/import")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.header(Header::new("Access-Control-Request-Method", "POST"))
.header(Header::new(
"Access-Control-Request-Headers",
"content-type",
))
.dispatch();
assert_eq!(allow_origin(&res), None, "import preflight still allowed");
assert_eq!(res.headers().get_one("Access-Control-Allow-Methods"), None);
}
/// Everything aw-watcher-web needs must keep working, unchanged.
#[test]
fn test_watcher_endpoints_still_allowed() {
let client = client();
let res = client
.get("/api/0/info")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(res.status(), Status::Ok);
assert_eq!(allow_origin(&res).as_deref(), Some(EXT_ORIGIN));
let res = client
.post("/api/0/buckets/aw-watcher-web-firefox_testhost")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.body(r#"{"type":"web.tab.current","client":"aw-client-web","hostname":"testhost"}"#)
.dispatch();
assert_ne!(res.status(), Status::Forbidden, "ensureBucket blocked");
assert_eq!(allow_origin(&res).as_deref(), Some(EXT_ORIGIN));
let res = client
.post("/api/0/buckets/aw-watcher-web-firefox_testhost/heartbeat?pulsetime=60")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.body(
r#"{"timestamp":"2026-07-28T09:00:00+00:00","duration":0,
"data":{"url":"https://example.com","title":"Example","audible":false,
"incognito":false,"tabCount":1}}"#,
)
.dispatch();
assert_eq!(res.status(), Status::Ok, "heartbeat blocked");
assert_eq!(allow_origin(&res).as_deref(), Some(EXT_ORIGIN));
}
/// A wildcard-matched extension must not create or write to buckets owned
/// by other watchers. The URL bucket ID is the downstream authorization
/// boundary, so this check must happen before the handler runs.
#[test]
fn test_extension_cannot_write_other_watcher_bucket() {
let client = client();
let res = client
.post("/api/0/buckets/aw-watcher-window_testhost")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.body(r#"{"type":"currentwindow","client":"aw-client","hostname":"testhost"}"#)
.dispatch();
assert_eq!(res.status(), Status::Forbidden, "foreign bucket created");
assert_eq!(allow_origin(&res), None);
let res = client
.post("/api/0/buckets/aw-watcher-window_testhost/heartbeat?pulsetime=60")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.body(
r#"{"timestamp":"2026-07-28T09:00:00+00:00","duration":0,
"data":{"app":"attacker","title":"injected"}}"#,
)
.dispatch();
assert_eq!(res.status(), Status::Forbidden, "foreign heartbeat written");
assert_eq!(allow_origin(&res), None);
}
/// Preflight for a watcher endpoint must still succeed — aw-client-js
/// sends JSON, which is always preflighted.
#[test]
fn test_watcher_preflight_still_allowed() {
let client = client();
let res = client
.options("/api/0/buckets/aw-watcher-web-firefox_testhost/heartbeat")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.header(Header::new("Access-Control-Request-Method", "POST"))
.header(Header::new(
"Access-Control-Request-Headers",
"content-type",
))
.dispatch();
assert_eq!(allow_origin(&res).as_deref(), Some(EXT_ORIGIN));
}
/// Scoping is per (method, path): the bucket path is writable for create,
/// but must not become a deletion or read channel.
#[test]
fn test_bucket_path_scoped_by_method() {
let client = client();
let res = client
.delete("/api/0/buckets/aw-watcher-web-firefox_testhost")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(res.status(), Status::Forbidden, "bucket deletable");
let res = client
.get("/api/0/buckets/aw-watcher-web-firefox_testhost/events")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(res.status(), Status::Forbidden, "events readable");
}
/// Same desync bug class as #588/#636: matching the raw path string would
/// let an encoded spelling reach the handler while skipping this gate.
#[test]
fn test_encoded_paths_do_not_bypass_scope() {
let client = client();
for path in [
"/%61pi/0/export", // encoded 'a' in /api/
"/api/0/%65xport", // encoded 'e' in /export
"//api/0/export", // #588 double-slash trick
"/%61pi/0/%65xport", // both
"/api/0/info/../query", // dot-segment
] {
let res = client
.get(path)
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(
res.status(),
Status::Forbidden,
"extension scope bypassed via {path}"
);
assert_eq!(allow_origin(&res), None, "CORS header leaked for {path}");
}
// Sanity check: these paths really do reach the export handler without
// an extension Origin, so the 403s above are this gate rejecting them
// rather than the router failing to match.
for path in ["/%61pi/0/export", "//api/0/export", "/api/0/%65xport"] {
let res = client
.get(path)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(
res.status(),
Status::Ok,
"{path} does not reach the handler"
);
}
}
/// Non-extension origins are untouched — the webui is same-origin and
/// still needs full access.
#[test]
fn test_webui_origin_unaffected() {
let client = client();
let res = client
.get("/api/0/export")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", WEBUI_ORIGIN))
.dispatch();
assert_eq!(res.status(), Status::Ok);
assert_eq!(allow_origin(&res).as_deref(), Some(WEBUI_ORIGIN));
}
/// Native clients send no Origin header and must be unaffected.
#[test]
fn test_no_origin_unaffected() {
let client = client();
let res = client
.get("/api/0/export")
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
/// An explicit `cors_regex` entry is a deliberate opt-in and keeps full
/// access — but only for the origins it actually matches.
#[test]
fn test_user_regex_allowlist_scoped_to_match() {
let server = setup_testserver(vec![format!("^{EXT_ORIGIN}$")]);
let client = Client::tracked(server).expect("valid instance");
let res = client
.get("/api/0/export")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(res.status(), Status::Ok);
// A different extension is still scoped.
let res = client
.get("/api/0/export")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new(
"Origin",
"moz-extension://00000000-0000-0000-0000-999999999999",
))
.dispatch();
assert_eq!(res.status(), Status::Forbidden);
}
/// A preflight that hides its real method must not pass.
#[test]
fn test_preflight_without_request_method_is_blocked() {
let client = client();
let res = client
.options("/api/0/buckets/some-bucket")
.header(Header::new("Host", "localhost:5600"))
.header(Header::new("Origin", EXT_ORIGIN))
.dispatch();
assert_eq!(allow_origin(&res), None);
}
}

View File

@@ -0,0 +1,195 @@
//! Host header check needs to be performed to protect against DNS poisoning
//! attacks[1].
//!
//! Uses a Request Fairing to intercept the request before it's handled.
//! If the Host header is not valid, the request will be rerouted to a
//! BadRequest
//!
//! [1]: https://github.com/ActivityWatch/activitywatch/security/advisories/GHSA-v9fg-6g9j-h4x4
use rocket::fairing::Fairing;
use rocket::http::uri::Origin;
use rocket::http::{Method, Status};
use rocket::route::Outcome;
use rocket::{Data, Request, Rocket, Route};
use crate::config::AWConfig;
use crate::endpoints::HttpErrorJson;
static FAIRING_ROUTE_BASE: &str = "/checkheader_fairing";
pub struct HostCheck {
validate: bool,
}
impl HostCheck {
pub fn new(config: &AWConfig) -> HostCheck {
// We only validate requests if the server binds a local address
let validate = config.address == "127.0.0.1" || config.address == "localhost";
HostCheck { validate }
}
}
/// Create a `Handler` for Fairing error handling
#[derive(Clone)]
struct FairingErrorRoute {}
#[rocket::async_trait]
impl rocket::route::Handler for FairingErrorRoute {
async fn handle<'r>(
&self,
request: &'r Request<'_>,
_: rocket::Data<'r>,
) -> rocket::route::Outcome<'r> {
let err = HttpErrorJson::new(Status::BadRequest, "Host header is invalid".to_string());
Outcome::from(request, err)
}
}
/// Create a new `Route` for Fairing handling
fn fairing_route() -> Route {
Route::ranked(1, Method::Get, "/", FairingErrorRoute {})
}
fn redirect_bad_request(request: &mut Request) {
let uri = FAIRING_ROUTE_BASE.to_string();
let origin = Origin::parse_owned(uri).unwrap();
request.set_method(Method::Get);
request.set_uri(origin);
}
#[rocket::async_trait]
impl Fairing for HostCheck {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "HostCheck",
kind: rocket::fairing::Kind::Ignite | rocket::fairing::Kind::Request,
}
}
async fn on_ignite(&self, rocket: Rocket<rocket::Build>) -> rocket::fairing::Result {
match self.validate {
true => Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()])),
false => {
warn!("Host header validation is turned off, this is a security risk");
Ok(rocket)
}
}
}
async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) {
if !self.validate {
// host header check is disabled
return;
}
// Fetch header
let hostheader_opt = request.headers().get_one("host");
if hostheader_opt.is_none() {
info!("Missing 'Host' header, denying request");
redirect_bad_request(request);
return;
}
// Parse hostname from host header
// hostname contains port, which we don't care about and filter out
let hostheader = hostheader_opt.unwrap();
let host_opt = hostheader.split(':').next();
if host_opt.is_none() {
info!("Host header '{}' not allowed, denying request", hostheader);
redirect_bad_request(request);
return;
}
// Deny requests to hosts that are not localhost
let valid_hosts: Vec<&str> = vec!["127.0.0.1", "localhost"];
let host = host_opt.unwrap();
if !valid_hosts.contains(&host) {
info!("Host header '{}' not allowed, denying request", hostheader);
redirect_bad_request(request);
}
// host header is verified, proceed with request
}
}
#[cfg(test)]
mod tests {
use rocket::http::{ContentType, Header, Status};
use rocket::Rocket;
use crate::config::AWConfig;
use crate::endpoints;
fn setup_testserver(address: String) -> Rocket<rocket::Build> {
let state = endpoints::ServerState {
datastore: aw_datastore::Datastore::new_in_memory(false),
asset_resolver: endpoints::AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let aw_config = AWConfig {
address,
..AWConfig::default()
};
endpoints::build_rocket(state, aw_config)
}
#[test]
fn test_public_address() {
let server = setup_testserver("0.0.0.0".to_string());
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
// When a public address is used, request should always pass, regardless
// if the Host header is missing
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
#[test]
fn test_localhost_address() {
let server = setup_testserver("127.0.0.1".to_string());
let client = rocket::local::blocking::Client::tracked(server).expect("valid instance");
// If Host header is missing we should get a BadRequest
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.dispatch();
assert_eq!(res.status(), Status::BadRequest);
// If Host header is not 127.0.0.1 or localhost we should get BadRequest
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "192.168.0.1:1234"))
.dispatch();
assert_eq!(res.status(), Status::BadRequest);
// If Host header is 127.0.0.1:5600 we should get OK
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
// If Host header is localhost:5600 we should get OK
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost:5600"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
// If Host header is missing port, we should still get OK
let res = client
.get("/api/0/info")
.header(ContentType::JSON)
.header(Header::new("Host", "localhost"))
.dispatch();
assert_eq!(res.status(), Status::Ok);
}
}

View File

@@ -0,0 +1,146 @@
use rocket::form::Form;
use rocket::http::Status;
use rocket::serde::json::Json;
use rocket::State;
use std::collections::{BTreeMap, HashSet};
use aw_models::{BucketsExport, Event};
use aw_datastore::{Datastore, DatastoreError};
use crate::endpoints::{HttpErrorJson, ServerState};
/// Computes a dedup identity tuple for an event.
///
/// Uses canonical JSON serialization (sorted keys via `BTreeMap`) so that
/// events with identical key-value pairs but different insertion order
/// (e.g., from different clients) are correctly identified as duplicates.
fn event_identity(
event: &Event,
) -> Result<(chrono::DateTime<chrono::Utc>, i64, String), HttpErrorJson> {
let duration_ns = event.duration.num_nanoseconds().ok_or_else(|| {
HttpErrorJson::new(
Status::InternalServerError,
"Failed to encode event duration for dedup".to_string(),
)
})?;
// Sort keys before serializing for canonical, order-independent dedup.
// This prevents missed duplicates when events from different clients
// serialize the same data with different key orderings.
let sorted: BTreeMap<_, _> = event.data.iter().collect();
let data_json = serde_json::to_string(&sorted).map_err(|e| {
HttpErrorJson::new(
Status::InternalServerError,
format!("Failed to encode event data for dedup: {e}"),
)
})?;
Ok((event.timestamp, duration_ns, data_json))
}
fn import(datastore: &Datastore, import: BucketsExport) -> Result<(), HttpErrorJson> {
for (_bucketname, mut bucket) in import.buckets {
match datastore.create_bucket(&bucket) {
Ok(_) => (),
Err(DatastoreError::BucketAlreadyExists(_)) => {
// Bucket already exists — merge events, skipping duplicates
info!("Bucket '{}' already exists, merging events", bucket.id);
if let Some(events) = bucket.events.take() {
let events_vec = events.take_inner();
if !events_vec.is_empty() {
// Determine time range of events to import
let start = events_vec.iter().map(|e| e.timestamp).min().unwrap();
let end = events_vec
.iter()
.map(|e| e.calculate_endtime())
.max()
.unwrap();
// Fetch existing events in that range to detect duplicates.
// Events without an explicit ID would otherwise be inserted as new rows
// via AUTOINCREMENT, silently creating duplicates on re-import.
//
// **Memory note**: This loads all events in the import time range into
// memory for O(1) dedup lookups. Typical Android re-imports involve a
// few thousand events (~1-2 MB), which is well within server bounds.
// Pathological cases (years of data) could be mitigated with pagination
// or a bloom filter if OOM issues arise in practice.
let existing = datastore
.get_events_unclipped(&bucket.id, Some(start), Some(end), None)
.map_err(|e| {
HttpErrorJson::new(
Status::InternalServerError,
format!(
"Failed to fetch existing events for dedup in '{}': {e:?}",
bucket.id
),
)
})?;
let existing_identities: HashSet<_> = existing
.iter()
.map(event_identity)
.collect::<Result<_, _>>()?;
// Filter out events already present (matched by timestamp, duration, data)
let new_events: Vec<_> = events_vec
.into_iter()
.map(|event| Ok((event_identity(&event)?, event)))
.collect::<Result<Vec<_>, HttpErrorJson>>()?
.into_iter()
.filter_map(|(identity, event)| {
(!existing_identities.contains(&identity)).then_some(event)
})
.collect();
if !new_events.is_empty() {
if let Err(e) = datastore.insert_events(&bucket.id, &new_events) {
let err_msg = format!(
"Failed to merge events into existing bucket '{}': {e:?}",
bucket.id
);
warn!("{}", err_msg);
return Err(HttpErrorJson::new(
Status::InternalServerError,
err_msg,
));
}
}
}
}
}
Err(e) => {
let err_msg = format!("Failed to import bucket: {e:?}");
warn!("{}", err_msg);
return Err(HttpErrorJson::new(Status::InternalServerError, err_msg));
}
}
}
Ok(())
}
#[post("/", data = "<json_data>", format = "application/json")]
pub fn bucket_import_json(
state: &State<ServerState>,
json_data: Json<BucketsExport>,
) -> Result<(), HttpErrorJson> {
import(&state.datastore, json_data.into_inner())
}
#[derive(FromForm)]
pub struct ImportForm {
// FIXME: In the web-ui the name of this field is buckets.json, but "." is not allowed in field
// names in Rocket and just simply "buckets" seems to work apparently but not sure why.
// FIXME: In aw-server python it will import all fields rather just the one named
// "buckets.json", that should probably be done here as well.
#[field(name = "buckets")]
import: Json<BucketsExport>,
}
#[post("/", data = "<form>", format = "multipart/form-data")]
pub fn bucket_import_form(
state: &State<ServerState>,
form: Form<ImportForm>,
) -> Result<(), HttpErrorJson> {
import(&state.datastore, form.into_inner().import.into_inner())
}

View File

@@ -0,0 +1,236 @@
use rust_embed::RustEmbed;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use gethostname::gethostname;
use rocket::fs::FileServer;
use rocket::http::ContentType;
use rocket::serde::json::Json;
use rocket::State;
use crate::config::AWConfig;
use aw_datastore::Datastore;
use aw_models::Info;
#[derive(RustEmbed)]
#[folder = "$AW_WEBUI_DIR"]
struct EmbeddedAssets;
pub struct AssetResolver {
asset_path: Option<PathBuf>,
}
impl AssetResolver {
pub fn new(asset_path: Option<PathBuf>) -> Self {
Self { asset_path }
}
fn resolve(&self, file_path: &str) -> Option<Vec<u8>> {
if let Some(asset_path) = &self.asset_path {
let content = std::fs::read(asset_path.join(file_path));
if let Ok(data) = content {
return Some(data);
}
}
Some(EmbeddedAssets::get(file_path)?.data.to_vec())
}
}
// The Datastore is just a cheap handle to the DB worker thread (a crossbeam
// channel sender), which serializes all DB access internally. No mutex is
// needed here — wrapping it in one would serialize all HTTP requests, letting
// a slow query block every heartbeat.
pub struct ServerState {
pub datastore: Datastore,
pub asset_resolver: AssetResolver,
pub device_id: String,
}
#[macro_use]
mod util;
mod apikey;
mod bucket;
mod cors;
mod export;
mod extension_cors;
mod hostcheck;
mod import;
mod query;
mod settings;
#[cfg(target_os = "android")]
pub(crate) use settings::settings_datastore_key;
pub use util::HttpErrorJson;
#[get("/")]
fn root_index(state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file("index.html".into(), state)
}
#[get("/css/<file..>")]
fn root_css(file: PathBuf, state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file(Path::new("css").join(file), state)
}
#[get("/fonts/<file..>")]
fn root_fonts(file: PathBuf, state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file(Path::new("fonts").join(file), state)
}
#[get("/js/<file..>")]
fn root_js(file: PathBuf, state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file(Path::new("js").join(file), state)
}
#[get("/static/<file..>")]
fn root_static(file: PathBuf, state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file(Path::new("static").join(file), state)
}
#[get("/favicon.ico")]
fn root_favicon(state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file("favicon.ico".into(), state)
}
#[get("/dark.css")]
fn root_dark(state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file("dark.css".into(), state)
}
#[get("/logo.png")]
fn root_logo(state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file("logo.png".into(), state)
}
#[get("/manifest.json")]
fn root_manifest(state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
get_file("manifest.json".into(), state)
}
#[get("/")]
fn server_info(config: &State<AWConfig>, state: &State<ServerState>) -> Json<Info> {
#[allow(clippy::or_fun_call)]
let hostname = gethostname().into_string().unwrap_or("unknown".to_string());
Json(Info {
hostname,
version: crate::version::version_string(),
testing: config.testing,
profile: crate::config::get_profile().to_string(),
device_id: state.device_id.clone(),
})
}
fn get_file(file: PathBuf, state: &State<ServerState>) -> Option<(ContentType, Vec<u8>)> {
let asset = state.asset_resolver.resolve(&file.display().to_string())?;
let content_type = file
.extension()
.and_then(OsStr::to_str)
.and_then(ContentType::from_extension)
.unwrap_or(ContentType::Bytes);
Some((content_type, asset))
}
pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rocket<rocket::Build> {
info!(
"Starting aw-server-rust at {}:{}",
config.address, config.port
);
let cors = cors::cors(&config);
let extension_cors = extension_cors::ExtensionCorsScope::new(&config);
let hostcheck = hostcheck::HostCheck::new(&config);
let apikey = apikey::ApiKeyCheck::new(&config);
let custom_static = config.custom_static.clone();
let mut rocket = rocket::custom(config.to_rocket_config())
.attach(cors.clone())
// Attached before the other request fairings so a blocked extension
// request is rewritten to the 403 route before they inspect the path.
.attach(extension_cors)
.attach(hostcheck)
.attach(apikey)
.manage(cors)
.manage(server_state)
.manage(config)
.mount(
"/",
routes![
root_index,
root_favicon,
root_fonts,
root_css,
root_js,
root_static,
// custom static files
root_dark,
root_logo,
root_manifest
],
)
.mount("/api/0/info", routes![server_info])
.mount(
"/api/0/buckets",
routes![
bucket::bucket_new,
bucket::bucket_delete,
bucket::buckets_get,
bucket::bucket_get,
bucket::bucket_events_get,
bucket::bucket_events_create,
bucket::bucket_events_heartbeat,
bucket::bucket_event_count,
bucket::bucket_events_get_single,
bucket::bucket_events_delete_by_id,
bucket::bucket_export
],
)
.mount("/api/0/query", routes![query::query])
.mount(
"/api/0/import",
routes![import::bucket_import_json, import::bucket_import_form],
)
.mount("/api/0/export", routes![export::buckets_export])
.mount(
"/api/0/settings",
routes![
settings::setting_get,
settings::setting_set,
settings::setting_delete,
settings::settings_get,
],
)
.mount("/", rocket_cors::catch_all_options_routes());
// for each custom static directory, mount it at the given name
for (name, dir) in custom_static {
info!(
"Serving /pages/{} custom static directory from {}",
name, dir
);
rocket = rocket.mount(&format!("/pages/{name}"), FileServer::from(dir));
}
rocket
}
mod tests {
#[test]
fn test_filesystem_resolver() {
let resolver = super::AssetResolver::new(Some(".".into()));
let content = resolver.resolve("Cargo.toml").unwrap();
assert!(String::from_utf8(content).unwrap().contains("aw-server"));
}
#[test]
fn test_resolver_without_asset() {
let resolver = super::AssetResolver::new(Some(".".into()));
let content = resolver.resolve("Cargo.json");
assert!(content.is_none());
}
}

View File

@@ -0,0 +1,65 @@
use rocket::http::Status;
use rocket::serde::json::{json, Json, Value};
use rocket::State;
use aw_models::Query;
use aw_query::QueryError;
use crate::endpoints::{HttpErrorJson, ServerState};
fn query_error_status(e: &QueryError) -> Status {
match e {
// BucketQueryError also wraps datastore failures, which are server
// errors rather than malformed client queries.
QueryError::BucketQueryError(_) => Status::InternalServerError,
QueryError::ParsingError(_)
| QueryError::EmptyQuery()
| QueryError::BucketNotFound(_)
| QueryError::VariableNotDefined(_)
| QueryError::MathError(_)
| QueryError::InvalidType(_)
| QueryError::InvalidFunctionParameters(_)
| QueryError::TimeIntervalError(_)
| QueryError::RegexCompileError(_) => Status::BadRequest,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_error_status_distinguishes_client_and_server_errors() {
assert_eq!(
query_error_status(&QueryError::RegexCompileError("invalid regex".into())),
Status::BadRequest
);
assert_eq!(
query_error_status(&QueryError::BucketNotFound("missing bucket".into())),
Status::BadRequest
);
assert_eq!(
query_error_status(&QueryError::BucketQueryError("datastore failure".into())),
Status::InternalServerError
);
}
}
#[post("/", data = "<query_req>", format = "application/json")]
pub fn query(query_req: Json<Query>, state: &State<ServerState>) -> Result<Value, HttpErrorJson> {
let query_code = query_req.0.query.join("\n");
let intervals = &query_req.0.timeperiods;
let mut results = Vec::new();
let datastore = &state.datastore;
for interval in intervals {
let result = match aw_query::query(&query_code, interval, datastore) {
Ok(data) => data,
Err(e) => {
warn!("Query failed: {:?}", e);
return Err(HttpErrorJson::new(query_error_status(&e), e.to_string()));
}
};
results.push(result);
}
Ok(json!(results))
}

View File

@@ -0,0 +1,156 @@
use crate::endpoints::ServerState;
use rocket::http::Status;
use rocket::serde::json::Json;
use rocket::State;
use std::collections::HashMap;
use aw_datastore::DatastoreError;
use crate::endpoints::HttpErrorJson;
/// Map a settings API key to the datastore key (`settings.<key>`).
///
/// Dots are allowed: `GET /api/0/settings/foo.bar` stores and retrieves
/// `settings.foo.bar`. Keys of length >= 128 are rejected (same rule as the
/// HTTP handler). Android JNI `getSetting` uses this helper so native callers
/// cannot silently miss a value the HTTP API would return.
pub(crate) fn settings_datastore_key(key: &str) -> Result<String, &'static str> {
if key.len() >= 128 {
Err("Too long key")
} else {
Ok(format!("settings.{}", key))
}
}
fn parse_key(key: String) -> Result<String, HttpErrorJson> {
settings_datastore_key(&key)
.map_err(|msg| HttpErrorJson::new(Status::BadRequest, msg.to_string()))
}
#[cfg(test)]
mod key_tests {
use super::settings_datastore_key;
#[test]
fn simple_keys_are_namespaced() {
assert_eq!(
settings_datastore_key("classes").unwrap(),
"settings.classes"
);
assert_eq!(
settings_datastore_key("startOfDay").unwrap(),
"settings.startOfDay"
);
}
#[test]
fn dotted_keys_are_namespaced_not_rejected() {
assert_eq!(
settings_datastore_key("foo.bar").unwrap(),
"settings.foo.bar"
);
assert_eq!(settings_datastore_key("a.b.c").unwrap(), "settings.a.b.c");
}
#[test]
fn overly_long_keys_are_rejected() {
assert!(settings_datastore_key(&"a".repeat(128)).is_err());
assert_eq!(
settings_datastore_key(&"a".repeat(127)).unwrap(),
format!("settings.{}", "a".repeat(127))
);
}
}
#[get("/")]
pub fn settings_get(
state: &State<ServerState>,
) -> Result<Json<HashMap<String, serde_json::Value>>, HttpErrorJson> {
let datastore = &state.datastore;
let queryresults = match datastore.get_key_values("settings.%") {
Ok(result) => Ok(result),
Err(err) => Err(err.into()),
};
match queryresults {
Ok(settings) => {
// strip 'settings.' prefix from keys
let mut map: HashMap<String, serde_json::Value> = HashMap::new();
for (key, value) in settings.iter() {
map.insert(
key.strip_prefix("settings.").unwrap_or(key).to_string(),
serde_json::from_str(value.clone().as_str()).unwrap(),
);
}
Ok(Json(map))
}
Err(err) => Err(err),
}
}
#[get("/<key>")]
pub fn setting_get(
state: &State<ServerState>,
key: String,
) -> Result<Json<serde_json::Value>, HttpErrorJson> {
let setting_key = parse_key(key)?;
let datastore = &state.datastore;
match datastore.get_key_value(&setting_key) {
Ok(value) => Ok(Json(serde_json::from_str(&value).unwrap())),
Err(DatastoreError::NoSuchKey(_)) => Ok(Json(serde_json::from_str("null").unwrap())),
Err(err) => Err(err.into()),
}
}
#[post("/<key>", data = "<value>", format = "application/json")]
pub fn setting_set(
state: &State<ServerState>,
key: String,
value: Json<serde_json::Value>,
) -> Result<Status, HttpErrorJson> {
let setting_key = parse_key(key)?;
let value_str = match serde_json::to_string(&value.0) {
Ok(value) => value,
Err(err) => {
return Err(HttpErrorJson::new(
Status::BadRequest,
format!("Invalid JSON: {}", err),
))
}
};
let datastore = &state.datastore;
let result = datastore.set_key_value(&setting_key, &value_str);
match result {
Ok(_) => {
// Worker also reloads on SetKeyValue of this key; this second
// RefreshPrivacyFilter is belt-and-suspenders so the HTTP path
// still works if a future writer bypasses that hook.
if setting_key == "settings.privacy_filters" {
let _ = datastore.refresh_privacy_filter();
}
Ok(Status::Created)
}
Err(err) => Err(err.into()),
}
}
#[delete("/<key>")]
pub fn setting_delete(state: &State<ServerState>, key: String) -> Result<(), HttpErrorJson> {
let setting_key = parse_key(key)?;
let datastore = &state.datastore;
let result = datastore.delete_key_value(&setting_key);
match result {
Ok(_) => {
if setting_key == "settings.privacy_filters" {
let _ = datastore.refresh_privacy_filter();
}
Ok(())
}
Err(err) => Err(err.into()),
}
}

View File

@@ -0,0 +1,115 @@
use std::io::{Cursor, Seek, SeekFrom};
use rocket::http::ContentType;
use rocket::http::Header;
use rocket::http::Status;
use rocket::request::Request;
use rocket::response::{self, Responder, Response};
use serde::Serialize;
#[derive(Serialize, Debug)]
pub struct HttpErrorJson {
#[serde(skip_serializing)]
status: Status,
message: String,
}
impl HttpErrorJson {
pub fn new(status: Status, err: String) -> HttpErrorJson {
HttpErrorJson {
status,
message: err,
}
}
}
impl<'r> Responder<'r, 'static> for HttpErrorJson {
fn respond_to(self, _: &Request) -> response::Result<'static> {
let body = serde_json::to_string(&self).map_err(|err| {
error!("Failed to serialize error response: {err}");
Status::InternalServerError
})?;
Response::build()
.status(self.status)
.sized_body(body.len(), Cursor::new(body))
.header(ContentType::new("application", "json"))
.ok()
}
}
pub struct BucketsExportRocket {
file: std::fs::File,
filename: String,
}
impl BucketsExportRocket {
pub fn new(
datastore: &aw_datastore::Datastore,
bucket_id: Option<&str>,
) -> Result<Self, HttpErrorJson> {
let io_error = |err: std::io::Error| {
error!("Failed to prepare export file: {err}");
HttpErrorJson::new(
Status::InternalServerError,
"Failed to prepare export file".into(),
)
};
// tempfile creates a private file and removes it when the response is
// dropped. Spooling preserves HTTP errors even if serialization or disk
// writes fail, while keeping event buffering bounded.
let file = tempfile::tempfile().map_err(io_error)?;
let (mut file, name) = datastore.export_to_file(bucket_id, file)?;
file.seek(SeekFrom::Start(0)).map_err(io_error)?;
let filename = match name {
Some(id) => format!("attachment; filename=aw-bucket-export_{id}.json"),
None => "attachment; filename=aw-buckets-export.json".into(),
};
Ok(Self { file, filename })
}
}
impl<'r> Responder<'r, 'static> for BucketsExportRocket {
fn respond_to(self, _: &Request) -> response::Result<'static> {
Response::build()
.status(Status::Ok)
.header(Header::new("Content-Disposition", self.filename))
.header(ContentType::JSON)
.streamed_body(rocket::tokio::fs::File::from_std(self.file))
.ok()
}
}
use aw_datastore::DatastoreError;
impl From<DatastoreError> for HttpErrorJson {
fn from(val: DatastoreError) -> Self {
match val {
DatastoreError::NoSuchBucket(bucket_id) => HttpErrorJson::new(
Status::NotFound,
format!("The requested bucket '{bucket_id}' does not exist"),
),
DatastoreError::BucketAlreadyExists(bucket_id) => HttpErrorJson::new(
Status::NotModified,
format!("Bucket '{bucket_id}' already exists"),
),
DatastoreError::NoSuchKey(key) => HttpErrorJson::new(
Status::NotFound,
format!("The requested key(s) '{key}' do not exist"),
),
DatastoreError::MpscError => HttpErrorJson::new(
Status::InternalServerError,
"Unexpected Mpsc error!".to_string(),
),
DatastoreError::InternalError(msg) => {
HttpErrorJson::new(Status::InternalServerError, msg)
}
// When upgrade is disabled
DatastoreError::Uninitialized(msg) => {
HttpErrorJson::new(Status::InternalServerError, msg)
}
DatastoreError::OldDbVersion(msg) => {
HttpErrorJson::new(Status::InternalServerError, msg)
}
}
}
}

36
aw-server/src/lib.rs Normal file
View File

@@ -0,0 +1,36 @@
#[macro_use]
extern crate rocket;
extern crate rocket_cors;
extern crate serde;
extern crate serde_json;
extern crate chrono;
#[cfg(target_os = "android")]
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
extern crate fern;
extern crate toml;
#[macro_use]
pub mod macros;
pub mod config;
pub mod device_id;
pub mod dirs;
pub mod endpoints;
pub mod logging;
pub mod panic_guard;
pub mod version;
#[cfg(target_os = "android")]
pub mod android;
extern crate aw_datastore;
extern crate aw_models;
extern crate aw_query;
extern crate aw_transform;

153
aw-server/src/logging.rs Normal file
View File

@@ -0,0 +1,153 @@
use std::fs;
use std::path::PathBuf;
use fern::colors::{Color, ColoredLevelConfig};
use crate::dirs;
/// Render a single log line.
///
/// `level` is taken as a `Display` so the caller decides whether it is colored:
/// stdout passes a colorized level, the logfile passes the plain one.
fn format_log_line(
level: &dyn std::fmt::Display,
target: &str,
message: &std::fmt::Arguments,
) -> String {
format!(
"[{}][{}][{}]: {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
level,
target,
message,
)
}
pub fn setup_logger(module: &str, profile: &str, verbose: bool) -> Result<(), fern::InitError> {
let testing = profile == "testing";
let mut logfile_path: PathBuf =
dirs::get_log_dir(module).expect("Unable to get log dir to store logs in");
fs::create_dir_all(logfile_path.clone()).expect("Unable to create folder for logs");
// Isolated profile roots (including new-style testing) use a bare filename
// — the directory already isolates. The `-testing` infix stays only in
// the legacy shared-root layout so existing log files keep matching.
let filename = if dirs::legacy_testing_suffix(profile).is_empty() {
format!("{}_%Y-%m-%dT%H-%M-%S%z.log", module)
} else {
format!("{}-testing_%Y-%m-%dT%H-%M-%S%z.log", module)
};
logfile_path.push(chrono::Local::now().format(&filename).to_string());
log_panics::init();
let colors = ColoredLevelConfig::new()
.debug(Color::White)
.info(Color::Green)
.warn(Color::Yellow)
.error(Color::Red);
let default_log_level = if testing || verbose {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
let log_level = std::env::var("LOG_LEVEL").map_or(default_log_level, |level| {
match level.to_lowercase().as_str() {
"trace" => log::LevelFilter::Trace,
"debug" => log::LevelFilter::Debug,
"info" => log::LevelFilter::Info,
"warn" => log::LevelFilter::Warn,
"error" => log::LevelFilter::Error,
_ => default_log_level,
}
});
let mut dispatch = fern::Dispatch::new().level(log_level);
// Set some Rocket messages to debug level
let is_debug = matches!(log_level, log::LevelFilter::Trace | log::LevelFilter::Debug);
if !is_debug {
dispatch = dispatch
.level_for("rocket", log::LevelFilter::Warn)
// rocket_cors has a lot of unhelpful info messages that spam the log on every request
// https://github.com/ActivityWatch/activitywatch/issues/975
.level_for("rocket_cors", log::LevelFilter::Warn)
.level_for("_", log::LevelFilter::Warn) // Rocket requests
.level_for("launch_", log::LevelFilter::Warn); // Rocket config info
}
dispatch
// Colored output to stdout
.chain(
fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(format_args!(
"{}",
format_log_line(&colors.color(record.level()), record.target(), message)
))
})
.chain(std::io::stdout()),
)
// Uncolored output to logfile, so the file doesn't contain ANSI escapes
.chain(
fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"{}",
format_log_line(&record.level(), record.target(), message)
))
})
.chain(fern::log_file(logfile_path)?),
)
.apply()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{format_log_line, setup_logger};
use fern::colors::{Color, ColoredLevelConfig};
/* disable this test.
* This is due to it failing in GitHub actions, claiming that the logger
* has been initialized twice which is not allowed */
#[ignore]
#[test]
fn test_setup_logger() {
setup_logger("aw-server-rust", "testing", true).unwrap();
}
/* The formatting is tested directly rather than through setup_logger, which
* installs the global logger and so can only ever run once per process. */
#[test]
fn test_format_log_line_is_plain_for_logfile() {
let line = format_log_line(&log::Level::Info, "aw_server::test", &format_args!("hello"));
assert!(
!line.contains('\u{1b}'),
"logfile lines must not contain ANSI escapes, got {line:?}"
);
assert!(
line.ends_with("[INFO][aw_server::test]: hello"),
"unexpected log line: {line:?}"
);
}
#[test]
fn test_format_log_line_is_colored_for_stdout() {
let colors = ColoredLevelConfig::new().info(Color::Green);
let line = format_log_line(
&colors.color(log::Level::Info),
"aw_server::test",
&format_args!("hello"),
);
assert!(
line.contains('\u{1b}'),
"stdout lines should keep their color, got {line:?}"
);
assert!(line.ends_with("[aw_server::test]: hello"));
}
}

13
aw-server/src/macros.rs Normal file
View File

@@ -0,0 +1,13 @@
#[macro_export]
macro_rules! json_map {
{ $( $key:literal : $value:expr),* } => {{
use serde_json::Value;
use serde_json::map::Map;
#[allow(unused_mut)]
let mut map : Map<String, Value> = Map::new();
$(
map.insert( $key.to_string(), json!($value) );
)*
map
}};
}

233
aw-server/src/main.rs Normal file
View File

@@ -0,0 +1,233 @@
#[macro_use]
extern crate log;
use std::env;
use std::path::PathBuf;
use clap::crate_version;
use clap::Parser;
use aw_server::*;
#[cfg(target_os = "linux")]
use sd_notify::NotifyState;
#[cfg(all(target_os = "linux", target_arch = "x86"))]
extern crate jemallocator;
#[cfg(all(target_os = "linux", target_arch = "x86"))]
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
/// Rust server for ActivityWatch
#[derive(Parser)]
#[clap(version = crate_version!(), author = "Johan Bjäreholt, Erik Bjäreholt, et al.")]
struct Opts {
/// Named instance profile (e.g. "default", "testing", "research").
/// Drives the isolated directory root (and legacy testing-filename fallback).
/// Defaults to "testing" in debug builds, "default" in release builds.
#[clap(long)]
profile: Option<String>,
/// Run in testing mode (alias for --profile testing)
#[clap(long)]
testing: bool,
/// Verbose output
#[clap(long)]
verbose: bool,
/// Address to listen to
#[clap(long)]
host: Option<String>,
/// Port to listen on
#[clap(long)]
port: Option<String>,
/// Path to database override
/// Also implies --no-legacy-import if no db found
#[clap(long)]
dbpath: Option<String>,
/// Path to config file override
#[clap(short = 'c', long = "config")]
config: Option<PathBuf>,
/// Path to webui override
#[clap(long)]
webpath: Option<String>,
/// Mapping of custom static paths to serve, in the format: watcher1=/path,watcher2=/path2
#[clap(long)]
custom_static: Option<String>,
/// Device ID override
#[clap(long)]
device_id: Option<String>,
/// Don't import from aw-server-python if no aw-server-rust db found
#[clap(long)]
no_legacy_import: bool,
/// Encryption key for the database (requires 'encryption' feature).
/// Can also be set via the AW_DB_PASSWORD environment variable.
/// WARNING: passing a password on the command line may expose it in process listings.
#[clap(long, env = "AW_DB_PASSWORD")]
#[cfg(any(feature = "encryption", feature = "encryption-vendored"))]
db_password: Option<String>,
}
#[rocket::main]
#[allow(clippy::result_large_err)]
async fn main() -> Result<(), rocket::Error> {
let opts: Opts = Opts::parse();
// Clear sensitive env vars immediately after parse, before setup_logger can start
// background threads (std::env::remove_var is not thread-safe on all platforms).
#[cfg(any(feature = "encryption", feature = "encryption-vendored"))]
std::env::remove_var("AW_DB_PASSWORD");
// Resolve profile: --profile wins; --testing is an alias for "testing";
// debug builds default to "testing" (preserving prior behaviour).
let profile: String = match opts.profile {
Some(p) => {
if opts.testing && p != "testing" {
warn!(
"--testing and --profile {} both given; --profile wins, using '{}'",
p, p
);
}
p
}
None => match std::env::var("AW_PROFILE") {
// aw-qt exports AW_PROFILE for the modules it spawns, so a profile
// set on the launcher propagates without every module growing a flag.
Ok(p) if !p.is_empty() => p,
_ => {
if opts.testing || cfg!(debug_assertions) {
"testing".to_string()
} else {
"default".to_string()
}
}
},
};
dirs::validate_profile(&profile).unwrap_or_else(|e| panic!("Invalid profile name: {e}"));
// set_profile must run before setup_logger: get_log_dir reads the
// process-global profile via appname(), and named profiles otherwise
// land in the shared cache dir (ActivityWatch/activitywatch#1399).
config::set_profile(profile.clone());
logging::setup_logger("aw-server-rust", &profile, opts.verbose)
.expect("Failed to setup logging");
if profile != "default" {
info!("Running server with profile '{}'", profile);
}
let mut config = config::create_config(&profile, opts.config.as_deref());
// set host if overridden
if let Some(host) = opts.host {
config.address = host;
}
// set port if overridden
if let Some(port) = opts.port {
config.port = port.parse().unwrap();
}
// set custom_static if overridden, transform into map
if let Some(custom_static_str) = opts.custom_static {
let custom_static_map: std::collections::HashMap<String, String> = custom_static_str
.split(',')
.map(|s| {
let mut split = s.split('=');
let key = split.next().unwrap().to_string();
let value = split.next().unwrap().to_string();
(key, value)
})
.collect();
config.custom_static.extend(custom_static_map);
// validate paths, log error if invalid
// remove invalid paths
for (name, path) in config.custom_static.clone().iter() {
if !std::path::Path::new(path).exists() {
error!("custom_static path for {} does not exist ({})", name, path);
config.custom_static.remove(name);
}
}
}
// Set db path if overridden
let db_path: String = if let Some(dbpath) = opts.dbpath.clone() {
dbpath
} else {
dirs::db_path(&profile)
.expect("Failed to get db path")
.to_str()
.unwrap()
.to_string()
};
info!("Using DB at path {:?}", db_path);
let asset_path = opts.webpath.map(PathBuf::from);
info!("Using aw-webui assets at path {:?}", asset_path);
// Only use legacy import if opts.dbpath is not set
let legacy_import = !opts.no_legacy_import && opts.dbpath.is_none();
if opts.dbpath.is_some() {
info!("Since custom dbpath is set, --no-legacy-import is implied");
}
let device_id: String = if let Some(id) = opts.device_id {
id
} else {
device_id::get_device_id()
};
#[cfg(any(feature = "encryption", feature = "encryption-vendored"))]
let datastore = match opts.db_password {
Some(key) if key.is_empty() => {
// SQLCipher silently treats PRAGMA key '' as no encryption — reject early so
// callers don't end up with a plaintext database while believing it is encrypted.
panic!("--db-password / AW_DB_PASSWORD must not be empty; aborting to prevent silent plaintext storage");
}
Some(key) => {
info!("Using encrypted database (SQLCipher)");
aw_datastore::Datastore::new_encrypted(db_path, key, legacy_import)
}
None => aw_datastore::Datastore::new(db_path, legacy_import),
};
#[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))]
{
if std::env::var("AW_DB_PASSWORD").is_ok() {
panic!(
"AW_DB_PASSWORD is set but this binary was not compiled with encryption support. \
Refusing to start with an unencrypted database when the user requested encryption. \
Rebuild with the 'encryption' or 'encryption-vendored' feature, or unset \
AW_DB_PASSWORD to use an unencrypted database."
);
}
}
#[cfg(not(any(feature = "encryption", feature = "encryption-vendored")))]
let datastore = aw_datastore::Datastore::new(db_path, legacy_import);
let server_state = endpoints::ServerState {
// Even if legacy_import is set to true it is disabled on Android so
// it will not happen there
datastore,
asset_resolver: endpoints::AssetResolver::new(asset_path),
device_id,
};
let _rocket = endpoints::build_rocket(server_state, config)
.ignite()
.await?;
#[cfg(target_os = "linux")]
let _ = sd_notify::notify(true, &[NotifyState::Ready]);
_rocket.launch().await?;
Ok(())
}

View File

@@ -0,0 +1,106 @@
//! Catching Rust panics before they cross an FFI boundary.
//!
//! Since Rust 1.81 a panic that unwinds out of an `extern "C"` function aborts
//! the process. On Android that means every panic inside a
//! `Java_net_activitywatch_android_…` JNI entry point kills the whole app with
//! `SIGABRT` instead of surfacing as a recoverable error — which is what the
//! `libaw_server.so`/`libaw_sync.so` crash clusters in
//! ActivityWatch/aw-android#267 and the sync aborts in
//! ActivityWatch/aw-android#220 are.
//!
//! The JNI-facing wrappers in [`crate::android`] are thin: they call
//! [`catch_panic`] and turn an `Err` into the same `{"error": "…"}` object the
//! Kotlin side already parses. The logic here is platform-independent so it can
//! be unit tested on the host, where the JNI modules do not even compile.
use std::any::Any;
use std::panic::{catch_unwind, AssertUnwindSafe};
/// Best-effort human-readable message for a panic payload.
///
/// `panic!` payloads are `&'static str` for literal messages and `String` for
/// formatted ones; anything else (`panic_any`) is reported by type only.
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Box<dyn Any>".to_string()
}
}
/// Run `f`, converting a panic into an `Err` describing it.
///
/// The panic hook still runs first (and on Android still logs to logcat), so
/// this does not hide the panic — it only stops the unwind before it reaches
/// the `extern "C"` frame that would abort the process.
///
/// `JNIEnv` is not `UnwindSafe`, and neither is the `&mut` reborrow the JNI
/// wrappers capture, so [`AssertUnwindSafe`] is applied here rather than at
/// every call site. That is sound for this use: on the error path the caller
/// only allocates a Java string from the env, and any Rust state the panic left
/// inconsistent (the datastore connection) is behind a channel to a separate
/// worker thread rather than shared through the closure.
pub fn catch_panic<T, F: FnOnce() -> T>(name: &str, f: F) -> Result<T, String> {
catch_unwind(AssertUnwindSafe(f))
.map_err(|payload| format!("panic in {}: {}", name, panic_message(payload.as_ref())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passes_through_the_return_value_when_no_panic() {
assert_eq!(catch_panic("ok", || 42), Ok(42));
}
#[test]
fn reports_str_payloads() {
let err = catch_panic("entry", || panic!("boom")).unwrap_err();
assert_eq!(err, "panic in entry: boom");
}
#[test]
fn reports_formatted_string_payloads() {
let err = catch_panic("entry", || panic!("bad bucket {}", 7)).unwrap_err();
assert_eq!(err, "panic in entry: bad bucket 7");
}
#[test]
fn reports_unwrap_and_expect_payloads() {
// This is the shape the sync path actually panics in: an `unwrap()` or
// `expect()` on a datastore error deep inside the JNI call.
fn missing() -> Option<u8> {
None
}
let err = catch_panic("entry", || missing().expect("no value")).unwrap_err();
assert!(err.starts_with("panic in entry: no value"), "{err}");
fn failing() -> Result<u8, String> {
Err("io".to_string())
}
let err = catch_panic("entry", || failing().unwrap()).unwrap_err();
assert!(err.contains("io"), "{err}");
}
#[test]
fn reports_non_string_payloads_by_placeholder() {
let err = catch_panic("entry", || std::panic::panic_any(7u8)).unwrap_err();
assert_eq!(err, "panic in entry: Box<dyn Any>");
}
#[test]
fn allows_closures_capturing_mutable_state() {
// Mirrors how the JNI wrappers capture a `&mut JNIEnv` reborrow: the
// borrow must end when catch_panic returns so the error path can use it.
let mut env = String::new();
let result = catch_panic("entry", || {
env.push_str("touched");
panic!("after touching env");
});
assert!(result.is_err());
assert_eq!(env, "touched");
}
}

62
aw-server/src/version.rs Normal file
View File

@@ -0,0 +1,62 @@
//! Version string reported by `GET /api/0/info`.
//!
//! By default this is the aw-server-rust package version. That is wrong for
//! builds that embed aw-server-rust as a component of a larger product: on
//! Android the webui footer showed `v0.14.0 (rust)` (the Cargo.toml version)
//! while the installed app was `v0.14.0b2`.
//!
//! Such builds call [`set_version_override`] at startup to report their own
//! release version instead.
use std::sync::RwLock;
static VERSION_OVERRIDE: RwLock<Option<String>> = RwLock::new(None);
/// Report `version` from `/api/0/info` instead of the package version.
///
/// The string is used verbatim, so the caller controls the exact format
/// (including any `v` prefix).
pub fn set_version_override(version: &str) {
let mut guard = VERSION_OVERRIDE.write().unwrap();
*guard = Some(version.to_string());
}
/// The version string to report from `/api/0/info`.
pub fn version_string() -> String {
if let Some(version) = VERSION_OVERRIDE.read().unwrap().as_ref() {
return version.clone();
}
const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
format!("v{} (rust)", VERSION.unwrap_or("(unknown)"))
}
#[cfg(test)]
mod tests {
use super::*;
struct VersionGuard;
impl Drop for VersionGuard {
fn drop(&mut self) {
*VERSION_OVERRIDE.write().unwrap() = None;
}
}
// These share process-global state, so they run as one test.
#[test]
fn override_replaces_package_version() {
let _guard = VersionGuard; // resets VERSION_OVERRIDE on exit, even on panic
assert!(
version_string().ends_with(" (rust)"),
"default should report the package version, got {:?}",
version_string()
);
set_version_override("v0.14.0b2");
assert_eq!(version_string(), "v0.14.0b2");
// Used verbatim: the caller owns the format.
set_version_override("1.2.3-custom");
assert_eq!(version_string(), "1.2.3-custom");
}
}

914
aw-server/tests/api.rs Normal file
View File

@@ -0,0 +1,914 @@
extern crate log;
extern crate rocket;
extern crate aw_datastore;
extern crate aw_server;
#[cfg(test)]
mod api_tests {
use std::collections::HashMap;
use rocket::http::{ContentType, Header, Status};
use serde_json::{json, Value};
use aw_server::config;
use aw_server::endpoints;
use aw_models::{Bucket, BucketsExport};
use rocket::local::blocking::Client;
fn setup_testserver() -> rocket::Rocket<rocket::Build> {
let state = endpoints::ServerState {
datastore: aw_datastore::Datastore::new_in_memory(false),
asset_resolver: endpoints::AssetResolver::new(None),
device_id: "test_id".to_string(),
};
let aw_config = config::AWConfig::default();
endpoints::build_rocket(state, aw_config)
}
#[test]
fn export_includes_pending_writes_and_preserves_headers_and_missing_bucket_errors() {
let server = setup_testserver();
let datastore = server
.state::<endpoints::ServerState>()
.unwrap()
.datastore
.clone();
let bucket: Bucket = serde_json::from_value(json!({
"id": "live", "type": "test", "client": "test", "hostname": "test"
}))
.unwrap();
datastore.create_bucket(&bucket).unwrap();
let event = aw_models::Event::default();
let inserted = datastore.insert_events("live", &[event]).unwrap();
// No force_commit: exports must see writes acknowledged by the worker.
let client = Client::untracked(server).unwrap();
for path in ["/api/0/export", "/api/0/buckets/live/export"] {
let response = client
.get(path)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::JSON));
assert_eq!(
response.headers().get_one("Content-Disposition"),
Some("attachment; filename=aw-bucket-export_live.json")
);
let body: Value = serde_json::from_str(&response.into_string().unwrap()).unwrap();
assert_eq!(
body["buckets"]["live"]["events"],
serde_json::to_value(&inserted).unwrap()
);
}
let response = client
.get("/api/0/buckets/missing/export")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(response.status(), Status::NotFound);
let body: Value = serde_json::from_str(&response.into_string().unwrap()).unwrap();
assert!(body["message"].as_str().unwrap().contains("does not exist"));
}
#[test]
fn test_bucket() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Get empty list of buckets
let mut res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let buckets: HashMap<String, Bucket> =
serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(buckets.len(), 0);
// Try to fetch non-existing bucket
res = client
.get("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::NotFound);
// Create bucket
res = client
.post("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "id",
"type": "type",
"client": "client",
"hostname": "hostname"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Try to create bucket which already exists
res = client
.post("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "id",
"type": "type",
"client": "client",
"hostname": "hostname"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::NotModified);
assert_eq!(
res.into_string().unwrap(),
r#"{"message":"Bucket 'id' already exists"}"#
);
// Get list of buckets (1 bucket)
res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let buckets: HashMap<String, Bucket> =
serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(buckets.len(), 1);
let bucket = buckets.get("id").unwrap();
assert_eq!(bucket.id, "id");
assert_eq!(bucket._type, "type");
assert_eq!(bucket.client, "client");
assert_eq!(bucket.hostname, "hostname");
assert!(bucket.events.is_none());
assert_eq!(bucket.metadata.start, None);
assert_eq!(bucket.metadata.end, None);
// Get newly created bucket
res = client
.get("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Validate output
let bucket: Bucket = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(bucket.id, "id");
assert_eq!(bucket._type, "type");
assert_eq!(bucket.client, "client");
assert_eq!(bucket.hostname, "hostname");
assert!(bucket.events.is_none());
assert_eq!(bucket.metadata.start, None);
assert_eq!(bucket.metadata.end, None);
// Get non-existing bucket
res = client
.get("/api/0/buckets/invalid_bucket")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::NotFound);
// Delete bucket
res = client
.delete("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Try (and fail) to get deleted bucket
res = client
.get("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::NotFound);
// Get empty list of buckets
let res = client
.get("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let buckets: HashMap<String, Bucket> =
serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(buckets.len(), 0);
}
#[test]
fn test_bucket_hostname_validation() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// A client-supplied hostname containing whitespace is rejected
let res = client
.post("/api/0/buckets/spacey")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "spacey",
"type": "type",
"client": "client",
"hostname": "my phone"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::BadRequest);
assert_eq!(
res.into_string().unwrap(),
r#"{"message":"Invalid hostname \"my phone\": hostname may not contain whitespace"}"#
);
// The rejected bucket was not created
let res = client
.get("/api/0/buckets/spacey")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::NotFound);
// An already-existing bucket keeps the established "already exists" response
// even if the re-create payload carries a whitespace hostname. Watchers
// idempotently re-create their bucket on startup, so a legacy bucket stored
// under a spacey hostname must not start hard-failing every boot.
let res = client
.post("/api/0/buckets/existing")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "existing",
"type": "type",
"client": "client",
"hostname": "hostname"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let res = client
.post("/api/0/buckets/existing")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "existing",
"type": "type",
"client": "client",
"hostname": "my phone"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::NotModified);
// The "!local" sentinel bypasses validation (resolved server-side)
let res = client
.post("/api/0/buckets/localbucket")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "localbucket",
"type": "type",
"client": "client",
"hostname": "!local"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
}
#[test]
fn test_events() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Create bucket
let res = client
.post("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "id",
"type": "type",
"client": "client",
"hostname": "hostname"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Insert a single event
let res = client
.post("/api/0/buckets/id/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"[{
"timestamp": "2018-01-01T01:01:01Z",
"duration": 1.0,
"data": {}
}]"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(
res.into_string().unwrap(),
r#"[{"id":1,"timestamp":"2018-01-01T01:01:01Z","duration":1.0,"data":{}}]"#
);
// Get inserted event
let res = client
.get("/api/0/buckets/id/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(
res.into_string().unwrap(),
r#"[{"id":1,"timestamp":"2018-01-01T01:01:01Z","duration":1.0,"data":{}}]"#
);
// Heartbeat
let res = client
.post("/api/0/buckets/id/heartbeat?pulsetime=2")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"timestamp": "2018-01-01T01:01:02Z",
"duration": 1.0,
"data": {}
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// After the fix for #559, the heartbeat response includes the DB event ID
assert_eq!(
res.into_string().unwrap(),
r#"{"id":1,"timestamp":"2018-01-01T01:01:01Z","duration":2.0,"data":{}}"#
);
// Get heartbeat event
let res = client
.get("/api/0/buckets/id/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(
res.into_string().unwrap(),
r#"[{"id":1,"timestamp":"2018-01-01T01:01:01Z","duration":2.0,"data":{}}]"#
);
// Delete event
client
.delete("/api/0/buckets/id/events/1")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
// Get eventcount
let res = client
.get("/api/0/buckets/id/events/count")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(res.into_string().unwrap(), "0");
// Delete bucket
let res = client
.delete("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
}
#[test]
fn test_import_export() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Import bucket
let res = client
.post("/api/0/import")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{"buckets":
{"id1": {
"id": "id1",
"type": "type",
"client": "client",
"hostname": "hostname",
"events": [{
"timestamp":"2000-01-01T00:00:00Z",
"duration":1.0,
"data": {}
}]
}}}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Import already existing bucket with a new event — should merge instead of fail
let res = client
.post("/api/0/import")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{"buckets":
{"id1": {
"id": "id1",
"type": "type",
"client": "client",
"hostname": "hostname",
"events": [{
"timestamp":"2000-01-02T00:00:00Z",
"duration":1.0,
"data": {}
}]
}}}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Verify events were merged — bucket should now have 2 events
let res = client
.get("/api/0/buckets/id1/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let events: serde_json::Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(events.as_array().unwrap().len(), 2);
// Re-import the first event again — should be idempotent (no duplicate created)
let res = client
.post("/api/0/import")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{"buckets":
{"id1": {
"id": "id1",
"type": "type",
"client": "client",
"hostname": "hostname",
"events": [{
"timestamp":"2000-01-01T00:00:00Z",
"duration":1.0,
"data": {}
}]
}}}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Count should still be 2, not 3 — re-import is idempotent
let res = client
.get("/api/0/buckets/id1/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let events: serde_json::Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(
events.as_array().unwrap().len(),
2,
"Re-importing the same event should be idempotent"
);
// Import a narrower event fully contained within an existing longer event.
// This should be preserved as a distinct event, not dropped by clipped dedup.
let res = client
.post("/api/0/import")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{"buckets":
{"id1": {
"id": "id1",
"type": "type",
"client": "client",
"hostname": "hostname",
"events": [{
"timestamp":"2000-01-01T00:00:30Z",
"duration":30.0,
"data": {}
}]
}}}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let res = client
.get("/api/0/buckets/id1/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let events: serde_json::Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(
events.as_array().unwrap().len(),
3,
"Contained event should not be dropped by clipped dedup"
);
// Export single created bucket
let res = client
.get("/api/0/buckets/id1/export")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let export: BucketsExport = serde_json::from_str(&res.into_string().unwrap()).unwrap();
// Delete bucket so we can import it again
let res = client
.delete("/api/0/buckets/id1")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Import exported bucket again but with multipart/form-data
let boundary_start = b"--a\r\n";
let disposition = b"Content-Disposition: form-data; name=\"buckets.json\"\r\n";
let content_type = b"Content-Type: application/json\r\n";
let data_start = b"\r\n";
let content = serde_json::to_vec(&export).unwrap();
let boundary_end = b"\r\n--a--";
let sum = [
&boundary_start[..],
&disposition[..],
&content_type[..],
&data_start[..],
&content[..],
&boundary_end[..],
]
.concat();
let res = client
.post("/api/0/import")
.header(Header::new(
"Content-Type",
"multipart/form-data; boundary=a",
))
.header(Header::new("Host", "127.0.0.1:5600"))
.body(&sum[..])
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Get created bucket
let res = client
.get("/api/0/buckets/id1")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
println!("{:?}", res.into_string());
// Export all buckets
let res = client
.get("/api/0/export")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let export: BucketsExport = serde_json::from_str(&res.into_string().unwrap()).unwrap();
let mut buckets = export.buckets;
assert_eq!(buckets.len(), 1);
let b = buckets.remove("id1").unwrap();
assert_eq!(
b.events.unwrap().take_inner().len(),
3,
"Export should preserve the contained event added during merge testing"
);
assert_eq!(buckets.len(), 0);
}
#[test]
fn test_query() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Minimal query
let res = client
.post("/api/0/query")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"timeperiods": ["2000-01-01T00:00:00Z/2020-01-01T00:00:00Z"],
"query": ["return 1;"]
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(res.into_string().unwrap(), r#"[1.0]"#);
// Create bucket to query later
let res = client
.post("/api/0/buckets/id")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"id": "id",
"type": "type",
"client": "client",
"hostname": "hostname"
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Insert a event to query later
let res = client
.post("/api/0/buckets/id/events")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"[{
"timestamp": "2018-01-01T01:01:01Z",
"duration": 1.0,
"data": {}
}]"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
// Query events
let res = client
.post("/api/0/query")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"timeperiods": ["2000-01-01T00:00:00Z/2020-01-01T00:00:00Z"],
"query": ["return query_bucket(\"id\");"]
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(
res.into_string().unwrap(),
r#"[[{"data":{},"duration":1.0,"id":1,"timestamp":"2018-01-01T01:01:01Z"}]]"#
);
// Test error
let res = client
.post("/api/0/query")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(
r#"{
"timeperiods": ["2000-01-01T00:00:00Z/2020-01-01T00:00:00Z"],
"query": [""]
}"#,
)
.dispatch();
assert_eq!(res.status(), rocket::http::Status::BadRequest);
assert_eq!(res.into_string().unwrap(), r#"{"message":"EmptyQuery"}"#);
}
fn set_setting_request(client: &Client, key: &str, value: &Value) -> Status {
let body = serde_json::to_string(value).unwrap();
let res = client
.post(format!("/api/0/settings/{}", key))
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.body(body)
.dispatch();
res.status()
}
#[test]
fn test_illegally_long_key() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Test getting not found (getting nonexistent key)
let key = "thisisaverylongkthisisaverylongkthisisaverylongkthisisaverylongkthisisaverylongkthisisaverylongkthisisaverylongkthisisaverylongk";
let value = json!("test_value");
let res = set_setting_request(&client, key, &value);
assert_eq!(res, rocket::http::Status::BadRequest);
}
#[test]
fn test_setting_setting() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Test value creation
let key = "test_key";
let value = json!("test_value");
let response_status = set_setting_request(&client, key, &value);
assert_eq!(response_status, rocket::http::Status::Created);
}
#[test]
fn test_get_unset_setting() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Test getting not found (getting nonexistent key)
let key = "non_existent_key";
let res = client
.get(format!("/api/0/settings/{}", key))
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(res.into_string().unwrap(), "null")
}
#[test]
fn test_get_settings() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
let key1 = "test_key";
let key2 = "test_key_2";
let value = json!("test_value");
let response1_status = set_setting_request(&client, key1, &value);
assert_eq!(response1_status, rocket::http::Status::Created);
let response2_status = set_setting_request(&client, key2, &value);
assert_eq!(response2_status, rocket::http::Status::Created);
let res = client
.get("/api/0/settings")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
let expected: Value =
serde_json::from_str(r#"{"test_key_2":"test_value","test_key":"test_value"}"#).unwrap();
assert_eq!(deserialized, expected);
}
#[test]
fn test_get_setting() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
let key = "test_key";
let value = json!("test_value");
let response_status = set_setting_request(&client, key, &value);
assert_eq!(response_status, rocket::http::Status::Created);
// Test getting
let res = client
.get(format!("/api/0/settings/{}", key))
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(deserialized, value);
}
#[test]
fn test_get_setting_dotted_key() {
// HTTP accepts dotted keys; JNI getSetting must use the same grammar
// (ActivityWatch/aw-server-rust#653 Greptile: don't reject '.').
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
let key = "nested.like.key";
let value = json!("dotted_value");
assert_eq!(
set_setting_request(&client, key, &value),
rocket::http::Status::Created
);
let res = client
.get(format!("/api/0/settings/{}", key))
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(deserialized, value);
}
#[test]
fn test_get_setting_list() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Test array
let key = "test_key_array";
let value = json!([1, 2, 3]);
let response_status = set_setting_request(&client, key, &value);
assert_eq!(response_status, rocket::http::Status::Created);
let res = client
.get(format!("/api/0/settings/{}", key))
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(deserialized, value);
}
#[test]
fn test_get_setting_dict() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
// Test dict
let key = "test_key_dict";
let value = json!({"key": "value", "another_key": "another value"});
let response_status = set_setting_request(&client, key, &value);
assert_eq!(response_status, rocket::http::Status::Created);
let res = client
.get("/api/0/settings/test_key_dict")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(deserialized, value);
}
#[test]
fn test_set_setting() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
let key = "test_key";
let value1 = json!("test_value");
let value2 = json!("changed_test_value");
let post_1_status = set_setting_request(&client, key, &value1);
assert_eq!(post_1_status, rocket::http::Status::Created);
let res = client
.get(format!("/api/0/settings/{}", key))
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(deserialized, value1);
let post_2_status = set_setting_request(&client, key, &value2);
assert_eq!(post_2_status, rocket::http::Status::Created);
let res = client
.get("/api/0/settings/test_key")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let new_deserialized: Value = serde_json::from_str(&res.into_string().unwrap()).unwrap();
assert_eq!(new_deserialized, value2);
}
#[test]
fn test_delete_setting() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
let value = json!("test_value");
let response_status = set_setting_request(&client, "test_key", &value);
assert_eq!(response_status, rocket::http::Status::Created);
// Test deleting
let res = client
.delete("/api/0/settings/test_key")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
let res = client
.get("/api/0/settings/test_key")
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
assert_eq!(res.into_string().unwrap(), "null");
}
#[test]
fn test_cors_catching() {
let server = setup_testserver();
let client = Client::untracked(server).expect("valid instance");
let res = client
.options("/api/0/buckets/")
.header(ContentType::JSON)
.header(Header::new("Host", "127.0.0.1:5600"))
.dispatch();
assert_eq!(res.status(), rocket::http::Status::Ok);
}
}

15
aw-server/tests/macros.rs Normal file
View File

@@ -0,0 +1,15 @@
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate aw_server;
#[cfg(test)]
mod macros_tests {
#[test]
fn test_json_map() {
json_map! {};
json_map! {"a": json!(1)};
json_map! {"a": json!(1), "b": json!(2)};
}
}