mirror of
https://github.com/hyzendust/freedoms4-backend-public.git
synced 2026-06-30 23:12:18 +02:00
Compare commits
8 Commits
eb08b8ec5d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9037354195 | ||
|
|
a21e5f64ed | ||
|
|
a15bcedac1 | ||
|
|
5029eba715 | ||
|
|
b99440ea75 | ||
|
|
31c4356b1e | ||
|
|
483b995270 | ||
|
|
e5e37b6b6e |
157
admin.php
157
admin.php
@@ -30,6 +30,10 @@ define('DB_PORT', $env['DB_PORT'] ?? '5432');
|
||||
define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4');
|
||||
define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user');
|
||||
define('DB_PASS', $env['DB_PASS'] ?? '');
|
||||
define('PROSODY_DB_NAME', $env['PROSODY_DB_NAME'] ?? 'prosody');
|
||||
define('PROSODY_DB_USER', $env['PROSODY_DB_USER'] ?? 'prosody');
|
||||
define('PROSODY_DB_PASS', $env['PROSODY_DB_PASS'] ?? '');
|
||||
define('PROSODY_HOST', $env['PROSODY_HOST'] ?? 'freedoms4.org');
|
||||
|
||||
define('SESSION_NAME', 'f4_session');
|
||||
define('SESSION_SECURE', true);
|
||||
@@ -41,7 +45,7 @@ define('ADMIN_USER', 'hyzen');
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
|
||||
|
||||
if ($origin && !in_array($origin, $allowed_origins, true)) {
|
||||
if (!$origin || !in_array($origin, $allowed_origins, true)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden.']);
|
||||
@@ -83,6 +87,135 @@ function db_connect(): PDO {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function prosody_db_connect(): ?PDO {
|
||||
static $pdo = null;
|
||||
static $failed = false;
|
||||
if ($pdo !== null) return $pdo;
|
||||
if ($failed) return null;
|
||||
try {
|
||||
$dsn = sprintf('pgsql:host=127.0.0.1;port=5432;dbname=%s', PROSODY_DB_NAME);
|
||||
$pdo = new PDO($dsn, PROSODY_DB_USER, PROSODY_DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
return $pdo;
|
||||
} catch (PDOException $e) {
|
||||
error_log('admin.php Prosody DB error: ' . $e->getMessage());
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a user's XMPP account (all stores: accounts, roster, vcard, etc.)
|
||||
// so it doesn't keep working indefinitely after the user is deleted.
|
||||
function delete_xmpp_account(string $username): void {
|
||||
$pdo = prosody_db_connect();
|
||||
if (!$pdo) return; // Prosody DB unreachable — already logged, don't block user deletion on it
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM prosody WHERE host = :h AND "user" = :u')
|
||||
->execute([':h' => PROSODY_HOST, ':u' => strtolower($username)]);
|
||||
} catch (Exception $e) {
|
||||
error_log("delete_xmpp_account failed for {$username}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function ensure_xmpp_backup_table(PDO $pdo): void {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE IF NOT EXISTS xmpp_account_backups (
|
||||
username VARCHAR(32) PRIMARY KEY,
|
||||
rows JSONB NOT NULL,
|
||||
blocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
// Back up a user's Prosody rows (all stores) into xmpp_account_backups,
|
||||
// then remove them from the live prosody table — mirroring how
|
||||
// email-block.sh moves the Dovecot passwd-file entry into a backup file.
|
||||
function xmpp_backup_and_remove(string $username): void {
|
||||
$prosody = prosody_db_connect();
|
||||
if (!$prosody) return; // logged already — don't block the block/delete action on it
|
||||
|
||||
try {
|
||||
$stmt = $prosody->prepare('SELECT host, "user", store, key, type, value FROM prosody WHERE host = :h AND "user" = :u');
|
||||
$stmt->execute([':h' => PROSODY_HOST, ':u' => strtolower($username)]);
|
||||
$rows = $stmt->fetchAll();
|
||||
if (!$rows) return; // nothing to back up (e.g. never had an XMPP account)
|
||||
|
||||
$pdo = db_connect();
|
||||
ensure_xmpp_backup_table($pdo);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO xmpp_account_backups (username, rows, blocked_at)
|
||||
VALUES (:u, :rows, NOW())
|
||||
ON CONFLICT (username) DO UPDATE SET rows = :rows2, blocked_at = NOW()'
|
||||
)->execute([
|
||||
':u' => strtolower($username),
|
||||
':rows' => json_encode($rows),
|
||||
':rows2' => json_encode($rows),
|
||||
]);
|
||||
|
||||
$prosody->prepare('DELETE FROM prosody WHERE host = :h AND "user" = :u')
|
||||
->execute([':h' => PROSODY_HOST, ':u' => strtolower($username)]);
|
||||
} catch (Exception $e) {
|
||||
error_log("xmpp_backup_and_remove failed for {$username}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Restore a previously backed-up XMPP account (on unblock) and remove the
|
||||
// backup row once restored.
|
||||
function xmpp_restore_from_backup(string $username): void {
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
ensure_xmpp_backup_table($pdo);
|
||||
$stmt = $pdo->prepare('SELECT rows FROM xmpp_account_backups WHERE username = :u LIMIT 1');
|
||||
$stmt->execute([':u' => strtolower($username)]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) return; // nothing backed up — nothing to restore
|
||||
|
||||
$prosody = prosody_db_connect();
|
||||
if (!$prosody) return; // can't restore right now — backup row stays put, safe to retry later
|
||||
|
||||
$entries = json_decode($row['rows'], true) ?: [];
|
||||
$insert = $prosody->prepare(
|
||||
'INSERT INTO prosody (host, "user", store, key, type, value) VALUES (:host, :user, :store, :key, :type, :value)'
|
||||
);
|
||||
foreach ($entries as $entry) {
|
||||
try {
|
||||
$insert->execute([
|
||||
':host' => $entry['host'],
|
||||
':user' => $entry['user'],
|
||||
':store' => $entry['store'],
|
||||
':key' => $entry['key'],
|
||||
':type' => $entry['type'],
|
||||
':value' => $entry['value'],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Row may already exist (e.g. partial prior restore) — skip and continue
|
||||
error_log("xmpp_restore_from_backup: skipped one row for {$username}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM xmpp_account_backups WHERE username = :u')
|
||||
->execute([':u' => strtolower($username)]);
|
||||
} catch (Exception $e) {
|
||||
error_log("xmpp_restore_from_backup failed for {$username}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any leftover XMPP backup row for a user (used on permanent
|
||||
// deletion, in case they were blocked at some point before being deleted).
|
||||
function xmpp_delete_backup(string $username): void {
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
ensure_xmpp_backup_table($pdo);
|
||||
$pdo->prepare('DELETE FROM xmpp_account_backups WHERE username = :u')
|
||||
->execute([':u' => strtolower($username)]);
|
||||
} catch (Exception $e) {
|
||||
error_log("xmpp_delete_backup failed for {$username}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session + admin check ──
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name(SESSION_NAME);
|
||||
@@ -139,7 +272,7 @@ if ($user_id === 0) {
|
||||
$pdo = db_connect();
|
||||
|
||||
// Prevent admin from acting on themselves
|
||||
$stmt = $pdo->prepare('SELECT username FROM users WHERE id = :id LIMIT 1');
|
||||
$stmt = $pdo->prepare('SELECT username, email FROM users WHERE id = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $user_id]);
|
||||
$target = $stmt->fetch();
|
||||
if (!$target) {
|
||||
@@ -160,6 +293,10 @@ if ($action === 'block_user') {
|
||||
$safe_user = escapeshellarg($target['username']);
|
||||
shell_exec("sudo /usr/local/bin/email-block block {$safe_user} 2>&1");
|
||||
|
||||
// Backup and remove their XMPP/Prosody account so it stops working
|
||||
// while blocked, restorable on unblock.
|
||||
xmpp_backup_and_remove($target['username']);
|
||||
|
||||
json_out(['success' => true]);
|
||||
}
|
||||
|
||||
@@ -174,6 +311,9 @@ if ($action === 'unblock_user') {
|
||||
$safe_user = escapeshellarg($target['username']);
|
||||
shell_exec("sudo /usr/local/bin/email-block unblock {$safe_user} 2>&1");
|
||||
|
||||
// Restore their XMPP/Prosody account from backup, if one exists.
|
||||
xmpp_restore_from_backup($target['username']);
|
||||
|
||||
json_out(['success' => true]);
|
||||
}
|
||||
|
||||
@@ -202,6 +342,19 @@ if ($action === 'delete_user') {
|
||||
|
||||
$pdo->prepare('DELETE FROM users WHERE id = :id')
|
||||
->execute([':id' => $user_id]);
|
||||
|
||||
// Remove the user's XMPP/Prosody account so it stops working once their
|
||||
// freedoms4 account is gone, instead of remaining usable indefinitely.
|
||||
delete_xmpp_account($target['username']);
|
||||
// ...and remove any leftover backup row too, in case they were blocked
|
||||
// at some point before being deleted outright.
|
||||
xmpp_delete_backup($target['username']);
|
||||
|
||||
// Clear OTP history for this email so re-signing-up doesn't hit the
|
||||
// daily OTP request limit because of OTPs sent before deletion.
|
||||
$pdo->prepare('DELETE FROM email_otps WHERE email = :e')
|
||||
->execute([':e' => $target['email']]);
|
||||
|
||||
json_out(['success' => true]);
|
||||
}
|
||||
|
||||
|
||||
66
auth.php
66
auth.php
@@ -43,13 +43,14 @@ define('OTP_FROM', 'no-reply@freedoms4.org');
|
||||
define('OTP_TTL', 600); // 10 minutes
|
||||
define('OTP_MAX_DAY', 5); // max OTPs per email per 24 h
|
||||
define('OTP_MAX_FAILS', 10); // max failed OTP attempts per IP before lockout
|
||||
define('LOGIN_MAX_FAILS', 10); // max failed login attempts per username before lockout (independent of IP)
|
||||
define('MAX_BODY_BYTES', 4096);
|
||||
|
||||
// ── CORS ──
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
|
||||
|
||||
if ($origin && !in_array($origin, $allowed_origins, true)) {
|
||||
if (!$origin || !in_array($origin, $allowed_origins, true)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden.']);
|
||||
@@ -177,6 +178,40 @@ function otp_fail_reset(string $ip): void {
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
// Per-account login failure tracking — independent of the global per-IP
|
||||
// rate limit, so a brute-force attack distributed across many IPs still
|
||||
// gets locked out once a single account has too many failed attempts.
|
||||
function login_fail_count(string $username): int {
|
||||
$key = 'loginfail_' . hash('sha256', strtolower($username));
|
||||
if (function_exists('apcu_fetch')) {
|
||||
$count = apcu_fetch($key, $ok);
|
||||
return $ok ? (int)$count : 0;
|
||||
}
|
||||
return $_SESSION[$key] ?? 0;
|
||||
}
|
||||
|
||||
function login_fail_increment(string $username): void {
|
||||
$key = 'loginfail_' . hash('sha256', strtolower($username));
|
||||
if (function_exists('apcu_inc')) {
|
||||
if (!apcu_fetch($key)) {
|
||||
apcu_store($key, 1, 900); // 15 minute lockout window
|
||||
} else {
|
||||
apcu_inc($key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$_SESSION[$key] = ($_SESSION[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
function login_fail_reset(string $username): void {
|
||||
$key = 'loginfail_' . hash('sha256', strtolower($username));
|
||||
if (function_exists('apcu_delete')) {
|
||||
apcu_delete($key);
|
||||
return;
|
||||
}
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
function create_xmpp_account(string $username, string $password): bool {
|
||||
try {
|
||||
$pdo = prosody_db_connect();
|
||||
@@ -376,6 +411,12 @@ if ($action === 'login') {
|
||||
json_out(['success' => false, 'message' => 'Username and password are required.']);
|
||||
}
|
||||
|
||||
// Per-account brute-force lockout — checked before touching the DB, and
|
||||
// independent of the global per-IP limit above.
|
||||
if (login_fail_count($username) >= LOGIN_MAX_FAILS) {
|
||||
json_out(['success' => false, 'message' => 'Too many failed attempts for this account. Please wait 15 minutes and try again.'], 429);
|
||||
}
|
||||
|
||||
$pdo = db_connect();
|
||||
$stmt = $pdo->prepare('SELECT id, username, password_hash, blocked FROM users WHERE username = :u LIMIT 1');
|
||||
$stmt->execute([':u' => $username]);
|
||||
@@ -383,9 +424,13 @@ if ($action === 'login') {
|
||||
|
||||
$hash = $user['password_hash'] ?? '$2y$12$invalidhashpadding000000000000000000000000000000000000000';
|
||||
if (!$user || !password_verify($password, $hash)) {
|
||||
login_fail_increment($username);
|
||||
json_out(['success' => false, 'message' => 'Invalid username or password.']);
|
||||
}
|
||||
|
||||
// Correct password — clear the failure counter for this account.
|
||||
login_fail_reset($username);
|
||||
|
||||
if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) {
|
||||
json_out(['success' => false, 'message' => 'This account has been blocked.']);
|
||||
}
|
||||
@@ -499,9 +544,10 @@ if ($action === 'signup') {
|
||||
// Regenerate session after successful signup (#5)
|
||||
session_regenerate_id(true);
|
||||
|
||||
// Create XMPP account
|
||||
if (!create_xmpp_account($username, $password)) {
|
||||
error_log("XMPP account creation failed for new user: {$username}");
|
||||
// Create XMPP account (JID localparts are lowercased per XMPP convention)
|
||||
$xmpp_username = strtolower($username);
|
||||
if (!create_xmpp_account($xmpp_username, $password)) {
|
||||
error_log("XMPP account creation failed for new user: {$xmpp_username}");
|
||||
// Don't fail the signup — user account exists, XMPP can be fixed manually
|
||||
}
|
||||
|
||||
@@ -527,14 +573,14 @@ if ($action === 'signup') {
|
||||
|
||||
$welcome_message =
|
||||
"Hi {$username},\n\n" .
|
||||
"Welcome aboard!\n\n" .
|
||||
"Thank you for signing up.\n\n" .
|
||||
"Your credentials:\n\n" .
|
||||
"XMPP JID: {$username}@freedoms4.org\n" .
|
||||
"Email ID: {$username}@freedoms4.org\n\n" .
|
||||
"Welcome aboard, thank you for signing up!\n\n" .
|
||||
"Your credentials for the services:\n\n" .
|
||||
"Email ID: {$username}@freedoms4.org\n" .
|
||||
"XMPP JID: {$xmpp_username}@freedoms4.org\n" .
|
||||
"Passwords: Use the same password that you used during registration.\n\n" .
|
||||
"If you have any questions, I'm here to help:\n" .
|
||||
"Email <mailto:hyzen@freedoms4.org> and XMPP <xmpp:hyzen@freedoms4.org>: hyzen@freedoms4.org\n" .
|
||||
"Email: mailto:hyzen@freedoms4.org\n".
|
||||
"XMPP: xmpp:hyzen@freedoms4.org\n" .
|
||||
"IRC/Liberachat: hyzen, #freedoms4\n\n" .
|
||||
"Best regards,\n" .
|
||||
"hyzen, freedoms4.org.";
|
||||
|
||||
@@ -40,7 +40,7 @@ define('MAX_COMMENT_LEN', 2000);
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org'];
|
||||
|
||||
if ($origin && !in_array($origin, $allowed_origins, true)) {
|
||||
if (!$origin || !in_array($origin, $allowed_origins, true)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success' => false, 'message' => 'Forbidden.']);
|
||||
@@ -273,6 +273,12 @@ if ($action === 'post' || $action === 'reply') {
|
||||
if ($post_id === '') {
|
||||
json_out(['success' => false, 'message' => 'post_id is required.']);
|
||||
}
|
||||
// post_id should always be a site-relative path like "/blog/some-post/".
|
||||
// Reject anything else here, before it can shape outgoing notification
|
||||
// email content or anything else downstream.
|
||||
if (!preg_match('#^/[a-zA-Z0-9_/-]{1,200}/$#', $post_id)) {
|
||||
json_out(['success' => false, 'message' => 'Invalid post_id.']);
|
||||
}
|
||||
if ($text === '') {
|
||||
json_out(['success' => false, 'message' => 'Comment cannot be empty.']);
|
||||
}
|
||||
|
||||
60
uninstall.sh
60
uninstall.sh
@@ -20,11 +20,11 @@ fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo -e "${BLUE} freedoms4 uninstall (keeps DB + email accounts)${NC}"
|
||||
echo -e "${BLUE} freedoms4 uninstall (keeps DB + mail working)${NC}"
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo ""
|
||||
warn "This removes deployed files and config only."
|
||||
warn "Database, email accounts, and mailboxes are preserved."
|
||||
warn "This removes deployed API/nginx files and config only."
|
||||
warn "Database, email accounts, mailboxes, and mail client auth are preserved."
|
||||
echo ""
|
||||
|
||||
# ── 1. Stop php-fpm to release DB connections ──
|
||||
@@ -50,42 +50,17 @@ rm -f /usr/local/bin/email-account-create
|
||||
rm -f /etc/sudoers.d/email-account-create
|
||||
success "email-account-create removed."
|
||||
|
||||
# ── 5. Undo Dovecot passwd-file auth config ──
|
||||
# NOTE: /var/dovecot/users and /var/vmail are intentionally preserved.
|
||||
info "Reverting Dovecot auth config (preserving user accounts and mailboxes)..."
|
||||
sed -i '/auth-passwdfile/d' /etc/dovecot/conf.d/10-auth.conf
|
||||
cat > /etc/dovecot/conf.d/auth-passwdfile.conf.ext << 'DOVECOT'
|
||||
# passdb and userdb for virtual users — managed by full-setup.sh
|
||||
# (currently inactive; run full-setup.sh to re-enable)
|
||||
DOVECOT
|
||||
rm -f /etc/dovecot/conf.d/99-postfix-auth.conf
|
||||
systemctl reload dovecot
|
||||
success "Dovecot config reverted (accounts and mailboxes untouched)."
|
||||
# ── 5. Dovecot auth config: left untouched ──
|
||||
# Mail clients must keep working after uninstall, so the auth-passwdfile
|
||||
# config in /etc/dovecot/conf.d/10-auth.conf is intentionally NOT reverted.
|
||||
info "Leaving Dovecot auth config untouched (mail clients keep working)..."
|
||||
success "Dovecot config left as-is — existing accounts can still log in."
|
||||
|
||||
# ── 6. Undo Postfix SASL and virtual mailbox config ──
|
||||
info "Reverting Postfix config..."
|
||||
|
||||
# Use postconf -X to fully remove parameters rather than set them empty.
|
||||
postconf -X transport_maps
|
||||
postconf -X dovecot_destination_recipient_limit
|
||||
postconf -X local_recipient_maps
|
||||
postconf -X smtpd_sasl_type
|
||||
postconf -X smtpd_sasl_path
|
||||
postconf -e "smtpd_sasl_auth_enable = no"
|
||||
postconf -X smtpd_sasl_security_options
|
||||
postconf -X smtpd_sasl_local_domain
|
||||
postconf -e "broken_sasl_auth_clients = no"
|
||||
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination"
|
||||
|
||||
# Remove the dovecot pipe transport block from master.cf
|
||||
sed -i '/^dovecot[[:space:]]*unix.*pipe/,/argv=\/usr\/lib\/dovecot\//d' /etc/postfix/master.cf 2>/dev/null || true
|
||||
|
||||
# NOTE: /etc/postfix/virtual_transport and its .db are preserved so that
|
||||
# existing site-created email accounts retain their routing entries when
|
||||
# full-setup.sh re-enables transport_maps.
|
||||
success "Postfix config reverted — system users (hyzen etc.) unaffected."
|
||||
|
||||
systemctl reload postfix
|
||||
# ── 6. Postfix SASL / virtual mailbox config: left untouched ──
|
||||
# Reverting this previously broke client SMTP auth and mail delivery for
|
||||
# already-created accounts, so it's intentionally skipped here.
|
||||
info "Leaving Postfix SASL and virtual transport config untouched..."
|
||||
success "Postfix config left as-is — existing accounts keep sending/receiving mail."
|
||||
|
||||
# ── 7. Restart php-fpm ──
|
||||
info "Restarting php8.2-fpm..."
|
||||
@@ -103,6 +78,13 @@ echo " - /var/dovecot/users (virtual email accounts)"
|
||||
echo " - /var/vmail (mailboxes)"
|
||||
echo " - /etc/postfix/virtual_transport (routing entries)"
|
||||
echo " - vmail system user"
|
||||
echo " - Dovecot auth-passwdfile config (clients can still log in)"
|
||||
echo " - Postfix SASL + virtual transport config (mail still sends/receives)"
|
||||
echo ""
|
||||
echo " Run full-setup.sh again to redeploy without losing any data."
|
||||
echo " Removed:"
|
||||
echo " - API dir (/var/www/freedoms4), env file (/etc/freedoms4)"
|
||||
echo " - Nginx site for backend.freedoms4.org"
|
||||
echo " - email-account-create wrapper + sudoers rule (no new accounts via signup)"
|
||||
echo ""
|
||||
echo " Run full-setup.sh again to redeploy the API/signup flow."
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user