Skip to content

Commit

Permalink
Add optional listing page
Browse files Browse the repository at this point in the history
  • Loading branch information
RealOrangeOne committed Apr 29, 2024
1 parent 85e1107 commit 7d3b9c7
Show file tree
Hide file tree
Showing 11 changed files with 276 additions and 9 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ calmerge write ./calendars

Each calendar will be saved as a `.ics` file based on its slug to the `./calendars` directory.

### Listing

A listing page can be served at `/all/` using:

```toml
[listing]
enabled = true
```

Basic auth can be enabled using `auth = `.

Basic auth credentials for each calendar are not output in the listing. This can be enabled enabled using `include_credentials` on the `[listing]`.

## Deployment

`calmerge` is available as a Docker container. Configuration should be mounted to `/app/calendars.toml`. An empty file is provided so the server will start successfully.
Expand Down
23 changes: 20 additions & 3 deletions calmerge/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import aiohttp_jinja2
from aiohttp import web
from jinja2 import FileSystemLoader

from . import views
from . import templates, views
from .config import Config


def get_aiohttp_app(config: Config) -> web.Application:
app = web.Application()

jinja2_env = aiohttp_jinja2.setup(
app,
loader=FileSystemLoader(templates.TEMPLATES_DIR),
context_processors=[
aiohttp_jinja2.request_processor,
templates.config_context_processor,
],
)

jinja2_env.filters["webcal_url"] = templates.webcal_url

app["config"] = config

app.add_routes(
[
web.get("/.health/", views.healthcheck),
web.get("/{slug}.ics", views.calendar),
web.get("/.health/", views.healthcheck, name="healthcheck"),
web.get("/{slug}.ics", views.calendar, name="calendar"),
]
)

if config.listing.enabled:
app.add_routes([web.get("/all/", views.calendar_listing, name="listing")])

return app
7 changes: 7 additions & 0 deletions calmerge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,15 @@ def validate_offset_days(cls, offset_days: list[int]) -> list[int]:
return offset_days


class ListingConfig(BaseModel):
enabled: bool = False
auth: AuthConfig | None = None
include_credentials: bool = False


class Config(BaseModel):
calendars: list[CalendarConfig] = Field(alias="calendar", default_factory=list)
listing: ListingConfig = Field(default_factory=ListingConfig)

@field_validator("calendars")
@classmethod
Expand Down
29 changes: 29 additions & 0 deletions calmerge/templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from pathlib import Path

import jinja2
from aiohttp import web
from yarl import URL

from .config import CalendarConfig

TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"


async def config_context_processor(request: web.Request) -> dict:
return {"config": request.app["config"]}


@jinja2.pass_context
def webcal_url(context: dict, calendar_config: CalendarConfig) -> URL:
request: web.Request = context["request"]
config = context["config"]
calendar_url = context["url"](context, "calendar", slug=calendar_config.slug)

url = request.url.with_scheme("webcal").with_path(calendar_url.path)

if config.listing.include_credentials and calendar_config.auth is not None:
url = url.with_user(calendar_config.auth.username).with_password(
calendar_config.auth.password
)

return url
49 changes: 49 additions & 0 deletions calmerge/templates/listing.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<title>Calmerge</title>
<meta name="robots" content="noindex" />
<style>
table {
min-width: 50vw;
max-width: 100vw;
}

td {
text-align: center;
padding: 0.75rem;
}
</style>
</head>
<body>
<h1>Calmerge</h1>
<table>
<tr>
<th>URL</th>
<th>Name</th>
<th>Description</th>
<th>Offsets</th>
<th>TTL</th>
<th>Auth</th>
<th>+</th>
</tr>
{% for calendar in config.calendars|sort(attribute="slug") %}
<tr>
{% with calendar_url = url('calendar', slug=calendar.slug) %}
<td><a href="{{ calendar_url }}">{{ calendar_url }}</a></td>
{% endwith %}
<td>{{ calendar.name|default("-", true) }}</td>
<td>{{ calendar.description|default("-", true) }}</td>
<td>{{ calendar.offset_days|sort|join(", ") }}</td>
<td>{{ calendar.ttl_hours }} hours</td>
{% if config.listing.include_credentials and calendar.auth %}
<td><code>{{ calendar.auth.username }}:{{ calendar.auth.password }}</code></td>
{% else %}
<td>{{ "Yes" if calendar.auth else "No" }}</td>
{% endif %}
<td><a href="{{ calendar|webcal_url }}">Add to calendar</a></td>
</tr>
{% endfor %}
</table>
</body>
</html>
26 changes: 21 additions & 5 deletions calmerge/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from aiohttp import web
import aiohttp_jinja2
from aiohttp import hdrs, web

from .calendars import (
create_offset_calendar_events,
Expand Down Expand Up @@ -34,9 +35,24 @@ async def calendar(request: web.Request) -> web.Response:
return web.Response(
body=calendar.to_ical(),
headers={
"Content-Type": "text/calendar",
"Cache-Control": f"max-age={calendar_config.ttl_hours * 60 * 60}",
"Vary": "Authorization",
"Content-Disposition": f"attachment; filename={calendar_config.slug}.ics",
hdrs.CONTENT_TYPE: "text/calendar",
hdrs.CACHE_CONTROL: f"max-age={calendar_config.ttl_hours * 60 * 60}",
hdrs.VARY: "Authorization",
hdrs.CONTENT_DISPOSITION: f"attachment; filename={calendar_config.slug}.ics",
},
)


async def calendar_listing(request: web.Request) -> web.Response:
config = request.app["config"]

if config.listing.auth and not config.listing.auth.validate_header(
request.headers.get("Authorization", "")
):
raise web.HTTPUnauthorized(headers={hdrs.WWW_AUTHENTICATE: "Basic"})

response = aiohttp_jinja2.render_template("listing.html", request, {})
response.headers["Content-Security-Policy"] = (
"default-src 'self'; style-src 'unsafe-inline'"
)
return response
103 changes: 102 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ aiohttp = "^3.9.3"
pydantic = "^2.6.4"
icalendar = "^5.0.11"
aiocache = "^0.12.2"
aiohttp-jinja2 = "^1.6"

[tool.poetry.group.dev.dependencies]
ruff = "^0.3.2"
Expand Down
5 changes: 5 additions & 0 deletions tests/calendars.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
[listing]
enabled = true
auth = {username = "user", password="password"}
include_credentials = true

[[calendar]]
slug = "python"
name = "Python"
Expand Down
5 changes: 5 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,8 @@ def test_validate_config_command_missing_file(tmp_path: Path) -> None:
stderr=subprocess.PIPE,
)
assert result.returncode == 2


def test_default_listing_config() -> None:
config = Config()
assert not config.listing.enabled
24 changes: 24 additions & 0 deletions tests/test_listing_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from aiohttp import BasicAuth
from aiohttp.test_utils import TestClient

from calmerge.config import Config


async def test_listing_view(client: TestClient, config: Config) -> None:
response = await client.get("/all/", auth=BasicAuth("user", "password"))
assert response.status == 200


async def test_requires_auth(client: TestClient, config: Config) -> None:
response = await client.get("/all/")
assert response.status == 401


async def test_webcal_url(client: TestClient) -> None:
response = await client.get("/all/", auth=BasicAuth("user", "password"))
assert response.status == 200

text = await response.text()

assert f"webcal://127.0.0.1:{client.port}/python.ics" in text
assert f"webcal://user:[email protected]:{client.port}/python-authed.ics" in text

0 comments on commit 7d3b9c7

Please sign in to comment.