fix(backup): parse subpath-hosted Gitea/Forgejo repository URLs (#2642)

Gitea/Forgejo served under a ROOT_URL path prefix (e.g. https://host/gitea)
place repos at /<prefix>/owner/repo. The Gitea backend assumed a host-root
layout: parse_repo_url required exactly two path segments (so subpath URLs
raised "Cannot parse repository URL") and get_api_base dropped the prefix,
yielding https://host/api/v1 instead of https://host/gitea/api/v1.

Treat the final two path segments as owner/repo and keep leading segments as
a base-path prefix; derive the API base as {scheme}://{host}{prefix}/api/v1.
Root-hosted instances are unaffected. Forgejo inherits the fix; GitHub/GitLab
are untouched.
This commit is contained in:
maziggy
2026-07-27 12:53:43 +02:00
parent 98b2900812
commit da33ba64d1
3 changed files with 44 additions and 6 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Fixed
- **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers.
- **The Print Queue's History tab showed a count of all prints but only ever displayed the first 50, with no way to reach the rest (#2682, reporter @pchulpjoost)** — The History header read e.g. `History (311 items)`, but only 50 rows rendered and there was no "load more" control, so 261 finished prints were unreachable. **Root cause.** The full history is already loaded client-side (the queue endpoint has no limit) and sorted correctly — the header counts the whole list — but the row builder hard-sliced it to `items.slice(0, 50)`, a fixed cap with no accompanying control. Nothing was missing server-side; it simply wasn't drawn. **Fix.** History now paginates: it draws the 50 most-recent prints and, when there are more, shows a **Show more** button (with a `Showing X of Y` count) that loads the next 50, repeating until the whole history is on screen. The page size resets to the first page only when you re-sort or change the location filter — deliberately not on the periodic queue poll, so an expanded view doesn't collapse mid-scroll. Frontend-only; batch grouping and per-row actions are unchanged. Covered by a test asserting the 50-row cap, the `Showing 50 of 60` count, and that Show more reveals the remainder. Wiki updated.
- **LDAP Distinguished Names weren't redacted from the support bundle / bug report (#2681, reporter @MaxBareiss)** — With LDAP auth in use, the debug log carried lines like `LDAP authentication successful for user: … (DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, …)`. A DN's leaf `CN` is the user's real name — PII on par with the email address Bambuddy already redacts — and it passed straight through into an uploaded support bundle. **Fix.** The log sanitizer (used by both the support bundle and the in-app bug report) now redacts LDAP DNs to `[DN]` wherever they appear — the auth line, ldap3 exception strings, and group DNs alike — matching a run of `attr=value` RDN components (`CN/OU/DC/UID/…`) so ordinary `key=value` log text isn't affected. As primary hygiene the LDAP service also no longer logs the raw DN on successful auth (the username plus group count is enough). Covered by tests, including the exact reported line and non-DN `key=value` lines that must be left intact. Redaction list on the Bug Report wiki page updated.
- **An external USB camera could stay locked (LED stuck on) after closing the live view, blocking reopen (#2675, reporter @bitbarista)** — Closing an external USB (V4L2) camera's live view abruptly — tab/popup closed, or a dropped connection — could leave the backend's `ffmpeg` process running and holding `/dev/videoN` open. The camera LED stayed lit and the next attempt to open the view (or click Test) failed or took 10-30+ seconds while the new `ffmpeg` fought for exclusive device access. **Root cause.** This is the same class of leak as #776 (fixed for the built-in RTSP path), but the external/USB path was never wired into that fix. #776 added the `_active_streams` / `_disconnect_events` / spawned-PID registries so both the `/camera/stop` endpoint and the periodic orphan janitor could find and kill leaked ffmpeg — but external streams registered into none of them, so for USB cameras both were structurally blind: `/camera/stop` returned `{"stopped": 0}` even while a stream was genuinely running, and the janitor's `/proc` net matched only `rtsp(s)://bblp:` cmdlines, never a USB `ffmpeg`. Cleanup ran only via the stream generator's own `finally`, which an abrupt disconnect can skip. **Fix.** External USB (and external-RTSP) streams now register their `ffmpeg` process into the same registries the built-in path uses, so `/camera/stop` terminates them promptly (now `{"stopped": 1}`) and the janitor reaps any that leak within its cleanup interval. The `/proc` safety-net scan also now recognises USB (`-f v4l2`) `ffmpeg`, so orphans surviving an app restart are caught too; a leaked process that hangs on a still-locked device (rather than exiting) is registered before the startup probe so it can still be killed. Covered by tests: the stream hands its process to the registry, the stop endpoint and janitor both reap a registered external stream, and the `/proc` scan matches `v4l2` while ignoring unrelated `ffmpeg`. Thanks to @bitbarista for the precise diagnosis. (Reported alongside a working fix; implemented here.)
+13 -6
View File
@@ -63,14 +63,21 @@ class GiteaBackend(GitHubBackend):
return tree_node.get("sha")
return None
# Gitea/Forgejo can be hosted under a URL path prefix (ROOT_URL like
# https://host/gitea), so the repo lives at /<prefix...>/<owner>/<repo>
# rather than at the host root (#2642). Capture the scheme+host+prefix as
# one group and the final two path segments as owner/repo; the lazy prefix
# group is empty for a root-hosted instance. One shared pattern keeps
# parse_repo_url() and get_api_base() from drifting.
_HTTPS_REPO_RE = re.compile(
r"(https?://[\w.\-]+(?::\d+)?(?:/[\w.\-]+)*?)/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$"
)
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
if not url or len(url) > 500:
raise ValueError("Invalid Git URL: URL too long or empty")
match = re.match(
r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
url,
)
match = self._HTTPS_REPO_RE.match(url)
if match:
return match.group(2), match.group(3).removesuffix(".git")
match = re.match(
@@ -82,8 +89,8 @@ class GiteaBackend(GitHubBackend):
raise ValueError(f"Cannot parse repository URL: {url}")
def get_api_base(self, repo_url: str) -> str:
"""Derive API base from the repository URL's scheme and host."""
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
"""Derive API base from the repository URL's scheme, host and any path prefix."""
match = self._HTTPS_REPO_RE.match(repo_url)
if match:
return f"{match.group(1)}/api/v1"
raise ValueError(f"Cannot derive API base from URL: {repo_url}")
+30
View File
@@ -463,6 +463,26 @@ class TestGiteaBackendApiBase:
assert owner == "owner"
assert repo == "repo"
def test_parse_url_subpath_hosted(self):
# Gitea under a ROOT_URL path prefix, e.g. https://host/gitea (#2642)
owner, repo = self.backend.parse_repo_url("https://DOMAIN/gitea/user/repo")
assert owner == "user"
assert repo == "repo"
def test_parse_url_subpath_hosted_with_git_suffix(self):
owner, repo = self.backend.parse_repo_url("https://DOMAIN/gitea/user/repo.git")
assert owner == "user"
assert repo == "repo"
def test_derives_api_base_subpath_hosted(self):
# API base must keep the path prefix so calls hit /gitea/api/v1 (#2642)
result = self.backend.get_api_base("https://DOMAIN/gitea/user/repo")
assert result == "https://DOMAIN/gitea/api/v1"
def test_derives_api_base_subpath_hosted_with_port(self):
result = self.backend.get_api_base("https://DOMAIN:3000/gitea/user/repo")
assert result == "https://DOMAIN:3000/gitea/api/v1"
class TestGiteaBackendPushFiles:
def setup_method(self):
@@ -1358,6 +1378,16 @@ class TestForgejoBackendApiBase:
assert owner == "owner"
assert repo == "repo"
def test_parse_url_subpath_hosted(self):
# Forgejo inherits GiteaBackend's subpath handling (#2642)
owner, repo = self.backend.parse_repo_url("https://DOMAIN/forgejo/user/repo")
assert owner == "user"
assert repo == "repo"
def test_derives_api_base_subpath_hosted(self):
result = self.backend.get_api_base("https://DOMAIN/forgejo/user/repo")
assert result == "https://DOMAIN/forgejo/api/v1"
class TestForgejoTestConnection:
"""ForgejoBackend overrides test_connection to handle Forgejo v15+ 404-not-403 behaviour."""