Skip to content
This repository was archived by the owner on Jul 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ npm ci
| `PORT` | no | `7004` | Public bind port |
| `DATA_DIR` | no | `/data` | Directory for generated assets |
| `LOG_LEVEL` | no | `info` | Operational log verbosity |
| `TOONAMI_AFTERMATH_CLI` | no | bundled value | Override CLI path/command |
| `CLI_BIN` | no | `/usr/local/bin/toonamiaftermath-cli` | Override CLI binary path |
| `ALLOW_ANONYMOUS_LOCAL_REFRESH` | no | `false` | Allow unauthenticated `/refresh` from loopback/private LAN clients |
| `APP_REFRESH_TOKEN` | no | empty | Optional admin token for `/refresh` via `X-Admin-Token` or `Authorization: Bearer` |

## Usage

Expand Down
3 changes: 2 additions & 1 deletion TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ This guide helps you diagnose and resolve common issues with Toonami Aftermath D
4. **Manual refresh**
- Click "Refresh now" button in the UI
- Or call the API: `curl -X POST http://localhost:7004/refresh`
- If `APP_REFRESH_TOKEN` is configured: `curl -X POST -H "X-Admin-Token: <token>" http://localhost:7004/refresh`

### API Endpoints Not Working

Expand Down Expand Up @@ -415,4 +416,4 @@ Include the following information:

---

**Remember**: Most issues are related to file permissions, network connectivity, or missing dependencies. Start with the basics before diving into complex debugging! 🔍✨
**Remember**: Most issues are related to file permissions, network connectivity, or missing dependencies. Start with the basics before diving into complex debugging! 🔍✨
142 changes: 131 additions & 11 deletions app/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import ipaddress
import json
import logging
import mimetypes
Expand Down Expand Up @@ -271,10 +272,24 @@ async def _run_generate_files_serialized() -> dict[str, Any]:


def _is_local_host(host: str | None) -> bool:
"""Return True when client host is local."""
"""Return True when client host is loopback or private LAN/link-local."""
if not host:
return False
return host.startswith(("127.", "::1", "localhost"))

normalized = host.strip().lower()
if normalized == "localhost" or normalized.startswith("127.") or normalized == "::1":
return True

# Handle IPv4-mapped IPv6 addresses like ::ffff:192.168.1.5
if normalized.startswith("::ffff:"):
normalized = normalized[7:]

try:
ip_obj = ipaddress.ip_address(normalized)
except ValueError:
return False

return ip_obj.is_loopback or ip_obj.is_private or ip_obj.is_link_local


def _extract_bearer_token(authorization: str | None) -> str | None:
Expand Down Expand Up @@ -1258,7 +1273,8 @@ async def get_stream_codes(request: Request):
r"^\s*([0-9*,/\-]+)\s+([0-9*,/\-]+)\s+([0-9*,/\-]+)\s+([0-9*,/\-]+)\s+([0-9*,/\-]+)\s*$"
)
DEFAULT_FALLBACK_HOUR = 3
MAX_SCHEDULE_SEARCH_MINUTES = 24 * 60 + 2
# Search up to one leap year ahead for DOM/MON/DOW constrained schedules.
MAX_SCHEDULE_SEARCH_MINUTES = (366 * 24 * 60) + 2


def parse_cron_field(val: str, default_value: int) -> tuple[str, int | None]:
Expand All @@ -1281,6 +1297,27 @@ def parse_cron_field(val: str, default_value: int) -> tuple[str, int | None]:
return "invalid", None


def _is_cron_field_supported(
mode: str,
value: int | None,
*,
min_value: int,
max_value: int,
allow_step: bool,
max_step: int | None = None,
) -> bool:
"""Validate parsed cron field mode/value against allowed bounds."""
if mode == "any":
return True
if mode == "fixed":
return value is not None and min_value <= value <= max_value
if mode == "step" and allow_step:
if value is None or value <= 0:
return False
return value <= max_step if max_step is not None else True
return False


def get_fallback_next_run(dt: datetime) -> datetime:
"""
Get fallback next run time (3 AM next day if past 3 AM, today if before).
Expand Down Expand Up @@ -1318,6 +1355,12 @@ def check_time_matches_cron(
min_val: int | None,
hr_mode: str,
hr_val: int | None,
dom_mode: str,
dom_val: int | None,
mon_mode: str,
mon_val: int | None,
dow_mode: str,
dow_val: int | None,
) -> bool:
"""
Check if a given time matches the cron schedule.
Expand All @@ -1330,10 +1373,14 @@ def check_time_matches_cron(
hr_val: Hour value (if applicable)

Returns:
bool: True if time matches schedule
bool: True if time matches schedule (with cron-style DOM/DOW semantics)
"""
h = candidate.hour
mnt = candidate.minute
day = candidate.day
month = candidate.month
# Cron weekday convention: Sunday=0, Monday=1, ... Saturday=6.
cron_weekday = (candidate.weekday() + 1) % 7

ok_min = (
(min_mode == "any")
Expand All @@ -1347,7 +1394,21 @@ def check_time_matches_cron(
or (hr_mode == "step" and hr_val and (h % hr_val == 0))
)

return ok_min and ok_hr
ok_dom = (dom_mode == "any") or (dom_mode == "fixed" and day == (dom_val or 0))
ok_mon = (mon_mode == "any") or (mon_mode == "fixed" and month == (mon_val or 0))
ok_dow = (dow_mode == "any") or (dow_mode == "fixed" and cron_weekday == (dow_val or 0))

# Standard cron behavior: if both DOM and DOW are restricted, either may match.
if dom_mode == "any" and dow_mode == "any":
ok_dom_dow = True
elif dom_mode == "any":
ok_dom_dow = ok_dow
elif dow_mode == "any":
ok_dom_dow = ok_dom
else:
ok_dom_dow = ok_dom or ok_dow

return ok_min and ok_hr and ok_mon and ok_dom_dow


def cron_next(dt: datetime, expr: str) -> datetime | None:
Expand All @@ -1368,22 +1429,81 @@ def cron_next(dt: datetime, expr: str) -> datetime | None:

mins, hrs, dom, mon, dow = cron_parts

# Parse minute and hour fields (we only support these for now)
# Parse fields
min_mode, min_val = parse_cron_field(mins, 0)
hr_mode, hr_val = parse_cron_field(hrs, DEFAULT_FALLBACK_HOUR)
dom_mode, dom_val = parse_cron_field(dom, 1)
mon_mode, mon_val = parse_cron_field(mon, 1)
dow_mode, dow_val = parse_cron_field(dow, 0)

# Normalize Sunday aliases.
if dow_mode == "fixed" and dow_val == 7:
dow_val = 0

# Validate supported field ranges and modes.
is_supported = all(
(
_is_cron_field_supported(
min_mode,
min_val,
min_value=0,
max_value=59,
allow_step=True,
max_step=59,
),
_is_cron_field_supported(
hr_mode,
hr_val,
min_value=0,
max_value=23,
allow_step=True,
max_step=24,
),
_is_cron_field_supported(
dom_mode,
dom_val,
min_value=1,
max_value=31,
allow_step=False,
),
_is_cron_field_supported(
mon_mode,
mon_val,
min_value=1,
max_value=12,
allow_step=False,
),
_is_cron_field_supported(
dow_mode,
dow_val,
min_value=0,
max_value=6,
allow_step=False,
),
)
)

# Check for invalid fields or unsupported complex expressions
if "invalid" in (min_mode, hr_mode) or any(
x not in ("*",) and not x.isdigit() for x in (dom, mon, dow)
):
if not is_supported:
logger.warning(f"Unsupported cron expression: {expr}, using fallback schedule")
return get_fallback_next_run(dt)

# Search for next matching time
candidate = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)

for _ in range(MAX_SCHEDULE_SEARCH_MINUTES):
if check_time_matches_cron(candidate, min_mode, min_val, hr_mode, hr_val):
if check_time_matches_cron(
candidate,
min_mode,
min_val,
hr_mode,
hr_val,
dom_mode,
dom_val,
mon_mode,
mon_val,
dow_mode,
dow_val,
):
return candidate
candidate += timedelta(minutes=1)

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ services:
- /mnt/user/appdata/toonami-downlink:/data
environment:
- CRON_SCHEDULE=0 3 * * *
- ALLOW_ANONYMOUS_LOCAL_REFRESH=true
restart: unless-stopped
31 changes: 31 additions & 0 deletions test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sys
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse

Expand Down Expand Up @@ -218,6 +219,34 @@ def test_generation_requires_fresh_artifacts():
print("✅ Stale artifacts are rejected during generation validation")


def test_lan_refresh_host_detection():
"""Ensure LAN/private hosts are treated as local for refresh auth checks."""
from app import server

assert server._is_local_host("127.0.0.1")
assert server._is_local_host("192.168.1.20")
assert server._is_local_host("10.0.0.45")
assert server._is_local_host("172.16.0.10")
assert server._is_local_host("::1")
assert server._is_local_host("::ffff:192.168.1.40")
assert not server._is_local_host("8.8.8.8")
print("✅ LAN/private refresh host detection works as expected")


def test_cron_next_respects_dom_mon_dow():
"""Ensure cron scheduling applies day/month/day-of-week fields."""
from app import server

now = datetime(2026, 2, 24, 12, 0, tzinfo=UTC) # Tuesday

assert server.cron_next(now, "0 3 1 * *") == datetime(2026, 3, 1, 3, 0, tzinfo=UTC)
assert server.cron_next(now, "0 3 * 3 *") == datetime(2026, 3, 1, 3, 0, tzinfo=UTC)
assert server.cron_next(now, "0 3 * * 0") == datetime(2026, 3, 1, 3, 0, tzinfo=UTC)
# Cron semantics: if both DOM and DOW are restricted, either may match.
assert server.cron_next(now, "0 3 25 * 0") == datetime(2026, 2, 25, 3, 0, tzinfo=UTC)
print("✅ Cron scheduling respects day/month/day-of-week fields")


def cleanup():
"""Clean up test data directory."""
data_dir = os.environ.get("DATA_DIR")
Expand All @@ -236,6 +265,8 @@ def main():
test_xtreme_codes_api()
test_input_validation()
test_generation_requires_fresh_artifacts()
test_lan_refresh_host_detection()
test_cron_next_respects_dom_mon_dow()

print("\n🎉 All integration tests passed!")
print("✅ API endpoints are working correctly")
Expand Down
2 changes: 1 addition & 1 deletion web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ async function loadChannels() {

// Enhanced refresh with user feedback and accessibility
byId('refresh').addEventListener('click', async event => {
const btn = event.target;
const btn = event.currentTarget;
const originalText = btn.textContent;

try {
Expand Down
Loading