Initial Release
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
docker-compose.yml
|
||||||
|
data
|
||||||
|
.env
|
||||||
+49
@@ -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"]
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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**.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Vendored
+88
@@ -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
|
||||||
|
}
|
||||||
Vendored
+75
@@ -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))
|
||||||
|
}
|
||||||
Vendored
+101
@@ -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
|
||||||
|
}
|
||||||
Vendored
+264
@@ -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
|
||||||
|
}
|
||||||
Vendored
+84
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">•</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.
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})()
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user