rc-1
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
DO $MAIN$
|
||||
DECLARE _VERSION INTEGER;
|
||||
BEGIN
|
||||
/*
|
||||
* FETCH SCHEMA VERSION
|
||||
* Migration information is stored in the database using this nifty bit of
|
||||
* logic. Please note that there is no rollback procedure so make sure you
|
||||
* test as much as possible on your dev machine, thanks. @_@
|
||||
*/
|
||||
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'kvs' AND table_schema = 'public') THEN
|
||||
CREATE TABLE kvs (
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT value::INTEGER INTO _VERSION FROM kvs WHERE key = 'gifuu_version';
|
||||
IF (SELECT _VERSION IS NULL) THEN
|
||||
INSERT INTO kvs VALUES ('gifuu_updated', CURRENT_TIMESTAMP::TEXT);
|
||||
INSERT INTO kvs VALUES ('gifuu_version', 0);
|
||||
_VERSION := 0;
|
||||
END IF;
|
||||
|
||||
/*
|
||||
* Version: 1.0.0
|
||||
* Name: Initial Release
|
||||
* Description: Initialize Database for Initial Release
|
||||
*/
|
||||
IF (SELECT _VERSION < 1) THEN
|
||||
_VERSION := 1;
|
||||
RAISE NOTICE 'Upgrading to Version %', _VERSION;
|
||||
|
||||
-- INITIALIZATION --
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'gifuu_backend') THEN
|
||||
DROP OWNED BY gifuu_backend CASCADE;
|
||||
DROP ROLE gifuu_backend;
|
||||
END IF;
|
||||
|
||||
DROP SCHEMA IF EXISTS gifuu CASCADE;
|
||||
CREATE SCHEMA gifuu;
|
||||
|
||||
-- TABLES --
|
||||
CREATE TABLE gifuu.upload (
|
||||
id BIGINT NOT NULL PRIMARY KEY, -- Upload ID
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
upload_address_hash TEXT NOT NULL, -- Relevant IP Address
|
||||
upload_token_hash TEXT NOT NULL, -- Relevant Edit Token
|
||||
flag_sticker BOOLEAN NOT NULL, -- Is Sticker?
|
||||
flag_audio BOOLEAN NOT NULL, -- Has Audio?
|
||||
flag_bypass BOOLEAN NOT NULL DEFAULT FALSE, -- Ignore Reports?
|
||||
encode_fps INT NOT NULL, -- Output FPS
|
||||
encode_width INT NOT NULL, -- Output Width
|
||||
encode_height INT NOT NULL, -- Output Height
|
||||
meta_rating REAL NOT NULL, -- Model Safety Rating
|
||||
meta_title TEXT NOT NULL -- User Provided Title
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.tag (
|
||||
id BIGSERIAL NOT NULL PRIMARY KEY, -- Tag ID
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
label TEXT NOT NULL UNIQUE, -- Tag Name
|
||||
usage INT NOT NULL DEFAULT 0 -- Tag Usage
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.upload_tag (
|
||||
gif_id BIGINT REFERENCES gifuu.upload (id) ON DELETE CASCADE, -- Relevant GIF ID
|
||||
tag_id BIGINT REFERENCES gifuu.tag (id) ON DELETE CASCADE, -- Relevant Tag ID
|
||||
PRIMARY KEY(gif_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.mod_key (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY, -- Moderator Token
|
||||
label TEXT NOT NULL -- Moderator Name / Label
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.mod_banned (
|
||||
address_hash TEXT NOT NULL PRIMARY KEY, -- Relevant IP Address
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
reason TEXT -- Moderator Note
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.mod_report (
|
||||
id BIGSERIAL NOT NULL PRIMARY KEY, -- Report ID
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
upload_id BIGINT NOT NULL, -- Relevant GIF ID
|
||||
report_address_hash TEXT NOT NULL, -- Relevant IP Address
|
||||
reason_type INT NOT NULL, -- Report Type
|
||||
reason_text TEXT NOT NULL, -- Report Text
|
||||
FOREIGN KEY (upload_id) REFERENCES gifuu.upload (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- INDEXES --
|
||||
CREATE INDEX idx_tag_usage_popular ON gifuu.tag (usage DESC);
|
||||
CREATE INDEX gin_tag_metadata ON gifuu.tag USING GIN (label gin_trgm_ops);
|
||||
CREATE INDEX idx_upload_tag_gif ON gifuu.upload_tag (gif_id);
|
||||
CREATE INDEX idx_upload_tag_tag ON gifuu.upload_tag (tag_id);
|
||||
CREATE INDEX idx_upload_created ON gifuu.upload (created DESC);
|
||||
|
||||
-- TRIGGERS --
|
||||
CREATE OR REPLACE FUNCTION gifuu.update_tag_usage() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE gifuu.tag SET usage = usage + 1 WHERE id = NEW.tag_id;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE gifuu.tag SET usage = usage - 1 WHERE id = OLD.tag_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER gifuu_tag_usage_insert
|
||||
AFTER INSERT ON gifuu.upload_tag
|
||||
FOR EACH ROW EXECUTE FUNCTION gifuu.update_tag_usage();
|
||||
|
||||
CREATE TRIGGER gifuu_tag_usage_delete
|
||||
AFTER DELETE ON gifuu.upload_tag
|
||||
FOR EACH ROW EXECUTE FUNCTION gifuu.update_tag_usage();
|
||||
|
||||
-- USERS --
|
||||
CREATE ROLE gifuu_backend LOGIN NOINHERIT;
|
||||
GRANT USAGE ON SCHEMA gifuu TO gifuu_backend;
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA gifuu TO gifuu_backend;
|
||||
GRANT ALL ON ALL SEQUENCES IN SCHEMA gifuu TO gifuu_backend;
|
||||
|
||||
END IF;
|
||||
|
||||
/*
|
||||
* HOUSEKEEPING
|
||||
* Uses the "pg_cron" extension to enable automated maintenance without
|
||||
* requiring the use of an external service, see installation guide here:
|
||||
* https://github.com/citusdata/pg_cron#installing-pg_cron
|
||||
*/
|
||||
CREATE OR REPLACE PROCEDURE gifuu_reschedule (
|
||||
_SCHEDULE TEXT,
|
||||
_NAME TEXT,
|
||||
_COMMAND TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
BEGIN
|
||||
-- Task Scheduling --
|
||||
IF NOT EXISTS (SELECT FROM pg_available_extensions WHERE name = 'pg_cron') THEN
|
||||
RAISE WARNING 'Extension "pg_cron" is unavailable, command "%" unscheduled.', _NAME;
|
||||
ELSE
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
IF EXISTS (SELECT FROM cron.job WHERE jobname = _NAME) THEN
|
||||
PERFORM cron.unschedule(_NAME);
|
||||
END IF;
|
||||
|
||||
IF _COMMAND IS NOT NULL THEN
|
||||
PERFORM cron.schedule(_NAME, _SCHEDULE, _COMMAND);
|
||||
RAISE NOTICE 'Scheduled "%" (%)', _NAME, _SCHEDULE;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Test Command --
|
||||
IF _COMMAND IS NOT NULL THEN
|
||||
RAISE NOTICE 'Testing command for task "%"', _NAME;
|
||||
EXECUTE _COMMAND;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CALL gifuu_reschedule('0 0 * * *', 'gifuu:delete_unused_tags', $$ DELETE FROM gifuu.tag WHERE usage = 0; $$);
|
||||
|
||||
/*
|
||||
* UPDATE SCHEMA VERSION
|
||||
* Disabled in development to make iterative changes less annoying. Use the
|
||||
* following query to enter production mode and make changes permanent:
|
||||
*
|
||||
* INSERT INTO kvs VALUES ('gifuu_production', 'true');
|
||||
*/
|
||||
IF EXISTS (SELECT FROM kvs WHERE key = 'gifuu_production') THEN
|
||||
RAISE NOTICE 'Mode: Production';
|
||||
UPDATE kvs SET value = _VERSION WHERE key = 'gifuu_version';
|
||||
UPDATE kvs SET value = CURRENT_TIMESTAMP::TEXT WHERE key = 'gifuu_updated';
|
||||
ELSE
|
||||
RAISE NOTICE 'Mode: Development';
|
||||
END IF;
|
||||
|
||||
END $MAIN$;
|
||||
@@ -0,0 +1,16 @@
|
||||
module gifuu
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/yalue/onnxruntime_go v1.27.0
|
||||
golang.org/x/sync v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
|
||||
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="theme-color" content="#a0a0a0" />
|
||||
<title>{{ .title }} - gifuu</title>
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ .uri_site }}">
|
||||
<meta property="og:title" content="{{ .title }}">
|
||||
<meta property="og:description" content="{{ .tags }}">
|
||||
<meta property="og:site_name" content="gifuu">
|
||||
|
||||
<meta property="og:image" content="{{ .uri_image }}">
|
||||
<meta property="og:image:width" content="{{ .width }}">
|
||||
<meta property="og:image:height" content="{{ .height }}">
|
||||
|
||||
<meta property="og:video" content="{{ .uri_embed }}">
|
||||
<meta property="og:video:type" content="text/html">
|
||||
<meta property="og:video:width" content="{{ .width }}">
|
||||
<meta property="og:video:height" content="{{ .height }}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .title }}">
|
||||
<meta name="twitter:description" content="{{ .tags }}">
|
||||
<meta name="twitter:image" content="{{ .uri_image }}">
|
||||
</head>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
package include
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed nsfw.onnx
|
||||
var MODEL_NSFW []byte
|
||||
|
||||
//go:embed ANIMATION_METADATA.html
|
||||
var TEMPLATE_ANIMATION_METADATA string
|
||||
Binary file not shown.
+199
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gifuu/routes"
|
||||
"gifuu/tools"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
time.Local = time.UTC
|
||||
|
||||
// Startup Services
|
||||
var stopCtx, stop = context.WithCancel(context.Background())
|
||||
var stopWg sync.WaitGroup
|
||||
var syncWg sync.WaitGroup
|
||||
|
||||
tools.LoggerInit.Log(tools.INFO, "Starting Services")
|
||||
for _, fn := range []func(stop context.Context, await *sync.WaitGroup){
|
||||
tools.SetupDatabase,
|
||||
tools.SetupModel,
|
||||
} {
|
||||
syncWg.Add(1)
|
||||
go func() {
|
||||
defer syncWg.Done()
|
||||
fn(stopCtx, &stopWg)
|
||||
}()
|
||||
}
|
||||
syncWg.Wait()
|
||||
go StartupHTTP(stopCtx, &stopWg)
|
||||
|
||||
// Await Shutdown Signal
|
||||
cancel := make(chan os.Signal, 1)
|
||||
signal.Notify(cancel, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-cancel
|
||||
stop()
|
||||
|
||||
// Begin Shutdown Process
|
||||
tools.LoggerInit.Log(tools.WARN, "Shutting Down!")
|
||||
timeout, finish := context.WithTimeout(context.Background(), tools.TIMEOUT_CONTEXT)
|
||||
defer finish()
|
||||
go func() {
|
||||
<-timeout.Done()
|
||||
if timeout.Err() == context.DeadlineExceeded {
|
||||
tools.LoggerInit.Log(tools.FATAL, "Shutdown Deadline Exceeded")
|
||||
return
|
||||
}
|
||||
}()
|
||||
stopWg.Wait()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func SetupMux() http.HandlerFunc {
|
||||
|
||||
var (
|
||||
mux = http.NewServeMux()
|
||||
limitPublic = tools.NewRatelimiter("PUBLIC", 900, 5*time.Minute)
|
||||
limitDelete = tools.NewRatelimiter("DELETE", 100, 5*time.Minute)
|
||||
limitStart = tools.NewRatelimiter("START", 100, 5*time.Minute)
|
||||
limitCreate = tools.NewRatelimiter("CREATE", 50, 5*time.Minute)
|
||||
limitModerators = tools.NewRatelimiter("MOD", 300, 5*time.Minute)
|
||||
gatekeepModerator = tools.NewGatekeeper(true)
|
||||
powExpensive = tools.NewChallenger(20)
|
||||
powNormal = tools.NewChallenger(18)
|
||||
)
|
||||
|
||||
// General
|
||||
mux.Handle("/uploads", tools.MethodHandler{
|
||||
http.MethodPost: tools.Chain(routes.POST_Uploads, limitCreate, powExpensive),
|
||||
})
|
||||
mux.Handle("/tags/popular", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Tags_Popular, limitPublic),
|
||||
})
|
||||
mux.Handle("/tags/autocomplete", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Tags_Autocomplete, limitPublic),
|
||||
})
|
||||
mux.Handle("/art/latest", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Art_Latest, limitPublic),
|
||||
})
|
||||
mux.Handle("/art/search", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Art_Search, limitPublic),
|
||||
})
|
||||
mux.Handle("/art/{id}", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Art_ID, limitPublic),
|
||||
http.MethodDelete: tools.Chain(routes.DELETE_Art_ID, limitDelete),
|
||||
})
|
||||
mux.Handle("/art/{id}/report", tools.MethodHandler{
|
||||
http.MethodPost: tools.Chain(routes.POST_Art_ID_Reports, limitCreate, powNormal),
|
||||
})
|
||||
mux.Handle("/metadata/{id}", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Metadata_ID, limitPublic),
|
||||
})
|
||||
|
||||
// Moderation
|
||||
mux.Handle("/moderation/art/{id}", tools.MethodHandler{
|
||||
http.MethodDelete: tools.Chain(routes.DELETE_Moderation_Art_ID, limitModerators, gatekeepModerator),
|
||||
})
|
||||
// mux.Handle("/moderation/art/{id}/rating", tools.MethodHandler{
|
||||
// // TODO: Sets the items moderation to 100% hiding it from the public
|
||||
// http.MethodPatch: tools.Chain(routes.PATCH_Moderation_Art_ID_Rating, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
// mux.Handle("/moderation/art/{id}/bypass", tools.MethodHandler{
|
||||
// // TODO: Disables Reporting for the item by setting the 'flag_bypass' flag
|
||||
// http.MethodPatch: tools.Chain(routes.PATCH_Moderation_Art_ID_Bypass, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
// mux.Handle("/moderation/art/{id}/reports", tools.MethodHandler{
|
||||
// // TODO: Get Latest Reports for an Item
|
||||
// http.MethodGet: tools.Chain(routes.GET_Moderation_Art_ID_Reports, limitModerators, gatekeepModerator),
|
||||
// // TODO: Delete all Reports for an Item
|
||||
// http.MethodDelete: tools.Chain(routes.DELETE_Moderation_Art_ID_Reports, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
// // TODO: Get the Latest Reports across Site
|
||||
// mux.Handle("/moderation/latest", tools.MethodHandler{
|
||||
// http.MethodGet: tools.Chain(routes.GET_Moderation_Latest, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
|
||||
// Other
|
||||
mux.Handle("/challenge", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Challenge, limitStart),
|
||||
})
|
||||
mux.Handle("/limits", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Limits, limitPublic),
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
tools.SendClientError(w, r, tools.ERROR_UNKNOWN_ENDPOINT)
|
||||
})
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Inject CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Challenge-Nonce, X-Challenge-Counter")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func StartupHTTP(stop context.Context, await *sync.WaitGroup) {
|
||||
var listener net.Listener
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(tools.HTTP_ADDRESS, "unix/") {
|
||||
path := strings.TrimPrefix(tools.HTTP_ADDRESS, "unix/")
|
||||
os.Remove(path)
|
||||
listener, err = net.Listen("unix", path)
|
||||
if err == nil {
|
||||
os.Chmod(path, 0660)
|
||||
}
|
||||
} else {
|
||||
listener, err = net.Listen("tcp", tools.HTTP_ADDRESS)
|
||||
}
|
||||
if err != nil {
|
||||
tools.LoggerHTTP.Log(tools.FATAL, "Listen failed: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
svr := http.Server{
|
||||
Handler: SetupMux(),
|
||||
MaxHeaderBytes: 4096,
|
||||
IdleTimeout: 10 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Shutdown Logic
|
||||
await.Add(1)
|
||||
go func() {
|
||||
defer await.Done()
|
||||
<-stop.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), tools.TIMEOUT_SHUTDOWN)
|
||||
defer cancel()
|
||||
|
||||
if err := svr.Shutdown(shutdownCtx); err != nil {
|
||||
tools.LoggerHTTP.Log(tools.ERROR, "Shutdown error: %s", err)
|
||||
}
|
||||
|
||||
tools.LoggerHTTP.Log(tools.INFO, "Closed")
|
||||
}()
|
||||
|
||||
tools.LoggerHTTP.Log(tools.INFO, "Listening @ %s", tools.HTTP_ADDRESS)
|
||||
if err := svr.Serve(listener); err != http.ErrServerClosed {
|
||||
tools.LoggerHTTP.Log(tools.FATAL, "Startup Failed: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func DELETE_Art_ID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramToken := query.Get("token")
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramToken == "" || paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete Art
|
||||
tag, err := tools.Database.Exec(ctx,
|
||||
`DELETE FROM gifuu.upload WHERE id = $1 AND upload_token_hash = $2`,
|
||||
paramID,
|
||||
tools.RequestHash(paramToken),
|
||||
)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_GENERIC_UNAUTHORIZED)
|
||||
return
|
||||
}
|
||||
|
||||
go RemoveFilesForArt(paramID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func RemoveFilesForArt(artID int64) {
|
||||
normal := strconv.FormatInt(artID, 10)
|
||||
target := path.Join(tools.STORAGE_DISK_PUBLIC, normal)
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
tools.LoggerStorage.Log(tools.WARN, "Failed to delete directory '%s': %s", target, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func DELETE_Moderation_Art_ID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete Art
|
||||
tag, err := tools.Database.Exec(ctx,
|
||||
`DELETE FROM gifuu.upload WHERE id = $1`,
|
||||
paramID,
|
||||
)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_GENERIC_UNAUTHORIZED)
|
||||
return
|
||||
}
|
||||
|
||||
go RemoveFilesForArt(paramID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func GET_Art_ID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(r.Context(),
|
||||
`SELECT jsonb_build_object(
|
||||
'id', u.id::text,
|
||||
'created', u.created::timestamptz,
|
||||
'sticker', u.flag_sticker,
|
||||
'audio', u.flag_audio,
|
||||
'framerate', u.encode_fps,
|
||||
'width', u.encode_width,
|
||||
'height', u.encode_height,
|
||||
'rating', u.meta_rating,
|
||||
'title', u.meta_title,
|
||||
'tags', COALESCE(
|
||||
jsonb_agg(
|
||||
CASE WHEN t.id IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', t.id::text,
|
||||
'label', t.label,
|
||||
'usage', t.usage
|
||||
)
|
||||
END
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'
|
||||
)
|
||||
)
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id`,
|
||||
paramID,
|
||||
).Scan(&Results)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
tools.SendClientError(w, r, tools.ERROR_UNKNOWN_ANIMATION)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Art_Latest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
paramAfter := tools.ParseSnowflake(query.Get("after"))
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t.row), '[]') FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id::text,
|
||||
'created', u.created::timestamptz,
|
||||
'sticker', u.flag_sticker,
|
||||
'audio', u.flag_audio,
|
||||
'framerate', u.encode_fps,
|
||||
'width', u.encode_width,
|
||||
'height', u.encode_height,
|
||||
'rating', u.meta_rating,
|
||||
'title', u.meta_title,
|
||||
'tags', COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id::text,
|
||||
'label', t.label,
|
||||
'usage', t.usage
|
||||
)
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'
|
||||
)
|
||||
) AS row
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE ($1::bigint = 0 OR u.id < $1::bigint) AND u.meta_rating < $3
|
||||
GROUP BY u.id
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $2
|
||||
) t`,
|
||||
paramAfter,
|
||||
paramLimit,
|
||||
tools.MODEL_THRESHOLD_HIDE,
|
||||
).Scan(&Results)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Art_Search(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
paramAfter := tools.ParseSnowflake(query.Get("after"))
|
||||
var paramTags []int64
|
||||
|
||||
if params, ok := query["tag"]; ok {
|
||||
paramTags = make([]int64, 0, len(params))
|
||||
indexTags := make(map[int64]struct{}, len(params))
|
||||
for _, raw := range params {
|
||||
id := tools.ParseSnowflake(raw)
|
||||
if _, exists := indexTags[id]; exists || id == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
indexTags[id] = struct{}{}
|
||||
paramTags = append(paramTags, id)
|
||||
}
|
||||
}
|
||||
if len(paramTags) == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_EMPTY)
|
||||
return
|
||||
}
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t.obj), '[]') FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id::text,
|
||||
'created', u.created::timestamptz,
|
||||
'sticker', u.flag_sticker,
|
||||
'audio', u.flag_audio,
|
||||
'framerate', u.encode_fps,
|
||||
'width', u.encode_width,
|
||||
'height', u.encode_height,
|
||||
'rating', u.meta_rating,
|
||||
'title', u.meta_title,
|
||||
'tags', COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id::text,
|
||||
'label', t.label,
|
||||
'usage', t.usage
|
||||
)
|
||||
ORDER BY t.usage DESC
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'
|
||||
)
|
||||
) AS obj
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE ut.tag_id = ANY($1::bigint[])
|
||||
AND ($2::bigint = 0 OR u.id < $2::bigint)
|
||||
AND u.meta_rating < $4
|
||||
GROUP BY u.id
|
||||
HAVING COUNT(DISTINCT ut.tag_id) = cardinality($1::bigint[])
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $3::int
|
||||
) t`,
|
||||
paramTags,
|
||||
paramAfter,
|
||||
paramLimit,
|
||||
tools.MODEL_THRESHOLD_HIDE,
|
||||
).Scan(&Results)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GET_Challenge(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
paramDifficulty, _ := strconv.Atoi(query.Get("difficulty"))
|
||||
if paramDifficulty < 18 {
|
||||
tools.SendClientError(w, r, tools.ERROR_CHALLENGE_TOO_EASY)
|
||||
return
|
||||
}
|
||||
|
||||
// Create Session
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
normal := hex.EncodeToString(nonce)
|
||||
expiry := time.Now().Add(5 * time.Minute).Unix()
|
||||
|
||||
// Store Session
|
||||
tools.ChallengeAtomic.Lock()
|
||||
tools.ChallengeSession[normal] = tools.ChallengeSessionData{
|
||||
Expires: expiry,
|
||||
Difficulty: paramDifficulty,
|
||||
}
|
||||
tools.ChallengeAtomic.Unlock()
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, map[string]any{
|
||||
"nonce": normal,
|
||||
"difficulty": paramDifficulty,
|
||||
"expires": expiry,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
REPORT_REASON_EXPLICIT = 0 // Sexual, Nudity, or Fetish Content
|
||||
REPORT_REASON_HARASSMENT = 1 // Targeted Harassment or Hate Speech
|
||||
REPORT_REASON_VIOLENCE = 2 // Violence, Gore, or Abuse
|
||||
REPORT_REASON_SPAM = 3 // Spam, Advertising, or Solicitation
|
||||
REPORT_REASON_HARMFUL = 4 // Seizure-Inducing, Self-Harm, or Dangerous Content
|
||||
REPORT_REASON_ILLEGAL = 5 // Illegal Content (CSAM, Threats, etc.)
|
||||
)
|
||||
|
||||
type normalizerItem struct {
|
||||
Match string `json:"match"`
|
||||
Replace string `json:"replace"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
var cachedLimitsJSON []byte
|
||||
var cachedLimitsGZIP []byte
|
||||
|
||||
func init() {
|
||||
json, gzip, err := tools.PrepareStaticJSON(map[string]any{
|
||||
"upload": map[string]any{
|
||||
"input_width_min": MEDIA_MIN_WIDTH,
|
||||
"input_height_min": MEDIA_MIN_HEIGHT,
|
||||
"video_width_max": VIDEO_MAX_WIDTH,
|
||||
"video_height_max": VIDEO_MAX_HEIGHT,
|
||||
"image_width_max": IMAGE_MAX_WIDTH,
|
||||
"image_height_max": IMAGE_MAX_HEIGHT,
|
||||
"duration": MEDIA_MAX_DURATION,
|
||||
"filesize": tools.LIMIT_FILE,
|
||||
"mime_types": tools.LIMIT_MIME_TYPE,
|
||||
},
|
||||
"title": map[string]any{
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^\s+` /*---*/, `` /**/, "Trim Left"},
|
||||
{ /**/ `\s+$` /*---*/, `` /**/, "Trim Right"},
|
||||
{ /**/ `\s{2,}` /**/, ` ` /**/, "Regulate Excessive Spaces"},
|
||||
},
|
||||
"matcher": `^[\S\s]{1,80}$`,
|
||||
"max_length": 80,
|
||||
"min_length": 1,
|
||||
},
|
||||
"tag": map[string]any{
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^_+` /*-*/, `` /*-*/, "Trim Left Underscores"},
|
||||
{ /**/ `_+$` /*-*/, `` /*-*/, "Trim Right Underscores"},
|
||||
{ /**/ `_+` /*--*/, `_` /**/, "Regulate Excessive Underscores"},
|
||||
},
|
||||
"matcher": `^[\p{L}\p{N}_]{1,32}$`,
|
||||
"max_length": 32,
|
||||
"min_length": 1,
|
||||
},
|
||||
"comment": map[string]any{
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^\s+` /*--*/, `` /*--*/, "Trim Left Spaces"},
|
||||
{ /**/ `\s+$` /*--*/, `` /*--*/, "Trim Right Spaces"},
|
||||
{ /**/ `\n{2,}` /**/, `\n` /**/, "Regulate Excessive Newlines"},
|
||||
},
|
||||
"matcher": `^[\S\s]{10,240}$`,
|
||||
"max_length": 240,
|
||||
"min_length": 10,
|
||||
},
|
||||
"report": map[string]any{
|
||||
"values": []any{
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_EXPLICIT,
|
||||
"title": "EXPLICIT",
|
||||
"description": "Sexual, Nudity, or Fetish Content"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_HARASSMENT,
|
||||
"title": "HARASSMENT",
|
||||
"description": "Targeted Harassment or Hate Speech"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_VIOLENCE,
|
||||
"title": "VIOLENCE",
|
||||
"description": "Violence, Gore, or Abuse"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_SPAM,
|
||||
"title": "SPAM",
|
||||
"description": "Spam, Advertising, or Solicitation"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_HARMFUL,
|
||||
"title": "HARMFUL",
|
||||
"description": "Seizure-Inducing, Self-Harm, or Dangerous Content"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_ILLEGAL,
|
||||
"title": "ILLEGAL",
|
||||
"description": "Illegal Content (CSAM, Threats, etc.)"},
|
||||
},
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^\s+` /*--*/, `` /*--*/, "Trim Left Spaces"},
|
||||
{ /**/ `\s+$` /*--*/, `` /*--*/, "Trim Right Spaces"},
|
||||
{ /**/ `\n{2,}` /**/, `\n` /**/, "Regulate Excessive Newlines"},
|
||||
},
|
||||
"matcher": `^[\S\s]{10,240}$`,
|
||||
"max_length": 240,
|
||||
"min_length": 10,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cachedLimitsJSON = json
|
||||
cachedLimitsGZIP = gzip
|
||||
}
|
||||
|
||||
func GET_Limits(w http.ResponseWriter, r *http.Request) {
|
||||
tools.SendStaticJSON(w, r, http.StatusOK, cachedLimitsJSON, cachedLimitsGZIP)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gifuu/include"
|
||||
"gifuu/tools"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var animTemplate = template.Must(template.New("").Parse(include.TEMPLATE_ANIMATION_METADATA))
|
||||
|
||||
func GET_Metadata_ID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
var animationData struct {
|
||||
Created string
|
||||
Width int
|
||||
Height int
|
||||
Rating float64
|
||||
Sticker bool
|
||||
Title string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Fetch Animation Metadata
|
||||
err := tools.Database.QueryRow(r.Context(),
|
||||
`SELECT
|
||||
u.encode_width,
|
||||
u.encode_height,
|
||||
u.meta_rating,
|
||||
u.flag_sticker,
|
||||
u.meta_title,
|
||||
COALESCE(array_agg(t.label ORDER BY t.usage) FILTER (WHERE t.id IS NOT NULL), '{}')
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id`,
|
||||
paramID,
|
||||
).Scan(
|
||||
&animationData.Width,
|
||||
&animationData.Height,
|
||||
&animationData.Rating,
|
||||
&animationData.Sticker,
|
||||
&animationData.Title,
|
||||
&animationData.Tags,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
tools.SendClientError(w, r, tools.ERROR_UNKNOWN_ANIMATION)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Render Webpage
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
err = animTemplate.Execute(w, map[string]any{
|
||||
"width": animationData.Width,
|
||||
"height": animationData.Height,
|
||||
"title": html.EscapeString(animationData.Title),
|
||||
"tags": html.EscapeString(strings.Join(animationData.Tags, ", ")),
|
||||
"uri_embed": fmt.Sprintf("%s/embed.html?id=%d&quality=standard", tools.TEMPLATE_BASE_WEB, paramID),
|
||||
"uri_image": fmt.Sprintf("%s/%d/standard.avif", tools.TEMPLATE_BASE_CDN, paramID),
|
||||
"uri_site": fmt.Sprintf("%s/art/%d", tools.TEMPLATE_BASE_WEB, paramID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Render Error:", r.URL.Path, err)
|
||||
http.Error(w, "Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Tags_Autocomplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
paramQuery := query.Get("query")
|
||||
|
||||
if str, ok := tools.NormalizeTag(paramQuery); !ok {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
} else {
|
||||
paramQuery = str
|
||||
}
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t), '[]') FROM (
|
||||
SELECT id::text AS id, label, usage
|
||||
FROM gifuu.tag
|
||||
ORDER BY word_similarity(label, $1) DESC, usage DESC
|
||||
LIMIT $2
|
||||
) t`,
|
||||
paramQuery,
|
||||
paramLimit,
|
||||
).Scan(&Results)
|
||||
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Tags_Popular(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t), '[]') FROM (
|
||||
SELECT id::text AS id, label, usage
|
||||
FROM gifuu.tag
|
||||
ORDER BY usage DESC
|
||||
LIMIT $1
|
||||
) t`,
|
||||
paramLimit,
|
||||
).Scan(&Results)
|
||||
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func POST_Art_ID_Reports(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var Body struct {
|
||||
ReasonType int `json:"type"`
|
||||
ReasonText string `json:"reason"`
|
||||
}
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tools.ParseJSON(r.Body, &Body); err != nil {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_DATA)
|
||||
return
|
||||
}
|
||||
|
||||
if Body.ReasonType < REPORT_REASON_EXPLICIT || Body.ReasonType > REPORT_REASON_ILLEGAL {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
if str, ok := tools.NormalizeComment(Body.ReasonText); !ok {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
} else {
|
||||
Body.ReasonText = str
|
||||
}
|
||||
|
||||
// Attempt to store the users report. It may be discarded if another moderator
|
||||
// has previously review this item and deemed it didn't violate any rules.
|
||||
|
||||
if _, err := tools.Database.Exec(ctx,
|
||||
`INSERT INTO gifuu.mod_report (upload_id, reason_type, reason_text, report_address_hash)
|
||||
SELECT $1, $2, $3, $4 FROM gifuu.upload WHERE id = $1 AND flag_bypass = FALSE`,
|
||||
paramID,
|
||||
Body.ReasonType,
|
||||
Body.ReasonText,
|
||||
tools.RequestAddressHash(r),
|
||||
); err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gifuu/tools"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
VIDEO_MAX_WIDTH = 3840
|
||||
VIDEO_MAX_HEIGHT = 2160
|
||||
IMAGE_MAX_WIDTH = 7680
|
||||
IMAGE_MAX_HEIGHT = 4320
|
||||
MEDIA_MIN_WIDTH = 64
|
||||
MEDIA_MIN_HEIGHT = 64
|
||||
MEDIA_MAX_DURATION = 62
|
||||
MEDIA_COLOR_SPACE = "yuv420p"
|
||||
MEDIA_COLOR_RANGE = "tv"
|
||||
MEDIA_FILENAME_PREVIEW = "preview.avif"
|
||||
MEDIA_FILENAME_STANDARD = "standard.avif"
|
||||
MEDIA_FILENAME_ALPHA = "alpha.webm"
|
||||
MEDIA_FILENAME_AUDIO = "standard.ogg"
|
||||
MEDIA_BACKGROUND = "#ffffff"
|
||||
VIDEO_ENCODE_CODEC = "libsvtav1"
|
||||
VIDEO_ENCODE_EFFORT = "7"
|
||||
VIDEO_ENCODE_PARAMS = "lp=2"
|
||||
VIDEO_FILTERS = ""
|
||||
VIDEO_KEYFRAME_INTERVAL = 6
|
||||
VIDEO_PREVIEW_MAX_DURATION = 8
|
||||
VIDEO_PREVIEW_FPS = 16
|
||||
VIDEO_PREVIEW_SIZE = 240
|
||||
VIDEO_PREVIEW_QUALITY = "55"
|
||||
VIDEO_STANDARD_FPS = 60
|
||||
VIDEO_STANDARD_SIZE = 720
|
||||
VIDEO_STANDARD_QUALITY = "50"
|
||||
IMAGE_ENCODE_CODEC = "libsvtav1"
|
||||
IMAGE_ENCODE_EFFORT = "7"
|
||||
IMAGE_ENCODE_QUALITY = "45"
|
||||
IMAGE_ENCODE_PARAMS = "lp=2"
|
||||
IMAGE_FILTERS = "tpad=stop_mode=clone:stop_duration=1,"
|
||||
IMAGE_LARGE_SIZE = 2160
|
||||
IMAGE_PREVIEW_SIZE = 240
|
||||
)
|
||||
|
||||
func ternary[T any](cond bool, a, b T) T {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func POST_Uploads(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// --- Update Request Lifetime ---
|
||||
sse, err := tools.NewEventHelper(ctx, w, r)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
}
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Prevent Abuse ] ----------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
var (
|
||||
ClientAddress = tools.RequestAddressHash(r)
|
||||
ClientToken = tools.RequestToken()
|
||||
ClientLength = max(0, r.ContentLength)
|
||||
)
|
||||
{
|
||||
// --- Request Upload Capacity ---
|
||||
if ClientLength == 0 {
|
||||
sse.SendClientError(tools.ERROR_BODY_EMPTY)
|
||||
return
|
||||
}
|
||||
if ClientLength > int64(tools.LIMIT_FILE+tools.LIMIT_JSON) {
|
||||
sse.SendClientError(tools.ERROR_BODY_TOO_LARGE)
|
||||
return
|
||||
}
|
||||
|
||||
if !tools.SEMA_UPLOADS.TryAcquire(ClientLength) {
|
||||
sse.SendClientError(tools.ERROR_SERVER_RESOURCES_EXHAUSTED)
|
||||
return
|
||||
}
|
||||
defer tools.SEMA_UPLOADS.Release(ClientLength)
|
||||
|
||||
// --- Check Ban List ---
|
||||
var SubjectCount int
|
||||
err := tools.Database.
|
||||
QueryRow(ctx, "SELECT COUNT(*) FROM gifuu.mod_banned WHERE address_hash = $1", ClientAddress).
|
||||
Scan(&SubjectCount)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
if SubjectCount > 0 {
|
||||
sse.SendClientError(tools.ERROR_GENERIC_FORBIDDEN)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Parse Incoming Request ] -------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
var (
|
||||
TempID = tools.RequestSnowflake()
|
||||
TempIDString = strconv.FormatInt(TempID, 10)
|
||||
TempSuccess = false
|
||||
TempUpload *os.File
|
||||
TempLogger *os.File
|
||||
Body struct {
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
PathUpload = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+".bin")
|
||||
PathLogger = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+".log")
|
||||
PathAlpha = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_ALPHA)
|
||||
PathPreview = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_PREVIEW)
|
||||
PathStandard = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_STANDARD)
|
||||
PathAudio = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_AUDIO)
|
||||
)
|
||||
{
|
||||
// --- Create Temporary Files ---
|
||||
flags := os.O_CREATE | os.O_TRUNC | os.O_WRONLY
|
||||
|
||||
if f, err := os.OpenFile(PathUpload, flags, tools.FILE_PUBLIC); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
} else {
|
||||
TempUpload = f
|
||||
defer os.Remove(PathUpload)
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
if f, err := os.OpenFile(PathLogger, flags, tools.FILE_PRIVATE); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
} else {
|
||||
TempLogger = f
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
defer os.Remove(PathAlpha)
|
||||
defer os.Remove(PathPreview)
|
||||
defer os.Remove(PathStandard)
|
||||
defer os.Remove(PathAudio)
|
||||
|
||||
// --- Parse Form Body ---
|
||||
var haveFile, haveData bool
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_DATA)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// --- Parse Incoming Metadata ---
|
||||
if part.FormName() == "data" {
|
||||
if haveData {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
haveData = true
|
||||
|
||||
if err := tools.ParseJSON(part, &Body); err != nil {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
break
|
||||
}
|
||||
|
||||
// Validate Struct
|
||||
if normal, ok := tools.NormalizeTitle(Body.Title); !ok {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
} else {
|
||||
Body.Title = normal
|
||||
}
|
||||
|
||||
indexTags := make(map[string]struct{}, len(Body.Tags))
|
||||
for i, given := range Body.Tags {
|
||||
normal, ok := tools.NormalizeTag(given)
|
||||
if _, exists := indexTags[normal]; exists || !ok {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
indexTags[normal] = struct{}{}
|
||||
Body.Tags[i] = normal
|
||||
}
|
||||
|
||||
fmt.Fprintf(TempLogger, "Collected JSON : %s\n", Body)
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Store Incoming Upload ---
|
||||
if part.FormName() == "file" {
|
||||
if haveFile {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
haveFile = true
|
||||
|
||||
// Check File Extension
|
||||
mediaAccept := false
|
||||
mediaType := part.Header.Get("Content-Type")
|
||||
for _, t := range tools.LIMIT_MIME_TYPE {
|
||||
if strings.EqualFold(t, mediaType) {
|
||||
mediaAccept = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mediaAccept {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_CONTENT_TYPE)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy File
|
||||
mediaSize, err := io.Copy(TempUpload, io.LimitReader(part, ClientLength))
|
||||
if err != nil {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_DATA)
|
||||
return
|
||||
}
|
||||
TempUpload.Close()
|
||||
|
||||
fmt.Fprintf(TempLogger,
|
||||
"Collected File : %s (Type: %s) (Size: %db)\n",
|
||||
mediaType, part.FileName(), mediaSize,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
if !haveFile || !haveData {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
sse.SendJSON("id", TempIDString)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Media Validation ] -------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
var (
|
||||
ProbeResults tools.ProbeResults // Probe Results
|
||||
ProbeVideo *tools.ProbeStream // Relevant Video Stream
|
||||
ProbeAudio *tools.ProbeStream // Relevant Audio Stream
|
||||
MediaSticker bool // Is Static?
|
||||
MediaFramerate int // Approximate Framerate
|
||||
MediaHeight int // Approximate Scaled Height
|
||||
MediaWidth int // Approximate Scaled Width
|
||||
MediaRating float32 // Worst value from Classification
|
||||
)
|
||||
{
|
||||
sse.SendJSON("step", map[string]any{"id": "PROBE_QUEUE", "message": "Queued for Probing"})
|
||||
t := time.Now()
|
||||
|
||||
// --- Acquire Probe Slot ---
|
||||
if err := tools.SEMA_PROBES.Acquire(ctx, 1); err != nil {
|
||||
return
|
||||
}
|
||||
defer tools.SEMA_PROBES.Release(1)
|
||||
|
||||
// --- Probe Media Stream ---
|
||||
sse.SendJSON("step", map[string]any{"id": "PROBE_START", "message": "Probing"})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||
"-hide_banner",
|
||||
"-loglevel", "verbose",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-i", PathUpload,
|
||||
)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
fmt.Fprintf(TempLogger, "\n%s\n%s\nProbing completed in %s\n\n",
|
||||
// extra newline for proper log padding
|
||||
stdout.String(),
|
||||
stderr.String(),
|
||||
time.Since(t),
|
||||
)
|
||||
|
||||
if ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &ProbeResults); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Find Media Stream ---
|
||||
// isVideo: Only video streams (AV1, MP4, etc.)
|
||||
// isImage: Only select image streams if it's the only stream available
|
||||
for _, s := range ProbeResults.Streams {
|
||||
isVideo := (s.CodecType == "video")
|
||||
isImage := (s.CodecType == "image" && len(ProbeResults.Streams) == 1)
|
||||
isValid := true &&
|
||||
s.Width >= MEDIA_MIN_WIDTH && s.Height >= MEDIA_MIN_HEIGHT &&
|
||||
float64(s.Duration) <= float64(MEDIA_MAX_DURATION) &&
|
||||
ternary(s.NumberFrames < 2,
|
||||
s.Width < IMAGE_MAX_WIDTH && s.Height < IMAGE_MAX_HEIGHT,
|
||||
s.Width < VIDEO_MAX_WIDTH && s.Height < VIDEO_MAX_HEIGHT,
|
||||
)
|
||||
if isValid && (isVideo || isImage) && (ProbeVideo == nil || s.Duration > ProbeVideo.Duration) {
|
||||
ProbeVideo = &s
|
||||
}
|
||||
|
||||
isAudio := (s.CodecType == "audio")
|
||||
if isAudio {
|
||||
ProbeAudio = &s
|
||||
}
|
||||
}
|
||||
if ProbeVideo == nil {
|
||||
sse.SendClientError(tools.ERROR_MEDIA_INVALID)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Detect Properties ---
|
||||
|
||||
// Remove Audio from Stickers
|
||||
MediaSticker = (ProbeVideo.NumberFrames < 2)
|
||||
if MediaSticker {
|
||||
ProbeAudio = nil
|
||||
}
|
||||
|
||||
// Approximate Video Properties
|
||||
d := ternary(MediaSticker, IMAGE_LARGE_SIZE, VIDEO_STANDARD_SIZE)
|
||||
s := float64(min(ProbeVideo.Height, d)) / float64(ProbeVideo.Height)
|
||||
MediaWidth = int(float64(ProbeVideo.Width)*s) &^ 1
|
||||
MediaHeight = int(float64(ProbeVideo.Height)*s) &^ 1
|
||||
MediaFramerate = ternary(MediaSticker, 1, min(int(ProbeVideo.RFrameRate), VIDEO_STANDARD_FPS))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Media Processing ] -------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
{
|
||||
sse.SendJSON("step", map[string]any{"id": "ENCODE_QUEUE", "message": "Queued for Processing"})
|
||||
t := time.Now()
|
||||
|
||||
// --- Acquire Upload Slot ---
|
||||
if err := tools.SEMA_ENCODES.Acquire(ctx, 1); err != nil {
|
||||
return
|
||||
}
|
||||
defer tools.SEMA_ENCODES.Release(1)
|
||||
|
||||
// --- Prepare For Encoding ---
|
||||
sse.SendJSON("step", map[string]any{"id": "ENCODE_START", "message": "Processing"})
|
||||
|
||||
args := []string{
|
||||
"-loglevel", "verbose",
|
||||
"-hide_banner",
|
||||
"-stats",
|
||||
|
||||
"-i", PathUpload,
|
||||
"-filter_complex", fmt.Sprintf(""+
|
||||
"[0:%d]format=rgba[fg];[fg]split[fg1][fg2];[fg2]drawbox=x=0:y=0:w=iw:h=ih:color=%s:t=fill[bg];[bg][fg1]overlay[base];"+
|
||||
"[base]split=4[v1][v2][v3][v4];"+
|
||||
"[v1]%sscale=-2:%d:flags=lanczos,fps=%d[v1o];"+
|
||||
"[v2]%sscale=-2:%d:flags=lanczos,fps=%d[v2o];"+
|
||||
"[v3]%sscale=-2:%d:flags=lanczos,fps=%d[v3a];[v3a]format=rgba[v3f];[v3f]split[v3color][v3mask];[v3mask]alphaextract[v3ae];[v3color][v3ae]vstack[v3o];"+
|
||||
"[v4]%sscale=%d:%d:flags=neighbor,fps=%d[v4o];",
|
||||
|
||||
// Import
|
||||
ProbeVideo.Index,
|
||||
MEDIA_BACKGROUND,
|
||||
|
||||
// Export: Preview
|
||||
ternary(MediaSticker, IMAGE_FILTERS, VIDEO_FILTERS),
|
||||
min(ProbeVideo.Height, ternary(MediaSticker, IMAGE_PREVIEW_SIZE, VIDEO_PREVIEW_SIZE)),
|
||||
min(int(ProbeVideo.RFrameRate), ternary(MediaSticker, 1, VIDEO_PREVIEW_FPS)),
|
||||
|
||||
// Export: Standard
|
||||
ternary(MediaSticker, IMAGE_FILTERS, VIDEO_FILTERS),
|
||||
min(ProbeVideo.Height, ternary(MediaSticker, IMAGE_LARGE_SIZE, VIDEO_STANDARD_SIZE)),
|
||||
min(int(ProbeVideo.RFrameRate), ternary(MediaSticker, 1, VIDEO_STANDARD_FPS)),
|
||||
|
||||
// Export: Alpha
|
||||
ternary(MediaSticker, IMAGE_FILTERS, VIDEO_FILTERS),
|
||||
min(ProbeVideo.Height, ternary(MediaSticker, IMAGE_LARGE_SIZE, VIDEO_STANDARD_SIZE)),
|
||||
min(int(ProbeVideo.RFrameRate), ternary(MediaSticker, 1, VIDEO_STANDARD_FPS)),
|
||||
|
||||
// Export: Inference
|
||||
IMAGE_FILTERS,
|
||||
tools.MODEL_SIZE,
|
||||
tools.MODEL_SIZE,
|
||||
tools.MODEL_FRAMERATE,
|
||||
),
|
||||
|
||||
// Export Preview
|
||||
"-map", "[v1o]", "-an", "-sn",
|
||||
"-c:v" /*----------*/, ternary(MediaSticker, IMAGE_ENCODE_CODEC, VIDEO_ENCODE_CODEC),
|
||||
"-preset" /*-------*/, ternary(MediaSticker, IMAGE_ENCODE_EFFORT, VIDEO_ENCODE_EFFORT),
|
||||
"-qp" /*-----------*/, ternary(MediaSticker, IMAGE_ENCODE_QUALITY, VIDEO_PREVIEW_QUALITY),
|
||||
"-g" /*------------*/, strconv.Itoa(VIDEO_PREVIEW_FPS * VIDEO_KEYFRAME_INTERVAL),
|
||||
"-pix_fmt" /*------*/, MEDIA_COLOR_SPACE,
|
||||
"-color_range" /*--*/, MEDIA_COLOR_RANGE,
|
||||
"-svtav1-params" /**/, ternary(MediaSticker, IMAGE_ENCODE_PARAMS, VIDEO_ENCODE_PARAMS),
|
||||
"-frames:v" /*-----*/, ternary(MediaSticker, "1", strconv.Itoa(VIDEO_PREVIEW_FPS*VIDEO_PREVIEW_MAX_DURATION)),
|
||||
"-loop" /*---------*/, ternary(MediaSticker, "1", "0"),
|
||||
"-map_metadata" /*-*/, "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathPreview,
|
||||
|
||||
// Export: Standard
|
||||
"-map", "[v2o]", "-an", "-sn",
|
||||
"-c:v" /*----------*/, ternary(MediaSticker, IMAGE_ENCODE_CODEC, VIDEO_ENCODE_CODEC),
|
||||
"-preset" /*-------*/, ternary(MediaSticker, IMAGE_ENCODE_EFFORT, VIDEO_ENCODE_EFFORT),
|
||||
"-qp" /*-----------*/, ternary(MediaSticker, IMAGE_ENCODE_QUALITY, VIDEO_STANDARD_QUALITY),
|
||||
"-g" /*------------*/, strconv.Itoa(VIDEO_STANDARD_FPS * VIDEO_KEYFRAME_INTERVAL),
|
||||
"-pix_fmt" /*------*/, MEDIA_COLOR_SPACE,
|
||||
"-color_range" /*--*/, MEDIA_COLOR_RANGE,
|
||||
"-svtav1-params" /**/, ternary(MediaSticker, IMAGE_ENCODE_PARAMS, VIDEO_ENCODE_PARAMS),
|
||||
"-frames:v" /*-----*/, ternary(MediaSticker, "1", strconv.Itoa(VIDEO_STANDARD_FPS*MEDIA_MAX_DURATION)),
|
||||
"-loop" /*---------*/, ternary(MediaSticker, "1", "0"),
|
||||
"-map_metadata" /*-*/, "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathStandard,
|
||||
|
||||
// Export: Alpha
|
||||
"-map", "[v3o]", "-an", "-sn",
|
||||
"-c:v" /*----------*/, ternary(MediaSticker, IMAGE_ENCODE_CODEC, VIDEO_ENCODE_CODEC),
|
||||
"-preset" /*-------*/, ternary(MediaSticker, IMAGE_ENCODE_EFFORT, VIDEO_ENCODE_EFFORT),
|
||||
"-qp" /*-----------*/, ternary(MediaSticker, IMAGE_ENCODE_QUALITY, VIDEO_STANDARD_QUALITY),
|
||||
"-g" /*------------*/, strconv.Itoa(VIDEO_STANDARD_FPS * VIDEO_KEYFRAME_INTERVAL),
|
||||
"-pix_fmt" /*------*/, MEDIA_COLOR_SPACE,
|
||||
"-color_range" /*--*/, MEDIA_COLOR_RANGE,
|
||||
"-svtav1-params" /**/, IMAGE_ENCODE_PARAMS,
|
||||
"-frames:v" /*-----*/, ternary(MediaSticker, "1", strconv.Itoa(VIDEO_STANDARD_FPS*MEDIA_MAX_DURATION)),
|
||||
"-loop" /*---------*/, ternary(MediaSticker, "1", "0"),
|
||||
"-map_metadata" /*-*/, "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathAlpha,
|
||||
|
||||
// Export: Moderation
|
||||
"-map", "[v4o]", "-an", "-sn",
|
||||
"-f" /*-------*/, "rawvideo",
|
||||
"-fps_mode" /**/, "vfr",
|
||||
"-pix_fmt" /*-*/, "rgb24",
|
||||
"-",
|
||||
}
|
||||
|
||||
if ProbeAudio != nil {
|
||||
args = append(args,
|
||||
"-map", fmt.Sprintf("0:%d", ProbeAudio.Index), "-vn", "-sn",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "64k",
|
||||
"-af", "loudnorm=I=-16:TP=-2:LRA=11",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-map_metadata", "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathAudio,
|
||||
)
|
||||
}
|
||||
|
||||
var encodeCtx, encodeCancel = context.WithCancel(ctx)
|
||||
defer encodeCancel()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(encodeCtx, "ffmpeg", args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Auto Moderation ---
|
||||
var (
|
||||
classifyError error
|
||||
classifyPercent float32
|
||||
classifyAllowed = true
|
||||
classifyComplete = make(chan struct{}, 1)
|
||||
frameSize = (tools.MODEL_SIZE * tools.MODEL_SIZE * 3)
|
||||
tensorSize = (tools.MODEL_FRAMERATE * frameSize)
|
||||
tensorData = make([]float32, tensorSize)
|
||||
frameData = make([]byte, tensorSize)
|
||||
)
|
||||
|
||||
go func() {
|
||||
defer close(classifyComplete)
|
||||
defer io.Copy(io.Discard, stdout)
|
||||
frameIndex := 0
|
||||
for {
|
||||
// Process Raw Frames for Model
|
||||
n, err := io.ReadFull(stdout, frameData)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
encodeCancel()
|
||||
return
|
||||
}
|
||||
|
||||
frameCount := n / frameSize
|
||||
for i := 0; i < n; i++ {
|
||||
tensorData[i] = float32(frameData[i]) / 255.0
|
||||
}
|
||||
|
||||
// Classify Frames
|
||||
logits, err := tools.ModelClassifyTensorBatch(
|
||||
tensorData[:frameCount*frameSize],
|
||||
frameCount,
|
||||
)
|
||||
if err != nil {
|
||||
classifyError = err
|
||||
encodeCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate Results
|
||||
for idx, results := range logits {
|
||||
classifyPercent = (results.Hentai + results.Porn + (results.Sexy * 0.9))
|
||||
classifyAllowed = (classifyPercent < tools.MODEL_THRESHOLD_DENY)
|
||||
|
||||
fmt.Fprintf(TempLogger,
|
||||
"#%02d | D: %.2f | H: %.2f | N: %.2f | P: %.2f | S: %.2f | T: %.2f%% | OK: %t\n",
|
||||
frameIndex+idx,
|
||||
results.Drawing,
|
||||
results.Hentai,
|
||||
results.Neutral,
|
||||
results.Porn,
|
||||
results.Sexy,
|
||||
classifyPercent,
|
||||
classifyAllowed,
|
||||
)
|
||||
|
||||
if classifyPercent > MediaRating {
|
||||
MediaRating = classifyPercent
|
||||
}
|
||||
|
||||
if !classifyAllowed {
|
||||
encodeCancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
frameIndex += frameCount
|
||||
|
||||
// Calculate Progress (Approximate)
|
||||
sse.SendJSON("progress", map[string]any{
|
||||
"percent": strconv.FormatFloat(
|
||||
min(100, (float64(frameIndex)/float64(tools.MODEL_FRAMERATE))/float64(ProbeVideo.Duration)*100),
|
||||
'f', 2, 64,
|
||||
),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// --- Wait for Results ---
|
||||
err = cmd.Wait()
|
||||
<-classifyComplete
|
||||
|
||||
fmt.Fprintf(TempLogger, "\n%s\nProcessing completed in %s\n",
|
||||
stderr.String(),
|
||||
time.Since(t),
|
||||
)
|
||||
|
||||
if ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
if !classifyAllowed {
|
||||
sse.SendClientError(tools.ERROR_MEDIA_INAPPROPRIATE)
|
||||
return
|
||||
}
|
||||
if classifyError != nil {
|
||||
sse.SendServerError(classifyError)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sse.SendJSON("step", map[string]any{"id": "SERVER_FINALIZE", "message": "Syncing"})
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Upload Objects ] ---------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
{
|
||||
var (
|
||||
TargetDir = filepath.Join(tools.STORAGE_DISK_PUBLIC, TempIDString)
|
||||
TargetAlpha = filepath.Join(TargetDir, MEDIA_FILENAME_ALPHA)
|
||||
TargetPreview = filepath.Join(TargetDir, MEDIA_FILENAME_PREVIEW)
|
||||
TargetStandard = filepath.Join(TargetDir, MEDIA_FILENAME_STANDARD)
|
||||
TargetAudio = filepath.Join(TargetDir, MEDIA_FILENAME_AUDIO)
|
||||
)
|
||||
|
||||
// --- Create Directory ---
|
||||
if err := os.MkdirAll(TargetDir, tools.FILE_PUBLIC); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if !TempSuccess {
|
||||
if err := os.RemoveAll(TargetDir); err != nil {
|
||||
tools.LoggerStorage.Log(tools.WARN, "Failed to delete incomplete files: %s", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// --- Move Files ---
|
||||
for _, op := range []struct {
|
||||
ShouldCopy bool
|
||||
PathSource string
|
||||
PathTarget string
|
||||
ContentType string
|
||||
}{
|
||||
{true, PathAlpha, TargetAlpha, "video/webm"},
|
||||
{true, PathPreview, TargetPreview, "image/avif"},
|
||||
{true, PathStandard, TargetStandard, "image/avif"},
|
||||
{ProbeAudio != nil, PathAudio, TargetAudio, "audio/ogg"},
|
||||
} {
|
||||
if !op.ShouldCopy {
|
||||
continue
|
||||
}
|
||||
|
||||
s, err := os.Open(op.PathSource)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
t, err := os.OpenFile(op.PathTarget, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, tools.FILE_PUBLIC)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer t.Close()
|
||||
|
||||
if _, err := io.Copy(t, s); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Upload Metadata ] --------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
{
|
||||
// --- Begin Transaction ---
|
||||
tx, err := tools.Database.Begin(ctx)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// --- Insert Row ---
|
||||
if _, err = tx.Exec(ctx,
|
||||
`INSERT INTO gifuu.upload (
|
||||
id,
|
||||
upload_address_hash,
|
||||
upload_token_hash,
|
||||
flag_sticker,
|
||||
flag_audio,
|
||||
encode_fps,
|
||||
encode_width,
|
||||
encode_height,
|
||||
meta_rating,
|
||||
meta_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
TempID,
|
||||
ClientAddress,
|
||||
tools.RequestHash(ClientToken),
|
||||
MediaSticker,
|
||||
ProbeAudio != nil,
|
||||
MediaFramerate,
|
||||
MediaWidth,
|
||||
MediaHeight,
|
||||
MediaRating,
|
||||
Body.Title,
|
||||
); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Upsert Tags ---
|
||||
if _, err := tx.Exec(ctx,
|
||||
`WITH upserted_tags AS (
|
||||
INSERT INTO gifuu.tag (label)
|
||||
SELECT unnest($1::text[])
|
||||
ON CONFLICT (label) DO UPDATE SET label = EXCLUDED.label
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO gifuu.upload_tag (gif_id, tag_id)
|
||||
SELECT $2, id FROM upserted_tags`,
|
||||
Body.Tags,
|
||||
TempID,
|
||||
); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Commit Transaction ---
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Return Results ] ---------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
TempSuccess = true
|
||||
sse.SendJSON("finish", map[string]any{
|
||||
"id": strconv.FormatInt(TempID, 10),
|
||||
"edit_token": ClientToken,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEventsUnsupported = errors.New("events unsupported")
|
||||
)
|
||||
|
||||
type EventHelper struct {
|
||||
m sync.Mutex
|
||||
c context.Context
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
f http.Flusher
|
||||
}
|
||||
|
||||
func NewEventHelper(ctx context.Context, w http.ResponseWriter, r *http.Request) (*EventHelper, error) {
|
||||
|
||||
// Setup Connection
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, ErrEventsUnsupported
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
sse := &EventHelper{f: flusher, c: ctx, w: w, r: r}
|
||||
|
||||
// Heartbeat Generator
|
||||
go func(h *EventHelper) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.c.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
h.m.Lock()
|
||||
fmt.Fprintf(h.w, ": ping\n\n")
|
||||
h.f.Flush()
|
||||
h.m.Unlock()
|
||||
}
|
||||
}
|
||||
}(sse)
|
||||
|
||||
return sse, nil
|
||||
}
|
||||
|
||||
func (h *EventHelper) SendJSON(eventName string, eventData any) {
|
||||
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"name": eventName,
|
||||
"data": eventData,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.m.Lock()
|
||||
fmt.Fprintf(h.w, "data: %s\n\n", b)
|
||||
h.f.Flush()
|
||||
h.m.Unlock()
|
||||
}
|
||||
|
||||
func (h *EventHelper) SendServerError(err error) {
|
||||
SendServerError(nil, h.r, err)
|
||||
h.SendClientError(ERROR_GENERIC_SERVER)
|
||||
}
|
||||
|
||||
func (h *EventHelper) SendClientError(err APIError) {
|
||||
h.SendJSON("error", err)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ChallengeSessionData struct {
|
||||
Expires int64
|
||||
Difficulty int
|
||||
}
|
||||
|
||||
type RatelimitEntry struct {
|
||||
Reset int64
|
||||
Usage int32
|
||||
}
|
||||
|
||||
type RatelimitShard struct {
|
||||
sync.Mutex
|
||||
data map[string]RatelimitEntry
|
||||
}
|
||||
|
||||
const (
|
||||
RatelimitShardCount = 256
|
||||
)
|
||||
|
||||
var (
|
||||
ChallengeAtomic sync.Mutex
|
||||
ChallengeSession = make(map[string]ChallengeSessionData, 1024)
|
||||
RatelimitShards = make([]RatelimitShard, RatelimitShardCount)
|
||||
)
|
||||
|
||||
func init() {
|
||||
for i := range RatelimitShards {
|
||||
RatelimitShards[i].data = make(map[string]RatelimitEntry, 128)
|
||||
}
|
||||
|
||||
// Cleanup Challenges
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
now := time.Now().UnixNano()
|
||||
ChallengeAtomic.Lock()
|
||||
for k, v := range ChallengeSession {
|
||||
if now > v.Expires {
|
||||
delete(ChallengeSession, k)
|
||||
}
|
||||
}
|
||||
ChallengeAtomic.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Cleanup Ratelimits
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
now := time.Now().UnixNano()
|
||||
for i := range RatelimitShards {
|
||||
s := &RatelimitShards[i]
|
||||
s.Lock()
|
||||
for k, v := range s.data {
|
||||
if now >= v.Reset {
|
||||
delete(s.data, k)
|
||||
}
|
||||
}
|
||||
s.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func NewChallenger(minimumDifficulty int) ChainMiddleware {
|
||||
return func(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
// --- Proof of Work ---
|
||||
var givenNonce = r.Header.Get("X-Challenge-Nonce")
|
||||
var givenCounterRaw = r.Header.Get("X-Challenge-Counter")
|
||||
var givenCounter = 0
|
||||
|
||||
if _, err := hex.DecodeString(givenNonce); err != nil {
|
||||
SendClientError(w, r, ERROR_BODY_INVALID_CHALLENGE)
|
||||
return false
|
||||
}
|
||||
if v, err := strconv.Atoi(givenCounterRaw); err != nil || v < 0 {
|
||||
SendClientError(w, r, ERROR_BODY_INVALID_CHALLENGE)
|
||||
return false
|
||||
} else {
|
||||
givenCounter = v
|
||||
}
|
||||
|
||||
// Consume Session
|
||||
ChallengeAtomic.Lock()
|
||||
session, exists := ChallengeSession[givenNonce]
|
||||
if !exists {
|
||||
ChallengeAtomic.Unlock()
|
||||
SendClientError(w, r, ERROR_UNKNOWN_CHALLENGE)
|
||||
return false
|
||||
}
|
||||
delete(ChallengeSession, givenNonce)
|
||||
ChallengeAtomic.Unlock()
|
||||
|
||||
if time.Now().Unix() >= session.Expires {
|
||||
SendClientError(w, r, ERROR_CHALLENGE_EXPIRED)
|
||||
return false
|
||||
}
|
||||
if session.Difficulty < minimumDifficulty {
|
||||
SendClientError(w, r, ERROR_CHALLENGE_TOO_EASY)
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate Results
|
||||
sessionInput := fmt.Sprintf("%s%d", givenNonce, givenCounter)
|
||||
sessionHash := sha256.Sum256([]byte(sessionInput))
|
||||
zeroBitsRequired := session.Difficulty
|
||||
zeroBitsFound := 0
|
||||
|
||||
for _, b := range sessionHash {
|
||||
if b == 0 {
|
||||
zeroBitsFound += 8
|
||||
} else {
|
||||
zeroBitsFound += bits.LeadingZeros8(b)
|
||||
break
|
||||
}
|
||||
}
|
||||
if zeroBitsFound < zeroBitsRequired {
|
||||
SendClientError(w, r, ERROR_CHALLENGE_INVALID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent Spam by Limiting Amount of Incoming Requests
|
||||
func NewRatelimiter(categoryName string, limit int32, period time.Duration) ChainMiddleware {
|
||||
limitStr := strconv.Itoa(int(limit))
|
||||
return func(w http.ResponseWriter, r *http.Request) bool {
|
||||
name := categoryName + ":" + RequestAddressHash(r)
|
||||
|
||||
// FNV-1a 32-bit
|
||||
var h uint64 = 2166136261
|
||||
for i := 0; i < len(name); i++ {
|
||||
h ^= uint64(name[i])
|
||||
h *= 16777619
|
||||
}
|
||||
|
||||
// Calculate Usage
|
||||
s := &RatelimitShards[h%RatelimitShardCount]
|
||||
s.Lock()
|
||||
now := time.Now()
|
||||
e, ok := s.data[name]
|
||||
if !ok || now.UnixNano() >= e.Reset {
|
||||
e = RatelimitEntry{Reset: now.Add(period).UnixNano(), Usage: 1}
|
||||
} else {
|
||||
e.Usage++
|
||||
}
|
||||
s.data[name] = e
|
||||
s.Unlock()
|
||||
|
||||
// Generate Headers
|
||||
resetSecs := strconv.FormatFloat(float64(e.Reset-now.UnixNano())/float64(time.Second), 'f', 2, 64)
|
||||
remaining := max(0, limit-e.Usage)
|
||||
|
||||
hdr := w.Header()
|
||||
hdr.Set("X-Ratelimit-Category", categoryName)
|
||||
hdr.Set("X-Ratelimit-Reset", resetSecs)
|
||||
hdr.Set("X-Ratelimit-Limit", limitStr)
|
||||
hdr.Set("X-Ratelimit-Remaining", strconv.Itoa(int(remaining)))
|
||||
|
||||
if e.Usage > int32(limit) {
|
||||
SendClientError(w, r, ERROR_GENERIC_RATELIMIT)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Create Gatekeeper
|
||||
func NewGatekeeper(allowModerators bool) ChainMiddleware {
|
||||
return func(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
if allowModerators {
|
||||
var Count int
|
||||
err := Database.QueryRow(r.Context(),
|
||||
"SELECT 1 FROM gifuu.mod_key WHERE token_hash = $1",
|
||||
RequestHash(r.Header.Get("Authorization")),
|
||||
).Scan(
|
||||
&Count,
|
||||
)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
SendServerError(w, r, err)
|
||||
return false
|
||||
}
|
||||
if err == pgx.ErrNoRows {
|
||||
SendClientError(w, r, ERROR_GENERIC_UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ChainMiddleware func(w http.ResponseWriter, r *http.Request) bool
|
||||
|
||||
// Apply Middleware before Processing Request
|
||||
func Chain(h http.HandlerFunc, wares ...ChainMiddleware) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, mw := range wares {
|
||||
if !mw(w, r) {
|
||||
return
|
||||
}
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type MethodHandler map[string]http.HandlerFunc
|
||||
|
||||
func (mh MethodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if handler, ok := mh[r.Method]; ok {
|
||||
handler(w, r)
|
||||
} else {
|
||||
SendClientError(w, r, ERROR_GENERIC_METHOD_NOT_ALLOWED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
snowflakeMutex sync.Mutex
|
||||
snowflakeMachineID int64
|
||||
snowflakeSequence int64
|
||||
snowflakeTimestamp int64
|
||||
|
||||
// Updates to normalizers here should be mirrored in `GET_Limits.go`!
|
||||
|
||||
RegexSpaces = regexp.MustCompile(`\s{2,}`)
|
||||
RegexUnderscores = regexp.MustCompile(`_+`)
|
||||
RegexNewlines = regexp.MustCompile(`\n{2,}`)
|
||||
RegexMatcherTitle = regexp.MustCompile(`^[\S\s]{1,80}$`)
|
||||
RegexMatcherTag = regexp.MustCompile(`^[\p{L}\p{N}_]{1,32}$`)
|
||||
RegexMatcherComment = regexp.MustCompile(`^[\S\s]{10,240}$`)
|
||||
)
|
||||
|
||||
func NormalizeTitle(str string) (string, bool) {
|
||||
if str == "" {
|
||||
return str, false
|
||||
}
|
||||
if !RegexMatcherTitle.MatchString(str) {
|
||||
return str, false
|
||||
}
|
||||
str = RegexSpaces.ReplaceAllString(str, " ")
|
||||
str = strings.TrimSpace(str)
|
||||
str = html.EscapeString(str)
|
||||
return str, true
|
||||
}
|
||||
|
||||
func NormalizeTag(str string) (string, bool) {
|
||||
if str == "" {
|
||||
return str, false
|
||||
}
|
||||
if !RegexMatcherTag.MatchString(str) {
|
||||
return str, false
|
||||
}
|
||||
str = RegexUnderscores.ReplaceAllString(str, "_")
|
||||
str = strings.Trim(str, "_")
|
||||
str = strings.ToUpper(str)
|
||||
return str, true
|
||||
}
|
||||
|
||||
func NormalizeComment(str string) (string, bool) {
|
||||
if str == "" {
|
||||
return str, false
|
||||
}
|
||||
if !RegexMatcherComment.MatchString(str) {
|
||||
return str, false
|
||||
}
|
||||
str = RegexNewlines.ReplaceAllString(str, " ")
|
||||
str = strings.TrimSpace(str)
|
||||
str = html.EscapeString(str)
|
||||
return str, true
|
||||
}
|
||||
|
||||
func ParseLimit(str string) int {
|
||||
v, _ := strconv.Atoi(str)
|
||||
return min(100, max(1, v))
|
||||
}
|
||||
|
||||
func ParseSnowflake(str string) int64 {
|
||||
v, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil || v < 1 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ParseJSON(r io.Reader, v any) error {
|
||||
l := io.LimitReader(r, int64(LIMIT_JSON))
|
||||
d := json.NewDecoder(l)
|
||||
d.DisallowUnknownFields()
|
||||
return d.Decode(v)
|
||||
}
|
||||
|
||||
// Generate a Unique Snowflake
|
||||
func RequestSnowflake() int64 {
|
||||
snowflakeMutex.Lock()
|
||||
defer snowflakeMutex.Unlock()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if now != snowflakeTimestamp {
|
||||
snowflakeSequence = 0
|
||||
} else {
|
||||
snowflakeSequence++
|
||||
if snowflakeSequence > SNOWFLAKE_MAX_SEQUENCE {
|
||||
for now <= snowflakeTimestamp {
|
||||
time.Sleep(time.Millisecond)
|
||||
now = time.Now().UnixMilli()
|
||||
}
|
||||
snowflakeSequence = 0
|
||||
}
|
||||
}
|
||||
|
||||
snowflakeTimestamp = now
|
||||
return ((now - SNOWFLAKE_EPOCH_MILLI) << 22) | (snowflakeMachineID << 12) | snowflakeSequence
|
||||
}
|
||||
|
||||
// Generate a Hex String out of random bytes
|
||||
func RequestToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
panic("failed to generate enough random bytes")
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Generate a SHA256 Hex String from a given string
|
||||
func RequestHash(str string) string {
|
||||
h := sha256.Sum256([]byte(str))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// Get the Client IP Address as a SHA256 Hex String
|
||||
func RequestAddressHash(r *http.Request) string {
|
||||
return RequestHash(RequestAddress(r))
|
||||
}
|
||||
|
||||
// Get the Client IP Address
|
||||
func RequestAddress(r *http.Request) string {
|
||||
if HTTP_PROXY != "" {
|
||||
return r.Header.Get(HTTP_PROXY)
|
||||
}
|
||||
addr, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type APIError struct {
|
||||
Status int `json:"-"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
ERROR_GENERIC_SERVER = APIError{Status: 500, Code: 10000, Message: "Server Error"}
|
||||
ERROR_GENERIC_NOT_FOUND = APIError{Status: 404, Code: 10001, Message: "Endpoint Not Found"}
|
||||
ERROR_GENERIC_RATELIMIT = APIError{Status: 429, Code: 10002, Message: "Too Many Requests"}
|
||||
ERROR_GENERIC_UNAUTHORIZED = APIError{Status: 401, Code: 10003, Message: "Unauthorized"}
|
||||
ERROR_GENERIC_FORBIDDEN = APIError{Status: 403, Code: 10004, Message: "Forbidden"}
|
||||
ERROR_GENERIC_METHOD_NOT_ALLOWED = APIError{Status: 405, Code: 10005, Message: "Method Not Allowed"}
|
||||
ERROR_SERVER_RESOURCES_EXHAUSTED = APIError{Status: 507, Code: 11006, Message: "Resources Exhausted"}
|
||||
ERROR_BODY_EMPTY = APIError{Status: 411, Code: 12000, Message: "Request Body is Empty"}
|
||||
ERROR_BODY_TOO_LARGE = APIError{Status: 413, Code: 12001, Message: "Request Body is Too Large"}
|
||||
ERROR_BODY_INVALID_CONTENT_TYPE = APIError{Status: 400, Code: 12002, Message: "Invalid 'Content-Type' Header"}
|
||||
ERROR_BODY_INVALID_CHALLENGE = APIError{Status: 400, Code: 12003, Message: "Invalid 'X-Challenge-*' Header"}
|
||||
ERROR_BODY_INVALID_FIELD = APIError{Status: 400, Code: 12004, Message: "Invalid Body Field"}
|
||||
ERROR_BODY_INVALID_DATA = APIError{Status: 422, Code: 12005, Message: "Invalid Body"}
|
||||
ERROR_UNKNOWN_ENDPOINT = APIError{Status: 404, Code: 13000, Message: "Unknown Endpoint"}
|
||||
ERROR_UNKNOWN_FUNCTION = APIError{Status: 404, Code: 13001, Message: "Unknown Function"}
|
||||
ERROR_UNKNOWN_ANIMATION = APIError{Status: 404, Code: 13002, Message: "Unknown Animation"}
|
||||
ERROR_UNKNOWN_TASK = APIError{Status: 404, Code: 13003, Message: "Unknown Task"}
|
||||
ERROR_UNKNOWN_CHALLENGE = APIError{Status: 404, Code: 13004, Message: "Unknown Challenge"}
|
||||
ERROR_CHALLENGE_INVALID = APIError{Status: 400, Code: 14000, Message: "Invalid Challenge Result"}
|
||||
ERROR_CHALLENGE_TOO_EASY = APIError{Status: 400, Code: 14001, Message: "Challenge Difficulty Too Low"}
|
||||
ERROR_CHALLENGE_EXPIRED = APIError{Status: 400, Code: 14002, Message: "Challenge Expired"}
|
||||
ERROR_MEDIA_INVALID = APIError{Status: 400, Code: 15000, Message: "Media Invalid"}
|
||||
ERROR_MEDIA_INAPPROPRIATE = APIError{Status: 400, Code: 15001, Message: "Media Inappropriate"}
|
||||
)
|
||||
|
||||
// Reject request due to a Client Mistake
|
||||
func SendClientError(w http.ResponseWriter, r *http.Request, err APIError) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(err.Status)
|
||||
fmt.Fprintf(w, `{"code":%d,"message":%q}`, err.Code, err.Message)
|
||||
}
|
||||
|
||||
// Reject request due to a Server Error
|
||||
// Additionally collects debug information and logs it to the console
|
||||
func SendServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
debugStack := strings.Split(string(debug.Stack()), "\n")
|
||||
for i, item := range debugStack {
|
||||
debugStack[i] = strings.ReplaceAll(item, "\t", " ")
|
||||
}
|
||||
if len(debugStack) > 5 {
|
||||
debugStack = debugStack[5:] // skip header
|
||||
}
|
||||
|
||||
reqHeader := make(map[string]string, len(r.Header))
|
||||
for key, header := range r.Header {
|
||||
reqHeader[key] = strings.Join(header, ", ")
|
||||
}
|
||||
|
||||
LoggerHTTP.Data(ERROR, err.Error(), map[string]any{
|
||||
"request": map[string]any{
|
||||
"method": r.Method,
|
||||
"url": r.URL.String(),
|
||||
"headers": reqHeader,
|
||||
},
|
||||
"error": map[string]any{
|
||||
"raw": err,
|
||||
"message": err.Error(),
|
||||
"stack": debugStack,
|
||||
},
|
||||
})
|
||||
|
||||
if w != nil {
|
||||
SendClientError(w, r, ERROR_GENERIC_SERVER)
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to the request with a JSON object
|
||||
func SendJSON(w http.ResponseWriter, r *http.Request, statusCode int, responseObject any) (int, error) {
|
||||
|
||||
// Check Compression
|
||||
var g io.Writer
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
z := gzip.NewWriter(w)
|
||||
defer z.Close()
|
||||
g = z
|
||||
} else {
|
||||
g = w
|
||||
}
|
||||
|
||||
// Stream Object
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
if b, ok := responseObject.([]byte); ok {
|
||||
return g.Write(b)
|
||||
} else {
|
||||
j := json.NewEncoder(g)
|
||||
return 0, j.Encode(responseObject)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode Object as JSON and gzipped version
|
||||
func PrepareStaticJSON(responseObject any) ([]byte, []byte, error) {
|
||||
|
||||
// Encode Object
|
||||
buf, err := json.Marshal(responseObject)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Compress Object
|
||||
cmp := bytes.Buffer{}
|
||||
zip := gzip.NewWriter(&cmp)
|
||||
|
||||
if _, err := zip.Write(buf); err != nil {
|
||||
zip.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := zip.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return buf, cmp.Bytes(), nil
|
||||
}
|
||||
|
||||
// Respond to the request with a Static JSON Object
|
||||
func SendStaticJSON(w http.ResponseWriter, r *http.Request, statusCode int, content []byte, gzipped []byte) (int, error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if gzipped != nil && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
return w.Write(gzipped)
|
||||
}
|
||||
return w.Write(content)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var Database *pgxpool.Pool
|
||||
|
||||
func SetupDatabase(stop context.Context, await *sync.WaitGroup) {
|
||||
|
||||
var err error
|
||||
t := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create and Test Client
|
||||
cfg, err := pgxpool.ParseConfig(DATABASE_URL)
|
||||
if err != nil {
|
||||
LoggerDatabase.Log(FATAL, "Invalid Database URI: %s", err)
|
||||
return
|
||||
}
|
||||
if Database, err = pgxpool.NewWithConfig(ctx, cfg); err != nil {
|
||||
LoggerDatabase.Log(FATAL, "Failed to create pool: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if err = Database.Ping(ctx); err != nil {
|
||||
LoggerDatabase.Log(FATAL, "Failed to ping database: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Shutdown Logic
|
||||
await.Add(1)
|
||||
go func() {
|
||||
defer await.Done()
|
||||
<-stop.Done()
|
||||
Database.Close()
|
||||
LoggerDatabase.Log(INFO, "Closed")
|
||||
}()
|
||||
|
||||
LoggerDatabase.Log(INFO, "Ready in %s", time.Since(t))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoggerSeverity string
|
||||
|
||||
const (
|
||||
INFO LoggerSeverity = "INFO" // This is a basic informational alert.
|
||||
WARN LoggerSeverity = "WARN" // This is a warning, meaning the program has recovered from an error.
|
||||
DEBUG LoggerSeverity = "DEBUG" // This is a detailed alert containing information used for debugging.
|
||||
ERROR LoggerSeverity = "ERROR" // An error has occurred, please advise.
|
||||
FATAL LoggerSeverity = "FATAL" // An irrecoverable error has occured and the program must exit immediately.
|
||||
)
|
||||
|
||||
var (
|
||||
LoggerInit = &LoggerInstance{source: "INIT"}
|
||||
LoggerHTTP = &LoggerInstance{source: "HTTP"}
|
||||
LoggerModel = &LoggerInstance{source: "ONNX"}
|
||||
LoggerStorage = &LoggerInstance{source: "DISK"}
|
||||
LoggerDatabase = &LoggerInstance{source: "RMDB"}
|
||||
)
|
||||
|
||||
type LoggerInstance struct {
|
||||
source string
|
||||
}
|
||||
|
||||
func (p *LoggerInstance) entry(severity LoggerSeverity, source, message string) {
|
||||
target := os.Stdout
|
||||
if severity == ERROR || severity == FATAL {
|
||||
target = os.Stderr
|
||||
}
|
||||
fmt.Fprintf(target, "%s [%s] [%s] %s\n", time.Now().Format(time.DateTime), severity, source, message)
|
||||
}
|
||||
|
||||
func (p *LoggerInstance) Log(severity LoggerSeverity, format string, a ...any) {
|
||||
p.entry(severity, p.source, fmt.Sprintf(format, a...))
|
||||
if severity == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LoggerInstance) Data(severity LoggerSeverity, message string, data any) {
|
||||
if data == nil {
|
||||
p.entry(severity, p.source, message)
|
||||
} else {
|
||||
entryData := ""
|
||||
if b, err := json.MarshalIndent(data, "", " "); err != nil {
|
||||
entryData = fmt.Sprintf("marshal_error: %q", err)
|
||||
} else {
|
||||
entryData = string(b)
|
||||
}
|
||||
p.entry(severity, p.source, fmt.Sprintf("%s\n%s\n---", message, entryData))
|
||||
}
|
||||
if severity == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gifuu/include"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
onnx "github.com/yalue/onnxruntime_go"
|
||||
)
|
||||
|
||||
const (
|
||||
MODEL_THRESHOLD_DENY = 0.95
|
||||
MODEL_THRESHOLD_HIDE = 0.75
|
||||
MODEL_SIZE = 224
|
||||
MODEL_FRAMERATE = 3
|
||||
)
|
||||
|
||||
var onnxSession *onnx.DynamicAdvancedSession
|
||||
|
||||
type ClassifyResults struct {
|
||||
Drawing float32
|
||||
Hentai float32
|
||||
Neutral float32
|
||||
Porn float32
|
||||
Sexy float32
|
||||
}
|
||||
|
||||
func SetupModel(stop context.Context, await *sync.WaitGroup) {
|
||||
if ONNX_RUNTIME_PATH == "" {
|
||||
LoggerModel.Log(WARN, "Set runtime path with envvar ONNX_RUNTIME_PATH to enable model")
|
||||
return
|
||||
}
|
||||
t := time.Now()
|
||||
|
||||
// Initialize Environment
|
||||
onnx.SetSharedLibraryPath(ONNX_RUNTIME_PATH)
|
||||
if err := onnx.InitializeEnvironment(); err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to initialize ONNX Runtime: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize Settings
|
||||
options, err := onnx.NewSessionOptions()
|
||||
if err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to create session options: %s", err)
|
||||
return
|
||||
}
|
||||
defer options.Destroy()
|
||||
|
||||
if ONNX_RUNTIME_CUDA {
|
||||
cudaOptions, err := onnx.NewCUDAProviderOptions()
|
||||
if err != nil {
|
||||
LoggerModel.Log(WARN, "CUDA unavailable, falling back to CPU: %s", err)
|
||||
} else {
|
||||
defer cudaOptions.Destroy()
|
||||
cudaOptions.Update(map[string]string{
|
||||
"cudnn_conv_algo_search": "DEFAULT", // use the only working frontend
|
||||
})
|
||||
options.AppendExecutionProviderCUDA(cudaOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Model
|
||||
session, err := onnx.NewDynamicAdvancedSessionWithONNXData(
|
||||
include.MODEL_NSFW,
|
||||
[]string{"input"},
|
||||
[]string{"prediction"},
|
||||
options,
|
||||
)
|
||||
if err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to load model: %s", err)
|
||||
return
|
||||
}
|
||||
onnxSession = session
|
||||
|
||||
// Test Model with Dummy Data
|
||||
dummy := make([]float32, MODEL_SIZE*MODEL_SIZE*3)
|
||||
if _, err := ModelClassifyTensorBatch(dummy, 1); err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to initialize model: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
await.Add(1)
|
||||
go func() {
|
||||
defer await.Done()
|
||||
<-stop.Done()
|
||||
onnxSession.Destroy()
|
||||
onnxSession = nil
|
||||
onnx.DestroyEnvironment()
|
||||
LoggerModel.Log(INFO, "Closed")
|
||||
}()
|
||||
|
||||
LoggerModel.Log(INFO, "Model ready in %s", time.Since(t))
|
||||
}
|
||||
|
||||
func ModelClassifyTensorBatch(data []float32, count int) ([]ClassifyResults, error) {
|
||||
|
||||
// Model is disabled, generate some dummy results.
|
||||
if onnxSession == nil {
|
||||
results := make([]ClassifyResults, count)
|
||||
for i := 0; i < count; i++ {
|
||||
results = append(results, ClassifyResults{Neutral: 1})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
inputTensor, err := onnx.NewTensor(
|
||||
onnx.NewShape(int64(count), MODEL_SIZE, MODEL_SIZE, 3),
|
||||
data,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer inputTensor.Destroy()
|
||||
|
||||
outputs := []onnx.ArbitraryTensor{nil}
|
||||
if err := onnxSession.Run([]onnx.ArbitraryTensor{inputTensor}, outputs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer outputs[0].Destroy()
|
||||
|
||||
raw := outputs[0].(*onnx.Tensor[float32]).GetData()
|
||||
results := make([]ClassifyResults, count)
|
||||
|
||||
for i := range results {
|
||||
base := i * 5
|
||||
results[i] = ClassifyResults{
|
||||
Drawing: raw[base+0],
|
||||
Hentai: raw[base+1],
|
||||
Neutral: raw[base+2],
|
||||
Porn: raw[base+3],
|
||||
Sexy: raw[base+4],
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
const (
|
||||
SNOWFLAKE_MAX_MACHINE_ID int64 = (1 << 10) - 1
|
||||
SNOWFLAKE_MAX_SEQUENCE int64 = (1 << 12) - 1
|
||||
SNOWFLAKE_EPOCH_MILLI = 1207008000000
|
||||
SNOWFLAKE_EPOCH_SECONDS = SNOWFLAKE_EPOCH_MILLI / 1000 // Apr 1st 2008 (Teto B-Day!)
|
||||
TIMEOUT_SHUTDOWN = 1 * time.Minute // Standard Timeout for Shutdowns
|
||||
TIMEOUT_CONTEXT = 10 * time.Second // Standard Timeout for Requests
|
||||
FILE_PUBLIC = os.FileMode(0770) // rwxrwx---
|
||||
FILE_PRIVATE = os.FileMode(0700) // rwx------
|
||||
)
|
||||
|
||||
var (
|
||||
TEMP_CAPACITY atomic.Int64
|
||||
LIMIT_JSON = EnvNumber("LIMIT_JSON", 8*1024) // ( 8KB) Size limit per incoming JSON string
|
||||
LIMIT_FILE = EnvNumber("LIMIT_FILE", 25*1024*1024) // (25MB) Size limit per incoming media file
|
||||
LIMIT_TEMP = EnvNumber("LIMIT_TEMP", 2*1024*1024*1024) // ( 2GB) Disk space allowed for temporary files
|
||||
LIMIT_ENCODES = EnvNumber("LIMIT_ENCODES", 1) // Concurrent Uploads
|
||||
LIMIT_PROBES = EnvNumber("LIMIT_PROBES", 4) // Concurrent Probes
|
||||
LIMIT_MIME_TYPE = EnvSlice("LIMIT_MIME_TYPE", ",", []string{
|
||||
/* STANDARD */ "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
|
||||
/* FUTURE */ "image/avif", "image/jxl",
|
||||
/* LEGACY */ "image/tiff", "image/bmp",
|
||||
/* STANDARD */ "video/mp4", "video/webm", "video/quicktime", "video/x-matroska",
|
||||
/* LEGACY */ "video/avi", "video/x-ms-wmv",
|
||||
})
|
||||
TEMPLATE_BASE_WEB = EnvString("TEMPLATE_BASE_WEB", "http://localhost:5173")
|
||||
TEMPLATE_BASE_CDN = EnvString("TEMPLATE_BASE_CDN", "http://localhost:3000")
|
||||
TEMPLATE_BASE_API = EnvString("TEMPLATE_BASE_API", "http://localhost:8080")
|
||||
MACHINE_ID = EnvString("MACHINE_ID", "0")
|
||||
MACHINE_HOSTNAME = EnvString("MACHINE_HOSTNAME", "le fishe")
|
||||
MACHINE_PROVERB = EnvString("MACHINE_PROVERB", "><> .o( blub blub)")
|
||||
DATABASE_URL = EnvString("DATABASE_URL", "postgresql://postgres:password@localhost:5432")
|
||||
STORAGE_DISK_TEMP = EnvString("STORAGE_DISK_TEMP", "_temp")
|
||||
STORAGE_DISK_PUBLIC = EnvString("STORAGE_DISK_PUBLIC", "_public")
|
||||
ONNX_RUNTIME_PATH = EnvString("ONNX_RUNTIME_PATH", "")
|
||||
ONNX_RUNTIME_CUDA = EnvString("ONNX_RUNTIME_CUDA", "") != ""
|
||||
HTTP_ADDRESS = EnvString("HTTP_ADDRESS", "127.0.0.1:8080")
|
||||
HTTP_PROXY = EnvString("HTTP_PROXY", "")
|
||||
)
|
||||
|
||||
var (
|
||||
SEMA_UPLOADS = semaphore.NewWeighted(int64(LIMIT_TEMP))
|
||||
SEMA_ENCODES = semaphore.NewWeighted(int64(LIMIT_ENCODES))
|
||||
SEMA_PROBES = semaphore.NewWeighted(int64(LIMIT_PROBES))
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Prepare Directories
|
||||
if err := os.MkdirAll(STORAGE_DISK_PUBLIC, FILE_PUBLIC); err != nil {
|
||||
LoggerInit.Log(FATAL, "Cannot Create Public Directory")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(STORAGE_DISK_TEMP, FILE_PUBLIC); err != nil {
|
||||
LoggerInit.Log(FATAL, "Cannot Create Temp Directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Check Executables
|
||||
if err := exec.Command("ffmpeg", "--help").Run(); err != nil {
|
||||
LoggerInit.Log(FATAL, "FFmpeg failed to start: %s", err)
|
||||
return
|
||||
}
|
||||
if err := exec.Command("ffprobe", "--help").Run(); err != nil {
|
||||
LoggerInit.Log(FATAL, "FFprobe failed to start: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Read String from Environment
|
||||
func EnvString(field, initial string) string {
|
||||
if value := os.Getenv(field); value == "" {
|
||||
return initial
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// Read String from Environment and Parse it as a number
|
||||
func EnvNumber(field string, initial int) int {
|
||||
if value := os.Getenv(field); value == "" {
|
||||
return initial
|
||||
} else if number, err := strconv.Atoi(value); err != nil {
|
||||
return initial
|
||||
} else {
|
||||
return number
|
||||
}
|
||||
}
|
||||
|
||||
// Read String from Environment and Parse it as a slice using the given delimiter
|
||||
func EnvSlice(field, delimiter string, initial []string) []string {
|
||||
if value := os.Getenv(field); value == "" {
|
||||
return initial
|
||||
} else {
|
||||
return strings.Split(value, delimiter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StringInteger float64
|
||||
|
||||
func (d *StringInteger) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(string(data), `"`)
|
||||
v, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = StringInteger(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type StringFloat float64
|
||||
|
||||
func (d *StringFloat) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(string(data), `"`)
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = StringFloat(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type StringFramerate float64
|
||||
|
||||
func (d *StringFramerate) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(string(data), `"`)
|
||||
|
||||
if parts := strings.SplitN(s, "/", 2); len(parts) == 2 {
|
||||
|
||||
num, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid numerator: %s", err)
|
||||
}
|
||||
den, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid denominator: %s", err)
|
||||
}
|
||||
|
||||
if den == 0 {
|
||||
den = 1
|
||||
}
|
||||
|
||||
*d = StringFramerate(num / den)
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse framerate")
|
||||
}
|
||||
*d = StringFramerate(f)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProbeStream struct {
|
||||
Index int `json:"index"` // 0
|
||||
CodecType string `json:"codec_type"` // video
|
||||
Width int `json:"width"` // 1920
|
||||
Height int `json:"height"` // 1080
|
||||
NumberFrames StringInteger `json:"nb_frames"` // 1
|
||||
RFrameRate StringFramerate `json:"r_frame_rate"` // 15/1
|
||||
Duration StringFloat `json:"duration"` // 251.800
|
||||
}
|
||||
|
||||
type ProbeResults struct {
|
||||
Streams []ProbeStream `json:"streams"`
|
||||
}
|
||||
Reference in New Issue
Block a user