dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

links.go (21900B)


      1 package handlers
      2 
      3 import (
      4 	"bytes"
      5 	"dkforest/pkg/captcha"
      6 	"dkforest/pkg/database"
      7 	"dkforest/pkg/utils"
      8 	hutils "dkforest/pkg/web/handlers/utils"
      9 	"encoding/base64"
     10 	"encoding/csv"
     11 	"encoding/pem"
     12 	"fmt"
     13 	"github.com/asaskevich/govalidator"
     14 	"github.com/labstack/echo"
     15 	"github.com/sirupsen/logrus"
     16 	"gorm.io/gorm"
     17 	"net/http"
     18 	"net/url"
     19 	"regexp"
     20 	"strings"
     21 )
     22 
     23 func LinksHandler(c echo.Context) error {
     24 	authUser := c.Get("authUser").(*database.User)
     25 	db := c.Get("database").(*database.DkfDB)
     26 	var data linksData
     27 
     28 	data.Categories, _ = db.GetCategories()
     29 
     30 	data.Search = c.QueryParam("search")
     31 	filterCategory := c.QueryParam("category")
     32 
     33 	if filterCategory != "" {
     34 		if filterCategory == "uncategorized" {
     35 			db.DB().Raw(`SELECT l.*
     36 FROM links l
     37 LEFT JOIN links_categories_links cl ON cl.link_id = l.id
     38 WHERE cl.link_id IS NULL AND l.deleted_at IS NULL
     39 ORDER BY l.title COLLATE NOCASE ASC`).Scan(&data.Links)
     40 			data.LinksCount = int64(len(data.Links))
     41 		} else {
     42 			db.DB().Raw(`SELECT l.*
     43 FROM links_categories_links cl
     44 INNER JOIN links l ON l.id = cl.link_id
     45 WHERE cl.category_id = (SELECT id FROM links_categories WHERE name = ?) AND l.deleted_at IS NULL
     46 ORDER BY l.title COLLATE NOCASE ASC`, filterCategory).Scan(&data.Links)
     47 			data.LinksCount = int64(len(data.Links))
     48 		}
     49 	} else if data.Search != "" {
     50 		if govalidator.IsURL(data.Search) {
     51 			if searchedURL, err := url.Parse(data.Search); err == nil {
     52 				h := searchedURL.Scheme + "://" + searchedURL.Hostname()
     53 				var l database.Link
     54 				query := db.DB()
     55 				if authUser.IsModerator() {
     56 					query = query.Unscoped()
     57 				}
     58 				if err := query.First(&l, "url = ?", h).Error; err == nil {
     59 					data.Links = append(data.Links, l)
     60 				}
     61 				data.LinksCount = int64(len(data.Links))
     62 			}
     63 		} else {
     64 			if err := db.DB().Raw(`select l.id, l.uuid, l.url, l.title, l.description
     65 from fts5_links l
     66 where fts5_links match ?
     67 ORDER BY rank, l.title COLLATE NOCASE ASC
     68 LIMIT 100`, data.Search).Scan(&data.Links).Error; err != nil {
     69 				logrus.Error(err)
     70 			}
     71 			data.LinksCount = int64(len(data.Links))
     72 		}
     73 	} else {
     74 		if err := db.DB().Table("links").
     75 			Scopes(func(query *gorm.DB) *gorm.DB {
     76 				data.CurrentPage, data.MaxPage, data.LinksCount, query = NewPaginator().Paginate(c, query)
     77 				return query
     78 			}).Order("title COLLATE NOCASE ASC").Find(&data.Links).Error; err != nil {
     79 			logrus.Error(err)
     80 		}
     81 	}
     82 
     83 	// Get all links IDs
     84 	linksIDs := make([]int64, 0)
     85 	for _, l := range data.Links {
     86 		linksIDs = append(linksIDs, l.ID)
     87 	}
     88 	// Keep pointers to links for fast access
     89 	linksCache := make(map[int64]*database.Link)
     90 	for i, l := range data.Links {
     91 		linksCache[l.ID] = &data.Links[i]
     92 	}
     93 	// Get all mirrors for all links that we have
     94 	var mirrors []database.LinksMirror
     95 	db.DB().Raw(`select * from links_mirrors where link_id in (?)`, linksIDs).Scan(&mirrors)
     96 	// Put mirrors in links
     97 	for _, m := range mirrors {
     98 		if l, ok := linksCache[m.LinkID]; ok {
     99 			l.Mirrors = append(l.Mirrors, m)
    100 		}
    101 	}
    102 
    103 	return c.Render(http.StatusOK, "links", data)
    104 }
    105 
    106 func LinksDownloadHandler(c echo.Context) error {
    107 	authUser := c.Get("authUser").(*database.User)
    108 	db := c.Get("database").(*database.DkfDB)
    109 	fileName := "dkf_links.csv"
    110 
    111 	// Captcha for bigger files
    112 	var data captchaRequiredData
    113 	data.CaptchaDescription = "Captcha required"
    114 	data.CaptchaID, data.CaptchaImg = captcha.New()
    115 	const captchaRequiredTmpl = "captcha-required"
    116 	if c.Request().Method == http.MethodGet {
    117 		return c.Render(http.StatusOK, captchaRequiredTmpl, data)
    118 	}
    119 	captchaID := c.Request().PostFormValue("captcha_id")
    120 	captchaInput := c.Request().PostFormValue("captcha")
    121 	if err := hutils.CaptchaVerifyString(c, captchaID, captchaInput); err != nil {
    122 		data.ErrCaptcha = err.Error()
    123 		return c.Render(http.StatusOK, captchaRequiredTmpl, data)
    124 	}
    125 
    126 	// Keep track of user downloads
    127 	if _, err := db.CreateDownload(authUser.ID, fileName); err != nil {
    128 		logrus.Error(err)
    129 	}
    130 
    131 	// Get all categories and make a hashmap for fast access
    132 	categories, _ := db.GetLinksCategories()
    133 	categoriesMap := make(map[int64]string)
    134 	for _, category := range categories {
    135 		categoriesMap[category.ID] = category.Name
    136 	}
    137 	// Get all "categories links" associations between links and their categories
    138 	categoriesLinks, _ := db.GetCategoriesLinks()
    139 	// Build a map of all categories IDs for a given link ID
    140 	categoriesLinksMap := make(map[int64][]int64)
    141 	for _, cl := range categoriesLinks {
    142 		categoriesLinksMap[cl.LinkID] = append(categoriesLinksMap[cl.LinkID], cl.CategoryID)
    143 	}
    144 
    145 	links, _ := db.GetLinks()
    146 	by := make([]byte, 0)
    147 	buf := bytes.NewBuffer(by)
    148 	w := csv.NewWriter(buf)
    149 	_ = w.Write([]string{"UUID", "URL", "Title", "Description", "Categories"})
    150 	for _, link := range links {
    151 		// Get all categories for the link
    152 		categoryNames := make([]string, 0)
    153 		categoryIDs := categoriesLinksMap[link.ID]
    154 		for _, tagID := range categoryIDs {
    155 			categoryNames = append(categoryNames, categoriesMap[tagID])
    156 		}
    157 		_ = w.Write([]string{link.UUID, link.URL, link.Title, link.Description, strings.Join(categoryNames, ",")})
    158 	}
    159 	w.Flush()
    160 	c.Response().Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
    161 	return c.Stream(http.StatusOK, "application/octet-stream", buf)
    162 }
    163 
    164 func LinkHandler(c echo.Context) error {
    165 	db := c.Get("database").(*database.DkfDB)
    166 	shorthand := c.Param("shorthand")
    167 	linkUUID := c.Param("linkUUID")
    168 	var data linkData
    169 	var err error
    170 	if shorthand != "" {
    171 		data.Link, err = db.GetLinkByShorthand(shorthand)
    172 	} else {
    173 		data.Link, err = db.GetLinkByUUID(linkUUID)
    174 	}
    175 	if err != nil {
    176 		return c.Redirect(http.StatusFound, "/links")
    177 	}
    178 	data.PgpKeys, _ = db.GetLinkPgps(data.Link.ID)
    179 	data.Mirrors, _ = db.GetLinkMirrors(data.Link.ID)
    180 	return c.Render(http.StatusOK, "link", data)
    181 }
    182 
    183 func RestoreLinkHandler(c echo.Context) error {
    184 	authUser := c.Get("authUser").(*database.User)
    185 	db := c.Get("database").(*database.DkfDB)
    186 	if !authUser.IsModerator() {
    187 		return c.Redirect(http.StatusFound, "/")
    188 	}
    189 	linkUUID := c.Param("linkUUID")
    190 	var link database.Link
    191 	if err := db.DB().Unscoped().First(&link, "uuid = ?", linkUUID).Error; err != nil {
    192 		return hutils.RedirectReferer(c)
    193 	}
    194 	db.NewAudit(*authUser, fmt.Sprintf("restore link %s", link.URL))
    195 	db.DB().Unscoped().Model(&database.Link{}).Where("id = ?", link.ID).Update("deleted_at", nil)
    196 	return hutils.RedirectReferer(c)
    197 }
    198 
    199 func ClaimLinkHandler(c echo.Context) error {
    200 	authUser := c.Get("authUser").(*database.User)
    201 	db := c.Get("database").(*database.DkfDB)
    202 	linkUUID := c.Param("linkUUID")
    203 	link, err := db.GetLinkByUUID(linkUUID)
    204 	if err != nil {
    205 		return c.Redirect(http.StatusFound, "/")
    206 	}
    207 	var data claimLinkData
    208 	data.Link = link
    209 	data.Certificate = link.GenOwnershipCert(authUser.Username)
    210 
    211 	if c.Request().Method == http.MethodGet {
    212 		return c.Render(http.StatusOK, "link-claim", data)
    213 	}
    214 
    215 	data.Signature = c.Request().PostFormValue("signature")
    216 
    217 	b64Sig, err := base64.StdEncoding.DecodeString(data.Signature)
    218 	if err != nil {
    219 		data.Error = "invalid signature"
    220 		return c.Render(http.StatusOK, "link-claim", data)
    221 	}
    222 	pemSign := string(pem.EncodeToMemory(&pem.Block{Type: "SIGNATURE", Bytes: b64Sig}))
    223 
    224 	isValid := utils.VerifyTorSign(link.GetOnionAddr(), data.Certificate, pemSign)
    225 	if !isValid {
    226 		data.Error = "invalid signature"
    227 		return c.Render(http.StatusOK, "link-claim", data)
    228 	}
    229 
    230 	signedCert := "-----BEGIN SIGNED MESSAGE-----\n" +
    231 		data.Certificate + "\n" +
    232 		pemSign
    233 
    234 	link.SignedCertificate = signedCert
    235 	link.OwnerUserID = &authUser.ID
    236 	link.DoSave(db)
    237 
    238 	return c.Redirect(http.StatusFound, "/links/"+link.UUID)
    239 }
    240 
    241 func ClaimDownloadCertificateLinkHandler(c echo.Context) error {
    242 	authUser := c.Get("authUser").(*database.User)
    243 	db := c.Get("database").(*database.DkfDB)
    244 
    245 	linkUUID := c.Param("linkUUID")
    246 	link, err := db.GetLinkByUUID(linkUUID)
    247 	if err != nil {
    248 		return c.Redirect(http.StatusFound, "/")
    249 	}
    250 
    251 	fileName := "certificate.txt"
    252 
    253 	// Keep track of user downloads
    254 	if _, err := db.CreateDownload(authUser.ID, fileName); err != nil {
    255 		logrus.Error(err)
    256 	}
    257 
    258 	c.Response().Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
    259 	return c.Stream(http.StatusOK, "application/octet-stream", strings.NewReader(link.GenOwnershipCert(authUser.Username)))
    260 }
    261 
    262 func ClaimCertificateLinkHandler(c echo.Context) error {
    263 	linkUUID := c.Param("linkUUID")
    264 	db := c.Get("database").(*database.DkfDB)
    265 	link, err := db.GetLinkByUUID(linkUUID)
    266 	if err != nil {
    267 		return c.Redirect(http.StatusFound, "/")
    268 	}
    269 	return c.String(http.StatusOK, link.SignedCertificate)
    270 }
    271 
    272 func EditLinkHandler(c echo.Context) error {
    273 	authUser := c.Get("authUser").(*database.User)
    274 	db := c.Get("database").(*database.DkfDB)
    275 	if !authUser.IsModerator() {
    276 		return c.Redirect(http.StatusFound, "/")
    277 	}
    278 	linkUUID := c.Param("linkUUID")
    279 	link, err := db.GetLinkByUUID(linkUUID)
    280 	if err != nil {
    281 		return c.Redirect(http.StatusFound, "/")
    282 	}
    283 	out, _ := db.GetLinkCategories(link.ID)
    284 	categories := make([]string, 0)
    285 	for _, el := range out {
    286 		categories = append(categories, el.Name)
    287 	}
    288 	out1, err := db.GetLinkTags(link.ID)
    289 	tags := make([]string, 0)
    290 	for _, el := range out1 {
    291 		tags = append(tags, el.Name)
    292 	}
    293 	var data editLinkData
    294 	data.IsEdit = true
    295 	data.Link = link.URL
    296 	data.Title = link.Title
    297 	data.Description = link.Description
    298 	if link.Shorthand != nil {
    299 		data.Shorthand = *link.Shorthand
    300 	}
    301 	data.Categories = strings.Join(categories, ",")
    302 	data.Tags = strings.Join(tags, ",")
    303 	data.Mirrors, _ = db.GetLinkMirrors(link.ID)
    304 	data.LinkPgps, _ = db.GetLinkPgps(link.ID)
    305 	//data.Categories = link
    306 
    307 	if c.Request().Method == http.MethodPost {
    308 		formName := c.Request().PostFormValue("formName")
    309 		if formName == "createLink" {
    310 			_ = db.DeleteLinkCategories(link.ID)
    311 			_ = db.DeleteLinkTags(link.ID)
    312 
    313 			// If link is signed, we can no longer edit the link URL
    314 			if link.SignedCertificate == "" {
    315 				data.Link = c.Request().PostFormValue("link")
    316 			}
    317 			data.Title = c.Request().PostFormValue("title")
    318 			data.Description = c.Request().PostFormValue("description")
    319 			data.Shorthand = c.Request().PostFormValue("shorthand")
    320 			data.Categories = c.Request().PostFormValue("categories")
    321 			data.Tags = c.Request().PostFormValue("tags")
    322 			if !govalidator.Matches(data.Link, `^https?://[a-z2-7]{56}\.onion$`) {
    323 				data.ErrorLink = "invalid link"
    324 				return c.Render(http.StatusOK, "new-link", data)
    325 			}
    326 			if !govalidator.RuneLength(data.Title, "0", "255") {
    327 				data.ErrorTitle = "title must have 255 characters max"
    328 				return c.Render(http.StatusOK, "new-link", data)
    329 			}
    330 			if !govalidator.RuneLength(data.Description, "0", "1000") {
    331 				data.ErrorCategories = "description must have 1000 characters max"
    332 				return c.Render(http.StatusOK, "new-link", data)
    333 			}
    334 			if data.Shorthand != "" {
    335 				if !govalidator.Matches(data.Shorthand, `^[\w-_]{3,50}$`) {
    336 					data.ErrorLink = "invalid shorthand"
    337 					return c.Render(http.StatusOK, "new-link", data)
    338 				}
    339 			}
    340 			categoryRgx := regexp.MustCompile(`^\w{3,20}$`)
    341 			var tagsStr, categoriesStr []string
    342 			if data.Categories != "" {
    343 				categoriesStr = strings.Split(strings.ToLower(data.Categories), ",")
    344 				for _, category := range categoriesStr {
    345 					category = strings.TrimSpace(category)
    346 					if !categoryRgx.MatchString(category) {
    347 						data.ErrorCategories = `invalid category "` + category + `"`
    348 						return c.Render(http.StatusOK, "new-link", data)
    349 					}
    350 				}
    351 			}
    352 			if data.Tags != "" {
    353 				tagsStr = strings.Split(strings.ToLower(data.Tags), ",")
    354 				for _, tag := range tagsStr {
    355 					tag = strings.TrimSpace(tag)
    356 					if !categoryRgx.MatchString(tag) {
    357 						data.ErrorTags = `invalid tag "` + tag + `"`
    358 						return c.Render(http.StatusOK, "new-link", data)
    359 					}
    360 				}
    361 			}
    362 			//------------
    363 			var categories []database.LinksCategory
    364 			var tags []database.LinksTag
    365 			for _, categoryStr := range categoriesStr {
    366 				category, _ := db.CreateLinksCategory(categoryStr)
    367 				categories = append(categories, category)
    368 			}
    369 			for _, tagStr := range tagsStr {
    370 				tag, _ := db.CreateLinksTag(tagStr)
    371 				tags = append(tags, tag)
    372 			}
    373 			link.URL = data.Link
    374 			link.Title = data.Title
    375 			link.Description = data.Description
    376 			if data.Shorthand != "" {
    377 				link.Shorthand = &data.Shorthand
    378 			}
    379 			if err := db.DB().Save(&link).Error; err != nil {
    380 				if strings.Contains(err.Error(), "UNIQUE constraint failed: links.shorthand") {
    381 					data.ErrorShorthand = "shorthand already used"
    382 				} else {
    383 					data.ErrorLink = "failed to update link"
    384 				}
    385 				return c.Render(http.StatusOK, "new-link", data)
    386 			}
    387 			for _, category := range categories {
    388 				_ = db.AddLinkCategory(link.ID, category.ID)
    389 			}
    390 			for _, tag := range tags {
    391 				_ = db.AddLinkTag(link.ID, tag.ID)
    392 			}
    393 			db.NewAudit(*authUser, fmt.Sprintf("updated link %s", link.URL))
    394 			return c.Redirect(http.StatusFound, "/links")
    395 
    396 		} else if formName == "createPgp" {
    397 			data.PGPTitle = c.Request().PostFormValue("pgp_title")
    398 			if !govalidator.RuneLength(data.PGPTitle, "3", "255") {
    399 				data.ErrorPGPTitle = "title must have 3-255 characters"
    400 				return c.Render(http.StatusOK, "new-link", data)
    401 			}
    402 			data.PGPDescription = c.Request().PostFormValue("pgp_description")
    403 			data.PGPPublicKey = c.Request().PostFormValue("pgp_public_key")
    404 			if _, err = db.CreateLinkPgp(link.ID, data.PGPTitle, data.PGPDescription, data.PGPPublicKey); err != nil {
    405 				logrus.Error(err)
    406 			}
    407 			db.NewAudit(*authUser, fmt.Sprintf("create gpg for link %s", link.URL))
    408 			return hutils.RedirectReferer(c)
    409 
    410 		} else if formName == "createMirror" {
    411 			data.MirrorLink = c.Request().PostFormValue("mirror_link")
    412 			if !govalidator.Matches(data.MirrorLink, `^https?://[a-z2-7]{56}\.onion$`) {
    413 				data.ErrorMirrorLink = "invalid link"
    414 				return c.Render(http.StatusOK, "new-link", data)
    415 			}
    416 			if _, err = db.CreateLinkMirror(link.ID, data.MirrorLink); err != nil {
    417 				logrus.Error(err)
    418 			}
    419 			db.NewAudit(*authUser, fmt.Sprintf("create mirror for link %s", link.URL))
    420 			return hutils.RedirectReferer(c)
    421 		}
    422 		return c.Redirect(http.StatusFound, "/links")
    423 	}
    424 
    425 	return c.Render(http.StatusOK, "new-link", data)
    426 }
    427 
    428 func LinkDeleteHandler(c echo.Context) error {
    429 	authUser := c.Get("authUser").(*database.User)
    430 	db := c.Get("database").(*database.DkfDB)
    431 	linkUUID := c.Param("linkUUID")
    432 	link, err := db.GetLinkByUUID(linkUUID)
    433 	if err != nil {
    434 		return hutils.RedirectReferer(c)
    435 	}
    436 
    437 	if !authUser.IsModerator() {
    438 		return hutils.RedirectReferer(c)
    439 	}
    440 
    441 	var data deleteLinkData
    442 	data.Link = link
    443 
    444 	if c.Request().Method == http.MethodPost {
    445 		db.NewAudit(*authUser, fmt.Sprintf("deleted link %s", link.URL))
    446 		if err := db.DeleteLinkByID(link.ID); err != nil {
    447 			logrus.Error(err)
    448 		}
    449 		return c.Redirect(http.StatusFound, "/links")
    450 	}
    451 
    452 	return c.Render(http.StatusOK, "link-delete", data)
    453 }
    454 
    455 func LinkPgpDownloadHandler(c echo.Context) error {
    456 	authUser := c.Get("authUser").(*database.User)
    457 	db := c.Get("database").(*database.DkfDB)
    458 
    459 	pgpID := utils.DoParseInt64(c.Param("linkPgpID"))
    460 	linkPgp, err := db.GetLinkPgpByID(pgpID)
    461 	if err != nil {
    462 		return c.NoContent(http.StatusNotFound)
    463 	}
    464 
    465 	fileName := linkPgp.Title + ".asc"
    466 
    467 	// Keep track of user downloads
    468 	if _, err := db.CreateDownload(authUser.ID, fileName); err != nil {
    469 		logrus.Error(err)
    470 	}
    471 
    472 	c.Response().Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
    473 	return c.Stream(http.StatusOK, "application/octet-stream", strings.NewReader(linkPgp.PgpPublicKey))
    474 }
    475 
    476 func LinkPgpDeleteHandler(c echo.Context) error {
    477 	authUser := c.Get("authUser").(*database.User)
    478 	db := c.Get("database").(*database.DkfDB)
    479 	linkPgpID := utils.DoParseInt64(c.Param("linkPgpID"))
    480 	linkPgp, err := db.GetLinkPgpByID(linkPgpID)
    481 	if err != nil {
    482 		return hutils.RedirectReferer(c)
    483 	}
    484 	link, err := db.GetLinkByID(linkPgp.LinkID)
    485 	if err != nil {
    486 		return hutils.RedirectReferer(c)
    487 	}
    488 
    489 	if !authUser.IsModerator() {
    490 		return hutils.RedirectReferer(c)
    491 	}
    492 
    493 	var data deleteLinkPgpData
    494 	data.Link = link
    495 	data.LinkPgp = linkPgp
    496 
    497 	if c.Request().Method == http.MethodPost {
    498 		if err := db.DeleteLinkPgpByID(linkPgp.ID); err != nil {
    499 			logrus.Error(err)
    500 		}
    501 		return c.Redirect(http.StatusFound, "/links/"+link.UUID+"/edit")
    502 	}
    503 
    504 	return c.Render(http.StatusOK, "link-pgp-delete", data)
    505 }
    506 
    507 func LinkMirrorDeleteHandler(c echo.Context) error {
    508 	authUser := c.Get("authUser").(*database.User)
    509 	db := c.Get("database").(*database.DkfDB)
    510 	linkMirrorID := utils.DoParseInt64(c.Param("linkMirrorID"))
    511 	linkMirror, err := db.GetLinkMirrorByID(linkMirrorID)
    512 	if err != nil {
    513 		return c.Redirect(http.StatusFound, "/links")
    514 	}
    515 	link, err := db.GetLinkByID(linkMirror.LinkID)
    516 	if err != nil {
    517 		return c.Redirect(http.StatusFound, "/links")
    518 	}
    519 
    520 	if !authUser.IsModerator() {
    521 		return c.Redirect(http.StatusFound, "/links")
    522 	}
    523 
    524 	var data deleteLinkMirrorData
    525 	data.Link = link
    526 	data.LinkMirror = linkMirror
    527 
    528 	if c.Request().Method == http.MethodPost {
    529 		if err := db.DeleteLinkMirrorByID(linkMirror.ID); err != nil {
    530 			logrus.Error(err)
    531 		}
    532 		return c.Redirect(http.StatusFound, "/links/"+link.UUID+"/edit")
    533 	}
    534 
    535 	return c.Render(http.StatusOK, "link-mirror-delete", data)
    536 }
    537 
    538 type CsvLink struct {
    539 	URL   string
    540 	Title string
    541 }
    542 
    543 func LinksUploadHandler(c echo.Context) error {
    544 	db := c.Get("database").(*database.DkfDB)
    545 	var data linksUploadData
    546 	if c.Request().Method == http.MethodPost {
    547 		data.CsvStr = c.Request().PostFormValue("csv")
    548 		getValidLinks := func() (out []CsvLink, err error) {
    549 			r := csv.NewReader(strings.NewReader(data.CsvStr))
    550 			records, err := r.ReadAll()
    551 			if err != nil {
    552 				return out, err
    553 			}
    554 			for idx, record := range records {
    555 				link := strings.TrimSpace(strings.TrimRight(record[0], "/"))
    556 				title := record[1]
    557 				if !govalidator.Matches(link, `^https?://[a-z2-7]{56}\.onion$`) {
    558 					return out, fmt.Errorf("invalid link %s", link)
    559 				}
    560 				if !govalidator.RuneLength(title, "0", "255") {
    561 					return out, fmt.Errorf("title must have 255 characters max : record #%d", idx)
    562 				}
    563 				csvLink := CsvLink{
    564 					URL:   link,
    565 					Title: title,
    566 				}
    567 				out = append(out, csvLink)
    568 			}
    569 			return out, nil
    570 		}
    571 		csvLinks, err := getValidLinks()
    572 		if err != nil {
    573 			data.Error = err.Error()
    574 			return c.Render(http.StatusOK, "links-upload", data)
    575 		}
    576 		for _, csvLink := range csvLinks {
    577 			_, err := db.CreateLink(csvLink.URL, csvLink.Title, "", "")
    578 			if err != nil {
    579 				logrus.Error(err)
    580 			}
    581 		}
    582 		return hutils.RedirectReferer(c)
    583 	}
    584 	return c.Render(http.StatusOK, "links-upload", data)
    585 }
    586 
    587 func NewLinkHandler(c echo.Context) error {
    588 	authUser := c.Get("authUser").(*database.User)
    589 	db := c.Get("database").(*database.DkfDB)
    590 	if !authUser.IsModerator() {
    591 		return c.Redirect(http.StatusFound, "/")
    592 	}
    593 	var data newLinkData
    594 	if c.Request().Method == http.MethodPost {
    595 		data.Link = c.Request().PostFormValue("link")
    596 		data.Title = c.Request().PostFormValue("title")
    597 		data.Description = c.Request().PostFormValue("description")
    598 		data.Shorthand = c.Request().PostFormValue("shorthand")
    599 		data.Categories = c.Request().PostFormValue("categories")
    600 		data.Tags = c.Request().PostFormValue("tags")
    601 		if !govalidator.Matches(data.Link, `^https?://[a-z2-7]{56}\.onion$`) {
    602 			data.ErrorLink = "invalid link"
    603 			return c.Render(http.StatusOK, "new-link", data)
    604 		}
    605 		if !govalidator.RuneLength(data.Title, "0", "255") {
    606 			data.ErrorTitle = "title must have 255 characters max"
    607 			return c.Render(http.StatusOK, "new-link", data)
    608 		}
    609 		if !govalidator.RuneLength(data.Description, "0", "1000") {
    610 			data.ErrorCategories = "description must have 1000 characters max"
    611 			return c.Render(http.StatusOK, "new-link", data)
    612 		}
    613 		if data.Shorthand != "" {
    614 			if !govalidator.Matches(data.Shorthand, `^[\w-_]{3,50}$`) {
    615 				data.ErrorLink = "invalid shorthand"
    616 				return c.Render(http.StatusOK, "new-link", data)
    617 			}
    618 		}
    619 		categoryRgx := regexp.MustCompile(`^\w{3,20}$`)
    620 		var tagsStr, categoriesStr []string
    621 		if data.Categories != "" {
    622 			categoriesStr = strings.Split(strings.ToLower(data.Categories), ",")
    623 			for _, category := range categoriesStr {
    624 				category = strings.TrimSpace(category)
    625 				if !categoryRgx.MatchString(category) {
    626 					data.ErrorCategories = `invalid category "` + category + `"`
    627 					return c.Render(http.StatusOK, "new-link", data)
    628 				}
    629 			}
    630 		}
    631 		if data.Tags != "" {
    632 			tagsStr = strings.Split(strings.ToLower(data.Tags), ",")
    633 			for _, tag := range tagsStr {
    634 				tag = strings.TrimSpace(tag)
    635 				if !categoryRgx.MatchString(tag) {
    636 					data.ErrorTags = `invalid tag "` + tag + `"`
    637 					return c.Render(http.StatusOK, "new-link", data)
    638 				}
    639 			}
    640 		}
    641 		//------------
    642 		var categories []database.LinksCategory
    643 		var tags []database.LinksTag
    644 		for _, categoryStr := range categoriesStr {
    645 			category, _ := db.CreateLinksCategory(categoryStr)
    646 			categories = append(categories, category)
    647 		}
    648 		for _, tagStr := range tagsStr {
    649 			tag, _ := db.CreateLinksTag(tagStr)
    650 			tags = append(tags, tag)
    651 		}
    652 		link, err := db.CreateLink(data.Link, data.Title, data.Description, data.Shorthand)
    653 		if err != nil {
    654 			logrus.Error(err)
    655 			data.ErrorLink = "failed to create link"
    656 			return c.Render(http.StatusOK, "new-link", data)
    657 		}
    658 		for _, category := range categories {
    659 			_ = db.AddLinkCategory(link.ID, category.ID)
    660 		}
    661 		for _, tag := range tags {
    662 			_ = db.AddLinkTag(link.ID, tag.ID)
    663 		}
    664 		db.NewAudit(*authUser, fmt.Sprintf("create link %s", link.URL))
    665 		return c.Redirect(http.StatusFound, "/links")
    666 	}
    667 	return c.Render(http.StatusOK, "new-link", data)
    668 }
    669 
    670 func LinksClaimInstructionsHandler(c echo.Context) error {
    671 	return c.Render(http.StatusOK, "links-claim-instructions", nil)
    672 }
    673 
    674 func LinksReindexHandler(c echo.Context) error {
    675 	db := c.Get("database").(*database.DkfDB)
    676 	if err := db.DB().Exec(`INSERT INTO fts5_links(fts5_links) VALUES('rebuild')`).Error; err != nil {
    677 		logrus.Error(err)
    678 	}
    679 	db.DB().Exec(`delete from fts5_links where rowid in (select id from links where deleted_at is not null)`)
    680 	return hutils.RedirectReferer(c)
    681 }