gosora/routes.go

365 lines
12 KiB
Go

/*
*
* Gosora Route Handlers
* Copyright Azareal 2016 - 2020
*
*/
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode"
c "github.com/Azareal/Gosora/common"
"github.com/Azareal/Gosora/common/phrases"
)
// A blank list to fill out that parameter in Page for routes which don't use it
var tList []interface{}
var successJSONBytes = []byte(`{"success":1}`)
// TODO: Refactor this
// TODO: Use the phrase system
var phraseLoginAlerts = []byte(`{"msgs":[{"msg":"Login to see your alerts","path":"/accounts/login"}],"count":0}`)
var alertStrPool = sync.Pool{}
// TODO: Refactor this endpoint
// TODO: Move this into the routes package
func routeAPI(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {
// TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats
w.Header().Set("Content-Type", "application/json")
err := r.ParseForm()
if err != nil {
return c.PreErrorJS("Bad Form", w, r)
}
action := r.FormValue("a")
if action == "" {
action = "get"
}
if action != "get" && action != "set" {
return c.PreErrorJS("Invalid Action", w, r)
}
switch r.FormValue("m") {
// TODO: Split this into it's own function
case "dismiss-alert":
id, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
return c.PreErrorJS("Invalid id", w, r)
}
res, err := stmts.deleteActivityStreamMatch.Exec(user.ID, id)
if err != nil {
return c.InternalError(err, w, r)
}
count, err := res.RowsAffected()
if err != nil {
return c.InternalError(err, w, r)
}
// Don't want to throw an internal error due to a socket closing
if c.EnableWebsockets && count > 0 {
c.DismissAlert(user.ID, id)
}
w.Write(successJSONBytes)
// TODO: Split this into it's own function
case "alerts": // A feed of events tailored for a specific user
if !user.Loggedin {
h := w.Header()
if gzw, ok := w.(c.GzipResponseWriter); ok {
w = gzw.ResponseWriter
h.Del("Content-Encoding")
}
etag := "\"1583653869-n\""
//etag = c.StartEtag
h.Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return nil
}
}
w.Write(phraseLoginAlerts)
return nil
}
var count int
err = stmts.getActivityCountByWatcher.QueryRow(user.ID).Scan(&count)
if err == ErrNoRows {
return c.PreErrorJS("Unable to get the activity count", w, r)
} else if err != nil {
return c.InternalErrorJS(err, w, r)
}
if count == 0 {
if gzw, ok := w.(c.GzipResponseWriter); ok {
w = gzw.ResponseWriter
w.Header().Del("Content-Encoding")
}
_, _ = io.WriteString(w, `{}`)
return nil
}
rCreatedAt, _ := strconv.ParseInt(r.FormValue("t"), 10, 64)
rCount, _ := strconv.Atoi(r.FormValue("c"))
//log.Print("rCreatedAt:", rCreatedAt)
//log.Print("rCount:", rCount)
var actors []int
var alerts []*c.Alert
var createdAt time.Time
var topCreatedAt int64
if count != 0 {
rows, err := stmts.getActivityFeedByWatcher.Query(user.ID, 12)
if err != nil {
return c.InternalErrorJS(err, w, r)
}
defer rows.Close()
for rows.Next() {
al := &c.Alert{}
err = rows.Scan(&al.ASID, &al.ActorID, &al.TargetUserID, &al.Event, &al.ElementType, &al.ElementID, &createdAt)
if err != nil {
return c.InternalErrorJS(err, w, r)
}
uCreatedAt := createdAt.Unix()
//log.Print("uCreatedAt", uCreatedAt)
//if rCreatedAt == 0 || rCreatedAt < uCreatedAt {
alerts = append(alerts, al)
actors = append(actors, al.ActorID)
//}
if uCreatedAt > topCreatedAt {
topCreatedAt = uCreatedAt
}
}
if err = rows.Err(); err != nil {
return c.InternalErrorJS(err, w, r)
}
}
if len(alerts) == 0 || (rCreatedAt != 0 && rCreatedAt >= topCreatedAt && count == rCount) {
if gzw, ok := w.(c.GzipResponseWriter); ok {
w = gzw.ResponseWriter
w.Header().Del("Content-Encoding")
}
_, _ = io.WriteString(w, `{}`)
return nil
}
// Might not want to error here, if the account was deleted properly, we might want to figure out how we should handle deletions in general
list, err := c.Users.BulkGetMap(actors)
if err != nil {
log.Print("actors:", actors)
return c.InternalErrorJS(err, w, r)
}
var sb *strings.Builder
ii := alertStrPool.Get()
if ii == nil {
sb = &strings.Builder{}
} else {
sb = ii.(*strings.Builder)
sb.Reset()
}
sb.Grow(c.AlertsGrowHint + (len(alerts) * (c.AlertsGrowHint2 + 1)) - 1)
sb.WriteString(`{"msgs":[`)
var ok bool
for i, alert := range alerts {
if i != 0 {
sb.WriteRune(',')
}
alert.Actor, ok = list[alert.ActorID]
if !ok {
return c.InternalErrorJS(errors.New("No such actor"), w, r)
}
err := c.BuildAlertSb(sb, alert, user)
if err != nil {
return c.LocalErrorJS(err.Error(), w, r)
}
}
sb.WriteString(`],"count":`)
sb.WriteString(strconv.Itoa(count))
sb.WriteString(`,"tc":`)
//rCreatedAt
sb.WriteString(strconv.Itoa(int(topCreatedAt)))
sb.WriteRune('}')
_, _ = io.WriteString(w, sb.String())
alertStrPool.Put(sb)
default:
return c.PreErrorJS("Invalid Module", w, r)
}
return nil
}
// TODO: Remove this line after we move routeAPIPhrases to the routes package
var cacheControlMaxAge = "max-age=" + strconv.Itoa(int(c.Day))
// TODO: Be careful with exposing the panel phrases here, maybe move them into a different namespace? We also need to educate the admin that phrases aren't necessarily secret
// TODO: Move to the routes package
var phraseWhitelist = []string{
"topic",
"status",
"alerts",
"paginator",
"analytics",
"panel", // We're going to handle this specially below as this is a security boundary
}
func routeAPIPhrases(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {
// TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats
h := w.Header()
h.Set("Content-Type", "application/json")
err := r.ParseForm()
if err != nil {
return c.PreErrorJS("Bad Form", w, r)
}
query := r.FormValue("q")
if query == "" {
return c.PreErrorJS("No query provided", w, r)
}
var negations, positives []string
for _, queryBit := range strings.Split(query, ",") {
queryBit = strings.TrimSpace(queryBit)
if queryBit[0] == '!' && len(queryBit) > 1 {
queryBit = strings.TrimPrefix(queryBit, "!")
for _, ch := range queryBit {
if !unicode.IsLetter(ch) && ch != '-' && ch != '_' {
return c.PreErrorJS("No symbols allowed, only - and _", w, r)
}
}
negations = append(negations, queryBit)
} else {
for _, ch := range queryBit {
if !unicode.IsLetter(ch) && ch != '-' && ch != '_' {
return c.PreErrorJS("No symbols allowed, only - and _", w, r)
}
}
positives = append(positives, queryBit)
}
}
if len(positives) == 0 {
return c.PreErrorJS("You haven't requested any phrases", w, r)
}
h.Set("Cache-Control", cacheControlMaxAge) //Cache-Control: max-age=31536000
var etag string
_, ok := w.(c.GzipResponseWriter)
if ok {
etag = "\"" + strconv.FormatInt(phrases.GetCurrentLangPack().ModTime.Unix(), 10) + "-ng\""
} else {
etag = "\"" + strconv.FormatInt(phrases.GetCurrentLangPack().ModTime.Unix(), 10) + "-n\""
}
var plist map[string]string
var notModified, private bool
posLoop := func(positive string) c.RouteError {
// ! Constrain it to a subset of phrases for now
for _, item := range phraseWhitelist {
if strings.HasPrefix(positive, item) {
// TODO: Break this down into smaller security boundaries based on control panel sections?
// TODO: Do we have to be so strict with panel phrases?
if strings.HasPrefix(positive, "panel") {
private = true
ok = user.IsSuperMod
} else {
ok = true
if notModified {
return nil
}
w.Header().Set("ETag", etag)
match := r.Header.Get("If-None-Match")
if match != "" && strings.Contains(match, etag) {
notModified = true
return nil
}
}
break
}
}
if !ok {
return c.PreErrorJS("Outside of phrase prefix whitelist", w, r)
}
return nil
}
// A little optimisation to avoid copying entries from one map to the other, if we don't have to mutate it
if len(positives) > 1 {
plist = make(map[string]string)
for _, positive := range positives {
rerr := posLoop(positive)
if rerr != nil {
return rerr
}
pPhrases, ok := phrases.GetTmplPhrasesByPrefix(positive)
if !ok {
return c.PreErrorJS("No such prefix", w, r)
}
for name, phrase := range pPhrases {
plist[name] = phrase
}
}
} else {
rerr := posLoop(positives[0])
if rerr != nil {
return rerr
}
pPhrases, ok := phrases.GetTmplPhrasesByPrefix(positives[0])
if !ok {
return c.PreErrorJS("No such prefix", w, r)
}
plist = pPhrases
}
if private {
w.Header().Set("Cache-Control", "private")
} else if notModified {
w.WriteHeader(http.StatusNotModified)
return nil
}
for _, negation := range negations {
for name, _ := range plist {
if strings.HasPrefix(name, negation) {
delete(plist, name)
}
}
}
// TODO: Cache the output of this, especially for things like topic, so we don't have to waste more time than we need on this
jsonBytes, err := json.Marshal(plist)
if err != nil {
return c.InternalError(err, w, r)
}
w.Write(jsonBytes)
return nil
}
// A dedicated function so we can shake things up every now and then to make the token harder to parse
// TODO: Are we sure we want to do this by ID, just in case we reuse this and have multiple antispams on the page?
func routeJSAntispam(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {
h := sha256.New()
h.Write([]byte(c.JSTokenBox.Load().(string)))
h.Write([]byte(user.GetIP()))
jsToken := hex.EncodeToString(h.Sum(nil))
innerCode := "`document.getElementByld('golden-watch').value='" + jsToken + "';`"
io.WriteString(w, `let hihi=`+innerCode+`;hihi=hihi.replace('ld','Id');eval(hihi);`)
return nil
}