mirror of
https://github.com/the-second-city/faceclaimer.git
synced 2025-10-29 03:56:02 -07:00
Cobra
This commit is contained in:
7
LICENSE
Normal file
7
LICENSE
Normal 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
34
checks/checks.go
Normal 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
67
cmd/root.go
Normal 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
3
go.mod
@ -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
8
go.sum
@ -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
124
main.go
@ -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
115
routes/routes.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user