mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: add Let's Encrypt ACME support, fix security issues, improve server UX
Server: - Add Let's Encrypt (ACME) TLS mode with autocert, HTTP-01 challenges on :80, and automatic certificate renewal (tls.mode: "acme" in config.yaml) - Add ASCII art startup banner with server info and endpoint URLs - Fix CSP blocking admin panel inline styles/scripts (per-route override) - Suppress TLS handshake error noise in console output - Fix TOCTOU race in invite consumption (atomic UPDATE with row-count check) - Fix sendMsg mutex race condition (hold lock for entire send) - Fix permission override formula (deny-first, allow-wins) - Fix voice join parsing channelID before permission check - Add session expiry check at WebSocket auth and periodic revalidation - Add message length limit (4000 chars) and emoji length validation (32 bytes) - Add file size enforcement in storage after io.Copy - Add checksum URL validation in updater - Add backup path traversal protection (BackupToSafe) - Add self-modification guard in admin handlePatchUser - Fix admin ownerOnlyMiddleware to use context user instead of re-auth - Remove redundant startup log lines (banner shows same info) - Add periodic expired session cleanup (15-min ticker) - Add permissions package with bitfield constants and EffectivePerms - Add rate limiter cleanup goroutine to prevent unbounded growth - Add auth helpers (IsEffectivelyBanned, IsSessionExpired) - Add WebSocket origin validation Client: - Add TOFU certificate trust service - Add receive loop error handling - Fix redundant else-if in OnChatMessage
This commit is contained in:
@@ -4,3 +4,9 @@
|
||||
Server/chatserver.exe
|
||||
Server/config.yaml
|
||||
Server/data/
|
||||
|
||||
# Test coverage artifacts
|
||||
Server/cov.out
|
||||
Server/cover.out
|
||||
Server/coverage.out
|
||||
Server/ws_cover.out
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.IO;
|
||||
using OwnCord.Client.Services;
|
||||
|
||||
namespace OwnCord.Client.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CertificateTrustService — Trust-On-First-Use (TOFU) certificate pinning.
|
||||
/// Each test uses an isolated temp directory so there is no shared state between tests.
|
||||
/// </summary>
|
||||
public sealed class CertificateTrustServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
|
||||
// Factory so each assertion that needs a "new instance" can create one pointing at the same dir.
|
||||
private CertificateTrustService NewSvc() => new(_tempDir);
|
||||
|
||||
// ── IsTrusted ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_FirstUse_AutoTrustsAndReturnsTrue()
|
||||
{
|
||||
// Arrange: no stored fingerprint for this host
|
||||
var svc = NewSvc();
|
||||
|
||||
// Act: first connection — TOFU should auto-trust
|
||||
var result = svc.IsTrusted("server1.local:8443", "AABBCC112233");
|
||||
|
||||
// Assert
|
||||
Assert.True(result, "First-use should auto-trust the certificate and return true.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_SameFingerprint_ReturnsTrue()
|
||||
{
|
||||
// Arrange: trust fingerprint on first use
|
||||
var svc = NewSvc();
|
||||
svc.IsTrusted("server2.local:8443", "FINGERPRINT_A");
|
||||
|
||||
// Act: same fingerprint presented again
|
||||
var result = svc.IsTrusted("server2.local:8443", "FINGERPRINT_A");
|
||||
|
||||
Assert.True(result, "A previously trusted fingerprint must continue to be accepted.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_DifferentFingerprint_ReturnsFalse()
|
||||
{
|
||||
// Arrange: trust an initial fingerprint
|
||||
var svc = NewSvc();
|
||||
svc.IsTrusted("server3.local:8443", "FINGERPRINT_ORIGINAL");
|
||||
|
||||
// Act: different fingerprint — cert was swapped
|
||||
var result = svc.IsTrusted("server3.local:8443", "FINGERPRINT_ATTACKER");
|
||||
|
||||
Assert.False(result, "A changed fingerprint must be rejected to prevent MITM.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_NullCertificate_ReturnsFalse()
|
||||
{
|
||||
// Arrange: host has an existing trusted fingerprint
|
||||
var svc = NewSvc();
|
||||
svc.TrustFingerprint("server4.local:8443", "FINGERPRINT_OK");
|
||||
|
||||
// Act: null/empty fingerprint (cert was null)
|
||||
var resultNull = svc.IsTrusted("server4.local:8443", null!);
|
||||
var resultEmpty = svc.IsTrusted("server4.local:8443", "");
|
||||
|
||||
Assert.False(resultNull, "Null fingerprint must be rejected.");
|
||||
Assert.False(resultEmpty, "Empty fingerprint must be rejected.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_NullOrEmptyHost_ReturnsFalse()
|
||||
{
|
||||
var svc = NewSvc();
|
||||
|
||||
Assert.False(svc.IsTrusted(null!, "FINGERPRINT"), "Null host must return false.");
|
||||
Assert.False(svc.IsTrusted("", "FINGERPRINT"), "Empty host must return false.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_DifferentHostsSameFingerprint_TrackedIndependently()
|
||||
{
|
||||
// Two different hosts can have the same fingerprint — each is independent
|
||||
var svc = NewSvc();
|
||||
svc.IsTrusted("host-a:8443", "SHARED_FINGERPRINT");
|
||||
svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT");
|
||||
|
||||
// Changing one host's cert must not affect the other
|
||||
Assert.True(svc.IsTrusted("host-a:8443", "SHARED_FINGERPRINT"));
|
||||
Assert.True(svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT"));
|
||||
Assert.False(svc.IsTrusted("host-a:8443", "NEW_FINGERPRINT"));
|
||||
Assert.True(svc.IsTrusted("host-b:8443", "SHARED_FINGERPRINT"), "host-b trust must be unaffected.");
|
||||
}
|
||||
|
||||
// ── TrustFingerprint ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TrustFingerprint_StoresFingerprint_CanBeRetrieved()
|
||||
{
|
||||
var svc = NewSvc();
|
||||
svc.TrustFingerprint("server5.local:8443", "STORED_FP");
|
||||
|
||||
Assert.Equal("STORED_FP", svc.GetTrustedFingerprint("server5.local:8443"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustFingerprint_OverwritesExisting()
|
||||
{
|
||||
// Explicitly overwriting — e.g. user manually updated cert trust
|
||||
var svc = NewSvc();
|
||||
svc.TrustFingerprint("server6.local:8443", "OLD_FP");
|
||||
svc.TrustFingerprint("server6.local:8443", "NEW_FP");
|
||||
|
||||
Assert.Equal("NEW_FP", svc.GetTrustedFingerprint("server6.local:8443"));
|
||||
}
|
||||
|
||||
// ── RemoveTrust ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RemoveTrust_RemovesStoredFingerprint()
|
||||
{
|
||||
var svc = NewSvc();
|
||||
svc.TrustFingerprint("server7.local:8443", "FP");
|
||||
svc.RemoveTrust("server7.local:8443");
|
||||
|
||||
Assert.Null(svc.GetTrustedFingerprint("server7.local:8443"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveTrust_AfterRemoval_NextConnectionAutoTrustsAgain()
|
||||
{
|
||||
// After trust is cleared, the next connection acts as first-use again
|
||||
var svc = NewSvc();
|
||||
svc.TrustFingerprint("server8.local:8443", "OLD_FP");
|
||||
svc.RemoveTrust("server8.local:8443");
|
||||
|
||||
var result = svc.IsTrusted("server8.local:8443", "NEW_FP");
|
||||
|
||||
Assert.True(result, "After removing trust, the next fingerprint should be auto-trusted.");
|
||||
Assert.Equal("NEW_FP", svc.GetTrustedFingerprint("server8.local:8443"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveTrust_NonExistentHost_DoesNotThrow()
|
||||
{
|
||||
var svc = NewSvc();
|
||||
var ex = Record.Exception(() => svc.RemoveTrust("never-seen.local:8443"));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ── GetTrustedFingerprint ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetTrustedFingerprint_UnknownHost_ReturnsNull()
|
||||
{
|
||||
var svc = NewSvc();
|
||||
Assert.Null(svc.GetTrustedFingerprint("unknown.local:8443"));
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Persistence_FingerprintSurvivesNewInstanceCreation()
|
||||
{
|
||||
// Instance 1: store a fingerprint
|
||||
NewSvc().TrustFingerprint("persist-host:8443", "PERSISTED_FP");
|
||||
|
||||
// Instance 2: different object, same directory — must read the stored fingerprint
|
||||
var fp = NewSvc().GetTrustedFingerprint("persist-host:8443");
|
||||
Assert.Equal("PERSISTED_FP", fp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Persistence_IsTrustedUsesPersistedData()
|
||||
{
|
||||
// First process: trust on first use
|
||||
NewSvc().IsTrusted("persist2-host:8443", "FIRST_FP");
|
||||
|
||||
// Second process: different instance must reject a changed fingerprint
|
||||
var result = NewSvc().IsTrusted("persist2-host:8443", "CHANGED_FP");
|
||||
Assert.False(result, "Persisted fingerprint must be enforced across instances.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Persistence_RemoveTrustSurvivesNewInstance()
|
||||
{
|
||||
var svc1 = NewSvc();
|
||||
svc1.TrustFingerprint("persist3-host:8443", "FP");
|
||||
svc1.RemoveTrust("persist3-host:8443");
|
||||
|
||||
// New instance: trust should be gone
|
||||
Assert.Null(NewSvc().GetTrustedFingerprint("persist3-host:8443"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Persistence_CreatesDirectoryIfMissing()
|
||||
{
|
||||
Assert.False(Directory.Exists(_tempDir));
|
||||
NewSvc().TrustFingerprint("server-dir-test:8443", "FP");
|
||||
Assert.True(Directory.Exists(_tempDir));
|
||||
}
|
||||
|
||||
// ── Fingerprint case-insensitivity ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsTrusted_FingerprintComparison_IsCaseInsensitive()
|
||||
{
|
||||
// SHA-256 hex strings may arrive in upper or lower case depending on the source
|
||||
var svc = NewSvc();
|
||||
svc.TrustFingerprint("case-host:8443", "aabbccddeeff");
|
||||
|
||||
Assert.True(svc.IsTrusted("case-host:8443", "AABBCCDDEEFF"),
|
||||
"Fingerprint comparison must be case-insensitive.");
|
||||
Assert.True(svc.IsTrusted("case-host:8443", "aAbBcCdDeEfF"),
|
||||
"Mixed-case fingerprint must also match.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -340,7 +340,7 @@ public class ChatServiceTests
|
||||
|
||||
Assert.Single(_ws.SentMessages);
|
||||
var sent = JsonDocument.Parse(_ws.SentMessages[0]);
|
||||
Assert.Equal("typing", sent.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal("typing_start", sent.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal(1, sent.RootElement.GetProperty("payload").GetProperty("channel_id").GetInt64());
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ public partial class App : Application
|
||||
|
||||
var profileService = new ProfileService(dataDir);
|
||||
var credentialService = new CredentialService();
|
||||
var wsService = new WebSocketService();
|
||||
var apiClient = ApiClient.CreateWithSelfSignedTls();
|
||||
var trustService = new CertificateTrustService();
|
||||
var wsService = new WebSocketService(trustService);
|
||||
var apiClient = ApiClient.CreateWithTofuTls(trustService);
|
||||
var chatService = new ChatService(apiClient, wsService);
|
||||
|
||||
var connectVm = new ConnectViewModel(profileService, credentialService);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
|
||||
[assembly: InternalsVisibleTo("OwnCord.Client.Tests")]
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OwnCord.Client.Models;
|
||||
@@ -26,13 +27,28 @@ public sealed class ApiClient : IApiClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ApiClient with a handler that accepts self-signed TLS certificates.
|
||||
/// Creates an ApiClient that uses Trust-On-First-Use (TOFU) certificate pinning.
|
||||
/// On first connection to a host, the server's self-signed certificate SHA-256 fingerprint
|
||||
/// is stored. Subsequent connections must present the same fingerprint.
|
||||
/// </summary>
|
||||
public static ApiClient CreateWithSelfSignedTls()
|
||||
public static ApiClient CreateWithTofuTls(ICertificateTrustService trustService)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
ServerCertificateCustomValidationCallback = (_, cert, _, _) =>
|
||||
{
|
||||
if (cert == null) return false;
|
||||
var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256);
|
||||
// Extract host:port from the request — the cert object has no request URL,
|
||||
// but the HttpClient passes the request URI via the first argument (HttpRequestMessage).
|
||||
// Unfortunately the standard callback signature does not expose it directly,
|
||||
// so we store it as an ambient value set before each request.
|
||||
// Fail closed: if the host context is missing, reject the connection.
|
||||
// Never fall back to cert.Subject — an attacker controls that value.
|
||||
var host = TofuHostContext.CurrentHost;
|
||||
if (host == null) return false;
|
||||
return trustService.IsTrusted(host, fingerprint);
|
||||
}
|
||||
};
|
||||
var http = new HttpClient(handler);
|
||||
http.DefaultRequestHeaders.Add("User-Agent", "OwnCord-Client/0.1.0");
|
||||
@@ -55,11 +71,19 @@ public sealed class ApiClient : IApiClient
|
||||
|
||||
public async Task LogoutAsync(string host, string token, CancellationToken ct = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, BuildUrl(host, "/api/v1/auth/logout"));
|
||||
request.Headers.Add("Authorization", $"Bearer {token}");
|
||||
var response = await _http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
await ThrowApiExceptionAsync(response, ct);
|
||||
TofuHostContext.CurrentHost = NormalizeHost(host);
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, BuildUrl(host, "/api/v1/auth/logout"));
|
||||
request.Headers.Add("Authorization", $"Bearer {token}");
|
||||
var response = await _http.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
await ThrowApiExceptionAsync(response, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TofuHostContext.CurrentHost = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ApiUser> GetMeAsync(string host, string token, CancellationToken ct = default)
|
||||
@@ -86,8 +110,16 @@ public sealed class ApiClient : IApiClient
|
||||
|
||||
public async Task<HealthResponse> HealthCheckAsync(string host, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.GetAsync(BuildUrl(host, "/health"), ct);
|
||||
return await ReadOrThrowAsync<HealthResponse>(response, ct);
|
||||
TofuHostContext.CurrentHost = NormalizeHost(host);
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync(BuildUrl(host, "/health"), ct);
|
||||
return await ReadOrThrowAsync<HealthResponse>(response, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TofuHostContext.CurrentHost = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -114,16 +146,32 @@ public sealed class ApiClient : IApiClient
|
||||
|
||||
private async Task<HttpResponseMessage> PostJsonAsync(string host, string path, object body, CancellationToken ct)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, JsonOpts);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
return await _http.PostAsync(BuildUrl(host, path), content, ct);
|
||||
TofuHostContext.CurrentHost = NormalizeHost(host);
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, JsonOpts);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
return await _http.PostAsync(BuildUrl(host, path), content, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TofuHostContext.CurrentHost = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> GetAuthenticatedAsync(string host, string path, string token, CancellationToken ct)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, BuildUrl(host, path));
|
||||
request.Headers.Add("Authorization", $"Bearer {token}");
|
||||
return await _http.SendAsync(request, ct);
|
||||
TofuHostContext.CurrentHost = NormalizeHost(host);
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, BuildUrl(host, path));
|
||||
request.Headers.Add("Authorization", $"Bearer {token}");
|
||||
return await _http.SendAsync(request, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TofuHostContext.CurrentHost = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<T> ReadOrThrowAsync<T>(HttpResponseMessage response, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
|
||||
namespace OwnCord.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Trust-On-First-Use (TOFU) certificate pinning service.
|
||||
/// Fingerprints are persisted as a JSON file in the application data directory so
|
||||
/// that trust decisions survive application restarts.
|
||||
///
|
||||
/// Storage format: a flat JSON object mapping host strings to SHA-256 hex fingerprints,
|
||||
/// e.g. { "server.local:8443": "AABBCC..." }
|
||||
/// </summary>
|
||||
public sealed class CertificateTrustService : ICertificateTrustService
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _filePath;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
// ── Constructors ──────────────────────────────────────────────────────────
|
||||
|
||||
public CertificateTrustService()
|
||||
: this(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OwnCord",
|
||||
"certs")) { }
|
||||
|
||||
/// <summary>Internal constructor allowing an isolated directory for unit tests.</summary>
|
||||
internal CertificateTrustService(string dir)
|
||||
{
|
||||
_dir = dir;
|
||||
_filePath = Path.Combine(_dir, "trusted_certs.json");
|
||||
}
|
||||
|
||||
// ── ICertificateTrustService ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsTrusted(string host, string fingerprint)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host)) return false;
|
||||
if (string.IsNullOrEmpty(fingerprint)) return false;
|
||||
|
||||
_lock.Wait();
|
||||
try
|
||||
{
|
||||
var store = Load();
|
||||
|
||||
if (!store.TryGetValue(host, out var stored))
|
||||
{
|
||||
// First use — auto-trust (TOFU)
|
||||
var updated = new Dictionary<string, string>(store, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[host] = fingerprint
|
||||
};
|
||||
Save(updated);
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(stored, fingerprint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void TrustFingerprint(string host, string fingerprint)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host))
|
||||
throw new ArgumentException("Host must not be null or empty.", nameof(host));
|
||||
if (string.IsNullOrEmpty(fingerprint))
|
||||
throw new ArgumentException("Fingerprint must not be null or empty.", nameof(fingerprint));
|
||||
|
||||
_lock.Wait();
|
||||
try
|
||||
{
|
||||
var store = Load();
|
||||
var updated = new Dictionary<string, string>(store, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[host] = fingerprint
|
||||
};
|
||||
Save(updated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RemoveTrust(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host)) return;
|
||||
|
||||
_lock.Wait();
|
||||
try
|
||||
{
|
||||
var store = Load();
|
||||
if (!store.ContainsKey(host)) return;
|
||||
|
||||
var updated = new Dictionary<string, string>(store, StringComparer.OrdinalIgnoreCase);
|
||||
updated.Remove(host);
|
||||
Save(updated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? GetTrustedFingerprint(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
|
||||
var store = Load();
|
||||
return store.TryGetValue(host, out var fp) ? fp : null;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads the trust store from disk. Returns an empty dictionary if the file does not exist.
|
||||
/// Throws if the file exists but is corrupt/unreadable — this prevents a silent TOFU
|
||||
/// downgrade where a corrupt store causes all hosts to be re-auto-trusted.
|
||||
/// </summary>
|
||||
private Dictionary<string, string> Load()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var json = File.ReadAllText(_filePath);
|
||||
var raw = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
||||
return raw is null
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(raw, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Writes the trust store to disk atomically via a temp-file swap.</summary>
|
||||
private void Save(Dictionary<string, string> store)
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
|
||||
var json = JsonSerializer.Serialize(store, JsonOpts);
|
||||
|
||||
// Write to a temp file first, then replace, to avoid corruption on crash
|
||||
var tmp = _filePath + ".tmp";
|
||||
File.WriteAllText(tmp, json);
|
||||
File.Move(tmp, _filePath, overwrite: true);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ public sealed class ChatService : IChatService
|
||||
public event Action<WsErrorPayload>? ErrorReceived;
|
||||
public event Action<ServerRestartPayload>? ServerRestarting;
|
||||
public event Action<WsMember>? MemberJoined;
|
||||
public event Action<ChannelEventPayload>? ChannelCreated;
|
||||
public event Action<ChannelEventPayload>? ChannelUpdated;
|
||||
public event Action<long>? ChannelDeleted;
|
||||
public event Action<string>? ConnectionLost;
|
||||
|
||||
public ChatService(IApiClient api, IWebSocketService ws)
|
||||
@@ -89,7 +92,23 @@ public sealed class ChatService : IChatService
|
||||
|
||||
var wsUri = $"wss://{ApiClient.NormalizeHost(host)}/api/v1/ws";
|
||||
await _ws.ConnectAsync(wsUri, token, ct);
|
||||
_ = _ws.RunReceiveLoopAsync(ct);
|
||||
_ = RunReceiveLoopWithErrorHandlingAsync(ct);
|
||||
}
|
||||
|
||||
private async Task RunReceiveLoopWithErrorHandlingAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _ws.RunReceiveLoopAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown — ignore
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConnectionLost?.Invoke($"Receive loop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public Task DisconnectWebSocketAsync()
|
||||
@@ -124,7 +143,7 @@ public sealed class ChatService : IChatService
|
||||
{
|
||||
var envelope = new
|
||||
{
|
||||
type = "typing",
|
||||
type = "typing_start",
|
||||
payload = new { channel_id = channelId }
|
||||
};
|
||||
return _ws.SendAsync(envelope, ct);
|
||||
@@ -187,6 +206,17 @@ public sealed class ChatService : IChatService
|
||||
case "member_join":
|
||||
MemberJoined?.Invoke(Deserialize<WsMember>(envelope));
|
||||
break;
|
||||
case "channel_create":
|
||||
ChannelCreated?.Invoke(Deserialize<ChannelEventPayload>(envelope));
|
||||
break;
|
||||
case "channel_update":
|
||||
ChannelUpdated?.Invoke(Deserialize<ChannelEventPayload>(envelope));
|
||||
break;
|
||||
case "channel_delete":
|
||||
var delPayload = envelope.Payload?.Deserialize<JsonElement>();
|
||||
if (delPayload?.TryGetProperty("id", out var idEl) == true)
|
||||
ChannelDeleted?.Invoke(idEl.GetInt64());
|
||||
break;
|
||||
// Unknown types silently ignored — forward compatibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace OwnCord.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Trust-On-First-Use (TOFU) certificate pinning service.
|
||||
/// On the first connection to a host, the certificate fingerprint is automatically
|
||||
/// trusted and stored. On subsequent connections, the stored fingerprint must match.
|
||||
/// </summary>
|
||||
public interface ICertificateTrustService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the given fingerprint is trusted for the host.
|
||||
/// On first use (no stored fingerprint), automatically trusts and stores the fingerprint.
|
||||
/// Returns false if a different fingerprint was previously stored for this host.
|
||||
/// </summary>
|
||||
bool IsTrusted(string host, string fingerprint);
|
||||
|
||||
/// <summary>Explicitly stores a fingerprint as trusted for the given host.</summary>
|
||||
void TrustFingerprint(string host, string fingerprint);
|
||||
|
||||
/// <summary>Removes any stored trust record for the given host.</summary>
|
||||
void RemoveTrust(string host);
|
||||
|
||||
/// <summary>Returns the stored fingerprint for the host, or null if none is stored.</summary>
|
||||
string? GetTrustedFingerprint(string host);
|
||||
}
|
||||
@@ -50,5 +50,8 @@ public interface IChatService
|
||||
event Action<WsErrorPayload>? ErrorReceived;
|
||||
event Action<ServerRestartPayload>? ServerRestarting;
|
||||
event Action<WsMember>? MemberJoined;
|
||||
event Action<ChannelEventPayload>? ChannelCreated;
|
||||
event Action<ChannelEventPayload>? ChannelUpdated;
|
||||
event Action<long>? ChannelDeleted;
|
||||
event Action<string>? ConnectionLost;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace OwnCord.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Async-aware ambient context that carries the current server host into the
|
||||
/// <see cref="System.Net.Http.HttpClientHandler.ServerCertificateCustomValidationCallback"/>.
|
||||
///
|
||||
/// Uses <see cref="AsyncLocal{T}"/> instead of [ThreadStatic] so the value flows
|
||||
/// correctly through async continuations and thread-pool threads used by HttpClient.
|
||||
/// </summary>
|
||||
internal static class TofuHostContext
|
||||
{
|
||||
private static readonly AsyncLocal<string?> _currentHost = new();
|
||||
|
||||
/// <summary>Gets or sets the host (host:port) for the in-progress request in this async flow.</summary>
|
||||
internal static string? CurrentHost
|
||||
{
|
||||
get => _currentHost.Value;
|
||||
set => _currentHost.Value = value;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Net.WebSockets;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -8,8 +9,14 @@ namespace OwnCord.Client.Services;
|
||||
|
||||
public sealed class WebSocketService : IWebSocketService, IDisposable
|
||||
{
|
||||
private readonly ICertificateTrustService _trustService;
|
||||
private ClientWebSocket? _ws;
|
||||
|
||||
public WebSocketService(ICertificateTrustService trustService)
|
||||
{
|
||||
_trustService = trustService;
|
||||
}
|
||||
|
||||
public bool IsConnected => _ws?.State == WebSocketState.Open;
|
||||
public WebSocketState State => _ws?.State ?? WebSocketState.None;
|
||||
|
||||
@@ -21,8 +28,17 @@ public sealed class WebSocketService : IWebSocketService, IDisposable
|
||||
_ws?.Dispose();
|
||||
_ws = new ClientWebSocket();
|
||||
|
||||
// Accept self-signed TLS certificates (server generates self-signed by default).
|
||||
_ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
var host = ExtractHost(uri);
|
||||
|
||||
// Trust-On-First-Use (TOFU) certificate pinning.
|
||||
// On first connection to a host, the certificate SHA-256 fingerprint is stored.
|
||||
// On subsequent connections, the fingerprint must match the stored value.
|
||||
_ws.Options.RemoteCertificateValidationCallback = (_, cert, _, _) =>
|
||||
{
|
||||
if (cert == null) return false;
|
||||
var fingerprint = cert.GetCertHashString(HashAlgorithmName.SHA256);
|
||||
return _trustService.IsTrusted(host, fingerprint);
|
||||
};
|
||||
|
||||
await _ws.ConnectAsync(new Uri(uri), ct);
|
||||
var auth = JsonSerializer.Serialize(new { type = "auth", payload = new { token } });
|
||||
@@ -95,6 +111,23 @@ public sealed class WebSocketService : IWebSocketService, IDisposable
|
||||
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts "host:port" from a WebSocket URI for use as the trust store key.
|
||||
/// e.g. "wss://server.local:8443/ws" → "server.local:8443"
|
||||
/// </summary>
|
||||
private static string ExtractHost(string uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
var u = new Uri(uri);
|
||||
return u.IsDefaultPort ? u.Host : $"{u.Host}:{u.Port}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendRawAsync(string text, CancellationToken ct)
|
||||
{
|
||||
if (_ws is null) return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using OwnCord.Client.Models;
|
||||
@@ -13,6 +14,7 @@ public sealed class MainViewModel : ViewModelBase
|
||||
private string _messageInput = string.Empty;
|
||||
private bool _isTyping;
|
||||
private string? _connectionStatus;
|
||||
private Timer? _typingTimer;
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
@@ -34,6 +36,9 @@ public sealed class MainViewModel : ViewModelBase
|
||||
chat.ChatEdited += p => RunOnUI(() => OnChatEdited(p));
|
||||
chat.ChatDeleted += p => RunOnUI(() => OnChatDeleted(p));
|
||||
chat.MemberJoined += p => RunOnUI(() => OnMemberJoined(p));
|
||||
chat.ChannelCreated += p => RunOnUI(() => OnChannelCreated(p));
|
||||
chat.ChannelUpdated += p => RunOnUI(() => OnChannelUpdated(p));
|
||||
chat.ChannelDeleted += id => RunOnUI(() => OnChannelDeleted(id));
|
||||
chat.ConnectionLost += r => RunOnUI(() => OnConnectionLost(r));
|
||||
}
|
||||
|
||||
@@ -226,7 +231,7 @@ public sealed class MainViewModel : ViewModelBase
|
||||
);
|
||||
Messages.Add(msg);
|
||||
}
|
||||
else if (payload.ChannelId != SelectedChannel?.Id)
|
||||
else
|
||||
{
|
||||
// Increment unread for non-active channel
|
||||
UpdateUnreadCount(payload.ChannelId, GetUnreadCount(payload.ChannelId) + 1);
|
||||
@@ -236,7 +241,11 @@ public sealed class MainViewModel : ViewModelBase
|
||||
private void OnTyping(TypingPayload payload)
|
||||
{
|
||||
if (SelectedChannel is not null && payload.ChannelId == SelectedChannel.Id)
|
||||
{
|
||||
ShowTyping(payload.Username);
|
||||
_typingTimer?.Dispose();
|
||||
_typingTimer = new Timer(_ => RunOnUI(HideTyping), null, 5000, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPresence(PresencePayload payload)
|
||||
@@ -284,6 +293,42 @@ public sealed class MainViewModel : ViewModelBase
|
||||
Members.Add(new User(payload.Id, payload.Username, payload.Avatar, payload.RoleId, status));
|
||||
}
|
||||
|
||||
private void OnChannelCreated(ChannelEventPayload payload)
|
||||
{
|
||||
if (Channels.Any(c => c.Id == payload.Id)) return;
|
||||
var type = payload.Type switch
|
||||
{
|
||||
"voice" => ChannelType.Voice,
|
||||
"announcement" => ChannelType.Announcement,
|
||||
_ => ChannelType.Text
|
||||
};
|
||||
Channels.Add(new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, 0, null));
|
||||
}
|
||||
|
||||
private void OnChannelUpdated(ChannelEventPayload payload)
|
||||
{
|
||||
var idx = Channels.ToList().FindIndex(c => c.Id == payload.Id);
|
||||
if (idx < 0) return;
|
||||
var type = payload.Type switch
|
||||
{
|
||||
"voice" => ChannelType.Voice,
|
||||
"announcement" => ChannelType.Announcement,
|
||||
_ => ChannelType.Text
|
||||
};
|
||||
Channels[idx] = new Channel(payload.Id, payload.Name, type, payload.Category, payload.Position, Channels[idx].UnreadCount, Channels[idx].LastMessageId);
|
||||
}
|
||||
|
||||
private void OnChannelDeleted(long channelId)
|
||||
{
|
||||
var ch = Channels.FirstOrDefault(c => c.Id == channelId);
|
||||
if (ch is not null)
|
||||
{
|
||||
Channels.Remove(ch);
|
||||
if (SelectedChannel?.Id == channelId)
|
||||
SelectedChannel = Channels.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionLost(string reason)
|
||||
{
|
||||
ConnectionStatus = "Disconnected — reconnecting...";
|
||||
|
||||
@@ -47,6 +47,10 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.
|
||||
}
|
||||
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// The admin SPA uses inline <style> and <script> tags. Override the
|
||||
// global CSP (default-src 'self') to allow them.
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
w.Write(indexHTML)
|
||||
})
|
||||
r.Handle("/*", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
+162
-78
@@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -14,21 +15,46 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/updater"
|
||||
)
|
||||
|
||||
// ─── Context keys ─────────────────────────────────────────────────────────────
|
||||
|
||||
// adminContextKey is an unexported type for context keys in the admin package.
|
||||
type adminContextKey int
|
||||
|
||||
const (
|
||||
// adminUserKey is the context key for the authenticated *db.User.
|
||||
adminUserKey adminContextKey = iota
|
||||
// adminSessionKey is the context key for the authenticated *db.Session.
|
||||
adminSessionKey
|
||||
)
|
||||
|
||||
// ─── Allowed settings keys ────────────────────────────────────────────────────
|
||||
|
||||
// allowedSettingKeys is the whitelist of keys that may be written via
|
||||
// PATCH /admin/api/settings. Derived from the settings table in SCHEMA.md.
|
||||
var allowedSettingKeys = map[string]struct{}{
|
||||
"server_name": {},
|
||||
"server_icon": {},
|
||||
"motd": {},
|
||||
"max_upload_bytes": {},
|
||||
"voice_quality": {},
|
||||
"require_2fa": {},
|
||||
"registration_open": {},
|
||||
"backup_schedule": {},
|
||||
"backup_retention": {},
|
||||
}
|
||||
|
||||
// HubBroadcaster is the subset of ws.Hub needed by the admin package.
|
||||
type HubBroadcaster interface {
|
||||
BroadcastServerRestart(reason string, delaySeconds int)
|
||||
BroadcastChannelCreate(ch *db.Channel)
|
||||
BroadcastChannelUpdate(ch *db.Channel)
|
||||
BroadcastChannelDelete(channelID int64)
|
||||
}
|
||||
|
||||
// ─── Permission constants ─────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
permAdministrator = int64(0x40000000)
|
||||
ownerRolePosition = 100
|
||||
)
|
||||
|
||||
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
|
||||
|
||||
// NewAdminAPI returns a chi router with all /admin/api/* routes. All routes
|
||||
@@ -50,9 +76,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
|
||||
r.Patch("/users/{id}", handlePatchUser(database))
|
||||
r.Delete("/users/{id}/sessions", handleForceLogout(database))
|
||||
r.Get("/channels", handleListChannels(database))
|
||||
r.Post("/channels", handleCreateChannel(database))
|
||||
r.Patch("/channels/{id}", handlePatchChannel(database))
|
||||
r.Delete("/channels/{id}", handleDeleteChannel(database))
|
||||
r.Post("/channels", handleCreateChannel(database, hub))
|
||||
r.Patch("/channels/{id}", handlePatchChannel(database, hub))
|
||||
r.Delete("/channels/{id}", handleDeleteChannel(database, hub))
|
||||
r.Get("/audit-log", handleGetAuditLog(database))
|
||||
r.Get("/settings", handleGetSettings(database))
|
||||
r.Patch("/settings", handlePatchSettings(database))
|
||||
@@ -71,10 +97,12 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
|
||||
// ─── Middleware ───────────────────────────────────────────────────────────────
|
||||
|
||||
// adminAuthMiddleware validates the Bearer token and requires ADMINISTRATOR.
|
||||
// On success it stores the *db.User and *db.Session in the request context so
|
||||
// downstream handlers can retrieve them without re-querying the database.
|
||||
func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := extractBearer(r)
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid authorization header")
|
||||
return
|
||||
@@ -87,7 +115,7 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if isExpired(sess.ExpiresAt) {
|
||||
if auth.IsSessionExpired(sess.ExpiresAt) {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "session has expired")
|
||||
return
|
||||
}
|
||||
@@ -104,35 +132,26 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if role.Permissions&permAdministrator == 0 {
|
||||
if !permissions.HasAdmin(role.Permissions) {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "administrator permission required")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
ctx := context.WithValue(r.Context(), adminUserKey, user)
|
||||
ctx = context.WithValue(ctx, adminSessionKey, sess)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ownerOnlyMiddleware wraps a handler to require Owner role (position == 100).
|
||||
// It reads the user from context (set by adminAuthMiddleware) rather than
|
||||
// re-authenticating, avoiding redundant DB queries and session-expiry gaps.
|
||||
func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := extractBearer(r)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
hash := auth.HashToken(token)
|
||||
sess, err := database.GetSessionByTokenHash(hash)
|
||||
if err != nil || sess == nil {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid session")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := database.GetUserByID(sess.UserID)
|
||||
if err != nil || user == nil {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
|
||||
user, ok := r.Context().Value(adminUserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -142,7 +161,7 @@ func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if role.Position < ownerRolePosition {
|
||||
if role.Position < permissions.OwnerRolePosition {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
|
||||
return
|
||||
}
|
||||
@@ -174,7 +193,62 @@ func handleListUsers(database *db.DB) http.HandlerFunc {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
|
||||
safe := make([]adminUserResponse, len(users))
|
||||
for i, u := range users {
|
||||
safe[i] = toAdminUserResponse(u)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, safe)
|
||||
}
|
||||
}
|
||||
|
||||
// adminUserResponse is the safe public shape returned by user-listing and
|
||||
// user-patch endpoints. It deliberately excludes PasswordHash and TOTPSecret.
|
||||
type adminUserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
RoleID int64 `json:"role_id"`
|
||||
RoleName string `json:"role_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeen *string `json:"last_seen,omitempty"`
|
||||
Banned bool `json:"banned"`
|
||||
BanReason *string `json:"ban_reason,omitempty"`
|
||||
BanExpires *string `json:"ban_expires,omitempty"`
|
||||
}
|
||||
|
||||
// toAdminUserResponse converts a db.UserWithRole to the safe response shape.
|
||||
func toAdminUserResponse(u db.UserWithRole) adminUserResponse {
|
||||
return adminUserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: u.Avatar,
|
||||
RoleID: u.RoleID,
|
||||
RoleName: u.RoleName,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastSeen: u.LastSeen,
|
||||
Banned: u.Banned,
|
||||
BanReason: u.BanReason,
|
||||
BanExpires: u.BanExpires,
|
||||
}
|
||||
}
|
||||
|
||||
// toAdminUserResponseFromUser converts a plain db.User to the safe response
|
||||
// shape, leaving RoleName empty (it is unknown without a join).
|
||||
func toAdminUserResponseFromUser(u *db.User) adminUserResponse {
|
||||
return adminUserResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: u.Avatar,
|
||||
RoleID: u.RoleID,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
LastSeen: u.LastSeen,
|
||||
Banned: u.Banned,
|
||||
BanReason: u.BanReason,
|
||||
BanExpires: u.BanExpires,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +283,14 @@ func handlePatchUser(database *db.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorID(database, r)
|
||||
actor := actorFromContext(r)
|
||||
|
||||
// Prevent admins from modifying their own role or ban status, which
|
||||
// could lock them out of the admin panel with no recovery path.
|
||||
if id == actor {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "cannot modify your own account via admin panel")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RoleID != nil {
|
||||
if err := database.UpdateUserRole(id, *req.RoleID); err != nil {
|
||||
@@ -250,7 +331,7 @@ func handlePatchUser(database *db.DB) http.HandlerFunc {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
writeJSON(w, http.StatusOK, toAdminUserResponseFromUser(updated))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +347,7 @@ func handleForceLogout(database *db.DB) http.HandlerFunc {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user")
|
||||
return
|
||||
}
|
||||
actor := actorID(database, r)
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("force logout", "actor_id", actor, "target_user_id", id)
|
||||
_ = database.LogAudit(actor, "force_logout", "user", id, "all sessions terminated")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -293,7 +374,7 @@ type createChannelRequest struct {
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func handleCreateChannel(database *db.DB) http.HandlerFunc {
|
||||
func handleCreateChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req createChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -320,10 +401,13 @@ func handleCreateChannel(database *db.DB) http.HandlerFunc {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
|
||||
return
|
||||
}
|
||||
actor := actorID(database, r)
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("channel created", "actor_id", actor, "channel", req.Name, "type", req.Type)
|
||||
_ = database.LogAudit(actor, "channel_create", "channel", id,
|
||||
fmt.Sprintf("created #%s (%s)", req.Name, req.Type))
|
||||
if hub != nil {
|
||||
hub.BroadcastChannelCreate(ch)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, ch)
|
||||
}
|
||||
}
|
||||
@@ -337,7 +421,7 @@ type updateChannelRequest struct {
|
||||
Archived bool `json:"archived"`
|
||||
}
|
||||
|
||||
func handlePatchChannel(database *db.DB) http.HandlerFunc {
|
||||
func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathInt64(r, "id")
|
||||
if err != nil {
|
||||
@@ -373,17 +457,24 @@ func handlePatchChannel(database *db.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorID(database, r)
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name)
|
||||
_ = database.LogAudit(actor, "channel_update", "channel", id,
|
||||
fmt.Sprintf("updated #%s", req.Name))
|
||||
|
||||
updated, _ := database.GetChannel(id)
|
||||
updated, err := database.GetChannel(id)
|
||||
if err != nil || updated == nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel")
|
||||
return
|
||||
}
|
||||
if hub != nil {
|
||||
hub.BroadcastChannelUpdate(updated)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteChannel(database *db.DB) http.HandlerFunc {
|
||||
func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathInt64(r, "id")
|
||||
if err != nil {
|
||||
@@ -405,10 +496,13 @@ func handleDeleteChannel(database *db.DB) http.HandlerFunc {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
|
||||
return
|
||||
}
|
||||
actor := actorID(database, r)
|
||||
actor := actorFromContext(r)
|
||||
slog.Warn("channel deleted", "actor_id", actor, "channel_id", id, "name", existing.Name)
|
||||
_ = database.LogAudit(actor, "channel_delete", "channel", id,
|
||||
fmt.Sprintf("deleted #%s", existing.Name))
|
||||
if hub != nil {
|
||||
hub.BroadcastChannelDelete(id)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -446,7 +540,17 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorID(database, r)
|
||||
// Validate all keys against the whitelist before writing anything so
|
||||
// the operation is atomic from the caller's perspective.
|
||||
for key := range updates {
|
||||
if _, ok := allowedSettingKeys[key]; !ok {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
|
||||
fmt.Sprintf("unknown setting key: %q", key))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
actor := actorFromContext(r)
|
||||
for key, value := range updates {
|
||||
if err := database.SetSetting(key, value); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
|
||||
@@ -482,7 +586,7 @@ func handleBackup(database *db.DB) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorID(database, r)
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("database backup created", "actor_id", actor, "path", backupPath)
|
||||
_ = database.LogAudit(actor, "backup_create", "server", 0,
|
||||
fmt.Sprintf("backup saved to %s", backupPath))
|
||||
@@ -501,7 +605,7 @@ type errorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
@@ -511,18 +615,6 @@ func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, errorResponse{Error: code, Message: msg})
|
||||
}
|
||||
|
||||
func extractBearer(r *http.Request) (string, bool) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
func pathInt64(r *http.Request, param string) (int64, error) {
|
||||
raw := chi.URLParam(r, param)
|
||||
return strconv.ParseInt(raw, 10, 64)
|
||||
@@ -534,32 +626,24 @@ func queryInt(r *http.Request, key string, defaultVal int) int {
|
||||
return defaultVal
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
if err != nil || n < 1 {
|
||||
return defaultVal
|
||||
}
|
||||
// Cap to prevent unbounded result sets exhausting memory.
|
||||
const maxLimit = 500
|
||||
if n > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// actorID extracts the authenticated user's ID from the request token.
|
||||
// Returns 0 if the actor cannot be determined (should not happen behind auth middleware).
|
||||
func actorID(database *db.DB, r *http.Request) int64 {
|
||||
token, ok := extractBearer(r)
|
||||
if !ok {
|
||||
// actorFromContext returns the authenticated user's ID stored in the request
|
||||
// context by adminAuthMiddleware. Returns 0 if called outside that middleware
|
||||
// (should not happen in production).
|
||||
func actorFromContext(r *http.Request) int64 {
|
||||
user, ok := r.Context().Value(adminUserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
return 0
|
||||
}
|
||||
sess, err := database.GetSessionByTokenHash(auth.HashToken(token))
|
||||
if err != nil || sess == nil {
|
||||
return 0
|
||||
}
|
||||
return sess.UserID
|
||||
}
|
||||
|
||||
func isExpired(expiresAt string) bool {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
t, err := time.Parse(layout, expiresAt)
|
||||
if err == nil {
|
||||
return time.Now().UTC().After(t.UTC())
|
||||
}
|
||||
}
|
||||
return true
|
||||
return user.ID
|
||||
}
|
||||
|
||||
@@ -661,9 +661,457 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task 0.3: actor stored in context ───────────────────────────────────────
|
||||
|
||||
// TestAdminAPI_ActorFromContext verifies that after auth middleware runs, the
|
||||
// user ID surfaced by audit log entries comes from the context-stored user (not
|
||||
// a redundant DB lookup). We exercise this through the PATCH /users/{id} path
|
||||
// which logs an audit entry containing the actor_id.
|
||||
func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a target user to act on.
|
||||
targetUID, _ := database.CreateUser("ctxtarget", "hash", 3)
|
||||
|
||||
body := map[string]interface{}{"banned": true, "ban_reason": "context test"}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The audit log should have a non-zero actor_id showing the actor was
|
||||
// resolved (not 0, which would indicate a failed context lookup).
|
||||
entries, err := database.GetAuditLog(10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuditLog: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("expected at least 1 audit entry")
|
||||
}
|
||||
// All entries should have a non-zero actor_id.
|
||||
for _, e := range entries {
|
||||
if e.ActorID == 0 {
|
||||
t.Errorf("audit entry actor_id = 0, expected the admin user's ID (actor stored from context)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_ActorFromContext_ForceLogout exercises actorFromContext via the
|
||||
// DELETE /users/{id}/sessions path.
|
||||
func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser("logoutctx", "hash", 3)
|
||||
database.CreateSession(targetUID, "victim-hash-ctx", "web", "1.2.3.4")
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
entries, err := database.GetAuditLog(10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuditLog: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("expected at least 1 audit entry")
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.ActorID == 0 {
|
||||
t.Errorf("audit entry actor_id = 0, expected non-zero actor from context")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task 0.6: Settings key whitelist ────────────────────────────────────────
|
||||
|
||||
// TestAdminAPI_PatchSettings_RejectsUnknownKey verifies that an unknown key
|
||||
// returns 400 without writing anything to the database.
|
||||
func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
"unknown_key": "should be rejected",
|
||||
}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/settings", token, body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error response: %v", err)
|
||||
}
|
||||
// The error message must name the offending key so the caller knows what to fix.
|
||||
if msg, ok := resp["message"]; !ok || msg == "" {
|
||||
t.Error("response should include a non-empty 'message' field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_PatchSettings_RejectsMixedKeys verifies that a payload
|
||||
// containing both valid and invalid keys is rejected entirely (no partial write).
|
||||
func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
"server_name": "valid",
|
||||
"injected_key": "should block the whole request",
|
||||
}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/settings", token, body)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The valid key must NOT have been written because the request was rejected.
|
||||
val, err := database.GetSetting("server_name")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting: %v", err)
|
||||
}
|
||||
if val == "valid" {
|
||||
t.Error("server_name was updated despite invalid key in payload — partial write occurred")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys iterates over every key
|
||||
// in the whitelist and confirms each one is individually accepted.
|
||||
func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) {
|
||||
whitelistedKeys := []string{
|
||||
"server_name",
|
||||
"server_icon",
|
||||
"motd",
|
||||
"max_upload_bytes",
|
||||
"voice_quality",
|
||||
"require_2fa",
|
||||
"registration_open",
|
||||
"backup_schedule",
|
||||
"backup_retention",
|
||||
}
|
||||
|
||||
for _, key := range whitelistedKeys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{key: "testvalue"}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/settings", token, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("key %q: status = %d, want 200; body: %s", key, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_PatchSettings_EmptyPayloadIsOK verifies that an empty map
|
||||
// (no-op update) is accepted and returns the current settings.
|
||||
func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/settings", token, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fix 2.1: Sensitive field redaction ──────────────────────────────────────
|
||||
|
||||
// TestAdminAPI_ListUsers_NoPasswordHash verifies that GET /users does not
|
||||
// expose the PasswordHash field in any returned user object.
|
||||
func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a second user so the list is non-trivial.
|
||||
database.CreateUser("plainuser", "supersecretbcrypthash", 3)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
// The raw bcrypt hash must never appear in the response.
|
||||
if contains(body, "supersecretbcrypthash") {
|
||||
t.Error("GET /users response contains PasswordHash — sensitive field leaked")
|
||||
}
|
||||
// The JSON key itself must also be absent.
|
||||
if contains(body, "password_hash") || contains(body, "PasswordHash") {
|
||||
t.Error("GET /users response contains password_hash key — sensitive field leaked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_ListUsers_NoTOTPSecret verifies that GET /users does not
|
||||
// expose the TOTPSecret field.
|
||||
func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if contains(body, "totp_secret") || contains(body, "TOTPSecret") {
|
||||
t.Error("GET /users response contains totp_secret key — sensitive field leaked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_ListUsers_PublicFieldsPresent verifies that safe public fields
|
||||
// are still present after the sensitive-field removal.
|
||||
func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
t.Fatal("expected at least one user")
|
||||
}
|
||||
|
||||
u := users[0]
|
||||
for _, field := range []string{"id", "username", "status", "role_id", "created_at", "banned", "role_name"} {
|
||||
if _, ok := u[field]; !ok {
|
||||
t.Errorf("GET /users response user object missing expected field %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_PatchUser_NoPasswordHash verifies that PATCH /users/{id} does
|
||||
// not expose PasswordHash in the returned user object.
|
||||
func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"banned": true,
|
||||
"ban_reason": "test",
|
||||
}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
respBody := w.Body.String()
|
||||
if contains(respBody, "topsecretbcrypt") {
|
||||
t.Error("PATCH /users/{id} response contains PasswordHash — sensitive field leaked")
|
||||
}
|
||||
if contains(respBody, "password_hash") || contains(respBody, "PasswordHash") {
|
||||
t.Error("PATCH /users/{id} response contains password_hash key — sensitive field leaked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminAPI_PatchUser_NoTOTPSecret verifies that PATCH /users/{id} does
|
||||
// not expose TOTPSecret in the returned user object.
|
||||
func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser("patchtotp", "hash", 3)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, map[string]interface{}{
|
||||
"banned": false,
|
||||
})
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
respBody := w.Body.String()
|
||||
if contains(respBody, "totp_secret") || contains(respBody, "TOTPSecret") {
|
||||
t.Error("PATCH /users/{id} response contains totp_secret — sensitive field leaked")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4.1: Channel CRUD broadcast tests ───────────────────────────────────────
|
||||
|
||||
// mockHub records which broadcast methods were called and with what arguments.
|
||||
type mockHub struct {
|
||||
restartCalls []restartCall
|
||||
channelCreates []*db.Channel
|
||||
channelUpdates []*db.Channel
|
||||
channelDeleteIDs []int64
|
||||
}
|
||||
|
||||
type restartCall struct {
|
||||
reason string
|
||||
delaySeconds int
|
||||
}
|
||||
|
||||
func (m *mockHub) BroadcastServerRestart(reason string, delaySeconds int) {
|
||||
m.restartCalls = append(m.restartCalls, restartCall{reason, delaySeconds})
|
||||
}
|
||||
|
||||
func (m *mockHub) BroadcastChannelCreate(ch *db.Channel) {
|
||||
m.channelCreates = append(m.channelCreates, ch)
|
||||
}
|
||||
|
||||
func (m *mockHub) BroadcastChannelUpdate(ch *db.Channel) {
|
||||
m.channelUpdates = append(m.channelUpdates, ch)
|
||||
}
|
||||
|
||||
func (m *mockHub) BroadcastChannelDelete(channelID int64) {
|
||||
m.channelDeleteIDs = append(m.channelDeleteIDs, channelID)
|
||||
}
|
||||
|
||||
func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "broadcast-test",
|
||||
"type": "text",
|
||||
}
|
||||
w := doRequest(t, handler, http.MethodPost, "/channels", token, body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(hub.channelCreates) != 1 {
|
||||
t.Fatalf("BroadcastChannelCreate called %d times, want 1", len(hub.channelCreates))
|
||||
}
|
||||
if hub.channelCreates[0].Name != "broadcast-test" {
|
||||
t.Errorf("broadcast channel name = %q, want broadcast-test", hub.channelCreates[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
// nil hub: handler must not panic
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]interface{}{"name": "safe-channel", "type": "text"}
|
||||
w := doRequest(t, handler, http.MethodPost, "/channels", token, body)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d, want 201", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel("before", "text", "", "", 0)
|
||||
|
||||
body := map[string]interface{}{"name": "after"}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(hub.channelUpdates) != 1 {
|
||||
t.Fatalf("BroadcastChannelUpdate called %d times, want 1", len(hub.channelUpdates))
|
||||
}
|
||||
if hub.channelUpdates[0].Name != "after" {
|
||||
t.Errorf("broadcast channel name = %q, want after", hub.channelUpdates[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0)
|
||||
body := map[string]interface{}{"name": "patched"}
|
||||
w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel("delete-me", "text", "", "", 0)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(hub.channelDeleteIDs) != 1 {
|
||||
t.Fatalf("BroadcastChannelDelete called %d times, want 1", len(hub.channelDeleteIDs))
|
||||
}
|
||||
if hub.channelDeleteIDs[0] != chID {
|
||||
t.Errorf("broadcast channel id = %d, want %d", hub.channelDeleteIDs[0], chID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0)
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// itoa converts an int64 to a string for use in URL paths.
|
||||
func itoa(n int64) string {
|
||||
return fmt.Sprint(n)
|
||||
}
|
||||
|
||||
// contains reports whether s contains sub (plain substring search).
|
||||
func contains(s, sub string) bool {
|
||||
if len(sub) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// sanitizer strips all HTML from user-supplied strings before storage.
|
||||
@@ -105,13 +106,8 @@ func handleRegister(database *db.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate invite.
|
||||
inv, err := database.GetInvite(req.InviteCode)
|
||||
if err != nil || inv == nil || inv.Revoked {
|
||||
writeJSON(w, http.StatusBadRequest, genericAuthError)
|
||||
return
|
||||
}
|
||||
if err := database.UseInvite(req.InviteCode); err != nil {
|
||||
// Validate and consume invite atomically to prevent TOCTOU races.
|
||||
if err := database.UseInviteAtomic(req.InviteCode); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, genericAuthError)
|
||||
return
|
||||
}
|
||||
@@ -126,8 +122,8 @@ func handleRegister(database *db.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Create user with default Member role (4).
|
||||
uid, err := database.CreateUser(req.Username, hash, 4)
|
||||
// Create user with default Member role.
|
||||
uid, err := database.CreateUser(req.Username, hash, int(permissions.MemberRoleID))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "INVALID_INPUT",
|
||||
@@ -181,7 +177,8 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc {
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
// Do NOT trim req.Password — passwords may intentionally contain
|
||||
// leading/trailing whitespace. Bcrypt handles arbitrary bytes.
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
@@ -224,7 +221,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc {
|
||||
// Reset failure counter on success.
|
||||
limiter.Reset(failKey)
|
||||
|
||||
if user.Banned {
|
||||
if auth.IsEffectivelyBanned(user) {
|
||||
slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip)
|
||||
_ = database.LogAudit(user.ID, "login_blocked_banned", "user", user.ID,
|
||||
"banned user attempted login from "+ip)
|
||||
|
||||
@@ -388,6 +388,94 @@ func TestMe_NoAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fix 2.5: Password trim fix ───────────────────────────────────────────────
|
||||
|
||||
// TestLogin_PasswordWithLeadingSpaceIsPreserved verifies that a password with
|
||||
// leading whitespace is NOT trimmed, so a user who set " securePass1" can log
|
||||
// in with " securePass1" and NOT with "securePass1".
|
||||
func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
router := buildAuthRouter(database, limiter)
|
||||
|
||||
// Hash the password WITH the leading space — this is what was registered.
|
||||
hash, _ := auth.HashPassword(" securePass1")
|
||||
database.CreateUser("spacepassuser", hash, 4)
|
||||
|
||||
// Login with the exact same password (including space) must succeed.
|
||||
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
||||
"username": "spacepassuser",
|
||||
"password": " securePass1",
|
||||
})
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Login space-prefixed password status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_PasswordWithLeadingSpaceTrimmedFails verifies that logging in with
|
||||
// the trimmed version of a space-prefixed password correctly fails.
|
||||
func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
router := buildAuthRouter(database, limiter)
|
||||
|
||||
// Register with password that has a leading space.
|
||||
hash, _ := auth.HashPassword(" securePass1")
|
||||
database.CreateUser("spacepassuser2", hash, 4)
|
||||
|
||||
// Login without the leading space must fail.
|
||||
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
||||
"username": "spacepassuser2",
|
||||
"password": "securePass1",
|
||||
})
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Login trimmed space password status = %d, want 401; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_PasswordWithTrailingSpaceIsPreserved verifies that a password with
|
||||
// trailing whitespace is NOT trimmed.
|
||||
func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
router := buildAuthRouter(database, limiter)
|
||||
|
||||
hash, _ := auth.HashPassword("securePass1 ")
|
||||
database.CreateUser("trailingspaceuser", hash, 4)
|
||||
|
||||
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
||||
"username": "trailingspaceuser",
|
||||
"password": "securePass1 ",
|
||||
})
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Login trailing-space password status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_UsernameIsStillTrimmed verifies that the username IS still trimmed
|
||||
// (only the password trim was removed).
|
||||
func TestLogin_UsernameIsStillTrimmed(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
router := buildAuthRouter(database, limiter)
|
||||
|
||||
hash, _ := auth.HashPassword("correctPass1")
|
||||
database.CreateUser("trimuser", hash, 4)
|
||||
|
||||
// Username with surrounding spaces should resolve to "trimuser".
|
||||
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
||||
"username": " trimuser ",
|
||||
"password": "correctPass1",
|
||||
})
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Login space-padded username status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rate limiting integration test ──────────────────────────────────────────
|
||||
|
||||
func TestRegister_RateLimit(t *testing.T) {
|
||||
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Permission bits (from SCHEMA.md).
|
||||
permReadMessages = int64(0x0002) // bit 1
|
||||
permAdministrator = int64(0x40000000) // bit 30
|
||||
|
||||
defaultMessageLimit = 50
|
||||
maxMessageLimit = 100
|
||||
)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package api
|
||||
|
||||
// White-box tests for clientIP and isTrustedProxy.
|
||||
// These live in package api (not api_test) so they can reach unexported symbols.
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── isTrustedProxy ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestIsTrustedProxy_EmptyList_ReturnsFalse(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("10.0.0.1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if trusted {
|
||||
t.Error("isTrustedProxy(empty list) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_ExactIPMatch(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("10.0.0.1", []string{"10.0.0.1/32"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !trusted {
|
||||
t.Error("isTrustedProxy exact match = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_CIDRMatch(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("192.168.1.50", []string{"192.168.1.0/24"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !trusted {
|
||||
t.Error("isTrustedProxy CIDR match = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_CIDRNoMatch(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("10.9.9.9", []string{"192.168.1.0/24"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if trusted {
|
||||
t.Error("isTrustedProxy CIDR non-match = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_MultipleCIDRs_FirstMatches(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("10.0.0.5", []string{"172.16.0.0/12", "10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !trusted {
|
||||
t.Error("isTrustedProxy multi-CIDR first match = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_MultipleCIDRs_NoneMatch(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("8.8.8.8", []string{"10.0.0.0/8", "192.168.0.0/16"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if trusted {
|
||||
t.Error("isTrustedProxy multi-CIDR no match = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_InvalidCIDR_ReturnsError(t *testing.T) {
|
||||
_, err := isTrustedProxy("10.0.0.1", []string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Error("isTrustedProxy invalid CIDR should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_BarePlainIP_TreatedAsCIDR32(t *testing.T) {
|
||||
// Bare IP without mask — should not panic; behaviour is to return error or
|
||||
// treat as /32 depending on implementation. We just verify it doesn't panic.
|
||||
_, _ = isTrustedProxy("10.0.0.1", []string{"10.0.0.1"})
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy_IPv6Match(t *testing.T) {
|
||||
trusted, err := isTrustedProxy("::1", []string{"::1/128"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !trusted {
|
||||
t.Error("isTrustedProxy IPv6 exact match = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── clientIP with trusted proxies ───────────────────────────────────────────
|
||||
|
||||
func TestClientIP_NoTrustedProxies_UsesRemoteAddr(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "203.0.113.5:4321"
|
||||
req.Header.Set("X-Real-IP", "1.2.3.4")
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
|
||||
ip := clientIPWithProxies(req, nil)
|
||||
if ip != "203.0.113.5" {
|
||||
t.Errorf("clientIP no trusted proxies = %q, want %q", ip, "203.0.113.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP_TrustedProxy_UsesXRealIP(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:9999"
|
||||
req.Header.Set("X-Real-IP", "203.0.113.42")
|
||||
|
||||
ip := clientIPWithProxies(req, []string{"10.0.0.0/8"})
|
||||
if ip != "203.0.113.42" {
|
||||
t.Errorf("clientIP trusted proxy = %q, want %q", ip, "203.0.113.42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP_TrustedProxy_NoXRealIP_FallsBackToRemoteAddr(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:9999"
|
||||
// No X-Real-IP header set.
|
||||
|
||||
ip := clientIPWithProxies(req, []string{"10.0.0.0/8"})
|
||||
if ip != "10.0.0.1" {
|
||||
t.Errorf("clientIP trusted proxy no header = %q, want %q", ip, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP_UntrustedSource_IgnoresXRealIP(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "8.8.8.8:12345"
|
||||
req.Header.Set("X-Real-IP", "192.168.1.1") // attacker-supplied
|
||||
|
||||
ip := clientIPWithProxies(req, []string{"10.0.0.0/8"})
|
||||
// Must use RemoteAddr, not the forged X-Real-IP.
|
||||
if ip != "8.8.8.8" {
|
||||
t.Errorf("clientIP untrusted source = %q, want %q", ip, "8.8.8.8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:9999"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1")
|
||||
// No X-Real-IP; X-Forwarded-For first entry should be used.
|
||||
|
||||
ip := clientIPWithProxies(req, []string{"10.0.0.0/8"})
|
||||
if ip != "203.0.113.10" {
|
||||
t.Errorf("clientIP X-Forwarded-For = %q, want %q", ip, "203.0.113.10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP_RemoteAddrWithoutPort(t *testing.T) {
|
||||
// RemoteAddr sometimes has no port (e.g. Unix sockets in tests).
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1"
|
||||
|
||||
ip := clientIPWithProxies(req, nil)
|
||||
if ip != "10.0.0.1" {
|
||||
t.Errorf("clientIP no port = %q, want %q", ip, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// manageInvitesPerm is the MANAGE_INVITES permission bit.
|
||||
const manageInvitesPerm = int64(0x4000000)
|
||||
|
||||
// createInviteRequest is the JSON body for POST /api/v1/invites.
|
||||
type createInviteRequest struct {
|
||||
MaxUses int `json:"max_uses"`
|
||||
@@ -34,7 +32,7 @@ type inviteResponse struct {
|
||||
func MountInviteRoutes(r chi.Router, database *db.DB) {
|
||||
r.Route("/api/v1/invites", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(database))
|
||||
r.Use(RequirePermission(manageInvitesPerm))
|
||||
r.Use(RequirePermission(permissions.ManageInvites))
|
||||
|
||||
r.Post("/", handleCreateInvite(database))
|
||||
r.Get("/", handleListInvites(database))
|
||||
|
||||
+133
-41
@@ -3,12 +3,14 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// contextKey is an unexported type for context keys in this package.
|
||||
@@ -29,7 +31,7 @@ const (
|
||||
func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := extractBearerToken(r)
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
@@ -49,7 +51,7 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Check expiry.
|
||||
if isSessionExpired(sess.ExpiresAt) {
|
||||
if auth.IsSessionExpired(sess.ExpiresAt) {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "session has expired",
|
||||
@@ -67,6 +69,15 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Reject effectively-banned users before any further processing.
|
||||
if auth.IsEffectivelyBanned(user) {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "FORBIDDEN",
|
||||
Message: "your account has been suspended",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Load role for permission checks.
|
||||
role, err := database.GetRoleByID(user.RoleID)
|
||||
if err != nil {
|
||||
@@ -92,7 +103,6 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
|
||||
// role permissions. Returns 403 if the user lacks the required permission.
|
||||
// The ADMINISTRATOR bit (0x40000000) bypasses all checks.
|
||||
func RequirePermission(perm int64) func(http.Handler) http.Handler {
|
||||
const administrator = int64(0x40000000)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role, ok := r.Context().Value(RoleKey).(*db.Role)
|
||||
@@ -105,7 +115,7 @@ func RequirePermission(perm int64) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// ADMINISTRATOR bypasses all permission checks.
|
||||
if role.Permissions&administrator != 0 {
|
||||
if permissions.HasAdmin(role.Permissions) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -124,12 +134,17 @@ func RequirePermission(perm int64) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// RateLimitMiddleware returns middleware that limits requests per IP using the
|
||||
// provided RateLimiter. The IP is taken from X-Real-IP header when present,
|
||||
// falling back to RemoteAddr. Returns 429 with Retry-After when exceeded.
|
||||
func RateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Duration) func(http.Handler) http.Handler {
|
||||
// provided RateLimiter. The client IP is resolved via clientIPWithProxies using
|
||||
// the supplied trustedProxies CIDRs — pass nil to always use RemoteAddr.
|
||||
// Returns 429 with Retry-After when the limit is exceeded.
|
||||
func RateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Duration, trustedProxies ...[]string) func(http.Handler) http.Handler {
|
||||
var proxies []string
|
||||
if len(trustedProxies) > 0 {
|
||||
proxies = trustedProxies[0]
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
ip := clientIPWithProxies(r, proxies)
|
||||
|
||||
if !limiter.Allow(ip, limit, window) {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(window.Seconds())))
|
||||
@@ -147,44 +162,121 @@ func RateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Durat
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// extractBearerToken parses "Authorization: Bearer <token>" and returns the
|
||||
// token and true, or "", false if the header is missing or malformed.
|
||||
func extractBearerToken(r *http.Request) (string, bool) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// clientIP returns the client IP from X-Real-IP or RemoteAddr (without port).
|
||||
// clientIP returns the connecting IP from RemoteAddr, ignoring any proxy
|
||||
// headers. It is safe to use for audit logging and lockout keys where proxy
|
||||
// header trust has not been established. For rate-limiting with proxy support
|
||||
// use clientIPWithProxies.
|
||||
func clientIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
// RemoteAddr is "host:port"; strip the port.
|
||||
addr := r.RemoteAddr
|
||||
if idx := strings.LastIndex(addr, ":"); idx != -1 {
|
||||
return addr[:idx]
|
||||
}
|
||||
return addr
|
||||
return clientIPWithProxies(r, nil)
|
||||
}
|
||||
|
||||
// isSessionExpired returns true when expiresAt string represents a past time.
|
||||
// Handles both "2006-01-02 15:04:05" (SQLite) and "2006-01-02T15:04:05Z" formats.
|
||||
func isSessionExpired(expiresAt string) bool {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
t, err := time.Parse(layout, expiresAt)
|
||||
if err == nil {
|
||||
return time.Now().UTC().After(t.UTC())
|
||||
// clientIPWithProxies returns the real client IP for rate-limiting purposes.
|
||||
//
|
||||
// Security model:
|
||||
// - Always parse the actual connecting address from r.RemoteAddr.
|
||||
// - Only honour X-Real-IP or X-Forwarded-For if the connecting address matches
|
||||
// one of the trustedCIDRs. This prevents clients from forging their IP to
|
||||
// bypass rate limits.
|
||||
// - If trustedCIDRs is empty (the default), RemoteAddr is always used.
|
||||
//
|
||||
// Invalid CIDR entries in trustedCIDRs are silently skipped so that a
|
||||
// misconfigured entry cannot crash the server; the connecting IP is used as the
|
||||
// fallback.
|
||||
func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string {
|
||||
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
// RemoteAddr without port (e.g. Unix socket or test stub) — use as-is.
|
||||
remoteHost = r.RemoteAddr
|
||||
}
|
||||
|
||||
if len(trustedCIDRs) == 0 {
|
||||
return remoteHost
|
||||
}
|
||||
|
||||
trusted, _ := isTrustedProxy(remoteHost, trustedCIDRs)
|
||||
if !trusted {
|
||||
return remoteHost
|
||||
}
|
||||
|
||||
// Prefer X-Real-IP when coming from a trusted proxy.
|
||||
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fall back to the leftmost (client) entry in X-Forwarded-For.
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.SplitN(xff, ",", 2)
|
||||
if client := strings.TrimSpace(parts[0]); client != "" {
|
||||
return client
|
||||
}
|
||||
}
|
||||
// Unparseable expiry — treat as expired for safety.
|
||||
return true
|
||||
|
||||
return remoteHost
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether remoteIP (a plain IP string, no port) falls
|
||||
// within any of the provided CIDR ranges. It returns an error if any CIDR is
|
||||
// malformed.
|
||||
func isTrustedProxy(remoteIP string, cidrList []string) (bool, error) {
|
||||
ip := net.ParseIP(remoteIP)
|
||||
if ip == nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, cidr := range cidrList {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("isTrustedProxy: invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
if network.Contains(ip) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SecurityHeaders sets a standard suite of defensive HTTP response headers on
|
||||
// every response. It must be added to the router-level middleware stack so that
|
||||
// all routes, including error responses, carry these headers.
|
||||
//
|
||||
// Header choices:
|
||||
// - X-Content-Type-Options: nosniff — prevent MIME-type sniffing
|
||||
// - X-Frame-Options: DENY — block clickjacking via iframes
|
||||
// - X-XSS-Protection: 0 — disable legacy XSS filter; rely on CSP
|
||||
// - Referrer-Policy: strict-origin-when-cross-origin
|
||||
// - Content-Security-Policy: default-src 'self'
|
||||
// - Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
// - Cache-Control: no-store — prevent sensitive data caching
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("X-XSS-Protection", "0")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// MaxBodySize wraps r.Body with http.MaxBytesReader so that reads beyond
|
||||
// maxBytes return an error. This prevents clients from exhausting server memory
|
||||
// by sending arbitrarily large request bodies.
|
||||
//
|
||||
// Usage in the router:
|
||||
//
|
||||
// r.Use(MaxBodySize(1 << 20)) // 1 MiB default for API endpoints
|
||||
//
|
||||
// Upload endpoints that need a higher limit should apply their own
|
||||
// http.MaxBytesReader or a route-scoped middleware with a larger value.
|
||||
func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errorResponse is the standard error JSON shape.
|
||||
|
||||
@@ -3,6 +3,7 @@ package api_test
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@@ -272,22 +273,26 @@ func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware_XRealIPUsed(t *testing.T) {
|
||||
func TestRateLimitMiddleware_XRealIPIgnoredWithoutTrustedProxy(t *testing.T) {
|
||||
// Without trusted proxies configured, X-Real-IP must be ignored.
|
||||
// Each request with the same RemoteAddr host counts as the same IP regardless
|
||||
// of what the X-Real-IP header says.
|
||||
limiter := auth.NewRateLimiter()
|
||||
limit := 2
|
||||
|
||||
h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok))
|
||||
|
||||
// Two requests from the same X-Real-IP but different RemoteAddr.
|
||||
// Two requests from RemoteAddr 10.0.0.99 with an attacker-supplied X-Real-IP.
|
||||
for i := 0; i < limit; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Real-IP", "192.168.1.1")
|
||||
req.Header.Set("X-Real-IP", "192.168.1.1") // forged; must be ignored
|
||||
req.RemoteAddr = "10.0.0.99:9999"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
}
|
||||
|
||||
// Third request should be blocked by the X-Real-IP key.
|
||||
// Third request from the same RemoteAddr should be blocked — rate key is
|
||||
// 10.0.0.99, not the forged 192.168.1.1.
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Real-IP", "192.168.1.1")
|
||||
req.RemoteAddr = "10.0.0.99:9999"
|
||||
@@ -295,7 +300,279 @@ func TestRateLimitMiddleware_XRealIPUsed(t *testing.T) {
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("RateLimitMiddleware X-Real-IP status = %d, want 429", rr.Code)
|
||||
t.Errorf("RateLimitMiddleware no-trusted-proxy status = %d, want 429", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware_XRealIPHonouredFromTrustedProxy(t *testing.T) {
|
||||
// With a trusted proxy configured, X-Real-IP from that proxy is used.
|
||||
limiter := auth.NewRateLimiter()
|
||||
limit := 2
|
||||
trustedCIDRs := []string{"10.0.0.0/8"}
|
||||
|
||||
h := api.RateLimitMiddleware(limiter, limit, time.Minute, trustedCIDRs)(http.HandlerFunc(ok))
|
||||
|
||||
// Two requests coming through trusted proxy 10.0.0.1, client IP 203.0.113.5.
|
||||
for i := 0; i < limit; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Real-IP", "203.0.113.5")
|
||||
req.RemoteAddr = "10.0.0.1:9999"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
}
|
||||
|
||||
// Third request with same X-Real-IP from same trusted proxy — should be blocked.
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Real-IP", "203.0.113.5")
|
||||
req.RemoteAddr = "10.0.0.1:9999"
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("RateLimitMiddleware trusted proxy X-Real-IP status = %d, want 429", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fix 2.10: Ban expiry in AuthMiddleware ───────────────────────────────────
|
||||
|
||||
// TestAuthMiddleware_BannedUserBlocked verifies that an actively banned user
|
||||
// with no expiry cannot pass the auth middleware.
|
||||
func TestAuthMiddleware_BannedUserBlocked(t *testing.T) {
|
||||
database := newAPITestDB(t)
|
||||
uid, _ := database.CreateUser("banneduser", "hash", 4)
|
||||
database.BanUser(uid, "rule violation", nil) // permanent ban
|
||||
token, _ := auth.GenerateToken()
|
||||
hash := auth.HashToken(token)
|
||||
database.CreateSession(uid, hash, "test", "127.0.0.1")
|
||||
|
||||
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
withBearer(req, token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("AuthMiddleware banned user status = %d, want 403", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthMiddleware_ExpiredBanAllowed verifies that a user whose ban has
|
||||
// expired in the past can pass the auth middleware.
|
||||
func TestAuthMiddleware_ExpiredBanAllowed(t *testing.T) {
|
||||
database := newAPITestDB(t)
|
||||
uid, _ := database.CreateUser("expbanned", "hash", 4)
|
||||
|
||||
// Set ban with an expiry time in the past.
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
database.BanUser(uid, "temp ban", &past)
|
||||
|
||||
token, _ := auth.GenerateToken()
|
||||
hash := auth.HashToken(token)
|
||||
database.CreateSession(uid, hash, "test", "127.0.0.1")
|
||||
|
||||
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
withBearer(req, token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("AuthMiddleware expired-ban user status = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthMiddleware_ActiveTemporaryBanBlocked verifies that a user with a
|
||||
// temporary ban whose expiry is in the future is still blocked.
|
||||
func TestAuthMiddleware_ActiveTemporaryBanBlocked(t *testing.T) {
|
||||
database := newAPITestDB(t)
|
||||
uid, _ := database.CreateUser("tempbanned", "hash", 4)
|
||||
|
||||
// Set ban with an expiry time in the future.
|
||||
future := time.Now().UTC().Add(time.Hour)
|
||||
database.BanUser(uid, "temp ban", &future)
|
||||
|
||||
token, _ := auth.GenerateToken()
|
||||
hash := auth.HashToken(token)
|
||||
database.CreateSession(uid, hash, "test", "127.0.0.1")
|
||||
|
||||
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
withBearer(req, token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("AuthMiddleware active temp-ban user status = %d, want 403", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SecurityHeaders tests ───────────────────────────────────────────────────
|
||||
|
||||
func TestSecurityHeaders_AllHeadersPresent(t *testing.T) {
|
||||
h := api.SecurityHeaders(http.HandlerFunc(ok))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
want := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Xss-Protection": "0",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
for header, expected := range want {
|
||||
if got := rr.Header().Get(header); got != expected {
|
||||
t.Errorf("SecurityHeaders: %s = %q, want %q", header, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_PassesThrough(t *testing.T) {
|
||||
// Middleware must not swallow the response — downstream handler must be called.
|
||||
called := false
|
||||
h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if !called {
|
||||
t.Error("SecurityHeaders: downstream handler was not called")
|
||||
}
|
||||
if rr.Code != http.StatusTeapot {
|
||||
t.Errorf("SecurityHeaders: status = %d, want 418", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_DoesNotOverrideExistingHeaders(t *testing.T) {
|
||||
// If a downstream handler sets its own CSP, SecurityHeaders should not clobber it
|
||||
// because it runs before the handler writes. The middleware sets headers first,
|
||||
// the handler can then override them — that is the correct layering.
|
||||
// This test just confirms the middleware itself sets all seven headers.
|
||||
h := api.SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handler overrides CSP after SecurityHeaders has already set it.
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
// The handler's override wins because it runs after the middleware sets the header.
|
||||
if got := rr.Header().Get("Content-Security-Policy"); got != "default-src 'none'" {
|
||||
t.Errorf("SecurityHeaders: handler CSP override = %q, want \"default-src 'none'\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MaxBodySize tests ────────────────────────────────────────────────────────
|
||||
|
||||
func TestMaxBodySize_UnderLimit(t *testing.T) {
|
||||
// A body smaller than the limit must be read successfully by the handler.
|
||||
const limit = 10 // bytes
|
||||
body := strings.NewReader("hello") // 5 bytes — under limit
|
||||
|
||||
h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := make([]byte, 20)
|
||||
n, _ := r.Body.Read(data)
|
||||
if n != 5 {
|
||||
t.Errorf("MaxBodySize under limit: read %d bytes, want 5", n)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("MaxBodySize under limit: status = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBodySize_ExactLimit(t *testing.T) {
|
||||
// A body exactly at the limit must be read without error.
|
||||
const limit = 5
|
||||
body := strings.NewReader("hello") // exactly 5 bytes
|
||||
|
||||
h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := make([]byte, 10)
|
||||
n, _ := r.Body.Read(data)
|
||||
if n != 5 {
|
||||
t.Errorf("MaxBodySize exact limit: read %d bytes, want 5", n)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("MaxBodySize exact limit: status = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBodySize_OverLimit(t *testing.T) {
|
||||
// Reading beyond the limit must return an error from MaxBytesReader.
|
||||
const limit = 5
|
||||
body := strings.NewReader("hello world") // 11 bytes — over limit
|
||||
|
||||
var readErr error
|
||||
h := api.MaxBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := make([]byte, 20)
|
||||
_, readErr = r.Body.Read(data)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if readErr == nil {
|
||||
t.Error("MaxBodySize over limit: expected read error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBodySize_NilBody(t *testing.T) {
|
||||
// GET requests with no body must pass through without panic.
|
||||
h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Must not panic.
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("MaxBodySize nil body: status = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBodySize_PassesThrough(t *testing.T) {
|
||||
// Downstream handler must be called and its status code preserved.
|
||||
h := api.MaxBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("data"))
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Errorf("MaxBodySize pass-through: status = %d, want 201", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-17
@@ -15,9 +15,6 @@ import (
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
// version is the server version string, set by NewRouter from the caller.
|
||||
var version = "dev"
|
||||
|
||||
// NewRouter builds and returns the fully configured HTTP handler.
|
||||
func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
@@ -25,20 +22,22 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
|
||||
// Middleware stack.
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(setRequestIDHeader) // echo request ID into response header
|
||||
r.Use(middleware.RealIP)
|
||||
// NOTE: middleware.RealIP is intentionally omitted — trusting X-Real-IP from
|
||||
// any source allows IP spoofing for rate-limit bypass. IP header trust is now
|
||||
// handled explicitly in clientIPWithProxies using the trusted_proxies config.
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
version = ver
|
||||
r.Use(SecurityHeaders)
|
||||
r.Use(MaxBodySize(1 << 20)) // 1 MiB default; upload routes use their own limit
|
||||
|
||||
// Health check — unauthenticated, no versioning prefix.
|
||||
r.Get("/health", handleHealth)
|
||||
r.Get("/health", handleHealth(ver))
|
||||
|
||||
// Shared rate limiter for auth endpoints.
|
||||
limiter := auth.NewRateLimiter()
|
||||
|
||||
// Versioned API routes.
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/info", handleInfo(cfg))
|
||||
r.Get("/info", handleInfo(cfg, ver))
|
||||
})
|
||||
|
||||
// Auth routes: register, login, logout, me.
|
||||
@@ -56,7 +55,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
|
||||
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
|
||||
hub := ws.NewHub(database, limiter)
|
||||
go hub.Run()
|
||||
r.Get("/api/v1/ws", ws.ServeWS(hub, database))
|
||||
r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins))
|
||||
|
||||
// Admin panel: static files + REST API (Phase 6).
|
||||
u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord")
|
||||
@@ -77,18 +76,20 @@ type infoResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, healthResponse{
|
||||
Status: "ok",
|
||||
Version: version,
|
||||
})
|
||||
func handleHealth(ver string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, healthResponse{
|
||||
Status: "ok",
|
||||
Version: ver,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleInfo(cfg *config.Config) http.HandlerFunc {
|
||||
func handleInfo(cfg *config.Config, ver string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, infoResponse{
|
||||
Name: cfg.Server.Name,
|
||||
Version: version,
|
||||
Version: ver,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -105,7 +106,7 @@ func setRequestIDHeader(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// writeJSON encodes v as JSON and writes it to w with the given status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// ExtractBearerToken parses the "Authorization: Bearer <token>" header from r
|
||||
// and returns the token and true. Returns "", false if the header is absent,
|
||||
// uses a scheme other than "bearer" (case-insensitive), or has an empty token.
|
||||
func ExtractBearerToken(r *http.Request) (string, bool) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
// IsEffectivelyBanned reports whether u is currently banned, accounting for
|
||||
// temporary ban expiry. A user is effectively banned when:
|
||||
// - u.Banned is true, AND
|
||||
// - u.BanExpires is nil (permanent ban), OR the expiry is in the future.
|
||||
//
|
||||
// If u is nil the function returns false without panicking.
|
||||
// If BanExpires holds an unparseable string the ban is treated as active
|
||||
// (fail-safe: keep user blocked rather than silently unblocking them).
|
||||
func IsEffectivelyBanned(u *db.User) bool {
|
||||
if u == nil || !u.Banned {
|
||||
return false
|
||||
}
|
||||
// Permanent ban — no expiry set.
|
||||
if u.BanExpires == nil {
|
||||
return true
|
||||
}
|
||||
// Temporary ban — parse the expiry and compare to now.
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
t, err := time.Parse(layout, *u.BanExpires)
|
||||
if err == nil {
|
||||
// Ban is still active if expiry is in the future.
|
||||
return time.Now().UTC().Before(t.UTC())
|
||||
}
|
||||
}
|
||||
// Unparseable expiry — fail-safe: treat as still banned.
|
||||
return true
|
||||
}
|
||||
|
||||
// IsSessionExpired reports whether the expiresAt timestamp string represents a
|
||||
// time in the past. It accepts both the SQLite space-separated format
|
||||
// ("2006-01-02 15:04:05") and the ISO-8601 UTC format ("2006-01-02T15:04:05Z").
|
||||
// Any string that cannot be parsed is treated as expired for safety.
|
||||
func IsSessionExpired(expiresAt string) bool {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
t, err := time.Parse(layout, expiresAt)
|
||||
if err == nil {
|
||||
return time.Now().UTC().After(t.UTC())
|
||||
}
|
||||
}
|
||||
// Unparseable expiry — treat as expired for safety.
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// ─── ExtractBearerToken ───────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractBearerToken_ValidHeader(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer mytoken123")
|
||||
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("ExtractBearerToken() ok = false, want true")
|
||||
}
|
||||
if token != "mytoken123" {
|
||||
t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_MissingHeader(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if ok {
|
||||
t.Error("ExtractBearerToken() ok = true with no Authorization header, want false")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("ExtractBearerToken() token = %q, want empty string", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_EmptyHeaderValue(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "")
|
||||
|
||||
_, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if ok {
|
||||
t.Error("ExtractBearerToken() ok = true for empty header value, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_WrongScheme(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
||||
|
||||
_, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if ok {
|
||||
t.Error("ExtractBearerToken() ok = true for Basic scheme, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_BearerCaseInsensitive(t *testing.T) {
|
||||
cases := []string{
|
||||
"BEARER mytoken",
|
||||
"bearer mytoken",
|
||||
"Bearer mytoken",
|
||||
"bEaReR mytoken",
|
||||
}
|
||||
for _, authHeader := range cases {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", authHeader)
|
||||
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if !ok {
|
||||
t.Errorf("ExtractBearerToken() ok = false for header %q, want true", authHeader)
|
||||
}
|
||||
if token != "mytoken" {
|
||||
t.Errorf("ExtractBearerToken() token = %q for header %q, want %q", token, authHeader, "mytoken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_BearerWithNoToken(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer ")
|
||||
|
||||
_, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if ok {
|
||||
t.Error("ExtractBearerToken() ok = true for 'Bearer ' with empty token, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_OnlySchemeNoSpace(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer")
|
||||
|
||||
_, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if ok {
|
||||
t.Error("ExtractBearerToken() ok = true for 'Bearer' with no space or token, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_TokenPreservesValue(t *testing.T) {
|
||||
// Tokens can contain mixed-case, digits, hyphens, underscores, dots.
|
||||
rawToken := "aB3-xY9_zZ0.qQ7"
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+rawToken)
|
||||
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
if !ok {
|
||||
t.Fatal("ExtractBearerToken() ok = false, want true")
|
||||
}
|
||||
if token != rawToken {
|
||||
t.Errorf("ExtractBearerToken() token = %q, want %q", token, rawToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken_MultipleSpaces(t *testing.T) {
|
||||
// SplitN with n=2 means "Bearer tok" splits into ["Bearer", " tok"].
|
||||
// The second part " tok" is non-empty, so the function must return " tok", true.
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer mytoken")
|
||||
|
||||
token, ok := auth.ExtractBearerToken(r)
|
||||
|
||||
// The contract: returns whatever follows the single separating space.
|
||||
// " mytoken" is non-empty, so ok should be true.
|
||||
if !ok {
|
||||
t.Fatal("ExtractBearerToken() ok = false for double-space header, want true")
|
||||
}
|
||||
if token != " mytoken" {
|
||||
t.Errorf("ExtractBearerToken() token = %q, want %q", token, " mytoken")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IsSessionExpired ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestIsSessionExpired_FutureTimeNotExpired(t *testing.T) {
|
||||
future := time.Now().UTC().Add(time.Hour)
|
||||
expiresAt := future.Format("2006-01-02 15:04:05")
|
||||
|
||||
if auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = true for future time, want false", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_PastTimeExpired(t *testing.T) {
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
expiresAt := past.Format("2006-01-02 15:04:05")
|
||||
|
||||
if !auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = false for past time, want true", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_FutureTimeSQLiteFormat(t *testing.T) {
|
||||
future := time.Now().UTC().Add(24 * time.Hour)
|
||||
expiresAt := future.Format("2006-01-02 15:04:05")
|
||||
|
||||
if auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = true for future SQLite-format time, want false", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_PastTimeSQLiteFormat(t *testing.T) {
|
||||
past := time.Now().UTC().Add(-24 * time.Hour)
|
||||
expiresAt := past.Format("2006-01-02 15:04:05")
|
||||
|
||||
if !auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = false for past SQLite-format time, want true", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_FutureTimeISO8601Format(t *testing.T) {
|
||||
future := time.Now().UTC().Add(time.Hour)
|
||||
expiresAt := future.Format("2006-01-02T15:04:05Z")
|
||||
|
||||
if auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = true for future ISO-8601 time, want false", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_PastTimeISO8601Format(t *testing.T) {
|
||||
past := time.Now().UTC().Add(-time.Hour)
|
||||
expiresAt := past.Format("2006-01-02T15:04:05Z")
|
||||
|
||||
if !auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = false for past ISO-8601 time, want true", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_EmptyString(t *testing.T) {
|
||||
// Unparseable — must treat as expired for safety.
|
||||
if !auth.IsSessionExpired("") {
|
||||
t.Error("IsSessionExpired(\"\") = false for empty string, want true (fail-safe)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_InvalidFormat(t *testing.T) {
|
||||
cases := []string{
|
||||
"not-a-date",
|
||||
"2025/03/15 12:00:00",
|
||||
"15-03-2025",
|
||||
"2025-13-45T99:99:99Z", // out-of-range values
|
||||
}
|
||||
for _, s := range cases {
|
||||
if !auth.IsSessionExpired(s) {
|
||||
t.Errorf("IsSessionExpired(%q) = false for invalid format, want true (fail-safe)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionExpired_ExactlyNow(t *testing.T) {
|
||||
// A timestamp one second in the past must always be expired.
|
||||
justPast := time.Now().UTC().Add(-time.Second)
|
||||
expiresAt := justPast.Format("2006-01-02 15:04:05")
|
||||
|
||||
if !auth.IsSessionExpired(expiresAt) {
|
||||
t.Errorf("IsSessionExpired(%q) = false for just-past time, want true", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IsEffectivelyBanned ──────────────────────────────────────────────────────
|
||||
|
||||
// ptr is a helper to get a pointer to a string literal.
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
func TestIsEffectivelyBanned_NotBanned(t *testing.T) {
|
||||
u := &db.User{Banned: false}
|
||||
if auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=false) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_BannedNilExpiry(t *testing.T) {
|
||||
// Banned with no expiry — permanently banned.
|
||||
u := &db.User{Banned: true, BanExpires: nil}
|
||||
if !auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=true, BanExpires=nil) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_BannedFutureExpiry(t *testing.T) {
|
||||
// Banned with an expiry in the future — still banned.
|
||||
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05")
|
||||
u := &db.User{Banned: true, BanExpires: ptr(future)}
|
||||
if !auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=true, future expiry) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_BannedPastExpiry(t *testing.T) {
|
||||
// Banned but the ban expired in the past — should be treated as NOT banned.
|
||||
past := time.Now().UTC().Add(-time.Hour).Format("2006-01-02 15:04:05")
|
||||
u := &db.User{Banned: true, BanExpires: ptr(past)}
|
||||
if auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=true, past expiry) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_BannedExpiredISO8601(t *testing.T) {
|
||||
// ISO-8601 format for BanExpires past — should be treated as NOT banned.
|
||||
past := time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05Z")
|
||||
u := &db.User{Banned: true, BanExpires: ptr(past)}
|
||||
if auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 past expiry) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_BannedFutureISO8601(t *testing.T) {
|
||||
// ISO-8601 format for BanExpires in future — still banned.
|
||||
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02T15:04:05Z")
|
||||
u := &db.User{Banned: true, BanExpires: ptr(future)}
|
||||
if !auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=true, ISO-8601 future expiry) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_BannedUnparsableExpiry(t *testing.T) {
|
||||
// Unparseable expiry string — fail-safe: treat as still banned.
|
||||
u := &db.User{Banned: true, BanExpires: ptr("not-a-date")}
|
||||
if !auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=true, unparseable expiry) = false, want true (fail-safe)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_NotBannedIgnoresExpiry(t *testing.T) {
|
||||
// Banned=false even with a future expiry field — should be false.
|
||||
future := time.Now().UTC().Add(time.Hour).Format("2006-01-02 15:04:05")
|
||||
u := &db.User{Banned: false, BanExpires: ptr(future)}
|
||||
if auth.IsEffectivelyBanned(u) {
|
||||
t.Error("IsEffectivelyBanned(Banned=false, future expiry) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEffectivelyBanned_NilUser(t *testing.T) {
|
||||
// A nil user pointer must not panic and must return false.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("IsEffectivelyBanned(nil) panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if auth.IsEffectivelyBanned(nil) {
|
||||
t.Error("IsEffectivelyBanned(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultCleanupMaxWindow is the age beyond which a window entry with no
|
||||
// recent timestamps is considered stale and eligible for eviction.
|
||||
const defaultCleanupMaxWindow = 15 * time.Minute
|
||||
|
||||
// entry records individual request timestamps for sliding-window limiting.
|
||||
type entry struct {
|
||||
timestamps []time.Time
|
||||
@@ -102,3 +106,69 @@ func (r *RateLimiter) Reset(key string) {
|
||||
delete(r.windows, key)
|
||||
delete(r.lockouts, key)
|
||||
}
|
||||
|
||||
// Cleanup evicts stale map entries to prevent unbounded memory growth.
|
||||
//
|
||||
// A windows entry is removed when every recorded timestamp is older than
|
||||
// maxWindow — meaning the entry could not affect any future Allow call that
|
||||
// uses a window equal to or shorter than maxWindow.
|
||||
//
|
||||
// A lockouts entry is removed when its expiry has passed.
|
||||
//
|
||||
// Pass defaultCleanupMaxWindow (15 minutes) for normal server operation, or
|
||||
// a shorter duration in tests.
|
||||
func (r *RateLimiter) Cleanup(maxWindow time.Duration) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-maxWindow)
|
||||
|
||||
for key, e := range r.windows {
|
||||
allStale := true
|
||||
for _, ts := range e.timestamps {
|
||||
if ts.After(cutoff) {
|
||||
allStale = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allStale {
|
||||
delete(r.windows, key)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for key, lo := range r.lockouts {
|
||||
if now.After(lo.expiresAt) {
|
||||
delete(r.lockouts, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartCleanup runs Cleanup on a ticker with the given interval until the
|
||||
// stop channel is closed. It is intended to be called in a goroutine:
|
||||
//
|
||||
// stop := make(chan struct{})
|
||||
// go rl.StartCleanup(5*time.Minute, 15*time.Minute, stop)
|
||||
//
|
||||
// Closing stop causes the goroutine to exit promptly.
|
||||
func (r *RateLimiter) StartCleanup(interval, maxWindow time.Duration, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.Cleanup(maxWindow)
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of entries currently stored in the windows and
|
||||
// lockouts maps. It is primarily useful for testing and monitoring.
|
||||
func (r *RateLimiter) Len() (windows, lockouts int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.windows), len(r.lockouts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
)
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestCleanup_RemovesExpiredWindows verifies that window entries whose
|
||||
// timestamps are all older than the max window are deleted by Cleanup.
|
||||
func TestCleanup_RemovesExpiredWindows(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
|
||||
// Populate a window entry that will have expired timestamps.
|
||||
shortWindow := 30 * time.Millisecond
|
||||
rl.Allow("stale-ip", 10, shortWindow)
|
||||
|
||||
// Wait long enough that all timestamps fall outside the 15-minute
|
||||
// cleanup horizon — we override by using a very short max-window for test.
|
||||
time.Sleep(shortWindow + 10*time.Millisecond)
|
||||
|
||||
// Use a maxWindow shorter than 15 minutes so the test runs fast.
|
||||
rl.Cleanup(shortWindow)
|
||||
|
||||
wins, _ := rl.Len()
|
||||
if wins != 0 {
|
||||
t.Errorf("Len().windows = %d after Cleanup, want 0 (stale entry should be evicted)", wins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanup_RemovesExpiredLockouts verifies that expired lockout entries
|
||||
// are deleted by Cleanup.
|
||||
func TestCleanup_RemovesExpiredLockouts(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
|
||||
rl.Lockout("stale-lockout", 20*time.Millisecond)
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
|
||||
rl.Cleanup(15 * time.Minute)
|
||||
|
||||
_, locks := rl.Len()
|
||||
if locks != 0 {
|
||||
t.Errorf("Len().lockouts = %d after Cleanup, want 0 (expired lockout should be evicted)", locks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanup_PreservesActiveWindows verifies that a window with recent
|
||||
// timestamps is NOT evicted during Cleanup.
|
||||
func TestCleanup_PreservesActiveWindows(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
|
||||
// Issue a request; the timestamp is recent.
|
||||
rl.Allow("active-ip", 100, time.Hour)
|
||||
|
||||
// Cleanup with a 15-minute max window should keep the fresh entry.
|
||||
rl.Cleanup(15 * time.Minute)
|
||||
|
||||
wins, _ := rl.Len()
|
||||
if wins != 1 {
|
||||
t.Errorf("Len().windows = %d after Cleanup, want 1 (active entry should be preserved)", wins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanup_PreservesActiveLockouts verifies that a non-expired lockout
|
||||
// is NOT deleted by Cleanup.
|
||||
func TestCleanup_PreservesActiveLockouts(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
|
||||
rl.Lockout("live-lockout", time.Hour)
|
||||
|
||||
rl.Cleanup(15 * time.Minute)
|
||||
|
||||
_, locks := rl.Len()
|
||||
if locks != 1 {
|
||||
t.Errorf("Len().lockouts = %d after Cleanup, want 1 (active lockout should be preserved)", locks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanup_MixedEntries verifies that Cleanup correctly partitions stale
|
||||
// from active entries when both are present.
|
||||
//
|
||||
// Strategy: the "stale" window key gets a single request right now, then we
|
||||
// sleep until that timestamp is outside the cleanup maxWindow. The "active"
|
||||
// key gets a new request AFTER the sleep so its timestamp is always fresh.
|
||||
func TestCleanup_MixedEntries(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
shortWindow := 30 * time.Millisecond
|
||||
|
||||
// Stale window entry — its timestamp will be older than shortWindow.
|
||||
rl.Allow("stale", 10, shortWindow)
|
||||
// Stale lockout — expires in shortWindow.
|
||||
rl.Lockout("stale-lock", shortWindow)
|
||||
|
||||
// Wait until the stale timestamps fall outside shortWindow.
|
||||
time.Sleep(shortWindow + 10*time.Millisecond)
|
||||
|
||||
// Active entries added AFTER the sleep — their timestamps are fresh.
|
||||
rl.Allow("active", 10, time.Hour)
|
||||
rl.Lockout("live-lock", time.Hour)
|
||||
|
||||
// Cleanup with shortWindow: "stale" was recorded before the cutoff, so it
|
||||
// is evicted. "active" was just recorded, so it is kept.
|
||||
rl.Cleanup(shortWindow)
|
||||
|
||||
wins, locks := rl.Len()
|
||||
if wins != 1 {
|
||||
t.Errorf("windows = %d, want 1 (only active should remain)", wins)
|
||||
}
|
||||
if locks != 1 {
|
||||
t.Errorf("lockouts = %d, want 1 (only live lockout should remain)", locks)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Len ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestLen_Empty verifies Len returns (0, 0) on a fresh RateLimiter.
|
||||
func TestLen_Empty(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
wins, locks := rl.Len()
|
||||
if wins != 0 || locks != 0 {
|
||||
t.Errorf("Len() = (%d, %d), want (0, 0) on empty RateLimiter", wins, locks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLen_AfterAllows verifies Len accurately reflects the number of
|
||||
// distinct keys that have issued at least one request.
|
||||
func TestLen_AfterAllows(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
rl.Allow("a", 10, time.Hour)
|
||||
rl.Allow("b", 10, time.Hour)
|
||||
rl.Allow("a", 10, time.Hour) // same key again — should not increment
|
||||
|
||||
wins, _ := rl.Len()
|
||||
if wins != 2 {
|
||||
t.Errorf("Len().windows = %d, want 2", wins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLen_AfterLockouts verifies Len accurately reflects the number of
|
||||
// active lockout entries.
|
||||
func TestLen_AfterLockouts(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
rl.Lockout("x", time.Hour)
|
||||
rl.Lockout("y", time.Hour)
|
||||
|
||||
_, locks := rl.Len()
|
||||
if locks != 2 {
|
||||
t.Errorf("Len().lockouts = %d, want 2", locks)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── StartCleanup ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestStartCleanup_RunsPeriodically verifies that StartCleanup evicts stale
|
||||
// entries automatically without a manual Cleanup call.
|
||||
func TestStartCleanup_RunsPeriodically(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
shortWindow := 20 * time.Millisecond
|
||||
|
||||
rl.Allow("stale", 10, shortWindow)
|
||||
|
||||
wins, _ := rl.Len()
|
||||
if wins != 1 {
|
||||
t.Fatalf("expected 1 window entry before cleanup, got %d", wins)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
// Run cleanup every 10 ms with a 20 ms max window so the stale entry is
|
||||
// evicted after the first tick.
|
||||
go rl.StartCleanup(10*time.Millisecond, shortWindow, stop)
|
||||
defer close(stop)
|
||||
|
||||
// Give the ticker at least two cycles to fire.
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
if w, _ := rl.Len(); w == 0 {
|
||||
return // evicted as expected
|
||||
}
|
||||
}
|
||||
|
||||
wins, _ = rl.Len()
|
||||
if wins != 0 {
|
||||
t.Errorf("StartCleanup did not evict stale entry within 200 ms; Len().windows = %d", wins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartCleanup_StopsOnSignal verifies that closing the stop channel
|
||||
// terminates the background goroutine (no leak). We cannot observe the
|
||||
// goroutine directly, but we verify no panic/deadlock occurs after stop.
|
||||
func TestStartCleanup_StopsOnSignal(t *testing.T) {
|
||||
rl := auth.NewRateLimiter()
|
||||
stop := make(chan struct{})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
rl.StartCleanup(10*time.Millisecond, 15*time.Minute, stop)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
close(stop)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// goroutine exited cleanly
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Error("StartCleanup goroutine did not exit after stop channel was closed")
|
||||
}
|
||||
}
|
||||
+77
-8
@@ -11,12 +11,25 @@ import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/owncord/server/config"
|
||||
)
|
||||
|
||||
// TLSResult holds the output of LoadOrGenerate.
|
||||
// For most TLS modes only TLSConfig is set. In ACME mode, HTTPHandler is
|
||||
// also set and must be served on :80 for HTTP-01 challenges and redirect.
|
||||
type TLSResult struct {
|
||||
TLSConfig *tls.Config
|
||||
HTTPHandler http.Handler // non-nil only for ACME mode
|
||||
}
|
||||
|
||||
// GenerateSelfSigned generates an ECDSA P-256 self-signed TLS certificate
|
||||
// valid for 10 years and writes the PEM-encoded cert and key to the given
|
||||
// file paths.
|
||||
@@ -71,24 +84,32 @@ func GenerateSelfSigned(certFile, keyFile string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadOrGenerate returns a *tls.Config based on the TLS configuration mode:
|
||||
// LoadOrGenerate returns a *TLSResult based on the TLS configuration mode:
|
||||
// - "self_signed": loads existing cert/key or generates new ones
|
||||
// - "manual": loads existing cert/key from CertFile/KeyFile paths
|
||||
// - "off": returns nil (TLS disabled)
|
||||
// - "acme": not yet implemented — returns error
|
||||
func LoadOrGenerate(cfg config.TLSConfig) (*tls.Config, error) {
|
||||
// - "off": returns nil TLSConfig (TLS disabled)
|
||||
// - "acme": obtains Let's Encrypt certificate via ACME; HTTPHandler must be served on :80
|
||||
func LoadOrGenerate(cfg config.TLSConfig) (*TLSResult, error) {
|
||||
switch cfg.Mode {
|
||||
case "off":
|
||||
return nil, nil
|
||||
return &TLSResult{}, nil
|
||||
|
||||
case "self_signed":
|
||||
return loadOrGenerateSelfSigned(cfg)
|
||||
tlsCfg, err := loadOrGenerateSelfSigned(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TLSResult{TLSConfig: tlsCfg}, nil
|
||||
|
||||
case "manual":
|
||||
return loadCertPair(cfg.CertFile, cfg.KeyFile)
|
||||
tlsCfg, err := loadCertPair(cfg.CertFile, cfg.KeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TLSResult{TLSConfig: tlsCfg}, nil
|
||||
|
||||
case "acme":
|
||||
return nil, fmt.Errorf("TLS mode 'acme' is not yet implemented")
|
||||
return loadACME(cfg)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown TLS mode: %q", cfg.Mode)
|
||||
@@ -139,3 +160,51 @@ func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// loadACME sets up an autocert.Manager for automatic Let's Encrypt certificates.
|
||||
// The returned TLSResult includes an HTTPHandler that must be served on :80 for
|
||||
// HTTP-01 challenge validation and HTTP→HTTPS redirect.
|
||||
func loadACME(cfg config.TLSConfig) (*TLSResult, error) {
|
||||
if cfg.Domain == "" {
|
||||
return nil, fmt.Errorf("TLS mode 'acme' requires tls.domain to be set (e.g. \"chat.example.com\")")
|
||||
}
|
||||
|
||||
// Validate domain is not an IP address.
|
||||
if ip := net.ParseIP(cfg.Domain); ip != nil {
|
||||
return nil, fmt.Errorf("TLS mode 'acme': domain must be a hostname, not an IP address (%s); Let's Encrypt does not issue certificates for IP addresses", cfg.Domain)
|
||||
}
|
||||
|
||||
// Reject wildcard domains (HTTP-01 does not support them).
|
||||
if strings.HasPrefix(cfg.Domain, "*.") || strings.Contains(cfg.Domain, "*") {
|
||||
return nil, fmt.Errorf("TLS mode 'acme': wildcard domains (%s) are not supported with HTTP-01 challenge; use a specific hostname", cfg.Domain)
|
||||
}
|
||||
|
||||
cacheDir := cfg.AcmeCacheDir
|
||||
if cacheDir == "" {
|
||||
cacheDir = "data/acme_certs"
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("creating ACME cache directory %s: %w", cacheDir, err)
|
||||
}
|
||||
|
||||
m := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(cacheDir),
|
||||
HostPolicy: autocert.HostWhitelist(cfg.Domain),
|
||||
}
|
||||
|
||||
// HTTP handler serves ACME HTTP-01 challenges on port 80 and redirects
|
||||
// all other traffic to HTTPS.
|
||||
redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
target := "https://" + cfg.Domain + r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
})
|
||||
|
||||
tlsCfg := m.TLSConfig()
|
||||
tlsCfg.MinVersion = tls.VersionTLS12
|
||||
|
||||
return &TLSResult{
|
||||
TLSConfig: tlsCfg,
|
||||
HTTPHandler: m.HTTPHandler(redirect),
|
||||
}, nil
|
||||
}
|
||||
|
||||
+113
-12
@@ -3,8 +3,11 @@ package auth_test
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -91,15 +94,18 @@ func TestLoadOrGenerateSelfSigned(t *testing.T) {
|
||||
KeyFile: keyFile,
|
||||
}
|
||||
|
||||
tlsCfg, err := auth.LoadOrGenerate(cfg)
|
||||
result, err := auth.LoadOrGenerate(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerate() error: %v", err)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
t.Fatal("LoadOrGenerate() returned nil tls.Config")
|
||||
if result.TLSConfig == nil {
|
||||
t.Fatal("LoadOrGenerate() returned nil TLSConfig")
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
t.Error("LoadOrGenerate() returned tls.Config with no certificates")
|
||||
if len(result.TLSConfig.Certificates) == 0 {
|
||||
t.Error("LoadOrGenerate() returned TLSConfig with no certificates")
|
||||
}
|
||||
if result.HTTPHandler != nil {
|
||||
t.Error("self_signed mode should not set HTTPHandler")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,11 +126,11 @@ func TestLoadOrGenerateLoadsExistingCert(t *testing.T) {
|
||||
}
|
||||
|
||||
// Load the existing cert (should not regenerate).
|
||||
tlsCfg, err := auth.LoadOrGenerate(cfg)
|
||||
result, err := auth.LoadOrGenerate(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerate() error: %v", err)
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
if len(result.TLSConfig.Certificates) == 0 {
|
||||
t.Error("LoadOrGenerate() returned no certificates")
|
||||
}
|
||||
}
|
||||
@@ -132,12 +138,12 @@ func TestLoadOrGenerateLoadsExistingCert(t *testing.T) {
|
||||
func TestLoadOrGenerateModeOff(t *testing.T) {
|
||||
cfg := config.TLSConfig{Mode: "off"}
|
||||
|
||||
tlsCfg, err := auth.LoadOrGenerate(cfg)
|
||||
result, err := auth.LoadOrGenerate(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerate(mode=off) error: %v", err)
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
t.Error("LoadOrGenerate(mode=off) should return nil tls.Config")
|
||||
if result.TLSConfig != nil {
|
||||
t.Error("LoadOrGenerate(mode=off) should return nil TLSConfig")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,11 +176,11 @@ func TestLoadOrGenerateModeManualValidFiles(t *testing.T) {
|
||||
KeyFile: keyFile,
|
||||
}
|
||||
|
||||
tlsCfg, err := auth.LoadOrGenerate(cfg)
|
||||
result, err := auth.LoadOrGenerate(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerate(mode=manual) error: %v", err)
|
||||
}
|
||||
if len(tlsCfg.Certificates) == 0 {
|
||||
if len(result.TLSConfig.Certificates) == 0 {
|
||||
t.Error("LoadOrGenerate(mode=manual) returned no certificates")
|
||||
}
|
||||
}
|
||||
@@ -187,3 +193,98 @@ func TestLoadOrGenerateUnknownMode(t *testing.T) {
|
||||
t.Error("LoadOrGenerate() should error for unknown TLS mode")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ACME mode tests ───────────────────────────────────────────────────────
|
||||
|
||||
func TestLoadOrGenerateACME_MissingDomain(t *testing.T) {
|
||||
cfg := config.TLSConfig{Mode: "acme", Domain: ""}
|
||||
|
||||
_, err := auth.LoadOrGenerate(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ACME mode without domain")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "domain") {
|
||||
t.Errorf("error should mention domain, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrGenerateACME_IPAddress(t *testing.T) {
|
||||
cfg := config.TLSConfig{Mode: "acme", Domain: "192.168.1.1"}
|
||||
|
||||
_, err := auth.LoadOrGenerate(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ACME mode with IP address")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "IP address") {
|
||||
t.Errorf("error should mention IP address, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrGenerateACME_WildcardDomain(t *testing.T) {
|
||||
cfg := config.TLSConfig{Mode: "acme", Domain: "*.example.com"}
|
||||
|
||||
_, err := auth.LoadOrGenerate(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ACME mode with wildcard domain")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wildcard") {
|
||||
t.Errorf("error should mention wildcard, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrGenerateACME_ValidDomain(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cacheDir := filepath.Join(tmpDir, "acme_certs")
|
||||
|
||||
cfg := config.TLSConfig{
|
||||
Mode: "acme",
|
||||
Domain: "chat.example.com",
|
||||
AcmeCacheDir: cacheDir,
|
||||
}
|
||||
|
||||
result, err := auth.LoadOrGenerate(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerate(acme) error: %v", err)
|
||||
}
|
||||
if result.TLSConfig == nil {
|
||||
t.Fatal("ACME mode should return non-nil TLSConfig")
|
||||
}
|
||||
if result.TLSConfig.GetCertificate == nil {
|
||||
t.Error("ACME TLSConfig should have GetCertificate set")
|
||||
}
|
||||
if result.HTTPHandler == nil {
|
||||
t.Error("ACME mode should return non-nil HTTPHandler")
|
||||
}
|
||||
|
||||
// Verify cache directory was created.
|
||||
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
|
||||
t.Error("ACME cache directory was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrGenerateACME_HTTPRedirect(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfg := config.TLSConfig{
|
||||
Mode: "acme",
|
||||
Domain: "chat.example.com",
|
||||
AcmeCacheDir: filepath.Join(tmpDir, "acme_certs"),
|
||||
}
|
||||
|
||||
result, err := auth.LoadOrGenerate(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrGenerate(acme) error: %v", err)
|
||||
}
|
||||
|
||||
// Non-challenge requests should redirect to HTTPS.
|
||||
req := httptest.NewRequest(http.MethodGet, "http://chat.example.com/some/path", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
result.HTTPHandler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Errorf("expected 301 redirect, got %d", rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://chat.example.com/") {
|
||||
t.Errorf("redirect should point to HTTPS, got: %s", loc)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-15
@@ -39,9 +39,11 @@ type VoiceConfig struct {
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
type ServerConfig struct {
|
||||
Port int `koanf:"port"`
|
||||
Name string `koanf:"name"`
|
||||
DataDir string `koanf:"data_dir"`
|
||||
Port int `koanf:"port"`
|
||||
Name string `koanf:"name"`
|
||||
DataDir string `koanf:"data_dir"`
|
||||
AllowedOrigins []string `koanf:"allowed_origins"`
|
||||
TrustedProxies []string `koanf:"trusted_proxies"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database settings.
|
||||
@@ -51,10 +53,11 @@ type DatabaseConfig struct {
|
||||
|
||||
// TLSConfig holds TLS/certificate settings.
|
||||
type TLSConfig struct {
|
||||
Mode string `koanf:"mode"`
|
||||
CertFile string `koanf:"cert_file"`
|
||||
KeyFile string `koanf:"key_file"`
|
||||
Domain string `koanf:"domain"`
|
||||
Mode string `koanf:"mode"`
|
||||
CertFile string `koanf:"cert_file"`
|
||||
KeyFile string `koanf:"key_file"`
|
||||
Domain string `koanf:"domain"`
|
||||
AcmeCacheDir string `koanf:"acme_cache_dir"`
|
||||
}
|
||||
|
||||
// UploadConfig holds file upload settings.
|
||||
@@ -67,17 +70,20 @@ type UploadConfig struct {
|
||||
func defaults() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
Port: 8443,
|
||||
Name: "OwnCord Server",
|
||||
DataDir: "data",
|
||||
Port: 8443,
|
||||
Name: "OwnCord Server",
|
||||
DataDir: "data",
|
||||
AllowedOrigins: []string{"*"},
|
||||
TrustedProxies: []string{},
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: "data/chatserver.db",
|
||||
},
|
||||
TLS: TLSConfig{
|
||||
Mode: "self_signed",
|
||||
CertFile: "data/cert.pem",
|
||||
KeyFile: "data/key.pem",
|
||||
Mode: "self_signed",
|
||||
CertFile: "data/cert.pem",
|
||||
KeyFile: "data/key.pem",
|
||||
AcmeCacheDir: "data/acme_certs",
|
||||
},
|
||||
Upload: UploadConfig{
|
||||
MaxSizeMB: 100,
|
||||
@@ -98,6 +104,8 @@ server:
|
||||
port: 8443
|
||||
name: "OwnCord Server"
|
||||
data_dir: "data"
|
||||
# allowed_origins: ["*"] # restrict WebSocket origins, e.g. ["https://example.com"]
|
||||
# trusted_proxies: [] # CIDRs of trusted reverse proxies, e.g. ["10.0.0.0/8"]
|
||||
|
||||
database:
|
||||
path: "data/chatserver.db"
|
||||
@@ -106,7 +114,8 @@ tls:
|
||||
mode: "self_signed" # self_signed, acme, manual, off
|
||||
cert_file: "data/cert.pem"
|
||||
key_file: "data/key.pem"
|
||||
domain: ""
|
||||
domain: "" # required for acme mode (e.g. "chat.example.com")
|
||||
acme_cache_dir: "data/acme_certs" # where Let's Encrypt certs are cached
|
||||
|
||||
upload:
|
||||
max_size_mb: 100
|
||||
@@ -175,7 +184,7 @@ func Load(cfgPath string) (*Config, error) {
|
||||
|
||||
// validateYAML checks that raw bytes are valid YAML.
|
||||
func validateYAML(raw []byte) error {
|
||||
var v interface{}
|
||||
var v any
|
||||
return goyaml.Unmarshal(raw, &v)
|
||||
}
|
||||
|
||||
|
||||
+45
-14
@@ -4,14 +4,8 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ─── Permission constants ─────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
permAdministrator = int64(0x40000000)
|
||||
permManageServer = int64(0x2000000)
|
||||
permViewAuditLog = int64(0x8000000)
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
@@ -305,14 +299,51 @@ func (d *DB) GetAllSettings() (map[string]string, error) {
|
||||
|
||||
// ─── Backup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// BackupTo creates an online backup of the database at the given path using
|
||||
// SQLite's VACUUM INTO statement. The destination path must not already exist.
|
||||
// This only works meaningfully for file-backed databases; in-memory databases
|
||||
// will produce a valid but potentially minimal backup file.
|
||||
// BackupTo creates an online backup of the database using SQLite's VACUUM INTO.
|
||||
// The destination path must not already exist.
|
||||
//
|
||||
// Security: VACUUM INTO does not support bind parameters, so the path is
|
||||
// interpolated into SQL. To prevent injection we enforce two structural guards:
|
||||
// 1. The path must resolve to a location under safeRoot (after filepath.Clean
|
||||
// and filepath.Abs).
|
||||
// 2. After structural validation, any single-quote, semicolon, double-dash,
|
||||
// or null byte in the cleaned path causes rejection as defence-in-depth.
|
||||
//
|
||||
// The caller in handleBackup constructs the path from a hardcoded directory
|
||||
// and a timestamp — no user input reaches this function.
|
||||
func (d *DB) BackupTo(path string) error {
|
||||
_, err := d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", path))
|
||||
return d.BackupToSafe(path, filepath.Join("data", "backups"))
|
||||
}
|
||||
|
||||
// BackupToSafe is the internal implementation that accepts an explicit safe
|
||||
// root directory. Exported for testing with isolated directories.
|
||||
func (d *DB) BackupToSafe(path, safeRoot string) error {
|
||||
clean := filepath.Clean(path)
|
||||
|
||||
absRoot, err := filepath.Abs(safeRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("BackupTo: %w", err)
|
||||
return fmt.Errorf("BackupToSafe: resolving safe root: %w", err)
|
||||
}
|
||||
absClean, err := filepath.Abs(clean)
|
||||
if err != nil {
|
||||
return fmt.Errorf("BackupToSafe: resolving path: %w", err)
|
||||
}
|
||||
|
||||
// Structural guard: path must be under the safe root directory.
|
||||
if !strings.HasPrefix(absClean, absRoot+string(filepath.Separator)) {
|
||||
return fmt.Errorf("BackupToSafe: path %q is not under safe root %q", absClean, absRoot)
|
||||
}
|
||||
|
||||
// Defence-in-depth: reject characters that could break SQL quoting.
|
||||
for _, forbidden := range []string{"'", `"`, ";", "--", "\x00"} {
|
||||
if strings.Contains(clean, forbidden) {
|
||||
return fmt.Errorf("BackupToSafe: path contains forbidden sequence %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", clean))
|
||||
if err != nil {
|
||||
return fmt.Errorf("BackupToSafe: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -666,9 +666,9 @@ func TestGetAllSettings_AfterClearing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BackupTo ─────────────────────────────────────────────────────────────────
|
||||
// ─── BackupToSafe ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBackupTo(t *testing.T) {
|
||||
func TestBackupToSafe_AdminQueries(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "source.db")
|
||||
|
||||
@@ -685,9 +685,11 @@ func TestBackupTo(t *testing.T) {
|
||||
t.Fatalf("MigrateFS: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tmpDir, "backup.db")
|
||||
if err := database.BackupTo(backupPath); err != nil {
|
||||
t.Fatalf("BackupTo() error: %v", err)
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
os.MkdirAll(backupDir, 0o755)
|
||||
backupPath := filepath.Join(backupDir, "backup.db")
|
||||
if err := database.BackupToSafe(backupPath, backupDir); err != nil {
|
||||
t.Fatalf("BackupToSafe() error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(backupPath)
|
||||
@@ -699,7 +701,7 @@ func TestBackupTo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupTo_CreatesDirectoryFile(t *testing.T) {
|
||||
func TestBackupToSafe_CreatesDirectoryFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "src.db")
|
||||
|
||||
@@ -714,13 +716,12 @@ func TestBackupTo_CreatesDirectoryFile(t *testing.T) {
|
||||
}
|
||||
db.MigrateFS(database, migrFS)
|
||||
|
||||
// Create nested backup path
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
os.MkdirAll(backupDir, 0o755)
|
||||
backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db")
|
||||
|
||||
if err := database.BackupTo(backupPath); err != nil {
|
||||
t.Fatalf("BackupTo() error: %v", err)
|
||||
if err := database.BackupToSafe(backupPath, backupDir); err != nil {
|
||||
t.Fatalf("BackupToSafe() error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
|
||||
+26
-36
@@ -233,45 +233,35 @@ func (d *DB) GetInvite(code string) (*Invite, error) {
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// UseInvite increments use_count after validating the invite is usable.
|
||||
// Returns an error if the invite is revoked, expired, or has reached max uses.
|
||||
func (d *DB) UseInvite(code string) error {
|
||||
inv, err := d.GetInvite(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inv == nil {
|
||||
return errors.New("invite not found")
|
||||
}
|
||||
if inv.Revoked {
|
||||
return errors.New("invite has been revoked")
|
||||
}
|
||||
if inv.ExpiresAt != nil {
|
||||
// Try both SQLite datetime format and ISO-8601 format.
|
||||
var expires time.Time
|
||||
var parseErr error
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
|
||||
expires, parseErr = time.Parse(layout, *inv.ExpiresAt)
|
||||
if parseErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("parsing invite expiry: %w", parseErr)
|
||||
}
|
||||
if time.Now().UTC().After(expires) {
|
||||
return errors.New("invite has expired")
|
||||
}
|
||||
}
|
||||
if inv.MaxUses != nil && inv.Uses >= *inv.MaxUses {
|
||||
return errors.New("invite has reached its maximum uses")
|
||||
}
|
||||
_, err = d.sqlDB.Exec(
|
||||
`UPDATE invites SET use_count = use_count + 1 WHERE code = ?`,
|
||||
// UseInviteAtomic validates and increments the use_count in a single SQL
|
||||
// statement, eliminating the TOCTOU race that exists when GetInvite and
|
||||
// UseInvite are called as separate operations.
|
||||
//
|
||||
// The UPDATE only matches rows where:
|
||||
// - the code exists
|
||||
// - revoked = 0
|
||||
// - max_uses IS NULL (unlimited) OR uses < max_uses
|
||||
// - expires_at IS NULL (never) OR expires_at > now
|
||||
//
|
||||
// If zero rows are affected the invite is missing, revoked, expired, or
|
||||
// exhausted — an error is returned in all such cases.
|
||||
func (d *DB) UseInviteAtomic(code string) error {
|
||||
result, err := d.sqlDB.Exec(
|
||||
`UPDATE invites SET use_count = use_count + 1
|
||||
WHERE code = ? AND revoked = 0
|
||||
AND (max_uses IS NULL OR use_count < max_uses)
|
||||
AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now'))`,
|
||||
code,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UseInvite update: %w", err)
|
||||
return fmt.Errorf("UseInviteAtomic: %w", err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("UseInviteAtomic rows: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("UseInviteAtomic: invite not found, revoked, expired, or exhausted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+129
-52
@@ -389,58 +389,6 @@ func TestGetInvite_NotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseInvite_IncrementsUses(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("quinn", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 5, nil)
|
||||
|
||||
if err := database.UseInvite(code); err != nil {
|
||||
t.Fatalf("UseInvite: %v", err)
|
||||
}
|
||||
|
||||
inv, _ := database.GetInvite(code)
|
||||
if inv.Uses != 1 {
|
||||
t.Errorf("Uses = %d, want 1", inv.Uses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseInvite_ExceedsMaxUses(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("rachel", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 1, nil)
|
||||
|
||||
if err := database.UseInvite(code); err != nil {
|
||||
t.Fatalf("first UseInvite: %v", err)
|
||||
}
|
||||
// Second use should fail
|
||||
if err := database.UseInvite(code); err == nil {
|
||||
t.Error("UseInvite() returned nil error after exceeding max_uses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseInvite_Revoked(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("sam", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 0, nil)
|
||||
|
||||
database.RevokeInvite(code)
|
||||
if err := database.UseInvite(code); err == nil {
|
||||
t.Error("UseInvite() returned nil error for revoked invite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseInvite_Expired(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("tina", "hash", 4)
|
||||
|
||||
past := time.Now().Add(-time.Hour)
|
||||
code, _ := database.CreateInvite(uid, 0, &past)
|
||||
|
||||
if err := database.UseInvite(code); err == nil {
|
||||
t.Error("UseInvite() returned nil error for expired invite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeInvite(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("uma", "hash", 4)
|
||||
@@ -466,3 +414,132 @@ func TestCreateInvite_UnlimitedUses(t *testing.T) {
|
||||
t.Errorf("MaxUses = %v, want nil for unlimited", inv.MaxUses)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UseInviteAtomic tests ─────────────────────────────────────────────────────
|
||||
|
||||
// TestUseInviteAtomic_Success verifies a valid unlimited invite is accepted and
|
||||
// its use_count incremented in one operation.
|
||||
func TestUseInviteAtomic_Success(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("atomic_user1", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 0, nil)
|
||||
|
||||
if err := database.UseInviteAtomic(code); err != nil {
|
||||
t.Fatalf("UseInviteAtomic: %v", err)
|
||||
}
|
||||
|
||||
inv, _ := database.GetInvite(code)
|
||||
if inv.Uses != 1 {
|
||||
t.Errorf("Uses = %d, want 1", inv.Uses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUseInviteAtomic_IncrementsUses verifies the count advances correctly over
|
||||
// multiple sequential calls.
|
||||
func TestUseInviteAtomic_IncrementsUses(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("atomic_user2", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 5, nil)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := database.UseInviteAtomic(code); err != nil {
|
||||
t.Fatalf("UseInviteAtomic iteration %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
inv, _ := database.GetInvite(code)
|
||||
if inv.Uses != 3 {
|
||||
t.Errorf("Uses = %d, want 3", inv.Uses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUseInviteAtomic_Revoked returns an error for a revoked invite without
|
||||
// modifying the database.
|
||||
func TestUseInviteAtomic_Revoked(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("atomic_user3", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 0, nil)
|
||||
database.RevokeInvite(code)
|
||||
|
||||
if err := database.UseInviteAtomic(code); err == nil {
|
||||
t.Error("UseInviteAtomic returned nil error for revoked invite, want error")
|
||||
}
|
||||
|
||||
// use_count must not have changed.
|
||||
inv, _ := database.GetInvite(code)
|
||||
if inv.Uses != 0 {
|
||||
t.Errorf("Uses = %d after revoked attempt, want 0", inv.Uses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUseInviteAtomic_Expired returns an error for an expired invite.
|
||||
func TestUseInviteAtomic_Expired(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("atomic_user4", "hash", 4)
|
||||
|
||||
past := time.Now().Add(-time.Hour)
|
||||
code, _ := database.CreateInvite(uid, 0, &past)
|
||||
|
||||
if err := database.UseInviteAtomic(code); err == nil {
|
||||
t.Error("UseInviteAtomic returned nil error for expired invite, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUseInviteAtomic_ExceedsMaxUses returns an error when the invite has
|
||||
// reached its maximum use count.
|
||||
func TestUseInviteAtomic_ExceedsMaxUses(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("atomic_user5", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 1, nil)
|
||||
|
||||
if err := database.UseInviteAtomic(code); err != nil {
|
||||
t.Fatalf("UseInviteAtomic first use: %v", err)
|
||||
}
|
||||
if err := database.UseInviteAtomic(code); err == nil {
|
||||
t.Error("UseInviteAtomic returned nil error after exceeding max_uses, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUseInviteAtomic_NotFound returns an error for a completely unknown code.
|
||||
func TestUseInviteAtomic_NotFound(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
|
||||
if err := database.UseInviteAtomic("doesnotexist"); err == nil {
|
||||
t.Error("UseInviteAtomic returned nil error for unknown code, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUseInviteAtomic_ConcurrentSameCode simulates two goroutines racing to
|
||||
// redeem a single-use invite. Exactly one must succeed and exactly one must
|
||||
// fail; the use_count must end up at 1.
|
||||
func TestUseInviteAtomic_ConcurrentSameCode(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
uid, _ := database.CreateUser("atomic_user6", "hash", 4)
|
||||
code, _ := database.CreateInvite(uid, 1, nil)
|
||||
|
||||
type result struct{ err error }
|
||||
results := make(chan result, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
results <- result{err: database.UseInviteAtomic(code)}
|
||||
}()
|
||||
}
|
||||
|
||||
r1, r2 := <-results, <-results
|
||||
successes := 0
|
||||
if r1.err == nil {
|
||||
successes++
|
||||
}
|
||||
if r2.err == nil {
|
||||
successes++
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Errorf("concurrent redemptions: %d succeeded, want exactly 1", successes)
|
||||
}
|
||||
|
||||
inv, _ := database.GetInvite(code)
|
||||
if inv.Uses != 1 {
|
||||
t.Errorf("use_count = %d after concurrent race, want 1", inv.Uses)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// newBackupTestDB opens a file-backed database suitable for VACUUM INTO tests.
|
||||
// VACUUM INTO requires a file-backed source database; :memory: produces an
|
||||
// empty-but-valid backup file which is sufficient for validation tests.
|
||||
func newBackupFileDB(t *testing.T) (*db.DB, string) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "source.db")
|
||||
|
||||
database, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
migrFS := fstest.MapFS{
|
||||
"001_schema.sql": {Data: adminTestSchema},
|
||||
}
|
||||
if err := db.MigrateFS(database, migrFS); err != nil {
|
||||
t.Fatalf("MigrateFS: %v", err)
|
||||
}
|
||||
return database, tmpDir
|
||||
}
|
||||
|
||||
// ─── BackupToSafe path-validation tests ─────────────────────────────────────
|
||||
|
||||
// TestBackupToSafe_ValidPath verifies a properly-named backup file is created.
|
||||
func TestBackupToSafe_ValidPath(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
backupPath := filepath.Join(backupDir, "chatserver_20260315_120000.db")
|
||||
if err := database.BackupToSafe(backupPath, backupDir); err != nil {
|
||||
t.Fatalf("BackupToSafe() with valid path returned error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("backup file does not exist after BackupToSafe: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Error("backup file is empty, expected non-empty SQLite file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupToSafe_RejectsPathOutsideRoot ensures a path outside the safe root
|
||||
// is rejected.
|
||||
func TestBackupToSafe_RejectsPathOutsideRoot(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
// Try to write outside backupDir
|
||||
escapePath := filepath.Join(tmpDir, "escaped.db")
|
||||
err := database.BackupToSafe(escapePath, backupDir)
|
||||
if err == nil {
|
||||
t.Error("BackupToSafe() should reject path outside safe root, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupToSafe_RejectsSingleQuote ensures a path containing a single-quote
|
||||
// is rejected before the SQL is executed (prevents SQL injection).
|
||||
func TestBackupToSafe_RejectsSingleQuote(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
malicious := filepath.Join(backupDir, "evil'.db")
|
||||
err := database.BackupToSafe(malicious, backupDir)
|
||||
if err == nil {
|
||||
t.Error("BackupToSafe() with single-quote in path should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupToSafe_RejectsSemicolon ensures a semicolon in the path is rejected.
|
||||
func TestBackupToSafe_RejectsSemicolon(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
malicious := filepath.Join(backupDir, "evil;drop.db")
|
||||
err := database.BackupToSafe(malicious, backupDir)
|
||||
if err == nil {
|
||||
t.Error("BackupToSafe() with semicolon in path should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupToSafe_RejectsSQLComment ensures a path containing "--" is rejected.
|
||||
func TestBackupToSafe_RejectsSQLComment(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
malicious := filepath.Join(backupDir, "evil--comment.db")
|
||||
err := database.BackupToSafe(malicious, backupDir)
|
||||
if err == nil {
|
||||
t.Error("BackupToSafe() with '--' in path should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupToSafe_RejectsNullByte ensures a path containing a null byte is rejected.
|
||||
func TestBackupToSafe_RejectsNullByte(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
malicious := filepath.Join(backupDir, "evil\x00.db")
|
||||
err := database.BackupToSafe(malicious, backupDir)
|
||||
if err == nil {
|
||||
t.Error("BackupToSafe() with null byte in path should return error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackupToSafe_RejectsDoubleQuote ensures a path containing a double-quote
|
||||
// is rejected.
|
||||
func TestBackupToSafe_RejectsDoubleQuote(t *testing.T) {
|
||||
database, tmpDir := newBackupFileDB(t)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
malicious := filepath.Join(backupDir, `evil".db`)
|
||||
err := database.BackupToSafe(malicious, backupDir)
|
||||
if err == nil {
|
||||
t.Error("BackupToSafe() with double-quote in path should return error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,18 @@ func (d *DB) UpdateChannel(id int64, name, topic string, slowMode int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetChannelSlowMode updates only the slow_mode field for the given channel.
|
||||
func (d *DB) SetChannelSlowMode(id int64, slowMode int) error {
|
||||
_, err := d.sqlDB.Exec(
|
||||
`UPDATE channels SET slow_mode = ? WHERE id = ?`,
|
||||
slowMode, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetChannelSlowMode: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChannel removes the channel row (cascades to messages, overrides, etc.).
|
||||
func (d *DB) DeleteChannel(id int64) error {
|
||||
_, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id)
|
||||
@@ -128,7 +140,7 @@ func scanChannel(rows *sql.Rows) (Channel, error) {
|
||||
|
||||
// nullableString returns nil when s is empty, otherwise a pointer to s.
|
||||
// Used so empty strings are stored as NULL in optional TEXT columns.
|
||||
func nullableString(s string) interface{} {
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
+6
-39
@@ -5,9 +5,6 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/owncord/server/migrations"
|
||||
_ "modernc.org/sqlite" // register the sqlite3 driver
|
||||
@@ -54,60 +51,30 @@ func Open(path string) (*DB, error) {
|
||||
}
|
||||
|
||||
// Migrate runs all SQL migration files from the embedded migrations FS in
|
||||
// lexicographic order. It is idempotent — SQL uses IF NOT EXISTS / INSERT OR
|
||||
// IGNORE, so re-running is safe.
|
||||
// lexicographic order, applying each file exactly once. It delegates to
|
||||
// MigrateFS (defined in migrate.go) which maintains the schema_versions
|
||||
// tracking table.
|
||||
func Migrate(database *DB) error {
|
||||
return MigrateFS(database, migrations.FS)
|
||||
}
|
||||
|
||||
// MigrateFS runs all *.sql files from the given FS in sorted order.
|
||||
// This is exposed for testing with custom FS implementations.
|
||||
func MigrateFS(database *DB, fsys fs.FS) error {
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading migrations dir: %w", err)
|
||||
}
|
||||
|
||||
// Sort files to ensure deterministic order.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, readErr := fs.ReadFile(fsys, entry.Name())
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("reading migration %s: %w", entry.Name(), readErr)
|
||||
}
|
||||
|
||||
if _, execErr := database.sqlDB.Exec(string(raw)); execErr != nil {
|
||||
return fmt.Errorf("executing migration %s: %w", entry.Name(), execErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database connection.
|
||||
func (d *DB) Close() error {
|
||||
return d.sqlDB.Close()
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns at most one row.
|
||||
func (d *DB) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
|
||||
return d.sqlDB.QueryRow(query, args...)
|
||||
}
|
||||
|
||||
// Exec executes a query that doesn't return rows.
|
||||
func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return d.sqlDB.Exec(query, args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns multiple rows.
|
||||
func (d *DB) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return d.sqlDB.Query(query, args...)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package db
|
||||
|
||||
// migrate.go — tracked migration runner for the OwnCord server.
|
||||
//
|
||||
// Each .sql file in the provided FS is applied exactly once. The
|
||||
// schema_versions table records every applied migration filename and the UTC
|
||||
// timestamp at which it was applied.
|
||||
//
|
||||
// Seeding for existing databases
|
||||
// --------------------------------
|
||||
// When the server is first upgraded to include migration tracking, existing
|
||||
// databases will have all schema tables in place but no schema_versions table.
|
||||
// Without seeding, every migration would re-run and could destroy data.
|
||||
//
|
||||
// The seeding heuristic: if schema_versions does not exist AND the "users"
|
||||
// table already exists, we assume all migrations in the current FS have
|
||||
// already been applied. We create schema_versions and insert every migration
|
||||
// filename without executing the SQL, so subsequent runs treat them as done.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const createSchemaVersions = `
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`
|
||||
|
||||
// ensureSchemaVersions creates the tracking table if it does not yet exist.
|
||||
func ensureSchemaVersions(d *DB) error {
|
||||
if _, err := d.sqlDB.Exec(createSchemaVersions); err != nil {
|
||||
return fmt.Errorf("creating schema_versions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isExistingDatabase reports whether the database was previously migrated
|
||||
// without tracking — detected by the presence of the "users" table.
|
||||
func isExistingDatabase(d *DB) (bool, error) {
|
||||
var name string
|
||||
err := d.sqlDB.QueryRow(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'",
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
// sql.ErrNoRows means the table does not exist.
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// schemaVersionsExists reports whether the schema_versions table is present.
|
||||
func schemaVersionsExists(d *DB) (bool, error) {
|
||||
var name string
|
||||
err := d.sqlDB.QueryRow(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'",
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isApplied reports whether a migration filename has already been recorded.
|
||||
func isApplied(d *DB, filename string) (bool, error) {
|
||||
var v string
|
||||
err := d.sqlDB.QueryRow(
|
||||
"SELECT version FROM schema_versions WHERE version = ?", filename,
|
||||
).Scan(&v)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// recordApplied inserts a migration filename into schema_versions.
|
||||
func recordApplied(d *DB, filename string) error {
|
||||
_, err := d.sqlDB.Exec(
|
||||
"INSERT INTO schema_versions (version) VALUES (?)", filename,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording migration %s: %w", filename, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sqlFilenames returns all .sql entries from the FS sorted lexicographically.
|
||||
func sqlFilenames(fsys fs.FS) ([]string, error) {
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading migrations dir: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// seedExistingDatabase inserts all migration filenames into schema_versions
|
||||
// without executing them. This is called once when upgrading a pre-tracking
|
||||
// database.
|
||||
func seedExistingDatabase(d *DB, filenames []string) error {
|
||||
for _, name := range filenames {
|
||||
if err := recordApplied(d, name); err != nil {
|
||||
return fmt.Errorf("seeding %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateFS runs tracked migrations from the provided FS.
|
||||
//
|
||||
// Behaviour:
|
||||
// 1. Create schema_versions if absent.
|
||||
// 2. If this is the first run with tracking on an existing database (users
|
||||
// table exists but schema_versions was just created), seed all filenames
|
||||
// so they are not re-executed.
|
||||
// 3. For each .sql file in lexicographic order: skip if already recorded,
|
||||
// otherwise execute the SQL and record the filename.
|
||||
func MigrateFS(database *DB, fsys fs.FS) error {
|
||||
// Determine tracking state before we create schema_versions.
|
||||
svExists, err := schemaVersionsExists(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the tracking table (idempotent).
|
||||
if err := ensureSchemaVersions(database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect filenames first — needed for both seeding and normal application.
|
||||
filenames, err := sqlFilenames(fsys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seeding path: schema_versions did not exist AND users table does, which
|
||||
// means this is an existing database being upgraded to tracked migrations.
|
||||
if !svExists {
|
||||
existing, checkErr := isExistingDatabase(database)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
if existing {
|
||||
return seedExistingDatabase(database, filenames)
|
||||
}
|
||||
}
|
||||
|
||||
// Normal path: apply any migration not yet recorded.
|
||||
for _, name := range filenames {
|
||||
applied, applyErr := isApplied(database, name)
|
||||
if applyErr != nil {
|
||||
return applyErr
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, readErr := fs.ReadFile(fsys, name)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("reading migration %s: %w", name, readErr)
|
||||
}
|
||||
|
||||
if _, execErr := database.sqlDB.Exec(string(raw)); execErr != nil {
|
||||
return fmt.Errorf("executing migration %s: %w", name, execErr)
|
||||
}
|
||||
|
||||
if err := recordApplied(database, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
package db_test
|
||||
|
||||
// migrate_test.go — TDD tests for the tracked migration system.
|
||||
//
|
||||
// RED phase: these tests are written before the implementation exists.
|
||||
// They verify the contract of MigrateFS after it gains schema_versions tracking.
|
||||
//
|
||||
// Test matrix:
|
||||
// TestMigrate_SchemaVersionsTableCreated — schema_versions exists after first run
|
||||
// TestMigrate_AllMigrationsRecorded — every applied file is recorded
|
||||
// TestMigrate_SkipsAlreadyApplied — second call skips files already in schema_versions
|
||||
// TestMigrate_AppliesNewMigrationsOnly — only new files are applied on subsequent runs
|
||||
// TestMigrate_OrderIsLexicographic — migrations execute in sorted filename order
|
||||
// TestMigrate_SeedExistingDatabase — existing DB (no schema_versions) is seeded
|
||||
// TestMigrate_SeedDoesNotReRunMigrations — seeded migrations are not re-executed
|
||||
// TestMigrate_SchemaVersionsAppliedAtRecorded — applied_at column is populated
|
||||
// TestMigrate_EmptyFSSucceeds — empty FS is fine, no error
|
||||
// TestMigrate_InvalidSQLReturnsError — bad SQL still surfaces as an error
|
||||
// TestMigrate_ReadFileErrorReturnsError — FS read failure surfaces as an error
|
||||
// TestMigrate_PartialRunRecordsOnlyApplied — failure mid-run leaves earlier files recorded
|
||||
// TestMigrate_AppliedAtIsISO8601 — applied_at timestamp format is valid
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// failReadDirFS is an fs.FS whose root Open succeeds but ReadDir always errors.
|
||||
// This exercises the sqlFilenames ReadDir error path.
|
||||
type failReadDirFS struct{}
|
||||
|
||||
func (failReadDirFS) Open(name string) (fs.File, error) {
|
||||
if name == "." {
|
||||
return &badDirFile{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no files")
|
||||
}
|
||||
|
||||
type badDirFile struct{}
|
||||
|
||||
func (badDirFile) Read([]byte) (int, error) { return 0, fmt.Errorf("not a file") }
|
||||
func (badDirFile) Close() error { return nil }
|
||||
func (badDirFile) Stat() (fs.FileInfo, error) { return fakeDirInfo{}, nil }
|
||||
func (badDirFile) ReadDir(int) ([]fs.DirEntry, error) {
|
||||
return nil, fmt.Errorf("readdir always fails")
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
|
||||
// countVersions returns the number of rows in schema_versions.
|
||||
func countVersions(t *testing.T, database *db.DB) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := database.QueryRow("SELECT COUNT(*) FROM schema_versions").Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("counting schema_versions: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// hasVersion reports whether a specific filename is recorded in schema_versions.
|
||||
func hasVersion(t *testing.T, database *db.DB, filename string) bool {
|
||||
t.Helper()
|
||||
var v string
|
||||
err := database.QueryRow(
|
||||
"SELECT version FROM schema_versions WHERE version = ?", filename,
|
||||
).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("querying schema_versions for %q: %v", filename, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tableExists reports whether a table (or virtual table) exists in sqlite_master.
|
||||
func tableExists(t *testing.T, database *db.DB, name string) bool {
|
||||
t.Helper()
|
||||
var n string
|
||||
err := database.QueryRow(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", name,
|
||||
).Scan(&n)
|
||||
if err == sql.ErrNoRows {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("checking table %q: %v", name, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// simpleFS builds an fstest.MapFS with the provided filename→SQL pairs.
|
||||
func simpleFS(pairs ...string) fstest.MapFS {
|
||||
if len(pairs)%2 != 0 {
|
||||
panic("simpleFS requires an even number of arguments (name, sql, ...)")
|
||||
}
|
||||
m := fstest.MapFS{}
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
m[pairs[i]] = &fstest.MapFile{Data: []byte(pairs[i+1])}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- tests ------------------------------------------------------------------
|
||||
|
||||
// TestMigrate_SchemaVersionsTableCreated verifies that MigrateFS creates the
|
||||
// schema_versions tracking table on first run.
|
||||
func TestMigrate_SchemaVersionsTableCreated(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_create_foo.sql", "CREATE TABLE IF NOT EXISTS foo (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
if !tableExists(t, database, "schema_versions") {
|
||||
t.Error("schema_versions table was not created by MigrateFS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_AllMigrationsRecorded verifies that every applied .sql file
|
||||
// gets a row inserted into schema_versions.
|
||||
func TestMigrate_AllMigrationsRecorded(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_alpha.sql", "CREATE TABLE IF NOT EXISTS alpha (id INTEGER PRIMARY KEY);",
|
||||
"002_beta.sql", "CREATE TABLE IF NOT EXISTS beta (id INTEGER PRIMARY KEY);",
|
||||
"003_gamma.sql", "CREATE TABLE IF NOT EXISTS gamma (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"001_alpha.sql", "002_beta.sql", "003_gamma.sql"} {
|
||||
if !hasVersion(t, database, name) {
|
||||
t.Errorf("migration %q not recorded in schema_versions", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SkipsAlreadyApplied verifies that a second call to MigrateFS
|
||||
// with the same FS does not re-execute already-applied migrations.
|
||||
func TestMigrate_SkipsAlreadyApplied(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
// This migration inserts a row; if re-run it would violate UNIQUE.
|
||||
fsys := simpleFS(
|
||||
"001_unique.sql", `
|
||||
CREATE TABLE IF NOT EXISTS unique_check (val TEXT UNIQUE);
|
||||
INSERT INTO unique_check (val) VALUES ('singleton');
|
||||
`,
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() first run error: %v", err)
|
||||
}
|
||||
|
||||
// Second run — must not fail even though the INSERT would conflict if re-run.
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() second run error (migration was re-executed): %v", err)
|
||||
}
|
||||
|
||||
// Confirm the row exists exactly once.
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM unique_check WHERE val='singleton'").Scan(&count); err != nil {
|
||||
t.Fatalf("counting unique_check: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("unique_check has %d rows, want exactly 1 — migration was re-run", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_AppliesNewMigrationsOnly verifies that when a new file is added
|
||||
// to the FS, only that file is applied on the second call.
|
||||
func TestMigrate_AppliesNewMigrationsOnly(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsFirst := simpleFS(
|
||||
"001_base.sql", "CREATE TABLE IF NOT EXISTS base_tbl (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsFirst); err != nil {
|
||||
t.Fatalf("MigrateFS() first run error: %v", err)
|
||||
}
|
||||
|
||||
versionsAfterFirst := countVersions(t, database)
|
||||
|
||||
// Add a second migration.
|
||||
fsSecond := simpleFS(
|
||||
"001_base.sql", "CREATE TABLE IF NOT EXISTS base_tbl (id INTEGER PRIMARY KEY);",
|
||||
"002_extra.sql", "CREATE TABLE IF NOT EXISTS extra_tbl (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsSecond); err != nil {
|
||||
t.Fatalf("MigrateFS() second run error: %v", err)
|
||||
}
|
||||
|
||||
versionsAfterSecond := countVersions(t, database)
|
||||
|
||||
if versionsAfterSecond != versionsAfterFirst+1 {
|
||||
t.Errorf(
|
||||
"expected %d version rows after second run, got %d",
|
||||
versionsAfterFirst+1, versionsAfterSecond,
|
||||
)
|
||||
}
|
||||
|
||||
if !hasVersion(t, database, "002_extra.sql") {
|
||||
t.Error("002_extra.sql not recorded after second run")
|
||||
}
|
||||
|
||||
if !tableExists(t, database, "extra_tbl") {
|
||||
t.Error("extra_tbl not created by second run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_OrderIsLexicographic verifies that migrations are applied in
|
||||
// sorted filename order, not insertion or readdir order.
|
||||
func TestMigrate_OrderIsLexicographic(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
// 002 creates the table; 001 tries to insert into it.
|
||||
// If run out of order (002 before 001) the INSERT would fail with "no such table".
|
||||
// With lexicographic ordering 001 runs first and creates the table,
|
||||
// then 002 inserts into it — so we verify the correct order by checking
|
||||
// the table was created before the insert was attempted.
|
||||
fsys := simpleFS(
|
||||
"002_insert.sql", "INSERT INTO order_check (label) VALUES ('second');",
|
||||
"001_create.sql", "CREATE TABLE IF NOT EXISTS order_check (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
var label string
|
||||
if err := database.QueryRow("SELECT label FROM order_check LIMIT 1").Scan(&label); err != nil {
|
||||
t.Fatalf("selecting from order_check: %v", err)
|
||||
}
|
||||
if label != "second" {
|
||||
t.Errorf("label = %q, want 'second'", label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SeedExistingDatabase verifies that when schema_versions does not
|
||||
// exist but other known tables do (simulating an existing DB from before
|
||||
// tracking was added), all current migration filenames are seeded so they are
|
||||
// not re-executed.
|
||||
func TestMigrate_SeedExistingDatabase(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
// Manually create a table to simulate a previously-migrated database
|
||||
// that does not yet have schema_versions.
|
||||
if _, err := database.Exec(
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);",
|
||||
); err != nil {
|
||||
t.Fatalf("setup: creating users table: %v", err)
|
||||
}
|
||||
|
||||
// This migration would drop and recreate users; if it runs it will wipe data.
|
||||
// The seeding logic must prevent it from running.
|
||||
fsys := simpleFS(
|
||||
"001_initial.sql", `
|
||||
DROP TABLE IF EXISTS users;
|
||||
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);
|
||||
`,
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
// The migration must be recorded (seeded).
|
||||
if !hasVersion(t, database, "001_initial.sql") {
|
||||
t.Error("001_initial.sql should be seeded into schema_versions for existing DB")
|
||||
}
|
||||
|
||||
// The users table must still have its original schema (no 'name' column),
|
||||
// proving the DROP/CREATE did not run.
|
||||
_, err := database.Exec("INSERT INTO users (id) VALUES (42)")
|
||||
if err != nil {
|
||||
t.Errorf("users table appears to have been recreated (DROP ran): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SeedDoesNotReRunMigrations is a companion to the seeding test:
|
||||
// after seeding, a subsequent MigrateFS call with the same FS must be a no-op.
|
||||
// The seeding heuristic triggers on the presence of the "users" sentinel table,
|
||||
// so we create that table to simulate a pre-tracking database.
|
||||
func TestMigrate_SeedDoesNotReRunMigrations(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
// Simulate an existing DB: create the "users" sentinel table so the seeding
|
||||
// heuristic fires, plus the table that the migration would modify.
|
||||
if _, err := database.Exec(
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);",
|
||||
); err != nil {
|
||||
t.Fatalf("setup users: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(
|
||||
"CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);",
|
||||
); err != nil {
|
||||
t.Fatalf("setup existing: %v", err)
|
||||
}
|
||||
|
||||
// This migration would INSERT into existing; if it runs, count becomes 1.
|
||||
fsys := simpleFS(
|
||||
"001_existing.sql", `
|
||||
CREATE TABLE IF NOT EXISTS existing (id INTEGER PRIMARY KEY);
|
||||
INSERT INTO existing (id) VALUES (1);
|
||||
`,
|
||||
)
|
||||
|
||||
// First call — seeds because schema_versions is absent AND users table exists.
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() first run error: %v", err)
|
||||
}
|
||||
|
||||
// Second call — must be a no-op (migration is already recorded).
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() second run error: %v", err)
|
||||
}
|
||||
|
||||
// existing table should be empty — the INSERT was never executed (seeded only).
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM existing").Scan(&count); err != nil {
|
||||
t.Fatalf("counting existing: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("existing has %d rows, want 0 — seeded migration was re-executed", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SchemaVersionsAppliedAtRecorded verifies that applied_at is
|
||||
// populated for every recorded migration.
|
||||
func TestMigrate_SchemaVersionsAppliedAtRecorded(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_ts.sql", "CREATE TABLE IF NOT EXISTS ts_test (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
var appliedAt string
|
||||
err := database.QueryRow(
|
||||
"SELECT applied_at FROM schema_versions WHERE version = '001_ts.sql'",
|
||||
).Scan(&appliedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("querying applied_at: %v", err)
|
||||
}
|
||||
if appliedAt == "" {
|
||||
t.Error("applied_at should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_AppliedAtIsISO8601 verifies applied_at is a parseable datetime.
|
||||
func TestMigrate_AppliedAtIsISO8601(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_dt.sql", "CREATE TABLE IF NOT EXISTS dt_test (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
var appliedAt string
|
||||
if err := database.QueryRow(
|
||||
"SELECT applied_at FROM schema_versions WHERE version = '001_dt.sql'",
|
||||
).Scan(&appliedAt); err != nil {
|
||||
t.Fatalf("querying applied_at: %v", err)
|
||||
}
|
||||
|
||||
// SQLite datetime('now') produces "YYYY-MM-DD HH:MM:SS".
|
||||
formats := []string{
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
}
|
||||
var parsed bool
|
||||
for _, f := range formats {
|
||||
if _, err := time.Parse(f, appliedAt); err == nil {
|
||||
parsed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !parsed {
|
||||
t.Errorf("applied_at %q is not a recognised datetime format", appliedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_EmptyFSSucceeds verifies that an empty FS returns no error and
|
||||
// still creates the schema_versions table.
|
||||
func TestMigrate_EmptyFSSucceeds(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := fstest.MapFS{}
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() with empty FS error: %v", err)
|
||||
}
|
||||
|
||||
if !tableExists(t, database, "schema_versions") {
|
||||
t.Error("schema_versions should be created even for empty FS")
|
||||
}
|
||||
|
||||
if countVersions(t, database) != 0 {
|
||||
t.Error("schema_versions should be empty for empty FS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_InvalidSQLReturnsError verifies that a migration with invalid
|
||||
// SQL causes MigrateFS to return a non-nil error.
|
||||
func TestMigrate_InvalidSQLReturnsError(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_bad.sql", "THIS IS NOT VALID SQL !!!@@@###",
|
||||
)
|
||||
|
||||
err := db.MigrateFS(database, fsys)
|
||||
if err == nil {
|
||||
t.Error("MigrateFS() should return error for invalid SQL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_ReadFileErrorReturnsError verifies that an FS read failure
|
||||
// surfaces as an error from MigrateFS.
|
||||
func TestMigrate_ReadFileErrorReturnsError(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
err := db.MigrateFS(database, failReadFS{})
|
||||
if err == nil {
|
||||
t.Error("MigrateFS() should return error when ReadFile fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_PartialRunRecordsOnlyApplied verifies that if the second
|
||||
// migration in a set fails, only the first is recorded in schema_versions.
|
||||
func TestMigrate_PartialRunRecordsOnlyApplied(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_good.sql", "CREATE TABLE IF NOT EXISTS partial_good (id INTEGER PRIMARY KEY);",
|
||||
"002_bad.sql", "THIS IS DEFINITELY NOT SQL;",
|
||||
)
|
||||
|
||||
_ = db.MigrateFS(database, fsys) // we expect an error; ignore it here
|
||||
|
||||
if !hasVersion(t, database, "001_good.sql") {
|
||||
t.Error("001_good.sql should be recorded even though 002 failed")
|
||||
}
|
||||
if hasVersion(t, database, "002_bad.sql") {
|
||||
t.Error("002_bad.sql should NOT be recorded because it failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_NonSQLFilesSkipped verifies that files without a .sql extension
|
||||
// are skipped and not recorded in schema_versions.
|
||||
func TestMigrate_NonSQLFilesSkipped(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"README.md": {Data: []byte("not sql")},
|
||||
"001_ok.sql": {Data: []byte("CREATE TABLE IF NOT EXISTS ns_test (id INTEGER PRIMARY KEY);")},
|
||||
"002_ok.go": {Data: []byte("package migrations")},
|
||||
}
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
if hasVersion(t, database, "README.md") {
|
||||
t.Error("README.md should not be recorded in schema_versions")
|
||||
}
|
||||
if hasVersion(t, database, "002_ok.go") {
|
||||
t.Error("002_ok.go should not be recorded in schema_versions")
|
||||
}
|
||||
if !hasVersion(t, database, "001_ok.sql") {
|
||||
t.Error("001_ok.sql should be recorded in schema_versions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_WithRealMigrations is an integration smoke test: run the
|
||||
// production migration set through the tracked MigrateFS and verify the
|
||||
// schema_versions table contains exactly one row per .sql file in the FS.
|
||||
func TestMigrate_WithRealMigrations(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("Migrate() error: %v", err)
|
||||
}
|
||||
|
||||
if !tableExists(t, database, "schema_versions") {
|
||||
t.Fatal("schema_versions not created by Migrate()")
|
||||
}
|
||||
|
||||
// Count .sql files in the embedded FS by running Migrate again (no-op) and
|
||||
// inspecting the version count. We just verify the count is > 0.
|
||||
n := countVersions(t, database)
|
||||
if n == 0 {
|
||||
t.Error("schema_versions is empty after running production migrations")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_WithRealMigrationsIdempotent verifies the production migration
|
||||
// set can be run twice without error via the tracked path.
|
||||
func TestMigrate_WithRealMigrationsIdempotent(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("Migrate() first run error: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("Migrate() second run error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SeedDetectionUsesKnownTable verifies the seeding heuristic: it
|
||||
// must detect an existing DB by the presence of a known table (e.g. "users"),
|
||||
// not by an arbitrary table name.
|
||||
func TestMigrate_SeedDetectionUsesKnownTable(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
// Create only an unrelated table — not one of the known sentinel tables.
|
||||
if _, err := database.Exec(
|
||||
"CREATE TABLE IF NOT EXISTS unrelated (id INTEGER PRIMARY KEY);",
|
||||
); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_new.sql", "CREATE TABLE IF NOT EXISTS new_table (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
// Since "users" table was absent, seeding must NOT have occurred and the
|
||||
// migration must have actually been applied.
|
||||
if !tableExists(t, database, "new_table") {
|
||||
t.Error("new_table should exist — migration was not seeded, so it must have run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_ErrorMessageContainsFilename verifies that when a migration
|
||||
// fails, the returned error message includes the filename for easier debugging.
|
||||
func TestMigrate_ErrorMessageContainsFilename(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"042_broken.sql", "INVALID SQL STATEMENT;",
|
||||
)
|
||||
|
||||
err := db.MigrateFS(database, fsys)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "042_broken.sql") {
|
||||
t.Errorf("error %q does not mention the failing filename", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_ReadDirErrorReturnsError verifies that when the FS returns an
|
||||
// error from ReadDir, MigrateFS propagates it as a non-nil error.
|
||||
func TestMigrate_ReadDirErrorReturnsError(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
err := db.MigrateFS(database, failReadDirFS{})
|
||||
if err == nil {
|
||||
t.Error("MigrateFS() should return error when ReadDir fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SchemaVersionsHasPrimaryKey verifies the schema_versions table
|
||||
// uses version as PRIMARY KEY, preventing duplicate rows for the same file.
|
||||
func TestMigrate_SchemaVersionsHasPrimaryKey(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_pk.sql", "CREATE TABLE IF NOT EXISTS pk_test (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
// Attempting a duplicate insert must fail.
|
||||
_, err := database.Exec(
|
||||
"INSERT INTO schema_versions (version, applied_at) VALUES ('001_pk.sql', datetime('now'))",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("duplicate insert into schema_versions should fail — PRIMARY KEY not enforced")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_SeedRecordsAllFilesFromFS verifies that seeding writes a row for
|
||||
// every .sql file in the FS, including when there are multiple files.
|
||||
func TestMigrate_SeedRecordsAllFilesFromFS(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
// Create the users sentinel to trigger seeding on first call.
|
||||
if _, err := database.Exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY);"); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_a.sql", "CREATE TABLE IF NOT EXISTS seed_a (id INTEGER PRIMARY KEY);",
|
||||
"002_b.sql", "CREATE TABLE IF NOT EXISTS seed_b (id INTEGER PRIMARY KEY);",
|
||||
"003_c.sql", "CREATE TABLE IF NOT EXISTS seed_c (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"001_a.sql", "002_b.sql", "003_c.sql"} {
|
||||
if !hasVersion(t, database, name) {
|
||||
t.Errorf("%q not seeded into schema_versions", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Tables from the seeded migrations must NOT have been created
|
||||
// (seeding records without executing).
|
||||
for _, tbl := range []string{"seed_a", "seed_b", "seed_c"} {
|
||||
if tableExists(t, database, tbl) {
|
||||
t.Errorf("table %q should not exist — migration was seeded, not executed", tbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_FreshDatabaseRunsAllMigrations verifies that a completely fresh
|
||||
// database (no tables at all) runs every migration without seeding.
|
||||
func TestMigrate_FreshDatabaseRunsAllMigrations(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
fsys := simpleFS(
|
||||
"001_fresh.sql", "CREATE TABLE IF NOT EXISTS fresh_a (id INTEGER PRIMARY KEY);",
|
||||
"002_fresh.sql", "CREATE TABLE IF NOT EXISTS fresh_b (id INTEGER PRIMARY KEY);",
|
||||
)
|
||||
|
||||
if err := db.MigrateFS(database, fsys); err != nil {
|
||||
t.Fatalf("MigrateFS() error: %v", err)
|
||||
}
|
||||
|
||||
for _, tbl := range []string{"fresh_a", "fresh_b"} {
|
||||
if !tableExists(t, database, tbl) {
|
||||
t.Errorf("table %q should exist on fresh database run", tbl)
|
||||
}
|
||||
}
|
||||
|
||||
if countVersions(t, database) != 2 {
|
||||
t.Errorf("expected 2 version rows, got %d", countVersions(t, database))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrate_LargeNumberOfMigrations verifies that MigrateFS can handle
|
||||
// a large set of migrations without degrading or skipping any.
|
||||
func TestMigrate_LargeNumberOfMigrations(t *testing.T) {
|
||||
database := openMemory(t)
|
||||
|
||||
const n = 50
|
||||
pairs := make([]string, 0, n*2)
|
||||
for i := 1; i <= n; i++ {
|
||||
name := fmt.Sprintf("%03d_large.sql", i)
|
||||
sql := fmt.Sprintf("CREATE TABLE IF NOT EXISTS large_%03d (id INTEGER PRIMARY KEY);", i)
|
||||
pairs = append(pairs, name, sql)
|
||||
}
|
||||
|
||||
if err := db.MigrateFS(database, simpleFS(pairs...)); err != nil {
|
||||
t.Fatalf("MigrateFS() error with %d migrations: %v", n, err)
|
||||
}
|
||||
|
||||
got := countVersions(t, database)
|
||||
if got != n {
|
||||
t.Errorf("schema_versions has %d rows, want %d", got, n)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+4
-2
@@ -62,11 +62,13 @@ golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
|
||||
+121
-8
@@ -6,10 +6,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -29,6 +33,7 @@ func main() {
|
||||
}))
|
||||
|
||||
if err := run(log); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err)
|
||||
log.Error("server exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -53,11 +58,6 @@ func run(log *slog.Logger) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading config: %w", err)
|
||||
}
|
||||
log.Info("configuration loaded",
|
||||
"server_name", cfg.Server.Name,
|
||||
"port", cfg.Server.Port,
|
||||
"tls_mode", cfg.TLS.Mode,
|
||||
)
|
||||
|
||||
// ── 2. Ensure data directory exists ────────────────────────────────────
|
||||
if mkdirErr := os.MkdirAll(cfg.Server.DataDir, 0o755); mkdirErr != nil {
|
||||
@@ -74,13 +74,12 @@ func run(log *slog.Logger) error {
|
||||
if err := db.Migrate(database); err != nil {
|
||||
return fmt.Errorf("running migrations: %w", err)
|
||||
}
|
||||
log.Info("database ready", "path", cfg.Database.Path)
|
||||
|
||||
// ── 4. TLS ─────────────────────────────────────────────────────────────
|
||||
tlsCfg, err := auth.LoadOrGenerate(cfg.TLS)
|
||||
tlsResult, err := auth.LoadOrGenerate(cfg.TLS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuring TLS: %w", err)
|
||||
}
|
||||
tlsCfg := tlsResult.TLSConfig
|
||||
|
||||
// ── 5. Build HTTP router ───────────────────────────────────────────────
|
||||
router := api.NewRouter(cfg, database, version)
|
||||
@@ -94,12 +93,53 @@ func run(log *slog.Logger) error {
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ErrorLog: stdlog.New(io.Discard, "", 0), // suppress TLS handshake noise
|
||||
}
|
||||
|
||||
// ── 6b. ACME HTTP challenge server on :80 ─────────────────────────────
|
||||
// When using Let's Encrypt (tls.mode: acme), an HTTP server on port 80
|
||||
// is needed for HTTP-01 challenge validation and HTTP→HTTPS redirect.
|
||||
var acmeSrv *http.Server
|
||||
if tlsResult.HTTPHandler != nil {
|
||||
acmeSrv = &http.Server{
|
||||
Addr: ":80",
|
||||
Handler: tlsResult.HTTPHandler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
log.Info("ACME HTTP challenge server starting on :80")
|
||||
if err := acmeSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("ACME HTTP server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ── 7. Background maintenance ────────────────────────────────────────
|
||||
// Periodically purge expired sessions to prevent unbounded growth.
|
||||
stopMaintenance := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := database.DeleteExpiredSessions(); err != nil {
|
||||
log.Warn("failed to delete expired sessions", "error", err)
|
||||
}
|
||||
case <-stopMaintenance:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Listen for OS signals for graceful shutdown.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// Print startup banner.
|
||||
printBanner(cfg, version, tlsCfg != nil)
|
||||
|
||||
// Start serving in a goroutine.
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -143,10 +183,17 @@ func run(log *slog.Logger) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if acmeSrv != nil {
|
||||
if err := acmeSrv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Warn("ACME HTTP server shutdown error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("graceful shutdown: %w", err)
|
||||
}
|
||||
|
||||
close(stopMaintenance)
|
||||
log.Info("server stopped cleanly")
|
||||
return nil
|
||||
}
|
||||
@@ -156,3 +203,69 @@ func isAddrInUse(err error) bool {
|
||||
return err != nil && (strings.Contains(err.Error(), "address already in use") || strings.Contains(err.Error(), "Only one usage of each socket address"))
|
||||
}
|
||||
|
||||
// printBanner writes the startup banner to stderr (so it doesn't mix with
|
||||
// JSON-structured log output on stdout).
|
||||
func printBanner(cfg *config.Config, ver string, tls bool) {
|
||||
scheme := "http"
|
||||
if tls {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
localIP := getOutboundIP()
|
||||
port := cfg.Server.Port
|
||||
baseURL := fmt.Sprintf("%s://%s:%d", scheme, localIP, port)
|
||||
adminURL := baseURL + "/admin"
|
||||
|
||||
tlsStatus := "disabled"
|
||||
if tls {
|
||||
tlsStatus = "enabled"
|
||||
}
|
||||
|
||||
banner := fmt.Sprintf(`
|
||||
|
||||
___ ____ _
|
||||
/ _ \__ ___ __ / ___|___ _ __ __| |
|
||||
| | | \ \ /\ / / '_ \| | / _ \| '__/ _`+"`"+` |
|
||||
| |_| |\ V V /| | | | |__| (_) | | | (_| |
|
||||
\___/ \_/\_/ |_| |_|\____\___/|_| \__,_|
|
||||
|
||||
─────────────────────────────────────────────
|
||||
Server %s
|
||||
Version %s
|
||||
TLS %s
|
||||
Platform %s/%s
|
||||
─────────────────────────────────────────────
|
||||
API %s/api/v1/info
|
||||
WebSocket %s/api/v1/ws
|
||||
Admin %s
|
||||
Health %s/health
|
||||
─────────────────────────────────────────────
|
||||
Press Ctrl+C to stop the server.
|
||||
|
||||
`, cfg.Server.Name, ver, tlsStatus, runtime.GOOS, runtime.GOARCH,
|
||||
baseURL, wsURL(scheme, localIP, port), adminURL, baseURL)
|
||||
|
||||
fmt.Fprint(os.Stderr, banner)
|
||||
}
|
||||
|
||||
// wsURL builds the WebSocket URL with the correct scheme.
|
||||
func wsURL(httpScheme, ip string, port int) string {
|
||||
ws := "ws"
|
||||
if httpScheme == "https" {
|
||||
ws = "wss"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%d", ws, ip, port)
|
||||
}
|
||||
|
||||
// getOutboundIP returns the preferred outbound IP of this machine by dialing
|
||||
// a known external address (no actual connection is made with UDP).
|
||||
func getOutboundIP() string {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err != nil {
|
||||
return "localhost"
|
||||
}
|
||||
defer conn.Close()
|
||||
addr := conn.LocalAddr().(*net.UDPAddr)
|
||||
return addr.IP.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package permissions provides the canonical permission bit constants and
|
||||
// role ID constants for the OwnCord server. All other packages must import
|
||||
// from here instead of defining their own local copies.
|
||||
package permissions
|
||||
|
||||
// ─── Permission bit constants (from SCHEMA.md) ───────────────────────────────
|
||||
|
||||
const (
|
||||
SendMessages = int64(0x0001) // bit 0
|
||||
ReadMessages = int64(0x0002) // bit 1
|
||||
AttachFiles = int64(0x0020) // bit 5
|
||||
AddReactions = int64(0x0040) // bit 6
|
||||
UseSoundboard = int64(0x0100) // bit 8
|
||||
ConnectVoice = int64(0x0200) // bit 9
|
||||
SpeakVoice = int64(0x0400) // bit 10
|
||||
UseVideo = int64(0x0800) // bit 11
|
||||
ShareScreen = int64(0x1000) // bit 12
|
||||
ManageMessages = int64(0x10000) // bit 16
|
||||
ManageChannels = int64(0x20000) // bit 17
|
||||
KickMembers = int64(0x40000) // bit 18
|
||||
BanMembers = int64(0x80000) // bit 19
|
||||
MuteMembers = int64(0x100000) // bit 20
|
||||
ManageRoles = int64(0x1000000) // bit 24
|
||||
ManageServer = int64(0x2000000) // bit 25
|
||||
ManageInvites = int64(0x4000000) // bit 26
|
||||
ViewAuditLog = int64(0x8000000) // bit 27
|
||||
Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks
|
||||
)
|
||||
|
||||
// ─── Role ID constants (default roles inserted on first run) ─────────────────
|
||||
|
||||
const (
|
||||
OwnerRoleID = int64(1)
|
||||
AdminRoleID = int64(2)
|
||||
ModeratorRoleID = int64(3)
|
||||
MemberRoleID = int64(4)
|
||||
)
|
||||
|
||||
// OwnerRolePosition is the hierarchy position of the owner role. Roles with a
|
||||
// position below this value cannot modify the owner role or perform privileged
|
||||
// operations reserved for the owner.
|
||||
const OwnerRolePosition = 100
|
||||
|
||||
// ─── Permission helper functions ─────────────────────────────────────────────
|
||||
|
||||
// HasPerm reports whether rolePerms contains all bits in requiredPerm.
|
||||
// Returns false when requiredPerm is zero because zero is not a valid bit.
|
||||
func HasPerm(rolePerms, requiredPerm int64) bool {
|
||||
if requiredPerm == 0 {
|
||||
return false
|
||||
}
|
||||
return rolePerms&requiredPerm == requiredPerm
|
||||
}
|
||||
|
||||
// HasAdmin reports whether rolePerms includes the Administrator bit, which
|
||||
// grants unconditional access to all operations.
|
||||
func HasAdmin(rolePerms int64) bool {
|
||||
return rolePerms&Administrator != 0
|
||||
}
|
||||
|
||||
// EffectivePerms computes the resolved permission set for a channel override.
|
||||
// The formula matches Discord's channel override semantics:
|
||||
//
|
||||
// effective = (rolePerm & ^deny) | allow
|
||||
//
|
||||
// deny is applied first (strips bits), then allow is applied (adds bits),
|
||||
// so allow takes precedence over deny when both target the same bit.
|
||||
func EffectivePerms(rolePerm, allow, deny int64) int64 {
|
||||
return (rolePerm &^ deny) | allow
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package permissions_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// ─── Constant value tests ─────────────────────────────────────────────────────
|
||||
|
||||
// TestPermissionBitValues verifies every constant matches the SCHEMA.md bitfield.
|
||||
func TestPermissionBitValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
got int64
|
||||
expected int64
|
||||
}{
|
||||
{"SendMessages", permissions.SendMessages, 0x0001},
|
||||
{"ReadMessages", permissions.ReadMessages, 0x0002},
|
||||
{"AttachFiles", permissions.AttachFiles, 0x0020},
|
||||
{"AddReactions", permissions.AddReactions, 0x0040},
|
||||
{"UseSoundboard", permissions.UseSoundboard, 0x0100},
|
||||
{"ConnectVoice", permissions.ConnectVoice, 0x0200},
|
||||
{"SpeakVoice", permissions.SpeakVoice, 0x0400},
|
||||
{"UseVideo", permissions.UseVideo, 0x0800},
|
||||
{"ShareScreen", permissions.ShareScreen, 0x1000},
|
||||
{"ManageMessages", permissions.ManageMessages, 0x10000},
|
||||
{"ManageChannels", permissions.ManageChannels, 0x20000},
|
||||
{"KickMembers", permissions.KickMembers, 0x40000},
|
||||
{"BanMembers", permissions.BanMembers, 0x80000},
|
||||
{"MuteMembers", permissions.MuteMembers, 0x100000},
|
||||
{"ManageRoles", permissions.ManageRoles, 0x1000000},
|
||||
{"ManageServer", permissions.ManageServer, 0x2000000},
|
||||
{"ManageInvites", permissions.ManageInvites, 0x4000000},
|
||||
{"ViewAuditLog", permissions.ViewAuditLog, 0x8000000},
|
||||
{"Administrator", permissions.Administrator, 0x40000000},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.got != tc.expected {
|
||||
t.Errorf("%s: got 0x%X, want 0x%X", tc.name, tc.got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleIDConstants verifies the predefined role IDs match SCHEMA.md defaults.
|
||||
func TestRoleIDConstants(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
got int64
|
||||
expected int64
|
||||
}{
|
||||
{"OwnerRoleID", permissions.OwnerRoleID, 1},
|
||||
{"AdminRoleID", permissions.AdminRoleID, 2},
|
||||
{"ModeratorRoleID", permissions.ModeratorRoleID, 3},
|
||||
{"MemberRoleID", permissions.MemberRoleID, 4},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.got != tc.expected {
|
||||
t.Errorf("%s: got %d, want %d", tc.name, tc.got, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerRolePosition verifies the owner position sentinel value.
|
||||
func TestOwnerRolePosition(t *testing.T) {
|
||||
if permissions.OwnerRolePosition != 100 {
|
||||
t.Errorf("OwnerRolePosition: got %d, want 100", permissions.OwnerRolePosition)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HasPerm tests ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHasPerm_MatchingBitReturnsTrue(t *testing.T) {
|
||||
rolePerms := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice
|
||||
if !permissions.HasPerm(rolePerms, permissions.SendMessages) {
|
||||
t.Error("expected HasPerm to return true when bit is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPerm_MissingBitReturnsFalse(t *testing.T) {
|
||||
rolePerms := permissions.ReadMessages | permissions.ConnectVoice
|
||||
if permissions.HasPerm(rolePerms, permissions.SendMessages) {
|
||||
t.Error("expected HasPerm to return false when bit is not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPerm_ZeroPermsReturnsFalse(t *testing.T) {
|
||||
if permissions.HasPerm(0, permissions.SendMessages) {
|
||||
t.Error("expected HasPerm(0, ...) to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPerm_ZeroRequiredReturnsFalse(t *testing.T) {
|
||||
// Requiring perm 0 should never match — 0 is not a valid permission bit.
|
||||
if permissions.HasPerm(permissions.Administrator, 0) {
|
||||
t.Error("expected HasPerm(..., 0) to return false for zero required perm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPerm_MultipleBitsSetOnlyChecksRequired(t *testing.T) {
|
||||
// rolePerms has many bits; we ask about one that is present.
|
||||
rolePerms := permissions.SendMessages | permissions.ManageMessages | permissions.BanMembers
|
||||
if !permissions.HasPerm(rolePerms, permissions.ManageMessages) {
|
||||
t.Error("expected HasPerm to find ManageMessages in combined bitfield")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPerm_AllBitsSet(t *testing.T) {
|
||||
// 0x7FFFFFFF (Owner default) must satisfy every individual permission.
|
||||
allPerms := int64(0x7FFFFFFF)
|
||||
perms := []int64{
|
||||
permissions.SendMessages, permissions.ReadMessages, permissions.AttachFiles,
|
||||
permissions.AddReactions, permissions.UseSoundboard, permissions.ConnectVoice,
|
||||
permissions.SpeakVoice, permissions.UseVideo, permissions.ShareScreen,
|
||||
permissions.ManageMessages, permissions.ManageChannels, permissions.KickMembers,
|
||||
permissions.BanMembers, permissions.MuteMembers, permissions.ManageRoles,
|
||||
permissions.ManageServer, permissions.ManageInvites, permissions.ViewAuditLog,
|
||||
permissions.Administrator,
|
||||
}
|
||||
for _, p := range perms {
|
||||
if !permissions.HasPerm(allPerms, p) {
|
||||
t.Errorf("expected all-bits owner to have perm 0x%X", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HasAdmin tests ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestHasAdmin_AdministratorBitSet(t *testing.T) {
|
||||
if !permissions.HasAdmin(permissions.Administrator) {
|
||||
t.Error("expected HasAdmin to return true when Administrator bit is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdmin_AdministratorBitWithOthers(t *testing.T) {
|
||||
combined := permissions.SendMessages | permissions.Administrator | permissions.BanMembers
|
||||
if !permissions.HasAdmin(combined) {
|
||||
t.Error("expected HasAdmin to return true with Administrator bit among others")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdmin_NoAdministratorBit(t *testing.T) {
|
||||
if permissions.HasAdmin(permissions.SendMessages | permissions.BanMembers) {
|
||||
t.Error("expected HasAdmin to return false without Administrator bit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdmin_ZeroPerms(t *testing.T) {
|
||||
if permissions.HasAdmin(0) {
|
||||
t.Error("expected HasAdmin(0) to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdmin_AdminRolePermsMissingBit(t *testing.T) {
|
||||
// Admin role default is 0x3FFFFFFF — bit 30 (Administrator) is NOT set.
|
||||
adminDefault := int64(0x3FFFFFFF)
|
||||
if permissions.HasAdmin(adminDefault) {
|
||||
t.Error("expected HasAdmin to return false for Admin role (0x3FFFFFFF lacks bit 30)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdmin_OwnerRolePermsHasBit(t *testing.T) {
|
||||
// Owner role default is 0x7FFFFFFF — bit 30 IS set.
|
||||
ownerDefault := int64(0x7FFFFFFF)
|
||||
if !permissions.HasAdmin(ownerDefault) {
|
||||
t.Error("expected HasAdmin to return true for Owner role (0x7FFFFFFF has bit 30)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EffectivePerms tests ─────────────────────────────────────────────────────
|
||||
|
||||
// EffectivePerms(rolePerm, allow, deny) = (rolePerm & ^deny) | allow
|
||||
|
||||
func TestEffectivePerms_NoOverrides(t *testing.T) {
|
||||
base := permissions.SendMessages | permissions.ReadMessages
|
||||
got := permissions.EffectivePerms(base, 0, 0)
|
||||
if got != base {
|
||||
t.Errorf("EffectivePerms with no overrides: got 0x%X, want 0x%X", got, base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_AllowAddsPermission(t *testing.T) {
|
||||
base := permissions.ReadMessages
|
||||
allow := permissions.SendMessages
|
||||
got := permissions.EffectivePerms(base, allow, 0)
|
||||
want := permissions.ReadMessages | permissions.SendMessages
|
||||
if got != want {
|
||||
t.Errorf("EffectivePerms allow: got 0x%X, want 0x%X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_DenyRemovesPermission(t *testing.T) {
|
||||
base := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice
|
||||
deny := permissions.ConnectVoice
|
||||
got := permissions.EffectivePerms(base, 0, deny)
|
||||
want := permissions.SendMessages | permissions.ReadMessages
|
||||
if got != want {
|
||||
t.Errorf("EffectivePerms deny: got 0x%X, want 0x%X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_AllowAndDenyTogether(t *testing.T) {
|
||||
// deny removes ConnectVoice; allow grants ManageMessages.
|
||||
base := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice
|
||||
allow := permissions.ManageMessages
|
||||
deny := permissions.ConnectVoice
|
||||
got := permissions.EffectivePerms(base, allow, deny)
|
||||
want := permissions.SendMessages | permissions.ReadMessages | permissions.ManageMessages
|
||||
if got != want {
|
||||
t.Errorf("EffectivePerms allow+deny: got 0x%X, want 0x%X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_AllowOverridesDeny(t *testing.T) {
|
||||
// When both allow and deny target the same bit, allow wins
|
||||
// because the formula applies deny first, then allow.
|
||||
base := permissions.SendMessages
|
||||
allow := permissions.ConnectVoice
|
||||
deny := permissions.ConnectVoice
|
||||
got := permissions.EffectivePerms(base, allow, deny)
|
||||
// deny strips ConnectVoice, then allow adds it back.
|
||||
want := permissions.SendMessages | permissions.ConnectVoice
|
||||
if got != want {
|
||||
t.Errorf("EffectivePerms allow overrides deny: got 0x%X, want 0x%X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_ZeroBase(t *testing.T) {
|
||||
allow := permissions.SendMessages | permissions.ReadMessages
|
||||
got := permissions.EffectivePerms(0, allow, 0)
|
||||
if got != allow {
|
||||
t.Errorf("EffectivePerms zero base: got 0x%X, want 0x%X", got, allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_ZeroAll(t *testing.T) {
|
||||
got := permissions.EffectivePerms(0, 0, 0)
|
||||
if got != 0 {
|
||||
t.Errorf("EffectivePerms all zero: got 0x%X, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePerms_DenyAllGrantNone(t *testing.T) {
|
||||
base := int64(0x7FFFFFFF)
|
||||
deny := int64(0x7FFFFFFF)
|
||||
got := permissions.EffectivePerms(base, 0, deny)
|
||||
if got != 0 {
|
||||
t.Errorf("EffectivePerms deny all: got 0x%X, want 0", got)
|
||||
}
|
||||
}
|
||||
+119
-5
@@ -1,14 +1,40 @@
|
||||
// Package storage handles file upload validation and storage for the OwnCord server.
|
||||
// Full implementation follows in Phase 4 (Real-Time Chat Features).
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// blockedMagic maps format names to their magic byte signatures. Files whose
|
||||
// leading bytes match any entry are rejected by ValidateFileType.
|
||||
var blockedMagic = []struct {
|
||||
name string
|
||||
magic []byte
|
||||
}{
|
||||
{"PE executable", []byte("MZ")}, // Windows .exe / .dll
|
||||
{"ELF binary", []byte("\x7fELF")}, // Linux binaries
|
||||
{"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit
|
||||
{"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit
|
||||
{"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.)
|
||||
}
|
||||
|
||||
// ValidateFileType checks the first few bytes of a file against known blocked
|
||||
// magic bytes. It returns an error if the content matches a blocked file type,
|
||||
// or nil if the content is allowed.
|
||||
func ValidateFileType(header []byte) error {
|
||||
for _, blocked := range blockedMagic {
|
||||
if len(header) >= len(blocked.magic) && bytes.Equal(header[:len(blocked.magic)], blocked.magic) {
|
||||
return fmt.Errorf("blocked file type: %s", blocked.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Storage manages file uploads on disk.
|
||||
type Storage struct {
|
||||
dir string
|
||||
@@ -24,29 +50,117 @@ func New(dir string, maxSizeMB int) (*Storage, error) {
|
||||
return &Storage{dir: dir, maxSizeMB: maxSizeMB}, nil
|
||||
}
|
||||
|
||||
// sanitizeFilename validates that name is safe to use as a filename inside the
|
||||
// storage directory. It must be a plain basename with no path separators, must
|
||||
// not be empty, ".", or "..", and must not start with ".".
|
||||
func sanitizeFilename(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid filename: empty string")
|
||||
}
|
||||
// filepath.Base strips any directory component; if it differs from the
|
||||
// original input the caller smuggled a path separator.
|
||||
base := filepath.Base(name)
|
||||
if base != name {
|
||||
return fmt.Errorf("invalid filename %q: must not contain path separators", name)
|
||||
}
|
||||
// Reject "." and ".." explicitly.
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("invalid filename %q: reserved name", name)
|
||||
}
|
||||
// Reject filenames starting with "." (hidden/config files).
|
||||
if strings.HasPrefix(name, ".") {
|
||||
return fmt.Errorf("invalid filename %q: must not start with '.'", name)
|
||||
}
|
||||
// Explicitly reject embedded separators on both Unix and Windows.
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return fmt.Errorf("invalid filename %q: must not contain path separators", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedPath builds the absolute target path and verifies it stays within
|
||||
// the storage directory.
|
||||
func (s *Storage) resolvedPath(name string) (string, error) {
|
||||
absDir, err := filepath.Abs(s.dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving storage dir: %w", err)
|
||||
}
|
||||
target := filepath.Join(absDir, name)
|
||||
// Ensure the joined path is still under absDir.
|
||||
if !strings.HasPrefix(target, absDir+string(filepath.Separator)) &&
|
||||
target != absDir {
|
||||
return "", fmt.Errorf("resolved path %q escapes storage directory", target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Save writes the content from r to a file named by uuid within the storage dir.
|
||||
// It reads the first 8 bytes to validate the file type (rejecting executables
|
||||
// and scripts) before writing the full content to disk.
|
||||
// The caller is responsible for generating a UUID filename.
|
||||
func (s *Storage) Save(uuid string, r io.Reader) error {
|
||||
dst := filepath.Join(s.dir, uuid)
|
||||
if err := sanitizeFilename(uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := s.resolvedPath(uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the first 8 bytes to check magic bytes without consuming the stream.
|
||||
var header [8]byte
|
||||
n, err := io.ReadFull(r, header[:])
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return fmt.Errorf("reading file header: %w", err)
|
||||
}
|
||||
headerSlice := header[:n]
|
||||
|
||||
if err := ValidateFileType(headerSlice); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file %s: %w", dst, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Reconstruct the full stream: header bytes we already read + remainder.
|
||||
maxBytes := int64(s.maxSizeMB) * 1024 * 1024
|
||||
if _, err := io.Copy(f, io.LimitReader(r, maxBytes+1)); err != nil {
|
||||
full := io.MultiReader(bytes.NewReader(headerSlice), r)
|
||||
written, err := io.Copy(f, io.LimitReader(full, maxBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
if written > maxBytes {
|
||||
// File exceeds limit — remove the partial write and reject.
|
||||
f.Close()
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the file named uuid from the storage dir.
|
||||
func (s *Storage) Delete(uuid string) error {
|
||||
return os.Remove(filepath.Join(s.dir, uuid))
|
||||
if err := sanitizeFilename(uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := s.resolvedPath(uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(dst)
|
||||
}
|
||||
|
||||
// Open opens the file named uuid for reading.
|
||||
func (s *Storage) Open(uuid string) (*os.File, error) {
|
||||
return os.Open(filepath.Join(s.dir, uuid))
|
||||
if err := sanitizeFilename(uuid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst, err := s.resolvedPath(uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(dst)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/storage"
|
||||
)
|
||||
|
||||
// newTestStorage creates a Storage instance backed by a temporary directory
|
||||
// that is removed when the test ends.
|
||||
func newTestStorage(t *testing.T) *storage.Storage {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := storage.New(dir, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("storage.New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── sanitizeFilename / path validation (tested indirectly via Save/Delete/Open) ─
|
||||
|
||||
// TestSave_ValidUUID verifies that a normal UUID-style filename is accepted.
|
||||
func TestSave_ValidUUID(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save("550e8400-e29b-41d4-a716-446655440000", strings.NewReader("hello"))
|
||||
if err != nil {
|
||||
t.Errorf("Save valid uuid: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_PathTraversalDotDot rejects filenames containing "..".
|
||||
func TestSave_PathTraversalDotDot(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save("../../etc/passwd", strings.NewReader("evil"))
|
||||
if err == nil {
|
||||
t.Error("Save('../../etc/passwd') returned nil error, want path traversal error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_DotDotFilename rejects the literal string "..".
|
||||
func TestSave_DotDotFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save("..", strings.NewReader("evil"))
|
||||
if err == nil {
|
||||
t.Error("Save('..') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_SingleDotFilename rejects the literal string ".".
|
||||
func TestSave_SingleDotFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save(".", strings.NewReader("evil"))
|
||||
if err == nil {
|
||||
t.Error("Save('.') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_EmptyFilename rejects an empty string.
|
||||
func TestSave_EmptyFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save("", strings.NewReader("data"))
|
||||
if err == nil {
|
||||
t.Error("Save('') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_DotPrefixFilename rejects filenames starting with ".".
|
||||
func TestSave_DotPrefixFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save(".hidden", strings.NewReader("data"))
|
||||
if err == nil {
|
||||
t.Error("Save('.hidden') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_ForwardSlashRejected rejects filenames containing a forward slash.
|
||||
func TestSave_ForwardSlashRejected(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save("sub/file", strings.NewReader("data"))
|
||||
if err == nil {
|
||||
t.Error("Save('sub/file') returned nil error, want path separator error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_BackslashRejected rejects filenames containing a backslash.
|
||||
func TestSave_BackslashRejected(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save(`sub\file`, strings.NewReader("data"))
|
||||
if err == nil {
|
||||
t.Error(`Save('sub\file') returned nil error, want path separator error`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_ResolvedPathStaysInDir verifies the stored file is actually inside
|
||||
// the storage directory (defence-in-depth after sanitisation).
|
||||
func TestSave_ResolvedPathStaysInDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, _ := storage.New(dir, 10)
|
||||
|
||||
filename := "valid-file.dat"
|
||||
if err := s.Save(filename, strings.NewReader("content")); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(expectedPath); errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("expected file at %s but it was not found", expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete_ValidUUID verifies that a saved file can be deleted by its UUID.
|
||||
func TestDelete_ValidUUID(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if err := s.Save("abc123", strings.NewReader("data")); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := s.Delete("abc123"); err != nil {
|
||||
t.Errorf("Delete valid uuid: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete_PathTraversal rejects path-traversal filenames.
|
||||
func TestDelete_PathTraversal(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Delete("../../sensitive")
|
||||
if err == nil {
|
||||
t.Error("Delete('../../sensitive') returned nil error, want path traversal error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete_DotDot rejects "..".
|
||||
func TestDelete_DotDot(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if err := s.Delete(".."); err == nil {
|
||||
t.Error("Delete('..') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete_EmptyFilename rejects an empty string.
|
||||
func TestDelete_EmptyFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if err := s.Delete(""); err == nil {
|
||||
t.Error("Delete('') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete_DotPrefixFilename rejects filenames starting with ".".
|
||||
func TestDelete_DotPrefixFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if err := s.Delete(".hidden"); err == nil {
|
||||
t.Error("Delete('.hidden') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpen_ValidUUID verifies that a saved file can be opened and read back.
|
||||
func TestOpen_ValidUUID(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
content := "hello storage"
|
||||
if err := s.Save("myfile", strings.NewReader(content)); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
f, err := s.Open("myfile")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
got, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatalf("reading opened file: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Errorf("content = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpen_PathTraversal rejects path-traversal filenames.
|
||||
func TestOpen_PathTraversal(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
_, err := s.Open("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Open('../../etc/passwd') returned nil error, want path traversal error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpen_DotDot rejects "..".
|
||||
func TestOpen_DotDot(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if _, err := s.Open(".."); err == nil {
|
||||
t.Error("Open('..') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpen_EmptyFilename rejects an empty string.
|
||||
func TestOpen_EmptyFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if _, err := s.Open(""); err == nil {
|
||||
t.Error("Open('') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpen_DotPrefixFilename rejects filenames starting with ".".
|
||||
func TestOpen_DotPrefixFilename(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if _, err := s.Open(".env"); err == nil {
|
||||
t.Error("Open('.env') returned nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpen_ForwardSlashRejected rejects filenames containing a forward slash.
|
||||
func TestOpen_ForwardSlashRejected(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
if _, err := s.Open("dir/file"); err == nil {
|
||||
t.Error("Open('dir/file') returned nil error, want path separator error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_RoundTrip confirms data integrity through Save then Open.
|
||||
func TestSave_RoundTrip(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
payload := bytes.Repeat([]byte("abcdef"), 1000) // 6 KB
|
||||
if err := s.Save("roundtrip", bytes.NewReader(payload)); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
f, err := s.Open("roundtrip")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
got, _ := io.ReadAll(f)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Errorf("round-trip data mismatch: got %d bytes, want %d", len(got), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4.2: Magic byte validation ───────────────────────────────────────────────
|
||||
|
||||
// TestValidateFileType_AllowsNormalContent verifies that plain file content passes.
|
||||
func TestValidateFileType_AllowsNormalContent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header []byte
|
||||
}{
|
||||
{"PNG", []byte("\x89PNG\r\n\x1a\n")},
|
||||
{"JPEG", []byte("\xff\xd8\xff\xe0")},
|
||||
{"GIF87", []byte("GIF87a")},
|
||||
{"GIF89", []byte("GIF89a")},
|
||||
{"PDF", []byte("%PDF-1.4")},
|
||||
{"ZIP", []byte("PK\x03\x04")},
|
||||
{"plaintext", []byte("Hello world")},
|
||||
{"empty", []byte{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := storage.ValidateFileType(tc.header)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateFileType(%q) = %v, want nil", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileType_BlocksPEExecutable verifies Windows .exe files are rejected.
|
||||
func TestValidateFileType_BlocksPEExecutable(t *testing.T) {
|
||||
header := []byte("MZP\x00\x02\x00\x00\x00") // PE magic "MZ"
|
||||
err := storage.ValidateFileType(header)
|
||||
if err == nil {
|
||||
t.Error("ValidateFileType(PE header) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileType_BlocksELFBinary verifies Linux ELF binaries are rejected.
|
||||
func TestValidateFileType_BlocksELFBinary(t *testing.T) {
|
||||
header := []byte("\x7fELF\x02\x01\x01\x00")
|
||||
err := storage.ValidateFileType(header)
|
||||
if err == nil {
|
||||
t.Error("ValidateFileType(ELF header) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileType_BlocksMachO64 verifies macOS 64-bit Mach-O binaries are rejected.
|
||||
func TestValidateFileType_BlocksMachO64(t *testing.T) {
|
||||
header := []byte("\xcf\xfa\xed\xfe\x07\x00\x00\x01")
|
||||
err := storage.ValidateFileType(header)
|
||||
if err == nil {
|
||||
t.Error("ValidateFileType(Mach-O 64 header) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileType_BlocksMachO32 verifies macOS 32-bit Mach-O binaries are rejected.
|
||||
func TestValidateFileType_BlocksMachO32(t *testing.T) {
|
||||
header := []byte("\xce\xfa\xed\xfe\x07\x00\x00\x01")
|
||||
err := storage.ValidateFileType(header)
|
||||
if err == nil {
|
||||
t.Error("ValidateFileType(Mach-O 32 header) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileType_BlocksShellScript verifies shebang scripts are rejected.
|
||||
func TestValidateFileType_BlocksShellScript(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header []byte
|
||||
}{
|
||||
{"bash", []byte("#!/bin/bash\necho hi")},
|
||||
{"sh", []byte("#!/bin/sh\necho hi")},
|
||||
{"python", []byte("#!/usr/bin/env python3\nprint('x')")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := storage.ValidateFileType(tc.header)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateFileType(script %q) = nil, want error", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileType_ErrorMessageContainsFormat verifies the error names the blocked type.
|
||||
func TestValidateFileType_ErrorMessageContainsFormat(t *testing.T) {
|
||||
header := []byte("MZ\x90\x00") // PE executable
|
||||
err := storage.ValidateFileType(header)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PE executable") {
|
||||
t.Errorf("error message %q does not mention 'PE executable'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_BlocksExecutable verifies Save rejects PE executable content.
|
||||
func TestSave_BlocksExecutable(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
// Construct content with PE magic followed by padding.
|
||||
content := append([]byte("MZ"), bytes.Repeat([]byte{0x00}, 100)...)
|
||||
err := s.Save("malware.exe", bytes.NewReader(content))
|
||||
if err == nil {
|
||||
t.Error("Save(PE executable) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_BlocksELF verifies Save rejects ELF binary content.
|
||||
func TestSave_BlocksELF(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
content := append([]byte("\x7fELF"), bytes.Repeat([]byte{0x00}, 100)...)
|
||||
err := s.Save("linux-binary", bytes.NewReader(content))
|
||||
if err == nil {
|
||||
t.Error("Save(ELF binary) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_BlocksShellScript verifies Save rejects script content.
|
||||
func TestSave_BlocksShellScript(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
content := []byte("#!/bin/bash\nrm -rf /\n")
|
||||
err := s.Save("nasty.sh", bytes.NewReader(content))
|
||||
if err == nil {
|
||||
t.Error("Save(shell script) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_AllowsPNG verifies Save still accepts legitimate image content after magic check.
|
||||
func TestSave_AllowsPNG(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
content := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 100)...)
|
||||
err := s.Save("image.png", bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Errorf("Save(PNG) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSave_EmptyFileAllowed verifies that an empty file (no content) is accepted.
|
||||
func TestSave_EmptyFileAllowed(t *testing.T) {
|
||||
s := newTestStorage(t)
|
||||
err := s.Save("empty-file", bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
t.Errorf("Save(empty) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,9 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumUR
|
||||
if err := u.ValidateDownloadURL(downloadURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.ValidateDownloadURL(checksumURL); err != nil {
|
||||
return fmt.Errorf("validating checksum URL: %w", err)
|
||||
}
|
||||
|
||||
// Fetch checksum file.
|
||||
checksumData, err := u.fetchBody(ctx, checksumURL)
|
||||
|
||||
+58
-13
@@ -8,16 +8,24 @@ import (
|
||||
|
||||
const sendBufSize = 256
|
||||
|
||||
// SessionCheckInterval is the number of messages processed between periodic
|
||||
// session-expiry checks in readPump. Exported so tests can trigger the check
|
||||
// without waiting for a real ticker.
|
||||
const SessionCheckInterval = 10
|
||||
|
||||
// Client represents a single authenticated WebSocket connection.
|
||||
// The underlying transport (conn) is set by ServeWS; in tests it remains nil.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn wsConn // interface — nil in unit tests
|
||||
userID int64
|
||||
user *db.User
|
||||
channelID int64 // currently viewed channel for channel-scoped broadcasts
|
||||
send chan []byte
|
||||
mu sync.Mutex
|
||||
hub *Hub
|
||||
conn wsConn // interface — nil in unit tests
|
||||
userID int64
|
||||
user *db.User
|
||||
channelID int64 // currently viewed channel for channel-scoped broadcasts
|
||||
tokenHash string // SHA-256 hex of the session token; used for periodic revalidation
|
||||
msgCount int // count of messages processed; resets after session check
|
||||
sendClosed bool // true after the send channel has been closed
|
||||
send chan []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// wsConn is the subset of nhooyr.io/websocket.Conn used by writePump/readPump.
|
||||
@@ -28,16 +36,23 @@ type wsConn interface {
|
||||
}
|
||||
|
||||
// newClient creates a real client wrapping a WebSocket connection (set by serve.go).
|
||||
func newClient(hub *Hub, conn wsConn, user *db.User) *Client {
|
||||
func newClient(hub *Hub, conn wsConn, user *db.User, tokenHash string) *Client {
|
||||
return &Client{
|
||||
hub: hub,
|
||||
conn: conn,
|
||||
userID: user.ID,
|
||||
user: user,
|
||||
send: make(chan []byte, sendBufSize),
|
||||
hub: hub,
|
||||
conn: conn,
|
||||
userID: user.ID,
|
||||
user: user,
|
||||
tokenHash: tokenHash,
|
||||
send: make(chan []byte, sendBufSize),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTokenHash returns the session token hash stored on this client.
|
||||
// Exported for tests.
|
||||
func (c *Client) GetTokenHash() string {
|
||||
return c.tokenHash
|
||||
}
|
||||
|
||||
// NewTestClient creates a client with a caller-supplied send channel.
|
||||
// Intended for unit tests only — conn is nil.
|
||||
func NewTestClient(hub *Hub, userID int64, send chan []byte) *Client {
|
||||
@@ -70,11 +85,41 @@ func NewTestClientWithUser(hub *Hub, user *db.User, channelID int64, send chan [
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestClientWithTokenHash creates a test client that carries a session token
|
||||
// hash. Use this when tests need to exercise the periodic session-expiry check.
|
||||
func NewTestClientWithTokenHash(hub *Hub, user *db.User, tokenHash string, channelID int64, send chan []byte) *Client {
|
||||
return &Client{
|
||||
hub: hub,
|
||||
userID: user.ID,
|
||||
user: user,
|
||||
tokenHash: tokenHash,
|
||||
channelID: channelID,
|
||||
send: send,
|
||||
}
|
||||
}
|
||||
|
||||
// sendMsg queues a message to this client's send buffer without blocking.
|
||||
// It is a no-op if the send channel has already been closed.
|
||||
func (c *Client) sendMsg(msg []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.sendClosed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
// Buffer full — drop rather than block the hub.
|
||||
}
|
||||
}
|
||||
|
||||
// closeSend marks the send channel closed and closes it exactly once.
|
||||
// Safe to call from any goroutine.
|
||||
func (c *Client) closeSend() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.sendClosed {
|
||||
c.sendClosed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
+45
-15
@@ -7,15 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
// Permission bits (from SCHEMA.md).
|
||||
const (
|
||||
permSendMessages = int64(0x0001) // bit 0
|
||||
permReadMessages = int64(0x0002) // bit 1
|
||||
permAddReactions = int64(0x0040) // bit 6
|
||||
permManageMessages = int64(0x10000) // bit 16
|
||||
permAdministrator = int64(0x40000000) // bit 30
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// Rate limit windows.
|
||||
@@ -40,6 +33,26 @@ func (h *Hub) HandleMessageForTest(c *Client, raw []byte) {
|
||||
|
||||
// handleMessage parses the envelope and dispatches to the appropriate handler.
|
||||
func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
// Periodic session expiry check: every SessionCheckInterval messages,
|
||||
// re-validate the session token. This catches sessions that are revoked or
|
||||
// expire while the WebSocket connection is still open.
|
||||
c.mu.Lock()
|
||||
c.msgCount++
|
||||
shouldCheck := c.msgCount >= SessionCheckInterval
|
||||
if shouldCheck {
|
||||
c.msgCount = 0
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if shouldCheck && c.tokenHash != "" {
|
||||
sess, dbErr := h.db.GetSessionByTokenHash(c.tokenHash)
|
||||
if dbErr != nil || sess == nil || auth.IsSessionExpired(sess.ExpiresAt) {
|
||||
slog.Info("ws session expired, closing connection", "user_id", c.userID)
|
||||
h.kickClient(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
slog.Warn("ws handleMessage invalid JSON", "user_id", c.userID, "err", err)
|
||||
@@ -115,17 +128,30 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
|
||||
}
|
||||
|
||||
// Permission check.
|
||||
if !h.hasChannelPerm(c, channelID, permReadMessages|permSendMessages) {
|
||||
if !h.hasChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages) {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing SEND_MESSAGES permission"))
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize content.
|
||||
// Slow mode enforcement: moderators with MANAGE_MESSAGES bypass it.
|
||||
if ch.SlowMode > 0 && !h.hasChannelPerm(c, channelID, permissions.ManageMessages) {
|
||||
slowKey := fmt.Sprintf("slow:%d:%d", c.userID, channelID)
|
||||
if !h.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
|
||||
c.sendMsg(buildErrorMsg("SLOW_MODE", fmt.Sprintf("channel has %ds slow mode", ch.SlowMode)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize and validate content length.
|
||||
content := sanitizer.Sanitize(p.Content)
|
||||
if content == "" {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content cannot be empty"))
|
||||
return
|
||||
}
|
||||
if len([]rune(content)) > 4000 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content exceeds maximum length of 4000 characters"))
|
||||
return
|
||||
}
|
||||
|
||||
// Persist.
|
||||
msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo)
|
||||
@@ -223,7 +249,7 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
isMod := h.hasChannelPerm(c, msg.ChannelID, permManageMessages)
|
||||
isMod := h.hasChannelPerm(c, msg.ChannelID, permissions.ManageMessages)
|
||||
if err := h.db.DeleteMessage(msgID, c.userID, isMod); err != nil {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "cannot delete this message"))
|
||||
return
|
||||
@@ -260,6 +286,10 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji cannot be empty"))
|
||||
return
|
||||
}
|
||||
if len(p.Emoji) > 32 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji too long"))
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.db.GetMessage(msgID)
|
||||
if err != nil || msg == nil {
|
||||
@@ -267,7 +297,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.hasChannelPerm(c, msg.ChannelID, permAddReactions) {
|
||||
if !h.hasChannelPerm(c, msg.ChannelID, permissions.AddReactions) {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing ADD_REACTIONS permission"))
|
||||
return
|
||||
}
|
||||
@@ -347,7 +377,7 @@ func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool {
|
||||
if err != nil || role == nil {
|
||||
return false
|
||||
}
|
||||
if role.Permissions&permAdministrator != 0 {
|
||||
if role.Permissions&permissions.Administrator != 0 {
|
||||
return true
|
||||
}
|
||||
// Check channel overrides.
|
||||
@@ -355,7 +385,7 @@ func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
effective := (role.Permissions | allow) &^ deny
|
||||
effective := permissions.EffectivePerms(role.Permissions, allow, deny)
|
||||
return effective&perm == perm
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
// ─── schema used by handler tests ─────────────────────────────────────────────
|
||||
|
||||
// handlerTestSchema extends hubTestSchema with the audit_log table required by
|
||||
// some handler paths, and includes voice_states for completeness.
|
||||
var handlerTestSchema = append(hubTestSchema, []byte(`
|
||||
CREATE TABLE IF NOT EXISTS voice_states (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
muted INTEGER NOT NULL DEFAULT 0,
|
||||
deafened INTEGER NOT NULL DEFAULT 0,
|
||||
speaking INTEGER NOT NULL DEFAULT 0,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_id INTEGER NOT NULL REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL DEFAULT '',
|
||||
target_id INTEGER NOT NULL DEFAULT 0,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)...)
|
||||
|
||||
func openHandlerDB(t *testing.T) *db.DB {
|
||||
t.Helper()
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
migrFS := fstest.MapFS{
|
||||
"001_schema.sql": {Data: handlerTestSchema},
|
||||
}
|
||||
if err := db.MigrateFS(database, migrFS); err != nil {
|
||||
t.Fatalf("MigrateFS: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openHandlerDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
return hub, database
|
||||
}
|
||||
|
||||
// seedModUser inserts a Moderator-role user (roleID=3, permissions=1048575 which
|
||||
// includes MANAGE_MESSAGES bit 0x10000).
|
||||
func seedModUser(t *testing.T, database *db.DB, username string) *db.User {
|
||||
t.Helper()
|
||||
_, err := database.CreateUser(username, "hash", 3) // roleID=3 → Moderator
|
||||
if err != nil {
|
||||
t.Fatalf("seedModUser CreateUser: %v", err)
|
||||
}
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("seedModUser GetUserByUsername: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// seedMemberUser inserts a Member-role user (roleID=4, permissions=1635) that
|
||||
// does NOT have MANAGE_MESSAGES (0x10000=65536).
|
||||
func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User {
|
||||
t.Helper()
|
||||
_, err := database.CreateUser(username, "hash", 4) // roleID=4 → Member
|
||||
if err != nil {
|
||||
t.Fatalf("seedMemberUser CreateUser: %v", err)
|
||||
}
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("seedMemberUser GetUserByUsername: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// seedChannelWithSlowMode creates a text channel and sets its slow_mode to the
|
||||
// given seconds value, then returns the channel ID.
|
||||
func seedChannelWithSlowMode(t *testing.T, database *db.DB, name string, slowModeSecs int) int64 {
|
||||
t.Helper()
|
||||
chID, err := database.CreateChannel(name, "text", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("seedChannelWithSlowMode CreateChannel: %v", err)
|
||||
}
|
||||
if slowModeSecs > 0 {
|
||||
if err := database.SetChannelSlowMode(chID, slowModeSecs); err != nil {
|
||||
t.Fatalf("seedChannelWithSlowMode SetChannelSlowMode: %v", err)
|
||||
}
|
||||
}
|
||||
return chID
|
||||
}
|
||||
|
||||
// chatSendMsg constructs a raw chat_send WebSocket envelope.
|
||||
func chatSendMsg(channelID int64, content string) []byte {
|
||||
raw, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "chat_send",
|
||||
"payload": map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"content": content,
|
||||
},
|
||||
})
|
||||
return raw
|
||||
}
|
||||
|
||||
// receiveErrorCode drains up to n messages from ch and returns the first error
|
||||
// code field found, or "" if none.
|
||||
func receiveErrorCode(ch <-chan []byte, deadline time.Duration) string {
|
||||
timer := time.NewTimer(deadline)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env["type"] == "error" {
|
||||
if payload, ok := env["payload"].(map[string]interface{}); ok {
|
||||
code, _ := payload["code"].(string)
|
||||
return code
|
||||
}
|
||||
}
|
||||
case <-timer.C:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2.2: Session expiry check in readPump ────────────────────────────────────
|
||||
|
||||
// TestSessionExpiry_TokenHashStoredOnClient verifies that a Client created via
|
||||
// NewTestClientWithTokenHash carries the tokenHash field for periodic revalidation.
|
||||
func TestSessionExpiry_TokenHashStoredOnClient(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedOwnerUser(t, database, "expiry-user1")
|
||||
send := make(chan []byte, 16)
|
||||
|
||||
hash := "deadbeefdeadbeef"
|
||||
c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send)
|
||||
|
||||
if got := c.GetTokenHash(); got != hash {
|
||||
t.Errorf("GetTokenHash() = %q, want %q", got, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionExpiry_ValidSessionAllowsMessages verifies that when a client has a
|
||||
// valid (non-expired) session stored in the DB, the periodic expiry check does
|
||||
// NOT close the connection.
|
||||
func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedOwnerUser(t, database, "expiry-user2")
|
||||
chID := seedTestChannel(t, database, "expiry-chan2")
|
||||
|
||||
// Create a real session with a far-future expiry.
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
hash := auth.HashToken(token)
|
||||
if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Trigger the expiry check by sending enough messages to cross the check threshold.
|
||||
for i := 0; i < ws.SessionCheckInterval+1; i++ {
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i)))
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Client should still be registered.
|
||||
if hub.ClientCount() == 0 {
|
||||
t.Error("client was removed despite having a valid session")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionExpiry_ExpiredSessionClosesConnection verifies that after
|
||||
// SessionCheckInterval messages, a client whose session has been deleted from
|
||||
// the DB gets kicked.
|
||||
func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedOwnerUser(t, database, "expiry-user3")
|
||||
|
||||
// Create a session then immediately delete it to simulate expiry.
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
hash := auth.HashToken(token)
|
||||
if _, err := database.CreateSession(user.ID, hash, "test", "127.0.0.1"); err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
// Delete the session to simulate it being expired/revoked.
|
||||
if err := database.DeleteSession(hash); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Trigger the expiry check.
|
||||
for i := 0; i < ws.SessionCheckInterval+1; i++ {
|
||||
// Use a harmless but parseable message to accumulate message count.
|
||||
hub.HandleMessageForTest(c, []byte(`{"type":"presence_update","payload":{"status":"online"}}`))
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// The client's send channel should be closed (connection severed).
|
||||
// We verify this by checking that the send channel has been closed,
|
||||
// which manifests as a zero-value receive without blocking.
|
||||
select {
|
||||
case _, open := <-send:
|
||||
if open {
|
||||
// A message was delivered instead; drain and check again.
|
||||
}
|
||||
// closed channel or a message — either way connection was acted on.
|
||||
default:
|
||||
// Send channel still open and empty — check hub registration instead.
|
||||
}
|
||||
|
||||
// The most reliable assertion: hub should have unregistered the client.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Error("expired-session client was not removed from the hub")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionExpiry_MissingTokenHashSkipsCheck verifies that a client created
|
||||
// without a token hash (legacy / test-only path) does not crash during the
|
||||
// periodic check.
|
||||
func TestSessionExpiry_MissingTokenHashSkipsCheck(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedOwnerUser(t, database, "expiry-user4")
|
||||
chID := seedTestChannel(t, database, "expiry-chan4")
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
// No token hash — simulates old-style test clients.
|
||||
c := ws.NewTestClientWithUser(hub, user, chID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Send past the threshold; should not panic or remove the client.
|
||||
for i := 0; i < ws.SessionCheckInterval+1; i++ {
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i)))
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() == 0 {
|
||||
t.Error("client without token hash was incorrectly removed")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2.8: Slow mode enforcement ───────────────────────────────────────────────
|
||||
|
||||
// TestSlowMode_ZeroSlowMode_AllowsRapidMessages verifies that when slow_mode=0,
|
||||
// messages are not throttled by slow mode (only the normal rate limiter applies).
|
||||
func TestSlowMode_ZeroSlowMode_AllowsRapidMessages(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedOwnerUser(t, database, "slowmode-user1")
|
||||
chID := seedTestChannel(t, database, "no-slowmode-chan") // slow_mode defaults to 0
|
||||
|
||||
send := make(chan []byte, 64)
|
||||
c := ws.NewTestClientWithUser(hub, user, chID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Send 3 messages in quick succession.
|
||||
for i := 0; i < 3; i++ {
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("rapid %d", i)))
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Drain all messages.
|
||||
msgs := drainChan(send)
|
||||
for _, m := range msgs {
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(m, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env["type"] == "error" {
|
||||
if payload, ok := env["payload"].(map[string]interface{}); ok {
|
||||
if payload["code"] == "SLOW_MODE" {
|
||||
t.Error("got unexpected SLOW_MODE error when slow_mode=0")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlowMode_EnforcedAfterFirstMessage verifies that when slow_mode > 0, the
|
||||
// second message from the same user within the slow_mode window is rejected.
|
||||
func TestSlowMode_EnforcedAfterFirstMessage(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedMemberUser(t, database, "slowmode-user2")
|
||||
chID := seedChannelWithSlowMode(t, database, "slow-chan", 30) // 30s slow mode
|
||||
|
||||
send := make(chan []byte, 32)
|
||||
c := ws.NewTestClientWithUser(hub, user, chID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// First message should succeed.
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, "first message"))
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
drainChan(send) // clear the ack
|
||||
|
||||
// Second message within slow_mode window should be rejected.
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, "second message too soon"))
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
code := receiveErrorCode(send, 200*time.Millisecond)
|
||||
if code != "SLOW_MODE" {
|
||||
t.Errorf("expected SLOW_MODE error on second message, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlowMode_DifferentUsersNotBlocked verifies that the slow mode key is
|
||||
// per-user-per-channel: user B sending after user A is not blocked.
|
||||
func TestSlowMode_DifferentUsersNotBlocked(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
chID := seedChannelWithSlowMode(t, database, "slow-multi-chan", 30)
|
||||
|
||||
userA := seedMemberUser(t, database, "slowmode-userA")
|
||||
userB := seedMemberUser(t, database, "slowmode-userB")
|
||||
|
||||
sendA := make(chan []byte, 32)
|
||||
sendB := make(chan []byte, 32)
|
||||
cA := ws.NewTestClientWithUser(hub, userA, chID, sendA)
|
||||
cB := ws.NewTestClientWithUser(hub, userB, chID, sendB)
|
||||
hub.Register(cA)
|
||||
hub.Register(cB)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
hub.HandleMessageForTest(cA, chatSendMsg(chID, "from A"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// B sends after A — B's slow mode window is independent.
|
||||
hub.HandleMessageForTest(cB, chatSendMsg(chID, "from B"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// B should NOT receive a SLOW_MODE error.
|
||||
msgs := drainChan(sendB)
|
||||
for _, m := range msgs {
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(m, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env["type"] == "error" {
|
||||
if payload, ok := env["payload"].(map[string]interface{}); ok {
|
||||
if payload["code"] == "SLOW_MODE" {
|
||||
t.Error("user B was incorrectly slow-mode throttled by user A's window")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlowMode_ModeratorBypassesSlowMode verifies that a user with MANAGE_MESSAGES
|
||||
// permission can send multiple messages without hitting slow mode.
|
||||
func TestSlowMode_ModeratorBypassesSlowMode(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
chID := seedChannelWithSlowMode(t, database, "slow-mod-chan", 30)
|
||||
|
||||
mod := seedModUser(t, database, "slowmode-mod")
|
||||
send := make(chan []byte, 32)
|
||||
c := ws.NewTestClientWithUser(hub, mod, chID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Send two messages in rapid succession — mod should not be blocked.
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 1"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
drainChan(send)
|
||||
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 2"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
msgs := drainChan(send)
|
||||
for _, m := range msgs {
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(m, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env["type"] == "error" {
|
||||
if payload, ok := env["payload"].(map[string]interface{}); ok {
|
||||
if payload["code"] == "SLOW_MODE" {
|
||||
t.Error("moderator was incorrectly blocked by slow mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlowMode_DifferentChannels_IndependentWindows verifies that slow mode is
|
||||
// scoped per-channel: a user hitting slow mode in channel A is not affected in
|
||||
// channel B.
|
||||
func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
|
||||
chA := seedChannelWithSlowMode(t, database, "slow-chan-A", 30)
|
||||
chB := seedChannelWithSlowMode(t, database, "slow-chan-B", 30)
|
||||
|
||||
user := seedMemberUser(t, database, "slowmode-multichan")
|
||||
|
||||
sendA := make(chan []byte, 32)
|
||||
sendB := make(chan []byte, 32)
|
||||
|
||||
// Use two separate clients in each channel to simulate the user being in both.
|
||||
cA := ws.NewTestClientWithUser(hub, user, chA, sendA)
|
||||
// For channel B we need a separate client — re-use same userID is fine for
|
||||
// this test since we are calling HandleMessageForTest directly.
|
||||
cB := ws.NewTestClientWithUser(hub, user, chB, sendB)
|
||||
|
||||
hub.Register(cA)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// cA sends in channel A — triggers slow mode for A.
|
||||
hub.HandleMessageForTest(cA, chatSendMsg(chA, "msg in A"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
drainChan(sendA)
|
||||
|
||||
// Now send in channel B via cB — should NOT be affected.
|
||||
hub.Register(cB)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
hub.HandleMessageForTest(cB, chatSendMsg(chB, "msg in B"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
msgs := drainChan(sendB)
|
||||
for _, m := range msgs {
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(m, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env["type"] == "error" {
|
||||
if payload, ok := env["payload"].(map[string]interface{}); ok {
|
||||
if payload["code"] == "SLOW_MODE" {
|
||||
t.Error("slow mode in channel A incorrectly blocked channel B")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlowMode_ErrorMessageContainsSlowModeDuration verifies the error payload
|
||||
// describes the slow mode duration.
|
||||
func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
const slowSecs = 15
|
||||
chID := seedChannelWithSlowMode(t, database, "slow-msg-chan", slowSecs)
|
||||
|
||||
user := seedMemberUser(t, database, "slowmode-errmsg")
|
||||
send := make(chan []byte, 32)
|
||||
c := ws.NewTestClientWithUser(hub, user, chID, send)
|
||||
hub.Register(c)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// First message to prime the window.
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, "first"))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
drainChan(send)
|
||||
|
||||
// Second message — should receive SLOW_MODE error with duration in message.
|
||||
hub.HandleMessageForTest(c, chatSendMsg(chID, "too soon"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
timer := time.NewTimer(300 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg := <-send:
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env["type"] != "error" {
|
||||
continue
|
||||
}
|
||||
payload, ok := env["payload"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if payload["code"] != "SLOW_MODE" {
|
||||
continue
|
||||
}
|
||||
detail, _ := payload["message"].(string)
|
||||
expected := fmt.Sprintf("%ds slow mode", slowSecs)
|
||||
if detail == "" {
|
||||
t.Error("SLOW_MODE error had empty message")
|
||||
} else if len(detail) > 0 {
|
||||
// Verify the duration is mentioned somewhere in the message.
|
||||
found := false
|
||||
for i := 0; i <= len(detail)-len(expected); i++ {
|
||||
if detail[i:i+len(expected)] == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("SLOW_MODE message %q does not contain %q", detail, expected)
|
||||
}
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
t.Error("did not receive SLOW_MODE error within timeout")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -54,7 +54,7 @@ func (h *Hub) Run() {
|
||||
// If an existing client has the same userID, close its send channel
|
||||
// so writePump exits cleanly before the new client takes over.
|
||||
if old, ok := h.clients[c.userID]; ok && old != c {
|
||||
close(old.send)
|
||||
old.closeSend()
|
||||
}
|
||||
h.clients[c.userID] = c
|
||||
h.mu.Unlock()
|
||||
@@ -105,6 +105,21 @@ func (h *Hub) BroadcastServerRestart(reason string, delaySeconds int) {
|
||||
h.BroadcastToAll(buildServerRestartMsg(reason, delaySeconds))
|
||||
}
|
||||
|
||||
// BroadcastChannelCreate sends a channel_create message to all connected clients.
|
||||
func (h *Hub) BroadcastChannelCreate(ch *db.Channel) {
|
||||
h.BroadcastToAll(buildChannelCreate(ch))
|
||||
}
|
||||
|
||||
// BroadcastChannelUpdate sends a channel_update message to all connected clients.
|
||||
func (h *Hub) BroadcastChannelUpdate(ch *db.Channel) {
|
||||
h.BroadcastToAll(buildChannelUpdate(ch))
|
||||
}
|
||||
|
||||
// BroadcastChannelDelete sends a channel_delete message to all connected clients.
|
||||
func (h *Hub) BroadcastChannelDelete(channelID int64) {
|
||||
h.BroadcastToAll(buildChannelDelete(channelID))
|
||||
}
|
||||
|
||||
// SendToUser delivers msg directly to the client identified by userID.
|
||||
// Returns true if the client was found and the message was queued.
|
||||
func (h *Hub) SendToUser(userID int64, msg []byte) bool {
|
||||
@@ -130,6 +145,18 @@ func (h *Hub) ClientCount() int {
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// kickClient forcibly removes a client from the hub and closes its send channel,
|
||||
// which causes writePump to exit and the WebSocket connection to close.
|
||||
// It is safe to call from any goroutine.
|
||||
func (h *Hub) kickClient(c *Client) {
|
||||
h.mu.Lock()
|
||||
if current, ok := h.clients[c.userID]; ok && current == c {
|
||||
delete(h.clients, c.userID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
c.closeSend()
|
||||
}
|
||||
|
||||
// deliverBroadcast sends bm.msg to the appropriate clients.
|
||||
func (h *Hub) deliverBroadcast(bm broadcastMsg) {
|
||||
h.mu.RLock()
|
||||
|
||||
+72
-32
@@ -15,7 +15,7 @@ type envelope struct {
|
||||
}
|
||||
|
||||
// buildJSON marshals v into a JSON byte slice, logging on failure.
|
||||
func buildJSON(v interface{}) []byte {
|
||||
func buildJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
// Fallback: send a generic error rather than panicking.
|
||||
@@ -26,7 +26,7 @@ func buildJSON(v interface{}) []byte {
|
||||
|
||||
// buildErrorMsg produces an error envelope with the given code and message.
|
||||
func buildErrorMsg(code, message string) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "error",
|
||||
"payload": map[string]string{
|
||||
"code": code,
|
||||
@@ -37,9 +37,9 @@ func buildErrorMsg(code, message string) []byte {
|
||||
|
||||
// buildPresenceMsg constructs a presence broadcast payload.
|
||||
func buildPresenceMsg(userID int64, status string) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "presence",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"user_id": userID,
|
||||
"status": status,
|
||||
},
|
||||
@@ -48,13 +48,13 @@ func buildPresenceMsg(userID int64, status string) []byte {
|
||||
|
||||
// buildMemberJoin constructs a member_join broadcast for when a user comes online.
|
||||
func buildMemberJoin(user *db.User) []byte {
|
||||
var avatarVal interface{}
|
||||
var avatarVal any
|
||||
if user.Avatar != nil {
|
||||
avatarVal = *user.Avatar
|
||||
}
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "member_join",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"avatar": avatarVal,
|
||||
@@ -66,16 +66,16 @@ func buildMemberJoin(user *db.User) []byte {
|
||||
|
||||
// buildChatMessage constructs a chat_message broadcast envelope.
|
||||
func buildChatMessage(msgID, channelID, userID int64, username string, avatar *string, content string, timestamp string, replyTo *int64) []byte {
|
||||
avatarVal := interface{}(nil)
|
||||
var avatarVal any
|
||||
if avatar != nil {
|
||||
avatarVal = *avatar
|
||||
}
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "chat_message",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"id": msgID,
|
||||
"channel_id": channelID,
|
||||
"user": map[string]interface{}{
|
||||
"user": map[string]any{
|
||||
"id": userID,
|
||||
"username": username,
|
||||
"avatar": avatarVal,
|
||||
@@ -89,10 +89,10 @@ func buildChatMessage(msgID, channelID, userID int64, username string, avatar *s
|
||||
|
||||
// buildChatSendOK constructs a chat_send_ok ack.
|
||||
func buildChatSendOK(requestID string, msgID int64, timestamp string) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "chat_send_ok",
|
||||
"id": requestID,
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"message_id": msgID,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
@@ -101,9 +101,9 @@ func buildChatSendOK(requestID string, msgID int64, timestamp string) []byte {
|
||||
|
||||
// buildChatEdited constructs a chat_edited broadcast.
|
||||
func buildChatEdited(msgID, channelID int64, content, editedAt string) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "chat_edited",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"message_id": msgID,
|
||||
"channel_id": channelID,
|
||||
"content": content,
|
||||
@@ -114,9 +114,9 @@ func buildChatEdited(msgID, channelID int64, content, editedAt string) []byte {
|
||||
|
||||
// buildChatDeleted constructs a chat_deleted broadcast.
|
||||
func buildChatDeleted(msgID, channelID int64) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "chat_deleted",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"message_id": msgID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
@@ -125,9 +125,9 @@ func buildChatDeleted(msgID, channelID int64) []byte {
|
||||
|
||||
// buildReactionUpdate constructs a reaction_update broadcast.
|
||||
func buildReactionUpdate(msgID, channelID, userID int64, emoji, action string) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "reaction_update",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"message_id": msgID,
|
||||
"channel_id": channelID,
|
||||
"emoji": emoji,
|
||||
@@ -139,9 +139,9 @@ func buildReactionUpdate(msgID, channelID, userID int64, emoji, action string) [
|
||||
|
||||
// buildTypingMsg constructs a typing broadcast.
|
||||
func buildTypingMsg(channelID, userID int64, username string) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "typing",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
@@ -149,11 +149,11 @@ func buildTypingMsg(channelID, userID int64, username string) []byte {
|
||||
})
|
||||
}
|
||||
|
||||
// buildVoiceState constructs a voice_state server→client broadcast.
|
||||
// buildVoiceState constructs a voice_state server->client broadcast.
|
||||
func buildVoiceState(state db.VoiceState) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_state",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"channel_id": state.ChannelID,
|
||||
"user_id": state.UserID,
|
||||
"username": state.Username,
|
||||
@@ -164,11 +164,11 @@ func buildVoiceState(state db.VoiceState) []byte {
|
||||
})
|
||||
}
|
||||
|
||||
// buildVoiceLeave constructs a voice_leave server→client broadcast.
|
||||
// buildVoiceLeave constructs a voice_leave server->client broadcast.
|
||||
func buildVoiceLeave(channelID, userID int64) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "voice_leave",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"channel_id": channelID,
|
||||
"user_id": userID,
|
||||
},
|
||||
@@ -179,7 +179,7 @@ func buildVoiceLeave(channelID, userID int64) []byte {
|
||||
// channel members. The original payload is embedded unchanged.
|
||||
// channelID is provided for future filtering logic.
|
||||
func buildVoiceSignalRelay(msgType string, _ int64, data json.RawMessage) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": msgType,
|
||||
"payload": data,
|
||||
})
|
||||
@@ -187,20 +187,60 @@ func buildVoiceSignalRelay(msgType string, _ int64, data json.RawMessage) []byte
|
||||
|
||||
// buildSoundboardPlay constructs a soundboard_play broadcast.
|
||||
func buildSoundboardPlay(soundID string, userID int64) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "soundboard_play",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"sound_id": soundID,
|
||||
"user_id": userID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildChannelCreate constructs a channel_create broadcast.
|
||||
func buildChannelCreate(ch *db.Channel) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "channel_create",
|
||||
"payload": map[string]any{
|
||||
"id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"type": ch.Type,
|
||||
"category": ch.Category,
|
||||
"topic": ch.Topic,
|
||||
"position": ch.Position,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildChannelUpdate constructs a channel_update broadcast.
|
||||
func buildChannelUpdate(ch *db.Channel) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "channel_update",
|
||||
"payload": map[string]any{
|
||||
"id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"type": ch.Type,
|
||||
"category": ch.Category,
|
||||
"topic": ch.Topic,
|
||||
"position": ch.Position,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildChannelDelete constructs a channel_delete broadcast.
|
||||
func buildChannelDelete(channelID int64) []byte {
|
||||
return buildJSON(map[string]any{
|
||||
"type": "channel_delete",
|
||||
"payload": map[string]any{
|
||||
"id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildServerRestartMsg constructs a server_restart broadcast.
|
||||
func buildServerRestartMsg(reason string, delaySeconds int) []byte {
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "server_restart",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"reason": reason,
|
||||
"delay_seconds": delaySeconds,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@ package ws
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
func TestBuildServerRestartMsg(t *testing.T) {
|
||||
@@ -27,3 +29,153 @@ func TestBuildServerRestartMsg(t *testing.T) {
|
||||
t.Errorf("delay_seconds = %d, want 5", env.Payload.DelaySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── channel CRUD message builders ───────────────────────────────────────────
|
||||
|
||||
// channelPayload is the common shape expected in channel_create/update payloads.
|
||||
type channelPayload struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Topic string `json:"topic"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func sampleChannel() *db.Channel {
|
||||
return &db.Channel{
|
||||
ID: 42,
|
||||
Name: "general",
|
||||
Type: "text",
|
||||
Category: "Main",
|
||||
Topic: "All chat",
|
||||
Position: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelCreate_Type(t *testing.T) {
|
||||
msg := buildChannelCreate(sampleChannel())
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Type != "channel_create" {
|
||||
t.Errorf("type = %q, want channel_create", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelCreate_Payload(t *testing.T) {
|
||||
ch := sampleChannel()
|
||||
msg := buildChannelCreate(ch)
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
Payload channelPayload `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
p := env.Payload
|
||||
if p.ID != ch.ID {
|
||||
t.Errorf("payload.id = %d, want %d", p.ID, ch.ID)
|
||||
}
|
||||
if p.Name != ch.Name {
|
||||
t.Errorf("payload.name = %q, want %q", p.Name, ch.Name)
|
||||
}
|
||||
if p.Type != ch.Type {
|
||||
t.Errorf("payload.type = %q, want %q", p.Type, ch.Type)
|
||||
}
|
||||
if p.Category != ch.Category {
|
||||
t.Errorf("payload.category = %q, want %q", p.Category, ch.Category)
|
||||
}
|
||||
if p.Topic != ch.Topic {
|
||||
t.Errorf("payload.topic = %q, want %q", p.Topic, ch.Topic)
|
||||
}
|
||||
if p.Position != ch.Position {
|
||||
t.Errorf("payload.position = %d, want %d", p.Position, ch.Position)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelUpdate_Type(t *testing.T) {
|
||||
msg := buildChannelUpdate(sampleChannel())
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Type != "channel_update" {
|
||||
t.Errorf("type = %q, want channel_update", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelUpdate_Payload(t *testing.T) {
|
||||
ch := sampleChannel()
|
||||
msg := buildChannelUpdate(ch)
|
||||
var env struct {
|
||||
Payload channelPayload `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
p := env.Payload
|
||||
if p.ID != ch.ID {
|
||||
t.Errorf("payload.id = %d, want %d", p.ID, ch.ID)
|
||||
}
|
||||
if p.Name != ch.Name {
|
||||
t.Errorf("payload.name = %q, want %q", p.Name, ch.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelDelete_Type(t *testing.T) {
|
||||
msg := buildChannelDelete(99)
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Type != "channel_delete" {
|
||||
t.Errorf("type = %q, want channel_delete", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelDelete_Payload(t *testing.T) {
|
||||
msg := buildChannelDelete(99)
|
||||
var env struct {
|
||||
Payload struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Payload.ID != 99 {
|
||||
t.Errorf("payload.id = %d, want 99", env.Payload.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildChannelCreate_ValidJSON verifies the output is always valid JSON.
|
||||
func TestBuildChannelCreate_ValidJSON(t *testing.T) {
|
||||
msg := buildChannelCreate(sampleChannel())
|
||||
if !json.Valid(msg) {
|
||||
t.Errorf("buildChannelCreate output is not valid JSON: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildChannelUpdate_ValidJSON verifies the output is always valid JSON.
|
||||
func TestBuildChannelUpdate_ValidJSON(t *testing.T) {
|
||||
msg := buildChannelUpdate(sampleChannel())
|
||||
if !json.Valid(msg) {
|
||||
t.Errorf("buildChannelUpdate output is not valid JSON: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildChannelDelete_ValidJSON verifies the output is always valid JSON.
|
||||
func TestBuildChannelDelete_ValidJSON(t *testing.T) {
|
||||
msg := buildChannelDelete(1)
|
||||
if !json.Valid(msg) {
|
||||
t.Errorf("buildChannelDelete output is not valid JSON: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package ws
|
||||
|
||||
import "nhooyr.io/websocket"
|
||||
|
||||
// OriginAcceptOptions builds a *websocket.AcceptOptions that enforces origin
|
||||
// checking according to the provided allowed-origins list.
|
||||
//
|
||||
// Rules:
|
||||
// - nil or empty list → InsecureSkipVerify = true (same as the old default)
|
||||
// - list contains "*" → InsecureSkipVerify = true (explicit opt-in)
|
||||
// - any other list → OriginPatterns set to the list; origin checking active
|
||||
//
|
||||
// The wildcard cases preserve backward compatibility: if a deployment has not
|
||||
// set allowed_origins the server continues to work exactly as before.
|
||||
func OriginAcceptOptions(allowedOrigins []string) *websocket.AcceptOptions {
|
||||
if len(allowedOrigins) == 0 {
|
||||
return &websocket.AcceptOptions{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
for _, o := range allowedOrigins {
|
||||
if o == "*" {
|
||||
return &websocket.AcceptOptions{InsecureSkipVerify: true}
|
||||
}
|
||||
}
|
||||
|
||||
return &websocket.AcceptOptions{
|
||||
OriginPatterns: allowedOrigins,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
// TestOriginAcceptOptions_WildcardEnablesInsecureSkipVerify verifies that
|
||||
// when the allowed origins list contains only "*", InsecureSkipVerify is true
|
||||
// (preserving the previous opt-in permissive behaviour).
|
||||
func TestOriginAcceptOptions_WildcardEnablesInsecureSkipVerify(t *testing.T) {
|
||||
opts := ws.OriginAcceptOptions([]string{"*"})
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions([\"*\"]).InsecureSkipVerify = false, want true")
|
||||
}
|
||||
if len(opts.OriginPatterns) != 0 {
|
||||
t.Errorf("OriginAcceptOptions([\"*\"]).OriginPatterns = %v, want empty", opts.OriginPatterns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_ExplicitOrigins sets OriginPatterns and does NOT
|
||||
// skip origin verification.
|
||||
func TestOriginAcceptOptions_ExplicitOrigins(t *testing.T) {
|
||||
origins := []string{"https://example.com", "https://app.example.com"}
|
||||
opts := ws.OriginAcceptOptions(origins)
|
||||
|
||||
if opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions(explicit).InsecureSkipVerify = true, want false")
|
||||
}
|
||||
if len(opts.OriginPatterns) != 2 {
|
||||
t.Errorf("OriginAcceptOptions(explicit) len(OriginPatterns) = %d, want 2", len(opts.OriginPatterns))
|
||||
}
|
||||
for i, p := range opts.OriginPatterns {
|
||||
if p != origins[i] {
|
||||
t.Errorf("OriginPatterns[%d] = %q, want %q", i, p, origins[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_EmptyList falls back to wildcard (InsecureSkipVerify)
|
||||
// so that an empty configuration doesn't silently reject all connections.
|
||||
func TestOriginAcceptOptions_EmptyList(t *testing.T) {
|
||||
opts := ws.OriginAcceptOptions([]string{})
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions([]) should fall back to InsecureSkipVerify=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_NilList same as empty.
|
||||
func TestOriginAcceptOptions_NilList(t *testing.T) {
|
||||
opts := ws.OriginAcceptOptions(nil)
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions(nil) should fall back to InsecureSkipVerify=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_MixedWithWildcard if "*" appears anywhere in the
|
||||
// list we treat the whole list as wildcard (security: explicit wins over forged mix).
|
||||
func TestOriginAcceptOptions_MixedWithWildcard(t *testing.T) {
|
||||
opts := ws.OriginAcceptOptions([]string{"https://example.com", "*"})
|
||||
if !opts.InsecureSkipVerify {
|
||||
t.Error("OriginAcceptOptions with '*' in list should use InsecureSkipVerify=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginAcceptOptions_ReturnsAcceptOptions ensures the return type is the
|
||||
// correct websocket.AcceptOptions value (compile-time check via assignment).
|
||||
func TestOriginAcceptOptions_ReturnsAcceptOptions(t *testing.T) {
|
||||
var _ *websocket.AcceptOptions = ws.OriginAcceptOptions([]string{"https://example.com"})
|
||||
}
|
||||
+33
-23
@@ -20,24 +20,27 @@ const writeTimeout = 10 * time.Second
|
||||
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
|
||||
// then drives the client's read/write loops.
|
||||
// Do not wrap with AuthMiddleware — WS does its own auth.
|
||||
func ServeWS(hub *Hub, database *db.DB) http.HandlerFunc {
|
||||
//
|
||||
// allowedOrigins controls which HTTP origins may open a WebSocket connection.
|
||||
// Pass nil or []string{"*"} to allow all origins (insecure, for development).
|
||||
// Pass explicit origins such as []string{"https://example.com"} to restrict access.
|
||||
func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFunc {
|
||||
acceptOpts := OriginAcceptOptions(allowedOrigins)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
conn, err := websocket.Accept(w, r, acceptOpts)
|
||||
if err != nil {
|
||||
slog.Warn("ws upgrade failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authenticateConn(conn, database)
|
||||
user, tokenHash, err := authenticateConn(conn, database)
|
||||
if err != nil {
|
||||
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
c := newClient(hub, conn, user)
|
||||
c := newClient(hub, conn, user, tokenHash)
|
||||
hub.Register(c)
|
||||
|
||||
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
|
||||
@@ -109,24 +112,26 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) {
|
||||
}
|
||||
}
|
||||
|
||||
// authenticateConn reads the first WebSocket message and validates the session token.
|
||||
func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, error) {
|
||||
// authenticateConn reads the first WebSocket message and validates the session
|
||||
// token. Returns the authenticated user and the token hash (for later
|
||||
// periodic session revalidation).
|
||||
func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), authDeadline)
|
||||
defer cancel()
|
||||
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "invalid message"))
|
||||
return nil, fmt.Errorf("auth: invalid JSON: %w", err)
|
||||
return nil, "", fmt.Errorf("auth: invalid JSON: %w", err)
|
||||
}
|
||||
if env.Type != "auth" {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "first message must be auth"))
|
||||
return nil, fmt.Errorf("auth: unexpected type %q", env.Type)
|
||||
return nil, "", fmt.Errorf("auth: unexpected type %q", env.Type)
|
||||
}
|
||||
|
||||
var p struct {
|
||||
@@ -134,28 +139,33 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, error) {
|
||||
}
|
||||
if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "missing token"))
|
||||
return nil, fmt.Errorf("auth: missing token")
|
||||
return nil, "", fmt.Errorf("auth: missing token")
|
||||
}
|
||||
|
||||
hash := auth.HashToken(p.Token)
|
||||
sess, err := database.GetSessionByTokenHash(hash)
|
||||
if err != nil || sess == nil {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "invalid token"))
|
||||
return nil, fmt.Errorf("auth: invalid session")
|
||||
return nil, "", fmt.Errorf("auth: invalid session")
|
||||
}
|
||||
|
||||
if auth.IsSessionExpired(sess.ExpiresAt) {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "session expired"))
|
||||
return nil, "", fmt.Errorf("auth: session expired")
|
||||
}
|
||||
|
||||
user, err := database.GetUserByID(sess.UserID)
|
||||
if err != nil || user == nil {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("AUTH_ERROR", "user not found"))
|
||||
return nil, fmt.Errorf("auth: user not found")
|
||||
return nil, "", fmt.Errorf("auth: user not found")
|
||||
}
|
||||
|
||||
if user.Banned {
|
||||
if auth.IsEffectivelyBanned(user) {
|
||||
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg("BANNED", "you are banned"))
|
||||
return nil, fmt.Errorf("auth: banned user %d", user.ID)
|
||||
return nil, "", fmt.Errorf("auth: banned user %d", user.ID)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return user, hash, nil
|
||||
}
|
||||
|
||||
// buildAuthOK constructs the auth_ok server→client message.
|
||||
@@ -165,15 +175,15 @@ func buildAuthOK(database *db.DB, user *db.User) []byte {
|
||||
_ = database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&serverName)
|
||||
_ = database.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd)
|
||||
|
||||
var avatarVal interface{}
|
||||
var avatarVal any
|
||||
if user.Avatar != nil {
|
||||
avatarVal = *user.Avatar
|
||||
}
|
||||
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "auth_ok",
|
||||
"payload": map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"avatar": avatarVal,
|
||||
@@ -210,9 +220,9 @@ func buildReady(database *db.DB) ([]byte, error) {
|
||||
voiceStates = []db.VoiceState{}
|
||||
}
|
||||
|
||||
return buildJSON(map[string]interface{}{
|
||||
return buildJSON(map[string]any{
|
||||
"type": "ready",
|
||||
"payload": map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"channels": channels,
|
||||
"members": members,
|
||||
"voice_states": voiceStates,
|
||||
|
||||
@@ -5,12 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Voice permission bits (from SCHEMA.md).
|
||||
const (
|
||||
permConnectVoice = int64(0x200) // bit 9
|
||||
permUseSoundboard = int64(0x100) // bit 8
|
||||
"github.com/owncord/server/permissions"
|
||||
)
|
||||
|
||||
// Voice rate limit settings.
|
||||
@@ -27,17 +23,17 @@ const (
|
||||
// 3. Broadcasts voice_state to channel.
|
||||
// 4. Sends all current voice states in the channel back to the joiner.
|
||||
func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
|
||||
if !h.hasChannelPerm(c, 0, permConnectVoice) {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing CONNECT_VOICE permission"))
|
||||
return
|
||||
}
|
||||
|
||||
channelID, err := parseChannelID(payload)
|
||||
if err != nil || channelID <= 0 {
|
||||
c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer"))
|
||||
return
|
||||
}
|
||||
|
||||
if !h.hasChannelPerm(c, channelID, permissions.ConnectVoice) {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing CONNECT_VOICE permission"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil {
|
||||
slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel"))
|
||||
@@ -162,7 +158,7 @@ func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.hasChannelPerm(c, 0, permUseSoundboard) {
|
||||
if !h.hasChannelPerm(c, 0, permissions.UseSoundboard) {
|
||||
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_SOUNDBOARD permission"))
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user