runlight 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {runlight-0.2.0 → runlight-0.3.0}/PKG-INFO +2 -2
- {runlight-0.2.0 → runlight-0.3.0}/README.md +1 -1
- {runlight-0.2.0 → runlight-0.3.0}/pyproject.toml +1 -1
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/auth.py +115 -118
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/crypto.py +4 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/web.py +26 -8
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/build.json +3 -3
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/dashboard.js +7 -7
- runlight-0.3.0/src/runlight/assets/locales.json +7 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/core.py +67 -29
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/payload.py +17 -5
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/routes.py +24 -5
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/safefetch.py +23 -16
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/cli.py +17 -8
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/standalone.py +1 -1
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/sql.py +2 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/sql_store.py +20 -5
- runlight-0.2.0/src/runlight/assets/locales.json +0 -7
- {runlight-0.2.0 → runlight-0.3.0}/.gitignore +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/LICENSE +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/_aesgcm.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/_intl.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/_js.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/pages.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/asgi.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/assistant-action.json +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/dashboard.css +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/pick-return.js +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/picker.js +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/tracker.js +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/world.json +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assistant.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/body.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/brand.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/connect.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/agents.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/intl.json +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/sources.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/db.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/apps.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/management/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/management/commands/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/management/commands/runlight_check.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/env.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/fastapi.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/flask.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/funnels.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/geo.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/goals.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/hash.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/fetcher.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/headers.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/message.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/search_params.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/url.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/icon.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/bitly.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/csvvisits.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/dub.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/http.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/index.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/rebrandly.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/shortio.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/types.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/umami.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/visits.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/write.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/journeys.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/limit.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/links.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/secret.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/ses.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/smtp.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/transports.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mcp.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/messages.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mmdb.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/oauth.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/py.typed +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/query.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/reports.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/serve.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/agents.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/dbip.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/secret.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/sources.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/__init__.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/stores.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/time.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/ua.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/version.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/wsgi.py +0 -0
- {runlight-0.2.0 → runlight-0.3.0}/src/runlight/zip.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: runlight
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight.
|
|
5
5
|
Project-URL: Homepage, https://runlight.sh
|
|
6
6
|
Project-URL: Documentation, https://runlight.sh/docs/python/
|
|
@@ -61,7 +61,7 @@ rl = Runlight(
|
|
|
61
61
|
)
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Runlight reads the visitor's address from `X-Forwarded-For` and similar headers only when the request comes from this computer or a private network, where proxies and load balancers connect from, so a visitor who reaches your app straight from the internet cannot pick their own address. Behind a CDN that connects from public addresses, such as Cloudflare in front of your server, add `trust_proxy="cf-connecting-ip"` (the header it sets) or `trust_proxy=True`. Runlight logs a warning once when a public address sends forwarding headers that the default did not believe. `trust_proxy=False` never reads them.
|
|
65
65
|
|
|
66
66
|
Put it in front of your app. In Flask that is one line.
|
|
67
67
|
|
|
@@ -26,7 +26,7 @@ rl = Runlight(
|
|
|
26
26
|
)
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Runlight reads the visitor's address from `X-Forwarded-For` and similar headers only when the request comes from this computer or a private network, where proxies and load balancers connect from, so a visitor who reaches your app straight from the internet cannot pick their own address. Behind a CDN that connects from public addresses, such as Cloudflare in front of your server, add `trust_proxy="cf-connecting-ip"` (the header it sets) or `trust_proxy=True`. Runlight logs a warning once when a public address sends forwarding headers that the default did not believe. `trust_proxy=False` never reads them.
|
|
30
30
|
|
|
31
31
|
Put it in front of your app. In Flask that is one line.
|
|
32
32
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runlight"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
signed in. The standalone server always has them, and an app turns them on with routes({"accounts": True}).
|
|
3
3
|
|
|
4
4
|
A user is a dict with the TypeScript User's keys: id, email, hash, role ("owner", "admin", "member", or
|
|
5
|
-
"viewer"), createdAt, twoFactor (whether sign-in also asks for a code),
|
|
6
|
-
used)
|
|
5
|
+
"viewer"), createdAt, twoFactor (whether sign-in also asks for a code), recoveryLeft (recovery codes not yet
|
|
6
|
+
used), and signedOut when set (a mark signing out replaces, which every session is signed with). An invite has
|
|
7
|
+
id, email, role, invitedBy, createdAt, and expiresAt.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
12
|
+
import datetime as _dt
|
|
11
13
|
import re
|
|
12
14
|
import struct
|
|
13
15
|
import threading
|
|
16
|
+
import time as _time
|
|
14
17
|
from collections.abc import Callable
|
|
15
18
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
16
19
|
|
|
@@ -255,8 +258,14 @@ class Accounts:
|
|
|
255
258
|
if "role" not in names:
|
|
256
259
|
db.run(f"ALTER TABLE rl_users ADD COLUMN role {text(20)} NOT NULL DEFAULT 'owner'")
|
|
257
260
|
# Two-factor came later still: the secret (sealed), one being set up, recovery code hashes, and the last
|
|
258
|
-
# code's step.
|
|
259
|
-
for name, kind in (
|
|
261
|
+
# code's step. And the mark signing out replaces, later again.
|
|
262
|
+
for name, kind in (
|
|
263
|
+
("totp_secret", "TEXT"),
|
|
264
|
+
("totp_pending", "TEXT"),
|
|
265
|
+
("totp_recovery", "TEXT"),
|
|
266
|
+
("totp_step", "BIGINT"),
|
|
267
|
+
("signed_out", "TEXT"),
|
|
268
|
+
):
|
|
260
269
|
if name not in names:
|
|
261
270
|
db.run(f"ALTER TABLE rl_users ADD COLUMN {name} {kind}")
|
|
262
271
|
db.run(
|
|
@@ -290,6 +299,7 @@ class Accounts:
|
|
|
290
299
|
"createdAt": _js.number(r["created_at"]),
|
|
291
300
|
"twoFactor": _js.truthy(r.get("totp_secret")),
|
|
292
301
|
"recoveryLeft": len(_recovery_list(r.get("totp_recovery"))),
|
|
302
|
+
**({"signedOut": _js.string(r["signed_out"])} if _js.truthy(r.get("signed_out")) else {}),
|
|
293
303
|
}
|
|
294
304
|
|
|
295
305
|
def _seal(self, text: str) -> str:
|
|
@@ -676,10 +686,16 @@ class Accounts:
|
|
|
676
686
|
return None
|
|
677
687
|
return user if same_text(self._sign(f"{id_}.{expires}", self._session_key(user)), signed) else None
|
|
678
688
|
|
|
689
|
+
def end_sessions(self, id: str) -> None: # noqa: A002
|
|
690
|
+
"""Ends every session an account has, in every browser, as signing out does."""
|
|
691
|
+
self._init()
|
|
692
|
+
self.store.db.run("UPDATE rl_users SET signed_out = ? WHERE id = ?", [hex(random_bytes(8)), id])
|
|
693
|
+
|
|
679
694
|
def _session_key(self, user: dict[str, Any]) -> str:
|
|
680
|
-
"""What a session is signed with: the password hash,
|
|
681
|
-
other sessions."""
|
|
682
|
-
|
|
695
|
+
"""What a session is signed with: the password hash, whether two-factor is on, and a mark that signing out
|
|
696
|
+
replaces, so changing any of them ends other sessions."""
|
|
697
|
+
signed_out = user.get("signedOut")
|
|
698
|
+
return f"{user['hash']}{'.2fa' if user['twoFactor'] else ''}{f'.{signed_out}' if signed_out else ''}"
|
|
683
699
|
|
|
684
700
|
def device_for(self, user: dict[str, Any]) -> str:
|
|
685
701
|
"""A long-lived mark for a browser that signed in to an account. With it, failed tries by others
|
|
@@ -701,72 +717,95 @@ class Accounts:
|
|
|
701
717
|
|
|
702
718
|
class Throttle:
|
|
703
719
|
"""Counts failed sign-ins under a key and refuses more than a few in a while.
|
|
704
|
-
Keys are hashed, so the counts never hold an
|
|
705
|
-
given.
|
|
720
|
+
Keys are hashed with a key made for the day, so the counts never hold an
|
|
721
|
+
address or an email as it was given.
|
|
706
722
|
|
|
707
723
|
TypeScript keeps the counts in its process. A Python app often runs several
|
|
708
724
|
worker processes, so here they live in the database's settings, as the PHP
|
|
709
725
|
port keeps them: "throttle:<name>:<id>" holding {count, until}, and the
|
|
710
|
-
hashing key as "throttle-key". Every process of an install then
|
|
711
|
-
same counts, and the two ports read each other's.
|
|
726
|
+
hashing key as "throttle-key:<UTC day>". Every process of an install then
|
|
727
|
+
shares the same counts, and the two ports read each other's.
|
|
712
728
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
729
|
+
Some ids come from an address, so none may last past a day: each UTC day
|
|
730
|
+
has its own key, keys older than yesterday are deleted, and counts that ran
|
|
731
|
+
out go when read, when a new count starts, and in the scheduled check. A
|
|
732
|
+
window that crosses midnight starts again under the new day's key."""
|
|
717
733
|
|
|
718
|
-
_KEY = "throttle-key"
|
|
734
|
+
_KEY = "throttle-key:"
|
|
735
|
+
# The one key kept before keys were made each day.
|
|
736
|
+
_OLD_KEY = "throttle-key"
|
|
719
737
|
|
|
720
|
-
def __init__(
|
|
738
|
+
def __init__(
|
|
739
|
+
self, store: SqlStore, name: str, limit: int = 10, window_ms: int = 15 * 60_000, clock: Callable[[], int] | None = None
|
|
740
|
+
) -> None:
|
|
721
741
|
self.store = store
|
|
722
742
|
self.name = name
|
|
723
743
|
self.limit = limit
|
|
724
744
|
self.window_ms = window_ms
|
|
745
|
+
# Milliseconds since the epoch, for forgive() and clear(), which are given no time.
|
|
746
|
+
self._clock: Callable[[], int] = clock if clock is not None else (lambda: int(_time.time() * 1000))
|
|
725
747
|
self._lock = threading.Lock()
|
|
726
|
-
|
|
727
|
-
self.
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
self.store.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
748
|
+
# Today's key once read, so each count does not read it again.
|
|
749
|
+
self._salts: tuple[str, str] | None = None
|
|
750
|
+
|
|
751
|
+
@staticmethod
|
|
752
|
+
def _day(now: int) -> str:
|
|
753
|
+
return _dt.datetime.fromtimestamp(now // 1000, _dt.UTC).strftime("%Y-%m-%d")
|
|
754
|
+
|
|
755
|
+
def _salt(self, now: int) -> str:
|
|
756
|
+
day = self._day(now)
|
|
757
|
+
if self._salts is not None and self._salts[0] == day:
|
|
758
|
+
return self._salts[1]
|
|
759
|
+
key = self._KEY + day
|
|
760
|
+
saved = self.store.setting(key)
|
|
761
|
+
if not saved:
|
|
762
|
+
from ..hash import random_id
|
|
763
|
+
|
|
764
|
+
# Kept only if there is none yet, so two processes starting at once settle on one key.
|
|
765
|
+
made = random_id(16)
|
|
766
|
+
db = self.store.db
|
|
767
|
+
db.run(upsert(db.dialect(), "rl_settings", ['"key"', "value"], ['"key"'], []), [key, made])
|
|
768
|
+
saved = self.store.setting(key) or made
|
|
769
|
+
if saved == made:
|
|
770
|
+
Throttle.sweep(self.store, now)
|
|
771
|
+
self._salts = (day, saved)
|
|
772
|
+
return saved
|
|
773
|
+
|
|
774
|
+
@staticmethod
|
|
775
|
+
def sweep(store: SqlStore, now: int) -> None:
|
|
776
|
+
"""Deletes the counts of every throttle that have run out, the hashing keys older than yesterday, and the
|
|
777
|
+
one key kept before there was one a day, so nothing an id was made from outlasts a day. The scheduled
|
|
778
|
+
check runs it, and so does the first count of each day."""
|
|
779
|
+
keep = {Throttle._KEY + Throttle._day(now), Throttle._KEY + Throttle._day(now - 86_400_000)}
|
|
780
|
+
for setting in store.settings_starting_with(Throttle._KEY):
|
|
781
|
+
if setting["key"] not in keep:
|
|
782
|
+
store.set_setting(setting["key"], None)
|
|
783
|
+
if store.setting(Throttle._OLD_KEY) is not None:
|
|
784
|
+
store.set_setting(Throttle._OLD_KEY, None)
|
|
785
|
+
for setting in store.settings_starting_with("throttle:"):
|
|
786
|
+
ok, entry = _js.try_loads(setting["value"])
|
|
787
|
+
if not ok or not isinstance(entry, dict) or int(_js.number(entry.get("until", 0))) <= now:
|
|
788
|
+
store.db.run('DELETE FROM rl_settings WHERE "key" = ? AND value = ?', [setting["key"], setting["value"]])
|
|
789
|
+
|
|
790
|
+
def _id(self, key: str, now: int) -> str:
|
|
791
|
+
return base64url(hmac("SHA-256", self._salt(now), key))[:22]
|
|
751
792
|
|
|
752
793
|
def _prefix(self) -> str:
|
|
753
794
|
return f"throttle:{self.name}:"
|
|
754
795
|
|
|
755
|
-
def _entry(self, id_: str) -> dict[str, int] | None:
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
saved = self.store.setting(
|
|
759
|
-
|
|
760
|
-
|
|
796
|
+
def _entry(self, id_: str, now: int) -> dict[str, int] | None:
|
|
797
|
+
"""A count still running. One that has run out is deleted as it is read."""
|
|
798
|
+
key = self._prefix() + id_
|
|
799
|
+
saved = self.store.setting(key)
|
|
800
|
+
if saved is None:
|
|
801
|
+
return None
|
|
802
|
+
ok, entry = _js.try_loads(saved)
|
|
803
|
+
if not ok or not isinstance(entry, dict) or int(_js.number(entry.get("until", 0))) <= now:
|
|
804
|
+
self._drop(key, saved)
|
|
761
805
|
return None
|
|
762
806
|
return {"count": int(_js.number(entry.get("count", 0))), "until": int(_js.number(entry.get("until", 0)))}
|
|
763
807
|
|
|
764
808
|
def _save(self, id_: str, entry: dict[str, int]) -> None:
|
|
765
|
-
if self._memory is not None:
|
|
766
|
-
# Newest last, so the map's order is the order entries were made, as a JavaScript Map keeps it.
|
|
767
|
-
self._memory.pop(id_, None)
|
|
768
|
-
self._memory[id_] = entry
|
|
769
|
-
return
|
|
770
809
|
self.store.set_setting(self._prefix() + id_, _js.dumps(entry))
|
|
771
810
|
|
|
772
811
|
def _turn(self, id_: str, make: bool, fn: Callable[[], T]) -> T:
|
|
@@ -774,10 +813,6 @@ class Throttle:
|
|
|
774
813
|
room under the limit: one thread at a time in this process, and a transaction holding the entry's row
|
|
775
814
|
against other processes. SQLite's transaction holds the whole file; on Postgres and MySQL the row is
|
|
776
815
|
locked, made first (empty, which reads as expired) when `make` asks and it is not there yet."""
|
|
777
|
-
if self._memory is not None:
|
|
778
|
-
self._sweep()
|
|
779
|
-
with self._lock:
|
|
780
|
-
return fn()
|
|
781
816
|
db = self.store.db
|
|
782
817
|
|
|
783
818
|
def step(_db: Any) -> T:
|
|
@@ -793,19 +828,11 @@ class Throttle:
|
|
|
793
828
|
return db.transaction(step)
|
|
794
829
|
|
|
795
830
|
def blocked(self, key: str, now: int) -> bool:
|
|
796
|
-
return self.
|
|
797
|
-
|
|
798
|
-
def _is_blocked(self, id_: str, now: int) -> bool:
|
|
799
|
-
if self._memory is not None:
|
|
800
|
-
with self._lock:
|
|
801
|
-
return self._blocked_now(id_, now)
|
|
802
|
-
return self._blocked_now(id_, now)
|
|
831
|
+
return self._blocked_now(self._id(key, now), now)
|
|
803
832
|
|
|
804
833
|
def _blocked_now(self, id_: str, now: int) -> bool:
|
|
805
|
-
entry = self._entry(id_)
|
|
806
|
-
|
|
807
|
-
return False
|
|
808
|
-
return entry["count"] >= self.limit
|
|
834
|
+
entry = self._entry(id_, now)
|
|
835
|
+
return entry is not None and entry["count"] >= self.limit
|
|
809
836
|
|
|
810
837
|
def take(self, key: str, now: int) -> bool:
|
|
811
838
|
"""Counts a try before the slow check it guards, so a burst that arrives
|
|
@@ -813,7 +840,7 @@ class Throttle:
|
|
|
813
840
|
False, counting nothing, when the key is already at its limit. A try
|
|
814
841
|
that turns out right is taken back with forgive(). The check and the
|
|
815
842
|
count happen as one step, across threads and processes."""
|
|
816
|
-
id_ = self._id(key)
|
|
843
|
+
id_ = self._id(key, now)
|
|
817
844
|
|
|
818
845
|
def step() -> tuple[bool, bool]:
|
|
819
846
|
if self._blocked_now(id_, now):
|
|
@@ -827,31 +854,30 @@ class Throttle:
|
|
|
827
854
|
|
|
828
855
|
def forgive(self, key: str) -> None:
|
|
829
856
|
"""Takes back one counted try, for one that turned out right."""
|
|
830
|
-
|
|
857
|
+
now = self._clock()
|
|
858
|
+
id_ = self._id(key, now)
|
|
831
859
|
|
|
832
860
|
def step() -> None:
|
|
833
|
-
entry = self._entry(id_)
|
|
861
|
+
entry = self._entry(id_, now)
|
|
834
862
|
if entry is not None and entry["count"] > 0:
|
|
835
863
|
entry["count"] -= 1
|
|
836
|
-
|
|
837
|
-
self._save(id_, entry)
|
|
864
|
+
self._save(id_, entry)
|
|
838
865
|
|
|
839
866
|
self._turn(id_, False, step)
|
|
840
867
|
|
|
841
868
|
def fail(self, key: str, now: int) -> None:
|
|
842
|
-
id_ = self._id(key)
|
|
869
|
+
id_ = self._id(key, now)
|
|
843
870
|
if self._turn(id_, True, lambda: self._count(id_, now)):
|
|
844
871
|
self._prune(now)
|
|
845
872
|
|
|
846
873
|
def _count(self, id_: str, now: int) -> bool:
|
|
847
874
|
"""Counts a try, and says whether that made a new entry."""
|
|
848
|
-
entry = self._entry(id_)
|
|
849
|
-
if entry is None
|
|
875
|
+
entry = self._entry(id_, now)
|
|
876
|
+
if entry is None:
|
|
850
877
|
self._save(id_, {"count": 1, "until": now + self.window_ms})
|
|
851
878
|
return True
|
|
852
879
|
entry["count"] += 1
|
|
853
|
-
|
|
854
|
-
self._save(id_, entry)
|
|
880
|
+
self._save(id_, entry)
|
|
855
881
|
return False
|
|
856
882
|
|
|
857
883
|
def _drop(self, key: str, value: str) -> None:
|
|
@@ -859,13 +885,10 @@ class Throttle:
|
|
|
859
885
|
self.store.db.run('DELETE FROM rl_settings WHERE "key" = ? AND value = ?', [key, value])
|
|
860
886
|
|
|
861
887
|
def _prune(self, now: int) -> None:
|
|
862
|
-
"""Expired entries go first, then the
|
|
863
|
-
|
|
864
|
-
made, since only then can
|
|
865
|
-
|
|
866
|
-
with self._lock:
|
|
867
|
-
self._prune_memory(now)
|
|
868
|
-
return
|
|
888
|
+
"""Expired entries go first, then those with the fewest tries, oldest first, down to nine tenths of the
|
|
889
|
+
ceiling so this runs seldom. A flood of made-up names has one try each, so it cannot wipe out a block,
|
|
890
|
+
or the count of a name someone is close to blocking. Run when a new entry is made, since only then can
|
|
891
|
+
there be more."""
|
|
869
892
|
entries = []
|
|
870
893
|
for setting in self.store.settings_starting_with(self._prefix()):
|
|
871
894
|
ok, entry = _js.try_loads(setting["value"])
|
|
@@ -874,39 +897,13 @@ class Throttle:
|
|
|
874
897
|
self._drop(setting["key"], setting["value"])
|
|
875
898
|
continue
|
|
876
899
|
count = int(_js.number(entry.get("count", 0))) if isinstance(entry, dict) else 0
|
|
877
|
-
entries.append({"key": setting["key"], "value": setting["value"], "until": until, "
|
|
878
|
-
|
|
879
|
-
if size <= MAX_THROTTLED:
|
|
880
|
-
return
|
|
881
|
-
# Oldest first: each entry's window started window_ms before its end.
|
|
882
|
-
entries.sort(key=lambda e: e["until"])
|
|
883
|
-
for blocked in (False, True):
|
|
884
|
-
for entry in entries:
|
|
885
|
-
if size <= MAX_THROTTLED:
|
|
886
|
-
return
|
|
887
|
-
if entry["blocked"] is blocked:
|
|
888
|
-
self._drop(entry["key"], entry["value"])
|
|
889
|
-
size -= 1
|
|
890
|
-
|
|
891
|
-
def _prune_memory(self, now: int) -> None:
|
|
892
|
-
failures = self._memory
|
|
893
|
-
assert failures is not None
|
|
894
|
-
if len(failures) <= MAX_THROTTLED:
|
|
900
|
+
entries.append({"key": setting["key"], "value": setting["value"], "until": until, "count": count})
|
|
901
|
+
if len(entries) <= MAX_THROTTLED:
|
|
895
902
|
return
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
for
|
|
899
|
-
|
|
900
|
-
break
|
|
901
|
-
del failures[k]
|
|
902
|
-
for k in list(failures):
|
|
903
|
-
if len(failures) <= MAX_THROTTLED:
|
|
904
|
-
break
|
|
905
|
-
del failures[k]
|
|
903
|
+
# Each entry's window started window_ms before its end, so the earliest end is the oldest.
|
|
904
|
+
entries.sort(key=lambda e: (e["count"], e["until"]))
|
|
905
|
+
for entry in entries[: len(entries) - MAX_THROTTLED * 9 // 10]:
|
|
906
|
+
self._drop(entry["key"], entry["value"])
|
|
906
907
|
|
|
907
908
|
def clear(self, key: str) -> None:
|
|
908
|
-
|
|
909
|
-
with self._lock:
|
|
910
|
-
self._memory.pop(self._id(key), None)
|
|
911
|
-
return
|
|
912
|
-
self.store.set_setting(self._prefix() + self._id(key), None)
|
|
909
|
+
self.store.set_setting(self._prefix() + self._id(key, self._clock()), None)
|
|
@@ -107,6 +107,10 @@ def _scrypt_cost(parts: list[str]) -> dict[str, int] | None:
|
|
|
107
107
|
# N a power of two up to 2^20, and r and p small, so a stored hash cannot ask for a huge amount of memory or time.
|
|
108
108
|
if not whole(n, 1 << 20) or n < 2 or (int(n) & (int(n) - 1)) != 0 or not whole(r, 32) or not whole(p, 16):
|
|
109
109
|
return None
|
|
110
|
+
# N must also be below 2^(16r), as scrypt itself requires, so a stored hash past that fails to match rather than
|
|
111
|
+
# raises.
|
|
112
|
+
if int(n).bit_length() - 1 >= 16 * int(r):
|
|
113
|
+
return None
|
|
110
114
|
return {"N": int(n), "r": int(r), "p": int(p)}
|
|
111
115
|
|
|
112
116
|
|
|
@@ -128,7 +128,7 @@ def _role_of(value: Any) -> str | None:
|
|
|
128
128
|
|
|
129
129
|
class AccountsWeb:
|
|
130
130
|
"""What accounts_web() hands back: `accounts`, and signed_in(), access(), account_of(), token_made(),
|
|
131
|
-
has_account(), and handle()."""
|
|
131
|
+
tokens_of(), has_account(), and handle()."""
|
|
132
132
|
|
|
133
133
|
def __init__(self, options: Mapping[str, Any]) -> None:
|
|
134
134
|
self.rl = options["runlight"]
|
|
@@ -153,15 +153,16 @@ class AccountsWeb:
|
|
|
153
153
|
# cannot keep the server hashing; a request with no known address skips that one,
|
|
154
154
|
# since it would hold up everyone at once.
|
|
155
155
|
# Each try counts before the password is checked, and a right one is taken back.
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
self.
|
|
159
|
-
self.
|
|
156
|
+
# The counts live in the database, so every worker process shares them, hashed with a key for the day, so
|
|
157
|
+
# nothing made from an address lasts past a day.
|
|
158
|
+
self.per_address = Throttle(self.store, "address", 10, clock=self.now)
|
|
159
|
+
self.per_account = Throttle(self.store, "account", 50, clock=self.now)
|
|
160
|
+
self.per_ip = Throttle(self.store, "ip", 30, clock=self.now)
|
|
160
161
|
# Six-digit codes: five wrong tries an account every fifteen minutes, and five to confirm the first one.
|
|
161
162
|
# Password re-checks in Account: ten.
|
|
162
|
-
self.code_tries = Throttle(self.store, "code", 5)
|
|
163
|
-
self.confirm_tries = Throttle(self.store, "confirm", 5)
|
|
164
|
-
self.rechecks = Throttle(self.store, "recheck", 10)
|
|
163
|
+
self.code_tries = Throttle(self.store, "code", 5, clock=self.now)
|
|
164
|
+
self.confirm_tries = Throttle(self.store, "confirm", 5, clock=self.now)
|
|
165
|
+
self.rechecks = Throttle(self.store, "recheck", 10, clock=self.now)
|
|
165
166
|
|
|
166
167
|
def now(self) -> int:
|
|
167
168
|
return self._now()
|
|
@@ -312,6 +313,11 @@ class AccountsWeb:
|
|
|
312
313
|
return Response(AUTH_CSS, 200, {"content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600"})
|
|
313
314
|
if path == "/auth.js":
|
|
314
315
|
return Response(auth_js(query.get("lang") or "en"), 200, {"content-type": "application/javascript; charset=utf-8", "cache-control": "public, max-age=3600"})
|
|
316
|
+
# A form posted from another site, or a sibling one, could sign someone in to an account that is not theirs or
|
|
317
|
+
# plant a session, so these pages take forms only from their own. The browser says where a request came from.
|
|
318
|
+
cross_site = request.headers.get("sec-fetch-site") in ("cross-site", "same-site")
|
|
319
|
+
if cross_site and (method == "POST" or path == "/logout") and path in ("/setup", "/login", "/login/code", "/invite", "/logout"):
|
|
320
|
+
return _html(login_page(base, {"error": t("oauth.anotherSiteTitle"), "next": self.home_path, "forgot": self.forgot}, lang), 403)
|
|
315
321
|
|
|
316
322
|
if path == "/setup":
|
|
317
323
|
if self.has_account():
|
|
@@ -382,6 +388,7 @@ class AccountsWeb:
|
|
|
382
388
|
self.code_tries.clear(user["id"])
|
|
383
389
|
return self._signed_in_to(request, user, next_)
|
|
384
390
|
|
|
391
|
+
# Signing out ends this browser's session; the account page can end them everywhere.
|
|
385
392
|
if path == "/logout":
|
|
386
393
|
return _redirect(f"{base}/login", {"set-cookie": self._session_cookie(request, "", 0)})
|
|
387
394
|
|
|
@@ -505,6 +512,10 @@ class AccountsWeb:
|
|
|
505
512
|
return _coded("Send JSON", "send_json", 415)
|
|
506
513
|
if path == "/api/account" and method == "GET":
|
|
507
514
|
return _reply({"account": _person(user)})
|
|
515
|
+
# Ends the account's sessions in every browser, this one too, so a cookie copied before stops working.
|
|
516
|
+
if path == "/api/account/sign-out-everywhere" and method == "POST":
|
|
517
|
+
self.accounts.end_sessions(user["id"])
|
|
518
|
+
return _reply({"ok": True}, 200, {"set-cookie": self._session_cookie(request, "", 0)})
|
|
508
519
|
|
|
509
520
|
if path == "/api/account/password" and method == "POST":
|
|
510
521
|
input = _body(request) # noqa: A001
|
|
@@ -515,6 +526,9 @@ class AccountsWeb:
|
|
|
515
526
|
return refused
|
|
516
527
|
try:
|
|
517
528
|
updated = self.accounts.set_password(user["email"], _field(input, "next"), now)
|
|
529
|
+
# Asked for after a password got out: the tokens and apps the account made could still read on.
|
|
530
|
+
if input.get("revokeTokens") is True:
|
|
531
|
+
self._drop_tokens_of(user["id"])
|
|
518
532
|
# The new password ends every other sign-in; this browser gets a fresh one.
|
|
519
533
|
return _reply({"ok": True}, 200, self._fresh(request, updated))
|
|
520
534
|
except AccountError as error:
|
|
@@ -674,6 +688,10 @@ class AccountsWeb:
|
|
|
674
688
|
self.store.set_setting(f"{MADE_BY}{token['id']}", by)
|
|
675
689
|
return True
|
|
676
690
|
|
|
691
|
+
def tokens_of(self, by: str) -> set[str]:
|
|
692
|
+
"""The ids of the tokens an account made."""
|
|
693
|
+
return {s["key"][len(MADE_BY) :] for s in self.store.settings_starting_with(MADE_BY) if s["value"] == by}
|
|
694
|
+
|
|
677
695
|
def handle(self, request: Request, path: str, context: Mapping[str, Any] | None = None) -> Response | None:
|
|
678
696
|
"""Answers an account page or API request at a path under the base, or None for anything else."""
|
|
679
697
|
context = context or {}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by scripts/python-assets.mts from the TypeScript SDK's generated files. Do not edit.",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"apiVersion": 1,
|
|
5
|
-
"dashboardHash": "
|
|
5
|
+
"dashboardHash": "89287c3e896f",
|
|
6
6
|
"worldHash": "4422113781f8",
|
|
7
|
-
"localesHash": "
|
|
7
|
+
"localesHash": "0a7b5e462a6b",
|
|
8
8
|
"trackerHash": "52fcb1901ce5",
|
|
9
9
|
"icon": "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cstyle%3E.h%7Bfill%3A%23000%7D.r%7Bstroke%3A%23fff%7D%40media%20%28prefers-color-scheme%3Adark%29%7B.h%7Bfill%3A%23fff%7D.r%7Bstroke%3A%23000%7D%7D%3C/style%3E%3Crect%20class%3D%22h%22%20x%3D%222.5%22%20y%3D%222.5%22%20width%3D%2227%22%20height%3D%2227%22%20rx%3D%227%22/%3E%3Cpath%20class%3D%22r%22%20d%3D%22M11%2023V9h6.2a4.3%204.3%200%200%201%200%208.6H11m6%200%205%205.4%22%20fill%3D%22none%22%20stroke-width%3D%222.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22/%3E%3Ccircle%20cx%3D%2223.6%22%20cy%3D%228.4%22%20r%3D%222.6%22%20fill%3D%22%2322c55e%22/%3E%3C/svg%3E"
|
|
10
10
|
}
|