331 lines
9.2 KiB
Go
331 lines
9.2 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"
|
|
"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}`)
|
|
|
|
// 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("action")
|
|
if action == "" {
|
|
action = "get"
|
|
}
|
|
if action != "get" && action != "set" {
|
|
return c.PreErrorJS("Invalid Action", w, r)
|
|
}
|
|
|
|
switch r.FormValue("module") {
|
|
// 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.WsHub.PushMessage(user.ID, `{"event":"dismiss-alert","id":`+strconv.Itoa(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 {
|
|
var etag string
|
|
_, ok := w.(c.GzipResponseWriter)
|
|
if ok {
|
|
etag = "\"" + strconv.FormatInt(c.StartTime.Unix(), 10) + "-ng\""
|
|
} else {
|
|
etag = "\"" + strconv.FormatInt(c.StartTime.Unix(), 10) + "-n\""
|
|
}
|
|
w.Header().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)
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return c.InternalErrorJS(err, w, r)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var alert c.Alert
|
|
err = rows.Scan(&alert.ASID, &alert.ActorID, &alert.TargetUserID, &alert.Event, &alert.ElementType, &alert.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, alert)
|
|
actors = append(actors, alert.ActorID)
|
|
//}
|
|
if uCreatedAt > topCreatedAt {
|
|
topCreatedAt = uCreatedAt
|
|
}
|
|
}
|
|
err = rows.Err()
|
|
if err != nil {
|
|
return c.InternalErrorJS(err, w, r)
|
|
}
|
|
}
|
|
|
|
// 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 msglist string
|
|
//var sb strings.Builder
|
|
var ok bool
|
|
for _, alert := range alerts {
|
|
alert.Actor, ok = list[alert.ActorID]
|
|
if !ok {
|
|
return c.InternalErrorJS(errors.New("No such actor"), w, r)
|
|
}
|
|
res, err := c.BuildAlert(alert, user)
|
|
if err != nil {
|
|
return c.LocalErrorJS(err.Error(), w, r)
|
|
}
|
|
//sb.Write(res)
|
|
msglist += res + ","
|
|
}
|
|
if len(msglist) != 0 {
|
|
msglist = msglist[0 : len(msglist)-1]
|
|
}
|
|
|
|
if count == 0 || msglist == "" || (rCreatedAt != 0 && rCreatedAt >= topCreatedAt && count == rCount) {
|
|
_, _ = io.WriteString(w, `{}`)
|
|
} else {
|
|
_, _ = io.WriteString(w, `{"msgs":[`+msglist+`],"count":`+strconv.Itoa(count)+`,"tc":`+strconv.Itoa(int(topCreatedAt))+`}`)
|
|
}
|
|
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")
|
|
h.Set("Cache-Control", cacheControlMaxAge) //Cache-Control: max-age=31536000
|
|
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
return c.PreErrorJS("Bad Form", w, r)
|
|
}
|
|
query := r.FormValue("query")
|
|
if query == "" {
|
|
return c.PreErrorJS("No query provided", w, r)
|
|
}
|
|
|
|
var negations []string
|
|
var positives []string
|
|
|
|
queryBits := strings.Split(query, ",")
|
|
for _, queryBit := range queryBits {
|
|
queryBit = strings.TrimSpace(queryBit)
|
|
if queryBit[0] == '!' && len(queryBit) > 1 {
|
|
queryBit = strings.TrimPrefix(queryBit, "!")
|
|
for _, char := range queryBit {
|
|
if !unicode.IsLetter(char) && char != '-' && char != '_' {
|
|
return c.PreErrorJS("No symbols allowed, only - and _", w, r)
|
|
}
|
|
}
|
|
negations = append(negations, queryBit)
|
|
} else {
|
|
for _, char := range queryBit {
|
|
if !unicode.IsLetter(char) && char != '-' && char != '_' {
|
|
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)
|
|
}
|
|
|
|
var etag string
|
|
_, ok := w.(c.GzipResponseWriter)
|
|
if ok {
|
|
etag = "\"" + strconv.FormatInt(c.StartTime.Unix(), 10) + "-g\""
|
|
} else {
|
|
etag = "\"" + strconv.FormatInt(c.StartTime.Unix(), 10) + "\""
|
|
}
|
|
|
|
var plist map[string]string
|
|
var 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?
|
|
if strings.HasPrefix(positive, "panel") {
|
|
w.Header().Set("Cache-Control", "private")
|
|
ok = user.IsSuperMod
|
|
} else {
|
|
ok = true
|
|
w.Header().Set("ETag", etag)
|
|
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
if strings.Contains(match, etag) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
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
|
|
}
|
|
|
|
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.LastIP))
|
|
jsToken := hex.EncodeToString(h.Sum(nil))
|
|
|
|
var innerCode = "`document.getElementByld('golden-watch').value = '" + jsToken + "';`"
|
|
io.WriteString(w, `let hihi = `+innerCode+`;
|
|
hihi = hihi.replace('ld','Id');
|
|
eval(hihi);`)
|
|
|
|
return nil
|
|
}
|