mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 11:05:05 +03:00
Fix production deployment issues
This commit is contained in:
@@ -92,13 +92,14 @@ from fastapi import UploadFile, File, HTTPException, Request, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# File serving directories (matching main service structure)
|
||||
FILES_BASE_DIR = Path("data/uploads/files")
|
||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
|
||||
# Base storage directories
|
||||
BASE_DIR = Path("files")
|
||||
# Legacy upload layout (was data/uploads/files on monolith main under /app/data).
|
||||
# Keep under BASE_DIR so Docker uses the file_storage volume (/app/files), not /app/data
|
||||
# (different uid / optional mount → PermissionError on prod).
|
||||
FILES_BASE_DIR = BASE_DIR / "data" / "uploads" / "files"
|
||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
FILES_DIR = BASE_DIR / "files"
|
||||
THUMBS_DIR = BASE_DIR / "thumbs"
|
||||
TMP_DIR = BASE_DIR / "tmp"
|
||||
|
||||
@@ -319,15 +319,6 @@ async def health_check():
|
||||
return {"status": "healthy", "service": "main"}
|
||||
|
||||
|
||||
@app.get("/key/public")
|
||||
async def key_public_proxy():
|
||||
"""
|
||||
Proxy endpoint for messaging public key. In dev this calls the in-process function,
|
||||
in production it will proxy to the external messaging service via the keys helper.
|
||||
"""
|
||||
return await keys.get_public_key()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", "8300"))
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import os
|
||||
import logging
|
||||
import base64
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@@ -58,37 +56,3 @@ async def get_public_key():
|
||||
logger.error(f"Failed to fetch messaging public key via HTTP: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to contact messaging service")
|
||||
|
||||
|
||||
@router.post("/key/invalidate")
|
||||
async def invalidate_key():
|
||||
"""
|
||||
Request messaging service to invalidate its current ephemeral key (rotate).
|
||||
"""
|
||||
messaging_module = _get_messaging_module()
|
||||
if messaging_module:
|
||||
try:
|
||||
data = await messaging_module.invalidate_key() # type: ignore
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to invalidate key in in-process messaging module: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to invalidate messaging key")
|
||||
|
||||
messaging_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging:8301")
|
||||
url = f"{messaging_url.rstrip('/')}/key/invalidate"
|
||||
try:
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.post(url, timeout=5.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception:
|
||||
from urllib import request, error
|
||||
import json
|
||||
req = request.Request(url, method="POST")
|
||||
with request.urlopen(req, timeout=5) as r:
|
||||
body = r.read()
|
||||
return json.loads(body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to call messaging invalidate endpoint via HTTP: {e}")
|
||||
raise HTTPException(status_code=502, detail="Failed to contact messaging service")
|
||||
|
||||
|
||||
@@ -137,7 +137,8 @@ def decrypt_transport_blob(
|
||||
ephemeral transport private key and the client's public key.
|
||||
|
||||
Args:
|
||||
client_public_key_b64: Sender public key in base64 (raw X25519).
|
||||
client_public_key_b64: Client ephemeral public key in base64 (raw X25519),
|
||||
the same key as used for the transport-encrypted message body.
|
||||
encrypted_blob: Raw bytes of `nonce || ciphertext`.
|
||||
ephemeral_private_key: Server ephemeral X25519 private key.
|
||||
nonce_size: Nonce size in bytes (24 for XSalsa20-Poly1305).
|
||||
|
||||
@@ -6,8 +6,7 @@ providing compliance access while ensuring zero-knowledge storage of plaintext c
|
||||
|
||||
API Endpoints:
|
||||
- GET /health: Health check
|
||||
- GET /key/public: Get current ephemeral transport public key
|
||||
- POST /key/invalidate: Rotate ephemeral keys
|
||||
- GET /key/transport/public: Get current ephemeral transport public key
|
||||
- POST /process: Process encrypted message through envelope encryption pipeline
|
||||
"""
|
||||
|
||||
@@ -203,7 +202,8 @@ class ProcessMessageRequest(BaseModel):
|
||||
|
||||
class ProcessMessageWithFilesFile(BaseModel):
|
||||
"""
|
||||
A single transport-encrypted file blob (base64 of nonce||ciphertext).
|
||||
A single transport-encrypted file blob (base64 of nonce||ciphertext),
|
||||
encrypted with the same ephemeral client key as the message body.
|
||||
"""
|
||||
encrypted_file_data_b64: str
|
||||
filename: str = "file"
|
||||
@@ -213,6 +213,8 @@ class ProcessMessageWithFilesRequest(ProcessMessageRequest):
|
||||
"""
|
||||
Process a transport-encrypted message and a list of transport-encrypted files
|
||||
using a single MEK for the whole envelope.
|
||||
|
||||
Each file blob uses the same client_public_key_b64 / X25519 ephemeral pair as the message.
|
||||
"""
|
||||
files: list[ProcessMessageWithFilesFile]
|
||||
|
||||
@@ -353,6 +355,9 @@ async def process_message_with_files(
|
||||
):
|
||||
"""
|
||||
In-process helper: process message + transport-encrypted files with one MEK.
|
||||
|
||||
File blobs must be encrypted with the same ephemeral client key as the message
|
||||
(same client_public_key_b64), not the sender's long-term identity key.
|
||||
transport_files: list of {"encrypted_file_data_b64": str, "filename": str}
|
||||
"""
|
||||
private_key = _get_ephemeral_private_key()
|
||||
@@ -371,7 +376,7 @@ async def process_message_with_files(
|
||||
transport_blob = base64.b64decode(enc_b64)
|
||||
plaintext_files.append(
|
||||
decrypt_transport_blob(
|
||||
client_public_key_b64=sender_public_key_b64,
|
||||
client_public_key_b64=client_public_key_b64,
|
||||
encrypted_blob=transport_blob,
|
||||
ephemeral_private_key=private_key,
|
||||
)
|
||||
@@ -393,8 +398,8 @@ async def process_message_with_files_http(request: ProcessMessageWithFilesReques
|
||||
"""
|
||||
Process an encrypted message and its files using a single MEK.
|
||||
|
||||
- Message transport layer is decrypted using the message client ephemeral key
|
||||
- File transport layer is decrypted using the sender long-term public key
|
||||
- Message and file transport layers use the same client ephemeral X25519 keypair
|
||||
(client_public_key_b64); files are NaCl box ciphertexts to the server transport key
|
||||
- One MEK is generated and used to encrypt message + all files
|
||||
- MEK is wrapped for compliance, sender, and recipient (stored on DM envelope)
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
fromchat.ru {
|
||||
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
||||
lb_policy first
|
||||
header_up X-Real-IP {remote_host}
|
||||
reverse_proxy frontend:8301 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
|
||||
@@ -172,7 +172,7 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- caddy_data:/root/site/certs
|
||||
- caddy:/root/site/certs
|
||||
environment:
|
||||
XDG_DATA_HOME: /root/site/certs
|
||||
XDG_CONFIG_HOME: /root/site/certs
|
||||
|
||||
@@ -10,11 +10,12 @@ StartLimitBurst=3
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
ExecStart=/bin/docker compose up
|
||||
ExecStop=/bin/docker compose down
|
||||
ExecStart=/bin/bash -c "COMPOSE_PROFILES=production docker compose up --remove-orphans --force-recreate"
|
||||
ExecStop=/bin/bash -c "COMPOSE_PROFILES=production docker compose down --remove-orphans"
|
||||
WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitBurst=3
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -173,8 +173,21 @@ export async function sendWithFiles(
|
||||
|
||||
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||
|
||||
// Transport-encrypt message (client-side transport only; server will envelope-encrypt)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext || "", transportPublicKeyB64);
|
||||
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const messageCiphertext = tweetnacl.box(
|
||||
messagePlaintextBytes,
|
||||
messageNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
@@ -197,7 +210,7 @@ export async function sendWithFiles(
|
||||
new Uint8Array(fileData),
|
||||
transportNonce,
|
||||
transportPublicKey,
|
||||
keys.privateKey
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||
transportEncryptedWithNonce.set(transportNonce);
|
||||
|
||||
@@ -168,6 +168,22 @@ export async function ensureKeysOnLogin(password: string, token: string): Promis
|
||||
return pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the client already has a keypair (e.g. from localStorage) but the server has no public key row,
|
||||
* upload the public key. Covers failed uploads during login, DB resets, and legacy accounts.
|
||||
*/
|
||||
export async function syncPublicKeyToServerIfMissing(token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys?.publicKey?.length || !keys?.privateKey?.length) {
|
||||
return;
|
||||
}
|
||||
const serverPk = await fetchPublicKey(token);
|
||||
if (serverPk) {
|
||||
return;
|
||||
}
|
||||
await uploadPublicKey(keys.publicKey, token);
|
||||
}
|
||||
|
||||
export function restoreKeys() {
|
||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||
|
||||
@@ -94,6 +94,11 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
try {
|
||||
await api.user.auth.syncPublicKeyToServerIfMissing(data.token);
|
||||
} catch (e2) {
|
||||
console.error("Public key re-sync failed:", e2);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure WebSocket is connected and authenticated
|
||||
|
||||
@@ -122,6 +122,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
try {
|
||||
await api.user.auth.syncPublicKeyToServerIfMissing(data.token);
|
||||
} catch (e2) {
|
||||
console.error("Public key re-sync failed:", e2);
|
||||
}
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
|
||||
@@ -79,6 +79,11 @@ export const useUserStore = create<UserStore>((set) => ({
|
||||
if (fullResponse.ok) {
|
||||
const user: User = await fullResponse.json();
|
||||
api.user.auth.restoreKeys();
|
||||
try {
|
||||
await api.user.auth.syncPublicKeyToServerIfMissing(token);
|
||||
} catch (e) {
|
||||
console.error("Public key sync to server failed:", e);
|
||||
}
|
||||
|
||||
if (user.suspended) {
|
||||
set({
|
||||
|
||||
+96
-37
@@ -312,38 +312,39 @@ docker buildx use "$BUILDER_NAME" > /dev/null 2>&1
|
||||
# Detect services
|
||||
step "Detecting services"
|
||||
cd "$DEPLOYMENT_DIR"
|
||||
# Include profile-only services (e.g. caddy) so their images are built and pushed; otherwise prod keeps a stale Caddy image and ignores Caddyfile updates from rsync.
|
||||
export COMPOSE_PROFILES=production
|
||||
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVICES" ]; then
|
||||
error "No services found in docker-compose.yml"
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
error "jq is required for deploy (e.g. brew install jq / sudo apt install jq)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! COMPOSE_JSON=$(docker compose -f docker-compose.yml config --format json 2>/dev/null); then
|
||||
error "docker compose config --format json failed (needs Docker Compose v2.10+)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILT_IMAGES=()
|
||||
|
||||
for SERVICE in $SERVICES; do
|
||||
HAS_BUILD=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||
grep -A 30 "^[[:space:]]*${SERVICE}:" | \
|
||||
grep -q "build:" && echo "yes" || echo "no")
|
||||
|
||||
if [ "$HAS_BUILD" != "yes" ]; then
|
||||
if ! jq -e --arg s "$SERVICE" '(.services[$s].build // false) | type == "object"' <<< "$COMPOSE_JSON" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest"
|
||||
|
||||
|
||||
substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..."
|
||||
|
||||
BUILD_OUTPUT=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||
grep -A 15 "^[[:space:]]*${SERVICE}:" | \
|
||||
grep -A 10 "build:")
|
||||
|
||||
DOCKERFILE_REL=$(echo "$BUILD_OUTPUT" | grep "dockerfile:" | \
|
||||
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
|
||||
tr -d '"' | tr -d "'" | xargs)
|
||||
|
||||
CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
|
||||
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
|
||||
tr -d '"' | tr -d "'" | xargs)
|
||||
|
||||
DOCKERFILE_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.dockerfile // empty' <<< "$COMPOSE_JSON")
|
||||
CONTEXT_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.context // empty' <<< "$COMPOSE_JSON")
|
||||
# Multi-stage deployment/Dockerfile: without --target, the final stage (file_storage) is always tagged.
|
||||
BUILD_TARGET=$(jq -r --arg s "$SERVICE" '.services[$s].build.target // empty' <<< "$COMPOSE_JSON")
|
||||
|
||||
if [ -z "$CONTEXT_REL" ]; then
|
||||
CONTEXT_REL=".."
|
||||
@@ -377,12 +378,13 @@ for SERVICE in $SERVICES; do
|
||||
fi
|
||||
fi
|
||||
|
||||
if docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--file "$DOCKERFILE" \
|
||||
--tag "$IMAGE_TAG" \
|
||||
--load \
|
||||
"$BUILD_CONTEXT"; then
|
||||
BUILDX_ARGS=(buildx build --platform "$PLATFORM" --file "$DOCKERFILE" --tag "$IMAGE_TAG" --load)
|
||||
if [ -n "$BUILD_TARGET" ]; then
|
||||
BUILDX_ARGS+=(--target "$BUILD_TARGET")
|
||||
fi
|
||||
BUILDX_ARGS+=("$BUILD_CONTEXT")
|
||||
|
||||
if docker "${BUILDX_ARGS[@]}"; then
|
||||
echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}"
|
||||
BUILT_IMAGES+=("$IMAGE_TAG")
|
||||
echo ""
|
||||
@@ -413,12 +415,7 @@ COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev
|
||||
IMAGES=()
|
||||
|
||||
for S in $COMPOSE_SERVICES; do
|
||||
# Try to read explicit image: field from the compose config for this service
|
||||
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||
grep -A5 "^[[:space:]]*${S}:" | \
|
||||
grep -m1 "image:" || true)
|
||||
|
||||
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
|
||||
IMAGE_FROM_COMPOSE=$(jq -r --arg s "$S" '.services[$s].image // empty' <<< "$COMPOSE_JSON")
|
||||
|
||||
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
|
||||
IMAGES+=("$IMAGE_FROM_COMPOSE")
|
||||
@@ -520,11 +517,11 @@ step "Transferring deployment files"
|
||||
if [ -n "$SUDO_PASSWORD" ]; then
|
||||
ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1
|
||||
set -e
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment 2>/dev/null || true
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment 2>/dev/null || true
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment $DEPLOY_PATH/backend 2>/dev/null || true
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment $DEPLOY_PATH/backend 2>/dev/null || true
|
||||
REMOTE_SUDO_SCRIPT
|
||||
else
|
||||
ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment" > /dev/null 2>&1 || true
|
||||
ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment $DEPLOY_PATH/backend && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment $DEPLOY_PATH/backend" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Copy deployment directory excluding gitignored files
|
||||
@@ -561,9 +558,71 @@ else
|
||||
warning ".env.prod not found in deployment directory"
|
||||
fi
|
||||
|
||||
# Firebase service account: bind-mounted at runtime (see docker-compose main volumes), never in the image (.dockerignore).
|
||||
# If compose ever ran without this file on the host, Docker may have created a directory at this path — remove it before scp.
|
||||
FIREBASE_CERT="$PROJECT_ROOT/backend/firebase-cert.json"
|
||||
substep "Firebase service account (runtime bind-mount: backend/firebase-cert.json)..."
|
||||
# ~ is not expanded inside variables on the remote shell (e.g. C="$D/..." with D=~/foo checks a bogus path). Resolve to an absolute path.
|
||||
DEPLOY_PATH_ON_SERVER=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$SERVER" "eval echo $DEPLOY_PATH" 2>/dev/null || true)
|
||||
if [ -z "$DEPLOY_PATH_ON_SERVER" ]; then
|
||||
DEPLOY_PATH_ON_SERVER=$DEPLOY_PATH
|
||||
fi
|
||||
FIREBASE_REMOTE="$DEPLOY_PATH_ON_SERVER/backend/firebase-cert.json"
|
||||
# Docker may have created this path as a root-owned directory; plain rm fails without sudo.
|
||||
if [ -n "$SUDO_PASSWORD" ]; then
|
||||
ssh "$SERVER" bash << REMOTE_FIREBASE_CLEANUP > /dev/null 2>&1 || true
|
||||
set -e
|
||||
D=$DEPLOY_PATH_ON_SERVER
|
||||
C="\$D/backend/firebase-cert.json"
|
||||
mkdir -p "\$D/backend" 2>/dev/null || true
|
||||
if [ -d "\$C" ]; then
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' rm -rf "\$C"
|
||||
fi
|
||||
echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R "\$(whoami):\$(whoami)" "\$D/backend" 2>/dev/null || true
|
||||
REMOTE_FIREBASE_CLEANUP
|
||||
else
|
||||
QBASE=$(printf '%q' "$DEPLOY_PATH_ON_SERVER")
|
||||
ssh "$SERVER" "D=$QBASE; C=\"\$D/backend/firebase-cert.json\"; mkdir -p \"\$D/backend\"; if [ -d \"\$C\" ]; then sudo rm -rf \"\$C\" 2>/dev/null || rm -rf \"\$C\"; fi; sudo chown -R \$(whoami):\$(whoami) \"\$D/backend\" 2>/dev/null || true" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if [ -f "$FIREBASE_CERT" ]; then
|
||||
break
|
||||
fi
|
||||
if [ -d "$FIREBASE_CERT" ]; then
|
||||
echo -e " ${YELLOW}⚠${NC} $FIREBASE_CERT is a directory. Delete it and save the Firebase service account JSON as a file at that exact path."
|
||||
elif [ -e "$FIREBASE_CERT" ]; then
|
||||
echo -e " ${YELLOW}⚠${NC} $FIREBASE_CERT exists but is not a regular file."
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Missing $FIREBASE_CERT (Firebase service account JSON for FCM)."
|
||||
fi
|
||||
echo -e " ${CYAN}Fix this, then press Enter to check again (Ctrl+C to abort deploy).${NC}"
|
||||
read -r _
|
||||
done
|
||||
|
||||
substep "Copying backend/firebase-cert.json..."
|
||||
FIREBASE_SCP_LOG="/tmp/fromchat-firebase-scp-$$.log"
|
||||
if ! scp "$FIREBASE_CERT" "$SERVER:$FIREBASE_REMOTE" >"$FIREBASE_SCP_LOG" 2>&1; then
|
||||
error "Failed to copy firebase-cert.json to server"
|
||||
echo -e " ${YELLOW}Target:${NC} $SERVER:$FIREBASE_REMOTE" >&2
|
||||
if [ -s "$FIREBASE_SCP_LOG" ]; then
|
||||
sed 's/^/ /' "$FIREBASE_SCP_LOG" >&2
|
||||
else
|
||||
echo -e " ${YELLOW}(scp produced no output.)${NC}" >&2
|
||||
fi
|
||||
rm -f "$FIREBASE_SCP_LOG"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$FIREBASE_SCP_LOG"
|
||||
ssh "$SERVER" "chmod 600 $(printf '%q' "$FIREBASE_REMOTE") 2>/dev/null || true"
|
||||
if ! ssh "$SERVER" "test -f $(printf '%q' "$FIREBASE_REMOTE")"; then
|
||||
error "Server path is not a regular file after copy: $FIREBASE_REMOTE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deploy on server
|
||||
step "Deploying on server"
|
||||
ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="$DEPLOY_PATH" bash << 'REMOTE_SCRIPT'
|
||||
ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="${DEPLOY_PATH_ON_SERVER:-$DEPLOY_PATH}" bash << 'REMOTE_SCRIPT'
|
||||
set -e
|
||||
|
||||
REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}"
|
||||
@@ -583,7 +642,7 @@ if [ -z "$REMOTE_DEPLOY_PATH" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$REMOTE_DEPLOY_PATH/deployment"
|
||||
mkdir -p "$REMOTE_DEPLOY_PATH/deployment" "$REMOTE_DEPLOY_PATH/backend"
|
||||
cd "$REMOTE_DEPLOY_PATH/deployment"
|
||||
|
||||
if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then
|
||||
@@ -594,7 +653,7 @@ if systemctl is-active --quiet fromchat; then
|
||||
sudo_cmd systemctl stop fromchat
|
||||
fi
|
||||
|
||||
docker compose down > /dev/null 2>&1 || true
|
||||
COMPOSE_PROFILES=production docker compose down --remove-orphans > /dev/null 2>&1 || true
|
||||
|
||||
sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service
|
||||
sudo_cmd systemctl daemon-reload
|
||||
|
||||
Reference in New Issue
Block a user