From b94843861b71b2e955c8ef10d2ea6b94c2a7408a Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sat, 18 Jul 2026 15:55:29 +0200 Subject: [PATCH 1/3] Dedupe giveaway endpoint building between useGiveaways hooks buildGiveawaysEndpoint holds the endpoint selection and filter-to-param mapping once; useGiveaways and useInfiniteGiveaways only differ in how they compute the offset. --- frontend/src/hooks/useGiveaways.ts | 144 ++++++++++++----------------- 1 file changed, 57 insertions(+), 87 deletions(-) diff --git a/frontend/src/hooks/useGiveaways.ts b/frontend/src/hooks/useGiveaways.ts index 2d5596e..10f7451 100644 --- a/frontend/src/hooks/useGiveaways.ts +++ b/frontend/src/hooks/useGiveaways.ts @@ -47,6 +47,61 @@ interface GiveawaysApiResponse { count: number; } +/** + * Build the endpoint URL (path + query string) for a giveaways request. + * Shared by useGiveaways and useInfiniteGiveaways so the filter mapping + * stays in one place. + */ +function buildGiveawaysEndpoint( + filters: Omit, + limit: number, + offset: number +): string { + const params = new URLSearchParams(); + + // Determine which endpoint to use based on status filter + let endpointPath = '/api/v1/giveaways'; + if (filters.status === 'active') { + endpointPath = '/api/v1/giveaways/active'; + } else if (filters.status === 'wishlist') { + endpointPath = '/api/v1/giveaways/wishlist'; + } else if (filters.status === 'won') { + endpointPath = '/api/v1/giveaways/won'; + } + + // Add filter parameters + if (filters.status === 'entered') { + params.set('is_entered', 'true'); + params.set('active_only', 'true'); // Only show active entered giveaways + } + if (filters.type && filters.type !== 'all') { + params.set('type', filters.type); + } + if (filters.search) { + params.set('search', filters.search); + } + if (filters.sort) { + params.set('sort', filters.sort); + } + if (filters.order) { + params.set('order', filters.order); + } + if (filters.minScore !== undefined && filters.minScore > 0) { + params.set('min_score', String(filters.minScore)); + } + if (filters.safetyFilter && filters.safetyFilter !== 'all') { + params.set('is_safe', filters.safetyFilter === 'safe' ? 'true' : 'false'); + } + + params.set('limit', String(limit)); + if (offset > 0) { + params.set('offset', String(offset)); + } + + const queryString = params.toString(); + return `${endpointPath}${queryString ? `?${queryString}` : ''}`; +} + /** * Fetch giveaways with optional filters */ @@ -54,54 +109,11 @@ export function useGiveaways(filters: GiveawayFilters = {}) { return useQuery({ queryKey: giveawayKeys.list(filters), queryFn: async () => { - const params = new URLSearchParams(); - - // Determine which endpoint to use based on status filter - let endpointPath = '/api/v1/giveaways'; - if (filters.status === 'active') { - endpointPath = '/api/v1/giveaways/active'; - } else if (filters.status === 'wishlist') { - endpointPath = '/api/v1/giveaways/wishlist'; - } else if (filters.status === 'won') { - endpointPath = '/api/v1/giveaways/won'; - } - - // Add filter parameters - if (filters.status === 'entered') { - params.set('is_entered', 'true'); - params.set('active_only', 'true'); // Only show active entered giveaways - } - if (filters.type && filters.type !== 'all') { - params.set('type', filters.type); - } - if (filters.search) { - params.set('search', filters.search); - } - if (filters.sort) { - params.set('sort', filters.sort); - } - if (filters.order) { - params.set('order', filters.order); - } - if (filters.minScore !== undefined && filters.minScore > 0) { - params.set('min_score', String(filters.minScore)); - } - if (filters.safetyFilter && filters.safetyFilter !== 'all') { - params.set('is_safe', filters.safetyFilter === 'safe' ? 'true' : 'false'); - } - - // Pagination const limit = filters.limit || 20; const page = filters.page || 1; const offset = (page - 1) * limit; - params.set('limit', String(limit)); - if (offset > 0) { - params.set('offset', String(offset)); - } - - const queryString = params.toString(); - const endpoint = `${endpointPath}${queryString ? `?${queryString}` : ''}`; + const endpoint = buildGiveawaysEndpoint(filters, limit, offset); const response = await api.get(endpoint); if (!response.success) { @@ -129,51 +141,9 @@ export function useInfiniteGiveaways(filters: Omit = {} return useInfiniteQuery({ queryKey: [...giveawayKeys.lists(), 'infinite', filters], queryFn: async ({ pageParam = 0 }) => { - const params = new URLSearchParams(); - - // Determine which endpoint to use based on status filter - let endpointPath = '/api/v1/giveaways'; - if (filters.status === 'active') { - endpointPath = '/api/v1/giveaways/active'; - } else if (filters.status === 'wishlist') { - endpointPath = '/api/v1/giveaways/wishlist'; - } else if (filters.status === 'won') { - endpointPath = '/api/v1/giveaways/won'; - } - - // Add filter parameters - if (filters.status === 'entered') { - params.set('is_entered', 'true'); - params.set('active_only', 'true'); // Only show active entered giveaways - } - if (filters.type && filters.type !== 'all') { - params.set('type', filters.type); - } - if (filters.search) { - params.set('search', filters.search); - } - if (filters.sort) { - params.set('sort', filters.sort); - } - if (filters.order) { - params.set('order', filters.order); - } - if (filters.minScore !== undefined && filters.minScore > 0) { - params.set('min_score', String(filters.minScore)); - } - if (filters.safetyFilter && filters.safetyFilter !== 'all') { - params.set('is_safe', filters.safetyFilter === 'safe' ? 'true' : 'false'); - } - - // Pagination const limit = filters.limit || 20; - params.set('limit', String(limit)); - if (pageParam > 0) { - params.set('offset', String(pageParam)); - } - const queryString = params.toString(); - const endpoint = `${endpointPath}${queryString ? `?${queryString}` : ''}`; + const endpoint = buildGiveawaysEndpoint(filters, limit, pageParam); const response = await api.get(endpoint); if (!response.success) { From 04c8b8d0220e2bb763237bf12f1af2d0b9c505d6 Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sat, 18 Jul 2026 15:55:29 +0200 Subject: [PATCH 2/3] Add optional PUID/PGID support for non-root backend on Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New entrypoint: when PUID (and optionally PGID) is set, the backend runs as that uid/gid and /config is chown'd accordingly, so database and logs are owned by the host user instead of root. Default remains root — required for rootless podman, where container-root already maps to the host user. --- Dockerfile | 36 ++++++++++++++++++++++++++++++++---- docker-compose.yml | 5 +++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a8b948..278c3fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,8 +56,10 @@ ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app COPY backend/src/ ./src/ -# Create config directory for persistent data (database + logs) -RUN mkdir -p /config +# Create config directory for persistent data (database + logs), and the +# unprivileged user the backend runs as when PUID/PGID are set +RUN mkdir -p /config \ + && useradd --system --no-create-home --shell /usr/sbin/nologin app # Copy frontend build to nginx html directory COPY --from=frontend-build /frontend/dist /usr/share/nginx/html @@ -133,6 +135,7 @@ stderr_logfile_maxbytes=0 [program:backend] command=/opt/venv/bin/python -m uvicorn api.main:app --host 127.0.0.1 --port 8000 directory=/app/src +user=%(ENV_BACKEND_USER)s autostart=true autorestart=true stdout_logfile=/dev/stdout @@ -141,6 +144,30 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 SUPERVISOR_CONF +# Entrypoint: optionally drop the backend to an unprivileged user. +# Set PUID (and optionally PGID) to run the backend as that uid/gid and +# chown /config accordingly — useful for Docker where container-root is +# real root. Leave unset for the default root mode (the right choice for +# rootless podman, where container-root already maps to the host user). +COPY <<'ENTRYPOINT_SH' /usr/local/bin/docker-entrypoint.sh +#!/bin/sh +set -e + +if [ -n "$PUID" ]; then + PGID="${PGID:-$PUID}" + groupmod -o -g "$PGID" app + usermod -o -u "$PUID" -g "$PGID" app + chown -R "$PUID:$PGID" /config + export BACKEND_USER=app + echo "entrypoint: backend will run as uid=$PUID gid=$PGID" +else + export BACKEND_USER=root +fi + +exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf +ENTRYPOINT_SH +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + # Expose port 80 (nginx serves both frontend and proxies to backend) EXPOSE 80 @@ -148,5 +175,6 @@ EXPOSE 80 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost/api/v1/system/health || exit 1 -# Start supervisor (manages nginx + uvicorn) -CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] +# Start via the entrypoint (handles optional PUID/PGID, then runs +# supervisord which manages nginx + uvicorn) +CMD ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index af6b151..3d81c4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: environment: - ENVIRONMENT=production - DEBUG=false + # On Docker (real root in the container), uncomment to run the backend + # as your host user so ./config files aren't owned by root. + # Leave unset on rootless podman. + # - PUID=1000 + # - PGID=1000 restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost/api/v1/system/health"] From b67106ceab8c58957254de19f989c6fd88ead02d Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sat, 18 Jul 2026 15:55:29 +0200 Subject: [PATCH 3/3] Document security model, compose pull-vs-build, and PUID/PGID usage - New Security section: no built-in auth (LAN-only design), PHPSESSID stored in plain text under /config, reverse-proxy/VPN guidance - Quick Start: compose pulls the GHCR image by default, --build builds from source, --force-recreate needed after image updates - Non-root backend instructions for Docker (PUID/PGID) --- README.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92428da..4ab262a 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,28 @@ Or with Docker Compose: git clone https://github.com/kernelcoffee/SteamSelfGifter.git cd SteamSelfGifter -# Start with Docker Compose -docker-compose up -d +# Pulls the published image and starts it +docker compose up -d + +# ...or build from source instead of pulling +docker compose up -d --build # Access the web interface at http://localhost:8080 ``` +> **Note:** after pulling a new image or rebuilding, use +> `docker compose up -d --force-recreate` so the running container is +> actually replaced (some engines, e.g. podman-compose, keep the old +> container otherwise). + +#### Running the backend as a non-root user (Docker) + +By default everything in the container runs as root, which is fine for +rootless podman (container-root maps to your own host user). On real Docker, +set `PUID`/`PGID` (see the commented lines in `docker-compose.yml`) to run +the backend as that uid/gid — `/config` is chown'd accordingly on startup so +the database and logs end up owned by your host user instead of root. + ### Manual Installation #### Backend @@ -93,6 +109,21 @@ npm run dev # Development server at http://localhost:5173 5. Copy the `PHPSESSID` value 6. Paste it in the Settings page +## Security + +**The web interface and API have no authentication** — anyone who can reach +the port controls the bot and can read your SteamGifts session. The app is +designed to run on a trusted home network (LAN) only: + +- Don't expose the port to the internet. If you need remote access, put it + behind a reverse proxy with authentication (Authelia, basic auth, ...) or + access it over a VPN/tunnel (WireGuard, Tailscale, ...). +- Your `PHPSESSID` is stored in plain text in the SQLite database inside + `/config` (or `./config` with the provided compose file). Treat that + directory as a secret: don't commit it, don't share it. +- The `PHPSESSID` cookie is your logged-in SteamGifts session. Anyone who + obtains it can act as you on SteamGifts until it expires. + ## Architecture ```