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 }