diff --git a/backend/aci/__init__.py b/backend/aci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/aci/db.py b/backend/aci/db.py new file mode 100644 index 0000000..fe043ca --- /dev/null +++ b/backend/aci/db.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +import sqlite3 + +def get_db(): + conn = sqlite3.connect('./checkin.db') + db = conn.cursor() + try: + yield db + finally: + conn.commit() + db.close() + conn.close() diff --git a/backend/aci/models.py b/backend/aci/models.py new file mode 100644 index 0000000..cd9e4bb --- /dev/null +++ b/backend/aci/models.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +from pydantic import BaseModel, Field + +class CodeSubmission(BaseModel): + code: str = Field(pattern=r'[0-9]{6}') + success: bool diff --git a/backend/aci/utils.py b/backend/aci/utils.py new file mode 100644 index 0000000..c3bade8 --- /dev/null +++ b/backend/aci/utils.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +from datetime import datetime + +def today() -> str: + now = datetime.now() + #return now.strftime('%A') + ' ' + str(now.day) + ' ' + now.strftime('%B') + return 'Friday 16 February' diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..599ea54 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +from typing import Union +import hashlib +import sqlite3 + +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware + +import uvicorn + +from aci.db import get_db +from aci.models import CodeSubmission +from aci import utils + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"moz-extension://.*", + allow_methods=["GET"], + allow_headers="*", +) + +@app.post('/api/codes') +def submit_code(date: str, time: str, activity: str, space: str, submission: CodeSubmission, db: sqlite3.Cursor=Depends(get_db)): + # TODO: validate parameters meet expected form. + db.execute('INSERT INTO code_results (date, time, space, activity, code, result) VALUES (?,?,?,?,?,?)', (date, time, space, activity, submission.code, submission.success)) + return {'ok': True} + +@app.get('/api/codes') +def current_codes(db: sqlite3.Cursor=Depends(get_db)): + result = db.execute('SELECT date, time, space, activity, code, result FROM code_results WHERE date = ?', (utils.today(),)) + codes = result.fetchall() + code_scores = {} + code_info = {} + for date, time, space, activity, code, result in codes: + k = str((date, time, space, activity)) + code_info[k] = (date, time, space, activity) + if k not in code_scores: + code_scores[k] = {} + if code not in code_scores[k]: + code_scores[k][code] = 0 + code_scores[k][code] += 1 if result else -1 + ret = [] + for k, v in code_scores.items(): + date, time, space, activity = code_info[k] + codes = list(map(lambda c: {'code': c[0], 'score': c[1]}, v.items())) + codes.sort(key=lambda c: -c['score']) + ret.append({ + 'date': date, + 'time': time, + 'space': space, + 'activity': activity, + 'codes': codes, + }) + return { + 'activities': ret * 30, + } + +if __name__ == '__main__': + uvicorn.run(app) diff --git a/backend/checkin.db b/backend/checkin.db new file mode 100644 index 0000000..1fb9846 Binary files /dev/null and b/backend/checkin.db differ diff --git a/backend/migrate.py b/backend/migrate.py new file mode 100644 index 0000000..bb9da73 --- /dev/null +++ b/backend/migrate.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import sqlite3 + +conn = sqlite3.connect('./checkin.db') +db = conn.cursor() + +try: + db.execute("CREATE TABLE IF NOT EXISTS code_results (date text, time text, space text, activity text, code text, result boolean)") +finally: + db.close() + conn.close() diff --git a/backend/setup.py b/backend/setup.py new file mode 100644 index 0000000..1ce1355 --- /dev/null +++ b/backend/setup.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages + +setup(name='aci', + version='1.0.0', + packages=find_packages(), + scripts=['app.py', 'migrate.py'], + ) diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 0000000..38e274b --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,12 @@ +# Privacy policy + +Here is a list of all the components of this project and the data they collect: + +- Backend - logs requests, including the IP address of the reverse proxy. Also stores codes which are submitted to the service. +- NGINX - logs all requests, including IP client addresses for monitoring and rate limiting. +- Browser extension - collects data from requests made on the checkin domain. the data is anonymous (just the code and details about the session it corresponds to) and is only ever sent to the backend. + +## Additional details + +After 7 days, checkin session information is anonymised by replacing the details with randomised IDs, such that the data about code submissions can still be correlated, but not including any details such as textual descriptions of the activity. + diff --git a/docs/TOS.md b/docs/TOS.md new file mode 100644 index 0000000..e3635b2 --- /dev/null +++ b/docs/TOS.md @@ -0,0 +1,6 @@ +# Terms of Service + +Don't cause problems. + +Seriously; I don't want to write a TOS, don't be the reason I have to. + diff --git a/extension/manifest.json b/extension/manifest.json index c94a505..62fcf19 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -19,5 +19,8 @@ "scripts": ["src/background.js"], "persistent": false, "type": "module" + }, + "browser_action": { + "default_popup": "src/popup.html" } } diff --git a/extension/src/background.js b/extension/src/background.js index f2be9f0..1877e39 100644 --- a/extension/src/background.js +++ b/extension/src/background.js @@ -2,17 +2,19 @@ console.log('i am a background script'); const datastore = new Map(); -function attachActivityDetails(activityId, date, time, name) { +function attachActivityDetails(activityId, date, time, name, space) { if (datastore.has(activityId)) { const activity = datastore.get(activityId); activity.name = name; activity.date = date; activity.time = time; + activity.space = space; } else { datastore.set(activityId, { date, time, name, + space, code: null, }); } @@ -31,7 +33,7 @@ function interceptSubmit(details) { return; } const activity = datastore.get(activityId); - if (!activity.date || !activity.time || !activity.name || !activity.code) { + if (!activity.date || !activity.time || !activity.name || !activity.space || !activity.code) { console.error('we do not have all the information about the activity', activityId); return; } @@ -63,14 +65,26 @@ function interceptCode(details) { date: null, time: null, name: null, + space: null, code, }); } } +function handleMessage(message) { + if (message['type'] === 'link-activity-details') { + const { activityId, date, time, name, space } = message; + attachActivityDetails(activityId, date, time, name, space); + } else { + console.warn('recieved unknown message:', message); + } +} + browser.webRequest.onBeforeRequest.addListener(interceptCode, { urls: ['https://checkin.york.ac.uk/api/selfregistration/*/present'], }, ['requestBody']); browser.webRequest.onCompleted.addListener(interceptSubmit, { urls: ['https://checkin.york.ac.uk/api/selfregistration/*/present'], }); + +browser.runtime.onMessage.addListener() diff --git a/extension/src/config.js b/extension/src/config.js new file mode 100644 index 0000000..7c0e9da --- /dev/null +++ b/extension/src/config.js @@ -0,0 +1 @@ +this.SERVER_URL = 'http://localhost:8000'; diff --git a/extension/src/content.js b/extension/src/content.js index e921523..462677b 100644 --- a/extension/src/content.js +++ b/extension/src/content.js @@ -1 +1,20 @@ console.log('hello'); + +(async function() { + const sections = document.querySelectorAll('section[data-activities-id]') + for (const section of sections) { + const activityId = section.dataset.activitiesId; + const date = section.querySelector('span.pull-right').innerText; + const time = section.querySelector('.row:nth-child(1) > .col-md-4').innerText; + const name = section.querySelector('.row:nth-child(2) > .col-md-4').innerText; + const space = section.querySelector('.row:nth-child(4) > .col-md-4').innerText; + browser.runtime.sendMessage({ + type: 'link-activity-details', + activityId, + date, + time, + name, + space, + }); + } +})(); diff --git a/extension/src/popup.html b/extension/src/popup.html new file mode 100644 index 0000000..eb52789 --- /dev/null +++ b/extension/src/popup.html @@ -0,0 +1,18 @@ + + + + + + Checkin Codes + + + + + + + +
+

Available codes

+
+ + diff --git a/extension/src/popup.js b/extension/src/popup.js new file mode 100644 index 0000000..4a55e07 --- /dev/null +++ b/extension/src/popup.js @@ -0,0 +1,53 @@ +(async function() { + const root = document.querySelector('#root'); + + function codeTable(codes) { + const table = document.createElement('table'); + table.innerHTML = 'CodeScore'; + const body = document.createElement('tbody'); + for (const { code, score } of codes) { + const row = document.createElement('tr'); + const codeEle = document.createElement('td'); + codeEle.innerText = code; + row.appendChild(codeEle); + const scoreEle = document.createElement('td'); + scoreEle.innerText = score.toString(); + row.appendChild(scoreEle); + body.appendChild(row); + } + table.appendChild(body); + return table; + } + function dataRow(label, value) { + const ele = document.createElement('p'); + ele.innerText = value; + const labelEle = document.createElement('b'); + labelEle.innerText = label + ': '; + ele.insertBefore(labelEle, ele.firstChild); + return ele; + } + function activityEle(activity) { + const root = document.createElement('div'); + const title = document.createElement('h2'); + title.innerText = activity.activity; + root.appendChild(title); + const details = document.createElement('p'); + details.innerHTML += dataRow('Time', activity.time).innerHTML; + details.innerHTML += '
'; + details.innerHTML += dataRow('Location', activity.space).innerHTML; + root.appendChild(details); + root.appendChild(codeTable(activity.codes)); + return root; + } + + const res = await fetch(SERVER_URL + '/api/codes'); + if (!res.ok) { + // TODO: Handle error + return; + } + const { activities } = await res.json(); + activities.sort(activity => activity.time); + for (const activity of activities) { + root.appendChild(activityEle(activity)); + } +})(); diff --git a/extension/src/styles.css b/extension/src/styles.css new file mode 100644 index 0000000..8ba8db6 --- /dev/null +++ b/extension/src/styles.css @@ -0,0 +1,14 @@ +html, body { + margin: 0; + padding: 0; + font-family: sans-serif; + font-size: 16px; +} + +main { + padding: 8px; +} + +h1 { + margin: 0; +} diff --git a/flake.nix b/flake.nix index ce71b70..883e6d9 100644 --- a/flake.nix +++ b/flake.nix @@ -19,8 +19,27 @@ uvicorn python-lsp-server ]; + version = builtins.substring 0 8 self.lastModifiedDate; in { + packages = { + default = pkgs.python311Packages.buildPythonApplication { + pname = "aci-backend"; + inherit version; + propagatedBuildInputs = dependencies pkgs.python311Packages; + src = ./backend; + }; + + docker = pkgs.dockerTools.buildLayeredImage { + name = self.packages.${system}.default.pname; + tag = version; + contents = [ self.packages.${system}.default pkgs.busybox ]; + config = { + Cmd = [ "/bin/app.py" ]; + WorkingDir = "/state"; + }; + }; + }; devShells.default = pkgs.mkShell { nativeBuildInputs = with pkgs; [ (python311.withPackages devDependencies)