github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/routes/topic.go (about)

     1  package routes
     2  
     3  import (
     4  	"crypto/sha256"
     5  	"database/sql"
     6  	"encoding/hex"
     7  	"encoding/json"
     8  	"errors"
     9  	"io"
    10  
    11  	"image"
    12  	"image/gif"
    13  	"image/jpeg"
    14  	"image/png"
    15  	"log"
    16  	"net/http"
    17  	"os"
    18  	"regexp"
    19  	"strconv"
    20  	"strings"
    21  
    22  	"golang.org/x/image/tiff"
    23  
    24  	c "github.com/Azareal/Gosora/common"
    25  	co "github.com/Azareal/Gosora/common/counters"
    26  	p "github.com/Azareal/Gosora/common/phrases"
    27  	qgen "github.com/Azareal/Gosora/query_gen"
    28  )
    29  
    30  type TopicStmts struct {
    31  	getLikedTopic *sql.Stmt
    32  }
    33  
    34  var topicStmts TopicStmts
    35  
    36  // TODO: Move these DbInits into a TopicList abstraction
    37  func init() {
    38  	c.DbInits.Add(func(acc *qgen.Accumulator) error {
    39  		topicStmts = TopicStmts{
    40  			getLikedTopic: acc.Select("likes").Columns("targetItem").Where("sentBy=? && targetItem=? && targetType='topics'").Prepare(),
    41  		}
    42  		return acc.FirstError()
    43  	})
    44  }
    45  
    46  func ViewTopic(w http.ResponseWriter, r *http.Request, user *c.User, h *c.Header, urlBit string) c.RouteError {
    47  	page, _ := strconv.Atoi(r.FormValue("page"))
    48  	_, tid, err := ParseSEOURL(urlBit)
    49  	if err != nil {
    50  		return c.SimpleError(p.GetErrorPhrase("url_id_must_be_integer"), w, r, h)
    51  	}
    52  
    53  	// Get the topic...
    54  	topic, err := c.GetTopicUser(user, tid)
    55  	if err == sql.ErrNoRows {
    56  		return c.NotFound(w, r, nil) // TODO: Can we add a simplified invocation of header here? This is likely to be an extremely common NotFound
    57  	} else if err != nil {
    58  		return c.InternalError(err, w, r)
    59  	}
    60  
    61  	ferr := c.ForumUserCheck(h, w, r, user, topic.ParentID)
    62  	if ferr != nil {
    63  		return ferr
    64  	}
    65  	if !user.Perms.ViewTopic {
    66  		return c.NoPermissions(w, r, user)
    67  	}
    68  	h.Title = topic.Title
    69  	h.Path = topic.Link
    70  	//h.Path = c.BuildTopicURL(c.NameToSlug(topic.Title), topic.ID)
    71  
    72  	postGroup, err := c.Groups.Get(topic.Group)
    73  	if err != nil {
    74  		return c.InternalError(err, w, r)
    75  	}
    76  
    77  	topic.ContentLines = strings.Count(topic.Content, "\n")
    78  	if !user.Loggedin && user.LastAgent != c.SimpleBots[0] && user.LastAgent != c.SimpleBots[1] {
    79  		if len(topic.Content) > 200 {
    80  			h.OGDesc = topic.Content[:197] + "..."
    81  		} else {
    82  			h.OGDesc = topic.Content
    83  		}
    84  		h.OGDesc = c.H_topic_ogdesc_assign_hook(h.Hooks, h.OGDesc)
    85  	}
    86  
    87  	var parseSettings *c.ParseSettings
    88  	if (c.Config.NoEmbed || !postGroup.Perms.AutoEmbed) && (user.ParseSettings == nil || !user.ParseSettings.NoEmbed) {
    89  		parseSettings = c.DefaultParseSettings.CopyPtr()
    90  		parseSettings.NoEmbed = true
    91  	} else {
    92  		parseSettings = user.ParseSettings
    93  	}
    94  
    95  	// TODO: Cache ContentHTML when possible?
    96  	topic.ContentHTML, h.ExternalMedia = c.ParseMessage2(topic.Content, topic.ParentID, "forums", parseSettings, user)
    97  	// TODO: Do this more efficiently by avoiding the allocations entirely in ParseMessage, if there's nothing to do.
    98  	if topic.ContentHTML == topic.Content {
    99  		topic.ContentHTML = topic.Content
   100  	}
   101  
   102  	topic.Tag = postGroup.Tag
   103  	if postGroup.IsMod {
   104  		topic.ClassName = c.Config.StaffCSS
   105  	}
   106  	topic.Deletable = user.Perms.DeleteTopic || topic.CreatedBy == user.ID
   107  
   108  	forum, err := c.Forums.Get(topic.ParentID)
   109  	if err != nil {
   110  		return c.InternalError(err, w, r)
   111  	}
   112  
   113  	var poll *c.Poll
   114  	if topic.Poll != 0 {
   115  		pPoll, err := c.Polls.Get(topic.Poll)
   116  		if err != nil {
   117  			log.Print("Couldn't find the attached poll for topic " + strconv.Itoa(topic.ID))
   118  			return c.InternalError(err, w, r)
   119  		}
   120  		poll = new(c.Poll)
   121  		*poll = pPoll.Copy()
   122  	}
   123  
   124  	if topic.LikeCount > 0 && user.Liked > 0 {
   125  		var disp int // Discard this value
   126  		err = topicStmts.getLikedTopic.QueryRow(user.ID, topic.ID).Scan(&disp)
   127  		if err == nil {
   128  			topic.Liked = true
   129  		} else if err != nil && err != sql.ErrNoRows {
   130  			return c.InternalError(err, w, r)
   131  		}
   132  	}
   133  
   134  	if topic.AttachCount > 0 {
   135  		attachs, err := c.Attachments.MiniGetList("topics", topic.ID)
   136  		if err != nil && err != sql.ErrNoRows {
   137  			// TODO: We might want to be a little permissive here in-case of a desync?
   138  			return c.InternalError(err, w, r)
   139  		}
   140  		topic.Attachments = attachs
   141  	}
   142  
   143  	// Calculate the offset
   144  	offset, page, lastPage := c.PageOffset(topic.PostCount, page, c.Config.ItemsPerPage)
   145  	pageList := c.Paginate(page, lastPage, 5)
   146  	tpage := c.TopicPage{h, nil, topic, forum, poll, c.Paginator{pageList, page, lastPage}}
   147  
   148  	// Get the replies if we have any...
   149  	if topic.PostCount > 0 {
   150  		/*var pFrag int
   151  		if strings.HasPrefix(r.URL.Fragment, "post-") {
   152  			pFrag, _ = strconv.Atoi(strings.TrimPrefix(r.URL.Fragment, "post-"))
   153  		}*/
   154  		rlist, externalHead, err := topic.Replies(offset /* pFrag,*/, user)
   155  		if err == sql.ErrNoRows {
   156  			return c.LocalError("Bad Page. Some of the posts may have been deleted or you got here by directly typing in the page number.", w, r, user)
   157  		} else if err != nil {
   158  			return c.InternalError(err, w, r)
   159  		}
   160  		//fmt.Printf("rlist: %+v\n",rlist)
   161  		tpage.ItemList = rlist
   162  		if externalHead {
   163  			h.ExternalMedia = true
   164  		}
   165  	}
   166  
   167  	h.Zone = "view_topic"
   168  	h.ZoneID = topic.ID
   169  	h.ZoneData = topic
   170  
   171  	var rerr c.RouteError
   172  	tmpl := forum.Tmpl
   173  	if r.FormValue("i") == "1" {
   174  		if tpage.Poll != nil {
   175  			h.AddXRes("chartist/chartist.min.css", "chartist/chartist.min.js")
   176  		}
   177  		if tmpl == "" {
   178  			rerr = renderTemplate("topic_mini", w, r, h, tpage)
   179  		} else {
   180  			tmpl = "topic_mini" + tmpl
   181  			err = renderTemplate3(tmpl, tmpl, w, r, h, tpage)
   182  			if err != nil {
   183  				rerr = renderTemplate("topic_mini", w, r, h, tpage)
   184  			}
   185  		}
   186  	} else {
   187  		if tpage.Poll != nil {
   188  			h.AddSheet("chartist/chartist.min.css")
   189  			h.AddScript("chartist/chartist.min.js")
   190  		}
   191  		if tmpl == "" {
   192  			rerr = renderTemplate("topic", w, r, h, tpage)
   193  		} else {
   194  			tmpl = "topic_" + tmpl
   195  			err = renderTemplate3(tmpl, tmpl, w, r, h, tpage)
   196  			if err != nil {
   197  				rerr = renderTemplate("topic", w, r, h, tpage)
   198  			}
   199  		}
   200  	}
   201  	co.TopicViewCounter.Bump(topic.ID) // TODO: Move this into the router?
   202  	co.ForumViewCounter.Bump(topic.ParentID)
   203  	return rerr
   204  }
   205  
   206  func AttachTopicActCommon(w http.ResponseWriter, r *http.Request, u *c.User, stid string) (t *c.Topic, ferr c.RouteError) {
   207  	tid, e := strconv.Atoi(stid)
   208  	if e != nil {
   209  		return t, c.LocalErrorJS(p.GetErrorPhrase("id_must_be_integer"), w, r)
   210  	}
   211  	t, e = c.Topics.Get(tid)
   212  	if e != nil {
   213  		return t, c.NotFoundJS(w, r)
   214  	}
   215  	_, ferr = c.SimpleForumUserCheck(w, r, u, t.ParentID)
   216  	if ferr != nil {
   217  		return t, ferr
   218  	}
   219  	if t.IsClosed && !u.Perms.CloseTopic {
   220  		return t, c.NoPermissionsJS(w, r, u)
   221  	}
   222  	return t, nil
   223  }
   224  
   225  // TODO: Avoid uploading this again if the attachment already exists? They'll resolve to the same hash either way, but we could save on some IO / bandwidth here
   226  // TODO: Enforce the max request limit on all of this topic's attachments
   227  // TODO: Test this route
   228  func AddAttachToTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   229  	topic, ferr := AttachTopicActCommon(w, r, u, stid)
   230  	if ferr != nil {
   231  		return ferr
   232  	}
   233  	if !u.Perms.ViewTopic || !u.Perms.EditTopic || !u.Perms.UploadFiles {
   234  		return c.NoPermissionsJS(w, r, u)
   235  	}
   236  
   237  	// Handle the file attachments
   238  	pathMap, rerr := uploadAttachment(w, r, u, topic.ParentID, "forums", topic.ID, "topics", "")
   239  	if rerr != nil {
   240  		// TODO: This needs to be a JS error...
   241  		return rerr
   242  	}
   243  	if len(pathMap) == 0 {
   244  		return c.InternalErrorJS(errors.New("no paths for attachment add"), w, r)
   245  	}
   246  
   247  	var elemStr string
   248  	for path, aids := range pathMap {
   249  		elemStr += "\"" + path + "\":\"" + aids + "\","
   250  	}
   251  	if len(elemStr) > 1 {
   252  		elemStr = elemStr[:len(elemStr)-1]
   253  	}
   254  
   255  	w.Write([]byte(`{"success":1,"elems":[{` + elemStr + `}]}`))
   256  	return nil
   257  }
   258  
   259  func RemoveAttachFromTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   260  	_, ferr := AttachTopicActCommon(w, r, u, stid)
   261  	if ferr != nil {
   262  		return ferr
   263  	}
   264  	if !u.Perms.ViewTopic || !u.Perms.EditTopic {
   265  		return c.NoPermissionsJS(w, r, u)
   266  	}
   267  
   268  	for _, said := range strings.Split(r.PostFormValue("aids"), ",") {
   269  		aid, err := strconv.Atoi(said)
   270  		if err != nil {
   271  			return c.LocalErrorJS(p.GetErrorPhrase("id_must_be_integer"), w, r)
   272  		}
   273  		rerr := deleteAttachment(w, r, u, aid, true)
   274  		if rerr != nil {
   275  			// TODO: This needs to be a JS error...
   276  			return rerr
   277  		}
   278  	}
   279  
   280  	w.Write(successJSONBytes)
   281  	return nil
   282  }
   283  
   284  // ? - Should we add a new permission or permission zone (like per-forum permissions) specifically for profile comment creation
   285  // ? - Should we allow banned users to make reports? How should we handle report abuse?
   286  // TODO: Add a permission to stop certain users from using custom avatars
   287  // ? - Log username changes and put restrictions on this?
   288  // TODO: Test this
   289  // TODO: Revamp this route
   290  func CreateTopic(w http.ResponseWriter, r *http.Request, u *c.User, h *c.Header, sfid string) c.RouteError {
   291  	var fid int
   292  	var err error
   293  	if sfid != "" {
   294  		fid, err = strconv.Atoi(sfid)
   295  		if err != nil {
   296  			return c.LocalError(p.GetErrorPhrase("url_id_must_be_integer"), w, r, u)
   297  		}
   298  	}
   299  	if fid == 0 {
   300  		fid = c.Config.DefaultForum
   301  	}
   302  
   303  	ferr := c.ForumUserCheck(h, w, r, u, fid)
   304  	if ferr != nil {
   305  		return ferr
   306  	}
   307  	if !u.Perms.ViewTopic || !u.Perms.CreateTopic {
   308  		return c.NoPermissions(w, r, u)
   309  	}
   310  	// TODO: Add a phrase for this
   311  	h.Title = p.GetTitlePhrase("create_topic")
   312  	h.Zone = "create_topic"
   313  
   314  	// Lock this to the forum being linked?
   315  	// Should we always put it in strictmode when it's linked from another forum? Well, the user might end up changing their mind on what forum they want to post in and it would be a hassle, if they had to switch pages, even if it is a single click for many (exc. mobile)
   316  	var strict bool
   317  	h.Hooks.VhookNoRet("topic_create_pre_loop", w, r, fid, h, u, &strict)
   318  
   319  	// TODO: Re-add support for plugin_guilds
   320  	var forumList []c.Forum
   321  	var canSee []int
   322  	if u.IsSuperAdmin {
   323  		canSee, err = c.Forums.GetAllVisibleIDs()
   324  		if err != nil {
   325  			return c.InternalError(err, w, r)
   326  		}
   327  	} else {
   328  		group, err := c.Groups.Get(u.Group)
   329  		if err != nil {
   330  			// TODO: Refactor this
   331  			c.LocalError("Something weird happened behind the scenes", w, r, u)
   332  			log.Printf("Group #%d doesn't exist, but it's set on c.User #%d", u.Group, u.ID)
   333  			return nil
   334  		}
   335  		canSee = group.CanSee
   336  	}
   337  
   338  	// TODO: plugin_superadmin needs to be able to override this loop. Skip flag on topic_create_pre_loop?
   339  	for _, ffid := range canSee {
   340  		// TODO: Surely, there's a better way of doing this. I've added it in for now to support plugin_guilds, but we really need to clean this up
   341  		if strict && ffid != fid {
   342  			continue
   343  		}
   344  
   345  		// Do a bulk forum fetch, just in case it's the SqlForumStore?
   346  		f := c.Forums.DirtyGet(ffid)
   347  		if f.Name != "" && f.Active {
   348  			fcopy := f.Copy()
   349  			// TODO: Abstract this
   350  			//if h.Hooks.HookSkip("topic_create_frow_assign", &fcopy) {
   351  			if c.H_topic_create_frow_assign_hook(h.Hooks, &fcopy) {
   352  				continue
   353  			}
   354  			forumList = append(forumList, fcopy)
   355  		}
   356  	}
   357  
   358  	return renderTemplate("create_topic", w, r, h, c.CreateTopicPage{h, forumList, fid})
   359  }
   360  
   361  func CreateTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {
   362  	fid, err := strconv.Atoi(r.PostFormValue("board"))
   363  	if err != nil {
   364  		return c.LocalError(p.GetErrorPhrase("id_must_be_integer"), w, r, u)
   365  	}
   366  	// TODO: Add hooks to make use of headerLite
   367  	lite, ferr := c.SimpleForumUserCheck(w, r, u, fid)
   368  	if ferr != nil {
   369  		return ferr
   370  	}
   371  	if !u.Perms.ViewTopic || !u.Perms.CreateTopic {
   372  		return c.NoPermissions(w, r, u)
   373  	}
   374  
   375  	name := c.SanitiseSingleLine(r.PostFormValue("name"))
   376  	content := c.PreparseMessage(r.PostFormValue("content"))
   377  	// TODO: Fully parse the post and store it in the parsed column
   378  	tid, err := c.Topics.Create(fid, name, content, u.ID, u.GetIP())
   379  	if err != nil {
   380  		switch err {
   381  		case c.ErrNoRows:
   382  			return c.LocalError("Something went wrong, perhaps the forum got deleted?", w, r, u)
   383  		case c.ErrNoTitle:
   384  			return c.LocalError("This topic doesn't have a title", w, r, u)
   385  		case c.ErrLongTitle:
   386  			return c.LocalError("The length of the title is too long, max: "+strconv.Itoa(c.Config.MaxTopicTitleLength), w, r, u)
   387  		case c.ErrNoBody:
   388  			return c.LocalError("This topic doesn't have a body", w, r, u)
   389  		}
   390  		return c.InternalError(err, w, r)
   391  	}
   392  
   393  	topic, err := c.Topics.Get(tid)
   394  	if err != nil {
   395  		return c.LocalError("Unable to load the topic", w, r, u)
   396  	}
   397  	if r.PostFormValue("has_poll") == "1" {
   398  		maxPollOptions := 10
   399  		pollInputItems := make(map[int]string)
   400  		for key, values := range r.Form {
   401  			if !strings.HasPrefix(key, "pollinputitem[") {
   402  				continue
   403  			}
   404  			halves := strings.Split(key, "[")
   405  			if len(halves) != 2 {
   406  				return c.LocalError("Malformed pollinputitem", w, r, u)
   407  			}
   408  			halves[1] = strings.TrimSuffix(halves[1], "]")
   409  
   410  			index, err := strconv.Atoi(halves[1])
   411  			if err != nil {
   412  				return c.LocalError("Malformed pollinputitem", w, r, u)
   413  			}
   414  			for _, value := range values {
   415  				// If there are duplicates, then something has gone horribly wrong, so let's ignore them, this'll likely happen during an attack
   416  				_, exists := pollInputItems[index]
   417  				// TODO: Should we use SanitiseBody instead to keep the newlines?
   418  				if !exists && len(c.SanitiseSingleLine(value)) != 0 {
   419  					pollInputItems[index] = c.SanitiseSingleLine(value)
   420  					if len(pollInputItems) >= maxPollOptions {
   421  						break
   422  					}
   423  				}
   424  			}
   425  		}
   426  
   427  		if len(pollInputItems) > 0 {
   428  			// Make sure the indices are sequential to avoid out of bounds issues
   429  			seqPollInputItems := make(map[int]string)
   430  			for i := 0; i < len(pollInputItems); i++ {
   431  				seqPollInputItems[i] = pollInputItems[i]
   432  			}
   433  
   434  			pollType := 0 // Basic single choice
   435  			_, err := c.Polls.Create(topic, pollType, seqPollInputItems)
   436  			if err != nil {
   437  				return c.LocalError("Failed to add poll to topic", w, r, u) // TODO: Might need to be an internal error as it could leave phantom polls?
   438  			}
   439  		}
   440  	}
   441  
   442  	err = c.Subscriptions.Add(u.ID, tid, "topic")
   443  	if err != nil {
   444  		return c.InternalError(err, w, r)
   445  	}
   446  	err = u.IncreasePostStats(c.WordCount(content), true)
   447  	if err != nil {
   448  		return c.InternalError(err, w, r)
   449  	}
   450  
   451  	// Handle the file attachments
   452  	if u.Perms.UploadFiles {
   453  		_, rerr := uploadAttachment(w, r, u, fid, "forums", tid, "topics", "")
   454  		if rerr != nil {
   455  			return rerr
   456  		}
   457  	}
   458  
   459  	co.PostCounter.Bump()
   460  	co.TopicCounter.Bump()
   461  	// TODO: Pass more data to this hook?
   462  	skip, rerr := lite.Hooks.VhookSkippable("action_end_create_topic", tid, u)
   463  	if skip || rerr != nil {
   464  		return rerr
   465  	}
   466  	http.Redirect(w, r, "/topic/"+strconv.Itoa(tid), http.StatusSeeOther)
   467  	return nil
   468  }
   469  
   470  // TODO: Move this function
   471  func uploadFilesWithHash(w http.ResponseWriter, r *http.Request, u *c.User, dir string) (filenames []string, rerr c.RouteError) {
   472  	files, ok := r.MultipartForm.File["upload_files"]
   473  	if !ok {
   474  		return nil, nil
   475  	}
   476  	if len(files) > 5 {
   477  		return nil, c.LocalError("You can't attach more than five files", w, r, u)
   478  	}
   479  	disableEncode := r.PostFormValue("ko") == "1"
   480  
   481  	for _, file := range files {
   482  		if file.Filename == "" {
   483  			continue
   484  		}
   485  		//c.DebugLog("file.Filename ", file.Filename)
   486  
   487  		extarr := strings.Split(file.Filename, ".")
   488  		if len(extarr) < 2 {
   489  			return nil, c.LocalError("Bad file", w, r, u)
   490  		}
   491  		ext := extarr[len(extarr)-1]
   492  
   493  		// TODO: Can we do this without a regex?
   494  		reg, err := regexp.Compile("[^A-Za-z0-9]+")
   495  		if err != nil {
   496  			return nil, c.LocalError("Bad file extension", w, r, u)
   497  		}
   498  		ext = strings.ToLower(reg.ReplaceAllString(ext, ""))
   499  		if !c.AllowedFileExts.Contains(ext) {
   500  			return nil, c.LocalError("You're not allowed to upload files with this extension", w, r, u)
   501  		}
   502  
   503  		inFile, err := file.Open()
   504  		if err != nil {
   505  			return nil, c.LocalError("Upload failed", w, r, u)
   506  		}
   507  		defer inFile.Close()
   508  
   509  		hasher := sha256.New()
   510  		_, err = io.Copy(hasher, inFile)
   511  		if err != nil {
   512  			return nil, c.LocalError("Upload failed [Hashing Failed]", w, r, u)
   513  		}
   514  		inFile.Close()
   515  
   516  		checksum := hex.EncodeToString(hasher.Sum(nil))
   517  		filename := checksum + "." + ext
   518  
   519  		inFile, err = file.Open()
   520  		if err != nil {
   521  			return nil, c.LocalError("Upload failed", w, r, u)
   522  		}
   523  		defer inFile.Close()
   524  
   525  		outFile, err := os.Create(dir + filename)
   526  		if err != nil {
   527  			return nil, c.LocalError("Upload failed [File Creation Failed]", w, r, u)
   528  		}
   529  		defer outFile.Close()
   530  
   531  		if disableEncode || (ext != "jpg" && ext != "jpeg" && ext != "png" && ext != "gif" && ext != "tiff" && ext != "tif") {
   532  			_, err = io.Copy(outFile, inFile)
   533  			if err != nil {
   534  				return nil, c.LocalError("Upload failed [Copy Failed]", w, r, u)
   535  			}
   536  		} else {
   537  			img, _, err := image.Decode(inFile)
   538  			if err != nil {
   539  				return nil, c.LocalError("Upload failed [Image Decoding Failed]", w, r, u)
   540  			}
   541  
   542  			switch ext {
   543  			case "gif":
   544  				err = gif.Encode(outFile, img, nil)
   545  			case "png":
   546  				err = png.Encode(outFile, img)
   547  			case "tiff", "tif":
   548  				err = tiff.Encode(outFile, img, nil)
   549  			default:
   550  				err = jpeg.Encode(outFile, img, nil)
   551  			}
   552  			if err != nil {
   553  				return nil, c.LocalError("Upload failed [Image Encoding Failed]", w, r, u)
   554  			}
   555  		}
   556  
   557  		filenames = append(filenames, filename)
   558  	}
   559  
   560  	return filenames, nil
   561  }
   562  
   563  // TODO: Update the stats after edits so that we don't under or over decrement stats during deletes
   564  // TODO: Disable stat updates in posts handled by plugin_guilds
   565  func EditTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   566  	js := (r.PostFormValue("js") == "1")
   567  	tid, err := strconv.Atoi(stid)
   568  	if err != nil {
   569  		return c.PreErrorJSQ(p.GetErrorPhrase("id_must_be_integer"), w, r, js)
   570  	}
   571  	topic, err := c.Topics.Get(tid)
   572  	if err == sql.ErrNoRows {
   573  		return c.PreErrorJSQ("The topic you tried to edit doesn't exist.", w, r, js)
   574  	} else if err != nil {
   575  		return c.InternalErrorJSQ(err, w, r, js)
   576  	}
   577  
   578  	// TODO: Add hooks to make use of headerLite
   579  	lite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)
   580  	if ferr != nil {
   581  		return ferr
   582  	}
   583  	if !u.Perms.ViewTopic || !u.Perms.EditTopic {
   584  		return c.NoPermissionsJSQ(w, r, u, js)
   585  	}
   586  	if topic.IsClosed && !u.Perms.CloseTopic {
   587  		return c.NoPermissionsJSQ(w, r, u, js)
   588  	}
   589  
   590  	err = topic.Update(r.PostFormValue("name"), r.PostFormValue("content"))
   591  	// TODO: Avoid duplicating this across this route and the topic creation route
   592  	if err != nil {
   593  		switch err {
   594  		case c.ErrNoTitle:
   595  			return c.LocalErrorJSQ("This topic doesn't have a title", w, r, u, js)
   596  		case c.ErrLongTitle:
   597  			return c.LocalErrorJSQ("The length of the title is too long, max: "+strconv.Itoa(c.Config.MaxTopicTitleLength), w, r, u, js)
   598  		case c.ErrNoBody:
   599  			return c.LocalErrorJSQ("This topic doesn't have a body", w, r, u, js)
   600  		}
   601  		return c.InternalErrorJSQ(err, w, r, js)
   602  	}
   603  
   604  	err = c.Forums.UpdateLastTopic(topic.ID, u.ID, topic.ParentID)
   605  	if err != nil && err != sql.ErrNoRows {
   606  		return c.InternalErrorJSQ(err, w, r, js)
   607  	}
   608  
   609  	// TODO: Avoid the load to get this faster?
   610  	topic, err = c.Topics.Get(topic.ID)
   611  	if err == sql.ErrNoRows {
   612  		return c.PreErrorJSQ("The updated topic doesn't exist.", w, r, js)
   613  	} else if err != nil {
   614  		return c.InternalErrorJSQ(err, w, r, js)
   615  	}
   616  
   617  	skip, rerr := lite.Hooks.VhookSkippable("action_end_edit_topic", topic.ID, u)
   618  	if skip || rerr != nil {
   619  		return rerr
   620  	}
   621  
   622  	if !js {
   623  		http.Redirect(w, r, "/topic/"+strconv.Itoa(tid), http.StatusSeeOther)
   624  	} else {
   625  		outBytes, err := json.Marshal(JsonReply{c.ParseMessage(topic.Content, topic.ParentID, "forums", u.ParseSettings, u)})
   626  		if err != nil {
   627  			return c.InternalErrorJSQ(err, w, r, js)
   628  		}
   629  		w.Write(outBytes)
   630  	}
   631  	return nil
   632  }
   633  
   634  // TODO: Add support for soft-deletion and add a permission for hard delete in addition to the usual
   635  // TODO: Disable stat updates in posts handled by plugin_guilds
   636  func DeleteTopicSubmit(w http.ResponseWriter, r *http.Request, user *c.User) c.RouteError {
   637  	// TODO: Move this to some sort of middleware
   638  	var tids []int
   639  	js := c.ReqIsJson(r)
   640  	if js {
   641  		if r.Body == nil {
   642  			return c.PreErrorJS("No request body", w, r)
   643  		}
   644  		err := json.NewDecoder(r.Body).Decode(&tids)
   645  		if err != nil {
   646  			return c.PreErrorJS("We weren't able to parse your data", w, r)
   647  		}
   648  	} else {
   649  		tid, err := strconv.Atoi(r.URL.Path[len("/topic/delete/submit/"):])
   650  		if err != nil {
   651  			return c.PreError("The provided TopicID is not a valid number.", w, r)
   652  		}
   653  		tids = []int{tid}
   654  	}
   655  	if len(tids) == 0 {
   656  		return c.LocalErrorJSQ("You haven't provided any IDs", w, r, user, js)
   657  	}
   658  
   659  	for _, tid := range tids {
   660  		topic, err := c.Topics.Get(tid)
   661  		if err == sql.ErrNoRows {
   662  			return c.PreErrorJSQ("The topic you tried to delete doesn't exist.", w, r, js)
   663  		} else if err != nil {
   664  			return c.InternalErrorJSQ(err, w, r, js)
   665  		}
   666  
   667  		// TODO: Add hooks to make use of headerLite
   668  		lite, ferr := c.SimpleForumUserCheck(w, r, user, topic.ParentID)
   669  		if ferr != nil {
   670  			return ferr
   671  		}
   672  		if topic.CreatedBy != user.ID {
   673  			if !user.Perms.ViewTopic || !user.Perms.DeleteTopic {
   674  				return c.NoPermissionsJSQ(w, r, user, js)
   675  			}
   676  		}
   677  
   678  		// We might be able to handle this err better
   679  		err = topic.Delete()
   680  		if err != nil {
   681  			return c.InternalErrorJSQ(err, w, r, js)
   682  		}
   683  		err = c.ModLogs.Create("delete", tid, "topic", user.GetIP(), user.ID)
   684  		if err != nil {
   685  			return c.InternalErrorJSQ(err, w, r, js)
   686  		}
   687  
   688  		// ? - We might need to add soft-delete before we can do an action reply for this
   689  		/*_, err = stmts.createActionReply.Exec(tid,"delete",ip,user.ID)
   690  		if err != nil {
   691  			return c.InternalErrorJSQ(err,w,r,js)
   692  		}*/
   693  
   694  		// TODO: Do a bulk delete action hook?
   695  		skip, rerr := lite.Hooks.VhookSkippable("action_end_delete_topic", topic.ID, user)
   696  		if skip || rerr != nil {
   697  			return rerr
   698  		}
   699  
   700  		log.Printf("Topic #%d was deleted by UserID #%d", tid, user.ID)
   701  	}
   702  	http.Redirect(w, r, "/", http.StatusSeeOther)
   703  	return nil
   704  }
   705  
   706  func StickTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   707  	topic, lite, rerr := topicActionPre(stid, "pin", w, r, u)
   708  	if rerr != nil {
   709  		return rerr
   710  	}
   711  	if !u.Perms.ViewTopic || !u.Perms.PinTopic {
   712  		return c.NoPermissions(w, r, u)
   713  	}
   714  	return topicActionPost(topic.Stick(), "stick", w, r, lite, topic, u)
   715  }
   716  
   717  //
   718  //
   719  // mark
   720  //
   721  //
   722  func topicActionPre(stid, action string, w http.ResponseWriter, r *http.Request, u *c.User) (*c.Topic, *c.HeaderLite, c.RouteError) {
   723  	tid, err := strconv.Atoi(stid)
   724  	if err != nil {
   725  		return nil, nil, c.PreError(p.GetErrorPhrase("id_must_be_integer"), w, r)
   726  	}
   727  	t, err := c.Topics.Get(tid)
   728  	if err == sql.ErrNoRows {
   729  		return nil, nil, c.PreError("The topic you tried to "+action+" doesn't exist.", w, r)
   730  	} else if err != nil {
   731  		return nil, nil, c.InternalError(err, w, r)
   732  	}
   733  
   734  	// TODO: Add hooks to make use of headerLite
   735  	lite, ferr := c.SimpleForumUserCheck(w, r, u, t.ParentID)
   736  	if ferr != nil {
   737  		return nil, nil, ferr
   738  	}
   739  	return t, lite, nil
   740  }
   741  
   742  func topicActionPost(err error, action string, w http.ResponseWriter, r *http.Request, lite *c.HeaderLite, topic *c.Topic, u *c.User) c.RouteError {
   743  	if err != nil {
   744  		return c.InternalError(err, w, r)
   745  	}
   746  	err = addTopicAction(action, topic, u)
   747  	if err != nil {
   748  		return c.InternalError(err, w, r)
   749  	}
   750  	skip, rerr := lite.Hooks.VhookSkippable("action_end_"+action+"_topic", topic.ID, u)
   751  	if skip || rerr != nil {
   752  		return rerr
   753  	}
   754  	http.Redirect(w, r, "/topic/"+strconv.Itoa(topic.ID), http.StatusSeeOther)
   755  	return nil
   756  }
   757  
   758  func UnstickTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   759  	t, lite, rerr := topicActionPre(stid, "unpin", w, r, u)
   760  	if rerr != nil {
   761  		return rerr
   762  	}
   763  	if !u.Perms.ViewTopic || !u.Perms.PinTopic {
   764  		return c.NoPermissions(w, r, u)
   765  	}
   766  	return topicActionPost(t.Unstick(), "unstick", w, r, lite, t, u)
   767  }
   768  
   769  func LockTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User) c.RouteError {
   770  	// TODO: Move this to some sort of middleware
   771  	var tids []int
   772  	js := c.ReqIsJson(r)
   773  	if js {
   774  		if r.Body == nil {
   775  			return c.PreErrorJS("No request body", w, r)
   776  		}
   777  		err := json.NewDecoder(r.Body).Decode(&tids)
   778  		if err != nil {
   779  			return c.PreErrorJS("We weren't able to parse your data", w, r)
   780  		}
   781  	} else {
   782  		tid, err := strconv.Atoi(r.URL.Path[len("/topic/lock/submit/"):])
   783  		if err != nil {
   784  			return c.PreError("The provided TopicID is not a valid number.", w, r)
   785  		}
   786  		tids = append(tids, tid)
   787  	}
   788  	if len(tids) == 0 {
   789  		return c.LocalErrorJSQ("You haven't provided any IDs", w, r, u, js)
   790  	}
   791  
   792  	for _, tid := range tids {
   793  		topic, err := c.Topics.Get(tid)
   794  		if err == sql.ErrNoRows {
   795  			return c.PreErrorJSQ("The topic you tried to lock doesn't exist.", w, r, js)
   796  		} else if err != nil {
   797  			return c.InternalErrorJSQ(err, w, r, js)
   798  		}
   799  
   800  		// TODO: Add hooks to make use of headerLite
   801  		lite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)
   802  		if ferr != nil {
   803  			return ferr
   804  		}
   805  		if !u.Perms.ViewTopic || !u.Perms.CloseTopic {
   806  			return c.NoPermissionsJSQ(w, r, u, js)
   807  		}
   808  
   809  		err = topic.Lock()
   810  		if err != nil {
   811  			return c.InternalErrorJSQ(err, w, r, js)
   812  		}
   813  
   814  		err = addTopicAction("lock", topic, u)
   815  		if err != nil {
   816  			return c.InternalErrorJSQ(err, w, r, js)
   817  		}
   818  
   819  		// TODO: Do a bulk lock action hook?
   820  		skip, rerr := lite.Hooks.VhookSkippable("action_end_lock_topic", topic.ID, u)
   821  		if skip || rerr != nil {
   822  			return rerr
   823  		}
   824  	}
   825  
   826  	if len(tids) == 1 {
   827  		http.Redirect(w, r, "/topic/"+strconv.Itoa(tids[0]), http.StatusSeeOther)
   828  	}
   829  	return nil
   830  }
   831  
   832  func UnlockTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   833  	t, lite, rerr := topicActionPre(stid, "unlock", w, r, u)
   834  	if rerr != nil {
   835  		return rerr
   836  	}
   837  	if !u.Perms.ViewTopic || !u.Perms.CloseTopic {
   838  		return c.NoPermissions(w, r, u)
   839  	}
   840  	return topicActionPost(t.Unlock(), "unlock", w, r, lite, t, u)
   841  }
   842  
   843  // ! JS only route
   844  // TODO: Figure a way to get this route to work without JS
   845  func MoveTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, sfid string) c.RouteError {
   846  	fid, err := strconv.Atoi(sfid)
   847  	if err != nil {
   848  		return c.PreErrorJS(p.GetErrorPhrase("id_must_be_integer"), w, r)
   849  	}
   850  	// TODO: Move this to some sort of middleware
   851  	var tids []int
   852  	if r.Body == nil {
   853  		return c.PreErrorJS("No request body", w, r)
   854  	}
   855  	err = json.NewDecoder(r.Body).Decode(&tids)
   856  	if err != nil {
   857  		return c.PreErrorJS("We weren't able to parse your data", w, r)
   858  	}
   859  	if len(tids) == 0 {
   860  		return c.LocalErrorJS("You haven't provided any IDs", w, r)
   861  	}
   862  
   863  	for _, tid := range tids {
   864  		topic, err := c.Topics.Get(tid)
   865  		if err == sql.ErrNoRows {
   866  			return c.PreErrorJS("The topic you tried to move doesn't exist.", w, r)
   867  		} else if err != nil {
   868  			return c.InternalErrorJS(err, w, r)
   869  		}
   870  
   871  		// TODO: Add hooks to make use of headerLite
   872  		_, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)
   873  		if ferr != nil {
   874  			return ferr
   875  		}
   876  		if !u.Perms.ViewTopic || !u.Perms.MoveTopic {
   877  			return c.NoPermissionsJS(w, r, u)
   878  		}
   879  		lite, ferr := c.SimpleForumUserCheck(w, r, u, fid)
   880  		if ferr != nil {
   881  			return ferr
   882  		}
   883  		if !u.Perms.ViewTopic || !u.Perms.MoveTopic {
   884  			return c.NoPermissionsJS(w, r, u)
   885  		}
   886  
   887  		err = topic.MoveTo(fid)
   888  		if err != nil {
   889  			return c.InternalErrorJS(err, w, r)
   890  		}
   891  		// ? - Is there a better way of doing this?
   892  		err = addTopicAction("move-"+strconv.Itoa(fid), topic, u)
   893  		if err != nil {
   894  			return c.InternalErrorJS(err, w, r)
   895  		}
   896  
   897  		// TODO: Do a bulk move action hook?
   898  		skip, rerr := lite.Hooks.VhookSkippable("action_end_move_topic", topic.ID, u)
   899  		if skip || rerr != nil {
   900  			return rerr
   901  		}
   902  	}
   903  
   904  	if len(tids) == 1 {
   905  		http.Redirect(w, r, "/topic/"+strconv.Itoa(tids[0]), http.StatusSeeOther)
   906  	}
   907  	return nil
   908  }
   909  
   910  func addTopicAction(action string, t *c.Topic, u *c.User) error {
   911  	err := c.ModLogs.Create(action, t.ID, "topic", u.GetIP(), u.ID)
   912  	if err != nil {
   913  		return err
   914  	}
   915  	return t.CreateActionReply(action, u.GetIP(), u.ID)
   916  }
   917  
   918  // TODO: Refactor this
   919  func LikeTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   920  	js := r.PostFormValue("js") == "1"
   921  	tid, err := strconv.Atoi(stid)
   922  	if err != nil {
   923  		return c.PreErrorJSQ(p.GetErrorPhrase("id_must_be_integer"), w, r, js)
   924  	}
   925  	topic, err := c.Topics.Get(tid)
   926  	if err == sql.ErrNoRows {
   927  		return c.PreErrorJSQ("The requested topic doesn't exist.", w, r, js)
   928  	} else if err != nil {
   929  		return c.InternalErrorJSQ(err, w, r, js)
   930  	}
   931  
   932  	// TODO: Add hooks to make use of headerLite
   933  	lite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)
   934  	if ferr != nil {
   935  		return ferr
   936  	}
   937  	if !u.Perms.ViewTopic || !u.Perms.LikeItem {
   938  		return c.NoPermissionsJSQ(w, r, u, js)
   939  	}
   940  	if topic.CreatedBy == u.ID {
   941  		return c.LocalErrorJSQ("You can't like your own topics", w, r, u, js)
   942  	}
   943  
   944  	_, err = c.Users.Get(topic.CreatedBy)
   945  	if err != nil && err == sql.ErrNoRows {
   946  		return c.LocalErrorJSQ("The target user doesn't exist", w, r, u, js)
   947  	} else if err != nil {
   948  		return c.InternalErrorJSQ(err, w, r, js)
   949  	}
   950  
   951  	score := 1
   952  	err = topic.Like(score, u.ID)
   953  	if err == c.ErrAlreadyLiked {
   954  		return c.LocalErrorJSQ("You already liked this", w, r, u, js)
   955  	} else if err != nil {
   956  		return c.InternalErrorJSQ(err, w, r, js)
   957  	}
   958  
   959  	// ! Be careful about leaking per-route permission state with user ptr
   960  	alert := c.Alert{ActorID: u.ID, TargetUserID: topic.CreatedBy, Event: "like", ElementType: "topic", ElementID: tid, Actor: u}
   961  	err = c.AddActivityAndNotifyTarget(alert)
   962  	if err != nil {
   963  		return c.InternalErrorJSQ(err, w, r, js)
   964  	}
   965  
   966  	skip, rerr := lite.Hooks.VhookSkippable("action_end_like_topic", topic.ID, u)
   967  	if skip || rerr != nil {
   968  		return rerr
   969  	}
   970  	return actionSuccess(w, r, "/topic/"+strconv.Itoa(tid), js)
   971  }
   972  func UnlikeTopicSubmit(w http.ResponseWriter, r *http.Request, u *c.User, stid string) c.RouteError {
   973  	js := r.PostFormValue("js") == "1"
   974  	tid, err := strconv.Atoi(stid)
   975  	if err != nil {
   976  		return c.PreErrorJSQ(p.GetErrorPhrase("id_must_be_integer"), w, r, js)
   977  	}
   978  	topic, err := c.Topics.Get(tid)
   979  	if err == sql.ErrNoRows {
   980  		return c.PreErrorJSQ("The requested topic doesn't exist.", w, r, js)
   981  	} else if err != nil {
   982  		return c.InternalErrorJSQ(err, w, r, js)
   983  	}
   984  
   985  	// TODO: Add hooks to make use of headerLite
   986  	lite, ferr := c.SimpleForumUserCheck(w, r, u, topic.ParentID)
   987  	if ferr != nil {
   988  		return ferr
   989  	}
   990  	if !u.Perms.ViewTopic || !u.Perms.LikeItem {
   991  		return c.NoPermissionsJSQ(w, r, u, js)
   992  	}
   993  
   994  	_, err = c.Users.Get(topic.CreatedBy)
   995  	if err != nil && err == sql.ErrNoRows {
   996  		return c.LocalErrorJSQ("The target user doesn't exist", w, r, u, js)
   997  	} else if err != nil {
   998  		return c.InternalErrorJSQ(err, w, r, js)
   999  	}
  1000  	err = topic.Unlike(u.ID)
  1001  	if err != nil {
  1002  		return c.InternalErrorJSQ(err, w, r, js)
  1003  	}
  1004  
  1005  	// TODO: Better coupling between the two params queries
  1006  	aids, err := c.Activity.AidsByParams("like", topic.ID, "topic")
  1007  	if err != nil {
  1008  		return c.InternalErrorJSQ(err, w, r, js)
  1009  	}
  1010  	for _, aid := range aids {
  1011  		c.DismissAlert(topic.CreatedBy, aid)
  1012  	}
  1013  	err = c.Activity.DeleteByParams("like", topic.ID, "topic")
  1014  	if err != nil {
  1015  		return c.InternalErrorJSQ(err, w, r, js)
  1016  	}
  1017  
  1018  	skip, rerr := lite.Hooks.VhookSkippable("action_end_unlike_topic", topic.ID, u)
  1019  	if skip || rerr != nil {
  1020  		return rerr
  1021  	}
  1022  	return actionSuccess(w, r, "/topic/"+strconv.Itoa(tid), js)
  1023  }