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  }