Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Update job models to use UUID primary keys

Revision ID: fdd026a5ad52
Revises: 18b9095bc772
Create Date: 2025-10-05 10:23:19.044630

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "fdd026a5ad52"
down_revision: Union[str, Sequence[str], None] = "18b9095bc772"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("job_tasks", schema=None) as batch_op:
batch_op.alter_column(
"job_id",
existing_type=sa.VARCHAR(),
type_=sa.Uuid(),
existing_nullable=False,
)

with op.batch_alter_table("jobs", schema=None) as batch_op:
batch_op.alter_column(
"id", existing_type=sa.VARCHAR(), type_=sa.Uuid(), existing_nullable=False
)

# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("jobs", schema=None) as batch_op:
batch_op.alter_column(
"id", existing_type=sa.Uuid(), type_=sa.VARCHAR(), existing_nullable=False
)

with op.batch_alter_table("job_tasks", schema=None) as batch_op:
batch_op.alter_column(
"job_id",
existing_type=sa.Uuid(),
type_=sa.VARCHAR(),
existing_nullable=False,
)

# ### end Alembic commands ###
39 changes: 19 additions & 20 deletions src/borgitory/api/jobs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from typing import List, Dict, Optional
import uuid
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from starlette.responses import StreamingResponse
Expand All @@ -10,6 +11,8 @@
JobCreationResult,
JobStatusError,
JobStopResult,
JobStatusEnum,
JobTypeEnum,
)
from borgitory.dependencies import JobServiceDep, get_browser_timezone_offset
from borgitory.dependencies import JobStreamServiceDep, JobRenderServiceDep
Expand All @@ -23,7 +26,7 @@
class JobResponse(BaseModel):
"""Generic job response model"""

id: str
id: uuid.UUID
status: str
job_type: Optional[str] = None
started_at: Optional[str] = None
Expand All @@ -35,16 +38,13 @@ class JobResponse(BaseModel):
class JobStatusResponse(BaseModel):
"""Job status response model"""

id: str
status: str
running: bool
completed: bool
failed: bool
id: uuid.UUID
status: JobStatusEnum
started_at: Optional[str] = None
completed_at: Optional[str] = None
return_code: Optional[int] = None
error: Optional[str] = None
job_type: Optional[str] = None
job_type: Optional[JobTypeEnum] = None
current_task_index: Optional[int] = None
tasks: Optional[int] = None

Expand Down Expand Up @@ -78,7 +78,7 @@ class JobManagerStatsResponse(BaseModel):
completed_jobs: int
failed_jobs: int
active_processes: int
running_job_ids: List[str]
running_job_ids: List[uuid.UUID]


class QueueStatsResponse(BaseModel):
Expand Down Expand Up @@ -228,7 +228,9 @@ async def stream_current_jobs_html(


@router.get("/{job_id}/status", response_model=JobStatusResponse)
async def get_job_status(job_id: str, job_svc: JobServiceDep) -> JobStatusResponse:
async def get_job_status(
job_id: uuid.UUID, job_svc: JobServiceDep
) -> JobStatusResponse:
"""Get current job status and progress"""
result = await job_svc.get_job_status(job_id)

Expand All @@ -238,23 +240,20 @@ async def get_job_status(job_id: str, job_svc: JobServiceDep) -> JobStatusRespon
# Convert JobStatus to Pydantic JobStatusResponse
return JobStatusResponse(
id=result.id,
status=result.status.value,
running=result.running,
completed=result.completed,
failed=result.failed,
status=result.status,
started_at=result.started_at.isoformat() if result.started_at else None,
completed_at=result.completed_at.isoformat() if result.completed_at else None,
return_code=result.return_code,
error=result.error,
job_type=result.job_type.value,
job_type=result.job_type,
current_task_index=result.current_task_index,
tasks=result.total_tasks,
)


@router.get("/{job_id}/stream")
async def stream_job_output(
job_id: str,
job_id: uuid.UUID,
stream_svc: JobStreamServiceDep,
) -> StreamingResponse:
"""Stream real-time job output via Server-Sent Events"""
Expand All @@ -263,7 +262,7 @@ async def stream_job_output(

@router.post("/{job_id}/stop", response_class=HTMLResponse)
async def stop_job(
job_id: str,
job_id: uuid.UUID,
request: Request,
job_svc: JobServiceDep,
templates: TemplatesDep,
Expand Down Expand Up @@ -297,7 +296,7 @@ async def stop_job(

@router.get("/{job_id}/toggle-details", response_class=HTMLResponse)
async def toggle_job_details(
job_id: str,
job_id: uuid.UUID,
request: Request,
render_svc: JobRenderServiceDep,
templates: TemplatesDep,
Expand All @@ -322,7 +321,7 @@ async def toggle_job_details(

@router.get("/{job_id}/details-static", response_class=HTMLResponse)
async def get_job_details_static(
job_id: str,
job_id: uuid.UUID,
request: Request,
render_svc: JobRenderServiceDep,
templates: TemplatesDep,
Expand All @@ -340,7 +339,7 @@ async def get_job_details_static(

@router.get("/{job_id}/tasks/{task_order}/toggle-details", response_class=HTMLResponse)
async def toggle_task_details(
job_id: str,
job_id: uuid.UUID,
task_order: int,
request: Request,
render_svc: JobRenderServiceDep,
Expand Down Expand Up @@ -390,7 +389,7 @@ async def copy_job_output() -> MessageResponse:

@router.get("/{job_id}/tasks/{task_order}/stream")
async def stream_task_output(
job_id: str,
job_id: uuid.UUID,
task_order: int,
stream_svc: JobStreamServiceDep,
) -> StreamingResponse:
Expand Down
19 changes: 4 additions & 15 deletions src/borgitory/api/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import re
import html
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Request
from fastapi import APIRouter, HTTPException, status, Request, Depends
from fastapi.responses import HTMLResponse
from starlette.templating import _TemplateResponse

Expand All @@ -16,7 +16,9 @@
NotificationProviderRegistryDep,
TemplatesDep,
get_browser_timezone_offset,
get_notification_service,
)
from borgitory.services.notifications.service import NotificationService

router = APIRouter()
logger = logging.getLogger(__name__)
Expand All @@ -27,15 +29,12 @@ def _get_provider_template(provider: str, mode: str = "create") -> Optional[str]
if not provider:
return None

# Validate provider name: only allow alphanumerics, underscores, hyphens
if not re.fullmatch(r"^[\w-]+$", provider):
return None

# Use unified template (no more separate _edit templates)
template_path = f"partials/notifications/providers/{provider}_fields.html"
full_path = f"src/borgitory/templates/{template_path}"

# Ensure fully resolved full_path remains inside the intended provider templates dir
base_templates_dir = os.path.realpath(
os.path.normpath("src/borgitory/templates/partials/notifications/providers/")
)
Expand Down Expand Up @@ -78,9 +77,7 @@ async def get_provider_fields(
"submit_button_text": submit_button_text,
}

# For edit mode, include any configuration values passed via query params or form data
if mode == "edit":
# Get configuration data from query parameters (for HTMX requests)
for key, value in request.query_params.items():
if key not in ["provider", "mode"]:
context[key] = value
Expand All @@ -101,14 +98,11 @@ async def create_notification_config(
) -> _TemplateResponse:
"""Create a new notification configuration using the provider system"""
try:
# Get form data
form_data = await request.form()

# Extract basic fields
name_field = form_data.get("name", "")
provider_field = form_data.get("provider", "")

# Handle both str and UploadFile types
name = name_field.strip() if isinstance(name_field, str) else ""
provider = provider_field.strip() if isinstance(provider_field, str) else ""

Expand All @@ -120,13 +114,11 @@ async def create_notification_config(
status_code=400,
)

# Extract provider-specific configuration
provider_config = {}
for key, value in form_data.items():
if key not in ["name", "provider"] and value:
provider_config[key] = value

# Create config using service
try:
db_config = config_service.create_config(
name=name, provider=provider, provider_config=provider_config
Expand Down Expand Up @@ -191,13 +183,10 @@ async def test_notification_config(
config_id: int,
templates: TemplatesDep,
config_service: NotificationConfigServiceDep,
notification_service: NotificationService = Depends(get_notification_service),
) -> _TemplateResponse:
"""Test a notification configuration using the provider system"""
try:
# Pass encryption service like cloud sync does
from borgitory.dependencies import get_notification_service_singleton

notification_service = get_notification_service_singleton()
success, message = await config_service.test_config_with_service(
config_id, notification_service
)
Expand Down
44 changes: 28 additions & 16 deletions src/borgitory/api/repository_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,17 @@
if not repository:
raise HTTPException(status_code=404, detail="Repository not found")

stats = await stats_svc.get_repository_statistics(repository, db)

if "error" in stats:
raise HTTPException(status_code=500, detail=stats["error"])

return stats
try:
stats = await stats_svc.get_repository_statistics(repository, db)
return stats
except ValueError as e:
# Handle validation errors (e.g., no archives found)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
# Handle other errors
raise HTTPException(
status_code=500, detail=f"Error generating statistics: {str(e)}"
)


@router.get("/{repository_id}/stats/html")
Expand All @@ -68,17 +73,24 @@
if not repository:
raise HTTPException(status_code=404, detail="Repository not found")

# Generate statistics (no timeout for now)
stats = await stats_svc.get_repository_statistics(repository, db)
try:
# Generate statistics (no timeout for now)
stats = await stats_svc.get_repository_statistics(repository, db)

if "error" in stats:
return templates.TemplateResponse(
request,
"partials/repository_stats/stats_panel.html",
{"repository": repository, "stats": stats},
)
except ValueError as e:
# Handle validation errors (e.g., no archives found)
return HTMLResponse(
content=f"<p class='text-red-700 dark:text-red-300 text-sm text-center'>{str(e)}</p>",

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 28 days ago

To fix this information exposure issue, we should avoid displaying the stringified exception message str(e) to the external user. Instead, provide a generic error message whenever a ValueError occurs. The actual exception details can be logged on the server for diagnostics.

Specifically, edit lines 88–91 in src/borgitory/api/repository_stats.py so that:

  • The HTML response sent to the user contains a generic, non-specific error message.
  • The details of the original exception are logged (e.g., using logging.warning) for developers’ reference.

No additional imports are needed, as the logging module is already imported.

Suggested changeset 1
src/borgitory/api/repository_stats.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/borgitory/api/repository_stats.py b/src/borgitory/api/repository_stats.py
--- a/src/borgitory/api/repository_stats.py
+++ b/src/borgitory/api/repository_stats.py
@@ -85,8 +85,13 @@
         )
     except ValueError as e:
         # Handle validation errors (e.g., no archives found)
+        # Log validation error details for diagnostics, return generic message to user
+        logging.warning(
+            "Validation error during repository statistics HTML generation (repository_id=%s): %s",
+            repository_id, str(e)
+        )
         return HTMLResponse(
-            content=f"<p class='text-red-700 dark:text-red-300 text-sm text-center'>{str(e)}</p>",
+            content="<p class='text-red-700 dark:text-red-300 text-sm text-center'>A validation error has occurred while generating repository statistics.</p>",
             status_code=400,
         )
     except Exception:
EOF
@@ -85,8 +85,13 @@
)
except ValueError as e:
# Handle validation errors (e.g., no archives found)
# Log validation error details for diagnostics, return generic message to user
logging.warning(
"Validation error during repository statistics HTML generation (repository_id=%s): %s",
repository_id, str(e)
)
return HTMLResponse(
content=f"<p class='text-red-700 dark:text-red-300 text-sm text-center'>{str(e)}</p>",
content="<p class='text-red-700 dark:text-red-300 text-sm text-center'>A validation error has occurred while generating repository statistics.</p>",
status_code=400,
)
except Exception:
Copilot is powered by AI and may make mistakes. Always verify output.
status_code=400,
)
except Exception as e:
# Handle other errors
return HTMLResponse(
content=f"<p class='text-red-700 dark:text-red-300 text-sm text-center'>{stats['error']}</p>",
content=f"<p class='text-red-700 dark:text-red-300 text-sm text-center'>Error generating statistics: {str(e)}</p>",
status_code=500,
)

return templates.TemplateResponse(
request,
"partials/repository_stats/stats_panel.html",
{"repository": repository, "stats": stats},
)
10 changes: 4 additions & 6 deletions src/borgitory/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from borgitory.services.notifications.providers.discord_provider import HttpClient
from borgitory.config.command_runner_config import CommandRunnerConfig
from borgitory.config.job_manager_config import JobManagerEnvironmentConfig
from borgitory.services.jobs.job_manager import JobManagerConfig
from borgitory.services.jobs.job_models import JobManagerConfig
from borgitory.services.cloud_providers.registry_factory import RegistryFactory
from borgitory.services.volumes.file_system_interface import FileSystemInterface
from borgitory.protocols.repository_protocols import ArchiveServiceProtocol
Expand Down Expand Up @@ -699,7 +699,7 @@ def get_job_manager_config(
Returns:
JobManagerConfig: Configured JobManager instance
"""
from borgitory.services.jobs.job_manager import JobManagerConfig
from borgitory.services.jobs.job_models import JobManagerConfig

return JobManagerConfig(
max_concurrent_backups=env_config.max_concurrent_backups,
Expand Down Expand Up @@ -728,10 +728,8 @@ def get_job_manager_singleton() -> "JobManagerProtocol":
Returns:
JobManagerProtocol: Cached singleton instance
"""
from borgitory.services.jobs.job_manager import (
JobManagerDependencies,
JobManagerFactory,
)
from borgitory.services.jobs.job_models import JobManagerDependencies
from borgitory.services.jobs.job_manager_factory import JobManagerFactory

# Resolve all dependencies directly (not via FastAPI DI)
env_config = get_job_manager_env_config()
Expand Down
Loading
Loading