21 changed files with 543 additions and 60 deletions
@ -0,0 +1,71 @@ |
|||||||
|
package moderation |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"net/http" |
||||||
|
"strings" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/owncast/owncast/controllers" |
||||||
|
"github.com/owncast/owncast/core/chat" |
||||||
|
"github.com/owncast/owncast/core/chat/events" |
||||||
|
"github.com/owncast/owncast/core/user" |
||||||
|
log "github.com/sirupsen/logrus" |
||||||
|
) |
||||||
|
|
||||||
|
// GetUserDetails returns the details of a chat user for moderators.
|
||||||
|
func GetUserDetails(w http.ResponseWriter, r *http.Request) { |
||||||
|
type connectedClient struct { |
||||||
|
MessageCount int `json:"messageCount"` |
||||||
|
UserAgent string `json:"userAgent"` |
||||||
|
ConnectedAt time.Time `json:"connectedAt"` |
||||||
|
Geo string `json:"geo,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
type response struct { |
||||||
|
User *user.User `json:"user"` |
||||||
|
ConnectedClients []connectedClient `json:"connectedClients"` |
||||||
|
Messages []events.UserMessageEvent `json:"messages"` |
||||||
|
} |
||||||
|
|
||||||
|
pathComponents := strings.Split(r.URL.Path, "/") |
||||||
|
uid := pathComponents[len(pathComponents)-1] |
||||||
|
|
||||||
|
u := user.GetUserByID(uid) |
||||||
|
|
||||||
|
if u == nil { |
||||||
|
w.WriteHeader(http.StatusNotFound) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
c, _ := chat.GetClientsForUser(uid) |
||||||
|
clients := make([]connectedClient, len(c)) |
||||||
|
for i, c := range c { |
||||||
|
client := connectedClient{ |
||||||
|
MessageCount: c.MessageCount, |
||||||
|
UserAgent: c.UserAgent, |
||||||
|
ConnectedAt: c.ConnectedAt, |
||||||
|
} |
||||||
|
if c.Geo != nil { |
||||||
|
client.Geo = c.Geo.CountryCode |
||||||
|
} |
||||||
|
|
||||||
|
clients[i] = client |
||||||
|
} |
||||||
|
|
||||||
|
messages, err := chat.GetMessagesFromUser(uid) |
||||||
|
if err != nil { |
||||||
|
log.Errorln(err) |
||||||
|
} |
||||||
|
|
||||||
|
res := response{ |
||||||
|
User: u, |
||||||
|
ConnectedClients: clients, |
||||||
|
Messages: messages, |
||||||
|
} |
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json") |
||||||
|
if err := json.NewEncoder(w).Encode(res); err != nil { |
||||||
|
controllers.InternalErrorHandler(w, err) |
||||||
|
} |
||||||
|
} |
@ -1,27 +1,179 @@ |
|||||||
import { Col, Row } from 'antd'; |
import { Button, Col, Row, Spin } from 'antd'; |
||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import ChatModeration from '../../../services/moderation-service'; |
||||||
import s from './ChatModerationDetailsModal.module.scss'; |
import s from './ChatModerationDetailsModal.module.scss'; |
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */ |
|
||||||
interface Props { |
interface Props { |
||||||
// userID: string;
|
userId: string; |
||||||
|
accessToken: string; |
||||||
} |
} |
||||||
|
|
||||||
|
export interface UserDetails { |
||||||
|
user: User; |
||||||
|
connectedClients: Client[]; |
||||||
|
messages: Message[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Client { |
||||||
|
messageCount: number; |
||||||
|
userAgent: string; |
||||||
|
connectedAt: Date; |
||||||
|
geo: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Message { |
||||||
|
id: string; |
||||||
|
timestamp: Date; |
||||||
|
user: null; |
||||||
|
body: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface User { |
||||||
|
id: string; |
||||||
|
displayName: string; |
||||||
|
displayColor: number; |
||||||
|
createdAt: Date; |
||||||
|
previousNames: string[]; |
||||||
|
scopes: string[]; |
||||||
|
isBot: boolean; |
||||||
|
authenticated: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
const removeMessage = async (messageId: string, accessToken: string) => { |
||||||
|
try { |
||||||
|
ChatModeration.removeMessage(messageId, accessToken); |
||||||
|
} catch (e) { |
||||||
|
console.error(e); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const ValueRow = ({ label, value }: { label: string; value: string }) => ( |
||||||
|
<Row justify="space-around" align="middle"> |
||||||
|
<Col span={12}>{label}</Col> |
||||||
|
<Col span={12}>{value}</Col> |
||||||
|
</Row> |
||||||
|
); |
||||||
|
|
||||||
|
const ChatMessageRow = ({ |
||||||
|
id, |
||||||
|
body, |
||||||
|
accessToken, |
||||||
|
}: { |
||||||
|
id: string; |
||||||
|
body: string; |
||||||
|
accessToken: string; |
||||||
|
}) => ( |
||||||
|
<Row justify="space-around" align="middle"> |
||||||
|
<Col span={18}>{body}</Col> |
||||||
|
<Col> |
||||||
|
<Button onClick={() => removeMessage(id, accessToken)}>X</Button> |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
); |
||||||
|
|
||||||
|
const ConnectedClient = ({ client }: { client: Client }) => { |
||||||
|
const { messageCount, userAgent, connectedAt, geo } = client; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<ValueRow label="Messages Sent" value={`${messageCount}`} /> |
||||||
|
<ValueRow label="Geo" value={geo} /> |
||||||
|
<ValueRow label="Connected At" value={connectedAt.toString()} /> |
||||||
|
<ValueRow label="User Agent" value={userAgent} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
// eslint-disable-next-line react/prop-types
|
||||||
|
const UserColorBlock = ({ color }) => { |
||||||
|
const bg = `var(--theme-user-colors-${color})`; |
||||||
|
return ( |
||||||
|
<Row justify="space-around" align="middle"> |
||||||
|
<Col span={12}>Color</Col> |
||||||
|
<Col span={12}> |
||||||
|
<div className={s.colorBlock} style={{ backgroundColor: bg }}> |
||||||
|
{color} |
||||||
|
</div> |
||||||
|
</Col> |
||||||
|
</Row> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
export default function ChatModerationDetailsModal(props: Props) { |
export default function ChatModerationDetailsModal(props: Props) { |
||||||
|
const { userId, accessToken } = props; |
||||||
|
const [userDetails, setUserDetails] = useState<UserDetails | null>(null); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
|
||||||
|
const getDetails = async () => { |
||||||
|
try { |
||||||
|
const response = await (await fetch(`/api/moderation/chat/user/${userId}`)).json(); |
||||||
|
setUserDetails(response); |
||||||
|
setLoading(false); |
||||||
|
} catch (e) { |
||||||
|
console.error(e); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
getDetails(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
if (!userDetails) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const { user, connectedClients, messages } = userDetails; |
||||||
|
const { displayName, displayColor, createdAt, previousNames, scopes, isBot, authenticated } = |
||||||
|
user; |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div className={s.modalContainer}> |
<div className={s.modalContainer}> |
||||||
<Row justify="space-around" align="middle"> |
<Spin spinning={loading}> |
||||||
<Col span={12}>User created</Col> |
<h1>{displayName}</h1> |
||||||
<Col span={12}>xxxx</Col> |
<Row justify="space-around" align="middle"> |
||||||
</Row> |
{scopes.map(scope => ( |
||||||
|
<Col>{scope}</Col> |
||||||
|
))} |
||||||
|
{authenticated && <Col>Authenticated</Col>} |
||||||
|
{isBot && <Col>Bot</Col>} |
||||||
|
</Row> |
||||||
|
|
||||||
|
<UserColorBlock color={displayColor} /> |
||||||
|
|
||||||
|
<ValueRow label="User Created" value={createdAt.toString()} /> |
||||||
|
<ValueRow label="Previous Names" value={previousNames.join(',')} /> |
||||||
|
|
||||||
|
<hr /> |
||||||
|
|
||||||
<Row justify="space-around" align="middle"> |
<h2>Currently Connected</h2> |
||||||
<Col span={12}>Previous names</Col> |
{connectedClients.length > 0 && ( |
||||||
<Col span={12}>xxxx</Col> |
<Row gutter={[15, 15]} wrap> |
||||||
</Row> |
{connectedClients.map(client => ( |
||||||
|
<Col flex="auto"> |
||||||
|
<ConnectedClient client={client} /> |
||||||
|
</Col> |
||||||
|
))} |
||||||
|
</Row> |
||||||
|
)} |
||||||
|
|
||||||
<h1>Recent Chat Messages</h1> |
<hr /> |
||||||
|
{messages.length > 0 && ( |
||||||
|
<div> |
||||||
|
<h1>Recent Chat Messages</h1> |
||||||
|
|
||||||
<div className={s.chatHistory} /> |
<div className={s.chatHistory}> |
||||||
|
{messages.map(message => ( |
||||||
|
<ChatMessageRow |
||||||
|
key={message.id} |
||||||
|
id={message.id} |
||||||
|
body={message.body} |
||||||
|
accessToken={accessToken} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</Spin> |
||||||
</div> |
</div> |
||||||
); |
); |
||||||
} |
} |
||||||
|
@ -0,0 +1,38 @@ |
|||||||
|
const HIDE_MESSAGE_ENDPOINT = `/api/chat/messagevisibility`; |
||||||
|
const BAN_USER_ENDPOINT = `/api/chat/users/setenabled`; |
||||||
|
|
||||||
|
class ChatModerationService { |
||||||
|
public static async removeMessage(id: string, accessToken: string): Promise<any> { |
||||||
|
const url = new URL(HIDE_MESSAGE_ENDPOINT, window.location.toString()); |
||||||
|
url.searchParams.append('accessToken', accessToken); |
||||||
|
const hideMessageUrl = url.toString(); |
||||||
|
|
||||||
|
const options = { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify({ idArray: [id] }), |
||||||
|
}; |
||||||
|
|
||||||
|
await fetch(hideMessageUrl, options); |
||||||
|
} |
||||||
|
|
||||||
|
public static async banUser(id: string, accessToken: string): Promise<any> { |
||||||
|
const url = new URL(BAN_USER_ENDPOINT, window.location.toString()); |
||||||
|
url.searchParams.append('accessToken', accessToken); |
||||||
|
const hideMessageUrl = url.toString(); |
||||||
|
|
||||||
|
const options = { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify({ id }), |
||||||
|
}; |
||||||
|
|
||||||
|
await fetch(hideMessageUrl, options); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default ChatModerationService; |
Loading…
Reference in new issue