ursaproxy 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ursaproxy
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: A Bearblog-to-Gemini proxy showcasing Xitzin
5
5
  Author: Alan Velasco
6
6
  Author-email: Alan Velasco <ursaproxy@alanbato.com>
@@ -44,47 +44,93 @@ pip install ursaproxy
44
44
 
45
45
  ## Configuration
46
46
 
47
- UrsaProxy is configured via environment variables:
47
+ UrsaProxy can be configured via a TOML file or environment variables.
48
48
 
49
- ### Required
49
+ ### TOML Configuration (Recommended)
50
50
 
51
- | Variable | Description |
52
- |----------|-------------|
53
- | `BEARBLOG_URL` | The Bearblog URL to proxy (e.g., `https://example.bearblog.dev`) |
54
- | `BLOG_NAME` | Display name for the blog |
55
- | `CERT_FILE` | Path to TLS certificate file |
56
- | `KEY_FILE` | Path to TLS private key file |
51
+ Create an `ursaproxy.toml` file in your working directory:
57
52
 
58
- ### Optional
53
+ ```toml
54
+ bearblog_url = "https://example.bearblog.dev"
55
+ blog_name = "My Gemini Blog"
56
+ cert_file = "/path/to/cert.pem"
57
+ key_file = "/path/to/key.pem"
59
58
 
60
- | Variable | Default | Description |
61
- |----------|---------|-------------|
62
- | `PAGES` | `{}` | JSON dict of static pages `{"slug": "Title"}` |
63
- | `GEMINI_HOST` | `None` | Hostname for Gemini URLs in feed |
64
- | `CACHE_TTL_FEED` | `300` | Feed cache TTL in seconds (5 min) |
65
- | `CACHE_TTL_POST` | `1800` | Post cache TTL in seconds (30 min) |
66
- | `HOST` | `localhost` | Server bind address |
67
- | `PORT` | `1965` | Server port (Gemini default) |
59
+ # Optional settings
60
+ gemini_host = "gemini.example.com"
61
+ cache_ttl_feed = 300
62
+ cache_ttl_post = 1800
63
+ host = "localhost"
64
+ port = 1965
68
65
 
69
- ### Example
70
-
71
- ```bash
72
- export BEARBLOG_URL="https://example.bearblog.dev"
73
- export BLOG_NAME="My Gemini Blog"
74
- export CERT_FILE="/path/to/cert.pem"
75
- export KEY_FILE="/path/to/key.pem"
76
- export PAGES='{"about": "About Me", "now": "What I am doing now"}'
77
- export GEMINI_HOST="gemini.example.com"
66
+ [pages]
67
+ about = "About Me"
68
+ now = "What I am doing now"
78
69
  ```
79
70
 
71
+ ### Environment Variables
72
+
73
+ Environment variables override TOML settings:
74
+
75
+ | Variable | Required | Default | Description |
76
+ |----------|----------|---------|-------------|
77
+ | `BEARBLOG_URL` | Yes | - | The Bearblog URL to proxy |
78
+ | `BLOG_NAME` | Yes | - | Display name for the blog |
79
+ | `CERT_FILE` | No | `None` | Path to TLS certificate file |
80
+ | `KEY_FILE` | No | `None` | Path to TLS private key file |
81
+ | `PAGES` | No | `{}` | JSON dict of static pages |
82
+ | `GEMINI_HOST` | No | `None` | Hostname for Gemini URLs in feed |
83
+ | `CACHE_TTL_FEED` | No | `300` | Feed cache TTL in seconds |
84
+ | `CACHE_TTL_POST` | No | `1800` | Post cache TTL in seconds |
85
+ | `HOST` | No | `localhost` | Server bind address |
86
+ | `PORT` | No | `1965` | Server port |
87
+
88
+ ### Configuration Priority
89
+
90
+ Settings are loaded in this order (highest to lowest priority):
91
+
92
+ 1. Environment variables
93
+ 2. `ursaproxy.toml` file
94
+ 3. Default values
95
+
80
96
  ## Usage
81
97
 
98
+ ### Standalone
99
+
82
100
  ```bash
83
101
  ursaproxy
84
102
  ```
85
103
 
86
104
  The server will start on `gemini://localhost:1965/` by default.
87
105
 
106
+ ### As a Library
107
+
108
+ UrsaProxy can be embedded in other Xitzin applications for virtual hosting:
109
+
110
+ ```python
111
+ from ursaproxy import create_app, Settings
112
+
113
+ # Create settings programmatically
114
+ settings = Settings(
115
+ bearblog_url="https://myblog.bearblog.dev",
116
+ blog_name="My Blog",
117
+ )
118
+
119
+ # Create the app
120
+ app = create_app(settings)
121
+
122
+ # Mount in your main Xitzin app or run directly
123
+ app.run(certfile="cert.pem", keyfile="key.pem")
124
+ ```
125
+
126
+ Or load settings from TOML/environment:
127
+
128
+ ```python
129
+ from ursaproxy import create_app, load_settings
130
+
131
+ app = create_app(load_settings())
132
+ ```
133
+
88
134
  ### Routes
89
135
 
90
136
  | Route | Description |
@@ -151,7 +197,7 @@ src/ursaproxy/
151
197
  The test suite uses pytest with fixtures for offline testing:
152
198
 
153
199
  ```bash
154
- # Run all 111 tests
200
+ # Run all tests
155
201
  uv run pytest
156
202
 
157
203
  # Run specific test file
@@ -24,47 +24,93 @@ pip install ursaproxy
24
24
 
25
25
  ## Configuration
26
26
 
27
- UrsaProxy is configured via environment variables:
27
+ UrsaProxy can be configured via a TOML file or environment variables.
28
28
 
29
- ### Required
29
+ ### TOML Configuration (Recommended)
30
30
 
31
- | Variable | Description |
32
- |----------|-------------|
33
- | `BEARBLOG_URL` | The Bearblog URL to proxy (e.g., `https://example.bearblog.dev`) |
34
- | `BLOG_NAME` | Display name for the blog |
35
- | `CERT_FILE` | Path to TLS certificate file |
36
- | `KEY_FILE` | Path to TLS private key file |
31
+ Create an `ursaproxy.toml` file in your working directory:
37
32
 
38
- ### Optional
33
+ ```toml
34
+ bearblog_url = "https://example.bearblog.dev"
35
+ blog_name = "My Gemini Blog"
36
+ cert_file = "/path/to/cert.pem"
37
+ key_file = "/path/to/key.pem"
39
38
 
40
- | Variable | Default | Description |
41
- |----------|---------|-------------|
42
- | `PAGES` | `{}` | JSON dict of static pages `{"slug": "Title"}` |
43
- | `GEMINI_HOST` | `None` | Hostname for Gemini URLs in feed |
44
- | `CACHE_TTL_FEED` | `300` | Feed cache TTL in seconds (5 min) |
45
- | `CACHE_TTL_POST` | `1800` | Post cache TTL in seconds (30 min) |
46
- | `HOST` | `localhost` | Server bind address |
47
- | `PORT` | `1965` | Server port (Gemini default) |
39
+ # Optional settings
40
+ gemini_host = "gemini.example.com"
41
+ cache_ttl_feed = 300
42
+ cache_ttl_post = 1800
43
+ host = "localhost"
44
+ port = 1965
48
45
 
49
- ### Example
50
-
51
- ```bash
52
- export BEARBLOG_URL="https://example.bearblog.dev"
53
- export BLOG_NAME="My Gemini Blog"
54
- export CERT_FILE="/path/to/cert.pem"
55
- export KEY_FILE="/path/to/key.pem"
56
- export PAGES='{"about": "About Me", "now": "What I am doing now"}'
57
- export GEMINI_HOST="gemini.example.com"
46
+ [pages]
47
+ about = "About Me"
48
+ now = "What I am doing now"
58
49
  ```
59
50
 
51
+ ### Environment Variables
52
+
53
+ Environment variables override TOML settings:
54
+
55
+ | Variable | Required | Default | Description |
56
+ |----------|----------|---------|-------------|
57
+ | `BEARBLOG_URL` | Yes | - | The Bearblog URL to proxy |
58
+ | `BLOG_NAME` | Yes | - | Display name for the blog |
59
+ | `CERT_FILE` | No | `None` | Path to TLS certificate file |
60
+ | `KEY_FILE` | No | `None` | Path to TLS private key file |
61
+ | `PAGES` | No | `{}` | JSON dict of static pages |
62
+ | `GEMINI_HOST` | No | `None` | Hostname for Gemini URLs in feed |
63
+ | `CACHE_TTL_FEED` | No | `300` | Feed cache TTL in seconds |
64
+ | `CACHE_TTL_POST` | No | `1800` | Post cache TTL in seconds |
65
+ | `HOST` | No | `localhost` | Server bind address |
66
+ | `PORT` | No | `1965` | Server port |
67
+
68
+ ### Configuration Priority
69
+
70
+ Settings are loaded in this order (highest to lowest priority):
71
+
72
+ 1. Environment variables
73
+ 2. `ursaproxy.toml` file
74
+ 3. Default values
75
+
60
76
  ## Usage
61
77
 
78
+ ### Standalone
79
+
62
80
  ```bash
63
81
  ursaproxy
64
82
  ```
65
83
 
66
84
  The server will start on `gemini://localhost:1965/` by default.
67
85
 
86
+ ### As a Library
87
+
88
+ UrsaProxy can be embedded in other Xitzin applications for virtual hosting:
89
+
90
+ ```python
91
+ from ursaproxy import create_app, Settings
92
+
93
+ # Create settings programmatically
94
+ settings = Settings(
95
+ bearblog_url="https://myblog.bearblog.dev",
96
+ blog_name="My Blog",
97
+ )
98
+
99
+ # Create the app
100
+ app = create_app(settings)
101
+
102
+ # Mount in your main Xitzin app or run directly
103
+ app.run(certfile="cert.pem", keyfile="key.pem")
104
+ ```
105
+
106
+ Or load settings from TOML/environment:
107
+
108
+ ```python
109
+ from ursaproxy import create_app, load_settings
110
+
111
+ app = create_app(load_settings())
112
+ ```
113
+
68
114
  ### Routes
69
115
 
70
116
  | Route | Description |
@@ -131,7 +177,7 @@ src/ursaproxy/
131
177
  The test suite uses pytest with fixtures for offline testing:
132
178
 
133
179
  ```bash
134
- # Run all 111 tests
180
+ # Run all tests
135
181
  uv run pytest
136
182
 
137
183
  # Run specific test file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ursaproxy"
3
- version = "0.1.2"
3
+ version = "0.3.0"
4
4
  description = "A Bearblog-to-Gemini proxy showcasing Xitzin"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,234 @@
1
+ from datetime import datetime
2
+ from email.utils import parsedate_to_datetime
3
+
4
+ import httpx
5
+ from jinja2 import Environment, PackageLoader
6
+ from xitzin import NotFound, Request, Response, TemporaryFailure, Xitzin
7
+
8
+ from .cache import cache
9
+ from .config import Settings, load_settings
10
+ from .converter import extract_metadata, extract_slug, html_to_gemtext
11
+ from .fetcher import NotFoundError, ServerError, fetch_feed, fetch_html
12
+
13
+ __all__ = ["create_app", "Settings", "load_settings"]
14
+
15
+
16
+ def _rfc822_to_iso(date_str: str) -> str:
17
+ """Convert RFC 822 date to ISO 8601 format for Atom feeds."""
18
+ if not date_str:
19
+ return datetime.now().isoformat() + "Z"
20
+ try:
21
+ dt = parsedate_to_datetime(date_str)
22
+ return dt.isoformat().replace("+00:00", "Z")
23
+ except (ValueError, TypeError):
24
+ return datetime.now().isoformat() + "Z"
25
+
26
+
27
+ # Template environments
28
+ templates = Environment(
29
+ loader=PackageLoader("ursaproxy", "templates"),
30
+ autoescape=False, # Gemtext doesn't need HTML escaping
31
+ )
32
+
33
+ xml_templates = Environment(
34
+ loader=PackageLoader("ursaproxy", "templates"),
35
+ autoescape=True, # XML escaping for feed
36
+ )
37
+
38
+
39
+ def create_app(settings: Settings) -> Xitzin:
40
+ """Create and configure an UrsaProxy application instance.
41
+
42
+ This factory function allows ursaproxy to be embedded in other Xitzin apps
43
+ for virtual hosting scenarios.
44
+
45
+ Args:
46
+ settings: Configuration for this ursaproxy instance.
47
+
48
+ Returns:
49
+ A configured Xitzin application.
50
+
51
+ Example:
52
+ For standalone use::
53
+
54
+ from ursaproxy import create_app, load_settings
55
+
56
+ app = create_app(load_settings())
57
+ app.run()
58
+
59
+ For embedding in another app::
60
+
61
+ from ursaproxy import create_app, Settings
62
+
63
+ ursaproxy_settings = Settings(
64
+ bearblog_url="https://myblog.bearblog.dev",
65
+ blog_name="My Blog",
66
+ )
67
+ ursaproxy_app = create_app(ursaproxy_settings)
68
+ # Mount ursaproxy_app in your main Xitzin app
69
+ """
70
+ app = Xitzin()
71
+ app.state.settings = settings
72
+
73
+ @app.on_startup
74
+ async def startup() -> None:
75
+ """Initialize shared HTTP client."""
76
+ app.state.client = httpx.AsyncClient(timeout=30.0)
77
+
78
+ @app.on_shutdown
79
+ async def shutdown() -> None:
80
+ """Close HTTP client."""
81
+ await app.state.client.aclose()
82
+
83
+ async def _get_feed(client: httpx.AsyncClient):
84
+ """Fetch feed with caching and error handling."""
85
+ if cached := cache.get("feed", settings.cache_ttl_feed):
86
+ return cached
87
+
88
+ try:
89
+ feed = await fetch_feed(client, settings.bearblog_url)
90
+ cache.set("feed", feed)
91
+ return feed
92
+ except ServerError as e:
93
+ raise TemporaryFailure(str(e)) from e
94
+ except NotFoundError as e:
95
+ raise NotFound(str(e)) from e
96
+
97
+ async def _render_content(
98
+ client: httpx.AsyncClient,
99
+ slug: str,
100
+ content_type: str,
101
+ include_date: bool = True,
102
+ ) -> str:
103
+ """Fetch and render content as gemtext with caching."""
104
+ cache_key = f"{content_type}:{slug}"
105
+
106
+ if cached := cache.get(cache_key, settings.cache_ttl_post):
107
+ return cached
108
+
109
+ try:
110
+ html = await fetch_html(client, settings.bearblog_url, slug)
111
+ except NotFoundError as e:
112
+ raise NotFound(str(e)) from e
113
+ except ServerError as e:
114
+ raise TemporaryFailure(str(e)) from e
115
+
116
+ title, date = extract_metadata(html)
117
+ content = html_to_gemtext(html)
118
+
119
+ template = templates.get_template("post.gmi")
120
+ gemtext = template.render(
121
+ title=title,
122
+ date=date if include_date else None,
123
+ content=content,
124
+ web_url=f"{settings.bearblog_url}/{slug}/",
125
+ )
126
+
127
+ cache.set(cache_key, gemtext)
128
+ return gemtext
129
+
130
+ @app.gemini("/")
131
+ async def index(request: Request) -> str:
132
+ """Landing page with recent posts and page links."""
133
+ feed = await _get_feed(request.app.state.client)
134
+
135
+ posts = []
136
+ for entry in feed.entries[:10]:
137
+ link = getattr(entry, "link", None)
138
+ if not link:
139
+ continue
140
+ slug = extract_slug(link)
141
+ if not slug:
142
+ continue
143
+ date = entry.get("published", "")[:16] if entry.get("published") else ""
144
+ title = getattr(entry, "title", "Untitled")
145
+ posts.append({"slug": slug, "title": title, "date": date})
146
+
147
+ template = templates.get_template("index.gmi")
148
+ return template.render(
149
+ blog_name=settings.blog_name,
150
+ description=feed.feed.get("description", ""),
151
+ pages=settings.pages,
152
+ posts=posts,
153
+ )
154
+
155
+ @app.gemini("/post/{slug}")
156
+ async def post(request: Request, slug: str) -> str:
157
+ """Individual blog post."""
158
+ return await _render_content(
159
+ request.app.state.client, slug, "post", include_date=True
160
+ )
161
+
162
+ @app.gemini("/page/{slug}")
163
+ async def page(request: Request, slug: str) -> str:
164
+ """Static page (projects, now, etc.)."""
165
+ return await _render_content(
166
+ request.app.state.client, slug, "page", include_date=False
167
+ )
168
+
169
+ @app.gemini("/about")
170
+ async def about(request: Request) -> str:
171
+ """About page from feed metadata."""
172
+ feed = await _get_feed(request.app.state.client)
173
+ description = feed.feed.get("description", "A personal blog.")
174
+
175
+ template = templates.get_template("about.gmi")
176
+ return template.render(
177
+ blog_name=settings.blog_name,
178
+ description=description,
179
+ bearblog_url=settings.bearblog_url,
180
+ )
181
+
182
+ @app.gemini("/feed")
183
+ async def feed_route(request: Request) -> Response:
184
+ """Atom feed with Gemini URLs."""
185
+ rss = await _get_feed(request.app.state.client)
186
+
187
+ # Use configured gemini_host or fall back to request hostname
188
+ host = settings.gemini_host or request.hostname or "localhost"
189
+ base_url = f"gemini://{host}"
190
+
191
+ # Get the most recent update time
192
+ updated = _rfc822_to_iso(rss.feed.get("updated", ""))
193
+
194
+ entries = []
195
+ for entry in rss.entries:
196
+ link = getattr(entry, "link", None)
197
+ if not link:
198
+ continue
199
+ slug = extract_slug(link)
200
+ if not slug:
201
+ continue
202
+
203
+ entries.append(
204
+ {
205
+ "title": getattr(entry, "title", "Untitled"),
206
+ "url": f"{base_url}/post/{slug}",
207
+ "published": _rfc822_to_iso(entry.get("published", "")),
208
+ "summary": getattr(entry, "description", ""),
209
+ }
210
+ )
211
+
212
+ template = xml_templates.get_template("feed.xml")
213
+ atom_xml = template.render(
214
+ blog_name=settings.blog_name,
215
+ base_url=base_url,
216
+ updated=updated,
217
+ entries=entries,
218
+ )
219
+
220
+ return Response(body=atom_xml, mime_type="application/atom+xml")
221
+
222
+ return app
223
+
224
+
225
+ def main() -> None:
226
+ """Entry point for standalone execution."""
227
+ settings = load_settings()
228
+ app = create_app(settings)
229
+ app.run(
230
+ host=settings.host,
231
+ port=settings.port,
232
+ certfile=settings.cert_file,
233
+ keyfile=settings.key_file,
234
+ )
@@ -0,0 +1,99 @@
1
+ from pydantic import field_validator
2
+ from pydantic_settings import (
3
+ BaseSettings,
4
+ PydanticBaseSettingsSource,
5
+ SettingsConfigDict,
6
+ TomlConfigSettingsSource,
7
+ )
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ """Configuration from ursaproxy.toml file and/or environment variables.
12
+
13
+ Settings are loaded in this priority order (highest to lowest):
14
+ 1. Environment variables (e.g., BEARBLOG_URL)
15
+ 2. ursaproxy.toml file in current directory
16
+ 3. Default values
17
+
18
+ Example ursaproxy.toml:
19
+ bearblog_url = "https://example.bearblog.dev"
20
+ blog_name = "My Blog"
21
+ pages = { about = "About Me", now = "Now" }
22
+ """
23
+
24
+ model_config = SettingsConfigDict(
25
+ toml_file="ursaproxy.toml",
26
+ )
27
+
28
+ # Required: the Bearblog URL to proxy
29
+ bearblog_url: str
30
+ blog_name: str
31
+
32
+ cache_ttl_feed: int = 300 # 5 minutes
33
+ cache_ttl_post: int = 1800 # 30 minutes
34
+
35
+ # Static pages (slug -> title) - pages not in RSS feed
36
+ # Override via PAGES='{"about": "About Me", "now": "Now"}'
37
+ pages: dict[str, str] = {}
38
+
39
+ # Gemini capsule hostname (for feed URLs)
40
+ # e.g., "gemini.example.com" -> gemini://gemini.example.com/post/...
41
+ gemini_host: str | None = None
42
+
43
+ # Server settings
44
+ host: str = "localhost"
45
+ port: int = 1965
46
+ cert_file: str | None = None
47
+ key_file: str | None = None
48
+
49
+ @classmethod
50
+ def settings_customise_sources(
51
+ cls,
52
+ settings_cls: type[BaseSettings],
53
+ init_settings: PydanticBaseSettingsSource,
54
+ env_settings: PydanticBaseSettingsSource,
55
+ dotenv_settings: PydanticBaseSettingsSource,
56
+ file_secret_settings: PydanticBaseSettingsSource,
57
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
58
+ """Configure settings sources with TOML file support.
59
+
60
+ Priority (highest to lowest): env vars -> TOML file -> defaults.
61
+ """
62
+ return (
63
+ env_settings,
64
+ TomlConfigSettingsSource(settings_cls),
65
+ )
66
+
67
+ @field_validator("bearblog_url")
68
+ @classmethod
69
+ def normalize_url(cls, v: str) -> str:
70
+ """Remove trailing slash to prevent double slashes in URLs."""
71
+ v = v.rstrip("/")
72
+ if not v.startswith(("http://", "https://")):
73
+ raise ValueError("bearblog_url must start with http:// or https://")
74
+ return v
75
+
76
+
77
+ def load_settings() -> Settings:
78
+ """Load settings from TOML file and/or environment variables.
79
+
80
+ Use this when running ursaproxy standalone. For embedding in other apps,
81
+ create a Settings instance directly with the desired configuration.
82
+ """
83
+ try:
84
+ return Settings() # type: ignore[call-arg]
85
+ except FileNotFoundError:
86
+ # No TOML file - disable TOML source and rely on env vars only
87
+ class _EnvOnlySettings(Settings):
88
+ @classmethod
89
+ def settings_customise_sources(
90
+ cls,
91
+ settings_cls: type[BaseSettings],
92
+ init_settings: PydanticBaseSettingsSource,
93
+ env_settings: PydanticBaseSettingsSource,
94
+ dotenv_settings: PydanticBaseSettingsSource,
95
+ file_secret_settings: PydanticBaseSettingsSource,
96
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
97
+ return (env_settings,)
98
+
99
+ return _EnvOnlySettings() # type: ignore[call-arg]
@@ -1,8 +1,6 @@
1
1
  import feedparser
2
2
  import httpx
3
3
 
4
- from .config import settings
5
-
6
4
 
7
5
  class FetchError(Exception):
8
6
  """Base error for fetch operations."""
@@ -35,19 +33,30 @@ async def _fetch(
35
33
  raise ServerError(f"Network error: {e}") from e
36
34
 
37
35
 
38
- async def fetch_feed(client: httpx.AsyncClient) -> feedparser.FeedParserDict:
39
- """Fetch RSS feed from Bearblog."""
40
- url = f"{settings.bearblog_url}/feed/?type=rss"
36
+ async def fetch_feed(
37
+ client: httpx.AsyncClient, bearblog_url: str
38
+ ) -> feedparser.FeedParserDict:
39
+ """Fetch RSS feed from Bearblog.
40
+
41
+ Args:
42
+ client: HTTP client instance.
43
+ bearblog_url: Base URL of the Bearblog site.
44
+ """
45
+ url = f"{bearblog_url}/feed/?type=rss"
41
46
  response = await _fetch(client, url, f"Feed not found at {url}")
42
47
  return feedparser.parse(response.text)
43
48
 
44
49
 
45
- async def fetch_html(client: httpx.AsyncClient, slug: str) -> str:
46
- """
47
- Fetch HTML page from Bearblog.
50
+ async def fetch_html(client: httpx.AsyncClient, bearblog_url: str, slug: str) -> str:
51
+ """Fetch HTML page from Bearblog.
52
+
53
+ Args:
54
+ client: HTTP client instance.
55
+ bearblog_url: Base URL of the Bearblog site.
56
+ slug: Page slug (without leading/trailing slashes).
48
57
 
49
58
  Note: Bearblog URLs have trailing slashes: /{slug}/
50
59
  """
51
- url = f"{settings.bearblog_url}/{slug}/"
60
+ url = f"{bearblog_url}/{slug}/"
52
61
  response = await _fetch(client, url, f"Page not found: {slug}")
53
62
  return response.text
@@ -1,203 +0,0 @@
1
- from datetime import datetime
2
- from email.utils import parsedate_to_datetime
3
-
4
- import httpx
5
- from jinja2 import Environment, PackageLoader
6
- from xitzin import NotFound, Request, Response, TemporaryFailure, Xitzin
7
-
8
- from .cache import cache
9
- from .config import settings
10
- from .converter import extract_metadata, extract_slug, html_to_gemtext
11
- from .fetcher import NotFoundError, ServerError, fetch_feed, fetch_html
12
-
13
- app = Xitzin()
14
-
15
- # Template environments
16
- templates = Environment(
17
- loader=PackageLoader("ursaproxy", "templates"),
18
- autoescape=False, # Gemtext doesn't need HTML escaping
19
- )
20
-
21
- xml_templates = Environment(
22
- loader=PackageLoader("ursaproxy", "templates"),
23
- autoescape=True, # XML escaping for feed
24
- )
25
-
26
-
27
- @app.on_startup
28
- async def startup() -> None:
29
- """Initialize shared HTTP client."""
30
- app.state.client = httpx.AsyncClient(timeout=30.0)
31
-
32
-
33
- @app.on_shutdown
34
- async def shutdown() -> None:
35
- """Close HTTP client."""
36
- await app.state.client.aclose()
37
-
38
-
39
- async def _get_feed(client: httpx.AsyncClient):
40
- """Fetch feed with caching and error handling."""
41
- if cached := cache.get("feed", settings.cache_ttl_feed):
42
- return cached
43
-
44
- try:
45
- feed = await fetch_feed(client)
46
- cache.set("feed", feed)
47
- return feed
48
- except ServerError as e:
49
- raise TemporaryFailure(str(e)) from e
50
- except NotFoundError as e:
51
- raise NotFound(str(e)) from e
52
-
53
-
54
- async def _render_content(
55
- client: httpx.AsyncClient,
56
- slug: str,
57
- content_type: str,
58
- include_date: bool = True,
59
- ) -> str:
60
- """Fetch and render content as gemtext with caching."""
61
- cache_key = f"{content_type}:{slug}"
62
-
63
- if cached := cache.get(cache_key, settings.cache_ttl_post):
64
- return cached
65
-
66
- try:
67
- html = await fetch_html(client, slug)
68
- except NotFoundError as e:
69
- raise NotFound(str(e)) from e
70
- except ServerError as e:
71
- raise TemporaryFailure(str(e)) from e
72
-
73
- title, date = extract_metadata(html)
74
- content = html_to_gemtext(html)
75
-
76
- template = templates.get_template("post.gmi")
77
- gemtext = template.render(
78
- title=title,
79
- date=date if include_date else None,
80
- content=content,
81
- web_url=f"{settings.bearblog_url}/{slug}/",
82
- )
83
-
84
- cache.set(cache_key, gemtext)
85
- return gemtext
86
-
87
-
88
- @app.gemini("/")
89
- async def index(request: Request) -> str:
90
- """Landing page with recent posts and page links."""
91
- feed = await _get_feed(request.app.state.client)
92
-
93
- posts = []
94
- for entry in feed.entries[:10]:
95
- link = getattr(entry, "link", None)
96
- if not link:
97
- continue
98
- slug = extract_slug(link)
99
- if not slug:
100
- continue
101
- date = entry.get("published", "")[:16] if entry.get("published") else ""
102
- title = getattr(entry, "title", "Untitled")
103
- posts.append({"slug": slug, "title": title, "date": date})
104
-
105
- template = templates.get_template("index.gmi")
106
- return template.render(
107
- blog_name=settings.blog_name,
108
- description=feed.feed.get("description", ""),
109
- pages=settings.pages,
110
- posts=posts,
111
- )
112
-
113
-
114
- @app.gemini("/post/{slug}")
115
- async def post(request: Request, slug: str) -> str:
116
- """Individual blog post."""
117
- return await _render_content(
118
- request.app.state.client, slug, "post", include_date=True
119
- )
120
-
121
-
122
- @app.gemini("/page/{slug}")
123
- async def page(request: Request, slug: str) -> str:
124
- """Static page (projects, now, etc.)."""
125
- return await _render_content(
126
- request.app.state.client, slug, "page", include_date=False
127
- )
128
-
129
-
130
- @app.gemini("/about")
131
- async def about(request: Request) -> str:
132
- """About page from feed metadata."""
133
- feed = await _get_feed(request.app.state.client)
134
- description = feed.feed.get("description", "A personal blog.")
135
-
136
- template = templates.get_template("about.gmi")
137
- return template.render(
138
- blog_name=settings.blog_name,
139
- description=description,
140
- bearblog_url=settings.bearblog_url,
141
- )
142
-
143
-
144
- def _rfc822_to_iso(date_str: str) -> str:
145
- """Convert RFC 822 date to ISO 8601 format for Atom feeds."""
146
- if not date_str:
147
- return datetime.now().isoformat() + "Z"
148
- try:
149
- dt = parsedate_to_datetime(date_str)
150
- return dt.isoformat().replace("+00:00", "Z")
151
- except (ValueError, TypeError):
152
- return datetime.now().isoformat() + "Z"
153
-
154
-
155
- @app.gemini("/feed")
156
- async def feed(request: Request) -> Response:
157
- """Atom feed with Gemini URLs."""
158
- rss = await _get_feed(request.app.state.client)
159
-
160
- # Use configured gemini_host or fall back to request hostname
161
- host = settings.gemini_host or request.hostname or "localhost"
162
- base_url = f"gemini://{host}"
163
-
164
- # Get the most recent update time
165
- updated = _rfc822_to_iso(rss.feed.get("updated", ""))
166
-
167
- entries = []
168
- for entry in rss.entries:
169
- link = getattr(entry, "link", None)
170
- if not link:
171
- continue
172
- slug = extract_slug(link)
173
- if not slug:
174
- continue
175
-
176
- entries.append(
177
- {
178
- "title": getattr(entry, "title", "Untitled"),
179
- "url": f"{base_url}/post/{slug}",
180
- "published": _rfc822_to_iso(entry.get("published", "")),
181
- "summary": getattr(entry, "description", ""),
182
- }
183
- )
184
-
185
- template = xml_templates.get_template("feed.xml")
186
- atom_xml = template.render(
187
- blog_name=settings.blog_name,
188
- base_url=base_url,
189
- updated=updated,
190
- entries=entries,
191
- )
192
-
193
- return Response(body=atom_xml, mime_type="application/atom+xml")
194
-
195
-
196
- def main() -> None:
197
- """Entry point."""
198
- app.run(
199
- host=settings.host,
200
- port=settings.port,
201
- certfile=settings.cert_file,
202
- keyfile=settings.key_file,
203
- )
@@ -1,39 +0,0 @@
1
- from pydantic import field_validator
2
- from pydantic_settings import BaseSettings
3
-
4
-
5
- class Settings(BaseSettings):
6
- """Configuration from environment variables."""
7
-
8
- # Required: the Bearblog URL to proxy
9
- bearblog_url: str
10
- blog_name: str
11
-
12
- cache_ttl_feed: int = 300 # 5 minutes
13
- cache_ttl_post: int = 1800 # 30 minutes
14
-
15
- # Static pages (slug -> title) - pages not in RSS feed
16
- # Override via PAGES='{"about": "About Me", "now": "Now"}'
17
- pages: dict[str, str] = {}
18
-
19
- # Gemini capsule hostname (for feed URLs)
20
- # e.g., "gemini.example.com" -> gemini://gemini.example.com/post/...
21
- gemini_host: str | None = None
22
-
23
- # Server settings
24
- host: str = "localhost"
25
- port: int = 1965
26
- cert_file: str | None = None
27
- key_file: str | None = None
28
-
29
- @field_validator("bearblog_url")
30
- @classmethod
31
- def normalize_url(cls, v: str) -> str:
32
- """Remove trailing slash to prevent double slashes in URLs."""
33
- v = v.rstrip("/")
34
- if not v.startswith(("http://", "https://")):
35
- raise ValueError("bearblog_url must start with http:// or https://")
36
- return v
37
-
38
-
39
- settings = Settings() # type: ignore[call-arg] # pydantic-settings reads from env