mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
106 lines
4.0 KiB
Python
106 lines
4.0 KiB
Python
"""Forgejo backend — diverges from Gitea on token-scope validation (v15+)."""
|
|
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from backend.app.services.git_providers.gitea import GiteaBackend
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ForgejoBackend(GiteaBackend):
|
|
"""Backend for Forgejo instances.
|
|
|
|
Forgejo v15+ returns 404 (not 403) for private repositories when the token
|
|
lacks repository scope, requiring a /user pre-check to distinguish bad tokens
|
|
from inaccessible repos. test_connection is overridden to handle this.
|
|
Other methods are inherited from GiteaBackend unchanged.
|
|
"""
|
|
|
|
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
|
|
try:
|
|
owner, repo = self.parse_repo_url(repo_url)
|
|
api_base = self.get_api_base(repo_url)
|
|
headers = self.get_headers(token)
|
|
|
|
# Verify token validity before hitting the repo. On Forgejo v15+,
|
|
# private repos return 404 (not 403) when the token lacks repo scope,
|
|
# so we must distinguish "bad token" from "token OK but repo not visible".
|
|
user_resp = await client.get(f"{api_base}/user", headers=headers)
|
|
if user_resp.status_code == 401:
|
|
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
|
|
if user_resp.status_code == 403:
|
|
return {
|
|
"success": False,
|
|
"message": "Token has no read:user scope; cannot validate identity",
|
|
"repo_name": None,
|
|
"permissions": None,
|
|
}
|
|
if user_resp.status_code != 200:
|
|
return {
|
|
"success": False,
|
|
"message": f"Forgejo API error on /user: {user_resp.status_code}",
|
|
"repo_name": None,
|
|
"permissions": None,
|
|
}
|
|
|
|
repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
|
|
|
|
if repo_resp.status_code == 404:
|
|
return {
|
|
"success": False,
|
|
"message": (
|
|
"Repository not found or token cannot access it. "
|
|
"On Forgejo v15+, private repositories return 404 (not 403) "
|
|
"when the token lacks repository scope."
|
|
),
|
|
"repo_name": None,
|
|
"permissions": None,
|
|
}
|
|
|
|
if repo_resp.status_code != 200:
|
|
return {
|
|
"success": False,
|
|
"message": f"API error: {repo_resp.status_code}",
|
|
"repo_name": None,
|
|
"permissions": None,
|
|
}
|
|
|
|
data = repo_resp.json()
|
|
permissions = data.get("permissions", {})
|
|
is_private = bool(data.get("private", False))
|
|
|
|
if not permissions.get("push", False):
|
|
return {
|
|
"success": False,
|
|
"message": "Token does not have push permission to this repository",
|
|
"repo_name": data.get("full_name"),
|
|
"permissions": permissions,
|
|
"is_private": is_private,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Connection successful",
|
|
"repo_name": data.get("full_name"),
|
|
"permissions": permissions,
|
|
"is_private": is_private,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.exception("Forgejo connection test failed")
|
|
detail = str(e)[:200]
|
|
message = (
|
|
f"Connection failed: {type(e).__name__}: {detail}"
|
|
if detail
|
|
else f"Connection failed: {type(e).__name__}"
|
|
)
|
|
return {
|
|
"success": False,
|
|
"message": message,
|
|
"repo_name": None,
|
|
"permissions": None,
|
|
"is_private": None,
|
|
}
|