Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ NDK
*.sqlite*
*.db
*.db-journal

.vscode
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,33 @@ Available options:

# Additional regex CORS origins to allow (e.g. for sideloaded browser extensions)
#cors_regex = ["chrome-extension://yourextensionidhere"]

# Allow official ActivityWatch Chrome extension? (default: true)
#cors_allow_aw_chrome_extension = true

# Allow all Firefox extensions? (default: false, DANGEROUS)
#cors_allow_all_mozilla_extension = false
```

#### Persistence and Settings UI

The CORS-related settings (`cors`, `cors_regex`, `cors_allow_aw_chrome_extension`, and `cors_allow_all_mozilla_extension`) follow a precedence logic between the configuration file and the database:

- **TOML Precedence**: If a field is explicitly defined in your `config.toml`, it takes absolute precedence. The server will use the value from the file, and that setting will be **read-only** in the Web UI (marked as "Fixed in config file").
- **Database Fallback**: If a field is **missing** or commented out in the `config.toml`, the server will look for it in the database. These can be managed and edited via the **Security & CORS** modal in the Settings page.
- **Initial Setup**: On the first start, a default `config.toml` is created with all settings commented out, allowing the Web UI to take control of the configuration by default while providing a template for manual overrides.

> [!IMPORTANT]
> **Server Restart Required**: Changing any CORS-related settings (whether via `config.toml` or the Web UI) requires stopping and restarting the server for the changes to take effect. These settings are loaded into memory once during the server's initialization and are not hot-reloadable.

#### Custom CORS Origins

By default, the server allows requests from:
- The server's own origin (`http://127.0.0.1:<port>`, `http://localhost:<port>`)
- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`)
- All Firefox extensions (`moz-extension://.*`)
- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`) if `cors_allow_aw_chrome_extension` is true (default).
- All Firefox extensions (`moz-extension://.*`) ONLY IF `cors_allow_all_mozilla_extension` is set to true.

To allow additional origins (e.g. a sideloaded Chrome extension), add them to your config:
To allow additional origins (e.g. a sideloaded Chrome extension), add them to your `cors` or `cors_regex` config:

```toml
# Allow a specific sideloaded Chrome extension
Expand Down
10 changes: 8 additions & 2 deletions aw-datastore/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,15 +294,21 @@ impl DatastoreWorker {
Err(e) => Err(e),
},
Command::SetKeyValue(key, data) => match ds.insert_key_value(tx, &key, &data) {
Ok(()) => Ok(Response::Empty()),
Ok(()) => {
self.commit = true;
Ok(Response::Empty())
}
Err(e) => Err(e),
},
Command::GetKeyValue(key) => match ds.get_key_value(tx, &key) {
Ok(result) => Ok(Response::KeyValue(result)),
Err(e) => Err(e),
},
Command::DeleteKeyValue(key) => match ds.delete_key_value(tx, &key) {
Ok(()) => Ok(Response::Empty()),
Ok(()) => {
self.commit = true;
Ok(Response::Empty())
}
Err(e) => Err(e),
},
Command::Close() => {
Expand Down
1 change: 1 addition & 0 deletions aw-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ uuid = { version = "1.3", features = ["serde", "v4"] }
clap = { version = "4.1", features = ["derive", "cargo"] }
log-panics = { version = "2", features = ["with-backtrace"]}
rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] }
regex = "1"

aw-datastore = { path = "../aw-datastore" }
aw-models = { path = "../aw-models" }
Expand Down
83 changes: 72 additions & 11 deletions aw-server/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
use std::fs::File;
use std::io::{Read, Write};
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::Write;

use rocket::config::Config;
use rocket::data::{Limits, ToByteUnit};
use rocket::log::LogLevel;
use serde::{Deserialize, Serialize};

use crate::dirs;
use serde_json;

pub const CORS_FIELDS: &[&str] = &[
"cors",
"cors_regex",
"cors_allow_aw_chrome_extension",
"cors_allow_all_mozilla_extension",
];

// Far from an optimal way to solve it, but works and is simple
static mut TESTING: bool = true;
Expand All @@ -19,7 +28,7 @@ pub fn is_testing() -> bool {
unsafe { TESTING }
}

#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct AWConfig {
#[serde(default = "default_address")]
pub address: String,
Expand All @@ -36,6 +45,12 @@ pub struct AWConfig {
#[serde(default = "default_cors")]
pub cors_regex: Vec<String>,

#[serde(default = "default_true")]
pub cors_allow_aw_chrome_extension: bool,

#[serde(default = "default_false")]
pub cors_allow_all_mozilla_extension: bool,

// A mapping of watcher names to paths where the
// custom visualizations are located.
#[serde(default = "default_custom_static")]
Expand All @@ -50,6 +65,8 @@ impl Default for AWConfig {
testing: default_testing(),
cors: default_cors(),
cors_regex: default_cors(),
cors_allow_aw_chrome_extension: default_true(),
cors_allow_all_mozilla_extension: default_false(),
custom_static: default_custom_static(),
}
}
Expand Down Expand Up @@ -91,6 +108,14 @@ fn default_testing() -> bool {
is_testing()
}

fn default_true() -> bool {
true
}

fn default_false() -> bool {
false
}

fn default_port() -> u16 {
if is_testing() {
5666
Expand All @@ -103,14 +128,40 @@ fn default_custom_static() -> std::collections::HashMap<String, String> {
std::collections::HashMap::new()
}

pub fn create_config(testing: bool) -> AWConfig {
set_testing(testing);
pub fn get_config_path(testing: bool) -> (std::path::PathBuf, Vec<String>) {
let mut config_path = dirs::get_config_dir().unwrap();
if !testing {
config_path.push("config.toml")
} else {
config_path.push("config-testing.toml")
}
if !config_path.is_file() {
return (
config_path,
CORS_FIELDS.iter().map(|f| f.to_string()).collect(),
);
}
let content = fs::read_to_string(&config_path).unwrap_or_default();
let toml_value: toml::Value =
toml::from_str(&content).unwrap_or_else(|_| toml::Value::Table(toml::Table::new()));

let file_keys: HashSet<String> = toml_value
.as_table()
.map(|t| t.keys().cloned().collect())
.unwrap_or_default();

let missing = CORS_FIELDS
.iter()
.filter(|f| !file_keys.contains(&f.to_string()))
.map(|f| f.to_string())
.collect();

(config_path, missing)
}

pub fn create_config(testing: bool, datastore: &aw_datastore::Datastore) -> AWConfig {
set_testing(testing);
let (config_path, missing_cors_fields) = get_config_path(testing);

/* 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 */
Expand All @@ -132,12 +183,22 @@ pub fn create_config(testing: bool) -> AWConfig {
}

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");
let content = fs::read_to_string(config_path).expect("Failed to read config file");
let toml_value: toml::Value = toml::from_str(&content).expect("Failed to parse config file");

let mut aw_config: AWConfig =
toml_value.try_into().expect("Failed to convert TOML value to AWConfig");

for field in missing_cors_fields {
let Ok(value_str) = datastore.get_key_value(&format!("cors.{field}")) else { continue };

match field.as_str() {
"cors" => aw_config.cors = serde_json::from_str(&value_str).unwrap_or_default(),
"cors_regex" => aw_config.cors_regex = serde_json::from_str(&value_str).unwrap_or_default(),
"cors_allow_aw_chrome_extension" => aw_config.cors_allow_aw_chrome_extension = serde_json::from_str(&value_str).unwrap_or_default(),
"cors_allow_all_mozilla_extension" => aw_config.cors_allow_all_mozilla_extension = serde_json::from_str(&value_str).unwrap_or_default(),
_ => {}
}
}
aw_config
}
45 changes: 33 additions & 12 deletions aw-server/src/endpoints/cors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,26 @@ 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];
let mut allowed_exact_origins = vec![root_url.clone(), root_url_localhost.clone()];
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 = config.cors_regex.clone();

if config.cors_allow_aw_chrome_extension {
allowed_regex_origins.push("chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string());
}
let mut allowed_regex_origins = vec![
"chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string(),

if config.cors_allow_all_mozilla_extension {
// 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
"moz-extension://.*".to_string(),
];
allowed_regex_origins.extend(config.cors_regex.clone());
allowed_regex_origins.push("moz-extension://.*".to_string());
}

if config.testing {
allowed_exact_origins.extend(vec![
"http://127.0.0.1:27180".to_string(),
"http://localhost:27180".to_string(),
]);
allowed_regex_origins.push("chrome-extension://.*".to_string());
}

Expand All @@ -32,13 +37,29 @@ pub fn cors(config: &AWConfig) -> rocket_cors::Cors {
let allowed_headers = AllowedHeaders::all(); // TODO: is this unsafe?

// You can also deserialize this
rocket_cors::CorsOptions {
let cors_options = rocket_cors::CorsOptions {
allowed_origins,
allowed_methods,
allowed_headers,
allow_credentials: false,
..Default::default()
};

match cors_options.to_cors() {
Ok(cors) => cors,
Err(e) => {
error!("Failed to set up CORS with provided origins: {:?}", e);
error!("Exact origins: {:?}", allowed_exact_origins);
error!("Regex origins: {:?}", allowed_regex_origins);
// Fallback to a safe default to allow the server to at least start
let fallback_origins = vec![root_url, root_url_localhost];
let empty_regex: &[String] = &[];
rocket_cors::CorsOptions {
allowed_origins: AllowedOrigins::some(&fallback_origins, empty_regex),
..Default::default()
}
.to_cors()
.expect("Safe default CORS should always work")
}
}
.to_cors()
.expect("Failed to set up CORS")
}
Loading