Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
36 changes: 32 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -141,12 +144,37 @@ 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

# Health check
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"]
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

```
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
144 changes: 57 additions & 87 deletions frontend/src/hooks/useGiveaways.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,61 +47,73 @@ 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<GiveawayFilters, 'page'>,
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
*/
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<GiveawaysApiResponse>(endpoint);
if (!response.success) {
Expand Down Expand Up @@ -129,51 +141,9 @@ export function useInfiniteGiveaways(filters: Omit<GiveawayFilters, 'page'> = {}
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<GiveawaysApiResponse>(endpoint);
if (!response.success) {
Expand Down
Loading