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
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:
61
aw-server/Cargo.toml
Normal file
61
aw-server/Cargo.toml
Normal 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
30
aw-server/build.rs
Normal 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(())
|
||||
}
|
||||
536
aw-server/src/android/mod.rs
Normal file
536
aw-server/src/android/mod.rs
Normal 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(¶ms)
|
||||
);
|
||||
|
||||
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
278
aw-server/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
aw-server/src/device_id.rs
Normal file
19
aw-server/src/device_id.rs
Normal 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
583
aw-server/src/dirs.rs
Normal 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
|
||||
);
|
||||
}
|
||||
409
aw-server/src/endpoints/apikey.rs
Normal file
409
aw-server/src/endpoints/apikey.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
233
aw-server/src/endpoints/bucket.rs
Normal file
233
aw-server/src/endpoints/bucket.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
47
aw-server/src/endpoints/cors.rs
Normal file
47
aw-server/src/endpoints/cors.rs
Normal 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")
|
||||
}
|
||||
9
aw-server/src/endpoints/export.rs
Normal file
9
aw-server/src/endpoints/export.rs
Normal 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)
|
||||
}
|
||||
521
aw-server/src/endpoints/extension_cors.rs
Normal file
521
aw-server/src/endpoints/extension_cors.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
195
aw-server/src/endpoints/hostcheck.rs
Normal file
195
aw-server/src/endpoints/hostcheck.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
146
aw-server/src/endpoints/import.rs
Normal file
146
aw-server/src/endpoints/import.rs
Normal 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())
|
||||
}
|
||||
236
aw-server/src/endpoints/mod.rs
Normal file
236
aw-server/src/endpoints/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
65
aw-server/src/endpoints/query.rs
Normal file
65
aw-server/src/endpoints/query.rs
Normal 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))
|
||||
}
|
||||
156
aw-server/src/endpoints/settings.rs
Normal file
156
aw-server/src/endpoints/settings.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
115
aw-server/src/endpoints/util.rs
Normal file
115
aw-server/src/endpoints/util.rs
Normal 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
36
aw-server/src/lib.rs
Normal 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
153
aw-server/src/logging.rs
Normal 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
13
aw-server/src/macros.rs
Normal 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
233
aw-server/src/main.rs
Normal 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(())
|
||||
}
|
||||
106
aw-server/src/panic_guard.rs
Normal file
106
aw-server/src/panic_guard.rs
Normal 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
62
aw-server/src/version.rs
Normal 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
914
aw-server/tests/api.rs
Normal 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
15
aw-server/tests/macros.rs
Normal 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)};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user