This commit is contained in:
parent
cef4707307
commit
58392b7d6a
10 changed files with 80 additions and 122 deletions
|
|
@ -268,13 +268,19 @@ void PyramidController::getPixelInfo(const HttpRequestPtr &req,
|
|||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Create local int variables for proper PostgreSQL binding
|
||||
int faceIdInt = static_cast<int>(faceId);
|
||||
int xInt = static_cast<int>(x);
|
||||
int yInt = static_cast<int>(y);
|
||||
|
||||
*dbClient << R"(
|
||||
SELECT p.color, p.placed_at, u.id as user_id, u.username, u.user_color, u.avatar_url
|
||||
FROM pyramid_pixels p
|
||||
JOIN users u ON p.placed_by = u.id
|
||||
WHERE p.face_id = $1 AND p.x = $2 AND p.y = $3
|
||||
)"
|
||||
<< static_cast<int>(faceId) << static_cast<int>(x) << static_cast<int>(y)
|
||||
<< faceIdInt << xInt << yInt
|
||||
>> [callback, faceId, x, y](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
|
|
@ -484,6 +490,12 @@ void PyramidController::getPixelHistory(const HttpRequestPtr &req,
|
|||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Create local int variables for proper PostgreSQL binding
|
||||
int faceIdInt = static_cast<int>(faceId);
|
||||
int xInt = static_cast<int>(x);
|
||||
int yInt = static_cast<int>(y);
|
||||
|
||||
*dbClient << R"(
|
||||
SELECT h.id, h.color, h.placed_at, h.rolled_back, h.rolled_back_at,
|
||||
u.id as user_id, u.username, u.user_color,
|
||||
|
|
@ -495,7 +507,7 @@ void PyramidController::getPixelHistory(const HttpRequestPtr &req,
|
|||
ORDER BY h.placed_at DESC
|
||||
LIMIT 50
|
||||
)"
|
||||
<< static_cast<int>(faceId) << static_cast<int>(x) << static_cast<int>(y)
|
||||
<< faceIdInt << xInt << yInt
|
||||
>> [callback, faceId, x, y](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
|
|
@ -676,7 +688,7 @@ void PyramidWebSocketController::handlePlacePixel(const WebSocketConnectionPtr &
|
|||
WHERE pyramid_daily_limits.pixels_placed < 1000
|
||||
RETURNING pixels_placed
|
||||
)"
|
||||
<< connInfo.userId << 1000
|
||||
<< connInfo.userId
|
||||
>> [wsConnPtr, dbClient, connInfo, faceId, x, y, color](const Result& r) {
|
||||
if (r.empty()) {
|
||||
Json::Value error;
|
||||
|
|
|
|||
|
|
@ -348,66 +348,3 @@ void RestreamController::deleteDestination(const HttpRequestPtr &req,
|
|||
>> DB_ERROR(callback, "get stream key for delete");
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamController::testDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId) {
|
||||
int64_t rid = std::stoll(realmId);
|
||||
int64_t did = std::stoll(destinationId);
|
||||
|
||||
verifyRestreamPermission(req, rid, [callback, rid, did](bool authorized, const UserInfo& user) {
|
||||
if (!authorized) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get the destination and realm info
|
||||
*dbClient << "SELECT rd.id, rd.name, rd.rtmp_url, rd.stream_key, rd.enabled, "
|
||||
"r.stream_key as realm_stream_key, r.is_live "
|
||||
"FROM restream_destinations rd "
|
||||
"JOIN realms r ON rd.realm_id = r.id "
|
||||
"WHERE rd.id = $1 AND rd.realm_id = $2"
|
||||
<< did << rid
|
||||
>> [callback, did](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Destination not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
bool isLive = r[0]["is_live"].as<bool>();
|
||||
if (!isLive) {
|
||||
callback(jsonError("Stream must be live to test restream connection"));
|
||||
return;
|
||||
}
|
||||
|
||||
RestreamDestination dest;
|
||||
dest.id = r[0]["id"].as<int64_t>();
|
||||
dest.name = r[0]["name"].as<std::string>();
|
||||
dest.rtmpUrl = r[0]["rtmp_url"].as<std::string>();
|
||||
dest.streamKey = r[0]["stream_key"].as<std::string>();
|
||||
dest.enabled = r[0]["enabled"].as<bool>();
|
||||
|
||||
std::string realmStreamKey = r[0]["realm_stream_key"].as<std::string>();
|
||||
|
||||
// Try to start the push
|
||||
RestreamService::getInstance().startPush(realmStreamKey, dest,
|
||||
[callback, dest](bool success, const std::string& error) {
|
||||
Json::Value resp;
|
||||
resp["success"] = success;
|
||||
if (success) {
|
||||
resp["message"] = "Restream connection successful";
|
||||
resp["isConnected"] = true;
|
||||
} else {
|
||||
resp["message"] = "Restream connection failed";
|
||||
resp["error"] = error;
|
||||
resp["isConnected"] = false;
|
||||
}
|
||||
callback(jsonResp(resp, success ? k200OK : k400BadRequest));
|
||||
});
|
||||
}
|
||||
>> DB_ERROR(callback, "test restream destination");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ public:
|
|||
ADD_METHOD_TO(RestreamController::addDestination, "/api/realms/{1}/restream", Post);
|
||||
ADD_METHOD_TO(RestreamController::updateDestination, "/api/realms/{1}/restream/{2}", Put);
|
||||
ADD_METHOD_TO(RestreamController::deleteDestination, "/api/realms/{1}/restream/{2}", Delete);
|
||||
ADD_METHOD_TO(RestreamController::testDestination, "/api/realms/{1}/restream/{2}/test", Post);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getDestinations(const HttpRequestPtr &req,
|
||||
|
|
@ -32,11 +31,6 @@ public:
|
|||
const std::string &realmId,
|
||||
const std::string &destinationId);
|
||||
|
||||
void testDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId);
|
||||
|
||||
private:
|
||||
// Verify user has restream permission for a realm (owner + restreamer role)
|
||||
void verifyRestreamPermission(const HttpRequestPtr &req, int64_t realmId,
|
||||
|
|
|
|||
|
|
@ -294,10 +294,19 @@ void UserController::refresh(const HttpRequestPtr &req,
|
|||
} else {
|
||||
LOG_DEBUG << "Token refresh failed: " << result.error;
|
||||
|
||||
// Clear cookies on failure
|
||||
// Only clear cookies for definitive auth failures (revoked/disabled)
|
||||
// Don't clear for transient errors (race conditions, database issues)
|
||||
// to give user a chance to retry
|
||||
bool shouldClearCookies =
|
||||
result.error == "Session has been revoked" ||
|
||||
result.error == "Account is disabled";
|
||||
|
||||
auto response = jsonError(result.error, k401Unauthorized);
|
||||
clearAuthCookie(response);
|
||||
clearRefreshCookie(response);
|
||||
if (shouldClearCookies) {
|
||||
clearAuthCookie(response);
|
||||
clearRefreshCookie(response);
|
||||
LOG_INFO << "Cleared auth cookies due to: " << result.error;
|
||||
}
|
||||
callback(response);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1077,14 +1077,17 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken,
|
|||
|
||||
std::string tokenHash = hashToken(refreshToken);
|
||||
|
||||
// Find the token family with this hash
|
||||
// Find the token family with this hash (check both current and previous hash for multi-tab support)
|
||||
// The previous hash is valid for a grace period (60 seconds) to handle race conditions
|
||||
*dbClient << "SELECT rtf.id, rtf.user_id, rtf.family_id, rtf.expires_at, rtf.revoked, "
|
||||
"u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, "
|
||||
"u.is_bot, u.is_texter, u.is_pgp_only, u.is_disabled, u.user_color, u.avatar_url, "
|
||||
"u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes, u.screensaver_type "
|
||||
"u.token_version, u.screensaver_enabled, u.screensaver_timeout_minutes, u.screensaver_type, "
|
||||
"(rtf.current_token_hash = $1) AS is_current_token "
|
||||
"FROM refresh_token_families rtf "
|
||||
"JOIN users u ON rtf.user_id = u.id "
|
||||
"WHERE rtf.current_token_hash = $1"
|
||||
"WHERE rtf.current_token_hash = $1 "
|
||||
" OR (rtf.previous_token_hash = $1 AND rtf.previous_hash_expires_at > NOW())"
|
||||
<< tokenHash
|
||||
>> [this, dbClient, tokenHash, refreshToken, callback](const Result& r) {
|
||||
try {
|
||||
|
|
@ -1104,6 +1107,7 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken,
|
|||
std::string familyUuid = row["family_id"].as<std::string>();
|
||||
bool revoked = row["revoked"].as<bool>();
|
||||
bool isDisabled = row["is_disabled"].isNull() ? false : row["is_disabled"].as<bool>();
|
||||
bool isCurrentToken = row["is_current_token"].isNull() ? false : row["is_current_token"].as<bool>();
|
||||
|
||||
// Check if family is revoked
|
||||
if (revoked) {
|
||||
|
|
@ -1152,11 +1156,21 @@ void AuthService::validateAndRotateRefreshToken(const std::string& refreshToken,
|
|||
std::string newAccessToken = generateToken(user);
|
||||
|
||||
// Update the family with new token hash
|
||||
*dbClient << "UPDATE refresh_token_families SET current_token_hash = $1, last_used_at = NOW() "
|
||||
// Move current hash to previous with 60-second grace period for multi-tab support
|
||||
// This prevents race conditions where multiple tabs try to refresh simultaneously
|
||||
*dbClient << "UPDATE refresh_token_families SET "
|
||||
"previous_token_hash = current_token_hash, "
|
||||
"previous_hash_expires_at = NOW() + INTERVAL '60 seconds', "
|
||||
"current_token_hash = $1, "
|
||||
"last_used_at = NOW() "
|
||||
"WHERE id = $2"
|
||||
<< newTokenHash << familyId
|
||||
>> [callback, newAccessToken, newRefreshToken, familyUuid, user](const Result&) {
|
||||
LOG_DEBUG << "Rotated refresh token for family: " << familyUuid;
|
||||
>> [callback, newAccessToken, newRefreshToken, familyUuid, user, isCurrentToken](const Result&) {
|
||||
if (!isCurrentToken) {
|
||||
LOG_INFO << "Rotated refresh token for family (from previous token): " << familyUuid;
|
||||
} else {
|
||||
LOG_DEBUG << "Rotated refresh token for family: " << familyUuid;
|
||||
}
|
||||
RefreshTokenResult result;
|
||||
result.success = true;
|
||||
result.accessToken = newAccessToken;
|
||||
|
|
|
|||
|
|
@ -1132,6 +1132,9 @@ void ChatWebSocketController::handleAuthMessage(const WebSocketConnectionPtr& ws
|
|||
// which would cause it to appear on guest sessions with the same fingerprint
|
||||
it->second.fingerprint.clear();
|
||||
|
||||
// Clear guest session timeout - authenticated users should not be disconnected
|
||||
it->second.sessionTimeoutMinutes = 0;
|
||||
|
||||
// Update usernameToConnection_ map: remove old guest name, add new authenticated name
|
||||
if (!oldUsername.empty()) {
|
||||
std::string lowerOld = oldUsername;
|
||||
|
|
|
|||
|
|
@ -1240,6 +1240,28 @@ CREATE INDEX IF NOT EXISTS idx_refresh_families_family_id ON refresh_token_famil
|
|||
CREATE INDEX IF NOT EXISTS idx_refresh_families_active ON refresh_token_families(user_id, revoked) WHERE revoked = FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_families_expires ON refresh_token_families(expires_at) WHERE revoked = FALSE;
|
||||
|
||||
-- Add previous_token_hash for grace period during token rotation (multi-tab support)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'refresh_token_families' AND column_name = 'previous_token_hash'
|
||||
) THEN
|
||||
ALTER TABLE refresh_token_families ADD COLUMN previous_token_hash VARCHAR(64);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add previous_hash_expires_at for grace period expiry
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'refresh_token_families' AND column_name = 'previous_hash_expires_at'
|
||||
) THEN
|
||||
ALTER TABLE refresh_token_families ADD COLUMN previous_hash_expires_at TIMESTAMP WITH TIME ZONE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================
|
||||
-- SCREENSAVER SETTINGS
|
||||
-- ============================================
|
||||
|
|
|
|||
|
|
@ -657,39 +657,6 @@
|
|||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function testRestreamDestination(realmId, destId) {
|
||||
const realm = realms.find(r => r.id === realmId);
|
||||
if (!realm?.isLive) {
|
||||
error = 'Stream must be live to test restream';
|
||||
setTimeout(() => error = '', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
restreamLoading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realmId}/restream/${destId}/test`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
message = 'Restream connection successful!';
|
||||
await loadRestreamDestinations(realmId);
|
||||
} else {
|
||||
error = data.error || 'Restream connection failed';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error testing restream';
|
||||
console.error(e);
|
||||
} finally {
|
||||
restreamLoading = false;
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
// Video functions
|
||||
async function loadVideos() {
|
||||
videosLoading = true;
|
||||
|
|
@ -3614,14 +3581,6 @@
|
|||
>
|
||||
{dest.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
style="padding: 0.25rem 0.75rem; font-size: 0.85rem; background: #17a2b8;"
|
||||
on:click={() => testRestreamDestination(restreamRealmId, dest.id)}
|
||||
disabled={restreamLoading}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
style="padding: 0.25rem 0.75rem; font-size: 0.85rem;"
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ write_files:
|
|||
ufw allow out 53/udp comment 'DNS'
|
||||
ufw allow out 80/tcp comment 'HTTP'
|
||||
ufw allow out 443/tcp comment 'HTTPS'
|
||||
ufw allow out 1935/tcp comment 'RTMP restream'
|
||||
ufw allow out 123/udp comment 'NTP'
|
||||
ufw allow out to ${vpc_ip_range} comment 'VPC'
|
||||
|
||||
|
|
|
|||
|
|
@ -147,6 +147,13 @@ resource "digitalocean_firewall" "app" {
|
|||
destination_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# RTMP restream to external services
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "1935"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# NTP
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue