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.
Files changed (102) hide show
  1. {runlight-0.2.0 → runlight-0.3.0}/PKG-INFO +2 -2
  2. {runlight-0.2.0 → runlight-0.3.0}/README.md +1 -1
  3. {runlight-0.2.0 → runlight-0.3.0}/pyproject.toml +1 -1
  4. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/auth.py +115 -118
  5. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/crypto.py +4 -0
  6. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/web.py +26 -8
  7. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/build.json +3 -3
  8. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/dashboard.js +7 -7
  9. runlight-0.3.0/src/runlight/assets/locales.json +7 -0
  10. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/core.py +67 -29
  11. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/payload.py +17 -5
  12. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/routes.py +24 -5
  13. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/safefetch.py +23 -16
  14. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/cli.py +17 -8
  15. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/standalone.py +1 -1
  16. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/sql.py +2 -0
  17. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/sql_store.py +20 -5
  18. runlight-0.2.0/src/runlight/assets/locales.json +0 -7
  19. {runlight-0.2.0 → runlight-0.3.0}/.gitignore +0 -0
  20. {runlight-0.2.0 → runlight-0.3.0}/LICENSE +0 -0
  21. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/__init__.py +0 -0
  22. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/_aesgcm.py +0 -0
  23. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/_intl.py +0 -0
  24. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/_js.py +0 -0
  25. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/__init__.py +0 -0
  26. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/accounts/pages.py +0 -0
  27. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/asgi.py +0 -0
  28. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/assistant-action.json +0 -0
  29. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/dashboard.css +0 -0
  30. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/pick-return.js +0 -0
  31. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/picker.js +0 -0
  32. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/tracker.js +0 -0
  33. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets/world.json +0 -0
  34. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assets.py +0 -0
  35. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/assistant.py +0 -0
  36. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/body.py +0 -0
  37. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/brand.py +0 -0
  38. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/connect.py +0 -0
  39. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/__init__.py +0 -0
  40. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/agents.py +0 -0
  41. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/intl.json +0 -0
  42. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/data/sources.py +0 -0
  43. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/db.py +0 -0
  44. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/__init__.py +0 -0
  45. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/apps.py +0 -0
  46. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/management/__init__.py +0 -0
  47. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/management/commands/__init__.py +0 -0
  48. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/django/management/commands/runlight_check.py +0 -0
  49. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/env.py +0 -0
  50. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/fastapi.py +0 -0
  51. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/flask.py +0 -0
  52. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/funnels.py +0 -0
  53. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/geo.py +0 -0
  54. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/goals.py +0 -0
  55. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/hash.py +0 -0
  56. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/__init__.py +0 -0
  57. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/fetcher.py +0 -0
  58. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/headers.py +0 -0
  59. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/message.py +0 -0
  60. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/search_params.py +0 -0
  61. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/http/url.py +0 -0
  62. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/icon.py +0 -0
  63. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/__init__.py +0 -0
  64. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/bitly.py +0 -0
  65. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/csvvisits.py +0 -0
  66. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/dub.py +0 -0
  67. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/http.py +0 -0
  68. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/index.py +0 -0
  69. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/rebrandly.py +0 -0
  70. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/shortio.py +0 -0
  71. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/types.py +0 -0
  72. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/umami.py +0 -0
  73. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/visits.py +0 -0
  74. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/importers/write.py +0 -0
  75. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/journeys.py +0 -0
  76. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/limit.py +0 -0
  77. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/links.py +0 -0
  78. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/__init__.py +0 -0
  79. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/secret.py +0 -0
  80. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/ses.py +0 -0
  81. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/smtp.py +0 -0
  82. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mail/transports.py +0 -0
  83. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mcp.py +0 -0
  84. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/messages.py +0 -0
  85. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/mmdb.py +0 -0
  86. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/oauth.py +0 -0
  87. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/py.typed +0 -0
  88. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/query.py +0 -0
  89. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/reports.py +0 -0
  90. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/serve.py +0 -0
  91. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/__init__.py +0 -0
  92. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/agents.py +0 -0
  93. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/dbip.py +0 -0
  94. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/server/secret.py +0 -0
  95. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/sources.py +0 -0
  96. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/__init__.py +0 -0
  97. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/store/stores.py +0 -0
  98. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/time.py +0 -0
  99. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/ua.py +0 -0
  100. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/version.py +0 -0
  101. {runlight-0.2.0 → runlight-0.3.0}/src/runlight/wsgi.py +0 -0
  102. {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.2.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
- If nothing sits in front of your app (no load balancer, CDN, or reverse proxy), add `trust_proxy=False`, so a visitor cannot pick their own address with an `X-Forwarded-For` header. Runlight logs a warning once when it sees requests arrive that way with the default left on.
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
- If nothing sits in front of your app (no load balancer, CDN, or reverse proxy), add `trust_proxy=False`, so a visitor cannot pick their own address with an `X-Forwarded-For` header. Runlight logs a warning once when it sees requests arrive that way with the default left on.
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.2.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), and recoveryLeft (recovery codes not yet
6
- used). An invite has id, email, role, invitedBy, createdAt, and expiresAt.
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 (("totp_secret", "TEXT"), ("totp_pending", "TEXT"), ("totp_recovery", "TEXT"), ("totp_step", "BIGINT")):
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, and whether two-factor is on, so changing either ends
681
- other sessions."""
682
- return f"{user['hash']}{'.2fa' if user['twoFactor'] else ''}"
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 address or an email as it was
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 shares the
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
- With `memory`, the counts stay in the process instead, hashed with a key
714
- made at start, as TypeScript keeps them. That is for keys holding a client's
715
- address: the database never sees them, so a copy of it cannot be used to
716
- work out who tried to sign in from where."""
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__(self, store: SqlStore, name: str, limit: int = 10, window_ms: int = 15 * 60_000, memory: bool = False) -> None:
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
- self._memory: dict[str, dict[str, int]] | None = {} if memory else None
727
- self._memory_salt = random_bytes(16)
728
- # Counts kept in the settings before they moved into the process are deleted on first use.
729
- self._swept = not memory
730
-
731
- def _salt(self) -> str:
732
- saved = self.store.setting(self._KEY)
733
- if saved:
734
- return saved
735
- from ..hash import random_id
736
-
737
- made = random_id(16)
738
- self.store.set_setting(self._KEY, made)
739
- return made
740
-
741
- def _id(self, key: str) -> str:
742
- salt: str | bytes = self._memory_salt if self._memory is not None else self._salt()
743
- return base64url(hmac("SHA-256", salt, key))[:22]
744
-
745
- def _sweep(self) -> None:
746
- if self._swept:
747
- return
748
- self._swept = True
749
- for setting in self.store.settings_starting_with(self._prefix()):
750
- self.store.set_setting(setting["key"], None)
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
- if self._memory is not None:
757
- return self._memory.get(id_)
758
- saved = self.store.setting(self._prefix() + id_)
759
- ok, entry = _js.try_loads(saved) if saved is not None else (False, None)
760
- if not ok or not isinstance(entry, dict):
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._is_blocked(self._id(key), now)
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
- if entry is None or entry["until"] <= now:
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
- id_ = self._id(key)
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
- if self._memory is None:
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 or entry["until"] <= now:
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
- if self._memory is None:
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 oldest that are not blocked, and blocked ones last, so the counts
863
- have a hard ceiling and a flood of made-up names cannot wipe out a real block. Run when a new entry is
864
- made, since only then can there be more."""
865
- if self._memory is not None:
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, "blocked": count >= self.limit})
878
- size = len(entries)
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
- for k in [k for k, v in failures.items() if v["until"] <= now]:
897
- del failures[k]
898
- for k in [k for k, v in failures.items() if v["count"] < self.limit]:
899
- if len(failures) <= MAX_THROTTLED:
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
- if self._memory is not None:
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
- # Counts by address stay in the process, as TypeScript keeps them, so no address reaches the database.
157
- self.per_address = Throttle(self.store, "address", 10, memory=True)
158
- self.per_account = Throttle(self.store, "account", 50)
159
- self.per_ip = Throttle(self.store, "ip", 30, memory=True)
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.2.0",
3
+ "version": "0.3.0",
4
4
  "apiVersion": 1,
5
- "dashboardHash": "36bad85248ab",
5
+ "dashboardHash": "89287c3e896f",
6
6
  "worldHash": "4422113781f8",
7
- "localesHash": "2be1f2e59c89",
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
  }