justlog/api/server.go
2023-10-18 03:54:57 -03:00

498 lines
12 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gempir/justlog/bot"
"github.com/gempir/justlog/config"
"github.com/gempir/justlog/helix"
log "github.com/sirupsen/logrus"
"github.com/gempir/go-twitch-irc/v3"
"github.com/gempir/justlog/filelog"
)
// Server api server
type Server struct {
listenAddress string
logPath string
bot *bot.Bot
cfg *config.Config
fileLogger filelog.Logger
helixClient helix.TwitchApiClient
assetsHandler http.Handler
}
// NewServer create api Server
func NewServer(cfg *config.Config, bot *bot.Bot, fileLogger filelog.Logger, helixClient helix.TwitchApiClient, assets fs.FS) Server {
build, err := fs.Sub(assets, "web/dist")
if err != nil {
log.Fatal("failed to read public assets")
}
return Server{
listenAddress: cfg.ListenAddress,
bot: bot,
logPath: cfg.LogsDirectory,
cfg: cfg,
fileLogger: fileLogger,
helixClient: helixClient,
assetsHandler: http.FileServer(http.FS(build)),
}
}
const (
responseTypeJSON = "json"
responseTypeText = "text"
responseTypeRaw = "raw"
)
var (
userHourLimit = 744.0
channelHourLimit = 24.0
)
type channel struct {
UserID string `json:"userID"`
Name string `json:"name"`
}
// swagger:model
type AllChannelsJSON struct {
Channels []channel `json:"channels"`
}
// swagger:model
type chatLog struct {
Messages []chatMessage `json:"messages"`
}
// swagger:model
type logList struct {
AvailableLogs []filelog.UserLogFile `json:"availableLogs"`
}
// swagger:model
type channelLogList struct {
AvailableLogs []filelog.ChannelLogFile `json:"availableLogs"`
}
type chatMessage struct {
Text string `json:"text"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Channel string `json:"channel"`
Timestamp timestamp `json:"timestamp"`
ID string `json:"id"`
Type twitch.MessageType `json:"type"`
Raw string `json:"raw"`
Tags map[string]string `json:"tags"`
}
// ErrorResponse a simple error response
type ErrorResponse struct {
Message string `json:"message"`
}
type timestamp struct {
time.Time
}
// Init start the server
func (s *Server) Init() {
http.Handle("/", corsHandler(http.HandlerFunc(s.route)))
log.Infof("Listening on %s", s.listenAddress)
log.Fatal(http.ListenAndServe(s.listenAddress, nil))
}
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
url := r.URL.EscapedPath()
query, err := s.fillUserids(w, r)
if err != nil {
return
}
if url == "/list" {
if s.cfg.IsOptedOut(query.Get("userid")) || s.cfg.IsOptedOut(query.Get("channelid")) {
http.Error(w, "User or channel is a cuck", http.StatusForbidden)
return
}
s.writeAvailableLogs(w, r, query)
return
}
if url == "/channels" {
s.writeAllChannels(w, r)
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 {
s.writeChannels(w, r)
}
return
}
routedLogs := s.routeLogs(w, r)
if !routedLogs {
s.assetsHandler.ServeHTTP(w, r)
return
}
}
func (s *Server) fillUserids(w http.ResponseWriter, r *http.Request) (url.Values, error) {
query := r.URL.Query()
if query.Get("userid") == "" && query.Get("user") != "" {
username := strings.ToLower(query.Get("user"))
users, err := s.helixClient.GetUsersByUsernames([]string{username})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
if len(users) == 0 {
err := fmt.Errorf("could not find users")
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return nil, err
}
query.Set("userid", users[username].ID)
}
if query.Get("channelid") == "" && query.Get("channel") != "" {
channelName := strings.ToLower(query.Get("channel"))
users, err := s.helixClient.GetUsersByUsernames([]string{channelName})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, err
}
if len(users) == 0 {
err := fmt.Errorf("could not find users")
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return nil, err
}
query.Set("channelid", users[channelName].ID)
}
return query, nil
}
func (s *Server) routeLogs(w http.ResponseWriter, r *http.Request) bool {
request, err := s.newLogRequestFromURL(r)
if err != nil {
return false
}
if request.redirectPath != "" {
http.Redirect(w, r, request.redirectPath, http.StatusFound)
return true
}
if s.cfg.IsOptedOut(request.channelid) || s.cfg.IsOptedOut(request.userid) {
http.Error(w, "User or channel has opted out", http.StatusForbidden)
return true
}
var logs *chatLog
if request.time.random {
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)
} else {
logs, err = s.getChannelLogsRange(request)
}
} else {
if request.isUserRequest {
logs, err = s.getUserLogs(request)
} else {
logs, err = s.getChannelLogs(request)
}
}
if err != nil {
log.Error(err)
http.Error(w, "could not load logs", http.StatusNotFound)
return true
}
// Disable content type sniffing for log output
w.Header().Set("X-Content-Type-Options", "nosniff")
currentYear := fmt.Sprintf("%d", int(time.Now().Year()))
currentMonth := fmt.Sprintf("%d", int(time.Now().Month()))
if (request.time.year != "" && request.time.month != "") && (request.time.year < currentYear || (request.time.year == currentYear && request.time.month < currentMonth)) {
writeCacheControl(w, r, time.Hour*8760)
} else {
writeCacheControlNoCache(w, r)
}
if request.responseType == responseTypeJSON {
writeJSON(logs, http.StatusOK, w, r)
return true
}
if request.responseType == responseTypeRaw {
writeRaw(logs, http.StatusOK, w, r)
return true
}
if request.responseType == responseTypeText {
writeText(logs, http.StatusOK, w, r)
return true
}
return false
}
func corsHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
} else {
w.Header().Set("Access-Control-Allow-Origin", "*")
h.ServeHTTP(w, r)
}
})
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
func reverseSlice(input []string) []string {
for i, j := 0, len(input)-1; i < j; i, j = i+1, j-1 {
input[i], input[j] = input[j], input[i]
}
return input
}
// swagger:route GET /channels justlog channels
//
// List currently logged channels
//
// Produces:
// - application/json
// - text/plain
//
// Schemes: https
//
// Responses:
// 200: AllChannelsJSON
func (s *Server) writeAllChannels(w http.ResponseWriter, r *http.Request) {
response := new(AllChannelsJSON)
response.Channels = []channel{}
users, err := s.helixClient.GetUsersByUserIds(s.cfg.Channels)
if err != nil {
log.Error(err)
http.Error(w, "Failure fetching data from twitch", http.StatusInternalServerError)
return
}
for _, user := range users {
response.Channels = append(response.Channels, channel{UserID: user.ID, Name: user.Login})
}
writeJSON(response, http.StatusOK, w, r)
}
func writeJSON(data interface{}, code int, w http.ResponseWriter, r *http.Request) {
js, err := json.Marshal(data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
w.Write(js)
}
func writeCacheControl(w http.ResponseWriter, r *http.Request, cacheDuration time.Duration) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", cacheDuration.Seconds()))
}
func writeCacheControlNoCache(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
}
func writeRaw(cLog *chatLog, code int, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
for _, cMessage := range cLog.Messages {
w.Write([]byte(cMessage.Raw + "\n"))
}
}
func writeText(cLog *chatLog, code int, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(code)
for _, cMessage := range cLog.Messages {
switch cMessage.Type {
case twitch.PRIVMSG:
w.Write([]byte(fmt.Sprintf("[%s] #%s %s: %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Username, cMessage.Text)))
case twitch.CLEARCHAT:
w.Write([]byte(fmt.Sprintf("[%s] #%s %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Text)))
case twitch.USERNOTICE:
w.Write([]byte(fmt.Sprintf("[%s] #%s %s\n", cMessage.Timestamp.Format("2006-01-2 15:04:05"), cMessage.Channel, cMessage.Text)))
}
}
}
func (t timestamp) MarshalJSON() ([]byte, error) {
return []byte("\"" + t.UTC().Format(time.RFC3339) + "\""), nil
}
func (t *timestamp) UnmarshalJSON(data []byte) error {
goTime, err := time.Parse(time.RFC3339, strings.TrimSuffix(strings.TrimPrefix(string(data[:]), "\""), "\""))
if err != nil {
return err
}
*t = timestamp{
goTime,
}
return nil
}
func createLogResult() chatLog {
return chatLog{Messages: []chatMessage{}}
}
func parseFromTo(from, to string, limit float64) (time.Time, time.Time, error) {
var fromTime time.Time
var toTime time.Time
if from == "" && to == "" {
fromTime = time.Now().AddDate(0, -1, 0)
toTime = time.Now()
} else if from == "" && to != "" {
var err error
toTime, err = parseTimestamp(to)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse to timestamp: %s", err)
}
fromTime = toTime.AddDate(0, -1, 0)
} else if from != "" && to == "" {
var err error
fromTime, err = parseTimestamp(from)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse from timestamp: %s", err)
}
toTime = fromTime.AddDate(0, 1, 0)
} else {
var err error
fromTime, err = parseTimestamp(from)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse from timestamp: %s", err)
}
toTime, err = parseTimestamp(to)
if err != nil {
return fromTime, toTime, fmt.Errorf("Can't parse to timestamp: %s", err)
}
if toTime.Sub(fromTime).Hours() > limit {
return fromTime, toTime, errors.New("Timespan too big")
}
}
return fromTime, toTime, nil
}
func createChatMessage(parsedMessage twitch.Message) chatMessage {
switch message := parsedMessage.(type) {
case *twitch.PrivateMessage:
return chatMessage{
Timestamp: timestamp{message.Time},
Username: message.User.Name,
DisplayName: message.User.DisplayName,
Text: message.Message,
Type: message.Type,
Channel: message.Channel,
Raw: message.Raw,
ID: message.ID,
Tags: message.Tags,
}
case *twitch.ClearChatMessage:
return chatMessage{
Timestamp: timestamp{message.Time},
Username: message.TargetUsername,
DisplayName: message.TargetUsername,
Text: buildClearChatMessageText(*message),
Type: message.Type,
Channel: message.Channel,
Raw: message.Raw,
Tags: message.Tags,
}
case *twitch.UserNoticeMessage:
return chatMessage{
Timestamp: timestamp{message.Time},
Username: message.User.Name,
DisplayName: message.User.DisplayName,
Text: message.SystemMsg + " " + message.Message,
Type: message.Type,
Channel: message.Channel,
Raw: message.Raw,
ID: message.ID,
Tags: message.Tags,
}
}
return chatMessage{}
}
func parseTimestamp(timestamp string) (time.Time, error) {
i, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return time.Now(), err
}
return time.Unix(i, 0), nil
}
func buildClearChatMessageText(message twitch.ClearChatMessage) string {
if message.BanDuration == 0 {
return fmt.Sprintf("%s has been banned", message.TargetUsername)
}
return fmt.Sprintf("%s has been timed out for %d seconds", message.TargetUsername, message.BanDuration)
}