github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/routes.go (about) 1 /* 2 * 3 * Gosora Route Handlers 4 * Copyright Azareal 2016 - 2020 5 * 6 */ 7 package main 8 9 import ( 10 "crypto/sha256" 11 "encoding/hex" 12 "encoding/json" 13 "errors" 14 "io" 15 "log" 16 "net/http" 17 "strconv" 18 "strings" 19 "sync" 20 "time" 21 "unicode" 22 23 c "github.com/Azareal/Gosora/common" 24 "github.com/Azareal/Gosora/common/phrases" 25 ) 26 27 // A blank list to fill out that parameter in Page for routes which don't use it 28 var tList []interface{} 29 var successJSONBytes = []byte(`{"success":1}`) 30 31 // TODO: Refactor this 32 // TODO: Use the phrase system 33 var phraseLoginAlerts = []byte(`{"msgs":[{"msg":"Login to see your alerts","path":"/accounts/login"}],"count":0}`) 34 var alertStrPool = sync.Pool{} 35 36 // TODO: Refactor this endpoint 37 // TODO: Move this into the routes package 38 func routeAPI(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError { 39 // TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats 40 w.Header().Set("Content-Type", "application/json") 41 err := r.ParseForm() 42 if err != nil { 43 return c.PreErrorJS("Bad Form", w, r) 44 } 45 46 action := r.FormValue("a") 47 if action == "" { 48 action = "get" 49 } 50 if action != "get" && action != "set" { 51 return c.PreErrorJS("Invalid Action", w, r) 52 } 53 54 switch r.FormValue("m") { 55 // TODO: Split this into it's own function 56 case "dismiss-alert": 57 id, err := strconv.Atoi(r.FormValue("id")) 58 if err != nil { 59 return c.PreErrorJS("Invalid id", w, r) 60 } 61 res, err := stmts.deleteActivityStreamMatch.Exec(user.ID, id) 62 if err != nil { 63 return c.InternalError(err, w, r) 64 } 65 count, err := res.RowsAffected() 66 if err != nil { 67 return c.InternalError(err, w, r) 68 } 69 // Don't want to throw an internal error due to a socket closing 70 if c.EnableWebsockets && count > 0 { 71 c.DismissAlert(user.ID, id) 72 } 73 w.Write(successJSONBytes) 74 // TODO: Split this into it's own function 75 case "alerts": // A feed of events tailored for a specific user 76 if !user.Loggedin { 77 h := w.Header() 78 if gzw, ok := w.(c.GzipResponseWriter); ok { 79 w = gzw.ResponseWriter 80 h.Del("Content-Encoding") 81 } 82 etag := "\"1583653869-n\"" 83 //etag = c.StartEtag 84 h.Set("ETag", etag) 85 if match := r.Header.Get("If-None-Match"); match != "" { 86 if strings.Contains(match, etag) { 87 w.WriteHeader(http.StatusNotModified) 88 return nil 89 } 90 } 91 w.Write(phraseLoginAlerts) 92 return nil 93 } 94 95 var count int 96 err = stmts.getActivityCountByWatcher.QueryRow(user.ID).Scan(&count) 97 if err == ErrNoRows { 98 return c.PreErrorJS("Unable to get the activity count", w, r) 99 } else if err != nil { 100 return c.InternalErrorJS(err, w, r) 101 } 102 103 if count == 0 { 104 if gzw, ok := w.(c.GzipResponseWriter); ok { 105 w = gzw.ResponseWriter 106 w.Header().Del("Content-Encoding") 107 } 108 _, _ = io.WriteString(w, `{}`) 109 return nil 110 } 111 112 rCreatedAt, _ := strconv.ParseInt(r.FormValue("t"), 10, 64) 113 rCount, _ := strconv.Atoi(r.FormValue("c")) 114 //log.Print("rCreatedAt:", rCreatedAt) 115 //log.Print("rCount:", rCount) 116 var actors []int 117 var alerts []*c.Alert 118 var createdAt time.Time 119 var topCreatedAt int64 120 121 if count != 0 { 122 rows, err := stmts.getActivityFeedByWatcher.Query(user.ID, 12) 123 if err != nil { 124 return c.InternalErrorJS(err, w, r) 125 } 126 defer rows.Close() 127 128 for rows.Next() { 129 al := &c.Alert{} 130 err = rows.Scan(&al.ASID, &al.ActorID, &al.TargetUserID, &al.Event, &al.ElementType, &al.ElementID, &createdAt) 131 if err != nil { 132 return c.InternalErrorJS(err, w, r) 133 } 134 135 uCreatedAt := createdAt.Unix() 136 //log.Print("uCreatedAt", uCreatedAt) 137 //if rCreatedAt == 0 || rCreatedAt < uCreatedAt { 138 alerts = append(alerts, al) 139 actors = append(actors, al.ActorID) 140 //} 141 if uCreatedAt > topCreatedAt { 142 topCreatedAt = uCreatedAt 143 } 144 } 145 if err = rows.Err(); err != nil { 146 return c.InternalErrorJS(err, w, r) 147 } 148 } 149 150 if len(alerts) == 0 || (rCreatedAt != 0 && rCreatedAt >= topCreatedAt && count == rCount) { 151 if gzw, ok := w.(c.GzipResponseWriter); ok { 152 w = gzw.ResponseWriter 153 w.Header().Del("Content-Encoding") 154 } 155 _, _ = io.WriteString(w, `{}`) 156 return nil 157 } 158 159 // 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 160 list, err := c.Users.BulkGetMap(actors) 161 if err != nil { 162 log.Print("actors:", actors) 163 return c.InternalErrorJS(err, w, r) 164 } 165 166 var sb *strings.Builder 167 ii := alertStrPool.Get() 168 if ii == nil { 169 sb = &strings.Builder{} 170 } else { 171 sb = ii.(*strings.Builder) 172 sb.Reset() 173 } 174 sb.Grow(c.AlertsGrowHint + (len(alerts) * (c.AlertsGrowHint2 + 1)) - 1) 175 sb.WriteString(`{"msgs":[`) 176 177 var ok bool 178 for i, alert := range alerts { 179 if i != 0 { 180 sb.WriteRune(',') 181 } 182 alert.Actor, ok = list[alert.ActorID] 183 if !ok { 184 return c.InternalErrorJS(errors.New("No such actor"), w, r) 185 } 186 err := c.BuildAlertSb(sb, alert, user) 187 if err != nil { 188 return c.LocalErrorJS(err.Error(), w, r) 189 } 190 } 191 sb.WriteString(`],"count":`) 192 sb.WriteString(strconv.Itoa(count)) 193 sb.WriteString(`,"tc":`) 194 //rCreatedAt 195 sb.WriteString(strconv.Itoa(int(topCreatedAt))) 196 sb.WriteRune('}') 197 198 _, _ = io.WriteString(w, sb.String()) 199 alertStrPool.Put(sb) 200 default: 201 return c.PreErrorJS("Invalid Module", w, r) 202 } 203 return nil 204 } 205 206 // TODO: Remove this line after we move routeAPIPhrases to the routes package 207 var cacheControlMaxAge = "max-age=" + strconv.Itoa(int(c.Day)) 208 209 // 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 210 // TODO: Move to the routes package 211 var phraseWhitelist = []string{ 212 "topic", 213 "status", 214 "alerts", 215 "paginator", 216 "analytics", 217 218 "panel", // We're going to handle this specially below as this is a security boundary 219 } 220 221 func routeAPIPhrases(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError { 222 // TODO: Don't make this too JSON dependent so that we can swap in newer more efficient formats 223 h := w.Header() 224 h.Set("Content-Type", "application/json") 225 226 err := r.ParseForm() 227 if err != nil { 228 return c.PreErrorJS("Bad Form", w, r) 229 } 230 query := r.FormValue("q") 231 if query == "" { 232 return c.PreErrorJS("No query provided", w, r) 233 } 234 235 var negations, positives []string 236 for _, queryBit := range strings.Split(query, ",") { 237 queryBit = strings.TrimSpace(queryBit) 238 if queryBit[0] == '!' && len(queryBit) > 1 { 239 queryBit = strings.TrimPrefix(queryBit, "!") 240 for _, ch := range queryBit { 241 if !unicode.IsLetter(ch) && ch != '-' && ch != '_' { 242 return c.PreErrorJS("No symbols allowed, only - and _", w, r) 243 } 244 } 245 negations = append(negations, queryBit) 246 } else { 247 for _, ch := range queryBit { 248 if !unicode.IsLetter(ch) && ch != '-' && ch != '_' { 249 return c.PreErrorJS("No symbols allowed, only - and _", w, r) 250 } 251 } 252 positives = append(positives, queryBit) 253 } 254 } 255 if len(positives) == 0 { 256 return c.PreErrorJS("You haven't requested any phrases", w, r) 257 } 258 h.Set("Cache-Control", cacheControlMaxAge) //Cache-Control: max-age=31536000 259 260 var etag string 261 _, ok := w.(c.GzipResponseWriter) 262 if ok { 263 etag = "\"" + strconv.FormatInt(phrases.GetCurrentLangPack().ModTime.Unix(), 10) + "-ng\"" 264 } else { 265 etag = "\"" + strconv.FormatInt(phrases.GetCurrentLangPack().ModTime.Unix(), 10) + "-n\"" 266 } 267 268 var plist map[string]string 269 var notModified, private bool 270 posLoop := func(positive string) c.RouteError { 271 // ! Constrain it to a subset of phrases for now 272 for _, item := range phraseWhitelist { 273 if strings.HasPrefix(positive, item) { 274 // TODO: Break this down into smaller security boundaries based on control panel sections? 275 // TODO: Do we have to be so strict with panel phrases? 276 if strings.HasPrefix(positive, "panel") { 277 private = true 278 ok = user.IsSuperMod 279 } else { 280 ok = true 281 if notModified { 282 return nil 283 } 284 w.Header().Set("ETag", etag) 285 match := r.Header.Get("If-None-Match") 286 if match != "" && strings.Contains(match, etag) { 287 notModified = true 288 return nil 289 } 290 } 291 break 292 } 293 } 294 if !ok { 295 return c.PreErrorJS("Outside of phrase prefix whitelist", w, r) 296 } 297 return nil 298 } 299 300 // A little optimisation to avoid copying entries from one map to the other, if we don't have to mutate it 301 if len(positives) > 1 { 302 plist = make(map[string]string) 303 for _, positive := range positives { 304 rerr := posLoop(positive) 305 if rerr != nil { 306 return rerr 307 } 308 pPhrases, ok := phrases.GetTmplPhrasesByPrefix(positive) 309 if !ok { 310 return c.PreErrorJS("No such prefix", w, r) 311 } 312 for name, phrase := range pPhrases { 313 plist[name] = phrase 314 } 315 } 316 } else { 317 rerr := posLoop(positives[0]) 318 if rerr != nil { 319 return rerr 320 } 321 pPhrases, ok := phrases.GetTmplPhrasesByPrefix(positives[0]) 322 if !ok { 323 return c.PreErrorJS("No such prefix", w, r) 324 } 325 plist = pPhrases 326 } 327 328 if private { 329 w.Header().Set("Cache-Control", "private") 330 } else if notModified { 331 w.WriteHeader(http.StatusNotModified) 332 return nil 333 } 334 335 for _, negation := range negations { 336 for name, _ := range plist { 337 if strings.HasPrefix(name, negation) { 338 delete(plist, name) 339 } 340 } 341 } 342 343 // 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 344 jsonBytes, err := json.Marshal(plist) 345 if err != nil { 346 return c.InternalError(err, w, r) 347 } 348 w.Write(jsonBytes) 349 return nil 350 } 351 352 // A dedicated function so we can shake things up every now and then to make the token harder to parse 353 // TODO: Are we sure we want to do this by ID, just in case we reuse this and have multiple antispams on the page? 354 func routeJSAntispam(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError { 355 h := sha256.New() 356 h.Write([]byte(c.JSTokenBox.Load().(string))) 357 h.Write([]byte(user.GetIP())) 358 jsToken := hex.EncodeToString(h.Sum(nil)) 359 360 innerCode := "`document.getElementByld('golden-watch').value='" + jsToken + "';`" 361 io.WriteString(w, `let hihi=`+innerCode+`;hihi=hihi.replace('ld','Id');eval(hihi);`) 362 363 return nil 364 }