Compare commits
4 commits
e26fd346f3
...
3155eacdac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3155eacdac | ||
|
|
01bd631af8 | ||
|
|
0fc49d0032 | ||
|
|
118629549e |
21 changed files with 296 additions and 153 deletions
|
|
@ -2956,10 +2956,13 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
||||||
*dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, "
|
*dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, "
|
||||||
"last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at "
|
"last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at "
|
||||||
"FROM ssl_settings WHERE id = 1"
|
"FROM ssl_settings WHERE id = 1"
|
||||||
>> [callback](const Result& r) {
|
>> [callback, dbClient](const Result& r) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
|
|
||||||
|
std::string dbStatus = "none";
|
||||||
|
std::string domain = "";
|
||||||
|
|
||||||
if (r.empty()) {
|
if (r.empty()) {
|
||||||
// Return defaults if no settings exist
|
// Return defaults if no settings exist
|
||||||
resp["domain"] = "";
|
resp["domain"] = "";
|
||||||
|
|
@ -2971,9 +2974,12 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
||||||
resp["autoRenewalEnabled"] = true;
|
resp["autoRenewalEnabled"] = true;
|
||||||
} else {
|
} else {
|
||||||
const auto& row = r[0];
|
const auto& row = r[0];
|
||||||
resp["domain"] = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
domain = row["domain"].isNull() ? "" : row["domain"].as<std::string>();
|
||||||
|
dbStatus = row["certificate_status"].as<std::string>();
|
||||||
|
|
||||||
|
resp["domain"] = domain;
|
||||||
resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
|
resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
|
||||||
resp["certificateStatus"] = row["certificate_status"].as<std::string>();
|
resp["certificateStatus"] = dbStatus;
|
||||||
|
|
||||||
if (!row["certificate_expiry"].isNull()) {
|
if (!row["certificate_expiry"].isNull()) {
|
||||||
resp["certificateExpiry"] = row["certificate_expiry"].as<std::string>();
|
resp["certificateExpiry"] = row["certificate_expiry"].as<std::string>();
|
||||||
|
|
@ -2996,6 +3002,50 @@ void AdminController::getSSLSettings(const HttpRequestPtr &req,
|
||||||
resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as<bool>();
|
resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for existing certificates on disk if DB shows "none"
|
||||||
|
// This handles the case where certbot ran during cloud-init
|
||||||
|
if (dbStatus == "none") {
|
||||||
|
try {
|
||||||
|
// Check /etc/letsencrypt/live directory for any certificates
|
||||||
|
std::string letsencryptDir = "/etc/letsencrypt/live";
|
||||||
|
if (std::filesystem::exists(letsencryptDir) && std::filesystem::is_directory(letsencryptDir)) {
|
||||||
|
for (const auto& entry : std::filesystem::directory_iterator(letsencryptDir)) {
|
||||||
|
if (entry.is_directory()) {
|
||||||
|
std::string certPath = entry.path().string() + "/fullchain.pem";
|
||||||
|
if (std::filesystem::exists(certPath)) {
|
||||||
|
std::string detectedDomain = entry.path().filename().string();
|
||||||
|
LOG_INFO << "Detected existing SSL certificate for: " << detectedDomain;
|
||||||
|
|
||||||
|
// Update response to show active certificate
|
||||||
|
resp["certificateStatus"] = "active";
|
||||||
|
if (resp["domain"].asString().empty()) {
|
||||||
|
resp["domain"] = detectedDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database to reflect the existing certificate
|
||||||
|
*dbClient << "INSERT INTO ssl_settings (id, domain, certificate_status, updated_at) "
|
||||||
|
"VALUES (1, $1, 'active', CURRENT_TIMESTAMP) "
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET "
|
||||||
|
"certificate_status = 'active', "
|
||||||
|
"domain = CASE WHEN ssl_settings.domain = '' THEN $1 ELSE ssl_settings.domain END, "
|
||||||
|
"updated_at = CURRENT_TIMESTAMP"
|
||||||
|
<< detectedDomain
|
||||||
|
>> [](const Result&) {
|
||||||
|
LOG_INFO << "Updated ssl_settings with detected certificate";
|
||||||
|
}
|
||||||
|
>> [](const DrogonDbException& e) {
|
||||||
|
LOG_ERROR << "Failed to update ssl_settings: " << e.base().what();
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_DEBUG << "Error checking for certificates: " << e.what();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
callback(jsonResp(resp));
|
callback(jsonResp(resp));
|
||||||
}
|
}
|
||||||
>> [callback](const DrogonDbException& e) {
|
>> [callback](const DrogonDbException& e) {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ module "forgejo" {
|
||||||
ssh_keys = module.ssh_keys.forgejo_ssh_key_ids
|
ssh_keys = module.ssh_keys.forgejo_ssh_key_ids
|
||||||
droplet_size = var.forgejo_droplet_size
|
droplet_size = var.forgejo_droplet_size
|
||||||
droplet_image = var.forgejo_droplet_image
|
droplet_image = var.forgejo_droplet_image
|
||||||
volume_size = var.forgejo_volume_size
|
|
||||||
ssh_port = var.forgejo_ssh_port
|
ssh_port = var.forgejo_ssh_port
|
||||||
git_ssh_port = var.forgejo_git_ssh_port
|
git_ssh_port = var.forgejo_git_ssh_port
|
||||||
domain = var.forgejo_domain
|
domain = var.forgejo_domain
|
||||||
|
|
|
||||||
|
|
@ -152,54 +152,6 @@ write_files:
|
||||||
echo "Firewall configured successfully"
|
echo "Firewall configured successfully"
|
||||||
permissions: '0755'
|
permissions: '0755'
|
||||||
|
|
||||||
# Volume mount script
|
|
||||||
- path: /usr/local/bin/mount-volume.sh
|
|
||||||
content: |
|
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
VOLUME_NAME="${volume_name}"
|
|
||||||
MOUNT_POINT="/mnt/forgejo"
|
|
||||||
|
|
||||||
# Create mount point
|
|
||||||
mkdir -p "$MOUNT_POINT"
|
|
||||||
|
|
||||||
# Find the volume device
|
|
||||||
# DigitalOcean volumes are typically at /dev/disk/by-id/scsi-0DO_Volume_*
|
|
||||||
VOLUME_DEV=$(readlink -f /dev/disk/by-id/scsi-0DO_Volume_$VOLUME_NAME 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -z "$VOLUME_DEV" ]; then
|
|
||||||
echo "Waiting for volume to attach..."
|
|
||||||
sleep 10
|
|
||||||
VOLUME_DEV=$(readlink -f /dev/disk/by-id/scsi-0DO_Volume_$VOLUME_NAME 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$VOLUME_DEV" ]; then
|
|
||||||
# Check if already mounted
|
|
||||||
if ! mountpoint -q "$MOUNT_POINT"; then
|
|
||||||
mount "$VOLUME_DEV" "$MOUNT_POINT"
|
|
||||||
echo "Volume mounted at $MOUNT_POINT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add to fstab if not already there
|
|
||||||
if ! grep -q "$VOLUME_DEV" /etc/fstab; then
|
|
||||||
echo "$VOLUME_DEV $MOUNT_POINT ext4 defaults,nofail,discard 0 2" >> /etc/fstab
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create subdirectories
|
|
||||||
mkdir -p "$MOUNT_POINT/forgejo-data"
|
|
||||||
mkdir -p "$MOUNT_POINT/forgejo-db"
|
|
||||||
mkdir -p "$MOUNT_POINT/runner-data"
|
|
||||||
|
|
||||||
# Set permissions (UID 1000 is typically the forgejo user in container)
|
|
||||||
chown -R 1000:1000 "$MOUNT_POINT/forgejo-data"
|
|
||||||
chown -R 999:999 "$MOUNT_POINT/forgejo-db" # postgres user
|
|
||||||
chown -R 1000:1000 "$MOUNT_POINT/runner-data"
|
|
||||||
else
|
|
||||||
echo "WARNING: Volume not found. Please attach volume manually."
|
|
||||||
fi
|
|
||||||
permissions: '0755'
|
|
||||||
|
|
||||||
# Swap configuration script (needed for 1GB RAM)
|
# Swap configuration script (needed for 1GB RAM)
|
||||||
- path: /usr/local/bin/configure-swap.sh
|
- path: /usr/local/bin/configure-swap.sh
|
||||||
content: |
|
content: |
|
||||||
|
|
@ -269,7 +221,7 @@ write_files:
|
||||||
POSTGRES_PASSWORD: $${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: $${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: $${POSTGRES_DB:-forgejo}
|
POSTGRES_DB: $${POSTGRES_DB:-forgejo}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/forgejo/forgejo-db:/var/lib/postgresql/data
|
- /var/lib/forgejo/forgejo-db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- forgejo-internal
|
- forgejo-internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -326,7 +278,7 @@ write_files:
|
||||||
FORGEJO__log__MODE: "console"
|
FORGEJO__log__MODE: "console"
|
||||||
FORGEJO__log__LEVEL: "Info"
|
FORGEJO__log__LEVEL: "Info"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/forgejo/forgejo-data:/data
|
- /var/lib/forgejo/forgejo-data:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -394,7 +346,7 @@ write_files:
|
||||||
DOCKER_TLS_VERIFY: "1"
|
DOCKER_TLS_VERIFY: "1"
|
||||||
DOCKER_CERT_PATH: /certs/client
|
DOCKER_CERT_PATH: /certs/client
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/forgejo/runner-data:/data
|
- /var/lib/forgejo/runner-data:/data
|
||||||
- dind-certs-client:/certs/client:ro
|
- dind-certs-client:/certs/client:ro
|
||||||
networks:
|
networks:
|
||||||
- forgejo-internal
|
- forgejo-internal
|
||||||
|
|
@ -571,7 +523,7 @@ write_files:
|
||||||
| Domain: ${domain}
|
| Domain: ${domain}
|
||||||
| Git SSH Port: ${git_ssh_port}
|
| Git SSH Port: ${git_ssh_port}
|
||||||
| |
|
| |
|
||||||
| Data location: /mnt/forgejo |
|
| Data location: /var/lib/forgejo |
|
||||||
| Docker compose: /opt/forgejo |
|
| Docker compose: /opt/forgejo |
|
||||||
| |
|
| |
|
||||||
| Commands: |
|
| Commands: |
|
||||||
|
|
@ -633,12 +585,10 @@ runcmd:
|
||||||
# Configure swap (important for 1GB RAM)
|
# Configure swap (important for 1GB RAM)
|
||||||
- /usr/local/bin/configure-swap.sh
|
- /usr/local/bin/configure-swap.sh
|
||||||
|
|
||||||
# Mount the volume
|
# Create data directories on local disk
|
||||||
- /usr/local/bin/mount-volume.sh
|
- mkdir -p /var/lib/forgejo/forgejo-data /var/lib/forgejo/forgejo-db /var/lib/forgejo/runner-data
|
||||||
|
- chown -R 1000:1000 /var/lib/forgejo/forgejo-data /var/lib/forgejo/runner-data
|
||||||
# Fix ownership for Forgejo container (runs as UID 1000)
|
- chown -R 999:999 /var/lib/forgejo/forgejo-db
|
||||||
# This must run AFTER all directories are created to ensure correct permissions
|
|
||||||
- chown -R 1000:1000 /mnt/forgejo/forgejo-data
|
|
||||||
|
|
||||||
# Configure firewall
|
# Configure firewall
|
||||||
- /usr/local/bin/configure-firewall.sh
|
- /usr/local/bin/configure-firewall.sh
|
||||||
|
|
|
||||||
|
|
@ -22,19 +22,6 @@ resource "random_password" "forgejo_jwt_secret" {
|
||||||
special = false
|
special = false
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Forgejo Volume (Block Storage)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
resource "digitalocean_volume" "forgejo" {
|
|
||||||
name = "${var.project_name}-forgejo-${var.environment}"
|
|
||||||
region = var.region
|
|
||||||
size = var.volume_size
|
|
||||||
initial_filesystem_type = "ext4"
|
|
||||||
description = "Forgejo data volume for ${var.project_name}"
|
|
||||||
tags = var.tags
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Forgejo Droplet
|
# Forgejo Droplet
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -57,7 +44,6 @@ resource "digitalocean_droplet" "forgejo" {
|
||||||
ssh_port = var.ssh_port
|
ssh_port = var.ssh_port
|
||||||
git_ssh_port = var.git_ssh_port
|
git_ssh_port = var.git_ssh_port
|
||||||
vpc_ip_range = var.vpc_ip_range
|
vpc_ip_range = var.vpc_ip_range
|
||||||
volume_name = "${var.project_name}-forgejo-${var.environment}"
|
|
||||||
domain = var.domain
|
domain = var.domain
|
||||||
postgres_password = random_password.postgres.result
|
postgres_password = random_password.postgres.result
|
||||||
forgejo_secret_key = random_password.forgejo_secret_key.result
|
forgejo_secret_key = random_password.forgejo_secret_key.result
|
||||||
|
|
@ -73,15 +59,6 @@ resource "digitalocean_droplet" "forgejo" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Volume Attachment
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
resource "digitalocean_volume_attachment" "forgejo" {
|
|
||||||
droplet_id = digitalocean_droplet.forgejo.id
|
|
||||||
volume_id = digitalocean_volume.forgejo.id
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DNS Record (optional - requires domain to be managed by DigitalOcean)
|
# DNS Record (optional - requires domain to be managed by DigitalOcean)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,6 @@ output "urn" {
|
||||||
value = digitalocean_droplet.forgejo.urn
|
value = digitalocean_droplet.forgejo.urn
|
||||||
}
|
}
|
||||||
|
|
||||||
output "volume_id" {
|
|
||||||
description = "ID of the Forgejo volume"
|
|
||||||
value = digitalocean_volume.forgejo.id
|
|
||||||
}
|
|
||||||
|
|
||||||
output "volume_name" {
|
|
||||||
description = "Name of the Forgejo volume"
|
|
||||||
value = digitalocean_volume.forgejo.name
|
|
||||||
}
|
|
||||||
|
|
||||||
output "dns_record_fqdn" {
|
output "dns_record_fqdn" {
|
||||||
description = "FQDN of the DNS record (if managed)"
|
description = "FQDN of the DNS record (if managed)"
|
||||||
value = var.manage_dns ? digitalocean_record.forgejo[0].fqdn : null
|
value = var.manage_dns ? digitalocean_record.forgejo[0].fqdn : null
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,6 @@ variable "droplet_image" {
|
||||||
default = "debian-12-x64"
|
default = "debian-12-x64"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "volume_size" {
|
|
||||||
description = "Size of the data volume in GB"
|
|
||||||
type = number
|
|
||||||
default = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "ssh_port" {
|
variable "ssh_port" {
|
||||||
description = "System SSH port (non-standard, VPC only)"
|
description = "System SSH port (non-standard, VPC only)"
|
||||||
type = number
|
type = number
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,6 @@ output "forgejo_private_ip" {
|
||||||
value = module.forgejo.private_ip
|
value = module.forgejo.private_ip
|
||||||
}
|
}
|
||||||
|
|
||||||
output "forgejo_volume_id" {
|
|
||||||
description = "ID of the Forgejo volume"
|
|
||||||
value = module.forgejo.volume_id
|
|
||||||
}
|
|
||||||
|
|
||||||
output "forgejo_ssh_port" {
|
output "forgejo_ssh_port" {
|
||||||
description = "System SSH port for Forgejo (VPC only)"
|
description = "System SSH port for Forgejo (VPC only)"
|
||||||
value = var.forgejo_ssh_port
|
value = var.forgejo_ssh_port
|
||||||
|
|
|
||||||
|
|
@ -105,12 +105,6 @@ variable "forgejo_droplet_image" {
|
||||||
default = "debian-12-x64"
|
default = "debian-12-x64"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "forgejo_volume_size" {
|
|
||||||
description = "Size of the Forgejo data volume in GB"
|
|
||||||
type = number
|
|
||||||
default = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "forgejo_domain" {
|
variable "forgejo_domain" {
|
||||||
description = "Domain name for Forgejo (e.g., qbit.realms.pub)"
|
description = "Domain name for Forgejo (e.g., qbit.realms.pub)"
|
||||||
type = string
|
type = string
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json
|
- ./config.json:/app/config.json
|
||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,17 @@
|
||||||
|
|
||||||
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
||||||
|
|
||||||
|
// Helper for dynamic host detection
|
||||||
|
function getStreamHost() {
|
||||||
|
if (!browser) return 'localhost';
|
||||||
|
return window.location.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreamProtocol() {
|
||||||
|
if (!browser) return 'http';
|
||||||
|
return window.location.protocol === 'https:' ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
let player;
|
let player;
|
||||||
let playerElement;
|
let playerElement;
|
||||||
let viewerToken = null;
|
let viewerToken = null;
|
||||||
|
|
@ -111,10 +122,12 @@
|
||||||
function initializePlayer() {
|
function initializePlayer() {
|
||||||
if (!playerElement || !window.OvenPlayer || !viewerToken || !actualStreamKey) return;
|
if (!playerElement || !window.OvenPlayer || !viewerToken || !actualStreamKey) return;
|
||||||
|
|
||||||
|
const host = getStreamHost();
|
||||||
|
const proto = getStreamProtocol();
|
||||||
const sources = [
|
const sources = [
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `http://localhost:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${viewerToken}`,
|
file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${viewerToken}`,
|
||||||
label: 'LLHLS'
|
label: 'LLHLS'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,17 @@
|
||||||
|
|
||||||
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
||||||
|
|
||||||
|
// Helper for dynamic host detection
|
||||||
|
function getStreamHost() {
|
||||||
|
if (!browser) return 'localhost';
|
||||||
|
return window.location.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreamProtocol() {
|
||||||
|
if (!browser) return 'http';
|
||||||
|
return window.location.protocol === 'https:' ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
let players = {};
|
let players = {};
|
||||||
let viewerTokens = {};
|
let viewerTokens = {};
|
||||||
let offlineStreams = {}; // Track which streams are offline
|
let offlineStreams = {}; // Track which streams are offline
|
||||||
|
|
@ -127,10 +138,12 @@
|
||||||
|
|
||||||
const isMuted = $streamTiles.unmutedStream !== stream.streamKey;
|
const isMuted = $streamTiles.unmutedStream !== stream.streamKey;
|
||||||
|
|
||||||
|
const host = getStreamHost();
|
||||||
|
const proto = getStreamProtocol();
|
||||||
const sources = [
|
const sources = [
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `http://localhost:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${token}`,
|
file: `${proto}://${host}:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${token}`,
|
||||||
label: 'LLHLS'
|
label: 'LLHLS'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,7 @@
|
||||||
|
|
||||||
<div class="balance-row">
|
<div class="balance-row">
|
||||||
<span class="coin-icon">Ü</span>
|
<span class="coin-icon">Ü</span>
|
||||||
<span>Balance: <strong>{formatUbercoin($ubercoinBalance)}</strong></span>
|
<span>Your balance: <strong>{formatUbercoin($ubercoinBalance)}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,33 @@
|
||||||
import { writable, derived } from 'svelte/store';
|
import { writable, derived } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
// Nakama configuration from environment
|
// Nakama configuration - dynamically detect from browser location for production
|
||||||
const NAKAMA_SERVER_KEY = import.meta.env.VITE_NAKAMA_SERVER_KEY || 'defaultkey';
|
const NAKAMA_SERVER_KEY = import.meta.env.VITE_NAKAMA_SERVER_KEY || 'defaultkey';
|
||||||
const NAKAMA_HOST = import.meta.env.VITE_NAKAMA_HOST || 'localhost';
|
|
||||||
const NAKAMA_PORT = import.meta.env.VITE_NAKAMA_PORT || '80';
|
// Dynamically detect host/protocol from browser location
|
||||||
const NAKAMA_USE_SSL = import.meta.env.VITE_NAKAMA_USE_SSL === 'true';
|
// This ensures production uses the correct domain and SSL settings
|
||||||
|
function getNakamaConfig() {
|
||||||
|
if (!browser) {
|
||||||
|
// SSR fallback - use env vars or defaults
|
||||||
|
return {
|
||||||
|
host: import.meta.env.VITE_NAKAMA_HOST || 'localhost',
|
||||||
|
port: import.meta.env.VITE_NAKAMA_PORT || '80',
|
||||||
|
useSSL: import.meta.env.VITE_NAKAMA_USE_SSL === 'true'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Browser: use current page's host/protocol
|
||||||
|
const isSSL = window.location.protocol === 'https:';
|
||||||
|
return {
|
||||||
|
host: window.location.hostname,
|
||||||
|
port: isSSL ? '443' : (window.location.port || '80'),
|
||||||
|
useSSL: isSSL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nakamaConfig = getNakamaConfig();
|
||||||
|
const NAKAMA_HOST = nakamaConfig.host;
|
||||||
|
const NAKAMA_PORT = nakamaConfig.port;
|
||||||
|
const NAKAMA_USE_SSL = nakamaConfig.useSSL;
|
||||||
|
|
||||||
// Polling interval for games lists (ms)
|
// Polling interval for games lists (ms)
|
||||||
export const GAMES_POLL_INTERVAL = 30000;
|
export const GAMES_POLL_INTERVAL = 30000;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { writable, derived, get } from 'svelte/store';
|
import { writable, derived, get } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
|
// Dynamically detect WebSocket URL from browser location
|
||||||
|
function getWsUrl() {
|
||||||
|
if (!browser) {
|
||||||
|
return import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_URL = getWsUrl();
|
||||||
const SYNC_INTERVAL = 5000; // Sync every 5 seconds (server pushes every 1s anyway)
|
const SYNC_INTERVAL = 5000; // Sync every 5 seconds (server pushes every 1s anyway)
|
||||||
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
|
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
|
||||||
const LEAD_IN_DURATION = 3000; // 3 seconds lead-in for buffering
|
const LEAD_IN_DURATION = 3000; // 3 seconds lead-in for buffering
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let reconnectTimeout = null;
|
let reconnectTimeout = null;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
|
|
@ -5,7 +7,17 @@ const MAX_RECONNECT_ATTEMPTS = 10;
|
||||||
const BASE_RECONNECT_DELAY = 1000; // 1 second
|
const BASE_RECONNECT_DELAY = 1000; // 1 second
|
||||||
const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
||||||
|
|
||||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
|
// Dynamically detect WebSocket URL from browser location
|
||||||
|
// This ensures production uses wss:// and the correct host
|
||||||
|
function getWebSocketURL() {
|
||||||
|
if (!browser) {
|
||||||
|
return import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_URL = getWebSocketURL();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate exponential backoff delay with jitter
|
* Calculate exponential backoff delay with jitter
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
||||||
|
const WEBRTC_PORT = import.meta.env.VITE_WEBRTC_PORT || '3333';
|
||||||
|
|
||||||
|
// Helper functions for dynamic host/protocol detection
|
||||||
|
function getStreamHost() {
|
||||||
|
if (!browser) return 'localhost';
|
||||||
|
return window.location.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreamProtocol(secure = false) {
|
||||||
|
if (!browser) return secure ? 'https' : 'http';
|
||||||
|
return window.location.protocol === 'https:' ? 'https' : 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWsProtocol() {
|
||||||
|
if (!browser) return 'ws';
|
||||||
|
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
}
|
||||||
|
|
||||||
let player;
|
let player;
|
||||||
let realm = null;
|
let realm = null;
|
||||||
let streamKey = '';
|
let streamKey = '';
|
||||||
|
|
@ -295,23 +312,28 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = [];
|
const sources = [];
|
||||||
|
|
||||||
if (streamKey) {
|
if (streamKey) {
|
||||||
|
// Dynamic URLs based on current page host/protocol
|
||||||
|
const host = getStreamHost();
|
||||||
|
const httpProto = getStreamProtocol();
|
||||||
|
const wsProto = getWsProtocol();
|
||||||
|
|
||||||
// Add all sources - LLHLS first (default), then HLS, then WebRTC as fallback
|
// Add all sources - LLHLS first (default), then HLS, then WebRTC as fallback
|
||||||
sources.push(
|
sources.push(
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${viewerToken}`,
|
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${viewerToken}`,
|
||||||
label: 'LLHLS (Low Latency)'
|
label: 'LLHLS (Low Latency)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${viewerToken}`,
|
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${viewerToken}`,
|
||||||
label: 'HLS (Standard)'
|
label: 'HLS (Standard)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'webrtc',
|
type: 'webrtc',
|
||||||
file: `ws://localhost:3333/app/${streamKey}`,
|
file: `${wsProto}://${host}:${WEBRTC_PORT}/app/${streamKey}`,
|
||||||
label: 'WebRTC (Ultra Low Latency)'
|
label: 'WebRTC (Ultra Low Latency)'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('banner', file);
|
formData.append('banner', file);
|
||||||
|
|
||||||
const response = await fetch(`/api/forums/${$page.params.slug}/banner`, {
|
const response = await fetch(`/api/forums/${forum.id}/banner`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData
|
body: formData
|
||||||
|
|
@ -274,7 +274,7 @@
|
||||||
bannerError = '';
|
bannerError = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/forums/${$page.params.slug}/banner`, {
|
const response = await fetch(`/api/forums/${forum.id}/banner`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
@ -351,7 +351,7 @@
|
||||||
bannerError = '';
|
bannerError = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/forums/${$page.params.slug}/banner/position`, {
|
const response = await fetch(`/api/forums/${forum.id}/banner/position`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
@ -396,7 +396,7 @@
|
||||||
bannerError = '';
|
bannerError = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/forums/${$page.params.slug}/title-color`, {
|
const response = await fetch(`/api/forums/${forum.id}/title-color`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { auth, isAuthenticated, isStreamer, isRestreamer, isUploader, isWatchCreator } from '$lib/stores/auth';
|
import { auth, isAuthenticated, isStreamer, isRestreamer, isUploader, isWatchCreator } from '$lib/stores/auth';
|
||||||
import { siteSettings } from '$lib/stores/siteSettings';
|
import { siteSettings } from '$lib/stores/siteSettings';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
|
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
|
||||||
|
|
||||||
|
// Dynamic host for streaming URLs (use current domain in production)
|
||||||
|
$: streamHost = browser ? window.location.hostname : 'localhost';
|
||||||
|
|
||||||
let realms = [];
|
let realms = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error = '';
|
let error = '';
|
||||||
|
|
@ -2968,13 +2972,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-info-row">
|
<div class="stream-info-row">
|
||||||
<span class="stream-info-label">RTMP URL:</span>
|
<span class="stream-info-label">RTMP URL:</span>
|
||||||
<span class="stream-key">rtmp://localhost:1935/app/{realm.streamKey}</span>
|
<span class="stream-key">rtmp://{streamHost}:1935/app/{realm.streamKey}</span>
|
||||||
<button on:click={() => copyToClipboard(`rtmp://localhost:1935/app/${realm.streamKey}`)}>Copy</button>
|
<button on:click={() => copyToClipboard(`rtmp://${streamHost}:1935/app/${realm.streamKey}`)}>Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-info-row">
|
<div class="stream-info-row">
|
||||||
<span class="stream-info-label">SRT URL:</span>
|
<span class="stream-info-label">SRT URL:</span>
|
||||||
<span class="stream-key">srt://localhost:9999?streamid={encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}</span>
|
<span class="stream-key">srt://{streamHost}:9999?streamid={encodeURIComponent(`srt://${streamHost}:9999/app/${realm.streamKey}`)}</span>
|
||||||
<button on:click={() => copyToClipboard(`srt://localhost:9999?streamid=${encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}`)}>Copy</button>
|
<button on:click={() => copyToClipboard(`srt://${streamHost}:9999?streamid=${encodeURIComponent(`srt://${streamHost}:9999/app/${realm.streamKey}`)}`)}>Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,11 @@
|
||||||
async function loadProfile(username) {
|
async function loadProfile(username) {
|
||||||
try {
|
try {
|
||||||
// Public endpoint - no auth header needed
|
// Public endpoint - no auth header needed
|
||||||
const response = await fetch(`/api/users/${username}`);
|
// Add cache: 'no-store' to always get fresh data
|
||||||
|
const response = await fetch(`/api/users/${username}`, {
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
profile = data.profile;
|
profile = data.profile;
|
||||||
|
|
@ -114,11 +117,20 @@
|
||||||
showTipModal = false;
|
showTipModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTipSent(event) {
|
async function handleTipSent(event) {
|
||||||
showTipModal = false;
|
showTipModal = false;
|
||||||
// Optionally refresh profile to show updated balance
|
const result = event.detail;
|
||||||
|
|
||||||
|
// Update profile balance immediately based on the transaction result
|
||||||
|
// This avoids race conditions where the DB hasn't committed yet
|
||||||
|
if (profile && result && result.received !== undefined) {
|
||||||
|
profile.ubercoinBalance = (profile.ubercoinBalance || 0) + result.received;
|
||||||
|
profile = profile; // Trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also reload from server after a short delay to ensure data consistency
|
||||||
if (profile) {
|
if (profile) {
|
||||||
loadProfile(profile.username);
|
setTimeout(() => loadProfile(profile.username), 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,13 @@ const config = {
|
||||||
'font-src': ["'self'", 'data:', 'https://cdnjs.cloudflare.com'],
|
'font-src': ["'self'", 'data:', 'https://cdnjs.cloudflare.com'],
|
||||||
'connect-src': [
|
'connect-src': [
|
||||||
"'self'",
|
"'self'",
|
||||||
'ws://localhost:*',
|
'ws://*:*', // Allow any WebSocket
|
||||||
'wss://localhost:*',
|
'wss://*:*', // Allow any secure WebSocket
|
||||||
'http://localhost:*',
|
'http://*:*', // Allow any HTTP (for dev and streaming)
|
||||||
'ws://127.0.0.1:*',
|
'https://*:*', // Allow any HTTPS
|
||||||
'wss://127.0.0.1:*',
|
|
||||||
'http://127.0.0.1:*',
|
|
||||||
'https://www.youtube.com'
|
'https://www.youtube.com'
|
||||||
],
|
],
|
||||||
'media-src': ["'self'", 'blob:', 'http://localhost:*'],
|
'media-src': ["'self'", 'blob:', 'http://*:*', 'https://*:*'],
|
||||||
'frame-src': ["'self'", 'blob:', 'https://www.youtube.com'],
|
'frame-src': ["'self'", 'blob:', 'https://www.youtube.com'],
|
||||||
'object-src': ["'none'"],
|
'object-src': ["'none'"],
|
||||||
'frame-ancestors': ["'none'"],
|
'frame-ancestors': ["'none'"],
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,71 @@ runcmd:
|
||||||
- mkdir -p /opt/realms
|
- mkdir -p /opt/realms
|
||||||
- mkdir -p /opt/realms/uploads
|
- mkdir -p /opt/realms/uploads
|
||||||
|
|
||||||
|
# Generate .env with secure random secrets (only if it doesn't exist)
|
||||||
|
- |
|
||||||
|
ENV_FILE="/opt/realms/.env"
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Generating .env with secure random secrets..."
|
||||||
|
cat > "$ENV_FILE" << 'ENVEOF'
|
||||||
|
# =============================================================================
|
||||||
|
# Realms Production Environment - Auto-generated on first deploy
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
|
||||||
|
|
||||||
|
# JWT Secret for authentication
|
||||||
|
JWT_SECRET=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48)
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
|
||||||
|
|
||||||
|
# OvenMediaEngine API Token
|
||||||
|
OME_API_TOKEN=$(openssl rand -hex 32)
|
||||||
|
|
||||||
|
# Nakama Game Server
|
||||||
|
NAKAMA_SERVER_KEY=$(openssl rand -hex 16)
|
||||||
|
NAKAMA_CONSOLE_PASSWORD=$(openssl rand -base64 16 | tr -d '/+=' | head -c 16)
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
# Generate actual random values by evaluating the file
|
||||||
|
# Read template and generate real values
|
||||||
|
DB_PASS=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
|
||||||
|
JWT_SEC=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48)
|
||||||
|
REDIS_PASS=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
|
||||||
|
OME_TOKEN=$(openssl rand -hex 32)
|
||||||
|
NAKAMA_KEY=$(openssl rand -hex 16)
|
||||||
|
NAKAMA_PASS=$(openssl rand -base64 16 | tr -d '/+=' | head -c 16)
|
||||||
|
|
||||||
|
cat > "$ENV_FILE" << ENVEOF
|
||||||
|
# =============================================================================
|
||||||
|
# Realms Production Environment - Auto-generated on first deploy
|
||||||
|
# Generated: $(date -Iseconds)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_PASSWORD=$DB_PASS
|
||||||
|
|
||||||
|
# JWT Secret for authentication
|
||||||
|
JWT_SECRET=$JWT_SEC
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_PASSWORD=$REDIS_PASS
|
||||||
|
|
||||||
|
# OvenMediaEngine API Token
|
||||||
|
OME_API_TOKEN=$OME_TOKEN
|
||||||
|
|
||||||
|
# Nakama Game Server
|
||||||
|
NAKAMA_SERVER_KEY=$NAKAMA_KEY
|
||||||
|
NAKAMA_CONSOLE_PASSWORD=$NAKAMA_PASS
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo ".env generated with secure random secrets"
|
||||||
|
else
|
||||||
|
echo ".env already exists, skipping generation"
|
||||||
|
fi
|
||||||
|
|
||||||
# Enable unattended upgrades
|
# Enable unattended upgrades
|
||||||
- systemctl enable unattended-upgrades
|
- systemctl enable unattended-upgrades
|
||||||
- systemctl start unattended-upgrades
|
- systemctl start unattended-upgrades
|
||||||
|
|
@ -237,12 +302,35 @@ runcmd:
|
||||||
|
|
||||||
# Obtain initial SSL certificate (standalone mode - no webserver running yet)
|
# Obtain initial SSL certificate (standalone mode - no webserver running yet)
|
||||||
# This runs before Docker services start, so port 80 is free
|
# This runs before Docker services start, so port 80 is free
|
||||||
|
# Retry with delays to wait for DigitalOcean firewall propagation
|
||||||
- |
|
- |
|
||||||
certbot certonly --standalone \
|
MAX_ATTEMPTS=5
|
||||||
--non-interactive \
|
ATTEMPT=1
|
||||||
--agree-tos \
|
DELAY=30
|
||||||
--email ${letsencrypt_email} \
|
|
||||||
-d ${domain} \
|
|
||||||
|| echo "Certbot failed - certificate may need to be obtained manually after DNS propagates"
|
|
||||||
|
|
||||||
final_message: "Realms app server ready after $UPTIME seconds. SSL cert obtained for ${domain}. Deploy via Forgejo CI/CD."
|
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||||
|
echo "Certbot attempt $ATTEMPT of $MAX_ATTEMPTS..."
|
||||||
|
|
||||||
|
if certbot certonly --standalone \
|
||||||
|
--non-interactive \
|
||||||
|
--agree-tos \
|
||||||
|
--email ${letsencrypt_email} \
|
||||||
|
-d ${domain}; then
|
||||||
|
echo "SSL certificate obtained successfully!"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "Certbot failed on attempt $ATTEMPT"
|
||||||
|
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||||
|
echo "Waiting $${DELAY}s for firewall propagation before retry..."
|
||||||
|
sleep $DELAY
|
||||||
|
DELAY=$((DELAY * 2)) # Exponential backoff
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ATTEMPT -gt $MAX_ATTEMPTS ]; then
|
||||||
|
echo "Certbot failed after $MAX_ATTEMPTS attempts - obtain certificate manually"
|
||||||
|
fi
|
||||||
|
|
||||||
|
final_message: "Realms app server ready after $UPTIME seconds. Deploy via Forgejo CI/CD."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue