diff --git a/.github/workflows/build-next.yml b/.github/workflows/build-next.yml
index 383961321..6e8488b97 100644
--- a/.github/workflows/build-next.yml
+++ b/.github/workflows/build-next.yml
@@ -13,4 +13,4 @@ jobs:
uses: actions/checkout@v2
- name: Install dependencies
- run: cd web && npm install && npm run build
+ run: cd web && npm install --include=dev --force && npm run build
diff --git a/.github/workflows/build-storybook.yml b/.github/workflows/build-storybook.yml
index 3d15a4d3a..11a03fb61 100644
--- a/.github/workflows/build-storybook.yml
+++ b/.github/workflows/build-storybook.yml
@@ -1,7 +1,7 @@
name: Build and Deploy Components+Style Guide
on:
push:
- paths: ["web/stories/**", "web/components/**"] # Trigger the action only when files change in the folders defined here
+ paths: ['web/stories/**', 'web/components/**'] # Trigger the action only when files change in the folders defined here
jobs:
build-and-deploy:
runs-on: ubuntu-latest
@@ -14,7 +14,7 @@ jobs:
- name: Install and Build
run: | # Install npm packages and build the Storybook files
cd web
- npm install --include-dev
+ npm install --include-dev --force
npm run build-storybook -- -o ../docs/components
- name: Dispatch event to web site
diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml
index bdea52c0b..d60838bfb 100644
--- a/.github/workflows/chromatic.yml
+++ b/.github/workflows/chromatic.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Install dependencies
- run: npm install --include=dev
+ run: npm install --include=dev --force
# 👇 Adds Chromatic as a step in the workflow
- name: Publish to Chromatic
uses: chromaui/action@v1
diff --git a/.github/workflows/javascript-formatting.yml b/.github/workflows/javascript-formatting.yml
index 3587ee18a..dc1f8d5f3 100644
--- a/.github/workflows/javascript-formatting.yml
+++ b/.github/workflows/javascript-formatting.yml
@@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v2
- name: Install Dependencies
- run: npm install
+ run: npm install --force
- name: Lint
run: npm run lint
diff --git a/controllers/moderation/moderation.go b/controllers/moderation/moderation.go
new file mode 100644
index 000000000..d799e4608
--- /dev/null
+++ b/controllers/moderation/moderation.go
@@ -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)
+ }
+}
diff --git a/core/chat/persistence.go b/core/chat/persistence.go
index d50bb06c6..4e93b7924 100644
--- a/core/chat/persistence.go
+++ b/core/chat/persistence.go
@@ -1,6 +1,8 @@
package chat
import (
+ "context"
+ "database/sql"
"fmt"
"strings"
"time"
@@ -300,6 +302,29 @@ func GetChatHistory() []interface{} {
return m
}
+// GetMessagesFromUser returns chat messages that were sent by a specific user.
+func GetMessagesFromUser(userID string) ([]events.UserMessageEvent, error) {
+ query, err := _datastore.GetQueries().GetMessagesFromUser(context.Background(), sql.NullString{String: userID, Valid: true})
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]events.UserMessageEvent, len(query))
+ for i, row := range query {
+ results[i] = events.UserMessageEvent{
+ Event: events.Event{
+ Timestamp: row.Timestamp.Time,
+ ID: row.ID,
+ },
+ MessageEvent: events.MessageEvent{
+ Body: row.Body.String,
+ },
+ }
+ }
+
+ return results, nil
+}
+
// SetMessageVisibilityForUserID will bulk change the visibility of messages for a user
// and then send out visibility changed events to chat clients.
func SetMessageVisibilityForUserID(userID string, visible bool) error {
diff --git a/db/db.go b/db/db.go
index cbb8c9a5d..f30b89ec0 100644
--- a/db/db.go
+++ b/db/db.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.13.0
+// sqlc v1.14.0
package db
diff --git a/db/models.go b/db/models.go
index 91d932907..369309471 100644
--- a/db/models.go
+++ b/db/models.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.13.0
+// sqlc v1.14.0
package db
@@ -52,6 +52,19 @@ type IpBan struct {
CreatedAt sql.NullTime
}
+type Message struct {
+ ID string
+ UserID sql.NullString
+ Body sql.NullString
+ EventType sql.NullString
+ HiddenAt sql.NullTime
+ Timestamp sql.NullTime
+ Title sql.NullString
+ Subtitle sql.NullString
+ Image sql.NullString
+ Link sql.NullString
+}
+
type Notification struct {
ID int32
Channel string
diff --git a/db/query.sql b/db/query.sql
index 6e386f802..b1fce3e15 100644
--- a/db/query.sql
+++ b/db/query.sql
@@ -99,6 +99,9 @@ UPDATE user_access_tokens SET user_id = $1 WHERE token = $2;
-- name: SetUserAsAuthenticated :exec
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1;
+-- name: GetMessagesFromUser :many
+SELECT id, body, hidden_at, timestamp FROM messages WHERE eventType = 'CHAT' AND user_id = $1 ORDER BY TIMESTAMP DESC;
+
-- name: IsDisplayNameAvailable :one
SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL;
diff --git a/db/query.sql.go b/db/query.sql.go
index 00975e9eb..19996759d 100644
--- a/db/query.sql.go
+++ b/db/query.sql.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.13.0
+// sqlc v1.14.0
// source: query.sql
package db
@@ -412,6 +412,45 @@ func (q *Queries) GetLocalPostCount(ctx context.Context) (int64, error) {
return count, err
}
+const getMessagesFromUser = `-- name: GetMessagesFromUser :many
+SELECT id, body, hidden_at, timestamp FROM messages WHERE eventType = 'CHAT' AND user_id = $1 ORDER BY TIMESTAMP DESC
+`
+
+type GetMessagesFromUserRow struct {
+ ID string
+ Body sql.NullString
+ HiddenAt sql.NullTime
+ Timestamp sql.NullTime
+}
+
+func (q *Queries) GetMessagesFromUser(ctx context.Context, userID sql.NullString) ([]GetMessagesFromUserRow, error) {
+ rows, err := q.db.QueryContext(ctx, getMessagesFromUser, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []GetMessagesFromUserRow
+ for rows.Next() {
+ var i GetMessagesFromUserRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Body,
+ &i.HiddenAt,
+ &i.Timestamp,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getNotificationDestinationsForChannel = `-- name: GetNotificationDestinationsForChannel :many
SELECT destination FROM notifications WHERE channel = $1
`
diff --git a/db/schema.sql b/db/schema.sql
index 33976add6..644813648 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -79,3 +79,21 @@ CREATE TABLE IF NOT EXISTS auth (
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX auth_token ON auth (token);
+
+CREATE TABLE IF NOT EXISTS messages (
+ "id" string NOT NULL,
+ "user_id" TEXT,
+ "body" TEXT,
+ "eventType" TEXT,
+ "hidden_at" DATE,
+ "timestamp" DATE,
+ "title" TEXT,
+ "subtitle" TEXT,
+ "image" TEXT,
+ "link" TEXT,
+ PRIMARY KEY (id)
+ );CREATE INDEX index ON messages (id, user_id, hidden_at, timestamp);
+ CREATE INDEX id ON messages (id);
+ CREATE INDEX user_id ON messages (user_id);
+ CREATE INDEX hidden_at ON messages (hidden_at);
+ CREATE INDEX timestamp ON messages (timestamp);
diff --git a/router/router.go b/router/router.go
index 9e52f7e79..bd0cab659 100644
--- a/router/router.go
+++ b/router/router.go
@@ -15,6 +15,7 @@ import (
"github.com/owncast/owncast/controllers/admin"
fediverseauth "github.com/owncast/owncast/controllers/auth/fediverse"
"github.com/owncast/owncast/controllers/auth/indieauth"
+ "github.com/owncast/owncast/controllers/moderation"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
@@ -323,6 +324,9 @@ func Start() error {
// Enable/disable a user
http.HandleFunc("/api/chat/users/setenabled", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateUserEnabled))
+ // Get a user's details
+ http.HandleFunc("/api/moderation/chat/user/", middleware.RequireUserModerationScopeAccesstoken(moderation.GetUserDetails))
+
// Configure Federation features
// enable/disable federation features
diff --git a/web/.storybook/main.js b/web/.storybook/main.js
index 11adedf7a..0543917b4 100644
--- a/web/.storybook/main.js
+++ b/web/.storybook/main.js
@@ -17,6 +17,7 @@ module.exports = {
'storybook-addon-designs',
'storybook-dark-mode',
'addon-screen-reader',
+ 'storybook-addon-fetch-mock',
],
webpackFinal: async (config, { configType }) => {
// @see https://github.com/storybookjs/storybook/issues/9070
diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js
index c1d5f4eb4..b67a3217d 100644
--- a/web/.storybook/preview.js
+++ b/web/.storybook/preview.js
@@ -6,6 +6,9 @@ import { themes } from '@storybook/theming';
import { DocsContainer } from './storybook-theme';
export const parameters = {
+ fetchMock: {
+ mocks: [],
+ },
actions: { argTypesRegex: '^on[A-Z].*' },
docs: {
container: DocsContainer,
diff --git a/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx b/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx
index 6da4799b4..2de8d66fb 100644
--- a/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx
+++ b/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx
@@ -4,16 +4,14 @@ import {
EyeInvisibleOutlined,
SmallDashOutlined,
} from '@ant-design/icons';
-import { Dropdown, Menu, MenuProps, Space, Modal } from 'antd';
+import { Dropdown, Menu, MenuProps, Space, Modal, message } from 'antd';
import { useState } from 'react';
import ChatModerationDetailsModal from './ChatModerationDetailsModal';
import s from './ChatModerationActionMenu.module.scss';
+import ChatModeration from '../../../services/moderation-service';
const { confirm } = Modal;
-const HIDE_MESSAGE_ENDPOINT = `/api/chat/messagevisibility`;
-const BAN_USER_ENDPOINT = `/api/chat/users/setenabled`;
-
/* eslint-disable @typescript-eslint/no-unused-vars */
interface Props {
accessToken: string;
@@ -27,42 +25,20 @@ export default function ChatModerationActionMenu(props: Props) {
const [showUserDetailsModal, setShowUserDetailsModal] = useState(false);
const handleBanUser = async () => {
- const url = new URL(BAN_USER_ENDPOINT);
- url.searchParams.append('accessToken', accessToken);
- const hideMessageUrl = url.toString();
-
- const options = {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ userID }),
- };
-
try {
- await fetch(hideMessageUrl, options);
+ await ChatModeration.banUser(userID, accessToken);
} catch (e) {
console.error(e);
+ message.error(e);
}
};
const handleHideMessage = async () => {
- const url = new URL(HIDE_MESSAGE_ENDPOINT);
- url.searchParams.append('accessToken', accessToken);
- const hideMessageUrl = url.toString();
-
- const options = {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ idArray: [messageID] }),
- };
-
try {
- await fetch(hideMessageUrl, options);
+ await ChatModeration.removeMessage(messageID, accessToken);
} catch (e) {
console.error(e);
+ message.error(e);
}
};
@@ -141,12 +117,14 @@ export default function ChatModerationActionMenu(props: Props) {