thuja

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

commit 7b7906fbbac5a806b95b16950ac3a5379547e33a
Author: kqueue <kqueue@cocaine.ninja>
Date:   Mon,  2 Jan 2023 15:25:56 -0500

deleted old commits, rebranded

Diffstat:
ALICENSE | 5+++++
AREADME.md | 22++++++++++++++++++++++
Ago-nude/LICENSE.txt | 22++++++++++++++++++++++
Ago-nude/README.md | 2++
Ago-nude/nude.go | 359+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago-nude/region.go | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago-nude/util.go | 25+++++++++++++++++++++++++
Ago.mod | 18++++++++++++++++++
Ago.sum | 28++++++++++++++++++++++++++++
Apublic/index.html | 20++++++++++++++++++++
Athuja.go | 523+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1174 insertions(+), 0 deletions(-)

diff --git a/LICENSE b/LICENSE @@ -0,0 +1,5 @@ +Copyright 2022 kqueue <kqueue@cocaine.ninja> + +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. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING 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. diff --git a/README.md b/README.md @@ -0,0 +1,22 @@ +# Thuja: a WIP Christian anonymous image board +Thuja is an imageboard made in Go with zero javascript, zero SQL(instead using a key-value database), and has a built in NSFW filter. This project is very WIP and is currently missing certain features that i hope to add in the future. I'm hoping to add a public instance in the future. +# Difference between other platforms +I hope this project avoids the degeneracy that comes with just about every single social media platform. I also don't want it to become addictive and mentally harmful like Facebook/Instagram/Twitter/Reddit or whatever. There are no algorithms made to addict you and waste your time. Simple code, freedom of thought, and a hopefully good community surrounding it. +# Building +run +``git clone git://kqueue.dev/thuja.git && cd thuja && go build .`` +# Running +There are several flags to be defined, run thuja --help for more info. +Here's an example that can be done directly after building thuja: +``./thuja -nsfw=0 -db=my.db -port=:8080 -staticdir=./public`` +**Note that you'll likely have to edit ./public/index.html if you deviate from standard config** + +**Note that thuja should be run behind a reverse proxy with the X-Forwarded-For header defined for rate limiting to work properly**. I personally use relayd for this purpose, but other reverse proxies such as Nginx work fine too. A reverse proxy is also required for tls support. +# Licensing +This project is licensed under ISC, which is legally very similar to MIT or BSD-2 +# Some features to expect in the future +* Performance and Concurrency improvements +* Search function +* Make the site far more navigable +* Possibly markdown support +* ~~Moderation tools~~ ([Now achieved with rapture](https://kqueue.dev/git/rapture)) diff --git a/go-nude/LICENSE.txt b/go-nude/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014 koyachi + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/go-nude/README.md b/go-nude/README.md @@ -0,0 +1,2 @@ +go-nude, the nudity detector used in thuja +not originally written by me, written by https://github.com/koyachi/go-nude diff --git a/go-nude/nude.go b/go-nude/nude.go @@ -0,0 +1,359 @@ +package nude + +import ( + "fmt" + "image" + "math" + "path/filepath" + "sort" +) + +func IsNude(img image.Image) (bool, error) { + return IsImageNude(img) +} + +func IsFileNude(imageFilePath string) (bool, error) { + path, err := filepath.Abs(imageFilePath) + if err != nil { + return false, err + } + + img, err := decodeImage(path) + if err != nil { + return false, err + } + + return IsImageNude(img) +} + +func IsImageNude(img image.Image) (bool, error) { + d:= NewDetector(img) + return d.Parse() +} + +type Detector struct { + image image.Image + width int + height int + totalPixels int + pixels Region + SkinRegions Regions + detectedRegions Regions + mergeRegions [][]int + lastFrom int + lastTo int + message string + result bool +} + +func NewDetector(img image.Image) *Detector { + d := &Detector{image: img } + return d +} + +func (d *Detector) Parse() (result bool, err error) { + img := d.image + bounds := img.Bounds() + d.image = img + d.width = bounds.Size().X + d.height = bounds.Size().Y + d.lastFrom = -1 + d.lastTo = -1 + d.totalPixels = d.width * d.height + + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + width := bounds.Size().X + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, _ := d.image.At(x, y).RGBA() + normR := r / 256 + normG := g / 256 + normB := b / 256 + currentIndex := x + y*width + nextIndex := currentIndex + 1 + + isSkin, v := classifySkin(normR, normG, normB) + if !isSkin { + d.pixels = append(d.pixels, &Pixel{currentIndex, false, 0, x, y, false, v}) + } else { + d.pixels = append(d.pixels, &Pixel{currentIndex, true, 0, x, y, false, v}) + + region := -1 + checkIndexes := []int{ + nextIndex - 2, + nextIndex - width - 2, + nextIndex - width - 1, + nextIndex - width, + } + checker := false + + for _, checkIndex := range checkIndexes { + if checkIndex < 0 { + continue + } + skin := d.pixels[checkIndex] + if skin != nil && skin.isSkin { + if skin.region != region && + region != -1 && + d.lastFrom != region && + d.lastTo != skin.region { + d.addMerge(region, skin.region) + } + region = d.pixels[checkIndex].region + checker = true + } + } + + if !checker { + d.pixels[currentIndex].region = len(d.detectedRegions) + d.detectedRegions = append(d.detectedRegions, Region{d.pixels[currentIndex]}) + continue + } else { + if region > -1 { + if len(d.detectedRegions) >= region { + d.detectedRegions = append(d.detectedRegions, Region{}) + } + d.pixels[currentIndex].region = region + d.detectedRegions[region] = append(d.detectedRegions[region], d.pixels[currentIndex]) + } + } + } + } + } + + d.merge(d.detectedRegions, d.mergeRegions) + d.analyzeRegions() + + return d.result, err +} + +func (d *Detector) addMerge(from, to int) { + d.lastFrom = from + d.lastTo = to + + fromIndex := -1 + toIndex := -1 + + for index, region := range d.mergeRegions { + for _, regionIndex := range region { + if regionIndex == from { + fromIndex = index + } + if regionIndex == to { + toIndex = index + } + } + } + + if fromIndex != -1 && toIndex != -1 { + if fromIndex != toIndex { + fromRegion := d.mergeRegions[fromIndex] + toRegion := d.mergeRegions[toIndex] + region := append(fromRegion, toRegion...) + d.mergeRegions[fromIndex] = region + d.mergeRegions = append(d.mergeRegions[:toIndex], d.mergeRegions[toIndex+1:]...) + } + return + } + + if fromIndex == -1 && toIndex == -1 { + d.mergeRegions = append(d.mergeRegions, []int{from, to}) + return + } + + if fromIndex != -1 && toIndex == -1 { + d.mergeRegions[fromIndex] = append(d.mergeRegions[fromIndex], to) + return + } + + if fromIndex == -1 && toIndex != -1 { + d.mergeRegions[toIndex] = append(d.mergeRegions[toIndex], from) + return + } + +} + +// function for merging detected regions +func (d *Detector) merge(detectedRegions Regions, mergeRegions [][]int) { + var newDetectedRegions Regions + + // merging detected regions + for index, region := range mergeRegions { + if len(newDetectedRegions) >= index { + newDetectedRegions = append(newDetectedRegions, Region{}) + } + for _, r := range region { + newDetectedRegions[index] = append(newDetectedRegions[index], detectedRegions[r]...) + detectedRegions[r] = Region{} + } + } + + // push the rest of the regions to the newDetectedRegions array + // (regions without merging) + for _, region := range detectedRegions { + if len(region) > 0 { + newDetectedRegions = append(newDetectedRegions, region) + } + } + + // clean up + d.clearRegions(newDetectedRegions) +} + +// clean up function +// only push regions which are bigger than a specific amount to the final resul +func (d *Detector) clearRegions(detectedRegions Regions) { + for _, region := range detectedRegions { + if len(region) > 30 { + d.SkinRegions = append(d.SkinRegions, region) + } + } +} + +func (d *Detector) analyzeRegions() bool { + skinRegionLength := len(d.SkinRegions) + + // if there are less than 3 regions + if skinRegionLength < 3 { + d.message = fmt.Sprintf("Less than 3 skin regions (%v)", skinRegionLength) + d.result = false + return d.result + } + + // sort the skinRegions + sort.Sort(sort.Reverse(d.SkinRegions)) + + // count total skin pixels + totalSkinPixels := float64(d.SkinRegions.totalPixels()) + + // check if there are more than 15% skin pixel in the image + totalSkinParcentage := totalSkinPixels / float64(d.totalPixels) * 100 + if totalSkinParcentage < 15 { + // if the parcentage lower than 15, it's not nude! + d.message = fmt.Sprintf("Total skin parcentage lower than 15 (%v%%)", totalSkinParcentage) + d.result = false + return d.result + } + + // check if the largest skin region is less than 35% of the total skin count + // AND if the second largest region is less than 30% of the total skin count + // AND if the third largest region is less than 30% of the total skin count + biggestRegionParcentage := float64(len(d.SkinRegions[0])) / totalSkinPixels * 100 + secondLargeRegionParcentage := float64(len(d.SkinRegions[1])) / totalSkinPixels * 100 + thirdLargesRegionParcentage := float64(len(d.SkinRegions[2])) / totalSkinPixels * 100 + if biggestRegionParcentage < 35 && + secondLargeRegionParcentage < 30 && + thirdLargesRegionParcentage < 30 { + d.message = "Less than 35%, 30%, 30% skin in the biggest regions" + d.result = false + return d.result + } + + // check if the number of skin pixels in the largest region is less than 45% of the total skin count + if biggestRegionParcentage < 45 { + d.message = fmt.Sprintf("The biggest region contains less than 45%% (%v)", biggestRegionParcentage) + d.result = false + return d.result + } + + // check if the total skin count is less than 30% of the total number of pixels + // AND the number of skin pixels within the bounding polygon is less than 55% of the size of the polygon + // if this condition is true, it's not nude. + if totalSkinParcentage < 30 { + for i, region := range d.SkinRegions { + skinRate := region.skinRateInBoundingPolygon() + //fmt.Printf("skinRate[%v] = %v\n", i, skinRate) + if skinRate < 0.55 { + d.message = fmt.Sprintf("region[%d].skinRate(%v) < 0.55", i, skinRate) + d.result = false + return d.result + } + } + } + + // if there are more than 60 skin regions and the average intensity within the polygon is less than 0.25 + // the image is not nude + averageIntensity := d.SkinRegions.averageIntensity() + if skinRegionLength > 60 && averageIntensity < 0.25 { + d.message = fmt.Sprintf("More than 60 skin regions(%v) and averageIntensity(%v) < 0.25", skinRegionLength, averageIntensity) + d.result = false + return d.result + } + + // otherwise it is nude + d.result = true + return d.result +} + +func (d *Detector) String() string { + return fmt.Sprintf("#<nude.Detector result=%t, message=%s>", d.result, d.message) +} + +// A Survey on Pixel-Based Skin Color Detection Techniques +func classifySkin(r, g, b uint32) (bool, float64) { + rgbClassifier := r > 95 && + g > 40 && g < 100 && + b > 20 && + maxRgb(r, g, b)-minRgb(r, g, b) > 15 && + math.Abs(float64(r-g)) > 15 && + r > g && + r > b + + nr, ng, _ := toNormalizedRgb(r, g, b) + normalizedRgbClassifier := nr/ng > 1.185 && + (float64(r*b))/math.Pow(float64(r+g+b), 2) > 0.107 && + (float64(r*g))/math.Pow(float64(r+g+b), 2) > 0.112 + + h, s, v := toHsv(r, g, b) + hsvClassifier := h > 0 && + h < 35 && + s > 0.23 && + s < 0.68 + + // ycc doesnt work + + result := rgbClassifier || normalizedRgbClassifier || hsvClassifier + return result, v +} + +func maxRgb(r, g, b uint32) float64 { + return math.Max(math.Max(float64(r), float64(g)), float64(b)) +} + +func minRgb(r, g, b uint32) float64 { + return math.Min(math.Min(float64(r), float64(g)), float64(b)) +} + +func toNormalizedRgb(r, g, b uint32) (nr, ng, nb float64) { + sum := float64(r + g + b) + nr = float64(r) / sum + ng = float64(g) / sum + nb = float64(b) / sum + + return nr, ng, nb +} + +func toHsv(r, g, b uint32) (h, s, v float64) { + h = 0.0 + sum := float64(r + g + b) + max := maxRgb(r, g, b) + min := minRgb(r, g, b) + diff := max - min + + if max == float64(r) { + h = float64(g-b) / diff + } else if max == float64(g) { + h = 2 + float64(g-r)/diff + } else { + h = 4 + float64(r-g)/diff + } + + h *= 60 + if h < 0 { + h += 360 + } + + s = 1.0 - 3.0*(min/sum) + v = (1.0 / 3.0) * max + + return h, s, v +} diff --git a/go-nude/region.go b/go-nude/region.go @@ -0,0 +1,150 @@ +package nude + +import ( + "math" +) + +type Pixel struct { + id int + isSkin bool + region int + X int + Y int + chekced bool + V float64 // intesitiy(Value) of HSV +} + +// TODO: cache caluculated leftMost, rightMost, upperMost, lowerMost. +type Region []*Pixel + +// TODO: optimize +//func (r Region) isSkin(x, y int) bool { +// for _, pixel := range r { +// if pixel.isSkin && pixel.X == x && pixel.Y == y { +// return true +// } +// } +// return false +//} + +func (r Region) leftMost() *Pixel { + minX := math.MaxInt32 + index := 0 + for i, pixel := range r { + if pixel.X < minX { + minX = pixel.X + index = i + } + } + return r[index] +} + +func (r Region) rightMost() *Pixel { + maxX := math.MinInt32 + index := 0 + for i, pixel := range r { + if pixel.X > maxX { + maxX = pixel.X + index = i + } + } + return r[index] +} + +func (r Region) upperMost() *Pixel { + minY := math.MaxInt32 + index := 0 + for i, pixel := range r { + if pixel.Y < minY { + minY = pixel.Y + index = i + } + } + return r[index] +} + +func (r Region) lowerMost() *Pixel { + maxY := math.MinInt32 + index := 0 + for i, pixel := range r { + if pixel.Y > maxY { + maxY = pixel.Y + index = i + } + } + return r[index] +} + +func (r Region) skinRateInBoundingPolygon() float64 { + // build the bounding polygon by the regions edge values: + // Identify the leftmost, the uppermost, the rightmost, and the lowermost skin pixels of the three largest skin regions. + // Use these points as the corner points of a bounding polygon. + left := r.leftMost() + right := r.rightMost() + upper := r.upperMost() + lower := r.lowerMost() + vertices := []*Pixel{left, upper, right, lower, left} + total := 0 + skin := 0 + + // via http://katsura-kotonoha.sakura.ne.jp/prog/c/tip0002f.shtml + for _, p1 := range r { + inPolygon := true + for i := 0; i < len(vertices)-1; i++ { + p2 := vertices[i] + p3 := vertices[i+1] + n := p1.X*(p2.Y-p3.Y) + p2.X*(p3.Y-p1.Y) + p3.X*(p1.Y-p2.Y) + if n < 0 { + inPolygon = false + break + } + } + if inPolygon && p1.isSkin { + skin = skin + 1 + } + total = total + 1 + } + return float64(skin) / float64(total) +} + +func (r Region) averageIntensity() float64 { + var totalIntensity float64 + for _, pixel := range r { + totalIntensity = totalIntensity + pixel.V + } + return totalIntensity / float64(len(r)) +} + +type Regions []Region + +func (r Regions) totalPixels() int { + var totalSkin int + for _, pixels := range r { + totalSkin += len(pixels) + } + return totalSkin +} + +func (r Regions) averageIntensity() float64 { + var totalIntensity float64 + for _, region := range r { + totalIntensity = totalIntensity + region.averageIntensity() + } + return totalIntensity / float64(len(r)) +} + +// +// sort interface +// + +func (r Regions) Len() int { + return len(r) +} + +func (r Regions) Swap(i, j int) { + r[i], r[j] = r[j], r[i] +} + +func (r Regions) Less(i, j int) bool { + return len(r[i]) < len(r[j]) +} diff --git a/go-nude/util.go b/go-nude/util.go @@ -0,0 +1,25 @@ +package nude + +import ( + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" +) + +// experimental +func DecodeImage(filePath string) (img image.Image, err error) { + return decodeImage(filePath) +} + +func decodeImage(filePath string) (img image.Image, err error) { + reader, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer reader.Close() + + img, _, err = image.Decode(reader) + return +} diff --git a/go.mod b/go.mod @@ -0,0 +1,18 @@ +module kqueue.dev/thuja + +go 1.18 + +require ( + github.com/CAFxX/httpcompression v0.0.8 + github.com/didip/tollbooth/v7 v7.0.1 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + go.etcd.io/bbolt v1.3.6 +) + +require ( + github.com/andybalholm/brotli v1.0.4 // indirect + github.com/go-pkgz/expirable-cache v0.1.0 // indirect + github.com/klauspost/compress v1.14.1 // indirect + github.com/klauspost/pgzip v1.2.5 // indirect + golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,28 @@ +github.com/CAFxX/httpcompression v0.0.8 h1:UBWojERnpCS6X7whJkGGZeCC3ruZBRwkwkcnfGfb0ko= +github.com/CAFxX/httpcompression v0.0.8/go.mod h1:bVd1taHK1vYb5SWe9lwNDCqrfj2ka+C1Zx7JHzxuHnU= +github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/didip/tollbooth/v7 v7.0.1 h1:TkT4sBKoQoHQFPf7blQ54iHrZiTDnr8TceU+MulVAog= +github.com/didip/tollbooth/v7 v7.0.1/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4= +github.com/go-pkgz/expirable-cache v0.1.0 h1:3bw0m8vlTK8qlwz5KXuygNBTkiKRTPrAGXU0Ej2AC1g= +github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs= +github.com/google/brotli/go/cbrotli v0.0.0-20210623081221-ce222e317e36/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= +github.com/klauspost/compress v1.14.1 h1:hLQYb23E8/fO+1u53d02A97a8UnsddcvYzq4ERRU4ds= +github.com/klauspost/compress v1.14.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.12/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/gozstd v1.11.0/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d h1:L/IKR6COd7ubZrs2oTnTi73IhgqJ71c9s80WsQnh0Es= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/public/index.html b/public/index.html @@ -0,0 +1,20 @@ +<!DOCTYPE HTML> +<html> +<head> +<title>Thuja: a better "social media" site</title> +<link rel="stylesheet" href="https://kqueue.dev/style.css" type="text/css" title="default" +<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> +<meta charset="UTF-8"> +</head> +<body> +<header> +<article> +<h1>Thuja: a better "social media" site </h1> +<p><b>List of Boards:</b></p> +<ul> + <li><a href="http://127.0.0.1:8080/get?board=Technology">Technology</a></li> + <li><a href="http://127.0.0.1:8080/get?board=Christianity">Christianity</a></li> + <li><a href="http://127.0.0.1:8080/get?board=General">General</a></li> + <li><a href="http://127.0.0.1:8080/get?board=Weightlifting">Weightlifting</a></li> +</ul> +</article> diff --git a/thuja.go b/thuja.go @@ -0,0 +1,523 @@ +// Copyright 2022 kqueue <kqueue@cocaine.ninja> + +// 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. + +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING +// 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. + +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "github.com/CAFxX/httpcompression" + "github.com/CAFxX/httpcompression/contrib/klauspost/pgzip" + "github.com/CAFxX/httpcompression/contrib/klauspost/zstd" + "kqueue.dev/thuja/go-nude" + "github.com/didip/tollbooth/v7" + "github.com/nfnt/resize" + bolt "go.etcd.io/bbolt" + "image" + "image/jpeg" + _ "image/png" + "io" + "log" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "unicode/utf8" +) + +var ( + staticdir *string + dbpath *string + nsfwallowed *bool + port *string + domain *string +) + +const ( + htmlTagStart = 60 // Unicode `<` + htmlTagEnd = 62 // Unicode `>` +) + +// Aggressively strips HTML tags from a string. +// It will only keep anything between `>` and `<`. +func stripHtmlTags(s string) string { + // Setup a string builder and allocate enough memory for the new string. + var builder strings.Builder + builder.Grow(len(s) + utf8.UTFMax) + + in := false // True if we are inside an HTML tag. + start := 0 // The index of the previous start tag character `<` + end := 0 // The index of the previous end tag character `>` + + for i, c := range s { + // If this is the last character and we are not in an HTML tag, save it. + if (i+1) == len(s) && end >= start { + builder.WriteString(s[end:]) + } + + // Keep going if the character is not `<` or `>` + if c != htmlTagStart && c != htmlTagEnd { + continue + } + + if c == htmlTagStart { + // Only update the start if we are not in a tag. + // This make sure we strip out `<<br>` not just `<br>` + if !in { + start = i + } + in = true + + // Write the valid string between the close and start of the two tags. + builder.WriteString(s[end:start]) + continue + } + // else c == htmlTagEnd + in = false + end = i + 1 + } + s = builder.String() + return s +} + +// defines json format for all posts made +type postform struct { + Title string `json:"title"` + Text string `json:"text"` + Id int `json:"id"` + Commentsnum int `json:"commentsnum"` + Comments []string `json:"comments"` + Ip string `json:"ip"` +} + +// function that overwrites keys in the bolt database +func databaseoverwrite(key string, value string, bucket string) error { + db, err := bolt.Open(*dbpath, 0600, nil) + if err != nil { + return err + } + defer db.Close() + tx, err := db.Begin(true) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { + return err + } + b := tx.Bucket([]byte(bucket)) + err = b.Delete([]byte(key)) + if err != nil { + return err + } + err = b.Put([]byte(key), []byte(value)) + if err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +// function that puts keys into the bolt database +func databaseput(key string, value string, bucket string) error { + db, err := bolt.Open(*dbpath, 0600, nil) + if err != nil { + return err + } + defer db.Close() + tx, err := db.Begin(true) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { + return err + } + b := tx.Bucket([]byte(bucket)) + err = b.Put([]byte(key), []byte(value)) + if err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} +func CreateBucketsIfNotExists(buckets []string, bucketnum int) error { + db, err := bolt.Open(*dbpath, 0600, nil) + if err != nil { + return err + } + defer db.Close() + tx, err := db.Begin(true) + if err != nil { + return err + } + defer tx.Rollback() + for i := 0; i < bucketnum; i++ { + _, err = tx.CreateBucketIfNotExists([]byte(buckets[i])) + if err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +// gets keys out the bolt database +func databaseget(key string, bucket string) (string, error) { + db, err := bolt.Open(*dbpath, 0600, nil) + errmsg := "something fucked up" + if err != nil { + return errmsg, err + } + defer db.Close() + tx, err := db.Begin(true) + if err != nil { + return errmsg, err + } + defer tx.Rollback() + // _, err = tx.CreateBucketIfNotExists([]byte(bucket)) + // if err != nil { + // return errmsg, err + // } + b := tx.Bucket([]byte(bucket)) + v := b.Get([]byte(key)) + if v == nil { + return "empty key", nil + } + return string(v), nil +} + +// function that allows thuja to handle multiple posts at once +func multiposts(overwrite bool, board string) int { + postnum, err := databaseget("postnum", board) + if postnum == "empty key" && overwrite == false { + databaseput("postnum", "0", board) + postnum = "0" + return 0 + } + if postnum == "empty key" && overwrite == true { + databaseput("postnum", "1", board) + postnum = "1" + return 1 + } + + if err != nil { + log.Fatal(err) + return -1 + } + i, _ := strconv.Atoi(postnum) + if !overwrite { + return i + } else { + i++ + databaseoverwrite("postnum", strconv.Itoa(i), board) + return i + } +} + +// the main function, just runs the http handler functions +func main() { + runtime.GOMAXPROCS(runtime.NumCPU() - 1) + staticdir = flag.String("staticdir", "/var/thuja/public", "the directory where index.html and image files are stored, must be readable and writable by thuja") + port = flag.String("port", ":8080", "the port thuja listens on") + dbpath = flag.String("db", "/var/thuja/thuja.db", "where thuja's bolt database is stored, must be readable and writable by thuja") + domain = flag.String("domain", "http://127.0.0.1", " by thuja") + nsfwallowed = flag.Bool("nsfw", false, "is nsfw content allowed or not?") + flag.Parse() + boards := []string{"Technology", "Christianity", "Weightlifting", "General"} + CreateBucketsIfNotExists(boards, 4) + postlmt := tollbooth.NewLimiter(1.0/55, nil) + postlmt.SetIPLookups([]string{"X-Forwarded-For"}) + postlmt.SetMessage("You're posting too quickly, wait a bit") + postlmt.SetMessageContentType("text/plain; charset=utf-8") + getlmt := tollbooth.NewLimiter(2, nil) + getlmt.SetMessage("rate limit") + getlmt.SetMessageContentType("text/plain; charset=utf-8") + postlmt.SetIPLookups([]string{"X-Forwarded-For"}) + // Set pgzip compression settings + pgz, err := pgzip.New(pgzip.Options{Level: 6, BlockSize: 100000, Blocks: runtime.NumCPU() - 1}) + if err != nil { + log.Fatal(err) + } + zstdenc, err := zstd.New() + // set zstd commpression settings + compress, err := httpcompression.Adapter( + httpcompression.Compressor("zstd", 0, zstdenc), + httpcompression.Compressor("gzip", 1, pgz), + ) + http.Handle("/post", tollbooth.LimitFuncHandler(postlmt, posthandler)) + http.Handle("/comment", tollbooth.LimitFuncHandler(postlmt, commenthandler)) + http.Handle("/get", compress(tollbooth.LimitFuncHandler(getlmt, gethandler))) + fs := http.FileServer(http.Dir(*staticdir)) + http.Handle("/", compress(tollbooth.LimitHandler(getlmt, fs))) + http.ListenAndServe(*port, nil) +} + +// self explanatory +func commenthandler(w http.ResponseWriter, r *http.Request) { + id := r.FormValue("id") + text := r.FormValue("text") + if utf8.RuneCountInString(text) > 800 { + fmt.Fprintf(w, "text is too long, comment must be less than 800 characters") + return + } + board := r.FormValue("board") + post, _ := databaseget(id, board) + if post == "empty key" { + fmt.Fprintf(w, "id not found") + return + } + var jsonpost postform + var jsonfr []byte + json.Unmarshal([]byte(post), &jsonpost) + strippedtext := stripHtmlTags(text) + if jsonpost.Commentsnum == 0 { + jsonpost.Comments = []string{strippedtext} + jsonpost.Commentsnum = 1 + jsonfr, _ = json.Marshal(jsonpost) + } else { + jsonpost.Commentsnum++ + jsonpost.Comments = append(jsonpost.Comments, strippedtext) + jsonfr, _ = json.Marshal(jsonpost) + } + err := databaseoverwrite(id, string(jsonfr), board) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + var url string + if *domain == "http://127.0.0.1" { + url = *domain + *port + } else { + url = *domain + } + url = fmt.Sprintf("%s/get?board=%s&id=%s", url, board, id) + http.Redirect(w, r, url, http.StatusFound) +} + +// self explanatory +func posthandler(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(2 << 20) + if err != nil { + fmt.Fprintf(w, "shit fucked up, likely a too large post request") + log.Println(err) + return + } + title := r.FormValue("title") + text := r.FormValue("text") + board := r.FormValue("board") + if board == "" { + fmt.Fprintf(w, "must specify board") + return + } + if utf8.RuneCountInString(title) > 55 { + fmt.Fprintf(w, "title is too long, titles must be less than 55 characters") + return + } + if utf8.RuneCountInString(text) > 4000 { + fmt.Fprintf(w, "text is too long, post must be less than 4000 characters") + return + } + + img, _, err := r.FormFile("image") + var nofile bool + switch err { + case nil: + nofile = false + case http.ErrMissingFile: + nofile = true + default: + log.Println(err) + nofile = true + } + if nofile == false { + buff := make([]byte, 512) + _, err = img.Read(buff) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + filetype := http.DetectContentType(buff) + switch filetype { + case "image/png": + // do nothing + case "image/jpeg": + // do nothing + default: + fmt.Fprintf(w, "use a different filetype, supported filetypes are png and jpeg") + return + } + _, err := img.Seek(0, io.SeekStart) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + shit := multiposts(true, board) + if shit == -1 { + fmt.Fprintf(w, "shit fucked up") + return + } + var filepath string + if nofile == false { + + realimg, _, err := image.Decode(img) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + if *nsfwallowed == false { + isNSFW, err := nude.IsNude(realimg) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + if isNSFW == true { + shit-- + databaseoverwrite("postnum", strconv.Itoa(shit), board) + fmt.Fprintf(w, "the image you posted was detected as NSFW, try a different image or try posting without an image") + return + } + } + filepath = fmt.Sprintf("%s/%s-%d.jpg", *staticdir, board, shit) + dst, err := os.Create(filepath) + if err != nil { + fmt.Fprintf(w, "shit fucked up") + log.Println(err) + } + resized := resize.Resize(400, 0, realimg, resize.Lanczos3) + options := jpeg.Options{80} + jpeg.Encode(dst, resized, &options) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + } else { + filepath = "nil" + } + Ip := r.Header.Get("X-Forwarded-For") + comments := []string{} + post := postform{stripHtmlTags(title), stripHtmlTags(text), shit, 0, comments, Ip} + jsonpost, err := json.Marshal(post) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + err = databaseput(strconv.Itoa(shit), string(jsonpost), board) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } else { + var url string + if *domain == "http://127.0.0.1" { + url = *domain + *port + } else { + url = *domain + } + url = fmt.Sprintf("%s/get?board=%s", url, board) + http.Redirect(w, r, url, http.StatusFound) + } +} +func gethandler(w http.ResponseWriter, r *http.Request) { + id := r.FormValue("id") + board := r.FormValue("board") + if board == "" { + fmt.Fprintf(w, "must specify board") + return + } + var url string + if *domain == "http://127.0.0.1" { + url = *domain + *port + } else { + url = *domain + } + + if id != "" { + text, err := databaseget(id, board) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + if text == "NULL" { + fmt.Fprintf(w, "post was deleted") + return + } + var testicle postform + json.Unmarshal([]byte(text), &testicle) + var comments string + if testicle.Commentsnum == 0 { + comments = "\nnone yet" + } else { + for a := 0; a < testicle.Commentsnum; a++ { + comments = fmt.Sprintf("%s\n<p>%s</p>", comments, testicle.Comments[a]) + } + } + // check if file exists + // include image file if it does exist + var image2 string + imagepath := fmt.Sprintf("%s/%s-%d.jpg", *staticdir, board, testicle.Id) + if _, err := os.Stat(imagepath); errors.Is(err, os.ErrNotExist) { + image2 = "\n" + } else { + image2 = fmt.Sprintf("<img src=\"%s/%s-%d.jpg\">\n", url, board, testicle.Id) + } + result := fmt.Sprintf("<!DOCTYPE HTML>\n<html>\n<head>\n<title>%s</title>\n<link rel=\"stylesheet\" href=\"https://alloca.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) + fmt.Fprintf(w, result) + return + } else { + shit := multiposts(false, board) + if shit == -1 { + fmt.Fprintf(w, "shit fucked up") + return + } + if shit == 0 { + fmt.Fprintf(w, "<!DOCTYPE HTML>\n<html>\n<head>\n<title>posts on %s</title>\n<link rel=\"stylesheet\" href=\"https://alloca.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) + return + } + postnum := shit + var result string + result = fmt.Sprintf("<!DOCTYPE HTML>\n<html>\n<head>\n<title>posts on %s</title>\n<link rel=\"stylesheet\" href=\"https://alloca.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) + for postnum != 0 { + text, err := databaseget(strconv.Itoa(postnum), board) + if err != nil { + log.Println(err) + fmt.Fprintf(w, "shit fucked up") + return + } + // NULL means the post was deleted + if text == "NULL" { + //do nothing + } else { + var testicle postform + json.Unmarshal([]byte(text), &testicle) + result = fmt.Sprintf("%s<a href=\"%s/get?id=%d&board=%s\">%s</a></p>\n", result, url, postnum, board, testicle.Title) + } + postnum-- + } + fmt.Fprintf(w, "%s\n</article>", result) + } +}