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:
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
|
||||
Reference in New Issue
Block a user