Require authentication to participate in chat (#4762)

* feat(chat): require authentication to participate in chat

* fix: it's pretty much impossible to bypass the auth requirement, addressing review feedback anyway

* feat(chat): render chat text input as disabled if chat auth is required

* Commit updated API documentation

---------

Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
Gabe Kangas
2026-01-28 11:49:07 -08:00
committed by GitHub
co-authored by Owncast
parent 83c8b2b3d5
commit 93b482871f
29 changed files with 5194 additions and 5143 deletions
+8
View File
@@ -397,6 +397,14 @@ func (s *Server) eventReceived(event chatClientEvent) {
return return
} }
// Check if authentication is required for chat
if configRepository.GetChatRequireAuthentication() {
if u == nil || (!u.Authenticated && !u.IsModerator()) {
s.sendActionToClient(c, "Authentication is required to participate in chat.")
return
}
}
var typecheck map[string]interface{} var typecheck map[string]interface{}
if err := json.Unmarshal(event.data, &typecheck); err != nil { if err := json.Unmarshal(event.data, &typecheck); err != nil {
log.Debugln(err) log.Debugln(err)
+4214 -4581
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -1514,6 +1514,35 @@ paths:
responses: responses:
'204': '204':
$ref: '#/components/responses/204' $ref: '#/components/responses/204'
/admin/config/chat/requireauthentication:
post:
summary: Set require authentication for chat
operationId: SetChatRequireAuthentication
tags: ['Internal', 'Admin', 'Chat']
security:
- BasicAuth: []
requestBody:
$ref: '#/components/requestBodies/AdminConfigValue'
responses:
'200':
description: Require authentication setting updated
content:
application/json:
schema:
$ref: '#/components/schemas/BaseAPIResponse'
'400':
$ref: '#/components/responses/400'
'401':
$ref: '#/components/responses/401BasicAuth'
default:
$ref: '#/components/responses/Default'
options:
operationId: SetChatRequireAuthenticationOptions
x-internal: true
tags: ['Objects', 'Internal', 'Admin', 'Chat']
responses:
'204':
$ref: '#/components/responses/204'
/admin/config/video/codec: /admin/config/video/codec:
post: post:
summary: Set video codec summary: Set video codec
@@ -3660,6 +3689,9 @@ components:
type: boolean type: boolean
chatDisabled: chatDisabled:
type: boolean type: boolean
chatRequireAuthentication:
type: boolean
description: Whether users must authenticate before sending chat messages
nsfw: nsfw:
type: boolean type: boolean
authentication: authentication:
@@ -46,6 +46,7 @@ const (
chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode" chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode"
chatSpamProtectionEnabledKey = "chat_spam_protection_enabled" chatSpamProtectionEnabledKey = "chat_spam_protection_enabled"
chatSlurFilterEnabledKey = "chat_slur_filter_enabled" chatSlurFilterEnabledKey = "chat_slur_filter_enabled"
chatRequireAuthenticationKey = "chat_require_authentication"
notificationsEnabledKey = "notifications_enabled" notificationsEnabledKey = "notifications_enabled"
discordConfigurationKey = "discord_configuration" discordConfigurationKey = "discord_configuration"
browserPushConfigurationKey = "browser_push_configuration" browserPushConfigurationKey = "browser_push_configuration"
@@ -67,6 +67,8 @@ type ConfigRepository interface {
GetChatSpamProtectionEnabled() bool GetChatSpamProtectionEnabled() bool
SetChatSlurFilterEnabled(enabled bool) error SetChatSlurFilterEnabled(enabled bool) error
GetChatSlurFilterEnabled() bool GetChatSlurFilterEnabled() bool
SetChatRequireAuthentication(enabled bool) error
GetChatRequireAuthentication() bool
GetExternalActions() []models.ExternalAction GetExternalActions() []models.ExternalAction
SetExternalActions(actions []models.ExternalAction) error SetExternalActions(actions []models.ExternalAction) error
SetCustomStyles(styles string) error SetCustomStyles(styles string) error
@@ -541,6 +541,21 @@ func (r *SqlConfigRepository) GetChatSlurFilterEnabled() bool {
return false return false
} }
// SetChatRequireAuthentication will set if authentication is required for chat.
func (r *SqlConfigRepository) SetChatRequireAuthentication(enabled bool) error {
return r.datastore.SetBool(chatRequireAuthenticationKey, enabled)
}
// GetChatRequireAuthentication will return if authentication is required for chat.
func (r *SqlConfigRepository) GetChatRequireAuthentication() bool {
enabled, err := r.datastore.GetBool(chatRequireAuthenticationKey)
if err == nil {
return enabled
}
return false
}
// GetExternalActions will return the registered external actions. // GetExternalActions will return the registered external actions.
func (r *SqlConfigRepository) GetExternalActions() []models.ExternalAction { func (r *SqlConfigRepository) GetExternalActions() []models.ExternalAction {
configEntry, err := r.datastore.Get(externalActionsKey) configEntry, err := r.datastore.Get(externalActionsKey)
@@ -119,3 +119,68 @@ test('verify message is in the chat feed', async () => {
expect(message.length).toBe(0); expect(message.length).toBe(0);
}); });
// Chat authentication requirement tests
const unauthenticatedUserFailedChatMessage = {
body:
'this unauthenticated message should fail ' +
Math.floor(Math.random() * 100),
type: 'CHAT',
};
const unauthenticatedUserSucceedChatMessage = {
body:
'this unauthenticated message should succeed ' +
Math.floor(Math.random() * 100),
type: 'CHAT',
};
test('enable chat require authentication mode', async () => {
await sendAdminRequest('config/chat/requireauthentication', true);
});
test('send a message after require authentication is enabled', async () => {
const registration = await registerChat();
const accessToken = registration.accessToken;
await sendChatMessage(unauthenticatedUserFailedChatMessage, accessToken);
});
test('verify unauthenticated message is not in the chat feed', async () => {
await new Promise((r) => setTimeout(r, 1000));
const res = await getAdminResponse('chat/messages');
const expectedBody =
`<p>` + unauthenticatedUserFailedChatMessage.body + `</p>`;
const message = res.body.filter((obj) => {
return obj.body === expectedBody;
});
expect(message.length).toBe(0);
});
test('disable chat require authentication mode', async () => {
await sendAdminRequest('config/chat/requireauthentication', false);
});
test('send a message after require authentication is disabled', async () => {
const registration = await registerChat();
const accessToken = registration.accessToken;
await sendChatMessage(unauthenticatedUserSucceedChatMessage, accessToken);
});
test('verify message from unauthenticated user is now in the chat feed', async () => {
await new Promise((r) => setTimeout(r, 1000));
const res = await getAdminResponse('chat/messages');
const expectedBody =
`<p>` + unauthenticatedUserSucceedChatMessage.body + `</p>`;
const message = res.body.filter((obj) => {
return obj.body === expectedBody;
});
expect(message.length).toBe(1);
});
@@ -1,6 +1,6 @@
var request = require('supertest'); var request = require('supertest');
var bcrypt = require('bcrypt'); var bcrypt = require('bcrypt');
var shajs = require("sha.js"); var shajs = require('sha.js');
const sendAdminRequest = require('./lib/admin').sendAdminRequest; const sendAdminRequest = require('./lib/admin').sendAdminRequest;
const failAdminRequest = require('./lib/admin').failAdminRequest; const failAdminRequest = require('./lib/admin').failAdminRequest;
@@ -330,6 +330,24 @@ test('disable search indexing', async () => {
); );
}); });
test('set chat require authentication enabled', async () => {
await sendAdminRequest('config/chat/requireauthentication', true);
});
test('verify chat require authentication is enabled in config', async () => {
const res = await getAdminResponse('serverconfig');
expect(res.body.chatRequireAuthentication).toBe(true);
});
test('set chat require authentication disabled', async () => {
await sendAdminRequest('config/chat/requireauthentication', false);
});
test('verify chat require authentication is disabled in config', async () => {
const res = await getAdminResponse('serverconfig');
expect(res.body.chatRequireAuthentication).toBe(false);
});
test('change admin password', async () => { test('change admin password', async () => {
await sendAdminRequest('config/adminpass', newAdminPassword); await sendAdminRequest('config/adminpass', newAdminPassword);
}); });
@@ -366,7 +384,7 @@ test('change admin password >72 bytes', async () => {
test('verify admin password change (>72 bytes)', async () => { test('verify admin password change (>72 bytes)', async () => {
const res = await getAdminResponse( const res = await getAdminResponse(
'serverconfig', 'serverconfig',
(adminPassword = newAdminPasswordLong) (adminPassword = newAdminPasswordLong),
); );
bcrypt.compare( bcrypt.compare(
@@ -374,7 +392,7 @@ test('verify admin password change (>72 bytes)', async () => {
res.body.adminPassword, res.body.adminPassword,
function (err, result) { function (err, result) {
expect(result).toBe(true); expect(result).toBe(true);
} },
); );
}); });
@@ -382,7 +400,7 @@ test('reset admin password (>72)', async () => {
await sendAdminRequest( await sendAdminRequest(
'config/adminpass', 'config/adminpass',
defaultAdminPassword, defaultAdminPassword,
(adminPassword = newAdminPasswordLong) (adminPassword = newAdminPasswordLong),
); );
}); });
@@ -618,6 +618,22 @@ export const ChatDisabled = {
}, },
}; };
export const AuthenticationRequired = {
render: Template,
args: {
loading: false,
messages,
usernameToHighlight: 'testuser',
chatUserId: 'testuser',
isModerator: false,
showInput: true,
chatAvailable: true,
inputEnabled: false,
inputDisabledPlaceholder: 'Authenticate to chat',
},
};
export const SingleMessage = { export const SingleMessage = {
render: Template, render: Template,
@@ -34,6 +34,8 @@ export type ChatContainerProps = {
focusInput?: boolean; focusInput?: boolean;
desktop?: boolean; desktop?: boolean;
readonly?: boolean; readonly?: boolean;
inputEnabled?: boolean;
inputDisabledPlaceholder?: string;
}; };
let resizeWindowCallback: () => void; let resizeWindowCallback: () => void;
@@ -82,11 +84,15 @@ export const ChatContainer: FC<ChatContainerProps> = ({
isModerator, isModerator,
showInput = true, showInput = true,
height = 'auto', height = 'auto',
chatAvailable: chatEnabled, chatAvailable,
desktop, desktop,
focusInput = true, focusInput = true,
readonly = false, readonly = false,
inputEnabled,
inputDisabledPlaceholder,
}) => { }) => {
// If inputEnabled is explicitly set, use that; otherwise fall back to chatAvailable
const chatInputEnabled = inputEnabled !== undefined ? inputEnabled : chatAvailable;
const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false); const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(false); const [isAtBottom, setIsAtBottom] = useState(false);
@@ -366,7 +372,11 @@ export const ChatContainer: FC<ChatContainerProps> = ({
{MessagesTable} {MessagesTable}
{showInput && ( {showInput && (
<div className={styles.chatTextField}> <div className={styles.chatTextField}>
<ChatTextField enabled={chatEnabled} focusInput={focusInput} /> <ChatTextField
enabled={chatInputEnabled}
focusInput={focusInput}
disabledPlaceholder={inputDisabledPlaceholder}
/>
</div> </div>
)} )}
{desktop && ( {desktop && (
@@ -30,6 +30,7 @@ export type ChatTextFieldProps = {
defaultText?: string; defaultText?: string;
enabled: boolean; enabled: boolean;
focusInput: boolean; focusInput: boolean;
disabledPlaceholder?: string;
}; };
const characterLimit = 300; const characterLimit = 300;
@@ -119,7 +120,12 @@ const getTextContent = node => {
return text; return text;
}; };
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => { export const ChatTextField: FC<ChatTextFieldProps> = ({
defaultText,
enabled,
focusInput,
disabledPlaceholder,
}) => {
const [inputDraft, setInputDraft] = useRecoilState(chatInputDraftAtom); const [inputDraft, setInputDraft] = useRecoilState(chatInputDraftAtom);
const [characterCount, setCharacterCount] = useState(defaultText?.length); const [characterCount, setCharacterCount] = useState(defaultText?.length);
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom); const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
@@ -276,7 +282,9 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
<ContentEditable <ContentEditable
id="chat-input-content-editable" id="chat-input-content-editable"
html={defaultText || ''} html={defaultText || ''}
placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'} placeholder={
enabled ? 'Send a message to chat' : disabledPlaceholder || 'Chat is disabled'
}
disabled={!enabled} disabled={!enabled}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onContentChange={handleChange} onContentChange={handleChange}
+11 -1
View File
@@ -14,9 +14,17 @@ export type ChatModalProps = {
messages: ChatMessage[]; messages: ChatMessage[];
currentUser: CurrentUser; currentUser: CurrentUser;
handleClose: () => void; handleClose: () => void;
inputEnabled?: boolean;
inputDisabledPlaceholder?: string;
}; };
export const ChatModal: FC<ChatModalProps> = ({ messages, currentUser, handleClose }) => { export const ChatModal: FC<ChatModalProps> = ({
messages,
currentUser,
handleClose,
inputEnabled = true,
inputDisabledPlaceholder,
}) => {
if (!currentUser) { if (!currentUser) {
return null; return null;
} }
@@ -58,6 +66,8 @@ export const ChatModal: FC<ChatModalProps> = ({ messages, currentUser, handleClo
chatUserId={id} chatUserId={id}
isModerator={isModerator} isModerator={isModerator}
chatAvailable chatAvailable
inputEnabled={inputEnabled}
inputDisabledPlaceholder={inputDisabledPlaceholder}
focusInput={false} focusInput={false}
/> />
</Modal> </Modal>
+20
View File
@@ -4,9 +4,11 @@ import MessageFilled from '@ant-design/icons/MessageFilled';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import classnames from 'classnames'; import classnames from 'classnames';
import { useTranslation } from 'next-export-i18n';
import ActionButtons from './ActionButtons'; import ActionButtons from './ActionButtons';
import { LOCAL_STORAGE_KEYS, getLocalStorage, setLocalStorage } from '../../../utils/localStorage'; import { LOCAL_STORAGE_KEYS, getLocalStorage, setLocalStorage } from '../../../utils/localStorage';
import { canPushNotificationsBeSupported } from '../../../utils/browserPushNotifications'; import { canPushNotificationsBeSupported } from '../../../utils/browserPushNotifications';
import { Localization } from '../../../types/localization';
import { import {
clientConfigStateAtom, clientConfigStateAtom,
@@ -19,6 +21,7 @@ import {
serverStatusState, serverStatusState,
isChatAvailableSelector, isChatAvailableSelector,
visibleChatMessagesSelector, visibleChatMessagesSelector,
chatAuthenticatedAtom,
} from '../../stores/ClientConfigStore'; } from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model'; import { ClientConfig } from '../../../interfaces/client-config.model';
@@ -97,6 +100,7 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
}; };
export const Content: FC = () => { export const Content: FC = () => {
const { t } = useTranslation();
const appState = useRecoilValue<AppStateOptions>(appStateAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom); const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const chatState = useRecoilValue<ChatState>(chatStateAtom); const chatState = useRecoilValue<ChatState>(chatStateAtom);
@@ -106,6 +110,7 @@ export const Content: FC = () => {
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector); const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
const online = useRecoilValue<boolean>(isOnlineSelector); const online = useRecoilValue<boolean>(isOnlineSelector);
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector); const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const isUserAuthenticated = useRecoilValue<boolean>(chatAuthenticatedAtom);
const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } = const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } =
useRecoilValue<ServerStatus>(serverStatusState); useRecoilValue<ServerStatus>(serverStatusState);
@@ -118,6 +123,7 @@ export const Content: FC = () => {
externalActions, externalActions,
offlineMessage, offlineMessage,
chatDisabled, chatDisabled,
chatRequireAuthentication,
federation, federation,
notifications, notifications,
} = clientConfig; } = clientConfig;
@@ -219,6 +225,16 @@ export const Content: FC = () => {
const showChat = isChatAvailable && !chatDisabled && chatState === ChatState.VISIBLE; const showChat = isChatAvailable && !chatDisabled && chatState === ChatState.VISIBLE;
// Determine if chat input should be enabled based on authentication requirements.
// Moderators bypass the authentication requirement.
const chatInputEnabled = !!(
isChatAvailable &&
(!chatRequireAuthentication || isUserAuthenticated || currentUser?.isModerator)
);
const chatInputDisabledMessage = chatRequireAuthentication
? t(Localization.Frontend.Chat.authenticateToChat)
: t(Localization.Frontend.chatDisabled);
return ( return (
<div className={styles.main}> <div className={styles.main}>
<div className={styles.mainColumn}> <div className={styles.mainColumn}>
@@ -325,6 +341,8 @@ export const Content: FC = () => {
isModerator={currentUser.isModerator} isModerator={currentUser.isModerator}
chatAvailable={isChatAvailable} chatAvailable={isChatAvailable}
showInput={!!currentUser} showInput={!!currentUser}
inputEnabled={chatInputEnabled}
inputDisabledPlaceholder={chatInputDisabledMessage}
desktop desktop
/> />
)} )}
@@ -351,6 +369,8 @@ export const Content: FC = () => {
messages={messages} messages={messages}
currentUser={currentUser} currentUser={currentUser}
handleClose={() => setShowChatModal(false)} handleClose={() => setShowChatModal(false)}
inputEnabled={chatInputEnabled}
inputDisabledPlaceholder={chatInputDisabledMessage}
/> />
)} )}
{isMobile && isChatAvailable && !chatDisabled && ( {isMobile && isChatAvailable && !chatDisabled && (
+1
View File
@@ -166,6 +166,7 @@
"unsupportedLocal": "Browser notifications are not supported for local servers." "unsupportedLocal": "Browser notifications are not supported for local servers."
}, },
"Chat": { "Chat": {
"authenticateToChat": "Authenticate to chat",
"moderatorNotification": "You are now a moderator.", "moderatorNotification": "You are now a moderator.",
"nameChangeText": "is now known as", "nameChangeText": "is now known as",
"userJoined": "joined the chat.", "userJoined": "joined the chat.",
+2
View File
@@ -9,6 +9,7 @@ export interface ClientConfig {
extraPageContent: string; extraPageContent: string;
socialHandles: SocialHandle[]; socialHandles: SocialHandle[];
chatDisabled: boolean; chatDisabled: boolean;
chatRequireAuthentication: boolean;
externalActions: any[]; externalActions: any[];
customStyles: string; customStyles: string;
appearanceVariables: Map<string, string>; appearanceVariables: Map<string, string>;
@@ -55,6 +56,7 @@ export function makeEmptyClientConfig(): ClientConfig {
extraPageContent: '', extraPageContent: '',
socialHandles: [], socialHandles: [],
chatDisabled: false, chatDisabled: false,
chatRequireAuthentication: false,
externalActions: [], externalActions: [],
customStyles: '', customStyles: '',
appearanceVariables: new Map(), appearanceVariables: new Map(),
+212 -75
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -19,6 +19,7 @@ import {
FIELD_PROPS_ENABLE_CHAT_SLUR_FILTER, FIELD_PROPS_ENABLE_CHAT_SLUR_FILTER,
CHAT_ESTABLISHED_USER_MODE, CHAT_ESTABLISHED_USER_MODE,
FIELD_PROPS_DISABLE_CHAT, FIELD_PROPS_DISABLE_CHAT,
FIELD_PROPS_CHAT_REQUIRE_AUTHENTICATION,
postConfigUpdateToAPI, postConfigUpdateToAPI,
RESET_TIMEOUT, RESET_TIMEOUT,
TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES, TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES,
@@ -47,6 +48,7 @@ export default function ConfigChat() {
chatEstablishedUserMode, chatEstablishedUserMode,
chatSpamProtectionEnabled, chatSpamProtectionEnabled,
chatSlurFilterEnabled, chatSlurFilterEnabled,
chatRequireAuthentication,
} = serverConfig; } = serverConfig;
const { welcomeMessage } = instanceDetails; const { welcomeMessage } = instanceDetails;
@@ -77,6 +79,10 @@ export default function ConfigChat() {
handleFieldChange({ fieldName: 'chatSlurFilterEnabled', value: enabled }); handleFieldChange({ fieldName: 'chatSlurFilterEnabled', value: enabled });
} }
function handleChatRequireAuthenticationChange(enabled: boolean) {
handleFieldChange({ fieldName: 'chatRequireAuthentication', value: enabled });
}
function resetForbiddenUsernameState() { function resetForbiddenUsernameState() {
setForbiddenUsernameSaveState(null); setForbiddenUsernameSaveState(null);
} }
@@ -169,6 +175,7 @@ export default function ConfigChat() {
chatEstablishedUserMode, chatEstablishedUserMode,
chatSpamProtectionEnabled, chatSpamProtectionEnabled,
chatSlurFilterEnabled, chatSlurFilterEnabled,
chatRequireAuthentication,
}); });
}, [serverConfig]); }, [serverConfig]);
@@ -250,6 +257,12 @@ export default function ConfigChat() {
checked={formDataValues.chatSlurFilterEnabled} checked={formDataValues.chatSlurFilterEnabled}
onChange={handleChatSlurFilterChange} onChange={handleChatSlurFilterChange}
/> />
<ToggleSwitch
fieldName="chatRequireAuthentication"
{...FIELD_PROPS_CHAT_REQUIRE_AUTHENTICATION}
checked={formDataValues.chatRequireAuthentication}
onChange={handleChatRequireAuthenticationChange}
/>
</div> </div>
</Col> </Col>
</Row> </Row>
+18 -1
View File
@@ -2,6 +2,7 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'next-export-i18n';
import { ChatMessage } from '../../../../interfaces/chat-message.model'; import { ChatMessage } from '../../../../interfaces/chat-message.model';
import { ChatContainer } from '../../../../components/chat/ChatContainer/ChatContainer'; import { ChatContainer } from '../../../../components/chat/ChatContainer/ChatContainer';
import { import {
@@ -12,6 +13,7 @@ import {
appStateAtom, appStateAtom,
serverStatusState, serverStatusState,
isChatAvailableSelector, isChatAvailableSelector,
chatAuthenticatedAtom,
} from '../../../../components/stores/ClientConfigStore'; } from '../../../../components/stores/ClientConfigStore';
import Header from '../../../../components/ui/Header/Header'; import Header from '../../../../components/ui/Header/Header';
import { ClientConfig } from '../../../../interfaces/client-config.model'; import { ClientConfig } from '../../../../interfaces/client-config.model';
@@ -19,8 +21,10 @@ import { AppStateOptions } from '../../../../components/stores/application-state
import { ServerStatus } from '../../../../interfaces/server-status.model'; import { ServerStatus } from '../../../../interfaces/server-status.model';
import { Theme } from '../../../../components/theme/Theme'; import { Theme } from '../../../../components/theme/Theme';
import { ComponentError } from '../../../../components/ui/ComponentError/ComponentError'; import { ComponentError } from '../../../../components/ui/ComponentError/ComponentError';
import { Localization } from '../../../../types/localization';
export default function ReadWriteChatEmbed() { export default function ReadWriteChatEmbed() {
const { t } = useTranslation();
const currentUser = useRecoilValue(currentUserAtom); const currentUser = useRecoilValue(currentUserAtom);
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector); const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom); const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
@@ -28,8 +32,19 @@ export default function ReadWriteChatEmbed() {
const appState = useRecoilValue<AppStateOptions>(appStateAtom); const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const isChatAvailable = useRecoilValue(isChatAvailableSelector); const isChatAvailable = useRecoilValue(isChatAvailableSelector);
const isUserAuthenticated = useRecoilValue<boolean>(chatAuthenticatedAtom);
const { name, chatDisabled } = clientConfig; const { name, chatDisabled, chatRequireAuthentication } = clientConfig;
// Determine if chat input should be enabled based on authentication requirements.
// Moderators bypass the authentication requirement.
const chatInputEnabled = !!(
isChatAvailable &&
(!chatRequireAuthentication || isUserAuthenticated || currentUser?.isModerator)
);
const chatInputDisabledMessage = chatRequireAuthentication
? t(Localization.Frontend.Chat.authenticateToChat)
: t(Localization.Frontend.chatDisabled);
const { videoAvailable } = appState; const { videoAvailable } = appState;
const { streamTitle, online } = clientStatus; const { streamTitle, online } = clientStatus;
@@ -73,6 +88,8 @@ export default function ReadWriteChatEmbed() {
showInput showInput
height="92vh" height="92vh"
chatAvailable={isChatAvailable} chatAvailable={isChatAvailable}
inputEnabled={chatInputEnabled}
inputDisabledPlaceholder={chatInputDisabledMessage}
/> />
</div> </div>
)} )}
+1
View File
@@ -31,6 +31,7 @@ const Page = () => {
const fakeConfig: ClientConfig = { const fakeConfig: ClientConfig = {
chatDisabled: false, chatDisabled: false,
chatRequireAuthentication: false,
name: 'Fake Owncast Server', name: 'Fake Owncast Server',
summary: '', summary: '',
logo: '', logo: '',
+1
View File
@@ -154,6 +154,7 @@ export interface ConfigDetails {
chatDisabled: boolean; chatDisabled: boolean;
chatSpamProtectionEnabled: boolean; chatSpamProtectionEnabled: boolean;
chatSlurFilterEnabled: boolean; chatSlurFilterEnabled: boolean;
chatRequireAuthentication: boolean;
federation: Federation; federation: Federation;
notifications: NotificationsConfig; notifications: NotificationsConfig;
chatJoinMessagesEnabled: boolean; chatJoinMessagesEnabled: boolean;
+2 -2
View File
@@ -68,8 +68,7 @@ export const Localization = {
deniedTitle: 'Frontend.BrowserNotifyModal.deniedTitle', deniedTitle: 'Frontend.BrowserNotifyModal.deniedTitle',
deniedDescription: 'Frontend.BrowserNotifyModal.deniedDescription', deniedDescription: 'Frontend.BrowserNotifyModal.deniedDescription',
mainDescription: 'Frontend.BrowserNotifyModal.mainDescription', mainDescription: 'Frontend.BrowserNotifyModal.mainDescription',
learnMoreAboutNotifications: learnMoreAboutNotifications: 'Frontend.BrowserNotifyModal.learnMoreAboutNotifications',
'Frontend.BrowserNotifyModal.learnMoreAboutNotifications',
errorTitle: 'Frontend.BrowserNotifyModal.errorTitle', errorTitle: 'Frontend.BrowserNotifyModal.errorTitle',
errorMessage: 'Frontend.BrowserNotifyModal.errorMessage', errorMessage: 'Frontend.BrowserNotifyModal.errorMessage',
}, },
@@ -107,6 +106,7 @@ export const Localization = {
userLeft: 'Frontend.Chat.userLeft', userLeft: 'Frontend.Chat.userLeft',
nameChangeText: 'Frontend.Chat.nameChangeText', nameChangeText: 'Frontend.Chat.nameChangeText',
moderatorNotification: 'Frontend.Chat.moderatorNotification', moderatorNotification: 'Frontend.Chat.moderatorNotification',
authenticateToChat: 'Frontend.Chat.authenticateToChat',
}, },
// Follow modal component // Follow modal component
+9
View File
@@ -40,6 +40,7 @@ const API_CHAT_JOIN_MESSAGES_ENABLED = '/chat/joinmessagesenabled';
const API_CHAT_ESTABLISHED_MODE = '/chat/establishedusermode'; const API_CHAT_ESTABLISHED_MODE = '/chat/establishedusermode';
const API_CHAT_SPAM_PROTECTION_ENABLED = '/chat/spamprotectionenabled'; const API_CHAT_SPAM_PROTECTION_ENABLED = '/chat/spamprotectionenabled';
const API_CHAT_SLUR_FILTER_ENABLED = '/chat/slurfilterenabled'; const API_CHAT_SLUR_FILTER_ENABLED = '/chat/slurfilterenabled';
const API_CHAT_REQUIRE_AUTHENTICATION = '/chat/requireauthentication';
const API_DISABLE_SEARCH_INDEXING = '/disablesearchindexing'; const API_DISABLE_SEARCH_INDEXING = '/disablesearchindexing';
const API_SOCKET_HOST_OVERRIDE = '/sockethostoverride'; const API_SOCKET_HOST_OVERRIDE = '/sockethostoverride';
const API_VIDEO_SERVING_ENDPOINT = '/videoservingendpoint'; const API_VIDEO_SERVING_ENDPOINT = '/videoservingendpoint';
@@ -292,6 +293,14 @@ export const CHAT_ESTABLISHED_USER_MODE = {
useSubmit: true, useSubmit: true,
}; };
export const FIELD_PROPS_CHAT_REQUIRE_AUTHENTICATION = {
apiPath: API_CHAT_REQUIRE_AUTHENTICATION,
configPath: '',
label: 'Require Authentication',
tip: 'Only users who have authenticated may chat.',
useSubmit: true,
};
export const TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES = { export const TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES = {
apiPath: API_CHAT_FORBIDDEN_USERNAMES, apiPath: API_CHAT_FORBIDDEN_USERNAMES,
placeholder: 'username', placeholder: 'username',
+2 -1
View File
@@ -71,6 +71,7 @@ const initialServerConfigState: ConfigDetails = {
chatDisabled: false, chatDisabled: false,
chatSpamProtectionEnabled: true, chatSpamProtectionEnabled: true,
chatSlurFilterEnabled: false, chatSlurFilterEnabled: false,
chatRequireAuthentication: false,
chatJoinMessagesEnabled: true, chatJoinMessagesEnabled: true,
chatEstablishedUserMode: false, chatEstablishedUserMode: false,
hideViewerCount: false, hideViewerCount: false,
@@ -127,6 +128,7 @@ const ServerStatusProvider: FC<ServerStatusProviderProps> = ({ children }) => {
setStatus({ ...result, error: { type: null, msg: null } }); setStatus({ ...result, error: { type: null, msg: null } });
} catch (error) { } catch (error) {
console.error('Failed to fetch server status:', error);
setStatus(initialStatus => ({ setStatus(initialStatus => ({
...initialStatus, ...initialStatus,
error: { error: {
@@ -134,7 +136,6 @@ const ServerStatusProvider: FC<ServerStatusProviderProps> = ({ children }) => {
msg: 'Cannot connect to the Owncast service. Please check you are connected to the internet and the Owncast server is running.', msg: 'Cannot connect to the Owncast service. Please check you are connected to the internet and the Owncast server is running.',
}, },
})); }));
// todo
} }
}; };
const getConfig = async () => { const getConfig = async () => {
+19
View File
@@ -877,6 +877,25 @@ func SetChatSlurFilterEnabled(w http.ResponseWriter, r *http.Request) {
webutils.WriteSimpleResponse(w, true, "chat message slur filter changed") webutils.WriteSimpleResponse(w, true, "chat message slur filter changed")
} }
// SetChatRequireAuthentication will enable or disable requiring authentication for chat.
func SetChatRequireAuthentication(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
configRepository := configrepository.Get()
if err := configRepository.SetChatRequireAuthentication(configValue.Value.(bool)); err != nil {
webutils.WriteSimpleResponse(w, false, err.Error())
return
}
webutils.WriteSimpleResponse(w, true, "chat authentication requirement changed")
}
func requirePOST(w http.ResponseWriter, r *http.Request) bool { func requirePOST(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
webutils.WriteSimpleResponse(w, false, r.Method+" not supported") webutils.WriteSimpleResponse(w, false, r.Method+" not supported")
+2
View File
@@ -66,6 +66,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ChatEstablishedUserMode: configRepository.GetChatEstbalishedUsersOnlyMode(), ChatEstablishedUserMode: configRepository.GetChatEstbalishedUsersOnlyMode(),
ChatSpamProtectionEnabled: configRepository.GetChatSpamProtectionEnabled(), ChatSpamProtectionEnabled: configRepository.GetChatSpamProtectionEnabled(),
ChatSlurFilterEnabled: configRepository.GetChatSlurFilterEnabled(), ChatSlurFilterEnabled: configRepository.GetChatSlurFilterEnabled(),
ChatRequireAuthentication: configRepository.GetChatRequireAuthentication(),
HideViewerCount: configRepository.GetHideViewerCount(), HideViewerCount: configRepository.GetHideViewerCount(),
DisableSearchIndexing: configRepository.GetDisableSearchIndexing(), DisableSearchIndexing: configRepository.GetDisableSearchIndexing(),
VideoSettings: videoSettings{ VideoSettings: videoSettings{
@@ -129,6 +130,7 @@ type serverConfigAdminResponse struct {
ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"` ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"`
ChatSpamProtectionEnabled bool `json:"chatSpamProtectionEnabled"` ChatSpamProtectionEnabled bool `json:"chatSpamProtectionEnabled"`
ChatSlurFilterEnabled bool `json:"chatSlurFilterEnabled"` ChatSlurFilterEnabled bool `json:"chatSlurFilterEnabled"`
ChatRequireAuthentication bool `json:"chatRequireAuthentication"`
DisableSearchIndexing bool `json:"disableSearchIndexing"` DisableSearchIndexing bool `json:"disableSearchIndexing"`
StreamKeyOverridden bool `json:"streamKeyOverridden"` StreamKeyOverridden bool `json:"streamKeyOverridden"`
HideViewerCount bool `json:"hideViewerCount"` HideViewerCount bool `json:"hideViewerCount"`
+2
View File
@@ -36,6 +36,7 @@ type webConfigResponse struct {
HideViewerCount bool `json:"hideViewerCount"` HideViewerCount bool `json:"hideViewerCount"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ChatSpamProtectionDisabled bool `json:"chatSpamProtectionDisabled"` ChatSpamProtectionDisabled bool `json:"chatSpamProtectionDisabled"`
ChatRequireAuthentication bool `json:"chatRequireAuthentication"`
NSFW bool `json:"nsfw"` NSFW bool `json:"nsfw"`
Authentication authenticationConfigResponse `json:"authentication"` Authentication authenticationConfigResponse `json:"authentication"`
} }
@@ -134,6 +135,7 @@ func getConfigResponse() webConfigResponse {
SocialHandles: socialHandles, SocialHandles: socialHandles,
ChatDisabled: configRepository.GetChatDisabled(), ChatDisabled: configRepository.GetChatDisabled(),
ChatSpamProtectionDisabled: configRepository.GetChatSpamProtectionEnabled(), ChatSpamProtectionDisabled: configRepository.GetChatSpamProtectionEnabled(),
ChatRequireAuthentication: configRepository.GetChatRequireAuthentication(),
ExternalActions: configRepository.GetExternalActions(), ExternalActions: configRepository.GetExternalActions(),
CustomStyles: configRepository.GetCustomStyles(), CustomStyles: configRepository.GetCustomStyles(),
MaxSocketPayloadSize: config.MaxSocketPayloadSize, MaxSocketPayloadSize: config.MaxSocketPayloadSize,
+8
View File
@@ -127,6 +127,14 @@ func (*ServerInterfaceImpl) SetChatSlurFilterEnabledOptions(w http.ResponseWrite
middleware.RequireAdminAuth(admin.SetChatSlurFilterEnabled)(w, r) middleware.RequireAdminAuth(admin.SetChatSlurFilterEnabled)(w, r)
} }
func (*ServerInterfaceImpl) SetChatRequireAuthentication(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.SetChatRequireAuthentication)(w, r)
}
func (*ServerInterfaceImpl) SetChatRequireAuthenticationOptions(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.SetChatRequireAuthentication)(w, r)
}
func (*ServerInterfaceImpl) SetVideoCodec(w http.ResponseWriter, r *http.Request) { func (*ServerInterfaceImpl) SetVideoCodec(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.SetVideoCodec)(w, r) middleware.RequireAdminAuth(admin.SetVideoCodec)(w, r)
} }
@@ -1,6 +1,6 @@
// Package generated provides primitives to interact with the openapi HTTP API. // Package generated provides primitives to interact with the openapi HTTP API.
// //
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT.
package generated package generated
import ( import (
@@ -918,6 +918,9 @@ type SetForbiddenUsernameListJSONRequestBody SetForbiddenUsernameListJSONBody
// SetChatJoinMessagesEnabledJSONRequestBody defines body for SetChatJoinMessagesEnabled for application/json ContentType. // SetChatJoinMessagesEnabledJSONRequestBody defines body for SetChatJoinMessagesEnabled for application/json ContentType.
type SetChatJoinMessagesEnabledJSONRequestBody = AdminConfigValue type SetChatJoinMessagesEnabledJSONRequestBody = AdminConfigValue
// SetChatRequireAuthenticationJSONRequestBody defines body for SetChatRequireAuthentication for application/json ContentType.
type SetChatRequireAuthenticationJSONRequestBody = AdminConfigValue
// SetChatSlurFilterEnabledJSONRequestBody defines body for SetChatSlurFilterEnabled for application/json ContentType. // SetChatSlurFilterEnabledJSONRequestBody defines body for SetChatSlurFilterEnabled for application/json ContentType.
type SetChatSlurFilterEnabledJSONRequestBody = AdminConfigValue type SetChatSlurFilterEnabledJSONRequestBody = AdminConfigValue
File diff suppressed because it is too large Load Diff