Skip to content
23 changes: 23 additions & 0 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import logging
import os
import sys
import secrets
import tempfile

from platformdirs import user_data_dir

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -41,6 +44,26 @@
THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails")
IMAGES_PATH = "./images"

# Generate a fresh cryptographic token on every backend startup.
# This token is written to a temporary file so that only the local Tauri
# frontend — which reads the same file — can authenticate shutdown requests.
# Any other process on the machine will not know the token and will be
# rejected with 403 Forbidden.
SHUTDOWN_TOKEN: str = secrets.token_hex(32)
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")

# Write with owner-only permissions (0o600) so other local users on
# multi-user systems cannot read the token and trigger a shutdown.
try:
_fd = os.open(SHUTDOWN_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(_fd, "w") as _f:
_f.write(SHUTDOWN_TOKEN)
# Enforce permissions even if file pre-existed with looser permissions
os.chmod(SHUTDOWN_TOKEN_FILE, 0o600)
except OSError as e:
logger.fatal(f"Failed to write shutdown token to {SHUTDOWN_TOKEN_FILE}: {e}")
logger.fatal("Cannot start backend securely. Exiting.")
sys.exit(1)

def _get_env_float(
name: str,
Expand Down
30 changes: 26 additions & 4 deletions backend/app/routes/shutdown.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import asyncio
import hmac
import os
import platform
import signal
from fastapi import APIRouter
from typing import Optional
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from app.config.settings import SHUTDOWN_TOKEN
from app.logging.setup_logging import get_logger

logger = get_logger(__name__)
Expand All @@ -28,6 +31,14 @@ async def _delayed_shutdown(delay: float = 0.5):
await asyncio.sleep(delay)
logger.info("Backend shutdown initiated, exiting process...")

# Clean up token file
from app.config.settings import SHUTDOWN_TOKEN_FILE
try:
os.remove(SHUTDOWN_TOKEN_FILE)
logger.info("Shutdown token file removed")
except OSError as e:
logger.warning(f"Could not remove shutdown token file: {e}")

if platform.system() == "Windows":
# Windows: SIGTERM doesn't work reliably with uvicorn subprocesses
os._exit(0)
Expand All @@ -37,16 +48,27 @@ async def _delayed_shutdown(delay: float = 0.5):


@router.post("/shutdown", response_model=ShutdownResponse)
async def shutdown():
async def shutdown(x_shutdown_token: Optional[str] = Header(default=None)):
"""
Gracefully shutdown the PictoPy backend.

This endpoint schedules backend server termination after response is sent.
The frontend is responsible for shutting down the sync service separately.
This endpoint requires the ``X-Shutdown-Token`` header to match the token
generated at startup. The token is shared with the Tauri frontend via a
temporary file, so only the PictoPy application itself can trigger this
endpoint — arbitrary local processes are rejected with 403 Forbidden.

Returns:
ShutdownResponse with status and message
"""
if x_shutdown_token is None:
logger.warning("Shutdown attempt rejected: missing token")
raise HTTPException(status_code=401, detail="Unauthorized")

# Use constant-time comparison to prevent timing-based token guessing
if not hmac.compare_digest(x_shutdown_token, SHUTDOWN_TOKEN):
logger.warning("Shutdown attempt rejected: invalid token")
raise HTTPException(status_code=403, detail="Forbidden")

logger.info("Shutdown request received for PictoPy backend")

# Define callback to handle potential exceptions in the background task
Expand Down
51 changes: 46 additions & 5 deletions frontend/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,27 +53,63 @@ fn on_window_event(window: &Window, event: &WindowEvent) {
}

#[cfg(unix)]
fn kill_process(process: &sysinfo::Process) {
fn kill_process(process: &sysinfo::Process) -> Result<(), String> {
use sysinfo::Signal;
let _ = process.kill_with(Signal::Term);
Ok(())
}

#[cfg(windows)]
pub fn kill_process(_process: &sysinfo::Process) -> Result<(), String> {
fn kill_process(_process: &sysinfo::Process) -> Result<(), String> {
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::str::FromStr;

// Read the per-session shutdown token written by the backend at startup.
// This guarantees that only the PictoPy frontend — which runs alongside
// the backend — can authenticate the shutdown request.
let token_path = std::env::temp_dir().join("pictopy_shutdown.token");
let token = match std::fs::read_to_string(&token_path) {
Ok(t) => {
let trimmed = t.trim().to_string();
if trimmed.is_empty() {
eprintln!("[PictoPy] Warning: shutdown token file is empty — shutdown request will be rejected by the backend.");
}
trimmed
}
Err(e) => {
eprintln!("[PictoPy] Warning: could not read shutdown token file ({token_path:?}): {e} — shutdown request will be rejected by the backend.");
String::new()
}
};

let mut headers = HeaderMap::new();
if !token.is_empty() {
if let (Ok(name), Ok(value)) = (
HeaderName::from_str("x-shutdown-token"),
HeaderValue::from_str(&token),
) {
headers.insert(name, value);
}
}

let client = Client::builder().build().map_err(|e| e.to_string())?;

for (name, url, _) in &ENDPOINTS {
match client.post(*url).send() {
match client.post(*url).headers(headers.clone()).send() {
Ok(resp) => {
let status = resp.status();

if status.is_success() {
println!("[{}] Shutdown OK ({})", name, status);
}
}
Err(_err) => {}
Err(_err) => {
eprintln!(
"[{}] Failed to send shutdown request to {}: {}",
name, url, _err
);
}
}
}

Expand All @@ -95,7 +131,12 @@ fn kill_process_tree() -> Result<(), String> {
let name = process.name().to_string_lossy();

if target_names.iter().any(|t| name.eq_ignore_ascii_case(t)) {
let _ = kill_process(process);
if let Err(e) = kill_process(process) {
eprintln!(
"[PictoPy] Failed to send shutdown signal to process {}: {}",
name, e
);
}
}
}

Expand Down
14 changes: 13 additions & 1 deletion sync-microservice/app/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from platformdirs import user_data_dir
import os
import secrets
import tempfile
import time as _time
import warnings

from platformdirs import user_data_dir

# Model Exports Path
MODEL_EXPORTS_PATH = "app/models/ONNX_Exports"
Expand Down Expand Up @@ -28,3 +33,10 @@
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
THUMBNAIL_IMAGES_PATH = "./images/thumbnails"
IMAGES_PATH = "./images"

# The backend writes a fresh cryptographic token to this temp file on every
# startup. The sync microservice reads the same file so both services share
# a single token without any additional coordination.
# The token is loaded during the FastAPI lifespan startup hook.
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")
SHUTDOWN_TOKEN: str = ""
21 changes: 21 additions & 0 deletions sync-microservice/app/core/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ async def lifespan(app: FastAPI):
# Startup
logger.info("Starting PictoPy Sync Microservice...")

# Wait for shutdown token from backend (up to 5 seconds)
import app.config.settings as settings
logger.info("Waiting for shutdown token from backend...")
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
try:
with open(settings.SHUTDOWN_TOKEN_FILE) as f:
token = f.read().strip()
if token:
settings.SHUTDOWN_TOKEN = token
logger.info("Shutdown token loaded successfully")
break
except FileNotFoundError:
pass
Comment thread
coderabbitai[bot] marked this conversation as resolved.
time.sleep(0.1)

if not settings.SHUTDOWN_TOKEN:
logger.error(f"pictopy_shutdown.token not found at {settings.SHUTDOWN_TOKEN_FILE} after 5 seconds")
logger.error("Ensure the backend starts before the sync service.")
raise RuntimeError("Backend shutdown token not found")

# Check database connection
logger.info("Checking database connection...")
connection_timeout = 60
Expand Down
36 changes: 31 additions & 5 deletions sync-microservice/app/routes/shutdown.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import asyncio
import hmac
import os
import platform
import signal
from fastapi import APIRouter
from typing import Optional
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from app.config.settings import SHUTDOWN_TOKEN
from app.utils.watcher import watcher_util_stop_folder_watcher
from app.logging.setup_logging import get_sync_logger

Expand All @@ -29,6 +32,14 @@ async def _delayed_shutdown(delay: float = 0.1):
await asyncio.sleep(delay)
logger.info("Exiting sync microservice...")

# Clean up token file
from app.config.settings import SHUTDOWN_TOKEN_FILE
try:
os.remove(SHUTDOWN_TOKEN_FILE)
logger.info("Shutdown token file removed")
except OSError as e:
logger.warning(f"Could not remove shutdown token file: {e}")

if platform.system() == "Windows":
# Windows: SIGTERM doesn't work reliably with uvicorn subprocesses
os._exit(0)
Expand All @@ -38,18 +49,33 @@ async def _delayed_shutdown(delay: float = 0.1):


@router.post("/shutdown", response_model=ShutdownResponse)
async def shutdown():
async def shutdown(x_shutdown_token: Optional[str] = Header(default=None)):
"""
Gracefully shutdown the sync microservice.

This endpoint requires the ``X-Shutdown-Token`` header to match the token
generated by the backend at startup. The token is shared between services
via a temporary file, so only the PictoPy application itself can trigger
shutdown — arbitrary local processes are rejected with 403 Forbidden.

This endpoint:
1. Stops the folder watcher
2. Schedules server termination after response is sent
3. Returns confirmation to the caller
1. Validates the shutdown token
2. Stops the folder watcher
3. Schedules server termination after response is sent
4. Returns confirmation to the caller

Returns:
ShutdownResponse with status and message
"""
if x_shutdown_token is None:
logger.warning("Shutdown attempt rejected: missing token")
raise HTTPException(status_code=401, detail="Unauthorized")

# Use constant-time comparison to prevent timing-based token guessing
if not hmac.compare_digest(x_shutdown_token, SHUTDOWN_TOKEN):
logger.warning("Shutdown attempt rejected: invalid token")
raise HTTPException(status_code=403, detail="Forbidden")

logger.info("Shutdown request received for sync microservice")

try:
Expand Down
Loading