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.
- micelio/__init__.py +12 -0
- micelio/app.py +120 -0
- micelio/appearance.py +465 -0
- micelio/auth/__init__.py +5 -0
- micelio/auth/passwords.py +27 -0
- micelio/auth/settings.py +83 -0
- micelio/auth/store.py +109 -0
- micelio/auth/totp.py +68 -0
- micelio/auth/web.py +266 -0
- micelio/cli.py +227 -0
- micelio/config.py +282 -0
- micelio/formatting.py +45 -0
- micelio/graph.py +250 -0
- micelio/i18n.py +82 -0
- micelio/landing.py +175 -0
- micelio/landing_web.py +260 -0
- micelio/locales/en.toml +286 -0
- micelio/locales/es.toml +285 -0
- micelio/pages.py +316 -0
- micelio/py.typed +0 -0
- micelio/render.py +183 -0
- micelio/static/css/appearance.css +76 -0
- micelio/static/css/fonts.css +35 -0
- micelio/static/css/graph.css +162 -0
- micelio/static/css/landing.css +174 -0
- micelio/static/css/micelio.css +330 -0
- micelio/static/img/psychedelic-dusk.webp +0 -0
- micelio/static/js/appearance.js +115 -0
- micelio/static/js/backdrop.js +14 -0
- micelio/static/js/draw.js +300 -0
- micelio/static/js/explore.js +96 -0
- micelio/static/js/graph-embed.js +4 -0
- micelio/static/js/graph.js +536 -0
- micelio/static/js/landing.js +307 -0
- micelio/static/js/specimens.js +42 -0
- micelio/static/vendor/README.md +33 -0
- micelio/static/vendor/d3-force/LICENSE +13 -0
- micelio/static/vendor/d3-force/d3-force.bundle.min.js +8 -0
- micelio/static/vendor/fonts/atkinson-hyperlegible/OFL.txt +93 -0
- micelio/static/vendor/fonts/atkinson-hyperlegible/atkinson-hyperlegible-latin-400.woff2 +0 -0
- micelio/static/vendor/fonts/atkinson-hyperlegible/atkinson-hyperlegible-latin-700.woff2 +0 -0
- micelio/static/vendor/fonts/bagel-fat-one/OFL.txt +93 -0
- micelio/static/vendor/fonts/bagel-fat-one/bagel-fat-one-latin-400.woff2 +0 -0
- micelio/static/vendor/fonts/fraunces/OFL.txt +93 -0
- micelio/static/vendor/fonts/fraunces/fraunces-latin-900.woff2 +0 -0
- micelio/static/vendor/fonts/literata/OFL.txt +93 -0
- micelio/static/vendor/fonts/literata/literata-latin-400.woff2 +0 -0
- micelio/static/vendor/fonts/literata/literata-latin-700.woff2 +0 -0
- micelio/static/vendor/fonts/righteous/OFL.txt +93 -0
- micelio/static/vendor/fonts/righteous/righteous-latin-400.woff2 +0 -0
- micelio/static/vendor/fonts/shrikhand/OFL.txt +93 -0
- micelio/static/vendor/fonts/shrikhand/shrikhand-latin.woff2 +0 -0
- micelio/static/vendor/fonts/space-grotesk/OFL.txt +93 -0
- micelio/static/vendor/fonts/space-grotesk/space-grotesk-latin-400.woff2 +0 -0
- micelio/static/vendor/fonts/space-grotesk/space-grotesk-latin-700.woff2 +0 -0
- micelio/templates/_macros.html +31 -0
- micelio/templates/appearance.html +184 -0
- micelio/templates/base.html +58 -0
- micelio/templates/explore.html +87 -0
- micelio/templates/health.html +45 -0
- micelio/templates/journal.html +20 -0
- micelio/templates/landing.html +67 -0
- micelio/templates/login.html +20 -0
- micelio/templates/not_found.html +12 -0
- micelio/templates/note.html +51 -0
- micelio/templates/project.html +58 -0
- micelio/templates/projects.html +35 -0
- micelio/vault/__init__.py +17 -0
- micelio/vault/index.py +377 -0
- micelio/vault/parser.py +114 -0
- micelio/vault/public.py +66 -0
- micelio-0.1.0.dist-info/METADATA +246 -0
- micelio-0.1.0.dist-info/RECORD +76 -0
- micelio-0.1.0.dist-info/WHEEL +4 -0
- micelio-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
micelio/auth/__init__.py
ADDED
|
@@ -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")
|