···20# init the db with dev presets
21RUN python3 -m millipds init millipds.test --dev
2200023# do the thing
24CMD python3 -m millipds run --listen_host=0.0.0.0 --listen_port=8123
25
···20# init the db with dev presets
21RUN python3 -m millipds init millipds.test --dev
2223+# create a test user
24+RUN python3 -m millipds account create bob.test did:web:bob.test --unsafe_password=hunter2
25+26# do the thing
27CMD python3 -m millipds run --listen_host=0.0.0.0 --listen_port=8123
28
+41-2
src/millipds/__main__.py
···3Usage:
4 millipds init <hostname> [--dev|--sandbox]
5 millipds config [--pds_pfx=URL] [--pds_did=DID] [--bsky_appview_pfx=URL] [--bsky_appview_did=DID]
06 millipds run [--sock_path=PATH] [--listen_host=HOST] [--listen_port=PORT]
7 millipds (-h | --help)
8 millipds --version
···19 Any options not specified will be left at their previous values. Once changes
20 have been made (or even if they haven't), the new config will be printed.
210022 --pds_pfx=URL The HTTP URL prefix that this PDS is publicly accessible at (e.g. mypds.example)
23 --pds_did=DID This PDS's DID (e.g. did:web:mypds.example)
24 --bsky_appview_pfx=URL AppView URL prefix e.g. "https://api.bsky-sandbox.dev"
25 --bsky_appview_did=DID AppView DID e.g. did:web:api.bsky-sandbox.dev
2600000000027Run:
28 Launch the service (in the foreground)
29···36 --version Show version.
37"""
3839-from docopt import docopt
40import importlib.metadata
41import asyncio
000004243from . import service
44from . import database
000004546"""
47This is the entrypoint for the `millipds` command (declared in project.scripts)
···75 bsky_appview_pfx="https://api.bsky-sandbox.dev",
76 bsky_appview_did="did:web:api.bsky-sandbox.dev",
77 )
78- else:
79 db.update_config(
80 pds_pfx=f'https://{args["<hostname>"]}',
81 pds_did=f'did:web:{args["<hostname>"]}',
···98 bsky_appview_did=args["--bsky_appview_did"],
99 )
100 db.print_config()
000000000000000000101 elif args["run"]:
102 asyncio.run(service.run(
103 sock_path=args["--sock_path"],
···3Usage:
4 millipds init <hostname> [--dev|--sandbox]
5 millipds config [--pds_pfx=URL] [--pds_did=DID] [--bsky_appview_pfx=URL] [--bsky_appview_did=DID]
6+ millipds account create <did> <handle> [--unsafe_password=PW]
7 millipds run [--sock_path=PATH] [--listen_host=HOST] [--listen_port=PORT]
8 millipds (-h | --help)
9 millipds --version
···20 Any options not specified will be left at their previous values. Once changes
21 have been made (or even if they haven't), the new config will be printed.
2223+ Do not change the config while the PDS is running (TODO: enforce this in code (or make sure it's harmless?))
24+25 --pds_pfx=URL The HTTP URL prefix that this PDS is publicly accessible at (e.g. mypds.example)
26 --pds_did=DID This PDS's DID (e.g. did:web:mypds.example)
27 --bsky_appview_pfx=URL AppView URL prefix e.g. "https://api.bsky-sandbox.dev"
28 --bsky_appview_did=DID AppView DID e.g. did:web:api.bsky-sandbox.dev
2930+Account create:
31+ Create a new user account on the PDS. Bring your own DID and corresponding
32+ handle - millipds will not (yet?) attempt to validate either.
33+ You'll be prompted for a password interactively.
34+35+ TODO: consider bring-your-own signing key?
36+37+ --unsafe_password=PW Specify password non-iteractively, for use in test scripts etc.
38+39Run:
40 Launch the service (in the foreground)
41···48 --version Show version.
49"""
50051import importlib.metadata
52import asyncio
53+import sys
54+import logging
55+from getpass import getpass
56+57+from docopt import docopt
5859from . import service
60from . import database
61+from . import crypto
62+63+64+logging.basicConfig(level=logging.DEBUG) # TODO: make this configurable?
65+6667"""
68This is the entrypoint for the `millipds` command (declared in project.scripts)
···96 bsky_appview_pfx="https://api.bsky-sandbox.dev",
97 bsky_appview_did="did:web:api.bsky-sandbox.dev",
98 )
99+ else: # "prod" presets
100 db.update_config(
101 pds_pfx=f'https://{args["<hostname>"]}',
102 pds_did=f'did:web:{args["<hostname>"]}',
···119 bsky_appview_did=args["--bsky_appview_did"],
120 )
121 db.print_config()
122+ elif args["account"]:
123+ if args["create"]:
124+ pw = args["--unsafe_password"]
125+ if pw:
126+ # rationale: only allow non-iteractive password input from scripts etc.
127+ if sys.stdin.buffer.isatty():
128+ print("error: --unsafe_password can't be used from an interactive shell")
129+ return
130+ else:
131+ pw = getpass(f"Password for new account: ")
132+ db.account_create(
133+ did=args["<did>"],
134+ handle=args["<handle>"],
135+ password=pw,
136+ privkey=crypto.keygen_p256() # TODO: supply from arg
137+ )
138+ else:
139+ print("CLI arg parse error?!")
140 elif args["run"]:
141 asyncio.run(service.run(
142 sock_path=args["--sock_path"],
···1"""
2-Ideally, all SQL statements are contained within this file
0003"""
45from typing import Optional, Dict
6from functools import cached_property
7import secrets
00809import apsw
0001011from . import static_config
12from . import util
0000001314class Database:
15 def __init__(self, path: str=static_config.MAIN_DB_PATH) -> None:
16 util.mkdirs_for_file(path)
17 self.con = apsw.Connection(path)
01819 try:
20 if self.config["db_version"] != static_config.MILLIPDS_DB_VERSION:
···27 self._init_central_tables()
2829 def _init_central_tables(self):
030 self.con.execute(
31 """
32 CREATE TABLE config(
···4243 self.con.execute(
44 """
45- INSERT INTO config (
46 db_version,
47 jwt_access_secret
48 ) VALUES (?, ?)
···54 """
55 CREATE TABLE user(
56 did TEXT PRIMARY KEY NOT NULL,
057 prefs BLOB NOT NULL,
58 pw_hash TEXT NOT NULL,
59 repo_path TEXT NOT NULL,
···61 )
62 """
63 )
006465 self.con.execute(
66 """
···84 if pds_did is not None:
85 self.con.execute("UPDATE config SET pds_did=?", (pds_did,))
86 if bsky_appview_pfx is not None:
87- self.con.execute("UPDATE config SET bsky_appview_pfx=?", (bsky_appview_pfx,))
00088 if bsky_appview_did is not None:
89- self.con.execute("UPDATE config SET bsky_appview_did=?", (bsky_appview_did,))
0009091- del self.config # invalidate the cached value
0009293 @cached_property
94 def config(self) -> Dict[str, object]:
···117 if redact_secrets and "secret" in k:
118 v = "[REDACTED]"
119 print(f"{k:<{maxlen}} : {v!r}")
00000000000000000000000000000000000000000000000000000000000000000
···1"""
2+Ideally, all SQL statements are contained within this file.
3+4+Password hashing also happens in here, because it doesn't make much sense to do
5+it anywhere else.
6"""
78from typing import Optional, Dict
9from functools import cached_property
10import secrets
11+import os
12+import logging
1314+from argon2 import PasswordHasher # maybe this should come from .crypto?
15import apsw
16+import apsw.bestpractice
17+18+from atmst.blockstore import BlockStore
1920from . import static_config
21from . import util
22+from . import crypto
23+24+logger = logging.getLogger(__name__)
25+26+# https://rogerbinns.github.io/apsw/bestpractice.html
27+apsw.bestpractice.apply(apsw.bestpractice.recommended)
2829class Database:
30 def __init__(self, path: str=static_config.MAIN_DB_PATH) -> None:
31 util.mkdirs_for_file(path)
32 self.con = apsw.Connection(path)
33+ self.pw_hasher = PasswordHasher()
3435 try:
36 if self.config["db_version"] != static_config.MILLIPDS_DB_VERSION:
···43 self._init_central_tables()
4445 def _init_central_tables(self):
46+ logger.info("initing central tables")
47 self.con.execute(
48 """
49 CREATE TABLE config(
···5960 self.con.execute(
61 """
62+ INSERT INTO config(
63 db_version,
64 jwt_access_secret
65 ) VALUES (?, ?)
···71 """
72 CREATE TABLE user(
73 did TEXT PRIMARY KEY NOT NULL,
74+ handle TEXT NOT NULL,
75 prefs BLOB NOT NULL,
76 pw_hash TEXT NOT NULL,
77 repo_path TEXT NOT NULL,
···79 )
80 """
81 )
82+83+ self.con.execute("CREATE UNIQUE INDEX user_by_handle ON user(handle)")
8485 self.con.execute(
86 """
···104 if pds_did is not None:
105 self.con.execute("UPDATE config SET pds_did=?", (pds_did,))
106 if bsky_appview_pfx is not None:
107+ self.con.execute(
108+ "UPDATE config SET bsky_appview_pfx=?",
109+ (bsky_appview_pfx,)
110+ )
111 if bsky_appview_did is not None:
112+ self.con.execute(
113+ "UPDATE config SET bsky_appview_did=?",
114+ (bsky_appview_did,)
115+ )
116117+ try:
118+ del self.config # invalidate the cached value
119+ except AttributeError:
120+ pass
121122 @cached_property
123 def config(self) -> Dict[str, object]:
···146 if redact_secrets and "secret" in k:
147 v = "[REDACTED]"
148 print(f"{k:<{maxlen}} : {v!r}")
149+150+ def account_create(self,
151+ did: str,
152+ handle: str,
153+ password: str,
154+ privkey: crypto.ec.EllipticCurvePrivateKey
155+ ) -> None:
156+ pw_hash = self.pw_hasher.hash(password)
157+ privkey_pem = crypto.privkey_to_pem(privkey)
158+ repo_path = f"{static_config.REPOS_DIR}/{util.did_to_safe_filename(did)}.sqlite3"
159+ logger.info(
160+ f"creating account for did={did}, handle={handle} at {repo_path}"
161+ )
162+ with self.con:
163+ self.con.execute(
164+ """
165+ INSERT INTO user(
166+ did,
167+ handle,
168+ prefs,
169+ pw_hash,
170+ repo_path,
171+ signing_key
172+ ) VALUES (?, ?, ?, ?, ?, ?)
173+ """,
174+ (did, handle, b"{}", pw_hash, repo_path, privkey_pem)
175+ )
176+ UserDatabase.init_tables(self.con, did, repo_path)
177+ self.con.execute("DETACH spoke")
178+179+180+class UserDBBlockStore(BlockStore):
181+ pass # TODO
182+183+184+class UserDatabase:
185+ def __init__(self, wcon: apsw.Connection, did: str, path: str) -> None:
186+ self.wcon = wcon # writes go via the hub database connection, using ATTACH
187+ self.rcon = apsw.Connection(path, flags=apsw.SQLITE_OPEN_READONLY)
188+189+ # TODO: check db version and did match
190+191+ @staticmethod
192+ def init_tables(wcon: apsw.Connection, did: str, path: str) -> None:
193+ util.mkdirs_for_file(path)
194+ wcon.execute("ATTACH ? AS spoke", (path,))
195+196+ wcon.execute(
197+ """
198+ CREATE TABLE spoke.repo(
199+ db_version INTEGER NOT NULL,
200+ did TEXT NOT NULL
201+ )
202+ """
203+ )
204+205+ wcon.execute(
206+ "INSERT INTO spoke.repo(db_version, did) VALUES (?, ?)",
207+ (static_config.MILLIPDS_DB_VERSION, did)
208+ )
209+210+ # TODO: the other tables
211+212+ # nb: caller is responsible for running "DETACH spoke", after the end
213+ # of the transaction
+1-3
src/millipds/service.py
···910from . import static_config
1112-logging.basicConfig(level=logging.DEBUG) # TODO: make this configurable?
13-1415async def hello(request: web.Request):
16 version = importlib.metadata.version("millipds")
···46This gets invoked via millipds.__main__.py
47"""
48async def run(sock_path: Optional[str], host: str, port: int):
49- runner = web.AppRunner(app, access_log_format=static_config.LOG_FMT)
50 await runner.setup()
5152 if sock_path is None:
···910from . import static_config
11001213async def hello(request: web.Request):
14 version = importlib.metadata.version("millipds")
···44This gets invoked via millipds.__main__.py
45"""
46async def run(sock_path: Optional[str], host: str, port: int):
47+ runner = web.AppRunner(app, access_log_format=static_config.HTTP_LOG_FMT)
48 await runner.setup()
4950 if sock_path is None:
+4-4
src/millipds/static_config.py
···4(some of this stuff might want to be broken out into a proper config file, eventually)
5"""
67-LOG_FMT = '%{X-Forwarded-For}i %t (%Tf) "%r" %s %b "%{Referer}i" "%{User-Agent}i"'
89GROUPNAME = "millipds-sock"
1011MILLIPDS_DB_VERSION = 1 # this gets bumped if we make breaking changes to the db schema
1213-DATA_DIR = "./data/"
14-MAIN_DB_PATH = DATA_DIR + "millipds.sqlite3"
15-REPOS_DIR = DATA_DIR + "repos/"
···4(some of this stuff might want to be broken out into a proper config file, eventually)
5"""
67+HTTP_LOG_FMT = '%{X-Forwarded-For}i %t (%Tf) "%r" %s %b "%{Referer}i" "%{User-Agent}i"'
89GROUPNAME = "millipds-sock"
1011MILLIPDS_DB_VERSION = 1 # this gets bumped if we make breaking changes to the db schema
1213+DATA_DIR = "./data"
14+MAIN_DB_PATH = DATA_DIR + "/millipds.sqlite3"
15+REPOS_DIR = DATA_DIR + "/repos"
···1import os
2+import hashlib
34def mkdirs_for_file(path: str) -> None:
5 os.makedirs(os.path.dirname(path), exist_ok=True)
6+7+FILANEME_SAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
8+9+def did_to_safe_filename(did: str) -> str:
10+ """
11+ The format is <sha256(did)>_<filtered_did>
12+ The former guarantees uniqueness, and the latter makes it human-recognizeable (ish)
13+ """
14+15+ hexdigest = hashlib.sha256(did.encode()).hexdigest()
16+ filtered = "".join(char for char in did if char in FILANEME_SAFE_CHARS)
17+18+ # Truncate to make sure we're staying within PATH_MAX
19+ # (with room to spare, in case the caller appends a file extension)
20+ return f"{hexdigest}_{filtered}"[:200]