498 lines
12 KiB
Go
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)
|
|
}
|