thuja

social media without the bullshit
git clone git://kqueue.dev/thuja.git
Log | Files | Refs | README | LICENSE

thuja.go (15849B)


      1 // Copyright 2022 kqueue <kqueue@cocaine.ninja>
      2 
      3 // Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
      4 
      5 // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING
      6 // ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      7 
      8 package main
      9 
     10 import (
     11 	"encoding/json"
     12 	"errors"
     13 	"flag"
     14 	"fmt"
     15 	"github.com/CAFxX/httpcompression"
     16 	"github.com/CAFxX/httpcompression/contrib/klauspost/gzip"
     17 	"github.com/CAFxX/httpcompression/contrib/klauspost/zstd"
     18 	"kqueue.dev/thuja/go-nude"
     19 	"github.com/didip/tollbooth/v7"
     20 	"github.com/nfnt/resize"
     21 	bolt "go.etcd.io/bbolt"
     22 	"image"
     23 	"image/jpeg"
     24 	_ "image/png"
     25 	"io"
     26 	"log"
     27 	"net/http"
     28 	"os"
     29 	"runtime"
     30 	"strconv"
     31 	"strings"
     32 	"unicode/utf8"
     33 )
     34 
     35 var (
     36 	staticdir   *string
     37 	dbpath      *string
     38 	nsfwallowed *bool
     39 	port        *string
     40 	domain      *string
     41 )
     42 
     43 const (
     44 	htmlTagStart = 60 // Unicode `<`
     45 	htmlTagEnd   = 62 // Unicode `>`
     46 )
     47 
     48 // Aggressively strips HTML tags from a string.
     49 // It will only keep anything between `>` and `<`.
     50 func stripHtmlTags(s string) string {
     51 	// Setup a string builder and allocate enough memory for the new string.
     52 	var builder strings.Builder
     53 	builder.Grow(len(s) + utf8.UTFMax)
     54 
     55 	in := false // True if we are inside an HTML tag.
     56 	start := 0  // The index of the previous start tag character `<`
     57 	end := 0    // The index of the previous end tag character `>`
     58 
     59 	for i, c := range s {
     60 		// If this is the last character and we are not in an HTML tag, save it.
     61 		if (i+1) == len(s) && end >= start {
     62 			builder.WriteString(s[end:])
     63 		}
     64 
     65 		// Keep going if the character is not `<` or `>`
     66 		if c != htmlTagStart && c != htmlTagEnd {
     67 			continue
     68 		}
     69 
     70 		if c == htmlTagStart {
     71 			// Only update the start if we are not in a tag.
     72 			// This make sure we strip out `<<br>` not just `<br>`
     73 			if !in {
     74 				start = i
     75 			}
     76 			in = true
     77 
     78 			// Write the valid string between the close and start of the two tags.
     79 			builder.WriteString(s[end:start])
     80 			continue
     81 		}
     82 		// else c == htmlTagEnd
     83 		in = false
     84 		end = i + 1
     85 	}
     86 	s = builder.String()
     87 	return s
     88 }
     89 
     90 // defines json format for all posts made
     91 type postform struct {
     92 	Title       string   `json:"title"`
     93 	Text        string   `json:"text"`
     94 	Id          int      `json:"id"`
     95 	Commentsnum int      `json:"commentsnum"`
     96 	Comments    []string `json:"comments"`
     97 	Ip          string   `json:"ip"`
     98 }
     99 
    100 // function that overwrites keys in the bolt database
    101 func databaseoverwrite(key string, value string, bucket string) error {
    102 	db, err := bolt.Open(*dbpath, 0600, nil)
    103 	if err != nil {
    104 		return err
    105 	}
    106 	defer db.Close()
    107 	tx, err := db.Begin(true)
    108 	if err != nil {
    109 		return err
    110 	}
    111 	defer tx.Rollback()
    112 	_, err = tx.CreateBucketIfNotExists([]byte(bucket))
    113 	if err != nil {
    114 		return err
    115 	}
    116 	b := tx.Bucket([]byte(bucket))
    117 	err = b.Delete([]byte(key))
    118 	if err != nil {
    119 		return err
    120 	}
    121 	err = b.Put([]byte(key), []byte(value))
    122 	if err != nil {
    123 		return err
    124 	}
    125 	if err := tx.Commit(); err != nil {
    126 		return err
    127 	}
    128 	return nil
    129 }
    130 
    131 // function that puts keys into the bolt database
    132 func databaseput(key string, value string, bucket string) error {
    133 	db, err := bolt.Open(*dbpath, 0600, nil)
    134 	if err != nil {
    135 		return err
    136 	}
    137 	defer db.Close()
    138 	tx, err := db.Begin(true)
    139 	if err != nil {
    140 		return err
    141 	}
    142 	defer tx.Rollback()
    143 	_, err = tx.CreateBucketIfNotExists([]byte(bucket))
    144 	if err != nil {
    145 		return err
    146 	}
    147 	b := tx.Bucket([]byte(bucket))
    148 	err = b.Put([]byte(key), []byte(value))
    149 	if err != nil {
    150 		return err
    151 	}
    152 	if err := tx.Commit(); err != nil {
    153 		return err
    154 	}
    155 	return nil
    156 }
    157 func CreateBucketsIfNotExists(buckets []string, bucketnum int) error {
    158 	db, err := bolt.Open(*dbpath, 0600, nil)
    159 	if err != nil {
    160 		return err
    161 	}
    162 	defer db.Close()
    163 	tx, err := db.Begin(true)
    164 	if err != nil {
    165 		return err
    166 	}
    167 	defer tx.Rollback()
    168 	for i := 0; i < bucketnum; i++ {
    169 		_, err = tx.CreateBucketIfNotExists([]byte(buckets[i]))
    170 		if err != nil {
    171 			return err
    172 		}
    173 	}
    174 	if err := tx.Commit(); err != nil {
    175 		return err
    176 	}
    177 	return nil
    178 }
    179 
    180 // gets keys out the bolt database
    181 func databaseget(key string, bucket string) (string, error) {
    182 	db, err := bolt.Open(*dbpath, 0600, nil)
    183 	errmsg := "something fucked up"
    184 	if err != nil {
    185 		return errmsg, err
    186 	}
    187 	defer db.Close()
    188 	tx, err := db.Begin(true)
    189 	if err != nil {
    190 		return errmsg, err
    191 	}
    192 	defer tx.Rollback()
    193 	//	_, err = tx.CreateBucketIfNotExists([]byte(bucket))
    194 	//	if err != nil {
    195 	//		return errmsg, err
    196 	//	}
    197 	b := tx.Bucket([]byte(bucket))
    198 	v := b.Get([]byte(key))
    199 	if v == nil {
    200 		return "empty key", nil
    201 	}
    202 	return string(v), nil
    203 }
    204 
    205 // function that allows thuja to handle multiple posts at once
    206 func multiposts(overwrite bool, board string) int {
    207 	postnum, err := databaseget("postnum", board)
    208 	if postnum == "empty key" && overwrite == false {
    209 		databaseput("postnum", "0", board)
    210 		postnum = "0"
    211 		return 0
    212 	}
    213 	if postnum == "empty key" && overwrite == true {
    214 		databaseput("postnum", "1", board)
    215 		postnum = "1"
    216 		return 1
    217 	}
    218 
    219 	if err != nil {
    220 		log.Fatal(err)
    221 		return -1
    222 	}
    223 	i, _ := strconv.Atoi(postnum)
    224 	if !overwrite {
    225 		return i
    226 	} else {
    227 		i++
    228 		databaseoverwrite("postnum", strconv.Itoa(i), board)
    229 		return i
    230 	}
    231 }
    232 
    233 // the main function, just runs the http handler functions
    234 func main() {
    235 	runtime.GOMAXPROCS(runtime.NumCPU() - 1)
    236 	staticdir = flag.String("staticdir", "/var/thuja/public", "the directory where index.html and image files are stored, must be readable and writable by thuja")
    237 	port = flag.String("port", ":8080", "the port thuja listens on")
    238 	dbpath = flag.String("db", "/var/thuja/thuja.db", "where thuja's bolt database is stored, must be readable and writable by thuja")
    239 	domain = flag.String("domain", "http://127.0.0.1", " by thuja")
    240 	nsfwallowed = flag.Bool("nsfw", false, "is nsfw content allowed or not?")
    241 	flag.Parse()
    242 	boards := []string{"Technology", "Christianity", "Weightlifting", "General"}
    243 	CreateBucketsIfNotExists(boards, 4)
    244 	postlmt := tollbooth.NewLimiter(1.0/55, nil)
    245 	postlmt.SetIPLookups([]string{"X-Forwarded-For"})
    246 	postlmt.SetMessage("You're posting too quickly, wait a bit")
    247 	postlmt.SetMessageContentType("text/plain; charset=utf-8")
    248 	getlmt := tollbooth.NewLimiter(2, nil)
    249 	getlmt.SetMessage("rate limit")
    250 	getlmt.SetMessageContentType("text/plain; charset=utf-8")
    251 	postlmt.SetIPLookups([]string{"X-Forwarded-For"})
    252 	// Set gzip compression settings
    253 	pgz, err := gzip.New(gzip.Options{Level: 6})
    254 	if err != nil {
    255 		log.Fatal(err)
    256 	}
    257 	zstdenc, err := zstd.New()
    258 	// set zstd commpression settings
    259 	compress, err := httpcompression.Adapter(
    260 		httpcompression.Compressor("zstd", 0, zstdenc),
    261 		httpcompression.Compressor("gzip", 1, pgz),
    262 	)
    263 	http.Handle("/post", tollbooth.LimitFuncHandler(postlmt, posthandler))
    264 	http.Handle("/comment", tollbooth.LimitFuncHandler(postlmt, commenthandler))
    265 	http.Handle("/get", compress(tollbooth.LimitFuncHandler(getlmt, gethandler)))
    266 	fs := http.FileServer(http.Dir(*staticdir))
    267 	http.Handle("/", compress(tollbooth.LimitHandler(getlmt, fs)))
    268 	http.ListenAndServe(*port, nil)
    269 }
    270 
    271 // self explanatory
    272 func commenthandler(w http.ResponseWriter, r *http.Request) {
    273 	id := r.FormValue("id")
    274 	text := r.FormValue("text")
    275 	if utf8.RuneCountInString(text) > 800 {
    276 		fmt.Fprintf(w, "text is too long, comment must be less than 800 characters")
    277 		return
    278 	}
    279 	board := r.FormValue("board")
    280 	post, _ := databaseget(id, board)
    281 	if post == "empty key" {
    282 		fmt.Fprintf(w, "id not found")
    283 		return
    284 	}
    285 	var jsonpost postform
    286 	var jsonfr []byte
    287 	json.Unmarshal([]byte(post), &jsonpost)
    288 	strippedtext := stripHtmlTags(text)
    289 	if jsonpost.Commentsnum == 0 {
    290 		jsonpost.Comments = []string{strippedtext}
    291 		jsonpost.Commentsnum = 1
    292 		jsonfr, _ = json.Marshal(jsonpost)
    293 	} else {
    294 		jsonpost.Commentsnum++
    295 		jsonpost.Comments = append(jsonpost.Comments, strippedtext)
    296 		jsonfr, _ = json.Marshal(jsonpost)
    297 	}
    298 	err := databaseoverwrite(id, string(jsonfr), board)
    299 	if err != nil {
    300 		log.Println(err)
    301 		fmt.Fprintf(w, "shit fucked up")
    302 		return
    303 	}
    304 	var url string
    305 	if *domain == "http://127.0.0.1" {
    306 		url = *domain + *port
    307 	} else {
    308 		url = *domain
    309 	}
    310 	url = fmt.Sprintf("%s/get?board=%s&id=%s", url, board, id)
    311 	http.Redirect(w, r, url, http.StatusFound)
    312 }
    313 
    314 // self explanatory
    315 func posthandler(w http.ResponseWriter, r *http.Request) {
    316 	err := r.ParseMultipartForm(2 << 20)
    317 	if err != nil {
    318 		fmt.Fprintf(w, "shit fucked up, likely a too large post request")
    319 		log.Println(err)
    320 		return
    321 	}
    322 	title := r.FormValue("title")
    323 	text := r.FormValue("text")
    324 	board := r.FormValue("board")
    325 	if board == "" {
    326 		fmt.Fprintf(w, "must specify board")
    327 		return
    328 	}
    329 	if utf8.RuneCountInString(title) > 55 {
    330 		fmt.Fprintf(w, "title is too long, titles must be less than 55 characters")
    331 		return
    332 	}
    333 	if utf8.RuneCountInString(text) > 4000 {
    334 		fmt.Fprintf(w, "text is too long, post must be less than 4000 characters")
    335 		return
    336 	}
    337 
    338 	img, _, err := r.FormFile("image")
    339 	var nofile bool
    340 	switch err {
    341 	case nil:
    342 		nofile = false
    343 	case http.ErrMissingFile:
    344 		nofile = true
    345 	default:
    346 		log.Println(err)
    347 		nofile = true
    348 	}
    349 	if nofile == false {
    350 		buff := make([]byte, 512)
    351 		_, err = img.Read(buff)
    352 		if err != nil {
    353 			log.Println(err)
    354 			fmt.Fprintf(w, "shit fucked up")
    355 			return
    356 		}
    357 		filetype := http.DetectContentType(buff)
    358 		switch filetype {
    359 		case "image/png":
    360 			// do nothing
    361 		case "image/jpeg":
    362 			// do nothing
    363 		default:
    364 			fmt.Fprintf(w, "use a different filetype, supported filetypes are png and jpeg")
    365 			return
    366 		}
    367 		_, err := img.Seek(0, io.SeekStart)
    368 		if err != nil {
    369 			log.Println(err)
    370 			http.Error(w, err.Error(), http.StatusInternalServerError)
    371 			return
    372 		}
    373 	}
    374 	shit := multiposts(true, board)
    375 	if shit == -1 {
    376 		fmt.Fprintf(w, "shit fucked up")
    377 		return
    378 	}
    379 	var filepath string
    380 	if nofile == false {
    381 
    382 		realimg, _, err := image.Decode(img)
    383 		if err != nil {
    384 			log.Println(err)
    385 			fmt.Fprintf(w, "shit fucked up")
    386 			return
    387 		}
    388 		if *nsfwallowed == false {
    389 			isNSFW, err := nude.IsNude(realimg)
    390 			if err != nil {
    391 				log.Println(err)
    392 				fmt.Fprintf(w, "shit fucked up")
    393 				return
    394 			}
    395 			if isNSFW == true {
    396 				shit--
    397 				databaseoverwrite("postnum", strconv.Itoa(shit), board)
    398 				fmt.Fprintf(w, "the image you posted was detected as NSFW, try a different image or try posting without an image")
    399 				return
    400 			}
    401 		}
    402 		filepath = fmt.Sprintf("%s/%s-%d.jpg", *staticdir, board, shit)
    403 		dst, err := os.Create(filepath)
    404 		if err != nil {
    405 			fmt.Fprintf(w, "shit fucked up")
    406 			log.Println(err)
    407 		}
    408 		resized := resize.Resize(400, 0, realimg, resize.Lanczos3)
    409 		options := jpeg.Options{80}
    410 		jpeg.Encode(dst, resized, &options)
    411 		if err != nil {
    412 			log.Println(err)
    413 			fmt.Fprintf(w, "shit fucked up")
    414 			return
    415 		}
    416 	} else {
    417 		filepath = "nil"
    418 	}
    419 	Ip := r.Header.Get("X-Forwarded-For")
    420 	comments := []string{}
    421 	post := postform{stripHtmlTags(title), stripHtmlTags(text), shit, 0, comments, Ip}
    422 	jsonpost, err := json.Marshal(post)
    423 	if err != nil {
    424 		log.Println(err)
    425 		fmt.Fprintf(w, "shit fucked up")
    426 		return
    427 	}
    428 	err = databaseput(strconv.Itoa(shit), string(jsonpost), board)
    429 	if err != nil {
    430 		log.Println(err)
    431 		fmt.Fprintf(w, "shit fucked up")
    432 		return
    433 	} else {
    434 		var url string
    435 		if *domain == "http://127.0.0.1" {
    436 			url = *domain + *port
    437 		} else {
    438 			url = *domain
    439 		}
    440 		url = fmt.Sprintf("%s/get?board=%s", url, board)
    441 		http.Redirect(w, r, url, http.StatusFound)
    442 	}
    443 }
    444 func gethandler(w http.ResponseWriter, r *http.Request) {
    445 	id := r.FormValue("id")
    446 	board := r.FormValue("board")
    447 	if board == "" {
    448 		fmt.Fprintf(w, "must specify board")
    449 		return
    450 	}
    451 	var url string
    452 	if *domain == "http://127.0.0.1" {
    453 		url = *domain + *port
    454 	} else {
    455 		url = *domain
    456 	}
    457 
    458 	if id != "" {
    459 		text, err := databaseget(id, board)
    460 		if err != nil {
    461 			log.Println(err)
    462 			fmt.Fprintf(w, "shit fucked up")
    463 			return
    464 		}
    465 		if text == "NULL" {
    466 			fmt.Fprintf(w, "post was deleted")
    467 			return
    468 		}
    469 		var testicle postform
    470 		json.Unmarshal([]byte(text), &testicle)
    471 		var comments string
    472 		if testicle.Commentsnum == 0 {
    473 			comments = "\nnone yet"
    474 		} else {
    475 			for a := 0; a < testicle.Commentsnum; a++ {
    476 				comments = fmt.Sprintf("%s\n<p>%s</p>", comments, testicle.Comments[a])
    477 			}
    478 		}
    479 		// check if file exists
    480 		// include image file if it does exist
    481 		var image2 string
    482 		imagepath := fmt.Sprintf("%s/%s-%d.jpg", *staticdir, board, testicle.Id)
    483 		if _, err := os.Stat(imagepath); errors.Is(err, os.ErrNotExist) {
    484 			image2 = "\n"
    485 		} else {
    486 			image2 = fmt.Sprintf("<img src=\"%s/%s-%d.jpg\">\n", url, board, testicle.Id)
    487 		}
    488 		result := fmt.Sprintf("<!DOCTYPE HTML>\n<html>\n<head>\n<title>%s</title>\n<link rel=\"stylesheet\" href=\"https://kqueue.dev/style.css\" type=\"text/css\" title=\"default\"\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n</head>\n<body>\n<header>\n<article>\n<h1>%s</h1>\n<p>%s</p>\n%s<h3>Comments:</h3>\n<p%s</p>\n<h4>Submit a comment:</h4><form action=\"/comment?board=%s&id=%d\" method=\"post\" enctype=\"multipart/form-data\">\n <label for=\"text\">Text:</label>\n  <input type=\"text\" id=\"text\" name=\"text\"><br><br>\n   <input type=\"submit\" value=\"Submit\">\n</article>", testicle.Title, testicle.Title, testicle.Text, image2, comments, board, testicle.Id)
    489 		fmt.Fprintf(w, result)
    490 		return
    491 	} else {
    492 		shit := multiposts(false, board)
    493 		if shit == -1 {
    494 			fmt.Fprintf(w, "shit fucked up")
    495 			return
    496 		}
    497 		if shit == 0 {
    498 			fmt.Fprintf(w, "<!DOCTYPE HTML>\n<html>\n<head>\n<title>posts on %s</title>\n<link rel=\"stylesheet\" href=\"https://kqueue.dev/style.css\" type=\"text/css\" title=\"default\"\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n</head>\n<body>\n<header>\n<article><form action=\"/post?board=%s\" method=\"post\" enctype=\"multipart/form-data\">\n  <label for=\"title\">Title:</label>\n  <input type=\"text\" id=\"title\" name=\"title\"><br><br>\n  <label for=\"text\">Text:</label>\n  <input type=\"text\" id=\"text\" name=\"text\"><br><br>\n<label for=\"image\">Image:</label>\n  <input type=\"file\" id=\"image\" name=\"image\" accept = \"image/*\"><br><br>\n   <input type=\"submit\" value=\"Submit\"></article>", board, board)
    499 			return
    500 		}
    501 		postnum := shit
    502 		var result string
    503 		result = fmt.Sprintf("<!DOCTYPE HTML>\n<html>\n<head>\n<title>posts on %s</title>\n<link rel=\"stylesheet\" href=\"https://kqueue.dev/style.css\" type=\"text/css\" title=\"default\"\n<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n</head>\n<body>\n<header>\n<article><h1>Submit your own post</h1><form action=\"/post?board=%s\" method=\"post\" enctype=\"multipart/form-data\">\n  <label for=\"title\">Title:</label>\n  <input type=\"text\" id=\"title\" name=\"title\"><br><br>\n  <label for=\"text\">Text:</label>\n  <input type=\"text\" id=\"text\" name=\"text\"><br><br>\n<label for=\"image\">Image:</label>\n  <input type=\"file\" id=\"image\" name=\"image\" accept = \"image/*\"><br><br>\n   <input type=\"submit\" value=\"Submit\">\n<h1>Posts:</h1>\n", board, board)
    504 		for postnum != 0 {
    505 			text, err := databaseget(strconv.Itoa(postnum), board)
    506 			if err != nil {
    507 				log.Println(err)
    508 				fmt.Fprintf(w, "shit fucked up")
    509 				return
    510 			}
    511 			// NULL means the post was deleted
    512 			if text == "NULL" {
    513 				//do nothing
    514 			} else {
    515 				var testicle postform
    516 				json.Unmarshal([]byte(text), &testicle)
    517 				result = fmt.Sprintf("%s<a href=\"%s/get?id=%d&board=%s\">%s</a></p>\n", result, url, postnum, board, testicle.Title)
    518 			}
    519 			postnum--
    520 		}
    521 		fmt.Fprintf(w, "%s\n</article>", result)
    522 	}
    523 }