Compare commits

...
12 Commits
Author SHA1 Message Date
nobswebdev 633099371d add missing log about postgres running in backup.sh 2026-11-15 00:53:51 +01:00
nobswebdev ded7407090 Merge pull request 'Add interactive prod backup script for disaster-recovery archives.' (#10) from slave/backups into master
Reviewed-on: #10
2026-11-14 23:41:44 +00:00
nobswebdev f60fb7bbaf Add interactive prod backup script for disaster-recovery archives.
Document the workflow in the deployment guide and ignore secret-bearing backups under deploy/backups/.
2026-11-15 00:39:30 +01:00
nobswebdev 321251daf4 Raise nginx /api body limit so CMS uploads can exceed the default 1m. 2026-11-12 00:48:24 +01:00
nobswebdev c5bd4925d7 add bigger limit for product image 2026-11-11 23:48:13 +01:00
nobswebdev 84bfe634ab Bump Docker Node images to 22.23.4 for October 2026 security fixes.
Node 20 is not in the patched release line; 22.23.4 addresses the high-severity TLS, DNS, and Brotli issues from the Node.js security release.
2026-10-21 17:56:55 +02:00
nobswebdev 3f0ea33f10 Log when Electrum wallet load completes in entrypoint. 2026-10-16 23:49:55 +02:00
nobswebdev 45a89a8bc3 Quiet Electrum CLI noise in docker entrypoint logs.
Add a clear startup message for maybe_stop_electrum and suppress stop/load_wallet stdout while keeping entrypoint retry messages.
2026-10-16 23:48:04 +02:00
nobswebdev f04c21ec17 Fix Electrum daemon recovery and shutdown in Docker.
Clear stale locks on startup via electrum stop, use correct stop on SIGTERM, and keep the daemon alive with wait so graceful restarts do not leave a lockfile crash loop.
2026-10-16 23:30:04 +02:00
nobswebdev 55630e33b4 Allow application/x-zip-compressed for digital stock attachments.
Browsers often report ZIP files with this MIME type instead of application/zip.
2026-09-25 20:57:28 +02:00
nobswebdev e057b29cc7 Reject cart and discount mutations while checkout session is active.
Prevent cart cookie changes during payment so nav counts and checkout snapshots stay aligned until cancel or completion.
2026-09-18 13:31:10 +02:00
nobswebdev 226a31b937 Merge pull request 'Slave/blog' (#9) from slave/blog into master
Reviewed-on: http://4f5t7o2vgo4sdaliwax3isyxzfuxvbvkuwk4smfowutrvx2uneu4egyd.onion/nobswebdev/nullcart/pulls/9
2026-09-16 20:15:07 +00:00
13 changed files with 413 additions and 15 deletions
+2 -2
View File
@@ -46,7 +46,7 @@ MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
@@ -144,7 +144,7 @@ VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
+1
View File
@@ -3,6 +3,7 @@
/deploy/certs
/deploy/certbot/www
/deploy/backups
/monero-wallet-rpc/wallet
/electrum-daemon/wallet
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /backend
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /backend
+1 -1
View File
@@ -72,7 +72,7 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(10 * 1024 * 1024) // 10MB
@Max(50 * 1024 * 1024) // 50MB
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
@IsNotEmpty()
@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
@@ -89,6 +89,10 @@ export class StorefrontCartController {
@Res() res: Response,
@Body() { variantId, qty }: AddToCartDto
): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const cartMutation = await this.cartService.addToCart(cart, variantId, qty);
@@ -109,6 +113,10 @@ export class StorefrontCartController {
@Res() res: Response,
@Body() { variantId, qty }: UpdateCartQtyDto
): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty);
@@ -125,6 +133,10 @@ export class StorefrontCartController {
@Post('shop/cart/product/remove')
removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const cartMutation = this.cartService.removeFromCart(cart, variantId);
@@ -143,6 +155,10 @@ export class StorefrontCartController {
@Res() res: Response,
@Body() { code }: ApplyDiscountCodeDto
): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res);
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
@@ -160,6 +176,10 @@ export class StorefrontCartController {
@Post('shop/cart/discount/remove')
removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
const discountMutation = this.discountService.removeDiscountCode(discountCodes, code);
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /cms
+1
View File
@@ -5,6 +5,7 @@ const MIME_LABEL: Record<string, string> = {
'application/json': 'JSON',
'application/pdf': 'PDF',
'application/zip': 'ZIP',
'application/x-zip-compressed': 'ZIP',
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/jpg': 'JPEG',
+10 -2
View File
@@ -159,10 +159,18 @@ Tor Browser will then show ".onion available" when visitors open the clearnet si
3. Finish the setup checklist in settings.
4. Connect SimpleX notifications in shop settings.
## 10. Updates
## Updates
This pulls the latest code and rebuilds the stack (`deploy.sh`).
```bash
./deploy/scripts/update.sh
```
This pulls the latest code and rebuilds the stack (`deploy.sh`).
## Backups
This creates a full disaster-recovery archive.
```bash
./deploy/scripts/backup.sh
```
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.prod.yml"
BACKUP_DIR="${ROOT_DIR}/deploy/backups"
cd "$ROOT_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
if [[ -z "${COMPOSE_PROJECT_NAME:-}" ]]; then
echo "COMPOSE_PROJECT_NAME is not set in .env.prod" >&2
exit 1
fi
if [[ -z "${POSTGRES_USER:-}" || -z "${POSTGRES_DB:-}" ]]; then
echo "POSTGRES_USER and POSTGRES_DB must be set in .env.prod" >&2
exit 1
fi
if [[ -z "${MONERO_WALLET_DIR:-}" ]]; then
echo "MONERO_WALLET_DIR is not set in .env.prod" >&2
exit 1
fi
if [[ -z "${ELECTRUM_WALLET_DIR:-}" ]]; then
echo "ELECTRUM_WALLET_DIR is not set in .env.prod" >&2
exit 1
fi
compose() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
resolve_host_path() {
local rel="${1:-}"
local label="$2"
if [[ -z "$rel" ]]; then
echo "Cannot resolve empty path for ${label}" >&2
exit 1
fi
if [[ "$rel" == /* ]]; then
echo "$rel"
else
echo "${ROOT_DIR}/${rel#./}"
fi
}
build_compose_volume_name() {
local vol_key="$1"
echo "${COMPOSE_PROJECT_NAME}_${vol_key}"
}
require_min_bytes() {
local file="$1"
local min_bytes="$2"
local label="$3"
if [[ ! -f "$file" ]] || (( $(stat -c%s "$file") < min_bytes )); then
echo "Backup failed: ${label} is missing or empty." >&2
exit 1
fi
}
require_tar_entries() {
local file="$1"
local label="$2"
local entry_count
if [[ ! -f "$file" ]]; then
echo "Backup failed: ${label} is missing or has no archive entries." >&2
exit 1
fi
entry_count="$(tar tf "$file" 2>/dev/null | wc -l)"
if (( entry_count < 1 )); then
echo "Backup failed: ${label} is missing or has no archive entries." >&2
exit 1
fi
}
tar_volume() {
local vol_key="$1"
local archive_name="$2"
local vol_name
vol_name="$(build_compose_volume_name "$vol_key")"
if ! docker volume inspect "$vol_name" >/dev/null 2>&1; then
echo "Backup failed: Docker volume ${vol_name} not found. Is the stack deployed?" >&2
exit 1
fi
docker run --rm \
-v "${vol_name}:/data:ro" \
-v "${STAGING_TMP}:/backup" \
alpine tar -cf "/backup/${archive_name}" -C /data .
}
SHOULD_STOP=false
STOPPED=false
STAGING_TMP=""
ARCHIVE_TMP=""
on_exit() {
if [[ -n "$STAGING_TMP" && -d "$STAGING_TMP" ]]; then
rm -rf "$STAGING_TMP"
fi
if [[ -n "$ARCHIVE_TMP" && -f "$ARCHIVE_TMP" ]]; then
rm -f "$ARCHIVE_TMP"
fi
if [[ "$STOPPED" == true ]]; then
echo "Restarting NullCart..."
compose start
fi
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Checking if PostgreSQL is running and accepting connections..."
cid="$(compose ps --status running -q postgres 2>/dev/null | head -n1)"
if [[ -z "$cid" ]]; then
echo "Backup failed: PostgreSQL is not running. Start the stack (for example: docker compose --env-file \"${ENV_FILE}\" -f \"${COMPOSE_FILE}\" up -d postgres)." >&2
exit 1
fi
if ! compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
echo "Backup failed: PostgreSQL is not accepting connections. Check container logs or wait until the database is healthy." >&2
exit 1
fi
echo "PostgreSQL is running and accepting connections."
# Check if the script is running from an interactive terminal
if [[ ! -r /dev/tty ]]; then
echo "Run this script from an interactive terminal (for example: ssh -t)." >&2
exit 1
fi
printf 'Would you like to stop NullCart before doing backup? Doing backup while NullCart is running may lead to backup data corruption [y/n] (recommended y): '
read -n 1 -r STOP_ANSWER </dev/tty || {
echo "Backup failed: could not read answer from the terminal." >&2
exit 1
}
printf '\n'
case "$STOP_ANSWER" in
y)
SHOULD_STOP=true
;;
n)
echo "Continuing backup with NullCart running..."
;;
*)
echo "Invalid answer. Use y or n." >&2
exit 1
;;
esac
STAGING_TMP="$(mktemp -d)"
mkdir -p "$BACKUP_DIR"
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
ARCHIVE_PATH="${BACKUP_DIR}/nullcart-backup-${TIMESTAMP}.tar.gz"
ARCHIVE_TMP="${ARCHIVE_PATH}.tmp"
if [[ "$SHOULD_STOP" == true ]]; then
stop_services=()
compose_services="$(compose config --services)"
while IFS= read -r svc; do
if [[ -z "$svc" || "$svc" == "postgres" ]]; then
continue
fi
stop_services+=("$svc")
done <<<"$compose_services"
if ((${#stop_services[@]} == 0)); then
echo "Backup failed: no application services found to stop in ${COMPOSE_FILE}." >&2
exit 1
fi
echo "Stopping NullCart application services (PostgreSQL stays up for dump)..."
compose stop "${stop_services[@]}"
STOPPED=true
fi
echo "Backing up PostgreSQL..."
compose exec -T postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "${STAGING_TMP}/postgres.dump"
require_min_bytes "${STAGING_TMP}/postgres.dump" 64 "postgres.dump"
echo "Backing up environment file..."
cp "$ENV_FILE" "${STAGING_TMP}/env.prod"
require_min_bytes "${STAGING_TMP}/env.prod" 1 "env.prod"
echo "Backing up uploads..."
UPLOADS_DIR="${ROOT_DIR}/backend/uploads"
if [[ ! -d "$UPLOADS_DIR" ]]; then
echo "Backup failed: uploads directory not found at ${UPLOADS_DIR}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/uploads.tar" -C "${ROOT_DIR}/backend" uploads
require_tar_entries "${STAGING_TMP}/uploads.tar" "uploads.tar"
echo "Backing up Monero wallet..."
MONERO_WALLET_PATH="$(resolve_host_path "$MONERO_WALLET_DIR" "Monero wallet")"
if [[ ! -d "$MONERO_WALLET_PATH" ]]; then
echo "Backup failed: Monero wallet directory not found at ${MONERO_WALLET_PATH}" >&2
exit 1
fi
if [[ -z "$(ls -A "$MONERO_WALLET_PATH" 2>/dev/null || true)" ]]; then
echo "Backup failed: Monero wallet directory is empty at ${MONERO_WALLET_PATH}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/monero-wallet.tar" -C "$(dirname "$MONERO_WALLET_PATH")" "$(basename "$MONERO_WALLET_PATH")"
require_tar_entries "${STAGING_TMP}/monero-wallet.tar" "monero-wallet.tar"
echo "Backing up Electrum wallet..."
ELECTRUM_WALLET_PATH="$(resolve_host_path "$ELECTRUM_WALLET_DIR" "Electrum wallet")"
if [[ ! -d "$ELECTRUM_WALLET_PATH" ]]; then
echo "Backup failed: Electrum wallet directory not found at ${ELECTRUM_WALLET_PATH}" >&2
exit 1
fi
if [[ -z "$(ls -A "$ELECTRUM_WALLET_PATH" 2>/dev/null || true)" ]]; then
echo "Backup failed: Electrum wallet directory is empty at ${ELECTRUM_WALLET_PATH}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/electrum-wallet.tar" -C "$(dirname "$ELECTRUM_WALLET_PATH")" "$(basename "$ELECTRUM_WALLET_PATH")"
require_tar_entries "${STAGING_TMP}/electrum-wallet.tar" "electrum-wallet.tar"
echo "Backing up Docker volumes..."
echo "Backing up Tor hidden service volume..."
tar_volume "nullcart.tor.prod.data" "volume-tor.tar"
require_tar_entries "${STAGING_TMP}/volume-tor.tar" "volume-tor.tar"
echo "Backing up SimpleX volume..."
tar_volume "nullcart.simplex.prod.data" "volume-simplex.tar"
require_tar_entries "${STAGING_TMP}/volume-simplex.tar" "volume-simplex.tar"
echo "Backing up pgAdmin volume..."
tar_volume "nullcart.pgadmin.prod.data" "volume-pgadmin.tar"
require_tar_entries "${STAGING_TMP}/volume-pgadmin.tar" "volume-pgadmin.tar"
echo "Backing up TLS certs directory..."
CERTS_DIR="${ROOT_DIR}/deploy/certs"
if [[ ! -d "$CERTS_DIR" ]]; then
echo "Backup failed: TLS certs directory not found at ${CERTS_DIR}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/certs.tar" -C "${ROOT_DIR}/deploy" certs
require_tar_entries "${STAGING_TMP}/certs.tar" "certs.tar"
echo "Backing up Git commit..."
GIT_REV=""
if git -C "$ROOT_DIR" rev-parse HEAD >/dev/null 2>&1; then
GIT_REV="$(git -C "$ROOT_DIR" rev-parse HEAD)"
fi
{
echo "NullCart backup"
echo "Created (UTC): $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "Compose project: ${COMPOSE_PROJECT_NAME}"
echo "Stopped application services before pg_dump: $([[ "$STOPPED" == true ]] && echo yes || echo no)"
if [[ -n "$GIT_REV" ]]; then
echo "Git commit: ${GIT_REV}"
fi
echo ""
echo "Contents:"
echo " postgres.dump PostgreSQL custom-format dump (pg_restore)"
echo " env.prod Production environment file"
echo " uploads.tar backend/uploads"
echo " monero-wallet.tar Monero wallet directory"
echo " electrum-wallet.tar Electrum wallet directory"
echo " volume-tor.tar Tor hidden service keys"
echo " volume-simplex.tar SimpleX CLI data"
echo " volume-pgadmin.tar pgAdmin data"
echo " certs.tar deploy/certs (TLS)"
} > "${STAGING_TMP}/MANIFEST.txt"
require_min_bytes "${STAGING_TMP}/MANIFEST.txt" 1 "MANIFEST.txt"
rm -f "$ARCHIVE_TMP"
tar czf "$ARCHIVE_TMP" -C "$STAGING_TMP" .
tar tzf "$ARCHIVE_TMP" >/dev/null
chmod 600 "$ARCHIVE_TMP"
mv -f "$ARCHIVE_TMP" "$ARCHIVE_PATH"
echo "Backup complete: ${ARCHIVE_PATH}"
echo "This archive contains secrets. It is recommended to store it encrypted on an external storage."
+35 -5
View File
@@ -4,10 +4,17 @@ set -euo pipefail
NETWORK_FLAG="--${ELECTRUM_NETWORK}"
WALLET_PATH="/electrum/wallet/${ELECTRUM_WALLET_NAME}"
DATA_DIR="/electrum/data"
LOAD_WALLET_MAX_ATTEMPTS=90
DAEMON_STARTUP_WAIT_SECONDS=5
run_electrum() { electrum "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
run_electrum_offline() { electrum --offline "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
maybe_stop_electrum() {
echo "Trying to stop Electrum if it may still be running..."
run_electrum stop >/dev/null 2>&1 || true
}
mkdir -p /electrum/wallet "${DATA_DIR}"
chmod 700 /electrum/wallet
@@ -16,19 +23,42 @@ if [[ ! -f "${WALLET_PATH}" ]]; then
run_electrum_offline create -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
chmod 600 "${WALLET_PATH}" 2>/dev/null || true
echo "Wallet created at ${WALLET_PATH}"
fi
maybe_stop_electrum
run_electrum_offline setconfig rpcuser "${ELECTRUM_DAEMON_RPC_USER}"
run_electrum_offline setconfig rpcpassword "${ELECTRUM_DAEMON_RPC_PASSWORD}"
run_electrum_offline setconfig rpchost 0.0.0.0
run_electrum_offline setconfig rpcport "${ELECTRUM_DAEMON_PORT}"
run_electrum_offline setconfig server "${ELECTRUM_SERVER}"
trap 'run_electrum daemon stop || true; exit 0' SIGTERM SIGINT
trap 'run_electrum stop || true' SIGTERM SIGINT
run_electrum daemon -d
run_electrum load_wallet -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
echo "Starting Electrum daemon..."
run_electrum daemon &
sleep infinity
echo "Waiting for daemon connection (${DAEMON_STARTUP_WAIT_SECONDS}s)..."
sleep "${DAEMON_STARTUP_WAIT_SECONDS}"
load_wallet_attempt=0
echo "Loading wallet..."
until run_electrum load_wallet -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}" >/dev/null 2>&1; do
load_wallet_attempt=$((load_wallet_attempt + 1))
if [[ load_wallet_attempt -ge LOAD_WALLET_MAX_ATTEMPTS ]]; then
echo "Timed out waiting to load wallet into Electrum daemon" >&2
exit 1
fi
echo "Load wallet attempt ${load_wallet_attempt}/${LOAD_WALLET_MAX_ATTEMPTS} failed. Daemon may not be ready yet. Retrying in 1s..."
sleep 1
echo "Loading wallet..."
done
echo "Wallet loaded"
wait
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS cms-builder
FROM node:22.23.4-alpine AS cms-builder
WORKDIR /cms
@@ -20,6 +20,7 @@ location = /api/shop-settings/simplex-connect {
}
location /api {
client_max_body_size 20m;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;