diff --git a/README.MD b/README.MD index b6f6bfa..9a5bb20 100644 --- a/README.MD +++ b/README.MD @@ -3,6 +3,10 @@ ### What is this? Justlog is an twitch irc bot. It focuses on logging and providing an api for the logs. +### Optout + +Click the X icon on the web ui to find a explanation how to opt out. + ### API API documentation can be viewed via the justlog frontend by clicking the "docs" symbol: diff --git a/api/optout.go b/api/optout.go new file mode 100644 index 0000000..6f051f2 --- /dev/null +++ b/api/optout.go @@ -0,0 +1,45 @@ +package api + +import ( + "math/rand" + "net/http" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// swagger:route POST /optout justlog +// +// Generates optout code to use in chat +// +// Produces: +// - application/json +// +// Schemes: https +// +// Responses: +// 200: string +func (s *Server) writeOptOutCode(w http.ResponseWriter, r *http.Request) { + + code := randomString(6) + + s.bot.OptoutCodes.Store(code, true) + go func() { + time.Sleep(time.Second * 60) + s.bot.OptoutCodes.Delete(code) + }() + + writeJSON(code, http.StatusOK, w, r) +} + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890") + +func randomString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/api/server.go b/api/server.go index 031e823..e64c295 100644 --- a/api/server.go +++ b/api/server.go @@ -131,6 +131,11 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { return } + if url == "/optout" && r.Method == http.MethodPost { + s.writeOptOutCode(w, r) + return + } + if strings.HasPrefix(url, "/admin/channels") { success := s.authenticateAdmin(w, r) if success { @@ -195,11 +200,11 @@ func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) bool { var logs *chatLog if request.time.random { - if request.isUserRequest { - logs, err = s.getRandomQuote(request) - } else { - logs, err = s.getChannelRandomQuote(request) - } + if request.isUserRequest { + logs, err = s.getRandomQuote(request) + } else { + logs, err = s.getChannelRandomQuote(request) + } } else if request.time.from != "" && request.time.to != "" { if request.isUserRequest { logs, err = s.getUserLogsRange(request) diff --git a/bot/commands.go b/bot/commands.go index 54c4d68..79ecaac 100644 --- a/bot/commands.go +++ b/bot/commands.go @@ -20,10 +20,6 @@ func (b *Bot) handlePrivateMessageCommands(message twitch.PrivateMessage) { return } - if !contains(b.cfg.Admins, message.User.Name) { - return - } - args := strings.Fields(message.Message[len(commandPrefix):]) if len(args) < 1 { return @@ -33,19 +29,30 @@ func (b *Bot) handlePrivateMessageCommands(message twitch.PrivateMessage) { switch commandName { case "status": + if !contains(b.cfg.Admins, message.User.Name) { + return + } uptime := humanize.TimeSince(b.startTime) b.Say(message.Channel, fmt.Sprintf("%s, uptime: %s", message.User.DisplayName, uptime)) case "join": + if !contains(b.cfg.Admins, message.User.Name) { + return + } b.handleJoin(message, args) case "part": + if !contains(b.cfg.Admins, message.User.Name) { + return + } b.handlePart(message, args) case "optout": b.handleOptOut(message, args) - case "optin": + if !contains(b.cfg.Admins, message.User.Name) { + return + } b.handleOptIn(message, args) } } @@ -100,6 +107,16 @@ func (b *Bot) handleOptOut(message twitch.PrivateMessage, args []string) { return } + if _, ok := b.OptoutCodes.LoadAndDelete(args[0]); ok { + b.cfg.OptOutUsers(message.User.ID) + b.Say(message.Channel, fmt.Sprintf("%s, opted you out", message.User.DisplayName)) + return + } + + if !contains(b.cfg.Admins, message.User.Name) { + return + } + users, err := b.helixClient.GetUsersByUsernames(args) if err != nil { log.Error(err) diff --git a/bot/main.go b/bot/main.go index 56eeeb3..9ec7520 100644 --- a/bot/main.go +++ b/bot/main.go @@ -23,6 +23,7 @@ type Bot struct { worker []*worker channels map[string]helix.UserData clearchats sync.Map + OptoutCodes sync.Map } type worker struct { @@ -43,6 +44,7 @@ func NewBot(cfg *config.Config, helixClient helix.TwitchApiClient, fileLogger *f fileLogger: fileLogger, channels: channels, worker: []*worker{}, + OptoutCodes: sync.Map{}, } } diff --git a/web/src/components/Filters.tsx b/web/src/components/Filters.tsx index 5379c36..3ca0289 100644 --- a/web/src/components/Filters.tsx +++ b/web/src/components/Filters.tsx @@ -6,6 +6,7 @@ import styled from "styled-components"; import { useChannels } from "../hooks/useChannels"; import { store } from "../store"; import { Docs } from "./Docs"; +import { Optout } from "./Optout"; import { Settings } from "./Settings"; const FiltersContainer = styled.form` @@ -66,6 +67,7 @@ export function Filters() { + } \ No newline at end of file diff --git a/web/src/components/Optout.tsx b/web/src/components/Optout.tsx new file mode 100644 index 0000000..92d6df3 --- /dev/null +++ b/web/src/components/Optout.tsx @@ -0,0 +1,87 @@ +import { IconButton, Button } from "@material-ui/core"; +import { useContext, useState } from "react"; +import styled from "styled-components"; +import { store } from "../store"; +import CancelIcon from '@material-ui/icons/Cancel'; + +const OptoutWrapper = styled.div` + +`; + +export function Optout() { + const { state, setShowOptout } = useContext(store); + + const handleClick = () => { + setShowOptout(!state.showOptout); + } + + return + + + + ; +} + +const OptoutPanelWrapper = styled.div` + background: var(--bg-bright); + color: var(--text); + margin: 3rem; + font-size: 1.5rem; + padding: 2rem; + + code { + background: var(--bg); + padding: 1rem; + border-radius: 3px; + } + + .generator { + margin-top: 2rem; + display: flex; + gap: 1rem; + align-items: center; + + input { + background: var(--bg); + border: none; + color: white; + padding: 0.6rem; + font-size: 1.5rem; + text-align: center; + border-radius: 3px; + } + } + + .small { + font-size: 0.8rem; + font-family: monospace; + } +`; + +export function OptoutPanel() { + const { state } = useContext(store); + const [code, setCode] = useState(""); + + const generateCode = () => { + fetch(state.apiBaseUrl + "/optout", { method: "POST" }).then(res => res.json()).then(setCode).catch(console.error); + }; + + return +

+ You can opt out from being logged. This will also disable access to your previously logged data.
+ Opting out is permanent, there is not reverse action. So think twice if you want to opt out. +

+

+ If you still want to optout generate a token here and paste the command into a logged chat.
+ You will receive a confirmation message from the bot "@username, opted you out". +

+
+
!justlog optout {""}
+
+ +
+ {code &&

+ This code is valid for 60 seconds +

} +
; +} \ No newline at end of file diff --git a/web/src/components/Page.tsx b/web/src/components/Page.tsx index b89d82f..b3ee216 100644 --- a/web/src/components/Page.tsx +++ b/web/src/components/Page.tsx @@ -1,15 +1,20 @@ -import React from "react"; +import React, { useContext } from "react"; import styled from "styled-components"; +import { store } from "../store"; import { Filters } from "./Filters"; import { LogContainer } from "./LogContainer"; +import { OptoutPanel } from "./Optout"; const PageContainer = styled.div` `; export function Page() { + const {state} = useContext(store); + return + {state.showOptout && } ; } \ No newline at end of file diff --git a/web/src/store.tsx b/web/src/store.tsx index a0a2010..ed70ec3 100644 --- a/web/src/store.tsx +++ b/web/src/store.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useState } from "react"; -import { QueryClient } from 'react-query' +import { createContext, useState } from "react"; +import { QueryClient } from 'react-query'; import { useLocalStorage } from "./hooks/useLocalStorage"; export interface Settings { @@ -32,6 +32,7 @@ export interface State { error: boolean, activeSearchField: HTMLInputElement | null, showSwagger: boolean, + showOptout: boolean, } export type Action = Record; @@ -40,7 +41,7 @@ const url = new URL(window.location.href); const defaultContext = { state: { queryClient: new QueryClient(), - apiBaseUrl: process.env.REACT_APP_API_BASE_URL ?? window.location.protocol + "//" + window.location.host, + apiBaseUrl: process?.env.REACT_APP_API_BASE_URL ?? window.location.protocol + "//" + window.location.host, settings: { showEmotes: { displayName: "Show Emotes", @@ -66,12 +67,14 @@ const defaultContext = { currentChannel: url.searchParams.get("channel"), currentUsername: url.searchParams.get("username"), showSwagger: url.searchParams.has("swagger"), + showOptout: url.searchParams.has("optout"), error: false, } as State, setState: (state: State) => { }, setCurrents: (currentChannel: string | null = null, currentUsername: string | null = null) => { }, setSettings: (newSettings: Settings) => { }, setShowSwagger: (show: boolean) => { }, + setShowOptout: (show: boolean) => { }, }; const store = createContext(defaultContext); @@ -87,13 +90,29 @@ const StateProvider = ({ children }: { children: JSX.Element }): JSX.Element => if (show) { url.searchParams.set("swagger", "") + url.searchParams.delete("optout"); } else { url.searchParams.delete("swagger"); } window.history.replaceState({}, "justlog", url.toString()); - setState({ ...state, showSwagger: show }) + setState({ ...state, showSwagger: show, showOptout: false }) + } + + const setShowOptout = (show: boolean) => { + const url = new URL(window.location.href); + + if (show) { + url.searchParams.set("optout", ""); + url.searchParams.delete("swagger"); + } else { + url.searchParams.delete("optout"); + } + + window.history.replaceState({}, "justlog", url.toString()); + + setState({ ...state, showOptout: show, showSwagger: false }) } const setSettings = (newSettings: Settings) => { @@ -126,7 +145,7 @@ const StateProvider = ({ children }: { children: JSX.Element }): JSX.Element => window.history.replaceState({}, "justlog", url.toString()); } - return {children}; + return {children}; }; export { store, StateProvider };