This commit is contained in:
tiltowait
2025-10-19 22:30:53 -07:00
parent 4810c6e224
commit 582dea4525
7 changed files with 240 additions and 118 deletions

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright (c) 2025 tiltowait
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.

34
checks/checks.go Normal file
View File

@ -0,0 +1,34 @@
// checks has various validators.
package checks
import (
"net/url"
"os"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// IsValidURL returns true if the string is a valid URL with http or https scheme.
func IsValidURL(urlStr string) bool {
u, err := url.Parse(urlStr)
if err != nil {
return false
}
// Ensure the URL has http/https scheme and a host
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// DirExists returns true if the given directory exists.
func DirExists(dir string) bool {
info, err := os.Stat(dir)
if err != nil {
return false
}
return info.IsDir()
}
// IsValidObjectId returns true if the string represents a valid ObjectId.
func IsValidObjectId(oid string) bool {
_, err := primitive.ObjectIDFromHex(oid)
return err == nil
}

67
cmd/root.go Normal file
View File

@ -0,0 +1,67 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"errors"
"fmt"
"log/slog"
"os"
"github.com/spf13/cobra"
"image-processor/checks"
"image-processor/routes"
)
var (
port int
imagesDir string
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "image-processor <domain>",
Short: "API for managing character profile images.",
Long: `A web API for uploading and deleting character images uploaded to Discord.
Images are converted to WebP before being saved to local storage, inside a
sub-directory of guildId/userId/charId/imageId.webp, with imageId being a
BSON ObjectId.
*THIS API TAKES NO AUTHENTICATION!* It is recommended to run it in a jail
without an internet connection.`,
Args: cobra.MatchAll(
cobra.ExactArgs(1),
func(cmd *cobra.Command, args []string) error {
if !checks.IsValidURL(args[0]) {
return errors.New("domain must be a valid URL (e.g., https://example.com)")
}
if !checks.DirExists(imagesDir) {
return fmt.Errorf("images-dir does not exist: %s", imagesDir)
}
return nil
},
),
Run: func(cmd *cobra.Command, args []string) {
domain := args[0]
slog.Info("Starting images-processor", "imagesDir", imagesDir, "domain", domain)
routes.Run(domain, imagesDir, port)
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Define command-line flags
rootCmd.Flags().IntVar(&port, "port", 8080, "Port to run the server on")
rootCmd.Flags().StringVar(&imagesDir, "images-dir", "images", "Directory to store images")
}

3
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@ -25,6 +26,8 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect

8
go.sum
View File

@ -4,6 +4,7 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
@ -27,6 +28,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@ -46,6 +49,11 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

124
main.go
View File

@ -1,123 +1,11 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package main
import (
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson/primitive"
"image-processor/convert"
)
const (
ImagesDir = "./images"
Port = 8080
)
type UploadRequest struct {
Guild int `json:"guild"`
User int `json:"user"`
CharID string `json:"charid"`
ImageURL string `json:"image_url"`
}
func imagesDirExists() bool {
info, err := os.Stat(ImagesDir)
if err != nil {
return false
}
return info.IsDir()
}
func setupRouter() *gin.Engine {
r := gin.Default()
r.SetTrustedProxies(nil)
r.POST("/image/upload", handleImageUpload)
return r
}
func handleImageUpload(c *gin.Context) {
var request UploadRequest
if err := c.BindJSON(&request); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slog.Info("Image upload request", "user", request.User, "guild", request.Guild, "charId", request.CharID)
// Validate URL
if !isValidURL(request.ImageURL) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid image URL"})
return
}
// Download image data
resp, err := http.Get(request.ImageURL)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
imageData, err := io.ReadAll(resp.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("Downloaded image data", "url", request.ImageURL)
imageName, err := prepImageName(request)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
saveLoc := strings.Join([]string{ImagesDir, imageName}, "/")
err = convert.SaveWebP(imageData, saveLoc, 90)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, imageName)
}
func isValidURL(urlStr string) bool {
u, err := url.Parse(urlStr)
if err != nil {
return false
}
// Ensure the URL has a scheme (http/https) and a host
return u.Scheme != "" && u.Host != ""
}
func isValidObjectId(oid string) bool {
_, err := primitive.ObjectIDFromHex(oid)
return err == nil
}
func prepImageName(r UploadRequest) (string, error) {
if !isValidObjectId(r.CharID) {
return "", fmt.Errorf("%s is not a valid character ID", r.CharID)
}
guild := fmt.Sprint(r.Guild)
user := fmt.Sprint(r.User)
charId := fmt.Sprint(r.CharID)
imageName := fmt.Sprintf("%s.webp", primitive.NewObjectID().Hex())
return strings.Join([]string{guild, user, charId, imageName}, "/"), nil
}
import "image-processor/cmd"
func main() {
if !imagesDirExists() {
log.Fatalf("%s does not exist. Please create and mount the write-only nullfs.", ImagesDir)
}
r := setupRouter()
r.Run(fmt.Sprintf(":%d", Port))
cmd.Execute()
}

115
routes/routes.go Normal file
View File

@ -0,0 +1,115 @@
package routes
import (
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson/primitive"
"image-processor/checks"
"image-processor/convert"
)
type Config struct {
ImagesDir string
Domain string
}
type UploadRequest struct {
Guild int `json:"guild"`
User int `json:"user"`
CharID string `json:"charid"`
ImageURL string `json:"image_url"`
}
func setupRouter(cfg *Config) *gin.Engine {
r := gin.Default()
r.SetTrustedProxies(nil)
r.POST("/image/upload", func(c *gin.Context) {
handleImageUpload(c, cfg)
})
return r
}
func handleImageUpload(c *gin.Context, cfg *Config) {
var request UploadRequest
if err := c.BindJSON(&request); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slog.Info("Image upload request", "user", request.User, "guild", request.Guild, "charId", request.CharID)
if !checks.IsValidURL(request.ImageURL) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid image URL"})
return
}
// Download the image from the remote w/ timeout and size limit
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Get(request.ImageURL)
if err != nil || resp.StatusCode != http.StatusOK {
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"error": "failed to download image"})
return
}
defer resp.Body.Close()
// Discord Nitro users can upload up to 500MB, but anyone doing that with
// images is clearly insane, and we're being generous with a 100MB limit.
limitedReader := io.LimitReader(resp.Body, 100*1024*1024)
imageData, err := io.ReadAll(limitedReader)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("Downloaded image data", "url", request.ImageURL)
// We need the image name and the save location separately so we can construct
// the URL to return to the user.
imageName, err := prepImageName(request)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
saveLoc := strings.Join([]string{cfg.ImagesDir, imageName}, "/")
err = convert.SaveWebP(imageData, saveLoc, 90)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// The web URL doesn't include the images directory. That way, we can place
// the images at root, e.g. https://example.com/guildId/userId/charId/imageId.webp
webRoot := strings.Trim(cfg.Domain, "/")
url := strings.Join([]string{webRoot, imageName}, "/")
c.JSON(http.StatusCreated, url)
}
func prepImageName(r UploadRequest) (string, error) {
if !checks.IsValidObjectId(r.CharID) {
return "", fmt.Errorf("%s is not a valid character ID", r.CharID)
}
guild := fmt.Sprint(r.Guild)
user := fmt.Sprint(r.User)
charId := fmt.Sprint(r.CharID)
imageName := fmt.Sprintf("%s.webp", primitive.NewObjectID().Hex())
return strings.Join([]string{guild, user, charId, imageName}, "/"), nil
}
func Run(domain, imagesDir string, port int) {
cfg := &Config{
ImagesDir: imagesDir,
Domain: domain,
}
r := setupRouter(cfg)
r.Run(fmt.Sprintf(":%d", port))
}