diff --git a/app/api/endpoints/feedback_attach.py b/app/api/endpoints/feedback_attach.py new file mode 100644 index 0000000..f28f4aa --- /dev/null +++ b/app/api/endpoints/feedback_attach.py @@ -0,0 +1,103 @@ +"""Screenshot attachment endpoint for in-app feedback. + +After the cf-core feedback router creates a Forgejo issue, the frontend +can call POST /feedback/attach to upload a screenshot and pin it as a +comment on that issue. + +The endpoint is separate from the cf-core router so Kiwi owns it +without modifying shared infrastructure. +""" +from __future__ import annotations + +import base64 +import os + +import requests +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter() + +_FORGEJO_BASE = os.environ.get( + "FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1" +) +_REPO = "Circuit-Forge/kiwi" +_MAX_BYTES = 5 * 1024 * 1024 # 5 MB + + +class AttachRequest(BaseModel): + issue_number: int + filename: str = Field(default="screenshot.png", max_length=80) + image_b64: str # data URI or raw base64 + + +class AttachResponse(BaseModel): + comment_url: str + + +def _forgejo_headers() -> dict[str, str]: + token = os.environ.get("FORGEJO_API_TOKEN", "") + return {"Authorization": f"token {token}"} + + +def _decode_image(image_b64: str) -> tuple[bytes, str]: + """Return (raw_bytes, mime_type) from a base64 string or data URI.""" + if image_b64.startswith("data:"): + header, _, data = image_b64.partition(",") + mime = header.split(";")[0].split(":")[1] if ":" in header else "image/png" + else: + data = image_b64 + mime = "image/png" + return base64.b64decode(data), mime + + +@router.post("/attach", response_model=AttachResponse) +def attach_screenshot(payload: AttachRequest) -> AttachResponse: + """Upload a screenshot to a Forgejo issue as a comment with embedded image. + + The image is uploaded as an issue asset, then referenced in a comment + so it is visible inline when the issue is viewed. + """ + token = os.environ.get("FORGEJO_API_TOKEN", "") + if not token: + raise HTTPException(status_code=503, detail="Feedback not configured.") + + raw_bytes, mime = _decode_image(payload.image_b64) + + if len(raw_bytes) > _MAX_BYTES: + raise HTTPException( + status_code=413, + detail=f"Screenshot exceeds 5 MB limit ({len(raw_bytes) // 1024} KB received).", + ) + + # Upload image as issue asset + asset_resp = requests.post( + f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/assets", + headers=_forgejo_headers(), + files={"attachment": (payload.filename, raw_bytes, mime)}, + timeout=20, + ) + if not asset_resp.ok: + raise HTTPException( + status_code=502, + detail=f"Forgejo asset upload failed: {asset_resp.text[:200]}", + ) + + asset_url = asset_resp.json().get("browser_download_url", "") + + # Pin as a comment so the image is visible inline + comment_body = f"**Screenshot attached by reporter:**\n\n" + comment_resp = requests.post( + f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/comments", + headers={**_forgejo_headers(), "Content-Type": "application/json"}, + json={"body": comment_body}, + timeout=15, + ) + if not comment_resp.ok: + raise HTTPException( + status_code=502, + detail=f"Forgejo comment failed: {comment_resp.text[:200]}", + ) + + comment_url = comment_resp.json().get("html_url", "") + return AttachResponse(comment_url=comment_url) diff --git a/app/api/routes.py b/app/api/routes.py index e5fd5a0..451c395 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate, meal_plans, orch_usage +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage from app.api.endpoints.community import router as community_router api_router = APIRouter() @@ -13,7 +13,8 @@ api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags= api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"]) api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) -api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) +api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) +api_router.include_router(feedback_attach.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(household.router, prefix="/household", tags=["household"]) api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) diff --git a/frontend/src/components/FeedbackButton.vue b/frontend/src/components/FeedbackButton.vue index 800ab25..6d75a27 100644 --- a/frontend/src/components/FeedbackButton.vue +++ b/frontend/src/components/FeedbackButton.vue @@ -75,6 +75,21 @@ /> +
{{ stepError }}
@@ -140,6 +155,30 @@ import { ref, computed, onMounted } from 'vue' const props = defineProps<{ currentTab?: string }>() +const fileInput = ref