micelio 0.1.0__py3-none-any.whl

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 (76) hide show
  1. micelio/__init__.py +12 -0
  2. micelio/app.py +120 -0
  3. micelio/appearance.py +465 -0
  4. micelio/auth/__init__.py +5 -0
  5. micelio/auth/passwords.py +27 -0
  6. micelio/auth/settings.py +83 -0
  7. micelio/auth/store.py +109 -0
  8. micelio/auth/totp.py +68 -0
  9. micelio/auth/web.py +266 -0
  10. micelio/cli.py +227 -0
  11. micelio/config.py +282 -0
  12. micelio/formatting.py +45 -0
  13. micelio/graph.py +250 -0
  14. micelio/i18n.py +82 -0
  15. micelio/landing.py +175 -0
  16. micelio/landing_web.py +260 -0
  17. micelio/locales/en.toml +286 -0
  18. micelio/locales/es.toml +285 -0
  19. micelio/pages.py +316 -0
  20. micelio/py.typed +0 -0
  21. micelio/render.py +183 -0
  22. micelio/static/css/appearance.css +76 -0
  23. micelio/static/css/fonts.css +35 -0
  24. micelio/static/css/graph.css +162 -0
  25. micelio/static/css/landing.css +174 -0
  26. micelio/static/css/micelio.css +330 -0
  27. micelio/static/img/psychedelic-dusk.webp +0 -0
  28. micelio/static/js/appearance.js +115 -0
  29. micelio/static/js/backdrop.js +14 -0
  30. micelio/static/js/draw.js +300 -0
  31. micelio/static/js/explore.js +96 -0
  32. micelio/static/js/graph-embed.js +4 -0
  33. micelio/static/js/graph.js +536 -0
  34. micelio/static/js/landing.js +307 -0
  35. micelio/static/js/specimens.js +42 -0
  36. micelio/static/vendor/README.md +33 -0
  37. micelio/static/vendor/d3-force/LICENSE +13 -0
  38. micelio/static/vendor/d3-force/d3-force.bundle.min.js +8 -0
  39. micelio/static/vendor/fonts/atkinson-hyperlegible/OFL.txt +93 -0
  40. micelio/static/vendor/fonts/atkinson-hyperlegible/atkinson-hyperlegible-latin-400.woff2 +0 -0
  41. micelio/static/vendor/fonts/atkinson-hyperlegible/atkinson-hyperlegible-latin-700.woff2 +0 -0
  42. micelio/static/vendor/fonts/bagel-fat-one/OFL.txt +93 -0
  43. micelio/static/vendor/fonts/bagel-fat-one/bagel-fat-one-latin-400.woff2 +0 -0
  44. micelio/static/vendor/fonts/fraunces/OFL.txt +93 -0
  45. micelio/static/vendor/fonts/fraunces/fraunces-latin-900.woff2 +0 -0
  46. micelio/static/vendor/fonts/literata/OFL.txt +93 -0
  47. micelio/static/vendor/fonts/literata/literata-latin-400.woff2 +0 -0
  48. micelio/static/vendor/fonts/literata/literata-latin-700.woff2 +0 -0
  49. micelio/static/vendor/fonts/righteous/OFL.txt +93 -0
  50. micelio/static/vendor/fonts/righteous/righteous-latin-400.woff2 +0 -0
  51. micelio/static/vendor/fonts/shrikhand/OFL.txt +93 -0
  52. micelio/static/vendor/fonts/shrikhand/shrikhand-latin.woff2 +0 -0
  53. micelio/static/vendor/fonts/space-grotesk/OFL.txt +93 -0
  54. micelio/static/vendor/fonts/space-grotesk/space-grotesk-latin-400.woff2 +0 -0
  55. micelio/static/vendor/fonts/space-grotesk/space-grotesk-latin-700.woff2 +0 -0
  56. micelio/templates/_macros.html +31 -0
  57. micelio/templates/appearance.html +184 -0
  58. micelio/templates/base.html +58 -0
  59. micelio/templates/explore.html +87 -0
  60. micelio/templates/health.html +45 -0
  61. micelio/templates/journal.html +20 -0
  62. micelio/templates/landing.html +67 -0
  63. micelio/templates/login.html +20 -0
  64. micelio/templates/not_found.html +12 -0
  65. micelio/templates/note.html +51 -0
  66. micelio/templates/project.html +58 -0
  67. micelio/templates/projects.html +35 -0
  68. micelio/vault/__init__.py +17 -0
  69. micelio/vault/index.py +377 -0
  70. micelio/vault/parser.py +114 -0
  71. micelio/vault/public.py +66 -0
  72. micelio-0.1.0.dist-info/METADATA +246 -0
  73. micelio-0.1.0.dist-info/RECORD +76 -0
  74. micelio-0.1.0.dist-info/WHEEL +4 -0
  75. micelio-0.1.0.dist-info/entry_points.txt +2 -0
  76. micelio-0.1.0.dist-info/licenses/LICENSE +21 -0
micelio/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Micelio: your Markdown notes as a living network of ideas."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ DISTRIBUTION = "micelio"
6
+
7
+ try:
8
+ __version__ = version(DISTRIBUTION)
9
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = ["DISTRIBUTION", "__version__"]
micelio/app.py ADDED
@@ -0,0 +1,120 @@
1
+ """The FastAPI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from collections.abc import AsyncIterator, Mapping
8
+ from contextlib import asynccontextmanager
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.exception_handlers import http_exception_handler
14
+ from fastapi.responses import JSONResponse, Response
15
+ from fastapi.staticfiles import StaticFiles
16
+ from fastapi.templating import Jinja2Templates
17
+ from starlette.exceptions import HTTPException as StarletteHTTPException
18
+
19
+ from micelio import __version__
20
+ from micelio.appearance import AppearanceStore
21
+ from micelio.auth import resolve_auth
22
+ from micelio.auth.store import AuthStore
23
+ from micelio.auth.web import Clock, SignIn, install_security_headers
24
+ from micelio.config import Config
25
+ from micelio.formatting import Dates
26
+ from micelio.i18n import available_languages, browser_strings, translator
27
+ from micelio.landing_web import LandingPages
28
+ from micelio.pages import Pages
29
+ from micelio.vault import PublicView, VaultIndex
30
+
31
+ PACKAGE = Path(str(files("micelio")))
32
+ AUTH_DB = "auth.sqlite3"
33
+
34
+
35
+ def create_app(
36
+ config: Config,
37
+ *,
38
+ index: VaultIndex | None = None,
39
+ environ: Mapping[str, str] | None = None,
40
+ local_only: bool = False,
41
+ clock: Clock = time.time,
42
+ ) -> FastAPI:
43
+ """Build the app. Raises ``AuthSetupError`` when sign-in is on but not set up."""
44
+ vault = index or VaultIndex(config)
45
+ vault.refresh(force=True)
46
+
47
+ @asynccontextmanager
48
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
49
+ yield
50
+ if store is not None:
51
+ store.close()
52
+ looks.close()
53
+
54
+ settings = resolve_auth(
55
+ config, os.environ if environ is None else environ, local_only=local_only
56
+ )
57
+ store = AuthStore(config.data.dir / AUTH_DB) if settings.enabled else None
58
+ looks = AppearanceStore(config)
59
+
60
+ app = FastAPI(
61
+ lifespan=lifespan,
62
+ title=config.site.title,
63
+ version=__version__,
64
+ docs_url=None,
65
+ redoc_url=None,
66
+ openapi_url=None,
67
+ )
68
+ app.state.config = config
69
+ app.state.vault = vault
70
+
71
+ language = config.site.language
72
+ t = translator(language)
73
+ templates = Jinja2Templates(directory=PACKAGE / "templates")
74
+ templates.env.globals.update(t=t, site=config.site, language=language)
75
+
76
+ sign_in = SignIn(config, settings, store, templates, t, clock)
77
+ app.state.sign_in = sign_in
78
+ templates.env.globals["sign_in"] = sign_in
79
+
80
+ browser_locales = {lang: browser_strings(lang) for lang in available_languages()}
81
+
82
+ # Registered before the static mount, which would otherwise answer first.
83
+ @app.get("/static/locales/{name}", include_in_schema=False)
84
+ def browser_locale(name: str) -> Response:
85
+ """Browser strings, generated at start from ``locales/<lang>.toml`` (§5a)."""
86
+ language, dot, extension = name.rpartition(".")
87
+ strings = browser_locales.get(language) if dot and extension == "json" else None
88
+ if strings is None:
89
+ raise StarletteHTTPException(status_code=404)
90
+ return JSONResponse(strings, headers={"Cache-Control": "no-cache"})
91
+
92
+ app.mount("/static", StaticFiles(directory=PACKAGE / "static"), name="static")
93
+ sign_in.install(app)
94
+ install_security_headers(app)
95
+
96
+ dates = Dates(language, config.site.timezone)
97
+ public = PublicView(vault)
98
+ app.include_router(Pages(config, vault, templates, dates, looks, sign_in, public).router())
99
+ app.include_router(LandingPages(config, vault, looks, templates, sign_in, t, public).router())
100
+
101
+ @app.exception_handler(StarletteHTTPException)
102
+ async def not_found(request: Request, exc: StarletteHTTPException) -> Response:
103
+ if exc.status_code != 404:
104
+ return await http_exception_handler(request, exc)
105
+ return templates.TemplateResponse(request, "not_found.html", {}, status_code=404)
106
+
107
+ @app.get("/healthz", include_in_schema=False)
108
+ def healthz(request: Request) -> dict[str, object]:
109
+ """Liveness for containers and monitoring. Never titles; the number of notes
110
+ only when signed in, since private notes are not even counts for visitors (§9)."""
111
+ vault.refresh()
112
+ body: dict[str, object] = {
113
+ "status": "ok" if config.vault.root.is_dir() else "vault-missing",
114
+ "version": __version__,
115
+ }
116
+ if sign_in.sees_everything(request):
117
+ body["notes"] = len(vault.notes)
118
+ return body
119
+
120
+ return app
micelio/appearance.py ADDED
@@ -0,0 +1,465 @@
1
+ """The landing's look (§7b): palettes, the background image and the saved choices.
2
+
3
+ The choices made on ``/appearance`` live in the data folder (``site.sqlite3`` and
4
+ ``backgrounds/``), never in the vault. Without them the defaults of ``micelio.toml``
5
+ apply, so deleting the data folder only brings the defaults back.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import struct
13
+ import threading
14
+ from dataclasses import asdict, dataclass, replace
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ from micelio.config import (
19
+ LABEL_SIZE_MAX,
20
+ LABEL_SIZE_MIN,
21
+ PALETTE_NAMES,
22
+ SOIL_MAX,
23
+ SOIL_MIN,
24
+ TEXT_FONTS,
25
+ TEXT_SIZE_MAX,
26
+ TEXT_SIZE_MIN,
27
+ TITLE_EFFECTS,
28
+ TITLE_FONTS,
29
+ TITLE_SIZE_MAX,
30
+ TITLE_SIZE_MIN,
31
+ Config,
32
+ PaletteName,
33
+ TextFont,
34
+ TitleEffect,
35
+ TitleFont,
36
+ )
37
+
38
+ SITE_DB = "site.sqlite3"
39
+ BACKGROUNDS = "backgrounds"
40
+ BUILTIN_BACKGROUND = "img/psychedelic-dusk.webp" # under /static
41
+ BUILTIN_SOIL = 52
42
+
43
+ MAX_UPLOAD_BYTES = 8 * 1024 * 1024
44
+ MIN_WIDTH, MIN_HEIGHT = 1200, 800
45
+ MAX_TEXT = 120
46
+ MAX_NAME = 40
47
+
48
+ ImageType = Literal["png", "jpeg", "webp"]
49
+ MEDIA_TYPES: dict[ImageType, str] = {"png": "image/png", "jpeg": "image/jpeg", "webp": "image/webp"}
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Palette:
54
+ """Colors of one template. ``cats`` and ``spare`` colour categories by position;
55
+ ``ramp`` (dark to light) recolors the background, ``None`` keeps it as painted."""
56
+
57
+ key: PaletteName
58
+ name: str
59
+ cats: tuple[str, str, str, str]
60
+ spare: tuple[str, str, str, str]
61
+ ink: str
62
+ stem: str
63
+ thread: str
64
+ spore: str
65
+ crystals: tuple[str, str]
66
+ sun: str
67
+ accent: str
68
+ button: str
69
+ button_alt: str
70
+ ramp: tuple[str, ...] | None
71
+ swatch: tuple[str, ...]
72
+
73
+ def slot(self, position: int) -> str:
74
+ slots = (*self.cats, *self.spare)
75
+ return slots[position % len(slots)]
76
+
77
+ def to_json(self) -> dict[str, object]:
78
+ data = asdict(self)
79
+ data["ramp"] = list(self.ramp) if self.ramp else None
80
+ return data
81
+
82
+
83
+ PALETTES: dict[PaletteName, Palette] = {
84
+ "psychedelic": Palette(
85
+ key="psychedelic",
86
+ name="Psychedelic",
87
+ cats=("#ff2fa0", "#22d3ee", "#ff8a1f", "#ffd23f"),
88
+ spare=("#9b5cff", "#39ff6a", "#3b5bff", "#c6ff3a"),
89
+ ink="#12051f",
90
+ stem="#fff1d0",
91
+ thread="#ffffff",
92
+ spore="#ffd23f",
93
+ crystals=("#22d3ee", "#ffd23f"),
94
+ sun="#ffd23f",
95
+ accent="#ff2fa0",
96
+ button="#c6ff3a",
97
+ button_alt="#22d3ee",
98
+ ramp=None,
99
+ swatch=("#12051f", "#9b5cff", "#ff2fa0", "#ff8a1f", "#ffd23f", "#22d3ee"),
100
+ ),
101
+ # https://github.com/folke/tokyonight.nvim (MIT)
102
+ "tokyo-night": Palette(
103
+ key="tokyo-night",
104
+ name="Tokyo Night",
105
+ cats=("#bb9af7", "#7dcfff", "#ff9e64", "#9ece6a"),
106
+ spare=("#f7768e", "#73daca", "#e0af68", "#2ac3de"),
107
+ ink="#16161e",
108
+ stem="#c0caf5",
109
+ thread="#c0caf5",
110
+ spore="#e0af68",
111
+ crystals=("#7dcfff", "#e0af68"),
112
+ sun="#7aa2f7",
113
+ accent="#bb9af7",
114
+ button="#9ece6a",
115
+ button_alt="#7dcfff",
116
+ ramp=(
117
+ "#16161e",
118
+ "#1f2335",
119
+ "#2f3b6b",
120
+ "#3d59a1",
121
+ "#7aa2f7",
122
+ "#bb9af7",
123
+ "#7dcfff",
124
+ "#c0caf5",
125
+ ),
126
+ swatch=("#1a1b26", "#3d59a1", "#7aa2f7", "#bb9af7", "#7dcfff", "#ff9e64"),
127
+ ),
128
+ # https://github.com/catppuccin/catppuccin (MIT), the Mocha flavour
129
+ "catppuccin": Palette(
130
+ key="catppuccin",
131
+ name="Catppuccin Mocha",
132
+ cats=("#f38ba8", "#89dceb", "#fab387", "#a6e3a1"),
133
+ spare=("#cba6f7", "#94e2d5", "#f9e2af", "#89b4fa"),
134
+ ink="#11111b",
135
+ stem="#f5e0dc",
136
+ thread="#cdd6f4",
137
+ spore="#f9e2af",
138
+ crystals=("#89dceb", "#f9e2af"),
139
+ sun="#f5c2e7",
140
+ accent="#cba6f7",
141
+ button="#a6e3a1",
142
+ button_alt="#89b4fa",
143
+ ramp=(
144
+ "#11111b",
145
+ "#1e1e2e",
146
+ "#313244",
147
+ "#585b70",
148
+ "#89b4fa",
149
+ "#cba6f7",
150
+ "#f5c2e7",
151
+ "#f9e2af",
152
+ ),
153
+ swatch=("#1e1e2e", "#585b70", "#89b4fa", "#cba6f7", "#f5c2e7", "#fab387"),
154
+ ),
155
+ # https://github.com/rose-pine/rose-pine-theme (MIT)
156
+ "rose-pine": Palette(
157
+ key="rose-pine",
158
+ name="Rosé Pine",
159
+ cats=("#eb6f92", "#9ccfd8", "#f6c177", "#c4a7e7"),
160
+ spare=("#ebbcba", "#31748f", "#f6c177", "#9ccfd8"),
161
+ ink="#191724",
162
+ stem="#e0def4",
163
+ thread="#e0def4",
164
+ spore="#f6c177",
165
+ crystals=("#9ccfd8", "#f6c177"),
166
+ sun="#ebbcba",
167
+ accent="#eb6f92",
168
+ button="#f6c177",
169
+ button_alt="#9ccfd8",
170
+ ramp=(
171
+ "#191724",
172
+ "#1f1d2e",
173
+ "#26233a",
174
+ "#31748f",
175
+ "#9ccfd8",
176
+ "#c4a7e7",
177
+ "#ebbcba",
178
+ "#f6c177",
179
+ ),
180
+ swatch=("#191724", "#31748f", "#9ccfd8", "#c4a7e7", "#eb6f92", "#f6c177"),
181
+ ),
182
+ }
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class Appearance:
187
+ """What the landing looks like right now: the saved choices over the config."""
188
+
189
+ palette: PaletteName = "psychedelic"
190
+ background: Literal["builtin", "own"] = "builtin"
191
+ own_type: ImageType | None = None # set while an uploaded image exists
192
+ own_version: int = 0 # bumps on each upload, for cache busting
193
+ recolor: bool | None = None # None: on for the included painting, off for an upload
194
+ soil: int = BUILTIN_SOIL
195
+ headline: str = ""
196
+ tagline: str = ""
197
+ site_name: str = "" # empty: the config's site.title
198
+ title_font: TitleFont = "shrikhand"
199
+ text_font: TextFont = "system"
200
+ title_size: int = 100 # percent
201
+ text_size: int = 16 # px
202
+ graph_labels: int = 100 # percent
203
+ title_effect: TitleEffect = "poster"
204
+
205
+ @property
206
+ def uses_own(self) -> bool:
207
+ return self.background == "own" and self.own_type is not None
208
+
209
+ @property
210
+ def recolors(self) -> bool:
211
+ if self.recolor is not None:
212
+ return self.recolor
213
+ return not self.uses_own
214
+
215
+ @property
216
+ def colors(self) -> Palette:
217
+ return PALETTES[self.palette]
218
+
219
+
220
+ def palette_named(value: object, fallback: PaletteName) -> PaletteName:
221
+ """``value`` if it names a palette, else ``fallback``."""
222
+ return next((k for k in PALETTE_NAMES if k == value), fallback)
223
+
224
+
225
+ def in_range(value: object, low: int, high: int, fallback: int) -> int:
226
+ """``value`` if it is a whole number from ``low`` to ``high``, else ``fallback``."""
227
+ if isinstance(value, int) and not isinstance(value, bool) and low <= value <= high:
228
+ return value
229
+ return fallback
230
+
231
+
232
+ def title_font_named(value: object, fallback: TitleFont) -> TitleFont:
233
+ return next((k for k in TITLE_FONTS if k == value), fallback)
234
+
235
+
236
+ def text_font_named(value: object, fallback: TextFont) -> TextFont:
237
+ return next((k for k in TEXT_FONTS if k == value), fallback)
238
+
239
+
240
+ def title_effect_named(value: object, fallback: TitleEffect) -> TitleEffect:
241
+ return next((k for k in TITLE_EFFECTS if k == value), fallback)
242
+
243
+
244
+ def category_colors(config: Config, palette: Palette) -> dict[str, str]:
245
+ """Each category's color: its own ``color`` if set, else the palette's for its place."""
246
+ return {c.key: c.color or palette.slot(i) for i, c in enumerate(config.categories)}
247
+
248
+
249
+ # -- reading image headers ----------------------------------------------------------
250
+
251
+
252
+ class ImageError(ValueError):
253
+ """The upload is not an image we accept. ``code`` names the reason for the page."""
254
+
255
+ def __init__(self, code: str) -> None:
256
+ super().__init__(code)
257
+ self.code = code
258
+
259
+
260
+ def image_info(data: bytes) -> tuple[ImageType, int, int]:
261
+ """Type, width and height from the file's own bytes (never its name).
262
+
263
+ Raises ``ImageError("type")`` when it is not PNG, JPEG or WebP, or the header is broken.
264
+ """
265
+ if data.startswith(b"\x89PNG\r\n\x1a\n") and data[12:16] == b"IHDR":
266
+ width, height = struct.unpack(">II", data[16:24])
267
+ return "png", width, height
268
+ if data.startswith(b"\xff\xd8"):
269
+ return ("jpeg", *_jpeg_size(data))
270
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
271
+ return ("webp", *_webp_size(data))
272
+ raise ImageError("type")
273
+
274
+
275
+ def _jpeg_size(data: bytes) -> tuple[int, int]:
276
+ i = 2
277
+ while i + 9 < len(data):
278
+ if data[i] != 0xFF:
279
+ raise ImageError("type")
280
+ marker = data[i + 1]
281
+ if marker == 0xFF: # padding
282
+ i += 1
283
+ continue
284
+ if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
285
+ i += 2
286
+ continue
287
+ (length,) = struct.unpack(">H", data[i + 2 : i + 4])
288
+ # SOF0..SOF15 carry the size, except DHT (C4), JPG (C8) and DAC (CC).
289
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
290
+ height, width = struct.unpack(">HH", data[i + 5 : i + 9])
291
+ return width, height
292
+ i += 2 + length
293
+ raise ImageError("type")
294
+
295
+
296
+ def _webp_size(data: bytes) -> tuple[int, int]:
297
+ chunk = data[12:16]
298
+ if chunk == b"VP8X" and len(data) >= 30:
299
+ width = 1 + int.from_bytes(data[24:27], "little")
300
+ height = 1 + int.from_bytes(data[27:30], "little")
301
+ return width, height
302
+ if chunk == b"VP8 " and len(data) >= 30 and data[23:26] == b"\x9d\x01\x2a":
303
+ width, height = struct.unpack("<HH", data[26:30])
304
+ return width & 0x3FFF, height & 0x3FFF
305
+ if chunk == b"VP8L" and len(data) >= 25 and data[20] == 0x2F:
306
+ bits = int.from_bytes(data[21:25], "little")
307
+ return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
308
+ raise ImageError("type")
309
+
310
+
311
+ def check_upload(data: bytes) -> ImageType:
312
+ """Accept a background image or raise ``ImageError`` with ``empty``, ``size``,
313
+ ``type`` or ``small``."""
314
+ if not data:
315
+ raise ImageError("empty")
316
+ if len(data) > MAX_UPLOAD_BYTES:
317
+ raise ImageError("size")
318
+ kind, width, height = image_info(data)
319
+ if width < MIN_WIDTH or height < MIN_HEIGHT:
320
+ raise ImageError("small")
321
+ return kind
322
+
323
+
324
+ # -- the store ------------------------------------------------------------------------
325
+
326
+
327
+ _SCHEMA = """
328
+ CREATE TABLE IF NOT EXISTS settings (
329
+ key TEXT PRIMARY KEY,
330
+ value TEXT NOT NULL
331
+ );
332
+ """
333
+ _KEY = "appearance"
334
+
335
+
336
+ def clean_text(value: str) -> str:
337
+ """One line, no control characters, at most ``MAX_TEXT`` characters."""
338
+ text = "".join(ch if ch.isprintable() else " " for ch in value)
339
+ return " ".join(text.split())[:MAX_TEXT]
340
+
341
+
342
+ class AppearanceStore:
343
+ """The Appearance page's choices, in ``data/site.sqlite3`` and ``data/backgrounds/``."""
344
+
345
+ def __init__(self, config: Config) -> None:
346
+ self.config = config
347
+ self.folder = config.data.dir
348
+ self._lock = threading.Lock()
349
+ self._db: sqlite3.Connection | None = None
350
+
351
+ def _conn(self) -> sqlite3.Connection:
352
+ # Opened on first use, so a site nobody customizes never creates the file.
353
+ if self._db is None:
354
+ self.folder.mkdir(parents=True, exist_ok=True)
355
+ self._db = sqlite3.connect(
356
+ self.folder / SITE_DB, check_same_thread=False, isolation_level=None
357
+ )
358
+ self._db.executescript(_SCHEMA)
359
+ return self._db
360
+
361
+ def close(self) -> None:
362
+ with self._lock:
363
+ if self._db is not None:
364
+ self._db.close()
365
+ self._db = None
366
+
367
+ def _saved(self) -> dict[str, object]:
368
+ if self._db is None and not (self.folder / SITE_DB).is_file():
369
+ return {}
370
+ with self._lock:
371
+ row = (
372
+ self._conn().execute("SELECT value FROM settings WHERE key = ?", (_KEY,)).fetchone()
373
+ )
374
+ if row is None:
375
+ return {}
376
+ try:
377
+ loaded = json.loads(row[0])
378
+ except ValueError:
379
+ return {}
380
+ return loaded if isinstance(loaded, dict) else {}
381
+
382
+ def current(self) -> Appearance:
383
+ defaults = self.config.landing
384
+ type_defaults = self.config.typography
385
+ saved = self._saved()
386
+ palette = palette_named(saved.get("palette"), defaults.palette)
387
+ own_type = next((k for k in MEDIA_TYPES if k == saved.get("own_type")), None)
388
+ if own_type is not None and not self.own_path(own_type).is_file():
389
+ own_type = None
390
+ background: Literal["builtin", "own"] = (
391
+ "own" if saved.get("background") == "own" and own_type else "builtin"
392
+ )
393
+ recolor = saved.get("recolor")
394
+ soil = saved.get("soil", defaults.soil)
395
+ version = saved.get("own_version", 0)
396
+ return Appearance(
397
+ palette=palette,
398
+ background=background,
399
+ own_type=own_type,
400
+ own_version=version if isinstance(version, int) else 0,
401
+ recolor=recolor if isinstance(recolor, bool) else None,
402
+ soil=soil if isinstance(soil, int) and SOIL_MIN <= soil <= SOIL_MAX else defaults.soil,
403
+ headline=clean_text(str(saved.get("headline", ""))),
404
+ tagline=clean_text(str(saved.get("tagline", ""))),
405
+ site_name=clean_text(str(saved.get("site_name", "")))[:MAX_NAME],
406
+ title_font=title_font_named(saved.get("title_font"), type_defaults.title_font),
407
+ text_font=text_font_named(saved.get("text_font"), type_defaults.text_font),
408
+ title_size=in_range(
409
+ saved.get("title_size"), TITLE_SIZE_MIN, TITLE_SIZE_MAX, type_defaults.title_size
410
+ ),
411
+ text_size=in_range(
412
+ saved.get("text_size"), TEXT_SIZE_MIN, TEXT_SIZE_MAX, type_defaults.text_size
413
+ ),
414
+ graph_labels=in_range(
415
+ saved.get("graph_labels"),
416
+ LABEL_SIZE_MIN,
417
+ LABEL_SIZE_MAX,
418
+ type_defaults.graph_labels,
419
+ ),
420
+ title_effect=title_effect_named(saved.get("title_effect"), type_defaults.title_effect),
421
+ )
422
+
423
+ def save(self, appearance: Appearance) -> None:
424
+ data = asdict(appearance)
425
+ with self._lock:
426
+ self._conn().execute(
427
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
428
+ "ON CONFLICT (key) DO UPDATE SET value = excluded.value",
429
+ (_KEY, json.dumps(data)),
430
+ )
431
+
432
+ # -- the uploaded background --------------------------------------------------
433
+
434
+ def own_path(self, kind: str) -> Path:
435
+ return self.folder / BACKGROUNDS / f"background.{kind}"
436
+
437
+ def store_background(self, data: bytes) -> Appearance:
438
+ """Validate and keep ``data`` as the background, replacing any earlier upload."""
439
+ kind = check_upload(data)
440
+ folder = self.folder / BACKGROUNDS
441
+ folder.mkdir(parents=True, exist_ok=True)
442
+ target = self.own_path(kind)
443
+ partial = target.with_suffix(".part")
444
+ partial.write_bytes(data)
445
+ partial.replace(target)
446
+ for other in MEDIA_TYPES:
447
+ if other != kind:
448
+ self.own_path(other).unlink(missing_ok=True)
449
+ now = self.current()
450
+ updated = replace(
451
+ now,
452
+ background="own",
453
+ own_type=kind,
454
+ own_version=now.own_version + 1,
455
+ recolor=None,
456
+ )
457
+ self.save(updated)
458
+ return updated
459
+
460
+ def remove_background(self) -> Appearance:
461
+ for kind in MEDIA_TYPES:
462
+ self.own_path(kind).unlink(missing_ok=True)
463
+ updated = replace(self.current(), background="builtin", own_type=None, recolor=None)
464
+ self.save(updated)
465
+ return updated
@@ -0,0 +1,5 @@
1
+ """Single-user sign-in: argon2 password, TOTP code and server-side sessions."""
2
+
3
+ from micelio.auth.settings import AuthSettings, AuthSetupError, resolve_auth
4
+
5
+ __all__ = ["AuthSettings", "AuthSetupError", "resolve_auth"]
@@ -0,0 +1,27 @@
1
+ """Password hashing with argon2id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argon2 import PasswordHasher
6
+ from argon2.exceptions import InvalidHashError, VerificationError
7
+
8
+ _hasher = PasswordHasher()
9
+
10
+ MIN_LENGTH = 12
11
+
12
+
13
+ def hash_password(password: str) -> str:
14
+ if len(password) < MIN_LENGTH:
15
+ raise ValueError(f"use at least {MIN_LENGTH} characters")
16
+ return _hasher.hash(password)
17
+
18
+
19
+ def verify_password(password_hash: str, password: str) -> bool:
20
+ try:
21
+ return _hasher.verify(password_hash, password)
22
+ except (VerificationError, InvalidHashError):
23
+ return False
24
+
25
+
26
+ def is_valid_hash(password_hash: str) -> bool:
27
+ return password_hash.startswith("$argon2")