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:
20
.github/dependabot.yml
vendored
Normal file
20
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Set update schedule for GitHub Actions
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
|
||||||
|
# Maintain dependencies for pip/poetry
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Maintain submodule versions
|
||||||
|
- package-ecosystem: "gitsubmodule"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
81
.github/workflows/build.yml
vendored
Normal file
81
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Test on ${{ matrix.os }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
env:
|
||||||
|
RELEASE: false
|
||||||
|
SKIP_WEBUI: true
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||||
|
python_version: [3.9]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python_version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
pip install poetry==1.3.2 # due to: https://github.com/python-poetry/poetry/issues/7611
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate || source venv/Scripts/activate
|
||||||
|
make build
|
||||||
|
- name: Run tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
source venv/bin/activate || source venv/Scripts/activate
|
||||||
|
make test
|
||||||
|
- name: Report coverage
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# allow failures since codecov servers can be grumpy
|
||||||
|
bash <(curl -s https://codecov.io/bash) || true
|
||||||
|
- name: Package
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
source venv/bin/activate || source venv/Scripts/activate
|
||||||
|
pip install pyinstaller
|
||||||
|
make package
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
env:
|
||||||
|
RELEASE: false
|
||||||
|
SKIP_WEBUI: true
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
python_version: [3.9]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python_version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
pip install poetry
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate || source venv/Scripts/activate
|
||||||
|
make build
|
||||||
|
- name: Typecheck
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
source venv/bin/activate || source venv/Scripts/activate
|
||||||
|
make typecheck
|
||||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
schedule:
|
||||||
|
- cron: "3 10 * * 0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ python ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-and-quality
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
27
.github/workflows/dependabot-automerge.yml
vendored
Normal file
27
.github/workflows/dependabot-automerge.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Dependabot Auto-merge
|
||||||
|
|
||||||
|
# NOTE: This workflow relies on a Personal Access Token from the @ActivityWatchBot user
|
||||||
|
# See this issue for details: https://github.com/ridedott/merge-me-action/issues/1581
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
workflows:
|
||||||
|
# List all required workflow names here.
|
||||||
|
- Build
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto_merge:
|
||||||
|
name: Auto-merge
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.workflow_run.conclusion == 'success' && github.actor == 'dependabot[bot]'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: ridedott/merge-me-action@v2
|
||||||
|
with:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.AWBOT_GH_TOKEN }}
|
||||||
23
.github/workflows/lint.yml
vendored
Normal file
23
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
# Keep lint reproducible; the floating action tag can adopt new default rules.
|
||||||
|
- uses: jpetrucciani/ruff-check@5839e3c65007bdb626c1f3362153e45be347654f # ruff 0.15.22
|
||||||
|
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
- uses: psf/black@stable
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
*.swp
|
||||||
|
*.egg-info
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
static
|
||||||
|
.*cache
|
||||||
|
profiling
|
||||||
|
venv
|
||||||
|
|
||||||
|
# coverage stuff
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
coverage.xml
|
||||||
|
coverage_html
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "aw-webui"]
|
||||||
|
path = aw-webui
|
||||||
|
url = https://github.com/ActivityWatch/aw-webui.git
|
||||||
373
LICENSE.txt
Normal file
373
LICENSE.txt
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
Mozilla Public License Version 2.0
|
||||||
|
==================================
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
--------------
|
||||||
|
|
||||||
|
1.1. "Contributor"
|
||||||
|
means each individual or legal entity that creates, contributes to
|
||||||
|
the creation of, or owns Covered Software.
|
||||||
|
|
||||||
|
1.2. "Contributor Version"
|
||||||
|
means the combination of the Contributions of others (if any) used
|
||||||
|
by a Contributor and that particular Contributor's Contribution.
|
||||||
|
|
||||||
|
1.3. "Contribution"
|
||||||
|
means Covered Software of a particular Contributor.
|
||||||
|
|
||||||
|
1.4. "Covered Software"
|
||||||
|
means Source Code Form to which the initial Contributor has attached
|
||||||
|
the notice in Exhibit A, the Executable Form of such Source Code
|
||||||
|
Form, and Modifications of such Source Code Form, in each case
|
||||||
|
including portions thereof.
|
||||||
|
|
||||||
|
1.5. "Incompatible With Secondary Licenses"
|
||||||
|
means
|
||||||
|
|
||||||
|
(a) that the initial Contributor has attached the notice described
|
||||||
|
in Exhibit B to the Covered Software; or
|
||||||
|
|
||||||
|
(b) that the Covered Software was made available under the terms of
|
||||||
|
version 1.1 or earlier of the License, but not also under the
|
||||||
|
terms of a Secondary License.
|
||||||
|
|
||||||
|
1.6. "Executable Form"
|
||||||
|
means any form of the work other than Source Code Form.
|
||||||
|
|
||||||
|
1.7. "Larger Work"
|
||||||
|
means a work that combines Covered Software with other material, in
|
||||||
|
a separate file or files, that is not Covered Software.
|
||||||
|
|
||||||
|
1.8. "License"
|
||||||
|
means this document.
|
||||||
|
|
||||||
|
1.9. "Licensable"
|
||||||
|
means having the right to grant, to the maximum extent possible,
|
||||||
|
whether at the time of the initial grant or subsequently, any and
|
||||||
|
all of the rights conveyed by this License.
|
||||||
|
|
||||||
|
1.10. "Modifications"
|
||||||
|
means any of the following:
|
||||||
|
|
||||||
|
(a) any file in Source Code Form that results from an addition to,
|
||||||
|
deletion from, or modification of the contents of Covered
|
||||||
|
Software; or
|
||||||
|
|
||||||
|
(b) any new file in Source Code Form that contains any Covered
|
||||||
|
Software.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" of a Contributor
|
||||||
|
means any patent claim(s), including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by such
|
||||||
|
Contributor that would be infringed, but for the grant of the
|
||||||
|
License, by the making, using, selling, offering for sale, having
|
||||||
|
made, import, or transfer of either its Contributions or its
|
||||||
|
Contributor Version.
|
||||||
|
|
||||||
|
1.12. "Secondary License"
|
||||||
|
means either the GNU General Public License, Version 2.0, the GNU
|
||||||
|
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||||
|
Public License, Version 3.0, or any later versions of those
|
||||||
|
licenses.
|
||||||
|
|
||||||
|
1.13. "Source Code Form"
|
||||||
|
means the form of the work preferred for making modifications.
|
||||||
|
|
||||||
|
1.14. "You" (or "Your")
|
||||||
|
means an individual or a legal entity exercising rights under this
|
||||||
|
License. For legal entities, "You" includes any entity that
|
||||||
|
controls, is controlled by, or is under common control with You. For
|
||||||
|
purposes of this definition, "control" means (a) the power, direct
|
||||||
|
or indirect, to cause the direction or management of such entity,
|
||||||
|
whether by contract or otherwise, or (b) ownership of more than
|
||||||
|
fifty percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants and Conditions
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
2.1. Grants
|
||||||
|
|
||||||
|
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or trademark)
|
||||||
|
Licensable by such Contributor to use, reproduce, make available,
|
||||||
|
modify, display, perform, distribute, and otherwise exploit its
|
||||||
|
Contributions, either on an unmodified basis, with Modifications, or
|
||||||
|
as part of a Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||||
|
for sale, have made, import, and otherwise transfer either its
|
||||||
|
Contributions or its Contributor Version.
|
||||||
|
|
||||||
|
2.2. Effective Date
|
||||||
|
|
||||||
|
The licenses granted in Section 2.1 with respect to any Contribution
|
||||||
|
become effective for each Contribution on the date the Contributor first
|
||||||
|
distributes such Contribution.
|
||||||
|
|
||||||
|
2.3. Limitations on Grant Scope
|
||||||
|
|
||||||
|
The licenses granted in this Section 2 are the only rights granted under
|
||||||
|
this License. No additional rights or licenses will be implied from the
|
||||||
|
distribution or licensing of Covered Software under this License.
|
||||||
|
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||||
|
Contributor:
|
||||||
|
|
||||||
|
(a) for any code that a Contributor has removed from Covered Software;
|
||||||
|
or
|
||||||
|
|
||||||
|
(b) for infringements caused by: (i) Your and any other third party's
|
||||||
|
modifications of Covered Software, or (ii) the combination of its
|
||||||
|
Contributions with other software (except as part of its Contributor
|
||||||
|
Version); or
|
||||||
|
|
||||||
|
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||||
|
its Contributions.
|
||||||
|
|
||||||
|
This License does not grant any rights in the trademarks, service marks,
|
||||||
|
or logos of any Contributor (except as may be necessary to comply with
|
||||||
|
the notice requirements in Section 3.4).
|
||||||
|
|
||||||
|
2.4. Subsequent Licenses
|
||||||
|
|
||||||
|
No Contributor makes additional grants as a result of Your choice to
|
||||||
|
distribute the Covered Software under a subsequent version of this
|
||||||
|
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||||
|
permitted under the terms of Section 3.3).
|
||||||
|
|
||||||
|
2.5. Representation
|
||||||
|
|
||||||
|
Each Contributor represents that the Contributor believes its
|
||||||
|
Contributions are its original creation(s) or it has sufficient rights
|
||||||
|
to grant the rights to its Contributions conveyed by this License.
|
||||||
|
|
||||||
|
2.6. Fair Use
|
||||||
|
|
||||||
|
This License is not intended to limit any rights You have under
|
||||||
|
applicable copyright doctrines of fair use, fair dealing, or other
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
2.7. Conditions
|
||||||
|
|
||||||
|
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||||
|
in Section 2.1.
|
||||||
|
|
||||||
|
3. Responsibilities
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
3.1. Distribution of Source Form
|
||||||
|
|
||||||
|
All distribution of Covered Software in Source Code Form, including any
|
||||||
|
Modifications that You create or to which You contribute, must be under
|
||||||
|
the terms of this License. You must inform recipients that the Source
|
||||||
|
Code Form of the Covered Software is governed by the terms of this
|
||||||
|
License, and how they can obtain a copy of this License. You may not
|
||||||
|
attempt to alter or restrict the recipients' rights in the Source Code
|
||||||
|
Form.
|
||||||
|
|
||||||
|
3.2. Distribution of Executable Form
|
||||||
|
|
||||||
|
If You distribute Covered Software in Executable Form then:
|
||||||
|
|
||||||
|
(a) such Covered Software must also be made available in Source Code
|
||||||
|
Form, as described in Section 3.1, and You must inform recipients of
|
||||||
|
the Executable Form how they can obtain a copy of such Source Code
|
||||||
|
Form by reasonable means in a timely manner, at a charge no more
|
||||||
|
than the cost of distribution to the recipient; and
|
||||||
|
|
||||||
|
(b) You may distribute such Executable Form under the terms of this
|
||||||
|
License, or sublicense it under different terms, provided that the
|
||||||
|
license for the Executable Form does not attempt to limit or alter
|
||||||
|
the recipients' rights in the Source Code Form under this License.
|
||||||
|
|
||||||
|
3.3. Distribution of a Larger Work
|
||||||
|
|
||||||
|
You may create and distribute a Larger Work under terms of Your choice,
|
||||||
|
provided that You also comply with the requirements of this License for
|
||||||
|
the Covered Software. If the Larger Work is a combination of Covered
|
||||||
|
Software with a work governed by one or more Secondary Licenses, and the
|
||||||
|
Covered Software is not Incompatible With Secondary Licenses, this
|
||||||
|
License permits You to additionally distribute such Covered Software
|
||||||
|
under the terms of such Secondary License(s), so that the recipient of
|
||||||
|
the Larger Work may, at their option, further distribute the Covered
|
||||||
|
Software under the terms of either this License or such Secondary
|
||||||
|
License(s).
|
||||||
|
|
||||||
|
3.4. Notices
|
||||||
|
|
||||||
|
You may not remove or alter the substance of any license notices
|
||||||
|
(including copyright notices, patent notices, disclaimers of warranty,
|
||||||
|
or limitations of liability) contained within the Source Code Form of
|
||||||
|
the Covered Software, except that You may alter any license notices to
|
||||||
|
the extent required to remedy known factual inaccuracies.
|
||||||
|
|
||||||
|
3.5. Application of Additional Terms
|
||||||
|
|
||||||
|
You may choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of Covered
|
||||||
|
Software. However, You may do so only on Your own behalf, and not on
|
||||||
|
behalf of any Contributor. You must make it absolutely clear that any
|
||||||
|
such warranty, support, indemnity, or liability obligation is offered by
|
||||||
|
You alone, and You hereby agree to indemnify every Contributor for any
|
||||||
|
liability incurred by such Contributor as a result of warranty, support,
|
||||||
|
indemnity or liability terms You offer. You may include additional
|
||||||
|
disclaimers of warranty and limitations of liability specific to any
|
||||||
|
jurisdiction.
|
||||||
|
|
||||||
|
4. Inability to Comply Due to Statute or Regulation
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
If it is impossible for You to comply with any of the terms of this
|
||||||
|
License with respect to some or all of the Covered Software due to
|
||||||
|
statute, judicial order, or regulation then You must: (a) comply with
|
||||||
|
the terms of this License to the maximum extent possible; and (b)
|
||||||
|
describe the limitations and the code they affect. Such description must
|
||||||
|
be placed in a text file included with all distributions of the Covered
|
||||||
|
Software under this License. Except to the extent prohibited by statute
|
||||||
|
or regulation, such description must be sufficiently detailed for a
|
||||||
|
recipient of ordinary skill to be able to understand it.
|
||||||
|
|
||||||
|
5. Termination
|
||||||
|
--------------
|
||||||
|
|
||||||
|
5.1. The rights granted under this License will terminate automatically
|
||||||
|
if You fail to comply with any of its terms. However, if You become
|
||||||
|
compliant, then the rights granted under this License from a particular
|
||||||
|
Contributor are reinstated (a) provisionally, unless and until such
|
||||||
|
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||||
|
ongoing basis, if such Contributor fails to notify You of the
|
||||||
|
non-compliance by some reasonable means prior to 60 days after You have
|
||||||
|
come back into compliance. Moreover, Your grants from a particular
|
||||||
|
Contributor are reinstated on an ongoing basis if such Contributor
|
||||||
|
notifies You of the non-compliance by some reasonable means, this is the
|
||||||
|
first time You have received notice of non-compliance with this License
|
||||||
|
from such Contributor, and You become compliant prior to 30 days after
|
||||||
|
Your receipt of the notice.
|
||||||
|
|
||||||
|
5.2. If You initiate litigation against any entity by asserting a patent
|
||||||
|
infringement claim (excluding declaratory judgment actions,
|
||||||
|
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||||
|
directly or indirectly infringes any patent, then the rights granted to
|
||||||
|
You by any and all Contributors for the Covered Software under Section
|
||||||
|
2.1 of this License shall terminate.
|
||||||
|
|
||||||
|
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||||
|
end user license agreements (excluding distributors and resellers) which
|
||||||
|
have been validly granted by You or Your distributors under this License
|
||||||
|
prior to termination shall survive termination.
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 6. Disclaimer of Warranty *
|
||||||
|
* ------------------------- *
|
||||||
|
* *
|
||||||
|
* Covered Software is provided under this License on an "as is" *
|
||||||
|
* basis, without warranty of any kind, either expressed, implied, or *
|
||||||
|
* statutory, including, without limitation, warranties that the *
|
||||||
|
* Covered Software is free of defects, merchantable, fit for a *
|
||||||
|
* particular purpose or non-infringing. The entire risk as to the *
|
||||||
|
* quality and performance of the Covered Software is with You. *
|
||||||
|
* Should any Covered Software prove defective in any respect, You *
|
||||||
|
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||||
|
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||||
|
* essential part of this License. No use of any Covered Software is *
|
||||||
|
* authorized under this License except under this disclaimer. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
************************************************************************
|
||||||
|
* *
|
||||||
|
* 7. Limitation of Liability *
|
||||||
|
* -------------------------- *
|
||||||
|
* *
|
||||||
|
* Under no circumstances and under no legal theory, whether tort *
|
||||||
|
* (including negligence), contract, or otherwise, shall any *
|
||||||
|
* Contributor, or anyone who distributes Covered Software as *
|
||||||
|
* permitted above, be liable to You for any direct, indirect, *
|
||||||
|
* special, incidental, or consequential damages of any character *
|
||||||
|
* including, without limitation, damages for lost profits, loss of *
|
||||||
|
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||||
|
* and all other commercial damages or losses, even if such party *
|
||||||
|
* shall have been informed of the possibility of such damages. This *
|
||||||
|
* limitation of liability shall not apply to liability for death or *
|
||||||
|
* personal injury resulting from such party's negligence to the *
|
||||||
|
* extent applicable law prohibits such limitation. Some *
|
||||||
|
* jurisdictions do not allow the exclusion or limitation of *
|
||||||
|
* incidental or consequential damages, so this exclusion and *
|
||||||
|
* limitation may not apply to You. *
|
||||||
|
* *
|
||||||
|
************************************************************************
|
||||||
|
|
||||||
|
8. Litigation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Any litigation relating to this License may be brought only in the
|
||||||
|
courts of a jurisdiction where the defendant maintains its principal
|
||||||
|
place of business and such litigation shall be governed by laws of that
|
||||||
|
jurisdiction, without reference to its conflict-of-law provisions.
|
||||||
|
Nothing in this Section shall prevent a party's ability to bring
|
||||||
|
cross-claims or counter-claims.
|
||||||
|
|
||||||
|
9. Miscellaneous
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning the subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. Any law or regulation which provides
|
||||||
|
that the language of a contract shall be construed against the drafter
|
||||||
|
shall not be used to construe this License against a Contributor.
|
||||||
|
|
||||||
|
10. Versions of the License
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
10.1. New Versions
|
||||||
|
|
||||||
|
Mozilla Foundation is the license steward. Except as provided in Section
|
||||||
|
10.3, no one other than the license steward has the right to modify or
|
||||||
|
publish new versions of this License. Each version will be given a
|
||||||
|
distinguishing version number.
|
||||||
|
|
||||||
|
10.2. Effect of New Versions
|
||||||
|
|
||||||
|
You may distribute the Covered Software under the terms of the version
|
||||||
|
of the License under which You originally received the Covered Software,
|
||||||
|
or under the terms of any subsequent version published by the license
|
||||||
|
steward.
|
||||||
|
|
||||||
|
10.3. Modified Versions
|
||||||
|
|
||||||
|
If you create software not governed by this License, and you want to
|
||||||
|
create a new license for such software, you may create and use a
|
||||||
|
modified version of this License if you rename the license and remove
|
||||||
|
any references to the name of the license steward (except to note that
|
||||||
|
such modified license differs from this License).
|
||||||
|
|
||||||
|
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||||
|
Licenses
|
||||||
|
|
||||||
|
If You choose to distribute Source Code Form that is Incompatible With
|
||||||
|
Secondary Licenses under the terms of this version of the License, the
|
||||||
|
notice described in Exhibit B of this License must be attached.
|
||||||
|
|
||||||
|
Exhibit A - Source Code Form License Notice
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to look
|
||||||
|
for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
||||||
|
|
||||||
|
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||||
|
---------------------------------------------------------
|
||||||
|
|
||||||
|
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||||
|
defined by the Mozilla Public License, v. 2.0.
|
||||||
58
Makefile
Normal file
58
Makefile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.PHONY: aw-webui build install test typecheck package clean
|
||||||
|
|
||||||
|
build: aw-webui
|
||||||
|
poetry install
|
||||||
|
|
||||||
|
aw-webui:
|
||||||
|
mkdir -p aw_server/static/
|
||||||
|
ifeq ($(SKIP_WEBUI),true) # Skip building webui if SKIP_WEBUI is true
|
||||||
|
@echo "Skipping building webui"
|
||||||
|
else
|
||||||
|
rm -rf aw_server/static/*
|
||||||
|
make --directory=aw-webui build DEV=$(DEV)
|
||||||
|
cp -r aw-webui/dist/* aw_server/static/
|
||||||
|
# Needed for https://github.com/ActivityWatch/activitywatch/pull/274, works around https://github.com/pypa/pip/issues/6279
|
||||||
|
# https://github.com/ActivityWatch/activitywatch/pull/367 Other solutions have been tried but did not actually work.
|
||||||
|
# If you aren't sure windows long paths are working, don't remove this
|
||||||
|
rm -rf aw-webui/node_modules/.cache
|
||||||
|
endif
|
||||||
|
|
||||||
|
install:
|
||||||
|
cp misc/aw-server.service /usr/lib/systemd/user/aw-server.service
|
||||||
|
|
||||||
|
test:
|
||||||
|
@# Note that extensive integration tests are also run in the bundle repo,
|
||||||
|
@# for both aw-server and aw-server-rust, but without code coverage.
|
||||||
|
python -c 'import aw_server'
|
||||||
|
python -m pytest tests/test_server.py tests/test_profile.py tests/test_profile_config.py
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
python -m mypy aw_server tests --ignore-missing-imports
|
||||||
|
|
||||||
|
package: bump-version
|
||||||
|
rm -rf dist
|
||||||
|
pyinstaller aw-server.spec --clean --noconfirm
|
||||||
|
|
||||||
|
PYFILES=$(shell find . -name '*.py')
|
||||||
|
|
||||||
|
lint:
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
lint-fix:
|
||||||
|
poetry run pyupgrade --py38-plus --exit-zero-even-if-changed $(PYFILES)
|
||||||
|
ruff check --fix .
|
||||||
|
|
||||||
|
format:
|
||||||
|
black .
|
||||||
|
|
||||||
|
bump-version:
|
||||||
|
@# make sure to pull tags in parent repo before running this
|
||||||
|
poetry run python -m aw_server.__about__
|
||||||
|
VERSION=$$(grep -oP '__version__ = "v\K[^"]+' aw_server/__about__.py | head -n1); echo $$VERSION; poetry version $$VERSION
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf build dist
|
||||||
|
rm -rf aw_server/__pycache__
|
||||||
|
rm -rf aw_server/static/*
|
||||||
|
pip3 uninstall -y aw_server
|
||||||
|
make --directory=aw-webui clean
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
aw-server
|
||||||
|
============
|
||||||
|
|
||||||
|
[](https://github.com/ActivityWatch/aw-server/actions?query=workflow%3ABuild+branch%3Amaster)
|
||||||
|
[](https://codecov.io/gh/ActivityWatch/aw-server)
|
||||||
|
|
||||||
|
|
||||||
|
ActivityWatch server, for secure storage and retrieval of all your Quantified Self data.
|
||||||
|
|
||||||
|
**Note:** In the future this will no longer be the default server used by ActivityWatch, instead we will be using [aw-server-rust](https://github.com/ActivityWatch/aw-server-rust/). The fate of this repo is uncertain, but it will likely be around and maintained for a while (as a reference implementation, for prototyping, etc).
|
||||||
|
|
||||||
|
|
||||||
|
## Build and Install
|
||||||
|
|
||||||
|
To install program and dependencies, Poetry is required. You'll want to activate a `venv` before building.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
The `aw-server` binary should now be available to you in your PATH (if it is set correctly).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run aw-server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aw-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
If you want to run aw-server in development, you probably want to run a
|
||||||
|
development instance beside your personal (stable) instance. You can do
|
||||||
|
this by giving aw-server the `--testing` flag. This will start the server
|
||||||
|
on another port and use a separate datastore.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aw-server --testing
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## API Examples
|
||||||
|
|
||||||
|
You can also get a very decent API browser by browsing to `localhost:5600` after starting the server.
|
||||||
|
|
||||||
|
There are also some API examples in the [documentation](https://docs.activitywatch.net/en/latest/api.html).
|
||||||
|
|
||||||
3
__main__.py
Normal file
3
__main__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import aw_server
|
||||||
|
|
||||||
|
aw_server.main()
|
||||||
48
aw-server.spec
Normal file
48
aw-server.spec
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- mode: python -*-
|
||||||
|
# vi: set ft=python :
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import aw_core
|
||||||
|
import flask_restx
|
||||||
|
|
||||||
|
aw_core_path = os.path.dirname(aw_core.__file__)
|
||||||
|
restx_path = os.path.dirname(flask_restx.__file__)
|
||||||
|
|
||||||
|
name = "aw-server"
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["__main__.py"],
|
||||||
|
pathex=[],
|
||||||
|
binaries=None,
|
||||||
|
datas=[
|
||||||
|
("aw_server/static", "aw_server/static"),
|
||||||
|
(os.path.join(restx_path, "templates"), "flask_restx/templates"),
|
||||||
|
(os.path.join(restx_path, "static"), "flask_restx/static"),
|
||||||
|
(os.path.join(aw_core_path, "schemas"), "aw_core/schemas"),
|
||||||
|
],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
exclude_binaries=True,
|
||||||
|
name=name,
|
||||||
|
contents_directory=".",
|
||||||
|
debug=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
console=True,
|
||||||
|
)
|
||||||
|
coll = COLLECT(
|
||||||
|
exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, name="aw-server"
|
||||||
|
)
|
||||||
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()
|
||||||
12
misc/aw-server.service
Normal file
12
misc/aw-server.service
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ActivityWatch
|
||||||
|
Documentation=
|
||||||
|
After=mongodb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/env aw-server
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
13
misc/process_logs.py
Normal file
13
misc/process_logs.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Useful for turning JSON logs into human-readable logs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
aw-server --log-json 2>&1 | python3 process_logs.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
while True:
|
||||||
|
msg = json.loads(input())
|
||||||
|
print("{asctime} [{levelname}] {message}".format(**msg))
|
||||||
1195
poetry.lock
generated
Normal file
1195
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
pyproject.toml
Normal file
43
pyproject.toml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "aw-server"
|
||||||
|
version = "0.14.0"
|
||||||
|
description = "Server for ActivityWatch"
|
||||||
|
authors = ["Erik Bjäreholt <erik@bjareho.lt>"]
|
||||||
|
license = "MPL-2.0"
|
||||||
|
include = ["aw_server/static/*"]
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
aw-server = "aw_server:main"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.8"
|
||||||
|
aw-core = "^0.5.18"
|
||||||
|
aw-client = "^0.5.8"
|
||||||
|
flask = "^2.2"
|
||||||
|
flask-restx = "^1.0.3"
|
||||||
|
flask-cors = "*"
|
||||||
|
importlib-metadata = {version = "*", python = "<3.10"}
|
||||||
|
werkzeug = "^2.3.3"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
mypy = "*"
|
||||||
|
pytest = "^7.3"
|
||||||
|
pytest-flask = "*"
|
||||||
|
pytest-benchmark = "*"
|
||||||
|
pytest-cov = "*"
|
||||||
|
types-Werkzeug = "*"
|
||||||
|
types-setuptools = "*"
|
||||||
|
types-Flask = "*"
|
||||||
|
black = "^23.3.0"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "6.0"
|
||||||
|
addopts = "--cov-report=term --cov-report=xml --cov-report=html --cov=aw_server"
|
||||||
|
python_files = ["*.py",]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
ignore = ["E402", "E501"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
43
scripts/benchmark-api-heartbeat.py
Normal file
43
scripts/benchmark-api-heartbeat.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from time import sleep
|
||||||
|
import cProfile
|
||||||
|
import pstats
|
||||||
|
from datetime import timezone as tz
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aw_core.models import Event
|
||||||
|
|
||||||
|
import aw_datastore
|
||||||
|
import aw_server
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark():
|
||||||
|
ds = aw_datastore.Datastore(aw_datastore.storages.PeeweeStorage, testing=True)
|
||||||
|
api = aw_server.api.ServerAPI(ds, testing=True)
|
||||||
|
|
||||||
|
print(api.get_info())
|
||||||
|
|
||||||
|
bucket_id = "test-benchmark"
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.create_bucket(bucket_id, "test", "test", "test")
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
print("Benchmarking... this will take 30 seconds")
|
||||||
|
for i in range(120):
|
||||||
|
sleep(0.1)
|
||||||
|
api.heartbeat(
|
||||||
|
bucket_id,
|
||||||
|
Event(timestamp=datetime.now(tz=tz.utc), data={"test": str(int(i))}),
|
||||||
|
pulsetime=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
f_bench = "benchmark.dat"
|
||||||
|
cProfile.run("benchmark()", f_bench)
|
||||||
|
p = pstats.Stats(f_bench)
|
||||||
|
p.strip_dirs()
|
||||||
|
# p.sort_stats('tottime')
|
||||||
|
p.sort_stats("cumulative")
|
||||||
|
p.print_stats(20)
|
||||||
6
scripts/profile-werkzeug-read.py
Normal file
6
scripts/profile-werkzeug-read.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import pstats
|
||||||
|
|
||||||
|
p = pstats.Stats("profile.out")
|
||||||
|
p.sort_stats("cumulative").print_stats(100)
|
||||||
6
scripts/profile-werkzeug.py
Normal file
6
scripts/profile-werkzeug.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from werkzeug.contrib.profiler import ProfilerMiddleware
|
||||||
|
from aw_server import app
|
||||||
|
|
||||||
|
app.config["PROFILE"] = True
|
||||||
|
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30])
|
||||||
|
app.run(debug=True)
|
||||||
48
tests/conftest.py
Executable file
48
tests/conftest.py
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aw_client import ActivityWatchClient
|
||||||
|
from aw_server.server import AWFlask
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.WARN)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_aw_profile_after_test():
|
||||||
|
"""export_profile() writes os.environ directly; monkeypatch.delenv does
|
||||||
|
not record an undo when the var was already unset, so later tests would
|
||||||
|
inherit a leftover profile."""
|
||||||
|
yield
|
||||||
|
os.environ.pop("AW_PROFILE", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app():
|
||||||
|
# AWFlask does not go through parse_settings(), so a leftover AW_PROFILE
|
||||||
|
# from the environment (or a prior test) would disagree with testing=True.
|
||||||
|
old = os.environ.pop("AW_PROFILE", None)
|
||||||
|
application = AWFlask("127.0.0.1", testing=True)
|
||||||
|
if old is not None:
|
||||||
|
os.environ["AW_PROFILE"] = old
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def flask_client(app):
|
||||||
|
yield app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def aw_client():
|
||||||
|
# TODO: Could it be possible to write a sisterclass of ActivityWatchClient
|
||||||
|
# which calls aw_server.api directly? Would it be of use? Would add another
|
||||||
|
# layer of integration tests that are actually more like unit tests.
|
||||||
|
c = ActivityWatchClient("aw-client-test", testing=True)
|
||||||
|
yield c
|
||||||
|
|
||||||
|
# Delete test buckets after all tests needing the fixture have been run
|
||||||
|
buckets = c.get_buckets()
|
||||||
|
for bucket_id in buckets:
|
||||||
|
if bucket_id.startswith("test-"):
|
||||||
|
c.delete_bucket(bucket_id)
|
||||||
297
tests/test_client.py
Executable file
297
tests/test_client.py
Executable file
@@ -0,0 +1,297 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pprint import pprint
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aw_core.models import Event
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.WARN)
|
||||||
|
|
||||||
|
# TODO: Could it be possible to write a sisterclass of ActivityWatchClient
|
||||||
|
# which calls aw_server.api directly? Would it be of use? Would add another
|
||||||
|
# layer of integration tests that are actually more like unit tests.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def bucket(aw_client):
|
||||||
|
bucket_id = "test-" + str(random.randint(0, 10**5))
|
||||||
|
event_type = "testevents"
|
||||||
|
aw_client.create_bucket(bucket_id, event_type, queued=False)
|
||||||
|
print(f"Created bucket {bucket_id}")
|
||||||
|
sleep(1)
|
||||||
|
yield bucket_id
|
||||||
|
aw_client.delete_bucket(bucket_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queued_bucket(aw_client, bucket):
|
||||||
|
# FIXME: We need a way to clear the failed_requests file in order
|
||||||
|
# to have tests behave reasonably between runs.
|
||||||
|
aw_client.connect()
|
||||||
|
yield bucket
|
||||||
|
aw_client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_info(aw_client):
|
||||||
|
info = aw_client.get_info()
|
||||||
|
assert info["testing"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export(aw_client):
|
||||||
|
export = aw_client._get("export").json()
|
||||||
|
for bucket_id, bucket in export["buckets"].items():
|
||||||
|
assert bucket["id"]
|
||||||
|
assert "events" in bucket
|
||||||
|
# print(export)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_heartbeat_events(
|
||||||
|
start=datetime.now(tz=timezone.utc), delta=timedelta(seconds=1)
|
||||||
|
):
|
||||||
|
e1_ts = start
|
||||||
|
e2_ts = e1_ts + delta
|
||||||
|
|
||||||
|
# Needed since server (or underlying datastore) drops precision up to milliseconds.
|
||||||
|
# Update: Even with millisecond precision it sometimes fails. (tried using `round` and `int`)
|
||||||
|
# Now rounding down to 10ms precision to prevent random failure.
|
||||||
|
# 10ms precision at least seems to work well.
|
||||||
|
# TODO: Figure out why it sometimes fails with millisecond precision. Would probably
|
||||||
|
# be useful to find the microsecond values where it consistently always fails.
|
||||||
|
e1_ts = e1_ts.replace(microsecond=int(e1_ts.microsecond / 10000) * 100)
|
||||||
|
e2_ts = e2_ts.replace(microsecond=int(e2_ts.microsecond / 10000) * 100)
|
||||||
|
|
||||||
|
e1 = Event(timestamp=e1_ts, data={"label": "test"})
|
||||||
|
e2 = Event(timestamp=e2_ts, data={"label": "test"})
|
||||||
|
|
||||||
|
return e1, e2
|
||||||
|
|
||||||
|
|
||||||
|
def _create_periodic_events(
|
||||||
|
num_events, start=datetime.now(tz=timezone.utc), delta=timedelta(hours=1)
|
||||||
|
):
|
||||||
|
events = num_events * [None]
|
||||||
|
|
||||||
|
for i, dt in ((i, start + i * delta) for i in range(len(events))):
|
||||||
|
events[i] = Event(timestamp=dt, duration=delta, data={"label": "test"})
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat(aw_client, bucket):
|
||||||
|
bucket_id = bucket
|
||||||
|
|
||||||
|
e1, e2 = _create_heartbeat_events()
|
||||||
|
|
||||||
|
aw_client.heartbeat(bucket_id, e1, pulsetime=0)
|
||||||
|
aw_client.heartbeat(bucket_id, e2, pulsetime=10)
|
||||||
|
|
||||||
|
event = aw_client.get_events(bucket_id, limit=1)[0]
|
||||||
|
|
||||||
|
assert event.timestamp == e1.timestamp
|
||||||
|
assert event.duration == e2.timestamp - e1.timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_random_order(aw_client, bucket):
|
||||||
|
bucket_id = bucket
|
||||||
|
|
||||||
|
# All the events will have the same data
|
||||||
|
events = _create_periodic_events(100, delta=timedelta(seconds=1))
|
||||||
|
random.shuffle(events)
|
||||||
|
|
||||||
|
for e in events:
|
||||||
|
aw_client.heartbeat(bucket_id, e, pulsetime=2)
|
||||||
|
|
||||||
|
events = aw_client.get_events(bucket_id, limit=-1)
|
||||||
|
|
||||||
|
# FIXME: This should pass
|
||||||
|
# assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_queued_heartbeat(aw_client, queued_bucket):
|
||||||
|
bucket_id = queued_bucket
|
||||||
|
|
||||||
|
e1, e2 = _create_heartbeat_events()
|
||||||
|
|
||||||
|
aw_client.heartbeat(bucket_id, e1, pulsetime=0, queued=True)
|
||||||
|
aw_client.heartbeat(bucket_id, e2, pulsetime=10, queued=True)
|
||||||
|
|
||||||
|
# Needed because of aw_client-side heartbeat merging and delayed dispatch
|
||||||
|
aw_client.heartbeat(
|
||||||
|
bucket_id,
|
||||||
|
Event(timestamp=e2.timestamp, data={"label": "something different"}),
|
||||||
|
pulsetime=0,
|
||||||
|
queued=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Needed since the dispatcher thread might introduce some delay
|
||||||
|
max_tries = 20
|
||||||
|
for i in range(max_tries):
|
||||||
|
events = aw_client.get_events(bucket_id, limit=1)
|
||||||
|
if len(events) > 0 and events[0].duration > timedelta(seconds=0):
|
||||||
|
break
|
||||||
|
sleep(0.5)
|
||||||
|
|
||||||
|
assert i != max_tries - 1
|
||||||
|
print(f"Done on the {i + 1}th try")
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
|
||||||
|
assert event.timestamp == e1.timestamp
|
||||||
|
assert event.duration == e2.timestamp - e1.timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_buckets(aw_client, bucket):
|
||||||
|
buckets = aw_client.get_buckets()
|
||||||
|
assert bucket in buckets.keys()
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_event(aw_client, bucket):
|
||||||
|
event = Event(timestamp=datetime.now(tz=timezone.utc), data={"label": "test"})
|
||||||
|
aw_client.insert_event(bucket, event)
|
||||||
|
recv_events = aw_client.get_events(bucket, limit=1)
|
||||||
|
assert recv_events == [event]
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_events(aw_client, bucket):
|
||||||
|
events = _create_periodic_events(
|
||||||
|
2, start=datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
aw_client.insert_events(bucket, events)
|
||||||
|
recv_events = aw_client.get_events(bucket)
|
||||||
|
|
||||||
|
# Why isn't reverse=True needed here?
|
||||||
|
assert events == sorted(recv_events, key=lambda e: e.timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_event_single(aw_client, bucket):
|
||||||
|
start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50)
|
||||||
|
delta = timedelta(hours=1)
|
||||||
|
events = _create_periodic_events(10, delta=delta, start=start_dt)
|
||||||
|
|
||||||
|
aw_client.insert_events(bucket, events)
|
||||||
|
|
||||||
|
events = aw_client.get_events(bucket)
|
||||||
|
|
||||||
|
for e in events:
|
||||||
|
e2 = aw_client.get_event(bucket, e.id)
|
||||||
|
assert e.id == e2.id
|
||||||
|
assert e.timestamp == e2.timestamp
|
||||||
|
assert e.duration == e2.duration
|
||||||
|
assert e.data == e2.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events_interval(aw_client, bucket):
|
||||||
|
start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50)
|
||||||
|
end_dt = start_dt + timedelta(days=1)
|
||||||
|
delta = timedelta(hours=1)
|
||||||
|
events = _create_periodic_events(1000, delta=delta, start=start_dt)
|
||||||
|
|
||||||
|
aw_client.insert_events(bucket, events)
|
||||||
|
|
||||||
|
# start kwarg isn't currently range-inclusive
|
||||||
|
recv_events = aw_client.get_events(bucket, limit=50, start=start_dt, end=end_dt)
|
||||||
|
|
||||||
|
assert len(recv_events) == 25
|
||||||
|
|
||||||
|
# drop IDs
|
||||||
|
for e in recv_events:
|
||||||
|
e.id = None
|
||||||
|
print(recv_events)
|
||||||
|
src_events = sorted(events[:25], reverse=True, key=lambda e: e.timestamp)
|
||||||
|
|
||||||
|
# The first event gets cut off
|
||||||
|
assert recv_events[0].timestamp == src_events[0].timestamp
|
||||||
|
assert recv_events[0].data == src_events[0].data
|
||||||
|
|
||||||
|
# This fails due to microsecond precision issues
|
||||||
|
assert _round_td(recv_events[0].duration) == _round_td(
|
||||||
|
end_dt - recv_events[0].timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: Duration can differ by a few microseconds, so we round durations off to 10ms
|
||||||
|
recv_events = [_round_durations(e) for e in recv_events]
|
||||||
|
src_events = [_round_durations(e) for e in src_events]
|
||||||
|
|
||||||
|
assert recv_events[1:25] == src_events[1:25]
|
||||||
|
|
||||||
|
|
||||||
|
def _round_td(td: timedelta) -> timedelta:
|
||||||
|
return timedelta(seconds=round(td.total_seconds(), 2))
|
||||||
|
|
||||||
|
|
||||||
|
def _round_durations(e: Event):
|
||||||
|
e.duration = _round_td(e.duration)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_many_events(aw_client, bucket):
|
||||||
|
events = _create_periodic_events(
|
||||||
|
1000, start=datetime.now(tz=timezone.utc) - timedelta(days=50)
|
||||||
|
)
|
||||||
|
|
||||||
|
aw_client.insert_events(bucket, events)
|
||||||
|
recv_events = aw_client.get_events(bucket, limit=-1)
|
||||||
|
|
||||||
|
assert len(events) == len(recv_events)
|
||||||
|
assert recv_events == sorted(events, reverse=True, key=lambda e: e.timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_midnight(aw_client, bucket):
|
||||||
|
start_dt = datetime.now() - timedelta(days=1)
|
||||||
|
midnight = start_dt.replace(hour=23, minute=50)
|
||||||
|
events = _create_periodic_events(100, start=midnight, delta=timedelta(minutes=1))
|
||||||
|
|
||||||
|
aw_client.insert_events(bucket, events)
|
||||||
|
recv_events = aw_client.get_events(bucket, limit=-1)
|
||||||
|
assert len(recv_events) == len(events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_midnight_heartbeats(aw_client, bucket):
|
||||||
|
now = datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||||
|
# Zero out seconds/microseconds for deterministic midnight boundary.
|
||||||
|
# Without this, residual microseconds cause non-deterministic boundary
|
||||||
|
# matching when querying events around midnight (the endtime >= start_query
|
||||||
|
# condition can include/exclude boundary events depending on microseconds).
|
||||||
|
midnight = now.replace(hour=23, minute=50, second=0, microsecond=0)
|
||||||
|
events = _create_periodic_events(20, start=midnight, delta=timedelta(minutes=1))
|
||||||
|
|
||||||
|
label_ring = ["1", "1", "2", "3", "4"]
|
||||||
|
for i, e in enumerate(events):
|
||||||
|
e.data["label"] = label_ring[i % len(label_ring)]
|
||||||
|
aw_client.heartbeat(bucket, e, pulsetime=90)
|
||||||
|
|
||||||
|
recv_events_merged = aw_client.get_events(bucket, limit=-1)
|
||||||
|
assert len(recv_events_merged) == 4 / 5 * len(events)
|
||||||
|
|
||||||
|
# Query from midnight (00:00), which is exactly midnight + 10 minutes from
|
||||||
|
# the 23:50 start. Use a small offset to avoid boundary ambiguity where
|
||||||
|
# events ending exactly at midnight could be included by the >= condition.
|
||||||
|
query_start = midnight + timedelta(minutes=10, milliseconds=1)
|
||||||
|
recv_events_after_midnight = aw_client.get_events(bucket, start=query_start)
|
||||||
|
pprint(recv_events_after_midnight)
|
||||||
|
assert len(recv_events_after_midnight) == int(len(recv_events_merged) / 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings(aw_client):
|
||||||
|
aw_client.set_setting("test", "test")
|
||||||
|
aw_client.set_setting("list", json.dumps([1, 2, 3]))
|
||||||
|
aw_client.set_setting("dict", json.dumps({"a": 1, "b": 2}))
|
||||||
|
|
||||||
|
# check set
|
||||||
|
assert aw_client.get_setting("test") == "test"
|
||||||
|
assert json.loads(aw_client.get_setting("list")) == [1, 2, 3]
|
||||||
|
assert json.loads(aw_client.get_setting("dict")) == {"a": 1, "b": 2}
|
||||||
|
|
||||||
|
# check unset
|
||||||
|
assert aw_client.get_setting("test2") is None
|
||||||
|
|
||||||
|
# check get all
|
||||||
|
settings = aw_client.get_setting()
|
||||||
|
assert settings["test"] == "test"
|
||||||
|
assert json.loads(settings["list"]) == [1, 2, 3]
|
||||||
|
assert json.loads(settings["dict"]) == {"a": 1, "b": 2}
|
||||||
171
tests/test_extension_cors.py
Normal file
171
tests/test_extension_cors.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for moz-extension CORS endpoint scoping (extension_cors module)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aw_server.extension_cors import (
|
||||||
|
_is_allowed,
|
||||||
|
_is_allowed_path,
|
||||||
|
_matches_configured_origin,
|
||||||
|
)
|
||||||
|
from aw_server.server import AWFlask
|
||||||
|
|
||||||
|
_EXT_ORIGIN = "moz-extension://aabbccddeeff00112233445566778899"
|
||||||
|
_HOST = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
app = AWFlask(_HOST, testing=True)
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests for the path-matching helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,path,expected",
|
||||||
|
[
|
||||||
|
("GET", "/api/0/info", True),
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-web-hostname", True),
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
|
||||||
|
# Blocked paths
|
||||||
|
("GET", "/api/0/export", False),
|
||||||
|
("POST", "/api/0/export", False),
|
||||||
|
("GET", "/api/0/buckets/", False),
|
||||||
|
("GET", "/api/0/buckets/aw-watcher-web-hostname/events", False),
|
||||||
|
("POST", "/api/0/query/", False),
|
||||||
|
("GET", "/api/0/settings", False),
|
||||||
|
# Non-watcher bucket
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-window-hostname", False),
|
||||||
|
# Heartbeat on non-watcher bucket
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-afk-hostname/heartbeat", False),
|
||||||
|
# Percent-encoding should not bypass via raw path (segments are used)
|
||||||
|
("GET", "/api/0/%65xport", False), # %65 = 'e', decodes to 'export'
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_allowed_unit(method, path, expected):
|
||||||
|
segments = [s for s in path.split("/") if s]
|
||||||
|
assert _is_allowed(method, segments) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected",
|
||||||
|
[
|
||||||
|
("/api/0/info", True),
|
||||||
|
("/api/0/buckets/aw-watcher-web-hostname", True),
|
||||||
|
("/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
|
||||||
|
("/api/0/export", False),
|
||||||
|
("/api/0/buckets/", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_allowed_path_unit(path, expected):
|
||||||
|
segments = [s for s in path.split("/") if s]
|
||||||
|
assert _is_allowed_path(segments) is expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration tests via Flask test client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,path",
|
||||||
|
[
|
||||||
|
("GET", "/api/0/info"),
|
||||||
|
# Bucket creation: 400 without body is expected but NOT 403
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-web-testhost"),
|
||||||
|
# Heartbeat: 404 (bucket doesn't exist) is expected but NOT 403
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-web-testhost/heartbeat"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extension_allowed(client, method, path):
|
||||||
|
"""moz-extension origins are permitted at aw-watcher-web's endpoints."""
|
||||||
|
headers = {"Origin": _EXT_ORIGIN}
|
||||||
|
r = client.open(path, method=method, headers=headers)
|
||||||
|
assert (
|
||||||
|
r.status_code != 403
|
||||||
|
), f"{method} {path} should not be blocked; got {r.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,path",
|
||||||
|
[
|
||||||
|
("GET", "/api/0/export"),
|
||||||
|
("GET", "/api/0/buckets/"),
|
||||||
|
("POST", "/api/0/import"),
|
||||||
|
("POST", "/api/0/query/"),
|
||||||
|
("GET", "/api/0/settings"),
|
||||||
|
# Events read from a watcher bucket
|
||||||
|
("GET", "/api/0/buckets/aw-watcher-web-testhost/events"),
|
||||||
|
# Non-watcher bucket
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-window-testhost"),
|
||||||
|
("POST", "/api/0/buckets/aw-watcher-afk-testhost/heartbeat"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extension_blocked(client, method, path):
|
||||||
|
"""moz-extension origins are blocked at endpoints beyond aw-watcher-web's needs."""
|
||||||
|
headers = {"Origin": _EXT_ORIGIN}
|
||||||
|
r = client.open(path, method=method, headers=headers)
|
||||||
|
assert (
|
||||||
|
r.status_code == 403
|
||||||
|
), f"{method} {path} should be blocked (403); got {r.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"pattern,origin,expected",
|
||||||
|
[
|
||||||
|
("moz-extension://aabbcc", "moz-extension://aabbcc", True),
|
||||||
|
("MOZ-EXTENSION://AABBCC", "moz-extension://aabbcc", True),
|
||||||
|
(r"moz-extension://.*", "moz-extension://aabbcc", True),
|
||||||
|
(r"moz-extension://[a-f0-9]+", "moz-extension://aabbcc", True),
|
||||||
|
(r"moz-extension://[0-9]+", "moz-extension://aabbcc", False),
|
||||||
|
("moz-extension://other", "moz-extension://aabbcc", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_matches_configured_origin(pattern, origin, expected):
|
||||||
|
assert _matches_configured_origin(origin, pattern) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_regex_configured_extension_origin_bypasses_scope_guard():
|
||||||
|
"""Owner-configured regex origins retain unrestricted endpoint access."""
|
||||||
|
app = AWFlask(_HOST, testing=False, cors_origins=[r"moz-extension://.*"])
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
|
response = client.get("/api/0/export", headers={"Origin": _EXT_ORIGIN})
|
||||||
|
|
||||||
|
assert response.status_code != 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_extension_origin_passthrough(client):
|
||||||
|
"""Non-moz-extension origins are not affected by the scope guard."""
|
||||||
|
headers = {"Origin": "http://127.0.0.1:27180"}
|
||||||
|
r = client.get("/api/0/info", headers=headers)
|
||||||
|
assert r.status_code != 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_origin_passthrough(client):
|
||||||
|
"""Requests without an Origin header (native watchers, curl) are not blocked."""
|
||||||
|
r = client.get("/api/0/info")
|
||||||
|
assert r.status_code != 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_allowed_path(client):
|
||||||
|
"""OPTIONS preflight on an allowed path is permitted."""
|
||||||
|
headers = {
|
||||||
|
"Origin": _EXT_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
}
|
||||||
|
r = client.options("/api/0/info", headers=headers)
|
||||||
|
assert r.status_code != 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_blocked_path(client):
|
||||||
|
"""OPTIONS preflight on a blocked path is rejected."""
|
||||||
|
headers = {
|
||||||
|
"Origin": _EXT_ORIGIN,
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
}
|
||||||
|
r = client.options("/api/0/export", headers=headers)
|
||||||
|
assert r.status_code == 403
|
||||||
94
tests/test_profile.py
Normal file
94
tests/test_profile.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Unit tests for profile resolution."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aw_server.profile import (
|
||||||
|
DEFAULT_PROFILE,
|
||||||
|
TESTING_PROFILE,
|
||||||
|
export_profile,
|
||||||
|
is_testing,
|
||||||
|
profile_from_env,
|
||||||
|
profile_suffix,
|
||||||
|
resolve_profile,
|
||||||
|
validate_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfile:
|
||||||
|
def test_no_flags_gives_default(self):
|
||||||
|
assert resolve_profile(None, False) == DEFAULT_PROFILE
|
||||||
|
|
||||||
|
def test_testing_flag_is_an_alias(self):
|
||||||
|
assert resolve_profile(None, True) == TESTING_PROFILE
|
||||||
|
|
||||||
|
def test_explicit_profile_wins(self):
|
||||||
|
assert resolve_profile("research", False) == "research"
|
||||||
|
|
||||||
|
def test_testing_with_matching_profile_is_allowed(self):
|
||||||
|
assert resolve_profile("testing", True) == TESTING_PROFILE
|
||||||
|
|
||||||
|
def test_testing_with_conflicting_profile_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="conflicts"):
|
||||||
|
resolve_profile("research", True)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"]
|
||||||
|
)
|
||||||
|
def test_invalid_names_rejected(self, name):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_profile(name)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"])
|
||||||
|
def test_valid_names_accepted(self, name):
|
||||||
|
assert validate_profile(name) == name
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileSuffix:
|
||||||
|
def test_default_has_no_suffix(self):
|
||||||
|
assert profile_suffix(DEFAULT_PROFILE) == ""
|
||||||
|
|
||||||
|
def test_testing_keeps_legacy_suffix(self):
|
||||||
|
assert profile_suffix(TESTING_PROFILE) == "-testing"
|
||||||
|
|
||||||
|
def test_custom_profile_suffix(self):
|
||||||
|
assert profile_suffix("research") == "-research"
|
||||||
|
|
||||||
|
def test_suffixes_are_disjoint(self):
|
||||||
|
suffixes = {profile_suffix(p) for p in ("default", "testing", "research")}
|
||||||
|
assert len(suffixes) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTesting:
|
||||||
|
def test_only_testing_profile_is_testing(self):
|
||||||
|
assert is_testing(TESTING_PROFILE)
|
||||||
|
assert not is_testing(DEFAULT_PROFILE)
|
||||||
|
assert not is_testing("research")
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportProfile:
|
||||||
|
def test_exported_for_child_processes(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||||
|
export_profile("research")
|
||||||
|
assert os.environ["AW_PROFILE"] == "research"
|
||||||
|
|
||||||
|
def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("AW_PROFILE", "research")
|
||||||
|
export_profile(DEFAULT_PROFILE)
|
||||||
|
assert "AW_PROFILE" not in os.environ
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileFromEnv:
|
||||||
|
def test_defaults_when_unset(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||||
|
assert profile_from_env(False) == DEFAULT_PROFILE
|
||||||
|
assert profile_from_env(True) == TESTING_PROFILE
|
||||||
|
|
||||||
|
def test_reads_exported_profile(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("AW_PROFILE", "research")
|
||||||
|
assert profile_from_env(False) == "research"
|
||||||
|
|
||||||
|
def test_ignores_invalid_env_value(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("AW_PROFILE", "Not A Profile")
|
||||||
|
assert profile_from_env(False) == DEFAULT_PROFILE
|
||||||
127
tests/test_profile_config.py
Normal file
127
tests/test_profile_config.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Port, settings, and CLI wiring for named profiles."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aw_server.config import config_section, default_port
|
||||||
|
from aw_server.main import parse_settings
|
||||||
|
from aw_server.profile import DEFAULT_PROFILE, TESTING_PROFILE, export_profile
|
||||||
|
from aw_server.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def xdg_tmp(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||||
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||||
|
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigHelpers:
|
||||||
|
def test_sections_are_disjoint(self):
|
||||||
|
sections = {config_section(p) for p in ("default", "testing", "research")}
|
||||||
|
assert sections == {"server", "server-testing", "server-research"}
|
||||||
|
|
||||||
|
def test_default_ports(self):
|
||||||
|
assert default_port(DEFAULT_PROFILE) == 5600
|
||||||
|
assert default_port(TESTING_PROFILE) == 5666
|
||||||
|
assert default_port("research") == 5600
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsFilename:
|
||||||
|
def test_testing_keeps_legacy_name(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||||
|
settings = Settings(True)
|
||||||
|
assert settings.config_file.name == "settings-testing.json"
|
||||||
|
|
||||||
|
def test_default_unsuffixed(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||||
|
settings = Settings(False)
|
||||||
|
assert settings.config_file.name == "settings.json"
|
||||||
|
|
||||||
|
def test_named_profile_suffix(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("AW_PROFILE", "research")
|
||||||
|
settings = Settings(False)
|
||||||
|
assert settings.config_file.name == "settings-research.json"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSettings:
|
||||||
|
def test_testing_flag_selects_testing_port(self, xdg_tmp, monkeypatch):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["aw-server", "--testing"])
|
||||||
|
settings, _storage = parse_settings()
|
||||||
|
assert settings.testing is True
|
||||||
|
assert settings.profile == TESTING_PROFILE
|
||||||
|
assert settings.port == 5666
|
||||||
|
assert os.environ["AW_PROFILE"] == "testing"
|
||||||
|
|
||||||
|
def test_profile_testing_is_alias_for_testing_flag(self, xdg_tmp, monkeypatch):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "testing"])
|
||||||
|
settings, _storage = parse_settings()
|
||||||
|
assert settings.testing is True
|
||||||
|
assert settings.profile == TESTING_PROFILE
|
||||||
|
assert settings.port == 5666
|
||||||
|
|
||||||
|
def test_default_keeps_port_5600_and_unsets_env(self, xdg_tmp, monkeypatch):
|
||||||
|
monkeypatch.setenv("AW_PROFILE", "research")
|
||||||
|
monkeypatch.setattr(sys, "argv", ["aw-server"])
|
||||||
|
settings, _storage = parse_settings()
|
||||||
|
assert settings.testing is False
|
||||||
|
assert settings.profile == DEFAULT_PROFILE
|
||||||
|
assert settings.port == 5600
|
||||||
|
assert "AW_PROFILE" not in os.environ
|
||||||
|
|
||||||
|
def test_named_profile_exports_env_and_falls_back_to_server_section(
|
||||||
|
self, xdg_tmp, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "research"])
|
||||||
|
settings, _storage = parse_settings()
|
||||||
|
assert settings.testing is False
|
||||||
|
assert settings.profile == "research"
|
||||||
|
assert settings.port == 5600
|
||||||
|
assert os.environ["AW_PROFILE"] == "research"
|
||||||
|
|
||||||
|
def test_cli_port_override(self, xdg_tmp, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv", ["aw-server", "--profile", "research", "--port", "5667"]
|
||||||
|
)
|
||||||
|
settings, _storage = parse_settings()
|
||||||
|
assert settings.port == 5667
|
||||||
|
assert settings.profile == "research"
|
||||||
|
|
||||||
|
def test_conflicting_flags_are_a_usage_error(self, xdg_tmp, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv", ["aw-server", "--testing", "--profile", "research"]
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
parse_settings()
|
||||||
|
|
||||||
|
def test_invalid_profile_is_a_usage_error(self, xdg_tmp, monkeypatch):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "Research"])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
parse_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_profile_config_is_isolated_from_default(xdg_tmp, monkeypatch):
|
||||||
|
"""AW_PROFILE must be exported before load_config, else both profiles
|
||||||
|
share ~/.config/activitywatch/aw-server."""
|
||||||
|
import aw_core.dirs as dirs
|
||||||
|
|
||||||
|
export_profile("research")
|
||||||
|
research_dir = dirs.get_config_dir("aw-server")
|
||||||
|
export_profile(DEFAULT_PROFILE)
|
||||||
|
default_dir = dirs.get_config_dir("aw-server")
|
||||||
|
assert research_dir != default_dir
|
||||||
|
assert "activitywatch-research" in research_dir
|
||||||
|
assert "activitywatch-research" not in default_dir
|
||||||
184
tests/test_server.py
Normal file
184
tests/test_server.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bucket(flask_client):
|
||||||
|
"Context manager for creating and deleting a testing bucket"
|
||||||
|
try:
|
||||||
|
bucket_id = "test"
|
||||||
|
r = flask_client.post(
|
||||||
|
f"/api/0/buckets/{bucket_id}",
|
||||||
|
json={"client": "test", "type": "test", "hostname": "test"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
yield bucket_id
|
||||||
|
finally:
|
||||||
|
r = flask_client.delete(f"/api/0/buckets/{bucket_id}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_info(flask_client):
|
||||||
|
r = flask_client.get("/api/0/info")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json["testing"]
|
||||||
|
assert r.json["profile"] == "testing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_buckets(flask_client, bucket, benchmark):
|
||||||
|
@benchmark
|
||||||
|
def list_buckets():
|
||||||
|
r = flask_client.get("/api/0/buckets/")
|
||||||
|
print(r.json)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeats(flask_client, bucket, benchmark):
|
||||||
|
# FIXME: Currently tests using the memory storage method
|
||||||
|
# TODO: Test with a longer data section and see if there's a significant difference
|
||||||
|
# TODO: Test with a larger bucket and see if there's a significant difference
|
||||||
|
@benchmark
|
||||||
|
def heartbeat():
|
||||||
|
now = datetime.now()
|
||||||
|
r = flask_client.post(
|
||||||
|
f"/api/0/buckets/{bucket}/heartbeat?pulsetime=1",
|
||||||
|
json={"timestamp": now, "duration": 0, "data": {"random": random.random()}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_events(flask_client, bucket, benchmark):
|
||||||
|
n_events = 100
|
||||||
|
start_time = datetime.now() - timedelta(days=100)
|
||||||
|
for i in range(n_events):
|
||||||
|
now = start_time + timedelta(hours=i)
|
||||||
|
r = flask_client.post(
|
||||||
|
f"/api/0/buckets/{bucket}/heartbeat?pulsetime=0",
|
||||||
|
json={"timestamp": now, "duration": 0, "data": {"random": random.random()}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@benchmark
|
||||||
|
def get_events():
|
||||||
|
r = flask_client.get(f"/api/0/buckets/{bucket}/events")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json
|
||||||
|
assert len(r.json) == n_events
|
||||||
|
|
||||||
|
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=-1")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json
|
||||||
|
assert len(r.json) == n_events
|
||||||
|
|
||||||
|
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=10")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json
|
||||||
|
assert len(r.json) == 10
|
||||||
|
|
||||||
|
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=100")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json
|
||||||
|
assert len(r.json) == n_events
|
||||||
|
|
||||||
|
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=1000")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json
|
||||||
|
assert len(r.json) == n_events
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_event_returns_list(flask_client, bucket):
|
||||||
|
"""Test that POST /events returns a list of events with IDs (matching aw-server-rust)."""
|
||||||
|
now = datetime.now()
|
||||||
|
event_data = {
|
||||||
|
"timestamp": now.isoformat(),
|
||||||
|
"duration": 0,
|
||||||
|
"data": {"label": "test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Single event as list
|
||||||
|
r = flask_client.post(
|
||||||
|
f"/api/0/buckets/{bucket}/events",
|
||||||
|
json=[event_data],
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
|
||||||
|
assert len(r.json) == 1
|
||||||
|
assert r.json[0]["id"] is not None
|
||||||
|
assert r.json[0]["data"] == {"label": "test"}
|
||||||
|
|
||||||
|
# Single event as dict (legacy format)
|
||||||
|
r = flask_client.post(
|
||||||
|
f"/api/0/buckets/{bucket}/events",
|
||||||
|
json=event_data,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
|
||||||
|
assert len(r.json) == 1
|
||||||
|
assert r.json[0]["id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_events_returns_list(flask_client, bucket):
|
||||||
|
"""Test that POST /events with multiple events returns a list."""
|
||||||
|
now = datetime.now()
|
||||||
|
events_data = [
|
||||||
|
{
|
||||||
|
"timestamp": (now - timedelta(hours=i)).isoformat(),
|
||||||
|
"duration": 0,
|
||||||
|
"data": {"label": f"test-{i}"},
|
||||||
|
}
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
|
||||||
|
r = flask_client.post(
|
||||||
|
f"/api/0/buckets/{bucket}/events",
|
||||||
|
json=events_data,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
|
||||||
|
assert len(r.json) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Add benchmark for basic AFK-filtering query
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_invalid_timeperiod(flask_client):
|
||||||
|
"""Malformed timeperiods must yield 400 (client error), not 500.
|
||||||
|
|
||||||
|
Regression test: a non-ISO8601 timeperiod previously raised an uncaught
|
||||||
|
iso8601.ParseError, surfacing as an Internal Server Error.
|
||||||
|
"""
|
||||||
|
r = flask_client.post(
|
||||||
|
"/api/0/query/",
|
||||||
|
json={"query": ["RETURN = 1;"], "timeperiods": ["not-a-valid-period"]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "not-a-valid-period" in r.json["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_timeperiod_missing_slash(flask_client):
|
||||||
|
"""A timeperiod without a start/end slash separator must yield 400, not 500.
|
||||||
|
|
||||||
|
Regression test: a single ISO8601 datetime (no slash) previously raised an
|
||||||
|
uncaught IndexError when indexing the split result.
|
||||||
|
"""
|
||||||
|
r = flask_client.post(
|
||||||
|
"/api/0/query/",
|
||||||
|
json={"query": ["RETURN = 1;"], "timeperiods": ["2024-01-01T00:00:00+00:00"]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_valid_timeperiod(flask_client):
|
||||||
|
"""A well-formed query with a valid timeperiod still succeeds."""
|
||||||
|
r = flask_client.post(
|
||||||
|
"/api/0/query/",
|
||||||
|
json={
|
||||||
|
"query": ["RETURN = 1;"],
|
||||||
|
"timeperiods": ["2024-01-01T00:00:00+00:00/2024-01-02T00:00:00+00:00"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json == [1]
|
||||||
Reference in New Issue
Block a user