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:
jevb
2026-03-15 07:07:59 +01:00
parent 98decace45
commit 6eba999233
58 changed files with 6064 additions and 480 deletions
+6
View File
@@ -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());
}
+3 -2
View File
@@ -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);
+3
View File
@@ -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,
+64 -16
View File
@@ -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);
}
}
+32 -2
View File
@@ -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...";
+4
View File
@@ -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
View File
@@ -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
}
+448
View File
@@ -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
}
+8 -11
View File
@@ -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)
+88
View File
@@ -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) {
-4
View File
@@ -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
)
+165
View File
@@ -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")
}
}
+2 -4
View File
@@ -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
View File
@@ -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.
+282 -5
View File
@@ -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
View File
@@ -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)
+67
View File
@@ -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
}
+311
View File
@@ -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")
}
}
+70
View File
@@ -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)
}
+212
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+10 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+151
View File
@@ -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")
}
}
+13 -1
View File
@@ -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
View File
@@ -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...)
}
+186
View File
@@ -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
}
+695
View File
@@ -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)
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+70
View File
@@ -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
}
+256
View File
@@ -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
View File
@@ -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)
}
+389
View File
@@ -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)
}
}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+535
View File
@@ -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
View File
@@ -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
View File
@@ -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 serverclient 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 serverclient 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,
},
+152
View File
@@ -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)
}
}
+29
View File
@@ -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,
}
}
+73
View File
@@ -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
View File
@@ -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,
+7 -11
View File
@@ -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
}