ursaproxy 0.1.2__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ursaproxy/__init__.py +193 -162
- ursaproxy/config.py +63 -3
- ursaproxy/fetcher.py +18 -9
- {ursaproxy-0.1.2.dist-info → ursaproxy-0.3.0.dist-info}/METADATA +74 -28
- {ursaproxy-0.1.2.dist-info → ursaproxy-0.3.0.dist-info}/RECORD +7 -7
- {ursaproxy-0.1.2.dist-info → ursaproxy-0.3.0.dist-info}/WHEEL +0 -0
- {ursaproxy-0.1.2.dist-info → ursaproxy-0.3.0.dist-info}/entry_points.txt +0 -0
ursaproxy/__init__.py
CHANGED
|
@@ -6,11 +6,23 @@ from jinja2 import Environment, PackageLoader
|
|
|
6
6
|
from xitzin import NotFound, Request, Response, TemporaryFailure, Xitzin
|
|
7
7
|
|
|
8
8
|
from .cache import cache
|
|
9
|
-
from .config import
|
|
9
|
+
from .config import Settings, load_settings
|
|
10
10
|
from .converter import extract_metadata, extract_slug, html_to_gemtext
|
|
11
11
|
from .fetcher import NotFoundError, ServerError, fetch_feed, fetch_html
|
|
12
12
|
|
|
13
|
-
|
|
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
|
+
|
|
14
26
|
|
|
15
27
|
# Template environments
|
|
16
28
|
templates = Environment(
|
|
@@ -24,177 +36,196 @@ xml_templates = Environment(
|
|
|
24
36
|
)
|
|
25
37
|
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.")
|
|
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
|
+
)
|
|
135
126
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
)
|
|
142
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
|
+
)
|
|
143
161
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return dt.isoformat().replace("+00:00", "Z")
|
|
151
|
-
except (ValueError, TypeError):
|
|
152
|
-
return datetime.now().isoformat() + "Z"
|
|
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
|
+
)
|
|
153
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
|
+
)
|
|
154
181
|
|
|
155
|
-
@app.gemini("/feed")
|
|
156
|
-
async def
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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,
|
|
183
218
|
)
|
|
184
219
|
|
|
185
|
-
|
|
186
|
-
atom_xml = template.render(
|
|
187
|
-
blog_name=settings.blog_name,
|
|
188
|
-
base_url=base_url,
|
|
189
|
-
updated=updated,
|
|
190
|
-
entries=entries,
|
|
191
|
-
)
|
|
220
|
+
return Response(body=atom_xml, mime_type="application/atom+xml")
|
|
192
221
|
|
|
193
|
-
return
|
|
222
|
+
return app
|
|
194
223
|
|
|
195
224
|
|
|
196
225
|
def main() -> None:
|
|
197
|
-
"""Entry point."""
|
|
226
|
+
"""Entry point for standalone execution."""
|
|
227
|
+
settings = load_settings()
|
|
228
|
+
app = create_app(settings)
|
|
198
229
|
app.run(
|
|
199
230
|
host=settings.host,
|
|
200
231
|
port=settings.port,
|
ursaproxy/config.py
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
from pydantic import field_validator
|
|
2
|
-
from pydantic_settings import
|
|
2
|
+
from pydantic_settings import (
|
|
3
|
+
BaseSettings,
|
|
4
|
+
PydanticBaseSettingsSource,
|
|
5
|
+
SettingsConfigDict,
|
|
6
|
+
TomlConfigSettingsSource,
|
|
7
|
+
)
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
class Settings(BaseSettings):
|
|
6
|
-
"""Configuration from environment variables.
|
|
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
|
+
)
|
|
7
27
|
|
|
8
28
|
# Required: the Bearblog URL to proxy
|
|
9
29
|
bearblog_url: str
|
|
@@ -26,6 +46,24 @@ class Settings(BaseSettings):
|
|
|
26
46
|
cert_file: str | None = None
|
|
27
47
|
key_file: str | None = None
|
|
28
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
|
+
|
|
29
67
|
@field_validator("bearblog_url")
|
|
30
68
|
@classmethod
|
|
31
69
|
def normalize_url(cls, v: str) -> str:
|
|
@@ -36,4 +74,26 @@ class Settings(BaseSettings):
|
|
|
36
74
|
return v
|
|
37
75
|
|
|
38
76
|
|
|
39
|
-
|
|
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]
|
ursaproxy/fetcher.py
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: ursaproxy
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
ursaproxy/__init__.py,sha256=
|
|
1
|
+
ursaproxy/__init__.py,sha256=6YbaViq9c7W-s4iQlNkOSEY_0QFIy_3rOh37nE0qWw8,7433
|
|
2
2
|
ursaproxy/cache.py,sha256=Q5cypE91xGte1ph-_NqcEwZ0NoT7_LszYT1DVpfd2bM,1205
|
|
3
|
-
ursaproxy/config.py,sha256=
|
|
3
|
+
ursaproxy/config.py,sha256=_qIVgLITf39zqieWwHlMBpFROjWgt7FZt22Z4rgfx9k,3320
|
|
4
4
|
ursaproxy/converter.py,sha256=FAW0fA7a3WtlPYMtZbDlsM0Gl7twXK73DAV911R7SPI,1955
|
|
5
|
-
ursaproxy/fetcher.py,sha256=
|
|
5
|
+
ursaproxy/fetcher.py,sha256=Qj-1bSG0nfqtL30fdVI-yITYcNSj3IEmNP1W9Z964fs,1851
|
|
6
6
|
ursaproxy/templates/about.gmi,sha256=hJxK25i9uXr2geBo1HrdkLztqkebICDy-_bdnQ4jlGI,105
|
|
7
7
|
ursaproxy/templates/feed.xml,sha256=0aiFNOfgHeMH0427LPc58pEf0NdvcDHIRa-x9tc_Ty8,597
|
|
8
8
|
ursaproxy/templates/index.gmi,sha256=2J-1lQRcwoxr46bqasW969RPpo_X5n2sh9sVuXIMdFg,265
|
|
9
9
|
ursaproxy/templates/post.gmi,sha256=DMPbqZl52v-G-6wmwbXh15kg8dYpTDUx3mfqkce-HhQ,133
|
|
10
|
-
ursaproxy-0.
|
|
11
|
-
ursaproxy-0.
|
|
12
|
-
ursaproxy-0.
|
|
13
|
-
ursaproxy-0.
|
|
10
|
+
ursaproxy-0.3.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
11
|
+
ursaproxy-0.3.0.dist-info/entry_points.txt,sha256=uM3A9bQS-p_6VhWbkFRtob2oN-SuBboJWS3ZwCjlAFk,46
|
|
12
|
+
ursaproxy-0.3.0.dist-info/METADATA,sha256=oUATQ02pD-CxrGO2YkJLNyJVzDWTL1uS1y3oJiV0SZw,5682
|
|
13
|
+
ursaproxy-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|