1
0

Initial Release

This commit is contained in:
2026-05-24 19:58:20 -07:00
commit d7b7e01e92
28 changed files with 4273 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

+3
View File
@@ -0,0 +1,3 @@
docker-compose.yml
data
.env
+49
View File
@@ -0,0 +1,49 @@
# --- Build ---
FROM golang:1.25.2-trixie AS build
WORKDIR /app
COPY . .
# Setup Compiler
RUN apt-get update
RUN apt-get install -y gcc wget tar
RUN rm -rf /var/lib/apt/lists/*
# Setup Tensorflow
RUN wget -O tensorflow.tar.gz -q https://storage.googleapis.com/tensorflow/versions/2.18.0/libtensorflow-cpu-linux-x86_64.tar.gz && \
tar -C /usr/local -xzf tensorflow.tar.gz && \
rm tensorflow.tar.gz
# Build Application
ENV CGO_ENABLED=1 \
CGO_CFLAGS="-I/usr/local/include" \
CGO_LDFLAGS="-L/usr/local/lib -ltensorflow"
RUN go build -o stickerboard.elf main.go
# --- Runtime ---
FROM debian:trixie-slim AS runtime
WORKDIR /app
RUN mkdir -p /data
# Install Dependencies
RUN apt-get update
RUN apt-get install -y ffmpeg
RUN rm -rf /var/lib/apt/lists/*
# Copy Application
COPY --from=build /usr/local/lib /usr/local/lib
COPY --from=build /app/resources ./resources
COPY --from=build /app/stickerboard.elf .
# Update Environment
ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
ENV TF_CPP_MIN_LOG_LEVEL=2
ENV HTTP_ADDRESS="0.0.0.0:8080"
ENV DATA_DIRECTORY=/data
# Start Application
EXPOSE 8080
CMD ["./stickerboard.elf"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 bakonpancakz
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.
+38
View File
@@ -0,0 +1,38 @@
# 🍡 `pancakz-stickerboard`
> ⚠ This service is no longer available.
A program that allows trolls to paste inappropriate stickers on my Profile.
Contribute to the efforts [here](https://stickers.pancakz.net)!
<p align="center">
<img src=".github/preview.png" height=320>
<h6 align="center">some basic css edits will get you far</h6>
</p>
### *Featuring...*
- AI Image Moderation using [nsfw_model](https://github.com/GantMan/nsfw_model)
- Resonably Efficient Rendering
- Support for **WEBP**, **PNG**, **JPEG**, **Animated GIF** Formats
- Awesome Sauce Hatsune Miku Themed Website
## ⚙️ Configuration
This application requires that [CGO](https://go.dev/wiki/cgo), [FFMPEG](https://www.ffmpeg.org/) and
[Tensorflow](https://www.tensorflow.org/install/lang_c) be installed and setup on your machine.
> Additionally you must include the `resources` folder with the **executable**.
You can set these using environment variables or a `.env` file in the working directory.
Required Variables are marked with an asterisk `*`.
| Environment Variable | Default | Description |
| -------------------- | ---------------- | --------------------------------------------------------------------- |
| `DATA_DIRECTORY` | `./data` | Path to Data Directory |
| `HTTP_PROXY_HEADER` | *(none)* | Retrieve IP Address (for ratelimiting) from the following HTTP Header |
| `HTTP_ADDRESS` | `localhost:8080` | Accept Incoming Requests on given Host and Port |
| `TLS_ENABLED` | `false` | Enable TLS? |
| `TLS_CERT` | *(none)* | Path to Certificate |
| `TLS_KEY` | *(none)* | Path to Private Key |
| `TLS_CA` | *(none)* | Path to CA Bundle |
- **💡 TIP:** You can set a custom background by placing a `854x480px PNG` named **background.png** in the **data directory**.
+11
View File
@@ -0,0 +1,11 @@
# Download Tensorflow from: https://www.tensorflow.org/install/lang_c
# Move extracted contents of './include => /usr/local/include'
# Move extracted contents of './lib => /usr/local/lib'
# Update Library cache with 'sudo ldconfig'
export TF_CPP_MIN_LOG_LEVEL=2
export CGO_ENABLED=1
export CGO_CFLAGS="-I/usr/local/include"
export CGO_LDFLAGS="-L/usr/local/lib -ltensorflow"
go run main.go
+9
View File
@@ -0,0 +1,9 @@
# Download Tensorflow from: https://www.tensorflow.org/install/lang_c
# Extract ZIP contents to 'C:\lib\tensorflow'
# Add 'C:\lib\tensorflow\lib' to System PATH
$env:TF_CPP_MIN_LOG_LEVEL=2
$env:CGO_ENABLED = "1"
$env:CGO_CFLAGS = "-IC:\lib\tensorflow\include"
$env:CGO_LDFLAGS = "-LC:\lib\tensorflow\lib -ltensorflow"
go run main.go
+88
View File
@@ -0,0 +1,88 @@
package env
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"os"
"strconv"
)
const (
MAX_FORM_BYTES = 1 << 24
FILE_MODE = os.FileMode(0770)
STICKERBOARD_FILENAME = "stickerboard.webp"
)
var (
HTTP_TLS *tls.Config // http: TLS Configuration
HTTP_PROXY_HEADER = envString("HTTP_PROXY_HEADER", "") // http: Retrieve IP Address from Following HTTP Header
HTTP_ADDRESS = envString("HTTP_ADDRESS", "localhost:8080") // http: Address to Listen for Requests on
TLS_ENABLED = envString("TLS_ENABLED", "false") == "true" // http: Enable TLS?
TLS_CERT = envString("TLS_CERT", "tls_crt.pem") // http: Path to TLS Certificate
TLS_KEY = envString("TLS_KEY", "tls_key.pem") // http: Path to TLS Key
TLS_CA = envString("TLS_CA", "tls_ca.pem") // http: Path to TLS CA Bundle
DATA_DIRECTORY = envString("DATA_DIRECTORY", "data") // env: Data Directory
)
func init() {
// Create Data Directory
if err := os.MkdirAll(DATA_DIRECTORY, FILE_MODE); err != nil {
log.Fatalln("[env/data] Create Directory Error:", err)
}
// Load and Parse TLS Configuration from Disk
if TLS_ENABLED {
cert, err := tls.LoadX509KeyPair(TLS_CERT, TLS_KEY)
if err != nil {
log.Fatalln("[env/tls] Cannot Load Keypair", err)
}
caBytes, err := os.ReadFile(TLS_CA)
if err != nil {
log.Fatalln("[env/tls] Cannot Read CA File", err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caBytes) {
log.Fatalln("[env/tls] Cannot Append Certificates")
}
HTTP_TLS = &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: caPool,
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
},
}
}
}
// Reads String from Environment
func envString(key, defaultValue string) string {
systemValue := os.Getenv(key)
if systemValue == "" {
if defaultValue == "\x00" {
fmt.Printf("[env] Environment Variable '%s' is undefined\n", key)
os.Exit(2)
}
return defaultValue
}
return systemValue
}
// Read Number from Environment
func envNumber(key string, defaultValue int) int {
systemValue := os.Getenv(key)
if systemValue == "" {
return defaultValue
}
v, err := strconv.Atoi(systemValue)
if err != nil {
fmt.Printf("[env] Environment Variable '%s' is not a integer: %s\n", key, err)
os.Exit(2)
}
return v
}
+75
View File
@@ -0,0 +1,75 @@
package env
import (
"context"
"encoding/json"
"errors"
"log"
"os"
"path"
"sync"
"time"
)
type DatabaseRoot struct {
Stickers []DatabaseSticker `json:"stickers"`
}
type DatabaseSticker struct {
Created time.Time `json:"created"` // Sticker Created
UserAddress string `json:"user_address"` // User IP Address (For Manual Bans)
UserName string `json:"user_name"` // User Name
UserURL string `json:"user_url"` // User URL (Optional)
Message string `json:"message"` // Sticker Message
Visible bool `json:"visible"` // Sticker Visible?
OffsetX int `json:"offset_x"` // Placement X
OffsetY int `json:"offset_y"` // Placement Y
ImageScale float64 `json:"image_scale"` // Image Scale
ImageHeight int `json:"image_height"` // Image Height
ImageWidth int `json:"image_width"` // Image Width
ImageType ImageType `json:"image_type"` // Original Image File Type
ImageHash string `json:"image_hash"` // Original Image File Hash
}
var (
Database DatabaseRoot
DatabaseMtx sync.RWMutex
)
func SetupDatabase(stop context.Context, await *sync.WaitGroup) {
t := time.Now()
p := path.Join(DATA_DIRECTORY, "database.json")
// Decode File
b, err := os.ReadFile(p)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Fatalln("[db] Read Database Error:", err)
}
Database.Stickers = make([]DatabaseSticker, 0)
}
if err == nil {
if err := json.Unmarshal(b, &Database); err != nil {
log.Fatalln("[db] Parse Database Error:", err)
}
}
// Shutdown Logic
await.Add(1)
go func() {
defer await.Done()
<-stop.Done()
// Write Database To Disk
b, err := json.MarshalIndent(&Database, "", " ")
if err != nil {
log.Fatalln("[db] Cannot Marshal Database:", err)
}
if err := os.WriteFile(p, b, FILE_MODE); err != nil {
log.Fatalln("[db] Cannot Write Database:", err)
}
log.Println("[db] Database Saved")
}()
log.Println("[db] Ready in", time.Since(t))
}
+101
View File
@@ -0,0 +1,101 @@
package env
import (
"context"
"image"
"log"
"sync"
"time"
tf "github.com/galeone/tensorflow/tensorflow/go"
"golang.org/x/image/draw"
)
const (
MODEL_TRESHOLD = 0.7 // Threshold before an image is considered inappropriate
MODEL_SIZE = 224 // Model Size
)
var nsfwModel *tf.SavedModel
func SetupModel(stop context.Context, await *sync.WaitGroup) {
t := time.Now()
// Read Model from Disk
model, err := tf.LoadSavedModel("resources/model", []string{"serve"}, nil)
if err != nil {
log.Fatalf("[model] Unable to Load Model (%s)\n", err)
}
nsfwModel = model
// Test Model using Dummy Tensor
dummy, _ := tf.NewTensor([1][MODEL_SIZE][MODEL_SIZE][3]float32{})
if _, err := ModelClassifyTensor(dummy); err != nil {
log.Fatalf("[model] Failed to Initialize Model (%s)\n", err)
}
// Shutdown Logic
await.Add(1)
go func() {
defer await.Done()
<-stop.Done()
nsfwModel.Session.Close()
log.Println("[model] Model Closed")
}()
log.Printf("[model] Model Ready (%s)\n", time.Since(t))
}
// Cast Predictions on a Tensor using the NSFW Model
func ModelClassifyTensor(tensor *tf.Tensor) ([]float32, error) {
results, err := nsfwModel.Session.Run(
map[tf.Output]*tf.Tensor{
nsfwModel.Graph.Operation("serving_default_input").Output(0): tensor,
},
[]tf.Output{
nsfwModel.Graph.Operation("StatefulPartitionedCall").Output(0),
},
[]*tf.Operation{},
)
if err != nil {
return []float32{}, err
}
// cursed...
return results[0].Value().([][]float32)[0], err
}
// Classify an Image returning true if it's considered safe
func ModelClassifyImage(someImage image.Image) (bool, error) {
// Resize Image to Usable Size
resized := image.NewRGBA(image.Rect(0, 0, MODEL_SIZE, MODEL_SIZE))
draw.NearestNeighbor.Scale(resized, resized.Rect, someImage, someImage.Bounds(), draw.Over, nil)
// Convert Pixel Data into Normalized Floats
var tensorCap = MODEL_SIZE * MODEL_SIZE * 3
var tensorData = make([]float32, 0, tensorCap)
var tensorShape = []int64{1, MODEL_SIZE, MODEL_SIZE, 3}
for x := 0; x < MODEL_SIZE; x++ {
for y := 0; y < MODEL_SIZE; y++ {
r, g, b, _ := resized.At(x, y).RGBA()
tensorData = append(tensorData, float32(r)/65535, float32(g)/65535, float32(b)/65535)
}
}
// Create Tensor, reshape it, then classify
tensor, err := tf.NewTensor(tensorData)
if err != nil {
return false, err
}
if err := tensor.Reshape(tensorShape); err != nil {
return false, err
}
results, err := ModelClassifyTensor(tensor)
if err != nil {
return false, err
}
// Calculate How Inappropriate this Image is
// Drawing[0], Hentai[1], Neutral[2], Porn[3], Sexy[4]
return (results[1] + results[3] + (results[4] * 0.9)) < MODEL_TRESHOLD, nil
}
+264
View File
@@ -0,0 +1,264 @@
package env
import (
"bytes"
"fmt"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"log"
"os"
"os/exec"
"path"
"runtime"
"sync"
"sync/atomic"
"time"
"golang.org/x/image/draw"
"golang.org/x/image/webp"
)
const (
CANVAS_WIDTH = 854 // Canvas Width
CANVAS_HEIGHT = 480 // Canvas Height
CANVAS_STICKER_MAX_HEIGHT = 240 // Canvas Max Sticker Height in Pixels
CANVAS_FRAMES = 100
CANVAS_FPS = 20
CANVAS_DELAY = 100 / CANVAS_FPS
)
var (
StickerboardReady atomic.Bool
StickerboardPath = path.Join(DATA_DIRECTORY, STICKERBOARD_FILENAME)
StickerboardBack *image.RGBA
StickerboardMtx sync.RWMutex
Stickerboard []byte
)
func init() {
// Initialize Background to Black to Prevent Coalescing
base := image.NewRGBA(image.Rect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT))
for x := 0; x < CANVAS_WIDTH; x++ {
for y := 0; y < CANVAS_HEIGHT; y++ {
base.Set(x, y, color.Black)
}
}
StickerboardBack = base
// Load Custom Background (if any)
p := path.Join(DATA_DIRECTORY, "background.png")
if f, err := os.Open(p); err == nil {
if o, err := png.Decode(f); err == nil {
b := base.Bounds()
draw.CatmullRom.Scale(base, b.Bounds(), o, o.Bounds(), draw.Over, nil)
}
f.Close()
}
}
func stickerboardCopy() {
b, err := os.ReadFile(StickerboardPath)
if err != nil {
log.Println("[stickerboard] Read Image Error:", err)
return
}
StickerboardMtx.Lock()
Stickerboard = b
StickerboardReady.Store(true)
StickerboardMtx.Unlock()
}
func StickerboardRender() (int, error) {
t := time.Now()
// Mass Decode and Resizing of all Stickers
type DecodedSticker struct {
Position image.Rectangle
Frames []*image.RGBA
Delays []int
}
stickers := make([]DecodedSticker, len(Database.Stickers))
DatabaseMtx.RLock()
if err := Multithread(len(stickers), func(i int) error {
// Read Sticker from Disk
info := &Database.Stickers[i]
path := path.Join(DATA_DIRECTORY, info.ImageHash)
reader, err := os.Open(path)
if err != nil {
return err
}
defer reader.Close()
var images = make([]image.Image, 1)
var delays = make([]int, 1)
// Decode Sticker Frames
var decodeGIF *gif.GIF
var decodeImage image.Image
var decodeError error
switch info.ImageType {
case IMAGE_GIF:
decodeGIF, decodeError = gif.DecodeAll(reader)
case IMAGE_WEBP:
decodeImage, decodeError = webp.Decode(reader)
case IMAGE_JPEG:
decodeImage, decodeError = jpeg.Decode(reader)
case IMAGE_PNG:
decodeImage, decodeError = png.Decode(reader)
default:
return fmt.Errorf("decoder available")
}
if decodeError != nil {
return err
}
if info.ImageType == IMAGE_GIF {
// This section here properly layers GIF frames
delays = decodeGIF.Delay
images = make([]image.Image, len(decodeGIF.Image))
var imageBase = image.NewRGBA(image.Rect(0, 0, decodeGIF.Config.Width, decodeGIF.Config.Height))
var imagePrev *image.RGBA
var disposal = byte(gif.DisposalNone)
for i, frame := range decodeGIF.Image {
if disposal == gif.DisposalPrevious {
imagePrev = image.NewRGBA(imageBase.Bounds())
copy(imageBase.Pix, imagePrev.Pix)
}
if i > 0 {
switch disposal {
case gif.DisposalBackground:
draw.Draw(imageBase, decodeGIF.Image[i-1].Bounds(), image.Transparent, image.Point{}, draw.Src)
case gif.DisposalPrevious:
if imagePrev != nil {
draw.Draw(imageBase, imageBase.Bounds(), imagePrev, image.Point{}, draw.Src)
}
}
}
// Save Composited Frame
draw.Draw(imageBase, frame.Bounds(), frame, frame.Bounds().Min, draw.Over)
imageCopy := image.NewRGBA(imageBase.Bounds())
copy(imageCopy.Pix, imageBase.Pix)
images[i] = imageCopy
disposal = decodeGIF.Disposal[i]
}
} else {
// Copy Static Frame
images[0] = decodeImage
}
// Resize Decoded Frames
var (
stickerFrames = make([]*image.RGBA, len(images))
stickerPosition image.Rectangle
stickerBounds = images[0].Bounds()
stickerWidth = int(float64(stickerBounds.Dx()) * info.ImageScale)
stickerHeight = int(float64(stickerBounds.Dy()) * info.ImageScale)
)
for j := range images {
// Nice and Smooth Scaling
source := images[j]
scaled := image.NewRGBA(image.Rect(0, 0, stickerWidth, stickerHeight))
draw.CatmullRom.Scale(scaled, scaled.Bounds(), source, source.Bounds(), draw.Over, nil)
// Invert Y position because browsers placment origin is bottom-left but server is top-left
y := CANVAS_HEIGHT - info.OffsetY - stickerHeight
stickerPosition = image.Rect(info.OffsetX, y, info.OffsetX+stickerWidth, y+stickerHeight)
stickerFrames[j] = scaled
}
// Store Resized Frame
stickers[i] = DecodedSticker{
Frames: stickerFrames,
Delays: delays,
Position: stickerPosition,
}
return nil
}); err != nil {
DatabaseMtx.RUnlock()
return 0, err
}
DatabaseMtx.RUnlock()
// Startup Encoder
var outputLogs bytes.Buffer
cmd := exec.Command(
"ffmpeg", "-y",
"-threads", fmt.Sprint(runtime.NumCPU()),
"-f", "rawvideo",
"-pix_fmt", "rgba",
"-s", fmt.Sprintf("%dx%d", CANVAS_WIDTH, CANVAS_HEIGHT),
"-framerate", fmt.Sprint(CANVAS_FPS),
"-i", "pipe:0",
"-vcodec", "libwebp",
"-compression_level", "4",
"-q:v", "75",
"-loop", "0",
StickerboardPath,
)
cmdStdin, err := cmd.StdinPipe()
if err != nil {
return 0, err
}
cmd.Stdout = &outputLogs
cmd.Stderr = &outputLogs
if err := cmd.Start(); err != nil {
return 0, err
}
// Generate Frames
for i := 0; i < CANVAS_FRAMES; i++ {
// Generate Frame
canvas := image.NewRGBA(StickerboardBack.Rect)
copy(canvas.Pix, StickerboardBack.Pix)
for j := range stickers {
decode := &stickers[j]
index := 0
if len(decode.Frames) > 1 {
offset := 0
for {
idx := index % len(decode.Frames)
offset += decode.Delays[idx]
if offset > (i * CANVAS_DELAY) {
index = idx
break
}
index++
}
}
draw.Draw(canvas, decode.Position, decode.Frames[index], image.Point{}, draw.Over)
}
// Submit Encoder
for y := 0; y < canvas.Rect.Dy(); y++ {
start := y * canvas.Stride
end := start + canvas.Rect.Dx()*4
cmdStdin.Write(canvas.Pix[start:end])
}
}
// Await Encoding
cmdStdin.Close()
if err := cmd.Wait(); err != nil {
exitCode := -1
if cmd.ProcessState != nil {
exitCode = cmd.ProcessState.ExitCode()
}
log.Println("[sticker] FFMPEG Exited With Code %d\n%s\n", exitCode, outputLogs.String())
return 0, err
}
log.Printf("[sticker] Rendered %d Images in %s", len(stickers), time.Since(t))
stickerboardCopy()
return len(stickers), nil
}
+84
View File
@@ -0,0 +1,84 @@
package env
import (
"runtime"
"sync"
"sync/atomic"
)
type ImageType string
const (
IMAGE_OTHER ImageType = "UNKNOWN"
IMAGE_WEBP ImageType = "WEBP"
IMAGE_JPEG ImageType = "JPG"
IMAGE_PNG ImageType = "PNG"
IMAGE_GIF ImageType = "GIF"
)
// Classify the contents of a file based on it's starting bytes
// https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files)
func ImageSniffType(d []byte) ImageType {
switch {
case len(d) > 3 && // JPEG
d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF:
return IMAGE_JPEG
case len(d) > 8 && // PNG
d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 &&
d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A:
return IMAGE_PNG
case len(d) > 4 && // GIF
d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38:
return IMAGE_GIF
case len(d) > 12 && // WEBP
d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50:
return IMAGE_WEBP
default:
return IMAGE_OTHER
}
}
// Easily spread a workload across all available threads, returning the error if any
func Multithread(jobCount int, handler func(i int) error) error {
threads := runtime.NumCPU()
channel := make(chan int, jobCount)
var wait sync.WaitGroup
var fail atomic.Value
// Startup Goroutines
for workerId := 0; workerId < threads; workerId++ {
wait.Add(1)
go func() {
defer wait.Done()
for i := range channel {
// Exit early if an error has ocurred
if fail.Load() != nil {
return
}
// Run Handler and store it's error (if any)
if err := handler(i); err != nil {
fail.Store(err)
return
}
}
}()
}
// Queue and Complete Work Across all Threads
for i := 0; i < jobCount; i++ {
channel <- i
}
close(channel) // close or wait forever
wait.Wait()
// Return Error (if any)
if v := fail.Load(); v != nil {
return v.(error)
}
return nil
}
+11
View File
@@ -0,0 +1,11 @@
module bakonpancakz/stickerboard
go 1.23.4
require (
github.com/galeone/tensorflow/tensorflow/go v0.0.0-20240119075110-6ad3cf65adfe
github.com/joho/godotenv v1.5.1
golang.org/x/image v0.27.0
)
require google.golang.org/protobuf v1.36.5 // indirect
+12
View File
@@ -0,0 +1,12 @@
github.com/galeone/tensorflow/tensorflow/go v0.0.0-20240119075110-6ad3cf65adfe h1:7yELf1NFEwECpXMGowkoftcInMlVtLTCdwWLmxKgzNM=
github.com/galeone/tensorflow/tensorflow/go v0.0.0-20240119075110-6ad3cf65adfe/go.mod h1:TelZuq26kz2jysARBwOrTv16629hyUsHmIoj54QqyFo=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"bakonpancakz/stickerboard/env"
"bakonpancakz/stickerboard/routes"
_ "github.com/joho/godotenv/autoload"
)
func main() {
// Startup Services
var stopCtx, stop = context.WithCancel(context.Background())
var stopWg sync.WaitGroup
env.SetupDatabase(stopCtx, &stopWg)
env.SetupModel(stopCtx, &stopWg)
go SetupHTTP(stopCtx, &stopWg)
go env.StickerboardRender()
// Await Shutdown Signal
cancel := make(chan os.Signal, 1)
signal.Notify(cancel, syscall.SIGINT, syscall.SIGTERM)
<-cancel
stop()
// Begin Shutdown Process
timeout, finish := context.WithTimeout(context.Background(), time.Minute)
defer finish()
go func() {
<-timeout.Done()
if timeout.Err() == context.DeadlineExceeded {
log.Fatalln("[main] Cleanup timeout! Exiting now.")
}
}()
stopWg.Wait()
log.Println("[main] All done, bye bye!")
os.Exit(0)
}
func SetupHTTP(stop context.Context, await *sync.WaitGroup) {
r := http.NewServeMux()
r.HandleFunc("/", routes.GET_Index)
r.HandleFunc("/stickers", routes.POST_Stickers)
r.HandleFunc("/assets/{filename}", routes.GET_Assets_Filename)
svr := http.Server{
Handler: r,
Addr: env.HTTP_ADDRESS,
TLSConfig: env.HTTP_TLS,
MaxHeaderBytes: 4096,
IdleTimeout: 5 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
ReadTimeout: 30 * time.Second,
}
// Shutdown Logic
await.Add(1)
go func() {
defer await.Done()
<-stop.Done()
svr.Shutdown(context.Background())
log.Println("[http] Cleaned up HTTP")
}()
// Server Startup
var err error
if env.TLS_ENABLED {
log.Printf("[http] Bound HTTPS - %s\n", svr.Addr)
err = svr.ListenAndServeTLS("", "")
} else {
log.Printf("[http] Bound HTTP - %s\n", svr.Addr)
err = svr.ListenAndServe()
}
if err != http.ErrServerClosed {
log.Fatalln("[http] Listen Error:", err)
}
}
+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Stickerboard - pancakz</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="bakonpancakz's Stickerboard!">
<link rel="stylesheet" href="/assets/index.css">
<link rel="icon" href="/assets/favicon.png">
</head>
<body>
<div class="layout-wrapper">
<div class="layout-canvas">
<div class="section-canvas">
<p class="chalk-highlight">loading stickers</p>
<img draggable="false" id="canvas" src="/assets/stickerboard.webp" alt="Latest Stickerboard" style="opacity: 0;" onload="this.style.opacity=1">
<img draggable="false" id="preview">
</div>
<div class="section-make-row">
<p class="chalk-primary">An AI will be monitoring your behaviour today ◑﹏◐</p>
</div>
<div class="section-make-row">
<a target="_blank" href="https://piapro.net/intl/en.html">
<img alt="Mikufan Button" src="/assets/button_mikufan.png">
</a>
</div>
</div>
<div class="layout-create">
<button id="pane-swap">Create Sticker</button>
<div class="layout-pane pane-stickers">
{{ range . }}
{{ if .Visible}}
<div class="section-post" data-offsetx="{{ .OffsetX }}" data-offsety="{{ .OffsetY }}" data-scale="{{ .ImageScale }}" data-height="{{ .ImageHeight }}" data-width="{{ .ImageWidth}}">
{{ if .UserName }}
<a class="text-header" href="{{ .UserURL }}" title="Visit '{{ .UserURL }}'" target="_blank">{{ .UserName }}</a>
{{ else }}
<p class="text-header">Anonymous</p>
{{ end }}
<p class="text-hint">{{ .Created.Format "Jan 02 2006 - 3:04 PM" }}</p>
<p class="text-description">{{ .Message }}</p>
</div>
{{ end }}
{{ end }}
</div>
<div class="layout-pane pane-uploader" hidden>
<div class="form-section">
<p class="text-header">Sticker</p>
<p class="text-description">
Select the image you wish to share.
It must be in either <u>GIF</u>, <u>JPEG</u>, or <u>PNG</u> format,
up to 16 Megabytes in size, and up to 2048 pixels in dimensions.
</p>
<input id="form-file" type="file" accept=".gif,.jpg,.jpeg,.png,.webp" hidden>
<button onclick="document.querySelector('#form-file').click()">
Select File
</button>
</div>
<div class="form-section">
<p class="text-header">Placement</p>
<p class="text-description">
You can position your sticker by either clicking anywhere
on the <u id="canvas" style="cursor: help;">canvas</u>
or by using the inputs boxes below.
</p>
<div class="form-unique-placement">
<p>X:</p>
<input id="form-x" type="number" value="0" disabled>
<p>Y:</p>
<input id="form-y" type="number" value="0" disabled>
<p>Scale:</p>
<input id="form-s" type="number" min="1" max="100" value="100" disabled>
</div>
<script>
(() => {
// Sometimes people don't know, yknow?
const u = document.querySelector("u#canvas")
const c = document.querySelector("img#canvas")
u.onmouseleave = () => c.style.borderColor = 'var(--color-primary)'
u.onmouseover = () => c.style.borderColor = 'var(--color-highlight)'
})()
</script>
</div>
<div class="form-section">
<p class="text-header">Profile</p>
<p class="text-description">
Leave these boxes blank to remain anonymous. You can
include a URL to your personal homepage or social media
if you like.
</p>
<input id="form-username" type="text" autocomplete="off" placeholder="Username: bakonpancakz">
<input id="form-userurl" type="text" autocomplete="off" placeholder="URL: https://panca.kz/">
</div>
<div class="form-section">
<p class="text-header">Comment</p>
<p class="text-description">
Leave a comment for other users to see.
Inappropriate comments will be censored.
</p>
<textarea id="form-comment" maxlength="1000" placeholder="Hello World!"></textarea>
<script>
(() => {
// Good for the soul :3
const c = document.querySelector("textarea#form-comment")
const x = [
"blah blah blah",
"sometimes i dream about cheese",
"weiner",
"mikudayo",
]
c.placeholder = x[Math.floor(Math.random() * x.length)]
})()
</script>
</div>
<div class="form-section">
<p id="form-error">...</p>
</div>
<div class="form-section" style="margin-bottom: 0;">
<button id="form-submit">Post</button>
</div>
</div>
<div class="section-make-row">
<a class="chalk-highlight" href="https://panca.kz/">Homepage</a>
<span class="chalk-secondary">&bull;</span>
<a class="chalk-highlight" href="https://panca.kz/goto/stickerboard">Source</a>
</div>
</div>
</div>
</body>
<script src="/assets/index.js"></script>
</html>
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+301
View File
@@ -0,0 +1,301 @@
:root {
color-scheme: dark;
--color-background: black;
--color-text: #f0f0f0;
--color-gray: #a3a3a3;
--color-primary: #86cecb;
--color-secondary: #128888;
--color-highlight: #e12885;
--canvas-width: 854px;
--canvas-height: 480px;
}
div::-webkit-scrollbar {
border: 1px solid var(--color-secondary);
width: 4px;
}
div::-webkit-scrollbar-thumb {
border: 1px solid var(--color-secondary);
background-color: var(--color-secondary);
}
body {
background-color: var(--color-background);
margin: 64px 0 0 0;
padding: 0;
}
textarea,
button,
input,
p,
a {
color: var(--color-text);
font-family: monospace;
padding: 0;
margin: 0;
}
.chalk-primary {
color: var(--color-primary);
}
.chalk-secondary {
color: var(--color-secondary);
}
.chalk-highlight {
color: var(--color-highlight);
}
.chalk-gray {
color: var(--color-gray);
}
.text-header {
font-size: x-large;
font-weight: bold;
color: var(--color-primary);
}
.text-hint {
color: var(--color-gray);
font-size: small;
}
.text-description {
color: var(--color-secondary);
margin-bottom: 4px;
}
.text-description u {
color: var(--color-highlight);
}
/* Site Layout - Canvas */
div.layout-wrapper {
width: fit-content;
height: fit-content;
margin: auto;
display: flex;
flex-wrap: nowrap;
gap: 8px;
}
div.layout-canvas {
display: grid;
gap: 8px;
}
div.section-canvas {
min-width: var(--canvas-width);
max-width: var(--canvas-width);
min-height: var(--canvas-height);
max-height: var(--canvas-height);
display: grid;
justify-content: center;
align-items: center;
position: relative;
border: 1px solid var(--color-primary);
box-sizing: border-box;
overflow: hidden;
}
div.section-canvas img#preview {
border: 1px solid var(--color-highlight);
box-sizing: border-box;
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
div.section-canvas>* {
grid-row: 1;
grid-column: 1;
}
div.section-make-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
/* Site Layout - Panes */
div.layout-create {
min-height: var(--canvas-height);
max-height: var(--canvas-height);
min-width: 300px;
max-width: 300px;
display: grid;
gap: 8px;
}
button#pane-swap {
border: 1px solid var(--color-primary);
box-sizing: border-box;
border-radius: 0;
background: none;
cursor: pointer;
min-width: 100%;
max-width: 100%;
min-height: 32px;
max-height: 32px;
}
button#pane-swap:active,
button#pane-swap:focus-visible {
background-color: var(--color-primary);
outline: none;
}
div.layout-pane {
min-width: 100%;
max-width: 100%;
min-height: calc(var(--canvas-height) - 30px);
max-height: calc(var(--canvas-height) - 30px);
padding: 8px;
box-sizing: border-box;
overflow: auto;
}
div.form-section {
min-width: 100%;
max-width: 100%;
overflow: auto;
display: grid;
gap: 4px;
margin-bottom: 12px;
}
/* Site Layout - Pane Posts */
div.section-post:not(:last-child) {
margin-bottom: 12px;
}
div.section-post {
display: grid;
gap: 4px;
}
div.section-post>a[href] {
text-decoration: none;
}
div.section-post>a[href]:hover {
text-decoration: underline;
}
div.section-post>a[href=""] {
pointer-events: none;
}
/* Site Layout - Pane Upload */
div.form-section>button {
border: 1px solid var(--color-primary);
box-sizing: border-box;
border-radius: 0;
background: none;
cursor: pointer;
min-width: 100%;
max-width: 100%;
min-height: 24px;
max-height: 24px;
border-color: var(--color-secondary);
}
div.form-section>button:active,
div.form-section>button:focus-visible {
background-color: var(--color-secondary);
outline: none;
}
div.form-section>input {
padding: 0 4px;
border: 1px solid var(--color-primary);
box-sizing: border-box;
border-radius: 0;
background: none;
min-width: 100%;
max-width: 100%;
min-height: 24px;
max-height: 24px;
border-color: var(--color-secondary);
}
div.form-section>input:focus-visible {
border-color: var(--color-primary);
outline: none;
}
div.form-section>input::placeholder {
color: var(--color-gray);
}
div.form-section>textarea {
padding: 4px;
border: 1px solid var(--color-secondary);
box-sizing: border-box;
border-radius: 0;
background: none;
min-width: 100%;
max-width: 100%;
min-height: 72px;
border-color: var(--color-secondary);
}
div.form-section>textarea:focus-visible {
border-color: var(--color-primary);
outline: none;
}
div.form-section>textarea::placeholder {
color: var(--color-gray);
}
div.form-unique-placement {
display: flex;
align-items: center;
gap: 8px;
}
div.form-unique-placement>p {
color: var(--color-secondary);
}
div.form-unique-placement>input {
padding: 0 4px;
border-radius: 0;
background: none;
border: 1px solid var(--color-secondary);
box-sizing: border-box;
background: none;
min-height: 24px;
max-height: 24px;
width: 100%;
outline: none;
}
div.form-unique-placement>input:disabled {
color: var(--color-gray);
cursor: not-allowed;
}
div.form-unique-placement>input:not(:disabled):focus-visible,
div.form-unique-placement>input:not(:disabled):active {
border-color: var(--color-highlight);
}
div.form-section>p#form-error {
color: var(--color-highlight);
text-align: center;
font-size: large;
}
div.form-section>button#form-submit {
min-height: 32px;
max-height: 32px;
}
+249
View File
@@ -0,0 +1,249 @@
//@ts-check
// ██
// ██ ██ ██
// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
// ██ ██ ██ ██ ██ ██ ██ ██ ██
// ██ ██ ██ ██ ██ ██ ██ ██ ██
// ██ ██
// ██
(() => {
const blank_pixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
/** @param {any} s */
const $ = s => document.querySelector(s)
/** @param {string} s */
const safe_url = s => {
try {
new URL(s)
return true
} catch (_) {
return false
}
}
/** @param {Blob} f */
const file_url = f => new Promise((o, r) => {
const reader = new FileReader()
reader.onerror = e => r(e)
reader.onload = () => o(reader.result)
reader.readAsDataURL(f)
})
const
/** @type {HTMLImageElement?} */ elemCanvas = $("#canvas"),
/** @type {HTMLImageElement?} */ elemPreview = $("#preview"),
/** @type {HTMLInputElement?} */ formFile = $("#form-file"),
/** @type {HTMLInputElement?} */ formX = $("#form-x"),
/** @type {HTMLInputElement?} */ formY = $("#form-y"),
/** @type {HTMLInputElement?} */ formS = $("#form-s"),
/** @type {HTMLInputElement?} */ formUserName = $("#form-username"),
/** @type {HTMLInputElement?} */ formUserURL = $("#form-userurl"),
/** @type {HTMLTextAreaElement?} */ formComment = $("#form-comment"),
/** @type {HTMLParagraphElement?} */ formError = $("#form-error"),
/** @type {HTMLButtonElement?} */ formSubmit = $("#form-submit"),
/** @type {HTMLDivElement?} */ paneStickers = $(".pane-stickers"),
/** @type {HTMLDivElement?} */ paneUploader = $(".pane-uploader"),
/** @type {HTMLButtonElement?} */ buttonSwap = $("#pane-swap")
let CANVAS_WIDTH = 854, CANVAS_HEIGHT = 480, CANVAS_STICKER_MAX_HEIGHT = 240, CANVAS_STICKER_MAX_HEIGHT_START = 160
let scale = 1, ox = 0, oy = 0, iw = 0, ih = 0, click = false, busy = false, preview = false
// Pane Swapping
if (buttonSwap) buttonSwap.onclick = (() => {
let viewUploader = false
return () => {
if (!paneStickers || !paneUploader) return
if (viewUploader) {
buttonSwap.textContent = "Create Sticker"
paneStickers.removeAttribute("hidden")
paneUploader.setAttribute("hidden", "true")
canvas_clear()
} else {
buttonSwap.textContent = "Cancel"
paneStickers.setAttribute("hidden", "true")
paneUploader.removeAttribute("hidden")
}
viewUploader = !viewUploader
}
})()
// Sticker Preview
function preview_update() {
if (!preview || !formS || !formY || !formX || !elemCanvas || !elemPreview) return
let max = Math.floor((CANVAS_STICKER_MAX_HEIGHT / ih) * 100)
if (max > 100) max = 100
formS.max = String(max)
formS.removeAttribute("disabled")
formX.min = String(0 - Math.round(iw * scale))
formX.max = String(elemCanvas.offsetWidth)
formX.removeAttribute("disabled")
formY.min = String(0 - Math.round(ih * scale))
formY.max = String(elemCanvas.offsetHeight)
formY.removeAttribute("disabled")
elemPreview.style.opacity = "1"
elemPreview.style.height = `${Math.round(ih * scale)}px`
elemPreview.style.width = `${Math.round(iw * scale)}px`
elemPreview.style.bottom = `${oy}px`
elemPreview.style.left = `${ox}px`
}
if (formX) formX.oninput = () => {
const e = formX, v = e.valueAsNumber
if (isNaN(v)) return
if (v < parseInt(e.min)) e.value = e.min
if (v > parseInt(e.max)) e.value = e.max
ox = e.valueAsNumber
preview_update()
}
if (formY) formY.oninput = () => {
const e = formY, v = e.valueAsNumber
if (isNaN(v)) return
if (v < parseInt(e.min)) e.value = e.min
if (v > parseInt(e.max)) e.value = e.max
oy = e.valueAsNumber
preview_update()
}
if (formS) formS.oninput = () => {
const e = formS, v = e.valueAsNumber
if (isNaN(v)) return
if (v < parseInt(e.min)) e.value = e.min
if (v > parseInt(e.max)) e.value = e.max
scale = e.valueAsNumber / 100
preview_update()
}
if (formFile) formFile.onchange = async () => {
if (!elemPreview || !formS || !formX || !formY) return
const image = formFile.files?.item(0)
if (!image) {
elemPreview.style.opacity = "0"
elemPreview.src = blank_pixel
return
}
elemPreview.src = await file_url(image)
elemPreview.onload = () => {
iw = elemPreview.naturalWidth
ih = elemPreview.naturalHeight
if (ih > CANVAS_STICKER_MAX_HEIGHT_START) {
scale = CANVAS_STICKER_MAX_HEIGHT_START / ih
}
ox = Math.round((CANVAS_WIDTH / 2) - ((iw * scale) / 2))
oy = Math.round((CANVAS_HEIGHT / 2) - ((ih * scale / 2)))
formS.valueAsNumber = Math.round(scale * 100)
formX.valueAsNumber = ox
formY.valueAsNumber = oy
preview = true
preview_update()
}
}
/** @param {MouseEvent} ev */
function preview_move(ev) {
if (!preview || !formX || !formY || !elemPreview) return
formX.valueAsNumber = ev.offsetX - Math.round((iw * scale) / 2)
formY.valueAsNumber = CANVAS_HEIGHT - ev.offsetY - Math.round((ih * scale) / 2)
if (formX.oninput) formX.oninput(ev)
if (formY.oninput) formY.oninput(ev)
}
if (elemCanvas) {
elemCanvas.onclick = preview_move
document.onmouseup = () => { click = false }
elemCanvas.onmousedown = () => { click = true }
elemCanvas.onmousemove = e => { click && preview_move(e) }
}
// Sticker Forms
function canvas_clear() {
if (
!formX || !formY || !formS || !formComment || !formUserName ||
!formUserURL || !formFile || !elemPreview || !elemPreview || !formError
) return
for (const elem of [formX, formY, formS]) {
elem.setAttribute("disabled", "true")
elem.value = "0"
}
formS.value = "100"
formComment.value = ""
formUserName.value = ""
formUserURL.value = ""
formFile.value = ""
elemPreview.style.opacity = "0"
elemPreview.src = blank_pixel
formError.textContent = "..."
preview = false
}
if (formSubmit) formSubmit.onclick = async () => {
if (
busy || !formX || !formY || !formS || !elemCanvas || !formError ||
!formFile || !formUserURL || !formUserName || !formComment
) return
busy = true
try {
// Sanity Checks
// Comment, X, Y, and Scale already accounted for
const image = formFile.files?.item(0)
if (!image) {
throw "Missing Sticker"
}
if (formUserURL.value && !safe_url(formUserURL.value)) {
throw "Invalid URL"
}
// Generate Form Body
const form = new FormData()
form.append("sticker", image, "sticker")
form.append("data", JSON.stringify({
offset_x: formX.valueAsNumber,
offset_y: formY.valueAsNumber,
image_scale: formS.valueAsNumber,
user_url: formUserURL.value,
user_name: formUserName.value,
message: formComment.value,
}))
// Send Image
formError.textContent = "Uploading, please wait..."
const resp = await fetch("/stickers", { method: "POST", body: form })
if (resp.status !== 201) {
throw `${resp.status}: ${await resp.text() || resp.statusText}`
}
formError.textContent = "Done! Refreshing..."
window.location.reload()
} catch (err) {
console.error(err)
formError.textContent = String(err)
}
busy = false
}
// Sticker Hovering
document.querySelectorAll(".section-post").forEach(elem => {
const x = parseInt(elem.getAttribute("data-offsetx") || "0")
const y = parseInt(elem.getAttribute("data-offsety") || "0")
const s = parseFloat(elem.getAttribute("data-scale") || "0")
const h = parseInt(elem.getAttribute("data-height") || "0")
const w = parseInt(elem.getAttribute("data-width") || "0")
elem.addEventListener("mouseover", () => {
if (elemPreview) {
elemPreview.style.opacity = "1"
elemPreview.style.height = `${Math.round(h * s)}px`
elemPreview.style.width = `${Math.round(w * s)}px`
elemPreview.style.bottom = `${y}px`
elemPreview.style.left = `${x}px`
}
})
elem.addEventListener("mouseout", () => {
if (elemPreview) {
elemPreview.style.opacity = "0"
}
})
})
})()
+65
View File
@@ -0,0 +1,65 @@
package routes
import (
"io"
"log"
"mime"
"net/http"
"os"
"path"
"time"
"bakonpancakz/stickerboard/env"
)
var pathPublic = path.Join("resources", "public")
// Serve Static File from Resource Directory
func serveStaticFilename(w http.ResponseWriter, filepath string) {
// Read File from Disk
f, err := os.Open(filepath)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
log.Println("[http] Read Asset Error:", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
defer f.Close()
// Determine Content-Type and Stream Contents
w.Header().Add("Content-Type", mime.TypeByExtension(path.Ext(filepath)))
io.Copy(w, f)
}
func GET_Assets_Filename(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
f := path.Clean(r.PathValue("filename"))
if f == env.STICKERBOARD_FILENAME {
// Wait Until Stickerboard is Ready, this should only occur on startup!
if !env.StickerboardReady.Load() {
for {
if env.StickerboardReady.Load() {
break
}
time.Sleep(time.Second)
}
}
// Serve Stickerboard from Memory
env.StickerboardMtx.RLock()
w.Header().Add("Content-Type", "image/webp")
w.Write(env.Stickerboard)
env.StickerboardMtx.RUnlock()
return
}
// Serve Asset from Disk
serveStaticFilename(w, path.Join(pathPublic, f))
}
+41
View File
@@ -0,0 +1,41 @@
package routes
import (
"bytes"
"fmt"
"html/template"
"net/http"
"bakonpancakz/stickerboard/env"
)
func GET_Index(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Parse and Render Template
// Rendered Document is stored in memory to help protect Database
// from Slowloris attacks
env.DatabaseMtx.RLock()
tmpl, err := template.ParseFiles("resources/index.html")
if err != nil {
fmt.Println("[http] Template Parse Error", err)
w.WriteHeader(http.StatusInternalServerError)
env.DatabaseMtx.RUnlock()
return
}
var data bytes.Buffer
if err := tmpl.Execute(&data, env.Database.Stickers); err != nil {
fmt.Println("[http] Template Execute Error", err)
w.WriteHeader(http.StatusInternalServerError)
env.DatabaseMtx.RUnlock()
return
}
env.DatabaseMtx.RUnlock()
// Send Document
w.Header().Add("Content-Type", "text/html; charset=utf-8")
w.Write(data.Bytes())
}
+243
View File
@@ -0,0 +1,243 @@
package routes
import (
"bakonpancakz/stickerboard/env"
"bytes"
"crypto/sha1"
"encoding/json"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"log"
"net"
"net/http"
"os"
"path"
"sync"
"time"
"golang.org/x/image/webp"
)
// Dead Simple Ratelimiting, refreshes whenever it feels like...
var uploadDebounce sync.Map
func init() {
go func() {
for {
uploadDebounce.Clear()
time.Sleep(time.Minute)
}
}()
}
// Retrieve the real IP address based on the environment variables
func getRealAddress(r *http.Request) string {
var ip string
if env.HTTP_PROXY_HEADER != "" {
ip = r.Header.Get(env.HTTP_PROXY_HEADER)
if ip != "" {
return ip
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip = host
return ip
}
func POST_Stickers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Rate Limiting
uploadOK := false
uploadIP := getRealAddress(r)
if _, exists := uploadDebounce.Load(uploadIP); exists {
http.Error(w, "Posted Too Recently", http.StatusTooManyRequests)
return
}
defer func() {
if !uploadOK {
uploadDebounce.Delete(uploadIP)
}
}()
uploadDebounce.Store(uploadIP, true)
// Sanity Checks
r.Body = http.MaxBytesReader(w, r.Body, env.MAX_FORM_BYTES)
if r.ContentLength > env.MAX_FORM_BYTES {
http.Error(w, "Payload Too Large", http.StatusRequestEntityTooLarge)
return
}
if err := r.ParseMultipartForm(env.MAX_FORM_BYTES); err != nil {
http.Error(w, "Invalid Form Body", http.StatusBadRequest)
return
}
// Parse Incoming JSON
var formJSON struct {
OffsetX int `json:"offset_x"`
OffsetY int `json:"offset_y"`
ImageScale int `json:"image_scale"`
UserName string `json:"user_name"`
UserURL string `json:"user_url"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(r.FormValue("data")), &formJSON); err != nil {
http.Error(w, "Malformed Form Data", http.StatusBadRequest)
return
}
if formJSON.ImageScale < 1 || formJSON.ImageScale > 100 || len(formJSON.Message) > 1024 {
http.Error(w, "Invalid Form Body", http.StatusBadRequest)
return
}
// Copy Incoming Image to Memory
// We're might partially or fully read it multiple times, so yes it has to
// be stored entirely in memory (@_@)
var formImage []byte
if file, _, err := r.FormFile("sticker"); err != nil {
http.Error(w, "Malformed Form Image", http.StatusBadRequest)
return
} else if data, err := io.ReadAll(file); err != nil {
http.Error(w, "Invalid Form Body", http.StatusBadRequest)
return
} else {
file.Close()
formImage = data
}
// Validate Incoming Image
var imageType = env.ImageSniffType(formImage)
var imageInfo image.Config
var imageErr error
switch imageType {
case env.IMAGE_WEBP:
imageInfo, imageErr = webp.DecodeConfig(bytes.NewReader(formImage))
case env.IMAGE_JPEG:
imageInfo, imageErr = jpeg.DecodeConfig(bytes.NewReader(formImage))
case env.IMAGE_PNG:
imageInfo, imageErr = png.DecodeConfig(bytes.NewReader(formImage))
case env.IMAGE_GIF:
imageInfo, imageErr = gif.DecodeConfig(bytes.NewReader(formImage))
default:
http.Error(w, "Unsupported Image Format", http.StatusBadRequest)
return
}
if imageErr != nil {
http.Error(w, "Invalid Image Data", http.StatusBadRequest)
return
}
if imageInfo.Height > 2048 || imageInfo.Width > 2048 {
http.Error(w, "Image dimension cannot be larger than 2048 pixels", http.StatusBadRequest)
return
}
if imageInfo.Height < 32 || imageInfo.Width < 32 {
http.Error(w, "Image dimension cannot be smaller than 32 pixels", http.StatusBadRequest)
return
}
// Validate Image Placement
scaledFloat := float32(formJSON.ImageScale) / 100
scaledWidth := int((float32(imageInfo.Width) * scaledFloat))
scaledHeight := int((float32(imageInfo.Height) * scaledFloat))
if scaledHeight > (env.CANVAS_STICKER_MAX_HEIGHT + 4) {
http.Error(w, "Image is Too Large", http.StatusBadRequest)
return
}
if formJSON.OffsetX < -scaledWidth || formJSON.OffsetX > env.CANVAS_WIDTH ||
formJSON.OffsetY < -scaledHeight || formJSON.OffsetY > env.CANVAS_HEIGHT {
http.Error(w, "Image cannot be placed off-screen", http.StatusBadRequest)
return
}
// Decode Image Frame(s) for Classification
var imageFrames = make([]image.Image, 0, 1)
var decodedStill image.Image
var decodedFrame *gif.GIF
switch imageType {
case env.IMAGE_WEBP:
decodedStill, imageErr = webp.Decode(bytes.NewReader(formImage))
case env.IMAGE_JPEG:
decodedStill, imageErr = jpeg.Decode(bytes.NewReader(formImage))
case env.IMAGE_PNG:
decodedStill, imageErr = png.Decode(bytes.NewReader(formImage))
case env.IMAGE_GIF:
decodedFrame, imageErr = gif.DecodeAll(bytes.NewReader(formImage))
default:
log.Printf("[http] Missing Decoder for Image Type: %s\n", imageType)
http.Error(w, "Unsupported Image Format", http.StatusBadRequest)
return
}
if imageErr != nil {
http.Error(w, "Invalid Image Data", http.StatusBadRequest)
return
} else {
// Copy Decoded Frames
if imageType == env.IMAGE_GIF {
for i := range decodedFrame.Image {
frame := decodedFrame.Image[i]
imageFrames = append(imageFrames, frame.SubImage(frame.Rect))
}
} else {
imageFrames = append(imageFrames, decodedStill)
decodedStill = nil
}
}
// Classify Decoded Frames
for i := range imageFrames {
pass, err := env.ModelClassifyImage(imageFrames[i])
if err != nil {
log.Println("[http] Cannot Classify Image:", err)
http.Error(w, "Model Error", http.StatusInternalServerError)
return
}
if !pass {
log.Printf("[http] Inappopriate Image Uploaded by %s\n", uploadIP)
http.Error(w, "Inappropriate Image", http.StatusBadRequest)
return
}
}
// Write Contents to Disk
imageHash := fmt.Sprintf("%X", sha1.Sum(formImage))
imagePath := path.Join(env.DATA_DIRECTORY, imageHash)
if err := os.WriteFile(imagePath, formImage, env.FILE_MODE); err != nil {
log.Println("[http] Cannot Write Image:", imagePath, err)
return
}
// Write Contents to Database
env.DatabaseMtx.Lock()
env.Database.Stickers = append(env.Database.Stickers, env.DatabaseSticker{
Created: time.Now(),
UserAddress: uploadIP,
UserName: formJSON.UserName,
UserURL: formJSON.UserURL,
Message: formJSON.Message,
Visible: true,
OffsetX: formJSON.OffsetX,
OffsetY: formJSON.OffsetY,
ImageScale: float64(formJSON.ImageScale) / 100,
ImageHeight: imageInfo.Height,
ImageWidth: imageInfo.Width,
ImageType: imageType,
ImageHash: imageHash,
})
env.DatabaseMtx.Unlock()
uploadOK = true
// Update Stickerboard
env.StickerboardRender()
w.WriteHeader(http.StatusCreated)
}