snapshot of https://github.com/ActivityWatch/aw-server.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 3m6s
Lint / lint (push) Successful in 22s
Build / typecheck (ubuntu-latest, 3.9) (push) Successful in 1m49s
Lint / format (push) Successful in 42s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m31s

This commit is contained in:
upstream-mirror
2026-09-16 00:20:00 +05:00
commit 07fb811788
40 changed files with 4688 additions and 0 deletions

139
aw_server/__about__.py Normal file
View File

@@ -0,0 +1,139 @@
import subprocess
import re
import logging
from typing import Optional
from pathlib import Path
from importlib.metadata import PackageNotFoundError, version as get_version
logger = logging.getLogger(__name__)
# TODO: Remove reliance on
basever = "v0.12"
srcpath = Path(__file__).absolute().parent
projectpath = srcpath.parent
bundlepath = projectpath.parent # the ActivityWatch bundle repo, in some circumstances
# This line set by script when run (metaprogramming)
__version__ = "v0.13.2"
def get_rev():
p = subprocess.run(
"git rev-parse --short HEAD",
shell=True,
capture_output=True,
encoding="utf8",
cwd=workdir,
)
return p.stdout.strip()
def get_tag_exact():
rev = get_rev()
p = subprocess.run(
["git", "describe", "--tags", "--abbrev=0", "--exact-match", rev],
capture_output=True,
encoding="utf8",
cwd=workdir,
)
if p.stderr:
if (
"no tag exactly matches" in p.stderr
or "No names found, cannot describe anything" in p.stderr
):
pass
else:
raise Exception(p.stderr)
else:
return p.stdout.strip()
def get_tag_latest():
try:
tag = subprocess.check_output(
["git", "describe", "--abbrev=0", "--tags"], encoding="utf8", cwd=workdir
).strip()
if not tag:
return
basever = tag
except subprocess.CalledProcessError as e:
print(e)
return
try:
return f"{basever}.dev+{get_rev()}"
except Exception as e:
# Unable to get current commit with git
logger.exception(e)
return None
def detect_version_git() -> Optional[str]:
# Returns an exact tag if there is one, or an approximate one + commit identifier
version = get_tag_exact()
if version:
return version
version = get_tag_latest()
if version:
return version
return None
def detect_version_pkg() -> Optional[str]:
try:
return f"v{get_version('aw-server')}.dev+{get_rev()}"
except PackageNotFoundError:
return None
def detect_version_poetry() -> Optional[str]:
"""Detect version from pyproject.toml file, with `poetry version -s`"""
try:
basever = subprocess.check_output(
["poetry", "version", "-s"], encoding="utf8", cwd=workdir
).strip()
except subprocess.CalledProcessError as e:
print(e)
return None
return f"v{basever}.dev+{get_rev()}"
def detect_version():
for detectfunc in (detect_version_git, detect_version_pkg):
version = detectfunc()
if version:
return version
return basever + ".dev+unknown"
def assign_static_version():
"""Self-modifies the current script to lock in version"""
version = detect_version()
with open(__file__) as f:
versionline = f'\n__version__ = "{version}"'
data = re.sub(r"\n__version__ = [^\n;]+", versionline, f.read())
with open(__file__, "w") as f:
f.write(data)
print(f"Set versionline: {versionline.strip()}")
if __name__ == "__main__":
# Use the bundle repo as working repo, if it exists
if (bundlepath / ".git").exists():
print("Found bundle repo in parent dir, using for calls to git")
workdir = bundlepath
else:
workdir = projectpath
assign_static_version()
print("By method:")
print(f" git: {detect_version_git()}")
print(f" pkg: {detect_version_pkg()}")

11
aw_server/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
import logging as _logging
logger = _logging.getLogger(__name__)
from .__about__ import __version__
from .main import main
__all__ = [
"__version__",
"main",
]

3
aw_server/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
import aw_server
aw_server.main()

388
aw_server/api.py Normal file
View File

@@ -0,0 +1,388 @@
import functools
import json
import logging
from datetime import datetime
from pathlib import Path
from socket import gethostname
from typing import (
Any,
Dict,
List,
Optional,
)
from uuid import uuid4
import iso8601
from aw_core.dirs import get_data_dir
from aw_core.log import get_log_file_path
from aw_core.models import Event
from aw_query import query2
from aw_query.exceptions import QueryException
from aw_transform import heartbeat_merge
from .__about__ import __version__
from .exceptions import NotFound
from .profile import profile_from_env
from .settings import Settings
logger = logging.getLogger(__name__)
def get_device_id() -> str:
path = Path(get_data_dir("aw-server")) / "device_id"
if path.exists():
with open(path) as f:
return f.read()
else:
uuid = str(uuid4())
with open(path, "w") as f:
f.write(uuid)
return uuid
def check_bucket_exists(f):
@functools.wraps(f)
def g(self, bucket_id, *args, **kwargs):
if bucket_id not in self.db.buckets():
raise NotFound("NoSuchBucket", f"There's no bucket named {bucket_id}")
return f(self, bucket_id, *args, **kwargs)
return g
class ServerAPI:
def __init__(self, db, testing) -> None:
self.db = db
self.settings = Settings(testing)
self.testing = testing
self.profile = profile_from_env(testing=testing)
self.last_event = {} # type: dict
def get_info(self) -> Dict[str, Any]:
"""Get server info"""
payload = {
"hostname": gethostname(),
"version": __version__,
"testing": self.testing,
"device_id": get_device_id(),
"profile": self.profile,
}
return payload
def get_buckets(self) -> Dict[str, Dict]:
"""Get dict {bucket_name: Bucket} of all buckets"""
logger.debug("Received get request for buckets")
buckets = self.db.buckets()
for b in buckets:
# TODO: Move this code to aw-core?
last_events = self.db[b].get(limit=1)
if len(last_events) > 0:
last_event = last_events[0]
last_updated = last_event.timestamp + last_event.duration
buckets[b]["last_updated"] = last_updated.isoformat()
return buckets
@check_bucket_exists
def get_bucket_metadata(self, bucket_id: str) -> Dict[str, Any]:
"""Get metadata about bucket."""
bucket = self.db[bucket_id]
return bucket.metadata()
@check_bucket_exists
def export_bucket(self, bucket_id: str) -> Dict[str, Any]:
"""Export a bucket to a dataformat consistent across versions, including all events in it."""
bucket = self.get_bucket_metadata(bucket_id)
bucket["events"] = self.get_events(bucket_id, limit=-1)
# Scrub event IDs
for event in bucket["events"]:
del event["id"]
return bucket
def export_all(self) -> Dict[str, Any]:
"""Exports all buckets and their events to a format consistent across versions"""
buckets = self.get_buckets()
exported_buckets = {}
for bid in buckets.keys():
exported_buckets[bid] = self.export_bucket(bid)
return exported_buckets
def import_bucket(self, bucket_data: Any):
bucket_id = bucket_data["id"]
logger.info(f"Importing bucket {bucket_id}")
# TODO: Check that bucket doesn't already exist
self.db.create_bucket(
bucket_id,
type=bucket_data["type"],
client=bucket_data["client"],
hostname=bucket_data["hostname"],
created=(
bucket_data["created"]
if isinstance(bucket_data["created"], datetime)
else iso8601.parse_date(bucket_data["created"])
),
)
# scrub IDs from events
# (otherwise causes weird bugs with no events seemingly imported when importing events exported from aw-server-rust, which contains IDs)
for event in bucket_data["events"]:
if "id" in event:
del event["id"]
self.create_events(
bucket_id,
[Event(**e) if isinstance(e, dict) else e for e in bucket_data["events"]],
)
def import_all(self, buckets: Dict[str, Any]):
for bid, bucket in buckets.items():
self.import_bucket(bucket)
def create_bucket(
self,
bucket_id: str,
event_type: str,
client: str,
hostname: str,
created: Optional[datetime] = None,
data: Optional[Dict[str, Any]] = None,
) -> bool:
"""
Create a 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).
Returns True if successful, otherwise false if a bucket with the given ID already existed.
"""
if created is None:
created = datetime.now()
if bucket_id in self.db.buckets():
return False
if hostname == "!local":
info = self.get_info()
if data is None:
data = {}
hostname = info["hostname"]
data["device_id"] = info["device_id"]
self.db.create_bucket(
bucket_id,
type=event_type,
client=client,
hostname=hostname,
created=created,
data=data,
)
return True
@check_bucket_exists
def update_bucket(
self,
bucket_id: str,
event_type: Optional[str] = None,
client: Optional[str] = None,
hostname: Optional[str] = None,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""Update bucket metadata"""
self.db.update_bucket(
bucket_id,
type=event_type,
client=client,
hostname=hostname,
data=data,
)
return None
@check_bucket_exists
def delete_bucket(self, bucket_id: str) -> None:
"""Delete a bucket"""
self.db.delete_bucket(bucket_id)
logger.debug(f"Deleted bucket '{bucket_id}'")
return None
@check_bucket_exists
def get_event(
self,
bucket_id: str,
event_id: int,
) -> Optional[Event]:
"""Get a single event from a bucket"""
logger.debug(
f"Received get request for event {event_id} in bucket '{bucket_id}'"
)
event = self.db[bucket_id].get_by_id(event_id)
return event.to_json_dict() if event else None
@check_bucket_exists
def get_events(
self,
bucket_id: str,
limit: int = -1,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> List[Event]:
"""Get events from a bucket"""
logger.debug(f"Received get request for events in bucket '{bucket_id}'")
if limit is None: # Let limit = None also mean "no limit"
limit = -1
events = [
event.to_json_dict() for event in self.db[bucket_id].get(limit, start, end)
]
return events
@check_bucket_exists
def create_events(self, bucket_id: str, events: List[Event]) -> List[Event]:
"""Create events for a bucket. Can handle both single events and multiple ones.
Always returns a list of inserted events (matching aw-server-rust behavior).
For single events, the returned event includes the server-assigned ID.
For bulk inserts, returns empty list (events may not have IDs without a response-SQL roundtrip).
"""
if len(events) == 1:
# Pass as single Event so Bucket.insert uses insert_one (returns Event with ID)
inserted = self.db[bucket_id].insert(events[0])
return [inserted]
else:
self.db[bucket_id].insert(events)
return []
@check_bucket_exists
def get_eventcount(
self,
bucket_id: str,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> int:
"""Get eventcount from a bucket"""
logger.debug(f"Received get request for eventcount in bucket '{bucket_id}'")
return self.db[bucket_id].get_eventcount(start, end)
@check_bucket_exists
def delete_event(self, bucket_id: str, event_id) -> bool:
"""Delete a single event from a bucket"""
return self.db[bucket_id].delete(event_id)
@check_bucket_exists
def heartbeat(self, bucket_id: str, heartbeat: Event, pulsetime: float) -> Event:
"""
Heartbeats are useful when implementing watchers that simply keep
track of a state, how long it's in that state and when it changes.
A single heartbeat always has a duration of zero.
If the heartbeat was identical to the last (apart from timestamp), then the last event has its duration updated.
If the heartbeat differed, then a new event is created.
Such as:
- Active application and window title
- Example: aw-watcher-window
- Currently open document/browser tab/playing song
- Example: wakatime
- Example: aw-watcher-web
- Example: aw-watcher-spotify
- Is the user active/inactive?
Send an event on some interval indicating if the user is active or not.
- Example: aw-watcher-afk
Inspired by: https://wakatime.com/developers#heartbeats
"""
logger.debug(
"Received heartbeat in bucket '{}'\n\ttimestamp: {}, duration: {}, pulsetime: {}\n\tdata: {}".format(
bucket_id,
heartbeat.timestamp,
heartbeat.duration,
pulsetime,
heartbeat.data,
)
)
# The endtime here is set such that in the event that the heartbeat is older than an
# existing event we should try to merge it with the last event before the heartbeat instead.
# FIXME: This (the endtime=heartbeat.timestamp) gets rid of the "heartbeat was older than last event"
# warning and also causes a already existing "newer" event to be overwritten in the
# replace_last call below. This is problematic.
# Solution: This could be solved if we were able to replace arbitrary events.
# That way we could double check that the event has been applied
# and if it hasn't we simply replace it with the updated counterpart.
last_event = None
if bucket_id not in self.last_event:
last_events = self.db[bucket_id].get(limit=1)
if len(last_events) > 0:
last_event = last_events[0]
else:
last_event = self.last_event[bucket_id]
if last_event:
if last_event.data == heartbeat.data:
merged = heartbeat_merge(last_event, heartbeat, pulsetime)
if merged is not None:
# Heartbeat was merged into last_event
logger.debug(
"Received valid heartbeat, merging. (bucket: {})".format(
bucket_id
)
)
self.last_event[bucket_id] = merged
self.db[bucket_id].replace_last(merged)
return merged
else:
logger.info(
"Received heartbeat after pulse window, inserting as new event. (bucket: {})".format(
bucket_id
)
)
else:
logger.debug(
"Received heartbeat with differing data, inserting as new event. (bucket: {})".format(
bucket_id
)
)
else:
logger.info(
"Received heartbeat, but bucket was previously empty, inserting as new event. (bucket: {})".format(
bucket_id
)
)
self.db[bucket_id].insert(heartbeat)
self.last_event[bucket_id] = heartbeat
return heartbeat
def query2(self, name, query, timeperiods, cache):
result = []
for timeperiod in timeperiods:
period = timeperiod.split("/")[
:2
] # iso8601 timeperiods are separated by a slash
if len(period) != 2:
raise QueryException(
f"Invalid timeperiod '{timeperiod}': expected two ISO8601 "
"datetimes separated by a slash (start/end)"
)
try:
starttime = iso8601.parse_date(period[0])
endtime = iso8601.parse_date(period[1])
except iso8601.ParseError as e:
raise QueryException(f"Invalid timeperiod '{timeperiod}': {e}")
query = "".join(query)
result.append(query2.query(name, query, starttime, endtime, self.db))
return result
# TODO: Right now the log format on disk has to be JSON, this is hard to read by humans...
def get_log(self):
"""Get the server log in json format"""
payload = []
with open(get_log_file_path()) as log_file:
for line in log_file.readlines()[::-1]:
payload.append(json.loads(line))
return payload, 200
def get_setting(self, key):
"""Get a setting"""
return self.settings.get(key, None)
def set_setting(self, key, value):
"""Set a setting"""
self.settings[key] = value
return value

45
aw_server/config.py Normal file
View File

@@ -0,0 +1,45 @@
from aw_core.config import load_config_toml
from .profile import DEFAULT_PROFILE, is_testing
default_config = """
[server]
host = "localhost"
port = "5600"
storage = "peewee"
cors_origins = ""
[server.custom_static]
[server-testing]
host = "localhost"
port = "5666"
storage = "peewee"
cors_origins = ""
[server-testing.custom_static]
""".strip()
def load_config():
"""Load aw-server.toml from the current profile's config dir.
Must be called *after* ``export_profile`` so aw-core dirs see
``AW_PROFILE`` and isolate the file from other instances.
"""
return load_config_toml("aw-server", default_config)
def config_section(profile: str) -> str:
"""TOML section for this profile: ``server`` or ``server-<profile>``."""
return "server" if profile == DEFAULT_PROFILE else f"server-{profile}"
def default_port(profile: str) -> int:
"""Built-in port: 5666 for testing, 5600 otherwise.
Named profiles take ``port`` from their own isolated config (the
research build bakes 5667 into that file). There is no hash-to-port
table — a custom profile without a port set collides with default.
"""
return 5666 if is_testing(profile) else 5600

View File

@@ -0,0 +1,55 @@
"""
Contains endpoints, as well as utility functions for custom static content.
NOTE: Experimental, not (yet) implemented in aw-server-rust.
Idea: Allow custom watchers to extend the Web UI and to write custom visualizations completely independently and free.
Usage for the watcher developer:
- You can use Vanilla JavaScript, Vue, React, ... as long as you have static content at the end.
- Register your watcher visualization in the config:
[server.custom_static]
aw-watcher-example=/home/user/path/to/static_dir/
- Your custom static content automatically gets the data for the requested time span as GET parameter called "data".
Another parameter called "view" can be used if you want to create multiple visualizations for a single watcher.
- You can show your custom visualizations in the official Activity Watch UI using the "Custom Watcher View"
See https://github.com/ActivityWatch/activitywatch/issues/453#issuecomment-910567848
"""
from flask import (
Blueprint,
escape,
jsonify,
send_from_directory,
)
def get_custom_static_blueprint(custom_static_directories):
custom_static_blueprint = Blueprint("custom_static", __name__, url_prefix="/")
@custom_static_blueprint.route("pages/")
def custom_static_supported_pages():
"""Serves a list of all watchers that are supported / were registered successfully"""
return jsonify(list(custom_static_directories.keys()))
@custom_static_blueprint.route(
"pages/<string:name>/", defaults={"path": "index.html"}
)
@custom_static_blueprint.route("pages/<string:name>/<path:path>")
def custom_static_pages(name: str, path: str):
"""Serves the custom static content"""
if name in custom_static_directories:
return send_from_directory(custom_static_directories[name], path)
else:
return (
f"Static content: {escape(path)} of watcher: {escape(name)} not found!",
404,
)
return custom_static_blueprint

19
aw_server/exceptions.py Normal file
View File

@@ -0,0 +1,19 @@
import werkzeug.exceptions
class BadRequest(werkzeug.exceptions.BadRequest):
def __init__(self, type: str, message: str) -> None:
super().__init__(message)
self.type = type
class NotFound(werkzeug.exceptions.NotFound):
def __init__(self, type: str, message: str) -> None:
super().__init__(message)
self.type = type
class Unauthorized(werkzeug.exceptions.Unauthorized):
def __init__(self, type: str, message: str) -> None:
super().__init__(message)
self.type = type

134
aw_server/extension_cors.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Endpoint scoping for the moz-extension:// CORS wildcard.
Firefox assigns every extension its own random origin, so aw-server uses
``moz-extension://*`` to allow aw-watcher-web without knowing the ID in advance.
That wildcard also permits every other installed extension to reach the full API —
including ``/api/0/export``, ``/api/0/import``, queries, and settings — with no
host permission and therefore no install-time browser prompt naming ActivityWatch.
This module registers a ``before_request`` hook that restricts wildcard-matched
extension origins to the three endpoints aw-watcher-web actually needs:
GET /api/0/info — hostname/version detection
POST /api/0/buckets/aw-watcher-web-<id> — ensure its bucket exists
POST /api/0/buckets/aw-watcher-web-<id>/heartbeat — heartbeat recording
All other paths return 403 before the handler executes. flask-cors CORS headers are
still added by the after_request hook, but the 403 status prevents JavaScript from
treating the response as a successful cross-origin fetch (and more importantly, the
server side never processes the request).
User-configured origins (``cors_origins`` / ``cors_regex`` in config) are exempted:
those are explicit opt-ins by the server owner, unlike the built-in wildcard.
Path matching uses split segments rather than the raw path string to avoid
percent-encoding bypasses — the same bug class as aw-server-rust#588 and #636.
See also: ActivityWatch/aw-server-rust#637 (the Rust sibling of this fix).
"""
import logging
import re
from typing import List, Optional
from flask import Flask, abort, request
logger = logging.getLogger(__name__)
_EXTENSION_SCHEME = "moz-extension://"
def register(app: Flask, user_origins: List[str]) -> None:
"""Register the extension CORS scope hook on *app*.
*user_origins* — origins the user configured explicitly (captured before
the built-in ``moz-extension://*`` wildcard is appended). These are
explicit opt-ins and bypass the scope narrowing.
"""
@app.before_request
def _restrict_extension_cors() -> Optional[object]:
origin = request.headers.get("Origin", "")
if not origin.lower().startswith(_EXTENSION_SCHEME):
return None # not a moz-extension origin — let flask-cors handle it
# flask-cors 4 treats strings containing regex metacharacters as regular
# expressions and otherwise compares them case-insensitively. Keep this
# exemption consistent with that contract.
for pattern in user_origins:
if _matches_configured_origin(origin, pattern):
return None
segments = [s for s in request.path.split("/") if s]
if _is_allowed(request.method, segments):
return None
abort(403)
def _matches_configured_origin(origin: str, pattern: str) -> bool:
"""Match an origin using flask-cors 4's configured-origin semantics."""
regex_chars = "*\\]?$^[()"
if any(char in pattern for char in regex_chars):
try:
return re.match(pattern, origin, flags=re.IGNORECASE) is not None
except re.error:
return False
return origin.lower() == pattern.lower()
def _is_allowed(method: str, segments: List[str]) -> bool:
"""Return True if *method* + *segments* is a path aw-watcher-web actually uses.
For OPTIONS preflights the path is checked against the set of allowed paths
(not the Access-Control-Request-Method header) to keep the logic simple while
still blocking preflights for disallowed paths such as ``/api/0/export``.
"""
if method == "OPTIONS":
return _is_allowed_path(segments)
# GET /api/0/info
if method == "GET" and segments == ["api", "0", "info"]:
return True
# POST /api/0/buckets/aw-watcher-web-<id>
if (
method == "POST"
and len(segments) == 4
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
):
return True
# POST /api/0/buckets/aw-watcher-web-<id>/heartbeat
if (
method == "POST"
and len(segments) == 5
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
and segments[4] == "heartbeat"
):
return True
return False
def _is_allowed_path(segments: List[str]) -> bool:
"""Return True if *segments* is on any allowed endpoint (for OPTIONS checks)."""
if segments == ["api", "0", "info"]:
return True
if (
len(segments) == 4
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
):
return True
if (
len(segments) == 5
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
and segments[4] == "heartbeat"
):
return True
return False

25
aw_server/log.py Normal file
View File

@@ -0,0 +1,25 @@
import logging
from werkzeug import serving
class FlaskLogHandler(serving.WSGIRequestHandler):
def __init__(self, *args):
self.logger = logging.getLogger("flask")
super().__init__(*args)
def log(self, levelname, message, *args):
msg = args[0]
code = int(args[1])
if code in [200, 304]:
levelname = "debug"
# type = "debug"
if levelname == "info":
levelno = logging.INFO
elif levelname == "debug":
levelno = logging.DEBUG
else:
raise Exception("Unknown level " + type)
self.logger.log(levelno, f"{code} ({self.address_string()}): {msg}")

176
aw_server/main.py Normal file
View File

@@ -0,0 +1,176 @@
import logging
import sys
from aw_core.log import setup_logging
from aw_datastore import get_storage_methods
from . import __version__
from .config import config_section, default_port, load_config
from .profile import (
DEFAULT_PROFILE,
export_profile,
is_testing,
resolve_profile,
)
from .server import _start
logger = logging.getLogger(__name__)
def main():
"""Called from the executable and __main__.py"""
settings, storage_method = parse_settings()
# FIXME: The LogResource API endpoint relies on the log being in JSON format
# at the path specified by aw_core.log.get_log_file_path(). We probably want
# to write the LogResource API so that it does not depend on any physical file
# but instead add a logging handler that it can use privately.
# That is why log_file_json=True currently.
# UPDATE: The LogResource API is no longer available so log_file_json is now False.
setup_logging(
"aw-server",
testing=settings.testing,
verbose=settings.verbose,
log_stderr=True,
log_file=True,
)
logger.info(f"Using storage method: {settings.storage}")
if settings.testing:
logger.info("Will run in testing mode")
if settings.profile != DEFAULT_PROFILE:
logger.info(f"Running with profile: {settings.profile}")
if settings.custom_static:
logger.info(f"Using custom_static: {settings.custom_static}")
logger.info("Starting up...")
_start(
host=settings.host,
port=settings.port,
testing=settings.testing,
storage_method=storage_method,
cors_origins=settings.cors_origins,
custom_static=settings.custom_static,
)
def parse_settings():
import argparse
""" CLI Arguments """
parser = argparse.ArgumentParser(description="Starts an ActivityWatch server")
parser.add_argument(
"--testing",
action="store_true",
help="Run aw-server in testing mode using different ports and database (alias for --profile testing)",
)
parser.add_argument(
"--profile",
dest="profile",
default=None,
help="Named instance profile (data, config, port and settings are isolated). --testing is an alias for --profile testing.",
)
parser.add_argument("--verbose", action="store_true", help="Be chatty.")
parser.add_argument(
"--version",
action="store_true",
help="Print version and quit",
)
parser.add_argument(
"--log-json", action="store_true", help="Output the logs in JSON format"
)
parser.add_argument(
"--host", dest="host", help="Which host address to bind the server to"
)
parser.add_argument(
"--port", dest="port", type=int, help="Which port to run the server on"
)
parser.add_argument(
"--storage",
dest="storage",
help="The method to use for storing data. Some methods (such as MongoDB) require specific Python packages to be available (in the MongoDB case: pymongo)",
)
parser.add_argument(
"--cors-origins",
dest="cors_origins",
help="CORS origins to allow (as a comma separated list)",
)
parser.add_argument(
"--custom-static",
dest="custom_static",
help="The custom static directories. Format: watcher_name=path,watcher_name2=path2,...",
)
args = parser.parse_args()
if args.version:
print(__version__)
sys.exit(0)
try:
profile = resolve_profile(args.profile, args.testing)
except ValueError as e:
parser.error(str(e))
# Export before loading config so aw-core dirs isolate this profile.
export_profile(profile)
testing = is_testing(profile)
""" Parse config file """
config = load_config()
section = config_section(profile)
if section not in config:
if profile not in (DEFAULT_PROFILE,):
logger.warning(
"Profile %s has no [%s] section, falling back to [server] "
"(port %s may collide with the default instance)",
profile,
section,
default_port(profile),
)
section = "server"
settings = argparse.Namespace()
settings.host = config[section]["host"]
settings.port = int(config[section]["port"])
settings.storage = config[section]["storage"]
settings.cors_origins = config[section]["cors_origins"]
settings.custom_static = dict(config[section]["custom_static"])
settings.profile = profile
settings.testing = testing
""" If a argument is not none, override the config value """
for key, value in vars(args).items():
if key in ("testing", "profile"):
# Resolved above; --profile testing must keep testing=True
# even when the raw --testing flag was absent.
continue
if value is not None:
if key == "custom_static":
settings.custom_static = parse_str_to_dict(value)
else:
vars(settings)[key] = value
settings.cors_origins = [o for o in settings.cors_origins.split(",") if o]
storage_methods = get_storage_methods()
storage_method = storage_methods[settings.storage]
return settings, storage_method
def parse_str_to_dict(str_value):
"""Parses a dict from a string in format: key=value,key2=value2,..."""
output = dict()
key_value_pairs = str_value.split(",")
for pair in key_value_pairs:
pair_split = pair.split("=")
if len(pair_split) != 2:
raise ValueError(f"Cannot parse key value pair: {pair}")
key, value = pair_split
output[key] = value
return output

104
aw_server/profile.py Normal file
View File

@@ -0,0 +1,104 @@
"""Profile resolution for aw-server.
A *profile* names an isolated ActivityWatch instance (data, config, port,
settings). `default` is the ordinary install, `testing` is what `--testing`
has always meant, and any other name (for example `research`) is a sibling
instance that can run at the same time as the others.
The carrier is the ``AW_PROFILE`` environment variable. aw-core's
``_get_appname()`` suffixes the platformdirs root when it is set to a
non-empty value, so exporting the profile here isolates dirs for this
process and anything it spawns — without threading a flag through the
datastore, settings, or Flask stack.
Kept in sync with aw-qt's ``aw_qt/profile.py`` (same validation rule as
aw-server-rust). One intentional difference: the default profile *unsets*
``AW_PROFILE`` instead of setting it to ``"default"``. aw-core treats any
non-empty value as a suffix, so ``AW_PROFILE=default`` would resolve to
``activitywatch-default`` and orphan an existing install.
Testing-root note (ActivityWatch/activitywatch#1399): python aw-core#149
maps ``AW_PROFILE=testing`` to ``activitywatch-testing``. The rust
isolation branch keeps testing on the bare ``activitywatch`` root so
existing ``sqlite-testing.db`` files are not orphaned. This module follows
the already-merged python dirs contract; unifying the rust testing root
is a follow-up on that isolation PR, not something to special-case here.
"""
import os
import re
from typing import Optional
DEFAULT_PROFILE = "default"
TESTING_PROFILE = "testing"
#: Same rule as aw-server-rust's `validate_profile`: lowercase alphanumeric
#: plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment.
PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
ENV_VAR = "AW_PROFILE"
def validate_profile(profile: str) -> str:
"""Return the profile unchanged, or raise ValueError if it is not usable."""
if not PROFILE_RE.match(profile):
raise ValueError(
f"Invalid profile name {profile!r}: expected lowercase alphanumeric "
"with '-' or '_', at most 32 characters"
)
return profile
def resolve_profile(profile: Optional[str], testing: bool) -> str:
"""Resolve the effective profile from the CLI flags.
``--testing`` is an alias for ``--profile testing``; passing both is only
an error if they disagree.
"""
if profile is None:
return TESTING_PROFILE if testing else DEFAULT_PROFILE
profile = validate_profile(profile)
if testing and profile != TESTING_PROFILE:
raise ValueError(
f"--testing conflicts with --profile {profile}: --testing is an "
f"alias for --profile {TESTING_PROFILE}"
)
return profile
def is_testing(profile: str) -> bool:
return profile == TESTING_PROFILE
def profile_suffix(profile: str) -> str:
"""Filename suffix for a profile (``""``, ``"-testing"``, ``"-research"``)."""
return "" if profile == DEFAULT_PROFILE else f"-{profile}"
def profile_from_env(testing: bool = False) -> str:
"""Read the profile the process was started with.
Falls back to the `--testing` bool for callers that only track that, so
behaviour is unchanged when no profile was set.
"""
profile = os.environ.get(ENV_VAR)
if not profile:
return TESTING_PROFILE if testing else DEFAULT_PROFILE
try:
return validate_profile(profile)
except ValueError:
return TESTING_PROFILE if testing else DEFAULT_PROFILE
def export_profile(profile: str) -> None:
"""Publish the profile to this process and its children.
The default profile leaves ``AW_PROFILE`` unset so aw-core keeps the
bare ``activitywatch`` root. Named profiles (including ``testing``)
set the env var; children inherit it without a CLI flag.
"""
if profile == DEFAULT_PROFILE:
os.environ.pop(ENV_VAR, None)
else:
os.environ[ENV_VAR] = profile

0
aw_server/py.typed Normal file
View File

412
aw_server/rest.py Normal file
View File

@@ -0,0 +1,412 @@
import json
import traceback
from functools import wraps
from threading import Lock
from typing import Dict
import iso8601
from aw_core import schema
from aw_core.models import Event
from aw_query.exceptions import QueryException
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from flask_restx import Api, Resource, fields
from . import logger
from .api import ServerAPI
from .exceptions import BadRequest, Unauthorized
def host_header_check(f):
"""
Protects against DNS rebinding attacks (see https://github.com/ActivityWatch/activitywatch/security/advisories/GHSA-v9fg-6g9j-h4x4)
Some discussion in Syncthing how they do it: https://github.com/syncthing/syncthing/issues/4819
"""
@wraps(f)
def decorator(*args, **kwargs):
server_host = current_app.config["HOST"]
req_host = request.headers.get("host", None)
if server_host == "0.0.0.0":
logger.warning(
"Server is listening on 0.0.0.0, host header check is disabled (potential security issue)."
)
elif req_host is None:
return {"message": "host header is missing"}, 400
else:
if req_host.split(":")[0] not in ["localhost", "127.0.0.1", server_host]:
return {"message": f"host header is invalid (was {req_host})"}, 400
return f(*args, **kwargs)
return decorator
blueprint = Blueprint("api", __name__, url_prefix="/api")
api = Api(blueprint, doc="/", decorators=[host_header_check])
# Loads event and bucket schema from JSONSchema in aw_core
event = api.schema_model("Event", schema.get_json_schema("event"))
bucket = api.schema_model("Bucket", schema.get_json_schema("bucket"))
buckets_export = api.schema_model("Export", schema.get_json_schema("export"))
# TODO: Construct all the models from JSONSchema?
# A downside to contructing from JSONSchema: flask-restplus does not have marshalling support
info = api.model(
"Info",
{
"hostname": fields.String(),
"version": fields.String(),
"testing": fields.Boolean(),
"device_id": fields.String(),
"profile": fields.String(),
},
)
create_bucket = api.model(
"CreateBucket",
{
"client": fields.String(required=True),
"type": fields.String(required=True),
"hostname": fields.String(required=True),
},
)
update_bucket = api.model(
"UpdateBucket",
{
"client": fields.String(required=False),
"type": fields.String(required=False),
"hostname": fields.String(required=False),
"data": fields.String(required=False),
},
)
query = api.model(
"Query",
{
"timeperiods": fields.List(
fields.String, required=True, description="List of periods to query"
),
"query": fields.List(
fields.String, required=True, description="String list of query statements"
),
},
)
def copy_doc(api_method):
"""Decorator that copies another functions docstring to the decorated function.
Used to copy the docstrings in ServerAPI over to the flask-restplus Resources.
(The copied docstrings are then used by flask-restplus/swagger)"""
def decorator(f):
f.__doc__ = api_method.__doc__
return f
return decorator
# SERVER INFO
@api.route("/0/info")
class InfoResource(Resource):
@api.marshal_with(info)
@copy_doc(ServerAPI.get_info)
def get(self) -> Dict[str, Dict]:
return current_app.api.get_info()
# BUCKETS
@api.route("/0/buckets/")
class BucketsResource(Resource):
# TODO: Add response marshalling/validation
@copy_doc(ServerAPI.get_buckets)
def get(self) -> Dict[str, Dict]:
return current_app.api.get_buckets()
@api.route("/0/buckets/<string:bucket_id>")
class BucketResource(Resource):
@api.doc(model=bucket)
@copy_doc(ServerAPI.get_bucket_metadata)
def get(self, bucket_id):
return current_app.api.get_bucket_metadata(bucket_id)
@api.expect(create_bucket)
@copy_doc(ServerAPI.create_bucket)
def post(self, bucket_id):
data = request.get_json()
bucket_created = current_app.api.create_bucket(
bucket_id,
event_type=data["type"],
client=data["client"],
hostname=data["hostname"],
)
if bucket_created:
return {}, 200
else:
return {}, 304
@api.expect(update_bucket)
@copy_doc(ServerAPI.update_bucket)
def put(self, bucket_id):
data = request.get_json()
current_app.api.update_bucket(
bucket_id,
event_type=data["type"],
client=data["client"],
hostname=data["hostname"],
data=data["data"],
)
return {}, 200
@copy_doc(ServerAPI.delete_bucket)
@api.param("force", "Needs to be =1 to delete a bucket it non-testing mode")
def delete(self, bucket_id):
args = request.args
if not current_app.api.testing:
if "force" not in args or args["force"] != "1":
msg = "Deleting buckets is only permitted if aw-server is running in testing mode or if ?force=1"
raise Unauthorized("DeleteBucketUnauthorized", msg)
current_app.api.delete_bucket(bucket_id)
return {}, 200
# EVENTS
@api.route("/0/buckets/<string:bucket_id>/events")
class EventsResource(Resource):
# For some reason this doesn't work with the JSONSchema variant
# Marshalling doesn't work with JSONSchema events
# @api.marshal_list_with(event)
@api.doc(model=event)
@api.param("limit", "the maximum number of requests to get")
@api.param("start", "Start date of events")
@api.param("end", "End date of events")
@copy_doc(ServerAPI.get_events)
def get(self, bucket_id):
args = request.args
limit = int(args["limit"]) if "limit" in args else -1
start = iso8601.parse_date(args["start"]) if "start" in args else None
end = iso8601.parse_date(args["end"]) if "end" in args else None
events = current_app.api.get_events(
bucket_id, limit=limit, start=start, end=end
)
return events, 200
# TODO: How to tell expect that it could be a list of events? Until then we can't use validate.
@api.expect(event)
@copy_doc(ServerAPI.create_events)
def post(self, bucket_id):
data = request.get_json()
logger.debug(
"Received post request for event in bucket '{}' and data: {}".format(
bucket_id, data
)
)
if isinstance(data, dict):
events = [Event(**data)]
elif isinstance(data, list):
events = [Event(**e) for e in data]
else:
raise BadRequest("Invalid POST data", "")
events = current_app.api.create_events(bucket_id, events)
return [e.to_json_dict() for e in events], 200
@api.route("/0/buckets/<string:bucket_id>/events/count")
class EventCountResource(Resource):
@api.doc(model=fields.Integer)
@api.param("start", "Start date of eventcount")
@api.param("end", "End date of eventcount")
@copy_doc(ServerAPI.get_eventcount)
def get(self, bucket_id):
args = request.args
start = iso8601.parse_date(args["start"]) if "start" in args else None
end = iso8601.parse_date(args["end"]) if "end" in args else None
events = current_app.api.get_eventcount(bucket_id, start=start, end=end)
return events, 200
@api.route("/0/buckets/<string:bucket_id>/events/<int:event_id>")
class EventResource(Resource):
@api.doc(model=event)
@copy_doc(ServerAPI.get_event)
def get(self, bucket_id: str, event_id: int):
logger.debug(
f"Received get request for event with id '{event_id}' in bucket '{bucket_id}'"
)
event = current_app.api.get_event(bucket_id, event_id)
if event:
return event, 200
else:
return None, 404
@copy_doc(ServerAPI.delete_event)
def delete(self, bucket_id: str, event_id: int):
logger.debug(
"Received delete request for event with id '{}' in bucket '{}'".format(
event_id, bucket_id
)
)
success = current_app.api.delete_event(bucket_id, event_id)
return {"success": success}, 200
@api.route("/0/buckets/<string:bucket_id>/heartbeat")
class HeartbeatResource(Resource):
# Class-level lock shared across all instances.
# Flask-RESTX creates a new Resource instance per request, so an
# instance-level lock would provide no mutual exclusion.
lock = Lock()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@api.expect(event, validate=True)
@api.param(
"pulsetime", "Largest timewindow allowed between heartbeats for them to merge"
)
@copy_doc(ServerAPI.heartbeat)
def post(self, bucket_id):
heartbeat = Event(**request.get_json())
if "pulsetime" in request.args:
pulsetime = float(request.args["pulsetime"])
else:
raise BadRequest("MissingParameter", "Missing required parameter pulsetime")
# This lock is meant to ensure that only one heartbeat is processed at a time,
# as the heartbeat function is not thread-safe.
# This should maybe be moved into the api.py file instead (but would be very messy).
acquired = self.lock.acquire(timeout=10)
if not acquired:
logger.warning(
"Heartbeat lock could not be acquired within timeout, rejecting request."
)
return {"message": "Server busy, try again later"}, 503
try:
event = current_app.api.heartbeat(bucket_id, heartbeat, pulsetime)
finally:
self.lock.release()
return event.to_json_dict(), 200
# QUERY
@api.route("/0/query/")
class QueryResource(Resource):
# TODO Docs
@api.expect(query, validate=True)
@api.param("name", "Name of the query (required if using cache)")
def post(self):
name = ""
if "name" in request.args:
name = request.args["name"]
query = request.get_json()
try:
result = current_app.api.query2(
name, query["query"], query["timeperiods"], False
)
return jsonify(result)
except QueryException as qe:
traceback.print_exc()
return {"type": type(qe).__name__, "message": str(qe)}, 400
# EXPORT AND IMPORT
@api.route("/0/export")
class ExportAllResource(Resource):
@api.doc(model=buckets_export)
@copy_doc(ServerAPI.export_all)
def get(self):
buckets_export = current_app.api.export_all()
payload = {"buckets": buckets_export}
response = make_response(json.dumps(payload))
filename = "aw-buckets-export.json"
response.headers["Content-Disposition"] = "attachment; filename={}".format(
filename
)
return response
# TODO: Perhaps we don't need this, could be done with a query argument to /0/export instead
@api.route("/0/buckets/<string:bucket_id>/export")
class BucketExportResource(Resource):
@api.doc(model=buckets_export)
@copy_doc(ServerAPI.export_bucket)
def get(self, bucket_id):
bucket_export = current_app.api.export_bucket(bucket_id)
payload = {"buckets": {bucket_export["id"]: bucket_export}}
response = make_response(json.dumps(payload))
filename = "aw-bucket-export_{}.json".format(bucket_export["id"])
response.headers["Content-Disposition"] = "attachment; filename={}".format(
filename
)
return response
@api.route("/0/import")
class ImportAllResource(Resource):
@api.expect(buckets_export)
@copy_doc(ServerAPI.import_all)
def post(self):
# If import comes from a form in th web-ui
if len(request.files) > 0:
# web-ui form only allows one file, but technically it's possible to
# upload multiple files at the same time
for filename, f in request.files.items():
buckets = json.loads(f.stream.read())["buckets"]
current_app.api.import_all(buckets)
# Normal import from body
else:
buckets = request.get_json()["buckets"]
current_app.api.import_all(buckets)
return None, 200
# LOGGING
@api.route("/0/log")
class LogResource(Resource):
@copy_doc(ServerAPI.get_log)
def get(self):
return current_app.api.get_log(), 200
# SETTINGS
@api.route("/0/settings", defaults={"key": ""})
@api.route("/0/settings/<string:key>")
class SettingsResource(Resource):
def get(self, key: str):
data = current_app.api.get_setting(key)
return jsonify(data)
def post(self, key: str):
if not key:
raise BadRequest("MissingParameter", "Missing required parameter key")
data = current_app.api.set_setting(key, request.get_json())
return data

151
aw_server/server.py Normal file
View File

@@ -0,0 +1,151 @@
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, List
import aw_datastore
import flask.json.provider
from aw_datastore import Datastore
from flask import (
Blueprint,
Flask,
current_app,
send_from_directory,
)
from flask_cors import CORS
from . import extension_cors, rest
from .api import ServerAPI
from .custom_static import get_custom_static_blueprint
from .log import FlaskLogHandler
logger = logging.getLogger(__name__)
app_folder = os.path.dirname(os.path.abspath(__file__))
static_folder = os.path.join(app_folder, "static")
root = Blueprint("root", __name__, url_prefix="/")
class AWFlask(Flask):
def __init__(
self,
host: str,
testing: bool,
storage_method=None,
cors_origins=[],
custom_static=dict(),
static_folder=static_folder,
static_url_path="",
):
name = "aw-server"
self.json_provider_class = CustomJSONProvider
# only prettyprint JSON if testing (due to perf)
self.json_provider_class.compact = not testing
# Initialize Flask
Flask.__init__(
self,
name,
static_folder=static_folder,
static_url_path=static_url_path,
)
self.config["HOST"] = host # needed for host-header check
with self.app_context():
_config_cors(cors_origins, testing)
# Initialize datastore and API
if storage_method is None:
storage_method = aw_datastore.get_storage_methods()["memory"]
db = Datastore(storage_method, testing=testing)
self.api = ServerAPI(db=db, testing=testing)
self.register_blueprint(root)
self.register_blueprint(rest.blueprint)
self.register_blueprint(get_custom_static_blueprint(custom_static))
class CustomJSONProvider(flask.json.provider.DefaultJSONProvider):
# encoding/decoding of datetime as iso8601 strings
# encoding of timedelta as second floats
def default(self, obj, *args, **kwargs):
try:
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, timedelta):
return obj.total_seconds()
except TypeError:
pass
return super().default(obj)
@root.route("/")
def static_root():
return current_app.send_static_file("index.html")
@root.route("/css/<path:path>")
def static_css(path):
return send_from_directory(static_folder + "/css", path)
@root.route("/js/<path:path>")
def static_js(path):
return send_from_directory(static_folder + "/js", path)
def _config_cors(cors_origins: List[str], testing: bool):
if cors_origins:
logger.warning(
"Running with additional allowed CORS origins specified through config "
"or CLI argument (could be a security risk): {}".format(cors_origins)
)
if testing:
# Used for development of aw-webui
cors_origins.append("http://127.0.0.1:27180/*")
# Capture user-configured origins before appending the built-in wildcard.
# extension_cors uses this list to exempt explicit opt-ins from scope narrowing.
user_origins = list(cors_origins)
# TODO: This could probably be more specific
# See https://github.com/ActivityWatch/aw-server/pull/43#issuecomment-386888769
cors_origins.append("moz-extension://*")
# See: https://flask-cors.readthedocs.org/en/latest/
CORS(current_app, resources={r"/api/*": {"origins": cors_origins}})
# Narrow the moz-extension wildcard to only the endpoints aw-watcher-web needs.
# See aw_server/extension_cors.py and ActivityWatch/aw-server-rust#637.
extension_cors.register(current_app._get_current_object(), user_origins)
# Only to be called from aw_server.main function!
def _start(
storage_method,
host: str,
port: int,
testing: bool = False,
cors_origins: List[str] = [],
custom_static: Dict[str, str] = dict(),
):
app = AWFlask(
host,
testing=testing,
storage_method=storage_method,
cors_origins=cors_origins,
custom_static=custom_static,
)
try:
app.run(
debug=testing,
host=host,
port=port,
request_handler=FlaskLogHandler,
use_reloader=False,
threaded=True,
)
except OSError as e:
logger.exception(e)
raise e

46
aw_server/settings.py Normal file
View File

@@ -0,0 +1,46 @@
import json
from pathlib import Path
from aw_core.dirs import get_config_dir
from .profile import profile_from_env, profile_suffix
class Settings:
def __init__(self, testing: bool):
# Dir isolation (AW_PROFILE) already separates profiles; the filename
# suffix is the pre-profile workaround and stays so --testing still
# finds settings-testing.json. Named profiles get the same shape.
filename = f"settings{profile_suffix(profile_from_env(testing=testing))}.json"
self.config_file = Path(get_config_dir("aw-server")) / filename
self.load()
def __getitem__(self, key):
return self.get(key)
def __setitem__(self, key, value):
return self.set(key, value)
def load(self):
if self.config_file.exists():
with open(self.config_file) as f:
self.data = json.load(f)
else:
self.data = {}
def save(self):
with open(self.config_file, "w") as f:
json.dump(self.data, f, indent=4)
def get(self, key: str, default=None):
if not key:
return self.data
return self.data.get(key, default)
def set(self, key, value):
if value:
self.data[key] = value
else:
if key in self.data:
del self.data[key]
self.save()