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
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:
139
aw_server/__about__.py
Normal file
139
aw_server/__about__.py
Normal 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
11
aw_server/__init__.py
Normal 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
3
aw_server/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import aw_server
|
||||
|
||||
aw_server.main()
|
||||
388
aw_server/api.py
Normal file
388
aw_server/api.py
Normal 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
45
aw_server/config.py
Normal 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
|
||||
55
aw_server/custom_static.py
Normal file
55
aw_server/custom_static.py
Normal 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
19
aw_server/exceptions.py
Normal 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
134
aw_server/extension_cors.py
Normal 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
25
aw_server/log.py
Normal 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
176
aw_server/main.py
Normal 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
104
aw_server/profile.py
Normal 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
0
aw_server/py.typed
Normal file
412
aw_server/rest.py
Normal file
412
aw_server/rest.py
Normal 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
151
aw_server/server.py
Normal 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
46
aw_server/settings.py
Normal 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()
|
||||
Reference in New Issue
Block a user