ursaproxy 0.2.0__tar.gz → 0.4.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.
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/PKG-INFO +74 -28
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/README.md +73 -27
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/pyproject.toml +1 -1
- ursaproxy-0.4.0/src/ursaproxy/__init__.py +234 -0
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/config.py +13 -10
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/fetcher.py +18 -9
- ursaproxy-0.2.0/src/ursaproxy/__init__.py +0 -203
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/cache.py +0 -0
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/converter.py +0 -0
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/templates/about.gmi +0 -0
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/templates/feed.xml +0 -0
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/templates/index.gmi +0 -0
- {ursaproxy-0.2.0 → ursaproxy-0.4.0}/src/ursaproxy/templates/post.gmi +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ursaproxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
47
|
+
UrsaProxy can be configured via a TOML file or environment variables.
|
|
48
48
|
|
|
49
|
-
###
|
|
49
|
+
### TOML Configuration (Recommended)
|
|
50
50
|
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
27
|
+
UrsaProxy can be configured via a TOML file or environment variables.
|
|
28
28
|
|
|
29
|
-
###
|
|
29
|
+
### TOML Configuration (Recommended)
|
|
30
30
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
180
|
+
# Run all tests
|
|
135
181
|
uv run pytest
|
|
136
182
|
|
|
137
183
|
# Run specific test file
|
|
@@ -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
|
+
)
|
|
@@ -11,9 +11,10 @@ class Settings(BaseSettings):
|
|
|
11
11
|
"""Configuration from ursaproxy.toml file and/or environment variables.
|
|
12
12
|
|
|
13
13
|
Settings are loaded in this priority order (highest to lowest):
|
|
14
|
-
1.
|
|
15
|
-
2.
|
|
16
|
-
3.
|
|
14
|
+
1. Constructor arguments (e.g., Settings(bearblog_url="..."))
|
|
15
|
+
2. Environment variables (e.g., BEARBLOG_URL)
|
|
16
|
+
3. ursaproxy.toml file in current directory
|
|
17
|
+
4. Default values
|
|
17
18
|
|
|
18
19
|
Example ursaproxy.toml:
|
|
19
20
|
bearblog_url = "https://example.bearblog.dev"
|
|
@@ -57,9 +58,10 @@ class Settings(BaseSettings):
|
|
|
57
58
|
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
58
59
|
"""Configure settings sources with TOML file support.
|
|
59
60
|
|
|
60
|
-
Priority (highest to lowest): env vars -> TOML file -> defaults.
|
|
61
|
+
Priority (highest to lowest): init args -> env vars -> TOML file -> defaults.
|
|
61
62
|
"""
|
|
62
63
|
return (
|
|
64
|
+
init_settings,
|
|
63
65
|
env_settings,
|
|
64
66
|
TomlConfigSettingsSource(settings_cls),
|
|
65
67
|
)
|
|
@@ -74,8 +76,12 @@ class Settings(BaseSettings):
|
|
|
74
76
|
return v
|
|
75
77
|
|
|
76
78
|
|
|
77
|
-
def
|
|
78
|
-
"""Load settings
|
|
79
|
+
def load_settings() -> Settings:
|
|
80
|
+
"""Load settings from TOML file and/or environment variables.
|
|
81
|
+
|
|
82
|
+
Use this when running ursaproxy standalone. For embedding in other apps,
|
|
83
|
+
create a Settings instance directly with the desired configuration.
|
|
84
|
+
"""
|
|
79
85
|
try:
|
|
80
86
|
return Settings() # type: ignore[call-arg]
|
|
81
87
|
except FileNotFoundError:
|
|
@@ -90,9 +96,6 @@ def _load_settings() -> Settings:
|
|
|
90
96
|
dotenv_settings: PydanticBaseSettingsSource,
|
|
91
97
|
file_secret_settings: PydanticBaseSettingsSource,
|
|
92
98
|
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
93
|
-
return (env_settings
|
|
99
|
+
return (init_settings, env_settings)
|
|
94
100
|
|
|
95
101
|
return _EnvOnlySettings() # type: ignore[call-arg]
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
settings = _load_settings()
|
|
@@ -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(
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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"{
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|