social media crossposting tool. 3rd time's the charm
mastodon misskey crossposting bluesky

add pds auth with refereshing, move caches to sqlite, use slingshot instead of manually resolving handles/dids

zenfyr.dev eef826f3 e14e6786

verified
+459 -179
+167
atproto/auth.py
··· 1 + import requests 2 + 3 + import env 4 + from atproto.store import AtprotoStore, IdentityInfo, Session 5 + from util.util import LOGGER, normalize_service_url, shutdown_hook 6 + 7 + 8 + class PDSAuth: 9 + def __init__(self, pds_url: str, store: AtprotoStore) -> None: 10 + self.pds_url: str = normalize_service_url(pds_url) 11 + self.store = store 12 + 13 + def login(self, identifier: str, password: str) -> Session: 14 + cached = self.store.get_session_by_pds(self.pds_url, identifier) 15 + if cached: 16 + LOGGER.info("Using cached session for %s", identifier) 17 + return cached 18 + return self.create_session(identifier, password) 19 + 20 + def create_session( 21 + self, 22 + identifier: str, 23 + password: str, 24 + auth_factor_token: str | None = None, 25 + ) -> Session: 26 + url = f"{self.pds_url}/xrpc/com.atproto.server.createSession" 27 + payload: dict[str, str] = { 28 + "identifier": identifier, 29 + "password": password, 30 + } 31 + if auth_factor_token: 32 + payload["authFactorToken"] = auth_factor_token 33 + 34 + response = requests.post(url, json=payload, timeout=30) 35 + 36 + match response.status_code: 37 + case 200: 38 + pass 39 + case 401: 40 + raise ValueError("Invalid identifier or password") 41 + case 400: 42 + raise ValueError(f"Authentication failed: {response.json()}") 43 + case _: 44 + raise ValueError(f"Authentication failed with status {response.status_code}") 45 + 46 + data = response.json() 47 + session = Session.from_dict(data, self.pds_url) 48 + self.store.set_session(session) 49 + LOGGER.info("Created session for %s (%s)", session.handle, session.did) 50 + return session 51 + 52 + def refresh_session(self, session: Session) -> Session: 53 + url = f"{self.pds_url}/xrpc/com.atproto.server.refreshSession" 54 + headers = {"Authorization": f"Bearer {session.refresh_jwt}"} 55 + 56 + response = requests.post(url, headers=headers, timeout=30) 57 + 58 + match response.status_code: 59 + case 200: 60 + pass 61 + case 401: 62 + error_data = response.json() if response.content else {} 63 + raise ValueError(f"Refresh failed: {error_data}") 64 + case 400: 65 + error_data = response.json() 66 + raise ValueError(f"Refresh failed: {error_data}") 67 + case _: 68 + raise ValueError(f"Refresh failed with status {response.status_code}") 69 + 70 + data = response.json() 71 + new_session = Session.from_dict(data, self.pds_url) 72 + self.store.set_session(new_session) 73 + LOGGER.info("Refreshed session for %s (%s)", new_session.handle, new_session.did) 74 + return new_session 75 + 76 + def get_session(self, did: str) -> Session | None: 77 + return self.store.get_session(did) 78 + 79 + def get_access_token(self, did: str) -> str | None: 80 + session = self.get_session(did) 81 + if not session: 82 + return None 83 + return session.access_jwt 84 + 85 + def list_sessions(self) -> list[Session]: 86 + return self.store.list_sessions_by_pds(self.pds_url) 87 + 88 + def remove_session(self, did: str) -> None: 89 + self.store.remove_session(did) 90 + 91 + 92 + _auth_instances: dict[str, PDSAuth] = None # type: ignore 93 + _store: AtprotoStore | None = None 94 + 95 + 96 + def init_atproto_store(db) -> AtprotoStore: 97 + global _store, _auth_instances 98 + if _store is None: 99 + _store = AtprotoStore(db.get_conn()) 100 + _auth_instances = {} 101 + return _store 102 + 103 + 104 + def get_atproto_store() -> AtprotoStore | None: 105 + return _store 106 + 107 + 108 + def get_auth(pds_url: str) -> PDSAuth: 109 + if _store is None: 110 + raise RuntimeError("AtprotoStore not initialized. Call init_atproto_store first.") 111 + normalized = normalize_service_url(pds_url) 112 + if normalized not in _auth_instances: 113 + _auth_instances[normalized] = PDSAuth(normalized, _store) 114 + return _auth_instances[normalized] 115 + 116 + 117 + def get_auth_by_did(did: str) -> PDSAuth | None: 118 + if _store is None: 119 + return None 120 + for auth in _auth_instances.values(): 121 + if auth.get_session(did): 122 + return auth 123 + return None 124 + 125 + 126 + def cleanup_expired() -> None: 127 + if _store is not None: 128 + _store.cleanup_expired() 129 + 130 + 131 + def flush_caches() -> tuple[int, int]: 132 + if _store is not None: 133 + return _store.flush_all() 134 + return 0, 0 135 + 136 + 137 + def resolve_identity(identifier: str) -> IdentityInfo: 138 + if _store is None: 139 + raise RuntimeError("AtprotoStore not initialized") 140 + 141 + cached = _store.get_identity(identifier) 142 + if cached: 143 + LOGGER.info("Using cached identity for %s", identifier) 144 + return cached 145 + 146 + url = f"{env.SLINGSHOT_URL}/xrpc/com.bad-example.identity.resolveMiniDoc" 147 + try: 148 + response = requests.get(url, params={"identifier": identifier}, timeout=10) 149 + match response.status_code: 150 + case 200: 151 + identity = IdentityInfo.from_dict(response.json()) 152 + _store.set_identity(identifier, identity) 153 + return identity 154 + case 404: 155 + raise ValueError(f"Identity not found: {identifier}") 156 + case _: 157 + raise ValueError(f"Slingshot returned status {response.status_code} for {identifier}") 158 + except requests.RequestException as e: 159 + raise ValueError(f"Failed to resolve identity {identifier}: {e}") from e 160 + 161 + 162 + def _cleanup_hook(): 163 + if _store: 164 + cleanup_expired() 165 + 166 + 167 + shutdown_hook.append(_cleanup_hook)
-170
atproto/identity.py
··· 1 - from pathlib import Path 2 - from typing import Any, override 3 - 4 - import dns.resolver 5 - import requests 6 - 7 - import env 8 - from util.cache import Cacheable, TTLCache 9 - from util.util import LOGGER, normalize_service_url, shutdown_hook 10 - 11 - 12 - class DidDocument: 13 - def __init__(self, raw_doc: dict[str, Any]) -> None: 14 - self.raw: dict[str, Any] = raw_doc 15 - self.atproto_pds: str | None = None 16 - 17 - def get_atproto_pds(self) -> str | None: 18 - if self.atproto_pds: 19 - return self.atproto_pds 20 - 21 - services = self.raw.get("service") 22 - if not services: 23 - return None 24 - 25 - for service in services: 26 - if ( 27 - service.get("id") == "#atproto_pds" 28 - and service.get("type") == "AtprotoPersonalDataServer" 29 - ): 30 - endpoint = service.get("serviceEndpoint") 31 - if endpoint: 32 - url = normalize_service_url(endpoint) 33 - self.atproto_pds = url 34 - return url 35 - self.atproto_pds = "" 36 - return None 37 - 38 - 39 - class DidResolver(Cacheable): 40 - def __init__(self, plc_host: str) -> None: 41 - self.plc_host: str = plc_host 42 - self.__cache: TTLCache[str, DidDocument] = TTLCache(ttl_seconds=12 * 60 * 60) 43 - 44 - def try_resolve_plc(self, did: str) -> DidDocument | None: 45 - url = f"{self.plc_host}/{did}" 46 - response = requests.get(url, timeout=10, allow_redirects=True) 47 - 48 - if response.status_code == 200: 49 - return DidDocument(response.json()) 50 - elif response.status_code == 404 or response.status_code == 410: 51 - return None # tombstone or not registered 52 - else: 53 - response.raise_for_status() 54 - return None 55 - 56 - def try_resolve_web(self, did: str) -> DidDocument | None: 57 - url = f"http://{did[len('did:web:') :]}/.well-known/did.json" 58 - response = requests.get(url, timeout=10, allow_redirects=True) 59 - 60 - if response.status_code == 200: 61 - return DidDocument(response.json()) 62 - elif response.status_code == 404 or response.status_code == 410: 63 - return None # tombstone or gone 64 - else: 65 - response.raise_for_status() 66 - return None 67 - 68 - def resolve_did(self, did: str) -> DidDocument: 69 - cached = self.__cache.get(did) 70 - if cached: 71 - return cached 72 - 73 - if did.startswith("did:plc:"): 74 - from_plc = self.try_resolve_plc(did) 75 - if from_plc: 76 - self.__cache.set(did, from_plc) 77 - return from_plc 78 - elif did.startswith("did:web:"): 79 - from_web = self.try_resolve_web(did) 80 - if from_web: 81 - self.__cache.set(did, from_web) 82 - return from_web 83 - raise Exception(f"Failed to resolve {did}!") 84 - return None # mypy 85 - 86 - @override 87 - def dump_cache(self, path: Path): 88 - self.__cache.dump_cache(path) 89 - 90 - @override 91 - def load_cache(self, path: Path): 92 - self.__cache.load_cache(path) 93 - 94 - class HandleResolver(Cacheable): 95 - def __init__(self) -> None: 96 - self.__cache: TTLCache[str, str] = TTLCache(ttl_seconds=12 * 60 * 60) 97 - 98 - def try_resolve_dns(self, handle: str) -> str | None: 99 - try: 100 - dns_query = f"_atproto.{handle}" 101 - answers = dns.resolver.resolve(dns_query, "TXT") 102 - 103 - for rdata in answers: 104 - for txt_data in rdata.strings: 105 - did = txt_data.decode("utf-8").strip() 106 - if did.startswith("did="): 107 - return str(did[4:]) 108 - except dns.resolver.NXDOMAIN: 109 - LOGGER.debug(f"DNS record not found for _atproto.{handle}") 110 - return None 111 - except dns.resolver.NoAnswer: 112 - LOGGER.debug(f"No TXT records found for _atproto.{handle}") 113 - return None 114 - return None 115 - 116 - def try_resolve_http(self, handle: str) -> str | None: 117 - url = f"http://{handle}/.well-known/atproto-did" 118 - response = requests.get(url, timeout=10, allow_redirects=True) 119 - 120 - if response.status_code == 200: 121 - did = str(response.text.strip()) 122 - if did.startswith("did:"): 123 - return did 124 - else: 125 - raise ValueError(f"Got invalid did: from {url} = {did}!") 126 - else: 127 - response.raise_for_status() 128 - return None 129 - 130 - def resolve_handle(self, handle: str) -> str: 131 - cached = self.__cache.get(handle) 132 - if cached: 133 - return cached 134 - 135 - from_dns = self.try_resolve_dns(handle) 136 - if from_dns: 137 - self.__cache.set(handle, from_dns) 138 - return from_dns 139 - 140 - from_http = self.try_resolve_http(handle) 141 - if from_http: 142 - self.__cache.set(handle, from_http) 143 - return from_http 144 - 145 - raise Exception(f"Failed to resolve handle {handle}!") 146 - return None # mypy 147 - 148 - @override 149 - def dump_cache(self, path: Path): 150 - self.__cache.dump_cache(path) 151 - 152 - @override 153 - def load_cache(self, path: Path): 154 - self.__cache.load_cache(path) 155 - 156 - 157 - handle_resolver = HandleResolver() 158 - did_resolver = DidResolver(env.PLC_HOST) 159 - 160 - did_cache = env.CACHE_DIR.joinpath('did.cache') 161 - handle_cache = env.CACHE_DIR.joinpath('handle.cache') 162 - 163 - did_resolver.load_cache(did_cache) 164 - handle_resolver.load_cache(handle_cache) 165 - 166 - def cache_dump(): 167 - did_resolver.dump_cache(did_cache) 168 - handle_resolver.dump_cache(handle_cache) 169 - 170 - shutdown_hook.append(cache_dump)
+199
atproto/store.py
··· 1 + import sqlite3 2 + import time 3 + from dataclasses import dataclass 4 + from typing import Any 5 + 6 + 7 + @dataclass 8 + class Session: 9 + access_jwt: str 10 + refresh_jwt: str 11 + handle: str 12 + did: str 13 + pds: str 14 + email: str | None = None 15 + email_confirmed: bool = False 16 + email_auth_factor: bool = False 17 + active: bool = True 18 + status: str | None = None 19 + 20 + @classmethod 21 + def from_row(cls, row: sqlite3.Row) -> "Session": 22 + return cls( 23 + access_jwt=row["access_jwt"], 24 + refresh_jwt=row["refresh_jwt"], 25 + handle=row["handle"], 26 + did=row["did"], 27 + pds=row["pds"], 28 + email=row["email"], 29 + email_confirmed=bool(row["email_confirmed"]), 30 + email_auth_factor=bool(row["email_auth_factor"]), 31 + active=bool(row["active"]), 32 + status=row["status"], 33 + ) 34 + 35 + @classmethod 36 + def from_dict(cls, data: dict[str, Any], pds: str) -> "Session": 37 + return cls( 38 + access_jwt=data["accessJwt"], 39 + refresh_jwt=data["refreshJwt"], 40 + handle=data["handle"], 41 + did=data["did"], 42 + pds=pds, 43 + email=data.get("email"), 44 + email_confirmed=data.get("emailConfirmed", False), 45 + email_auth_factor=data.get("emailAuthFactor", False), 46 + active=data.get("active", True), 47 + status=data.get("status"), 48 + ) 49 + 50 + 51 + @dataclass 52 + class IdentityInfo: 53 + did: str 54 + handle: str 55 + pds: str 56 + signing_key: str 57 + 58 + @classmethod 59 + def from_row(cls, row: sqlite3.Row) -> "IdentityInfo": 60 + return cls( 61 + did=row["did"], 62 + handle=row["handle"], 63 + pds=row["pds"], 64 + signing_key=row["signing_key"], 65 + ) 66 + 67 + @classmethod 68 + def from_dict(cls, data: dict[str, Any]) -> "IdentityInfo": 69 + return cls( 70 + did=data["did"], 71 + handle=data["handle"], 72 + pds=data["pds"], 73 + signing_key=data["signing_key"], 74 + ) 75 + 76 + 77 + class AtprotoStore: 78 + def __init__( 79 + self, 80 + db: sqlite3.Connection, 81 + session_ttl: int = 2 * 60 * 60, 82 + identity_ttl: int = 12 * 60 * 60, 83 + ) -> None: 84 + self.db = db 85 + self.db.row_factory = sqlite3.Row 86 + self.session_ttl = session_ttl 87 + self.identity_ttl = identity_ttl 88 + 89 + def get_session(self, did: str) -> Session | None: 90 + row = self.db.execute( 91 + "SELECT * FROM atproto_sessions WHERE did = ? AND created_at + ? > ?", 92 + (did, self.session_ttl, time.time()) 93 + ).fetchone() 94 + if not row: 95 + return None 96 + return Session.from_row(row) 97 + 98 + def set_session(self, session: Session) -> None: 99 + now = time.time() 100 + self.db.execute(""" 101 + INSERT OR REPLACE INTO atproto_sessions 102 + (did, pds, handle, access_jwt, refresh_jwt, email, email_confirmed, 103 + email_auth_factor, active, status, created_at) 104 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 105 + """, ( 106 + session.did, 107 + session.pds, 108 + session.handle, 109 + session.access_jwt, 110 + session.refresh_jwt, 111 + session.email, 112 + session.email_confirmed, 113 + session.email_auth_factor, 114 + session.active, 115 + session.status, 116 + now, 117 + )) 118 + self.db.commit() 119 + 120 + def get_session_by_pds(self, pds: str, identifier: str) -> Session | None: 121 + row = self.db.execute(""" 122 + SELECT * FROM atproto_sessions 123 + WHERE pds = ? AND (did = ? OR handle = ?) AND created_at + ? > ? 124 + """, (pds, identifier, identifier, self.session_ttl, time.time())).fetchone() 125 + if not row: 126 + return None 127 + return Session.from_row(row) 128 + 129 + def list_sessions_by_pds(self, pds: str) -> list[Session]: 130 + rows = self.db.execute(""" 131 + SELECT * FROM atproto_sessions 132 + WHERE pds = ? AND created_at + ? > ? 133 + """, (pds, self.session_ttl, time.time())).fetchall() 134 + return [Session.from_row(row) for row in rows] 135 + 136 + def remove_session(self, did: str) -> None: 137 + self.db.execute("DELETE FROM atproto_sessions WHERE did = ?", (did,)) 138 + self.db.commit() 139 + 140 + def cleanup_expired_sessions(self) -> int: 141 + cutoff = time.time() - self.session_ttl 142 + cursor = self.db.execute( 143 + "DELETE FROM atproto_sessions WHERE created_at + ? < ?", 144 + (self.session_ttl, cutoff) 145 + ) 146 + self.db.commit() 147 + return cursor.rowcount 148 + 149 + def get_identity(self, identifier: str) -> IdentityInfo | None: 150 + row = self.db.execute( 151 + "SELECT * FROM atproto_identities WHERE identifier = ? AND created_at + ? > ?", 152 + (identifier, self.identity_ttl, time.time()) 153 + ).fetchone() 154 + if not row: 155 + return None 156 + return IdentityInfo.from_row(row) 157 + 158 + def set_identity(self, identifier: str, identity: IdentityInfo) -> None: 159 + now = time.time() 160 + for key in (identifier, identity.did, identity.handle): 161 + self.db.execute(""" 162 + INSERT OR REPLACE INTO atproto_identities 163 + (identifier, did, handle, pds, signing_key, created_at) 164 + VALUES (?, ?, ?, ?, ?, ?) 165 + """, ( 166 + key, 167 + identity.did, 168 + identity.handle, 169 + identity.pds, 170 + identity.signing_key, 171 + now, 172 + )) 173 + self.db.commit() 174 + 175 + def remove_identity(self, identifier: str) -> None: 176 + self.db.execute("DELETE FROM atproto_identities WHERE identifier = ?", (identifier,)) 177 + self.db.commit() 178 + 179 + def cleanup_expired_identities(self) -> int: 180 + cutoff = time.time() - self.identity_ttl 181 + cursor = self.db.execute( 182 + "DELETE FROM atproto_identities WHERE created_at + ? < ?", 183 + (self.identity_ttl, cutoff) 184 + ) 185 + self.db.commit() 186 + return cursor.rowcount 187 + 188 + def cleanup_expired(self) -> None: 189 + self.cleanup_expired_sessions() 190 + self.cleanup_expired_identities() 191 + 192 + def flush_all(self) -> tuple[int, int]: 193 + """Delete all cached sessions and identities.""" 194 + sessions = self.db.execute("SELECT COUNT(*) FROM atproto_sessions").fetchone()[0] 195 + identities = self.db.execute("SELECT COUNT(*) FROM atproto_identities").fetchone()[0] 196 + self.db.execute("DELETE FROM atproto_sessions") 197 + self.db.execute("DELETE FROM atproto_identities") 198 + self.db.commit() 199 + return sessions, identities
+4
atproto/util.py
··· 5 5 class AtUri: 6 6 @classmethod 7 7 def record_uri(cls, uri: str) -> tuple[str, str, str]: 8 + if not uri.startswith(URI): 9 + raise ValueError(f"Ivalid record uri {uri}!") 10 + 8 11 did, collection, rid = uri[URI_LEN:].split("/") 9 12 if not (did and collection and rid): 10 13 raise ValueError(f"Ivalid record uri {uri}!") 14 + 11 15 return did, collection, rid
+6 -7
bluesky/info.py
··· 1 1 from abc import ABC, abstractmethod 2 2 from typing import Any 3 3 4 - from atproto.identity import did_resolver, handle_resolver 4 + from atproto.auth import resolve_identity 5 5 from cross.service import Service 6 6 from util.util import normalize_service_url 7 7 ··· 37 37 if not handle: 38 38 raise KeyError("No did: or atproto handle provided!") 39 39 self.log.info("Resolving ATP identity for %s...", handle) 40 - self.did = handle_resolver.resolve_handle(handle) 40 + identity = resolve_identity(handle) 41 + self.did = identity.did 41 42 42 43 if not pds: 43 - self.log.info("Resolving PDS from %s DID document...", self.did) 44 - atp_pds = did_resolver.resolve_did(self.did).get_atproto_pds() 45 - if not atp_pds: 46 - raise Exception("Failed to resolve atproto pds for %s") 47 - self.pds = atp_pds 44 + self.log.info("Resolving PDS for %s...", self.did) 45 + identity = resolve_identity(self.did) 46 + self.pds = identity.pds 48 47 49 48 @abstractmethod 50 49 def get_identity_options(self) -> tuple[str | None, str | None, str | None]:
+2
bluesky/input.py
··· 7 7 8 8 import websockets 9 9 10 + from atproto.auth import init_atproto_store 10 11 from atproto.util import AtUri 11 12 from bluesky.info import SERVICE, BlueskyService, validate_and_transform 12 13 from bluesky.tokens import tokenize_post ··· 220 221 def __init__(self, db: DatabasePool, options: BlueskyJetstreamInputOptions) -> None: 221 222 super().__init__(db) 222 223 self.options: BlueskyJetstreamInputOptions = options 224 + init_atproto_store(db) 223 225 self._init_identity() 224 226 225 227 @override
+9
bluesky/output.py
··· 1 1 from dataclasses import dataclass 2 2 from typing import Any, override 3 3 4 + from atproto.auth import get_auth, init_atproto_store 4 5 from bluesky.info import SERVICE, BlueskyService, validate_and_transform 5 6 from cross.post import Post, PostRef 6 7 from cross.service import OutputService ··· 12 13 handle: str | None = None 13 14 did: str | None = None 14 15 pds: str | None = None 16 + password: str 15 17 16 18 @classmethod 17 19 def from_dict(cls, data: dict[str, Any]) -> "BlueskyOutputOptions": 18 20 validate_and_transform(data) 21 + 22 + if "password" not in data: 23 + raise KeyError("password is required for bluesky") 24 + 19 25 return BlueskyOutputOptions(**data) 20 26 21 27 ··· 24 30 super().__init__(SERVICE, db) 25 31 self.options: BlueskyOutputOptions = options 26 32 self._init_identity() 33 + init_atproto_store(db) 34 + self._auth = get_auth(self.pds) 35 + self._auth.login(self.did, options.password) 27 36 28 37 @override 29 38 def get_identity_options(self) -> tuple[str | None, str | None, str | None]:
+1 -1
env.py
··· 11 11 12 12 MIGRATIONS_DIR = Path(os.environ.get("MIGRATIONS_DIR") or "./migrations") 13 13 14 - PLC_HOST = os.environ.get("PLC_HOST") or "https://plc.directory" 14 + SLINGSHOT_URL = os.environ.get("SLINGSHOT_URL") or "https://slingshot.microcosm.blue"
+30 -1
main.py
··· 1 + import argparse 1 2 import asyncio 2 3 import json 3 4 import queue ··· 5 6 from collections.abc import Callable 6 7 7 8 import env 9 + from atproto.auth import flush_caches as flush_atproto_caches 10 + from atproto.auth import init_atproto_store 8 11 from database.connection import DatabasePool 9 12 from database.migrations import DatabaseMigrator 10 13 from registry import create_input_service, create_output_service ··· 12 15 from util.util import LOGGER, read_env, shutdown_hook 13 16 14 17 18 + def flush_caches() -> None: 19 + """Flush all cached data (sessions and identities).""" 20 + db_pool = DatabasePool(env.DATABASE_DIR) 21 + init_atproto_store(db_pool) 22 + 23 + LOGGER.info("Flushing atproto caches...") 24 + sessions, identities = flush_atproto_caches() 25 + LOGGER.info("Flushed %d sessions and %d identities", sessions, identities) 26 + LOGGER.info("Cache flush complete!") 27 + 28 + db_pool.close() 29 + 30 + 15 31 def main() -> None: 32 + parser = argparse.ArgumentParser(description="xpost-next: AT Protocol crossposting tool") 33 + parser.add_argument( 34 + "--flush-caches", 35 + action="store_true", 36 + help="Flush all cached sessions and identities, then exit" 37 + ) 38 + args = parser.parse_args() 39 + 40 + if args.flush_caches: 41 + flush_caches() 42 + return 43 + 16 44 if not env.DATA_DIR.exists(): 17 45 env.DATA_DIR.mkdir(parents=True) 18 46 ··· 78 106 task_queue.join() 79 107 task_queue.put(None) 80 108 thread.join() 81 - db_pool.close() 82 109 83 110 for shook in shutdown_hook: 84 111 shook() 112 + 113 + db_pool.close() 85 114 86 115 87 116 if __name__ == "__main__":
+41
migrations/006_add_atproto_tables.py
··· 1 + import sqlite3 2 + 3 + 4 + def migrate(conn: sqlite3.Connection): 5 + _ = conn.execute(""" 6 + CREATE TABLE IF NOT EXISTS atproto_sessions ( 7 + did TEXT PRIMARY KEY, 8 + pds TEXT NOT NULL, 9 + handle TEXT NOT NULL, 10 + access_jwt TEXT NOT NULL, 11 + refresh_jwt TEXT NOT NULL, 12 + email TEXT, 13 + email_confirmed INTEGER DEFAULT 0, 14 + email_auth_factor INTEGER DEFAULT 0, 15 + active INTEGER DEFAULT 1, 16 + status TEXT, 17 + created_at REAL NOT NULL 18 + ) 19 + """) 20 + _ = conn.execute(""" 21 + CREATE TABLE IF NOT EXISTS atproto_identities ( 22 + identifier TEXT PRIMARY KEY, 23 + did TEXT NOT NULL, 24 + handle TEXT NOT NULL, 25 + pds TEXT NOT NULL, 26 + signing_key TEXT NOT NULL, 27 + created_at REAL NOT NULL 28 + ) 29 + """) 30 + _ = conn.execute(""" 31 + CREATE INDEX IF NOT EXISTS idx_sessions_pds ON atproto_sessions(pds) 32 + """) 33 + _ = conn.execute(""" 34 + CREATE INDEX IF NOT EXISTS idx_sessions_handle ON atproto_sessions(handle) 35 + """) 36 + _ = conn.execute(""" 37 + CREATE INDEX IF NOT EXISTS idx_identities_did ON atproto_identities(did) 38 + """) 39 + _ = conn.execute(""" 40 + CREATE INDEX IF NOT EXISTS idx_identities_handle ON atproto_identities(handle) 41 + """)