commit 448f2e33ef85abfa90f5c0bc57d6d04fb46ea025 Author: bakonpancakz Date: Sat May 23 17:17:56 2026 -0700 rc-1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f177d10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.development +_public +_temp +node_modules +dist diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..5c64ff3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "golang.go"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a797443 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.formatOnSave": true, + "editor.rulers": [ + 120 + ], + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.associations": {}, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..94b1d51 --- /dev/null +++ b/README.txt @@ -0,0 +1,17 @@ + ╱|、 + gifuu — the anonymous gif site (゚ˎ 。7 + https://gifuu.pancakz.net |、˜〵 + じしˍ,)ノ + +-------------- [ Stack ] -------------- + +backend - REST API, Functions, Transcoding, AI Inference, etc. +frontend - Preact SPA w/ WebGL Renderer + +------------- [ Credits ] ------------- + +Special thanks to all these projects for making this possible: + +* https://github.com/GantMan/nsfw_model +* https://ffmpeg.org/ +* https://preactjs.com/ diff --git a/backend/Database.sql b/backend/Database.sql new file mode 100644 index 0000000..d969aad --- /dev/null +++ b/backend/Database.sql @@ -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$; diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..4d1df29 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..c17ff60 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/include/ANIMATION_METADATA.html b/backend/include/ANIMATION_METADATA.html new file mode 100644 index 0000000..34ab5b6 --- /dev/null +++ b/backend/include/ANIMATION_METADATA.html @@ -0,0 +1,30 @@ + + + + + + + {{ .title }} - gifuu + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/include/Embed.go b/backend/include/Embed.go new file mode 100644 index 0000000..960b4bb --- /dev/null +++ b/backend/include/Embed.go @@ -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 diff --git a/backend/include/nsfw.onnx b/backend/include/nsfw.onnx new file mode 100644 index 0000000..50597a8 Binary files /dev/null and b/backend/include/nsfw.onnx differ diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..72e310c --- /dev/null +++ b/backend/main.go @@ -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 + } +} diff --git a/backend/routes/DELETE_Art_ID.go b/backend/routes/DELETE_Art_ID.go new file mode 100644 index 0000000..5a39369 --- /dev/null +++ b/backend/routes/DELETE_Art_ID.go @@ -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) + } +} diff --git a/backend/routes/DELETE_Moderation_Art_ID.go b/backend/routes/DELETE_Moderation_Art_ID.go new file mode 100644 index 0000000..2f608b4 --- /dev/null +++ b/backend/routes/DELETE_Moderation_Art_ID.go @@ -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) +} diff --git a/backend/routes/GET_Art_ID.go b/backend/routes/GET_Art_ID.go new file mode 100644 index 0000000..28b9afe --- /dev/null +++ b/backend/routes/GET_Art_ID.go @@ -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) +} diff --git a/backend/routes/GET_Art_Latest.go b/backend/routes/GET_Art_Latest.go new file mode 100644 index 0000000..6bfacdc --- /dev/null +++ b/backend/routes/GET_Art_Latest.go @@ -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) +} diff --git a/backend/routes/GET_Art_Search.go b/backend/routes/GET_Art_Search.go new file mode 100644 index 0000000..3487c94 --- /dev/null +++ b/backend/routes/GET_Art_Search.go @@ -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) +} diff --git a/backend/routes/GET_Challenge.go b/backend/routes/GET_Challenge.go new file mode 100644 index 0000000..3b82557 --- /dev/null +++ b/backend/routes/GET_Challenge.go @@ -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, + }) +} diff --git a/backend/routes/GET_Limits.go b/backend/routes/GET_Limits.go new file mode 100644 index 0000000..f8d79cd --- /dev/null +++ b/backend/routes/GET_Limits.go @@ -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) +} diff --git a/backend/routes/GET_Metadata_ID.go b/backend/routes/GET_Metadata_ID.go new file mode 100644 index 0000000..ea1a23a --- /dev/null +++ b/backend/routes/GET_Metadata_ID.go @@ -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 + } +} diff --git a/backend/routes/GET_Tags_Autocomplete.go b/backend/routes/GET_Tags_Autocomplete.go new file mode 100644 index 0000000..c71fb6e --- /dev/null +++ b/backend/routes/GET_Tags_Autocomplete.go @@ -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) +} diff --git a/backend/routes/GET_Tags_Popular.go b/backend/routes/GET_Tags_Popular.go new file mode 100644 index 0000000..c7be524 --- /dev/null +++ b/backend/routes/GET_Tags_Popular.go @@ -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) +} diff --git a/backend/routes/POST_Art_ID_Reports.go b/backend/routes/POST_Art_ID_Reports.go new file mode 100644 index 0000000..8edc93e --- /dev/null +++ b/backend/routes/POST_Art_ID_Reports.go @@ -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) +} diff --git a/backend/routes/POST_Uploads.go b/backend/routes/POST_Uploads.go new file mode 100644 index 0000000..436a56f --- /dev/null +++ b/backend/routes/POST_Uploads.go @@ -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, + }) +} diff --git a/backend/tools/http_events.go b/backend/tools/http_events.go new file mode 100644 index 0000000..ab15686 --- /dev/null +++ b/backend/tools/http_events.go @@ -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) +} diff --git a/backend/tools/http_middleware.go b/backend/tools/http_middleware.go new file mode 100644 index 0000000..b1a9feb --- /dev/null +++ b/backend/tools/http_middleware.go @@ -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) + } +} diff --git a/backend/tools/http_requests.go b/backend/tools/http_requests.go new file mode 100644 index 0000000..044634a --- /dev/null +++ b/backend/tools/http_requests.go @@ -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 +} diff --git a/backend/tools/http_responses.go b/backend/tools/http_responses.go new file mode 100644 index 0000000..aee0940 --- /dev/null +++ b/backend/tools/http_responses.go @@ -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) +} diff --git a/backend/tools/service_database.go b/backend/tools/service_database.go new file mode 100644 index 0000000..981c5ce --- /dev/null +++ b/backend/tools/service_database.go @@ -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)) +} diff --git a/backend/tools/service_logger.go b/backend/tools/service_logger.go new file mode 100644 index 0000000..b41967c --- /dev/null +++ b/backend/tools/service_logger.go @@ -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) + } +} diff --git a/backend/tools/service_model.go b/backend/tools/service_model.go new file mode 100644 index 0000000..85554ce --- /dev/null +++ b/backend/tools/service_model.go @@ -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 +} diff --git a/backend/tools/util_configuration.go b/backend/tools/util_configuration.go new file mode 100644 index 0000000..d443583 --- /dev/null +++ b/backend/tools/util_configuration.go @@ -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) + } +} diff --git a/backend/tools/util_ffprobe.go b/backend/tools/util_ffprobe.go new file mode 100644 index 0000000..cd273a8 --- /dev/null +++ b/backend/tools/util_ffprobe.go @@ -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"` +} diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..51e9e8f --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,13 @@ +{ + "tabWidth": 4, + "useTabs": false, + "printWidth": 120, + "singleQuote": true, + "semi": false, + "endOfLine": "lf", + "arrowParens": "always", + "bracketSameLine": true, + "bracketSpacing": true, + "trailingComma": "all", + "plugins": ["prettier-plugin-css-order"] +} diff --git a/frontend/embed.html b/frontend/embed.html new file mode 100644 index 0000000..3df7ad0 --- /dev/null +++ b/frontend/embed.html @@ -0,0 +1,19 @@ + + + + + + + gifuu embed + {{ include-env }} + {{ include-tag 'style' 'include/embed/critical.css' }} + + + + + + + {{ include-tag 'script' 'include/embed/foreground.ts' }} + + + diff --git a/frontend/include/articles/api-guide.html b/frontend/include/articles/api-guide.html new file mode 100644 index 0000000..eee27f1 --- /dev/null +++ b/frontend/include/articles/api-guide.html @@ -0,0 +1,405 @@ +
+

API Guide

+

Last Updated: April 13th 2026

+
+ +
+ +
+

[ Introduction ]

+

+ The gifuu API is publicly accessible and requires no authentication + for read operations. We recommend that you have the client make requests + on their own instead of proxying requests for them to avoid issues with + our rate limits. +

+ +

Use the following URLs for HTTP requests:

+
+        https://api.gifuu.pancakz.net/                     // Base URL for API Requests
+        https://cdn.gifuu.pancakz.net/                     // Base URL for CDN Requests
+
+        https://cdn.gifuu.pancakz.net/{id}/preview.avif    // Up to 240px at 16fps
+        https://cdn.gifuu.pancakz.net/{id}/standard.avif   // Up to 720px at 60fps
+        https://cdn.gifuu.pancakz.net/{id}/alpha.webm      // See "Transparency" section below
+        https://cdn.gifuu.pancakz.net/{id}/standard.ogg    // See "Audio" section below
+    
+ +

Ratelimit headers are provided with each request:

+
+        X-Ratelimit-Category        // Endpoint Category  (a.k.a Bucket)
+        X-Ratelimit-Reset           // Seconds until reset (float string)
+        X-Ratelimit-Limit           // Requests allowed per period
+        X-Ratelimit-Remaining       // Requests left before 429 errors appear
+    
+ +
+ +

[ Transparency ]

+

+ gifuu stores animations (also referred to as Art) as AVIF a very new and modern format. + We sacrifice compatibility with older devices to gain massive efficiency in file size and visual quality. + Unfortunately AVIF doesn't natively support transparency, so we use a stacked video technique to encode + the alpha channel (transparency) alongside the color data. +

+

+ The alpha.webm file is a double-height video where the top half contains the + color data and the bottom half contains the alpha channel encoded as a grayscale luma + (brightness) map. White pixels signify opaque, black pixels signify transparent. +

+

+ To render this correctly in the browser you must use a WebGL fragment shader to composite + the two halves together. The following shader can be used as a reference: +

+
+        // Sample color from top half, alpha from bottom half
+        vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
+        vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
+
+        // Apply colors to a texture
+        vec4 color   = texture2D(uFrame, colorUV);
+        float alpha  = texture2D(uFrame, alphaUV).r;
+        gl_FragColor = vec4(color.rgb, alpha);
+    
+

+ This technique was pioneered by + Jake Archibald. + If you don't want to implement this yourself, you can embed our player by clicking the + EMBED button while viewing any animation. +

+ +

[ Audio ]

+

+ You can check for audio by reading the audio field on Art objects, or by + requesting it from the CDN and checking for a 404 Not Found response. +

+

+ Content is encoded with the Opus + codec inside an Ogg container + for compatibility with Apple devices. +

+ +
+ +

[ Object Types ]

+

The following types are returned as responses across API endpoints:

+ +

Object: Tag

+
+        {
+            "id":         string        // Tag ID (snowflake string)
+            "label":      string        // Tag Name
+            "usage":      number        // Number of animations using this tag
+        }
+    
+ +

Object: Art

+
+        {
+            "id":         string        // Animation ID (snowflake string)
+            "created":    string        // ISO 8601 Timestamp
+            "sticker":    boolean       // Is Static?
+            "audio":      boolean       // Has Audio?
+            "framerate":  number        // Approximate Framerate
+            "width":      number        // Approximate Width
+            "height":     number        // Approximate Height
+            "rating":     string        // NSFW Rating (string float, range: 0.0 - 1.0)
+            "title":      string        // Associated Title
+            "tags":       Tag[]         // Associated Tags
+        }
+    
+ +
+ +

[ Special ]

+ +
+ +

GET /limits

+

+ Returns upload constraints and validation rules for all user input fields. + The regex patterns and normalizer rules here are authoritative. + You must sanitize your inputs against them before submitting or the server will reject your request. +

+
+        Response Body:
+        {
+            "upload": {
+                "input_width_min":  number      // Minimum input width  (64px)
+                "input_height_min": number      // Minimum input height (64px)
+                "video_width_max":  number      // Maximum video width  (3840px)
+                "video_height_max": number      // Maximum video height (2160px)
+                "image_width_max":  number      // Maximum image width  (7680px)
+                "image_height_max": number      // Maximum image height (4320px)
+                "duration":         number      // Maximum duration in seconds (62s)
+                "filesize":         number      // Maximum file size in bytes
+                "mime_types":       string[]    // Accepted MIME types
+            }
+            "title": {
+                "normalizers": NormalizerRule[] // Apply before validating
+                "matcher":     string           // Regex pattern
+                "max_length":  number           // 80
+                "min_length":  number           // 1
+            }
+            "tag": {
+                "normalizers": NormalizerRule[]
+                "matcher":     string           // ^[\p{L}\p{N}_]{1,32}$
+                "max_length":  number           // 32
+                "min_length":  number           // 1
+            }
+            "comment": {
+                "normalizers": NormalizerRule[]
+                "matcher":     string
+                "max_length":  number           // 240
+                "min_length":  number           // 10
+            }
+            "report": {
+                "values": [                     // Valid report reason types
+                    { "id": number, "title": string, "description": string }
+                ]
+                "normalizers": NormalizerRule[]
+                "matcher":     string
+                "max_length":  number           // 240
+                "min_length":  number           // 10
+            }
+        }
+
+        Object: NormalizerRule
+        {
+            "match":   string   // Regex pattern to find
+            "replace": string   // Replacement string
+            "comment": string   // Human-readable description
+        }
+    
+ +
+ +

GET /challenge

+

+ Returns a fresh Proof of Work challenge. + You select the difficulty, the server enforces a minimum of 18. + Challenges expire after 5 minutes. + They are consumed immediately upon use even if the request fails. +

+

+ Provide your completed counter and given nonce to endpoints that require PoW via the + X-Pow-Counter and X-Pow-Nonce headers respectively. +

+
+        Query Parameters:
+            difficulty  number      // Desired difficulty (minimum: 18)
+
+        Response Body:
+        {
+            "nonce":      string    // Hex-encoded nonce
+            "difficulty": number    // Confirmed difficulty
+            "expires":    number    // Expiry as UNIX timestamp
+        }
+    
+ +

+ Some endpoints enforce a higher minimum difficulty than the global floor. + Request at least the required difficulty for the endpoint you intend to call or it will be rejected: +

+
+        Endpoint                    Minimum Difficulty
+        ------------------------    ------------------
+        POST /uploads               20
+    
+ +

Example Solver (JavaScript):

+
+        const { nonce, difficulty } = await fetch("/challenge?difficulty=18").then(r => r.json())
+        const encoder = new TextEncoder()
+        let counter = 0
+        while (true) {
+            const data = encoder.encode(nonce + counter)
+            const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", data))
+            let zeroBits = 0
+            for (const byte of hash) {
+                if (byte === 0) { zeroBits += 8 }
+                else { zeroBits += Math.clz32(byte) - 24; break }
+            }
+            if (zeroBits >= difficulty) break
+            counter++
+        }
+
+        // Submit nonce + counter with your upload via X-Pow-Nonce and X-Pow-Counter headers
+    
+ +
+ +

[ Tags ]

+

+ gifuu uses tags to make its database queryable. Sanitize tag strings against the rules + provided by limits.tags or the server will reject your request. +

+ +
+ +

GET /tags/popular

+

Returns the most popular tags (highest usage) on the platform.

+
+        Query Parameters:
+            limit   number      // Amount of results to return (range 1-100)
+
+        Response Body:
+            Tag[]
+    
+ +
+ +

GET /tags/autocomplete

+

Search for tags with a similar spelling using word similarity ranking.

+
+        Query Parameters:
+            query   string      // Search query — must pass validation rules from 'limits.tag'
+            limit   number      // Amount of results to return (range 1-100)
+
+        Response Body:
+        Tag[]
+    
+ +
+ +

[ Art ]

+

+ Content is processed and served as AVIF files for efficiency. Most modern web browsers + and operating systems support this format. +

+ +
+ +

GET /art/latest

+

Returns the most recently uploaded animations, newest first.

+
+        Query Parameters:
+            limit   number      // Amount of results to return (range 1-100)
+            after   string?     // Cursor for pagination — snowflake ID of last seen item (optional)
+
+        Response Body:
+        Art[]
+    
+ +
+ +

GET /art/search

+

+ Returns animations matching all provided tags (AND logic). At least one + tag parameter is required. +

+
+        Query Parameters:
+            tag     string      // Tag ID to filter by (snowflake string) — repeat for multiple tags
+            limit   number      // Amount of results to return (range 1-100)
+            after   string?     // Cursor for pagination — snowflake ID of last seen item (optional)
+
+        // Example: /art/search?tag=123&tag=456&limit=20
+
+        Response Body:
+        Art[]
+    
+ +
+ +

GET /art/{id}

+

Returns metadata for a single animation.

+
+        Response Body:
+        Art
+    
+ +
+ +

DELETE /art/{id}

+

+ Deletes an animation. Requires the edit token returned at upload time, + passed as a query parameter. Responds with 204 No Content on success. +

+
+        Query Parameters:
+            token   string      // Edit token from upload response
+    
+ +
+ +

POST /art/{id}/reports

+

+ Submits a moderation report for an animation. + Valid reason type IDs are listed in limits.report.values. + The reason text must pass the report validation rules from the same endpoint. + Responds with 204 No Content on success. +

+
+        Request Body:
+        {
+            "type":   number    // Report reason ID (see 'limits.report.values')
+            "reason": string    // Description of the issue (10-240 characters)
+        }
+    
+ +
+ +

[ Uploads ]

+

Endpoints for creating and monitoring uploads.

+ +
+ +

POST /uploads

+

+ Uploads an animation to the site. To prevent spam this endpoint requires a valid + Proof of Work challenge solved via GET /challenge with a minimum difficulty of 20. +

+

+ This endpoint responds as a Server-Sent Events (SSE) stream. Events are + emitted throughout processing to report progress. The connection closes after the final + finish event or on any error. +

+

+ NOTE: Requests exceeding the limits.upload.filesize limit will be aborted immediately. +

+
+        Request Body: [multipart/form-data]
+
+        field: data (text/JSON)
+            {
+                "title": string     // Animation title (1-80 characters, see 'limits.title')
+                "tags":  string[]   // Tag names to attach, plaintext (see 'limits.tag')
+            }
+
+        field: file (binary)
+            // Accepted MIME types may change, fetch the current list from 'limits.upload.mime_types'
+            image/jpeg,  image/png,   image/gif, image/webp, image/heic, image/heif,
+            image/avif,  image/jxl,   image/tiff, image/bmp,
+            video/mp4,   video/webm,  video/quicktime,  video/x-matroska,
+            video/avi,   video/x-ms-wmv,
+
+        Request Headers:
+            X-Pow-Nonce     // Nonce from GET /challenge
+            X-Pow-Counter   // Your solved counter value
+    
+ +
+        SSE Event Stream:
+
+        event: id           // Emitted early — the assigned snowflake ID for this upload
+            { "id": string }
+
+        event: step         // Processing stage updates
+            { "id": string, "message": string }
+            // Known step IDs: PROBE_QUEUE, PROBE_START, SERVER_FINALIZE
+
+        event: progress     // Encoding/classification progress
+            { "percent": string }   // Float string, e.g. "42.50"
+
+        event: finish       // Final event on success — save edit_token, it is not recoverable!
+            {
+                "id":         string    // Animation snowflake ID
+                "edit_token": string    // Required to delete this animation later
+            }
+
+        event: error        // Emitted on client or server error, stream closes after
+            { "code": number, "message": string }
+    
+ +
diff --git a/frontend/include/articles/privacy-policy.html b/frontend/include/articles/privacy-policy.html new file mode 100644 index 0000000..ebf49c6 --- /dev/null +++ b/frontend/include/articles/privacy-policy.html @@ -0,0 +1,51 @@ +
+

Privacy Policy

+

Last Updated: March 21st 2026

+
+ +
+ +
+

[ Data Collection ]

+

We collect the following data in order to provide you with our services:

+
+

Content that you upload

+

Your edit tokens¹

+

Your IP address²

+
+

+ ¹ Edit tokens are kept on your device when using our website. + Please back them up via the settings menu, as we cannot assist in recovery. +

+

+ ² Your IP address is stored in hashed form via a one-way algorithm to reduce direct identification. + We use this data to prevent abuse on our platform and issue disciplinary actions towards bad actors. + Decisions are made at our discretion and are final. +

+
+ +
+ +
+

[ Third Party ]

+

+ We use a self-hosted instance of Umami + for analytics to see how people arrive at and interact with our site. + We use UTM parameters, + which can be manually removed if desired. +

+

+ All of this data is stored anonymously on our own servers and isn’t shared with any third parties. +

+
+ +
+ +
+

[ Contact ]

+

+ gifuu is a personal project operated by bakonpancakz. + For privacy or legal concerns, please visit: + https://pancakz.net/ +

+
diff --git a/frontend/include/articles/terms-of-service.html b/frontend/include/articles/terms-of-service.html new file mode 100644 index 0000000..c97ef69 --- /dev/null +++ b/frontend/include/articles/terms-of-service.html @@ -0,0 +1,69 @@ +
+

Terms of Service

+

Last Updated: March 21st 2026

+
+ +
+ +
+

[1] Acceptance

+

+ By using gifuu, you agree to the following terms of service. + If you do not agree to these terms, do not use our platform. +

+

+ These terms may be updated at any time without prior notice. Continued use constitutes acceptance. +

+
+ +
+ +
+

[2] Content

+

+ You are solely responsible for any content you upload to gifuu. + You may not upload, distribute, or store content that: +

+

+ Violates any applicable law or regulation; + infringes on any copyright, trademark, or other intellectual property right; + depicts any being in a sexual or exploitative manner; + constitutes targeted harassment, hate speech, or incitement of violence; + depicts graphic violence, gore, or abuse; + promotes or depicts self-harm, dangerous activity, or seizure-inducing imagery; + constitutes spam, advertising, or unsolicited solicitation; + or that you do not have the rights to distribute. +

+

+ We reserve the right to moderate, remove content, or restrict access to our platform at our discretion. + Violations may be reported by users and are reviewed by our moderation team. +

+
+ +
+ +
+

[3] Data Collection

+

+ You agree to our data collection and privacy policies. + A description of how we collect and process your data is available here. +

+
+ +
+ +
+

[4] Availability

+

+ We are not liable for any loss of data or damages resulting from use of the platform. +

+
+ +
+ +
+

[5] Other

+

+ You agree that bunnies are adorable. +

+
diff --git a/frontend/include/embed/critical.css b/frontend/include/embed/critical.css new file mode 100644 index 0000000..08bf8e4 --- /dev/null +++ b/frontend/include/embed/critical.css @@ -0,0 +1,106 @@ +:root { + --animation-transition: 200ms; + --border-thickness: 2px; + + --background-tertiary: hsl(0, 0%, 0%); + --background-secondary: hsl(0, 0%, 16%); + --background-primary: hsl(0, 0%, 32%); + + --font-color-accent: hsl(0, 50%, 80%); + --font-color-primary: hsl(0, 0%, 95%); + --font-color-secondary: hsl(0, 0%, 65%); +} + +html, +body { + box-sizing: border-box; + margin: 0; + background: transparent; + padding: 0; + overflow: hidden; +} + +.effect-centered { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +a.layout-wrapper { + display: inline-block; + position: relative; + cursor: pointer; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +a.layout-wrapper img.watermark { + position: absolute; + right: 16px; + bottom: 16px; + opacity: 0.8; + transition: var(--animation-transition) ease-in-out opacity; + height: 4vw; + min-height: 20px; +} + +a.layout-wrapper:hover img.watermark, +a.layout-wrapper:focus-visible img.watermark { + opacity: 1; +} + +/* Layout: Error */ +a.layout-wrapper div.error { + box-sizing: border-box; + border: var(--border-thickness) solid var(--background-primary); + background-image: + repeating-linear-gradient( + 45deg, + transparent, + transparent 8px, + var(--background-secondary) 8px, + var(--background-secondary) 9px + ), + repeating-linear-gradient( + -45deg, + transparent, + transparent 8px, + var(--background-secondary) 8px, + var(--background-secondary) 9px + ); + background-color: var(--background-tertiary); + width: 100%; + height: 100%; +} + +a.layout-wrapper div.error p { + border: var(--border-thickness) solid var(--background-primary); + background-color: var(--background-tertiary); + padding: 16px 32px; + width: fit-content; + color: var(--font-color-primary); + font-size: large; + font-family: monospace; + text-align: center; +} + +/* Layout: Canvas */ +a.layout-wrapper video.decoder { + position: absolute; + visibility: hidden; +} + +a.layout-wrapper canvas.render { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Layout: Image */ +a.layout-wrapper img.render { + width: 100%; + height: 100%; + object-fit: contain; +} diff --git a/frontend/include/embed/foreground.ts b/frontend/include/embed/foreground.ts new file mode 100644 index 0000000..4a29afe --- /dev/null +++ b/frontend/include/embed/foreground.ts @@ -0,0 +1,269 @@ +;(() => { + let defer: (() => void)[] = [] + let running = false + let paramQuality: 'standard' | 'transparent' + let paramID: bigint + + const elemHost = document.querySelector('a.layout-wrapper') + // @ts-expect-error + const BASE_CDN = window.__ENV__.CDN + // @ts-expect-error + const BASE_WEB = window.__ENV__.WEB + + if (BASE_CDN === undefined || BASE_WEB === undefined || !elemHost) { + console.error('[gifuu] Invalid Document') + return + } + + try { + const search = new URLSearchParams(window.location.search) + const givenQuality = search.get('quality') + const givenID = BigInt(search.get('id') ?? '0') + + if (givenID < 1) { + return exit('Invalid ID') + } + if (givenQuality !== 'standard' && givenQuality !== 'transparent') { + return exit('Invalid Quality') + } + + paramQuality = givenQuality + paramID = givenID + } catch (error) { + exit(error) + return + } + + function setupGL() { + if (!elemHost) throw 'Missing Anchor Node' + + // Setup Elements + const elemCanvas = document.createElement('canvas') + elemCanvas.classList.add('render') + elemCanvas.addEventListener('webglcontextlost', (ev) => { + ev.preventDefault() + console.warn('[gifuu] Failed to allocate a WebGL context for us! Using image fallback...') + teardown() + setupImage() + }) + + elemHost.appendChild(elemCanvas) + defer.push(() => elemCanvas.remove()) + + const elemVideo = document.createElement('video') + elemVideo.classList.add('decode') + elemVideo.crossOrigin = 'anonymous' + elemVideo.playsInline = true + elemVideo.autoplay = true + elemVideo.muted = true + elemVideo.loop = true + + elemHost.appendChild(elemVideo) + defer.push(() => elemVideo.remove()) + + // Setup Context + const gl = elemCanvas.getContext('webgl', { + powerPreference: 'low-power', + premultipliedAlpha: false, + antialias: false, + alpha: true, + depth: false, + }) + if (!gl) { + console.warn('[gifuu] WebGL is unsupported, using image fallback...') + teardown() + setupImage() + return + } + + try { + const VERT = ` + precision mediump float; + attribute vec2 aPos; + uniform mat3 uMatrix; + varying vec2 vUV; + void main() { + gl_Position = vec4(uMatrix * vec3(aPos, 1.0), 1.0); + vUV = aPos; + }` + + const FRAG = ` + precision mediump float; + uniform sampler2D uFrame; + varying vec2 vUV; + void main() { + vec2 colorUV = vec2(vUV.x, vUV.y * 0.5); + vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5); + vec4 color = texture2D(uFrame, colorUV); + float alpha = texture2D(uFrame, alphaUV).r; + gl_FragColor = vec4(color.rgb, alpha); + }` + + function compileShader(type: number, src: string) { + if (!gl) throw 'Missing GL Context' + + const s = gl.createShader(type) + if (!s) throw 'Shader compilation failed' + + gl.shaderSource(s, src) + gl.compileShader(s) + return s + } + + const prog = gl.createProgram() + gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, VERT)) + gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, FRAG)) + gl.linkProgram(prog) + gl.useProgram(prog) + defer.push(() => gl.deleteProgram(prog)) + + // --- Quad --- + const buf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), gl.STATIC_DRAW) + defer.push(() => gl.deleteBuffer(buf)) + + const aPos = gl.getAttribLocation(prog, 'aPos') + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0) + gl.uniformMatrix3fv(gl.getUniformLocation(prog, 'uMatrix'), false, [2, 0, 0, 0, -2, 0, -1, 1, 1]) + + // --- Texture --- + const tex = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.uniform1i(gl.getUniformLocation(prog, 'uFrame'), 0) + defer.push(() => gl.deleteTexture(tex)) + } catch (error) { + console.error('[gifuu] Failed to Initialize WebGL dependencies:', error) + teardown() + setupImage() + return + } + + // Tick Function + let cancel = 0 + let sized = false + function tick() { + cancel = requestAnimationFrame(tick) + try { + if (!gl) throw 'Missing GL Context' + if (!sized && elemVideo.videoWidth > 0) { + sized = true + elemCanvas.width = elemVideo.videoWidth + elemCanvas.height = Math.floor(elemVideo.videoHeight / 2) + gl.viewport(0, 0, elemCanvas.width, elemCanvas.height) + } + if (!sized) return + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, elemVideo) + gl.drawArrays(gl.TRIANGLES, 0, 6) + } catch (error) { + console.warn('[gifuu] Draw failed, using image fallback...', error) + teardown() + setupImage() + return + } + } + defer.push(() => cancelAnimationFrame(cancel)) + + // Download Video + fetch(`${BASE_CDN}/${paramID}/alpha.webm`, { mode: 'cors', cache: 'force-cache' }) + .then((r) => r.blob()) + .then((blob) => { + const content = URL.createObjectURL(blob) + defer.push(() => URL.revokeObjectURL(content)) + + elemVideo.src = content + elemVideo.play().catch(() => {}) + tick() + }) + .catch((error) => { + console.warn('[gifuu] Video download failed, using image fallback...', error) + teardown() + setupImage() + }) + } + + function setupImage() { + if (!elemHost) throw 'Missing Image Node' + + // Create Element + const elemImage = document.createElement('img') + elemImage.classList.add('render') + + elemHost.appendChild(elemImage) + defer.push(() => elemImage.remove()) + + // Download Image + fetch(`${BASE_CDN}/${paramID}/${paramQuality}.avif`, { mode: 'cors', cache: 'force-cache' }) + .then((r) => r.blob()) + .then((blob) => { + const content = URL.createObjectURL(blob) + defer.push(() => URL.revokeObjectURL(content)) + + elemImage.src = content + }) + .catch((error) => { + console.warn('[gifuu] Image download failed, quitting...', error) + teardown() + exit('Media Unavailable') + }) + } + + function teardown() { + let func + while ((func = defer.shift())) { + try { + func() + } catch (error) { + console.error('[gifuu] Teardown failed:', error) + } + } + } + + function exit(error: any) { + console.error('[gifuu] Exiting Embed:', error) + teardown() + + if (!elemHost) return + elemHost.href = BASE_WEB + + const container = document.createElement('div') + container.classList.add('error') + + const message = document.createElement('p') + message.classList.add('message', 'effect-centered') + message.textContent = String(error) + container.append(message) + + elemHost.append(container) + } + + // Lifecycle + elemHost.href = `${BASE_WEB}/art/${paramID}` + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !running) { + running = true + console.log('[gifuu] Showtime! Setting up...') + paramQuality === 'transparent' ? setupGL() : setupImage() + } else if (!entries[0].isIntersecting && running) { + running = false + console.log('[gifuu] Out of View! Tearing down...') + teardown() + } + }, + { threshold: 0.1 }, + ) + observer.observe(elemHost) + + window.addEventListener('pagehide', () => { + console.log('[gifuu] Goodbye!') + observer.disconnect() + elemHost.remove() + teardown() + }) +})() diff --git a/frontend/include/favicon.svg b/frontend/include/favicon.svg new file mode 100644 index 0000000..8c04752 --- /dev/null +++ b/frontend/include/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/include/index/background.ts b/frontend/include/index/background.ts new file mode 100644 index 0000000..ffb8551 --- /dev/null +++ b/frontend/include/index/background.ts @@ -0,0 +1,567 @@ +;(() => { + const elemParent = document.querySelector('div.layout-background') + const elemSprite = document.querySelector('link[rel="texture"]') + if (!elemParent || !elemSprite) throw 'Invalid Document' + + const FRAME_INTERVAL = 1000 + const FRAME_TIME_IDLE = 12 + const FRAME_TIME_ACTIVE = 60 + const CAM_ACCEL = 2 + const CAM_FRICTION = 0.85 + const PARTICLE_COUNT = 160 + const COLOR_PARTICLE = 0x484848 + const COLOR_FOREGROUND = 0x363636 + const COLOR_BACKGROUND = 0x000000 + + let keysHeld = new Set() + let frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE + let lastTime = 0 + let lastFrame = 0 + let time = 0 + let cameraMVP = mat4() + let cameraRotX = 0 + let cameraRotY = 0 + let camVelRotX = 0 + let camVelRotY = 0 + let spriteTexture: WebGLTexture + let spriteModel: Float32Array + let particlePos: Float32Array + let particleColor: Float32Array + let particleData: { + x: number + y: number + z: number + vx: number + vy: number + vz: number + life: number + maxLife: number + }[] = [] + let planeW: number + let planeH: number + let planeSegX: number + let planeSegY: number + let planeVerts: Float32Array + let planeOrig: Float32Array + let planeIdx + + const canvas = document.createElement('canvas') + const gl = canvas.getContext('webgl') + if (!gl) throw 'Failed to allocate WebGL Context' + + // --- Camera Controls --- + document.addEventListener('keydown', (e) => { + if (!e.shiftKey && !e.ctrlKey) return + if (e.target instanceof HTMLTextAreaElement) return + if (e.target instanceof HTMLInputElement) return + if ((e as any).isContentEditable) return + keysHeld.add(e.key) + }) + document.addEventListener('keyup', (e) => { + keysHeld.delete(e.key) + }) + + // --- Math Functions --- + function mat4(): Float32Array { + return new Float32Array(16) + } + function mat4Identity(m: Float32Array): Float32Array { + m[0] = 1 + m[1] = 0 + m[2] = 0 + m[3] = 0 + m[4] = 0 + m[5] = 1 + m[6] = 0 + m[7] = 0 + m[8] = 0 + m[9] = 0 + m[10] = 1 + m[11] = 0 + m[12] = 0 + m[13] = 0 + m[14] = 0 + m[15] = 1 + return m + } + function mat4Multiply(out: Float32Array, a: Float32Array, b: Float32Array): Float32Array { + for (let i = 0; i < 4; i++) + for (let j = 0; j < 4; j++) { + out[j * 4 + i] = 0 + for (let k = 0; k < 4; k++) out[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k] + } + return out + } + function mat4Perspective(m: Float32Array, fovY: number, aspect: number, near: number, far: number): Float32Array { + const f = 1.0 / Math.tan(fovY / 2) + m[0] = f / aspect + m[1] = 0 + m[2] = 0 + m[3] = 0 + m[4] = 0 + m[5] = f + m[6] = 0 + m[7] = 0 + m[8] = 0 + m[9] = 0 + m[10] = (far + near) / (near - far) + m[11] = -1 + m[12] = 0 + m[13] = 0 + m[14] = (2 * far * near) / (near - far) + m[15] = 0 + return m + } + function mat4RotateX(m: Float32Array, angle: number): Float32Array { + const c = Math.cos(angle) + const s = Math.sin(angle) + const t = mat4Identity(mat4()) + t[5] = c + t[6] = s + t[9] = -s + t[10] = c + return mat4Multiply(mat4(), t, m) + } + function mat4RotateY(m: Float32Array, angle: number): Float32Array { + const c = Math.cos(angle) + const s = Math.sin(angle) + const t = mat4Identity(mat4()) + t[0] = c + t[2] = -s + t[8] = s + t[10] = c + return mat4Multiply(mat4(), t, m) + } + function mat4Translate(m: Float32Array, x: number, y: number, z: number): Float32Array { + const t = mat4Identity(mat4()) + t[12] = x + t[13] = y + t[14] = z + return mat4Multiply(mat4(), t, m) + } + function randFloat(lo: number, hi: number): number { + return lo + Math.random() * (hi - lo) + } + function randFloatSpread(range: number): number { + return randFloat(-range / 2, range / 2) + } + function degToRad(d: number): number { + return (d * Math.PI) / 180 + } + function intToRGB(i: number): [number, number, number] { + return [((i >> 16) & 0xff) / 255, ((i >> 8) & 0xff) / 255, ((i >> 0) & 0xff) / 255] + } + + // --- Prepare Shaders --- + function createShader(type: number, src: string): WebGLShader { + if (!gl) throw 'Missing Global GL Context' + const s = gl.createShader(type) + if (!s) throw 'Shader Compilation Failed' + + gl.shaderSource(s, src) + gl.compileShader(s) + return s + } + + function createProgram(vert: string, frag: string): WebGLProgram { + if (!gl) throw 'Missing Global GL Context' + const p = gl.createProgram() + gl.attachShader(p, createShader(gl.VERTEX_SHADER, vert)) + gl.attachShader(p, createShader(gl.FRAGMENT_SHADER, frag)) + gl.linkProgram(p) + return p + } + + const planeProg = createProgram( + `attribute vec3 aPosition; + uniform mat4 uMVP; + uniform float uTime; + varying float vDist; + void main() { + float dist = sqrt(aPosition.x * aPosition.x + aPosition.z * aPosition.z); + float wave = sin(dist * 0.5 - uTime); + float cave = -exp(-dist * 0.1) * 3.5; + vec3 pos = vec3(aPosition.x, aPosition.y + wave + cave, aPosition.z); + vDist = length((uMVP * vec4(pos, 1.0)).xyz); + gl_Position = uMVP * vec4(pos, 1.0); + }`, + `precision mediump float; + uniform vec3 uFogColor; + uniform float uFogNear; + uniform float uFogFar; + varying float vDist; + void main() { + float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0); + vec3 color = mix(vec3(${intToRGB(COLOR_FOREGROUND).join(',')}), uFogColor, fog); + gl_FragColor = vec4(color, 1.0); + }`, + ) + + const particleProg = createProgram( + `attribute vec3 aPosition; + attribute vec4 aColor; + uniform mat4 uMVP; + varying vec4 vColor; + varying float vDist; + void main() { + vec4 pos = uMVP * vec4(aPosition, 1.0); + vDist = length(pos.xyz); + vColor = aColor; + gl_PointSize = 3.0; + gl_Position = pos; + }`, + `precision mediump float; + uniform vec3 uFogColor; + uniform float uFogNear; + uniform float uFogFar; + varying vec4 vColor; + varying float vDist; + void main() { + float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0); + float alpha = vColor.a * (1.0 - fog); + gl_FragColor = vec4(mix(vColor.rgb, uFogColor, fog), alpha); + }`, + ) + + const spriteProg = createProgram( + `attribute vec2 aPosition; + attribute vec2 aUV; + uniform mat4 uMVP; + varying vec2 vUV; + void main() { + vUV = aUV; + gl_Position = uMVP * vec4(aPosition.x , 0, aPosition.y, 1); + }`, + `precision mediump float; + uniform sampler2D uTex; + varying vec2 vUV; + void main() { + gl_FragColor = texture2D(uTex, vUV); + }`, + ) + + const planeBuf = gl.createBuffer() + const planeOrigBuf = gl.createBuffer() + const planeIdxBuf = gl.createBuffer() + const uTimePlane = gl.getUniformLocation(planeProg, 'uTime') + const uMVPPlane = gl.getUniformLocation(planeProg, 'uMVP') + const uFogColorPlane = gl.getUniformLocation(planeProg, 'uFogColor') + const uFogNearPlane = gl.getUniformLocation(planeProg, 'uFogNear') + const uFogFarPlane = gl.getUniformLocation(planeProg, 'uFogFar') + + const particlePosBuf = gl.createBuffer() + const particleColorBuf = gl.createBuffer() + const uMVPParticle = gl.getUniformLocation(particleProg, 'uMVP') + const uFogColorParticle = gl.getUniformLocation(particleProg, 'uFogColor') + const uFogNearParticle = gl.getUniformLocation(particleProg, 'uFogNear') + const uFogFarParticle = gl.getUniformLocation(particleProg, 'uFogFar') + + const spriteImage = new Image() + spriteImage.src = elemSprite.href + + const spriteBuf = gl.createBuffer() + const spriteIdxBuf = gl.createBuffer() + const uMVPSprite = gl.getUniformLocation(spriteProg, 'uMVP') + const uTexSprite = gl.getUniformLocation(spriteProg, 'uTex') + + function spawnParticle(i: number) { + // Put them in the center because its out of frame anyways save some resources + const x = randFloat(-planeW / 2, planeW / 2) + const z = randFloat(-planeH / 2, planeH / 2) + const l = randFloat(3.0, 6.0) + + particleData[i] = { + x, + y: 0, + z, + vx: randFloatSpread(0.05), + vy: randFloat(0.02, 0.05), + vz: randFloatSpread(0.05), + life: l, + maxLife: l, + } + const p = i * 3 + particlePos[p + 0] = x + particlePos[p + 1] = 0 + particlePos[p + 2] = z + + const c = i * 4 + const [r, g, b] = intToRGB(COLOR_PARTICLE) + particleColor[c + 0] = r + particleColor[c + 1] = g + particleColor[c + 2] = b + particleColor[c + 3] = 0 + } + + function startup() { + if (!gl) throw 'Missing Global GL Context' + + // Render Resolution + canvas.width = Math.floor(window.innerWidth * 0.3) + canvas.height = Math.floor(window.innerHeight * 0.3) + canvas.style.width = window.innerWidth + 'px' + canvas.style.height = window.innerHeight + 'px' + canvas.style.imageRendering = 'pixelated' + + // Build Sprite + spriteImage.onload = () => { + gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf) + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, 1, 1, -1, 0, 1, 1, 1, 0, 0, -1, 1, 1, 0]), + gl.STATIC_DRAW, + ) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW) + + spriteModel = mat4Identity(mat4()) + spriteModel = mat4RotateY(spriteModel, degToRad(180)) + spriteModel = mat4Translate(spriteModel, 0, 4, 16) + + // Upload Texture + spriteTexture = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, spriteTexture) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, spriteImage) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + + gl.useProgram(spriteProg) + gl.uniform1i(uTexSprite, 0) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, spriteTexture) + gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf) + } + + // Build Plane + gl.viewport(0, 0, canvas.width, canvas.height) + gl.clearColor(...intToRGB(COLOR_BACKGROUND), 1) + gl.enable(gl.BLEND) + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) + + const ratio = canvas.width / canvas.height + const scale = 24 + planeSegX = Math.round(12 * ratio) + planeSegY = 12 + planeW = scale * ratio + planeH = scale + + const nx = planeSegX + 1 + const ny = planeSegY + 1 + planeVerts = new Float32Array(nx * ny * 3) + planeOrig = new Float32Array(nx * ny * 3) + + let vi = 0 + for (let iy = 0; iy < ny; iy++) { + for (let ix = 0; ix < nx; ix++) { + const x = (ix / planeSegX - 0.5) * planeW + const z = (iy / planeSegY - 0.5) * planeH + planeVerts[vi] = x + planeVerts[vi + 1] = 0 + planeVerts[vi + 2] = z + planeOrig[vi] = x + planeOrig[vi + 1] = 0 + planeOrig[vi + 2] = z + vi += 3 + } + } + const lines = [] + for (let iy = 0; iy < ny; iy++) { + for (let ix = 0; ix < nx; ix++) { + const idx = iy * nx + ix + // wireframe indices two triangles per quad + if (ix < planeSegX) { + lines.push(idx, idx + 1) + } + if (iy < planeSegY) { + lines.push(idx, idx + nx) + } + if (ix < planeSegX && iy < planeSegY) { + lines.push(idx, idx + nx + 1) + } + } + } + planeIdx = new Uint16Array(lines) + + // Upload plane buffers + gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf) + gl.bufferData(gl.ARRAY_BUFFER, planeVerts, gl.DYNAMIC_DRAW) + gl.bindBuffer(gl.ARRAY_BUFFER, planeOrigBuf) + gl.bufferData(gl.ARRAY_BUFFER, planeOrig, gl.STATIC_DRAW) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, planeIdx, gl.STATIC_DRAW) + + // Initialize particles if first time + if (particleData.length === 0) { + particlePos = new Float32Array(PARTICLE_COUNT * 3) + particleColor = new Float32Array(PARTICLE_COUNT * 4) + for (let i = 0; i < PARTICLE_COUNT; i++) { + spawnParticle(i) + } + } + + gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf) + gl.bufferData(gl.ARRAY_BUFFER, particlePos, gl.DYNAMIC_DRAW) + gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf) + gl.bufferData(gl.ARRAY_BUFFER, particleColor, gl.DYNAMIC_DRAW) + + // Initialize Fog + gl.useProgram(planeProg) + gl.uniform3f(uFogColorPlane, ...intToRGB(COLOR_BACKGROUND)) + gl.uniform1f(uFogNearPlane, 2) + gl.uniform1f(uFogFarPlane, 22) + + gl.useProgram(particleProg) + gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP) + gl.uniform3f(uFogColorParticle, ...intToRGB(COLOR_BACKGROUND)) + gl.uniform1f(uFogNearParticle, 6) + gl.uniform1f(uFogFarParticle, 24) + + // Force dirty camera reset + camVelRotY = 0.005 + } + + function animate(now: number) { + requestAnimationFrame(animate) + + // Sleep (warning this sucks) + if (now - lastFrame < frameTime) return + const delta = (now - lastTime) * 0.00008 || 0 + lastFrame = now + lastTime = now + time += delta + + if (!gl) throw 'Missing Global GL Context' + gl.clear(gl.COLOR_BUFFER_BIT) + + // Camera Movement + if (keysHeld.has('ArrowLeft')) camVelRotY -= CAM_ACCEL + if (keysHeld.has('ArrowRight')) camVelRotY += CAM_ACCEL + if (keysHeld.has('ArrowUp')) camVelRotX -= CAM_ACCEL + if (keysHeld.has('ArrowDown')) camVelRotX += CAM_ACCEL + 8 + + camVelRotX *= CAM_FRICTION + camVelRotY *= CAM_FRICTION + const dirty = Math.abs(camVelRotX) + Math.abs(camVelRotY) > 0.001 + + if (dirty) { + cameraRotX += camVelRotX * delta * 1000 + cameraRotY += camVelRotY * delta * 1000 + + // Update Camera + const ratio = canvas.width / canvas.height + const proj = mat4Perspective(mat4(), degToRad(70), ratio, 0.1, 100) + + let view = mat4Identity(mat4()) + view = mat4Translate(view, 0, -7.5, -15) + view = mat4RotateX(view, degToRad(camVelRotX + 33.75)) + view = mat4RotateY(view, degToRad(camVelRotY)) + + cameraMVP = mat4Multiply(mat4(), proj, view) + gl.useProgram(planeProg) + gl.uniformMatrix4fv(uMVPPlane, false, cameraMVP) + gl.useProgram(particleProg) + gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP) + + frameTime = FRAME_INTERVAL / FRAME_TIME_ACTIVE + } else { + frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE + } + + // Update Sprite + if (spriteTexture) { + const spriteMVP = mat4Multiply(mat4(), cameraMVP, spriteModel) + gl.useProgram(spriteProg) + gl.uniformMatrix4fv(uMVPSprite, false, spriteMVP) + gl.uniform1i(uTexSprite, 0) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, spriteTexture) + + gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf) + const aPos = gl.getAttribLocation(spriteProg, 'aPosition') + const aUV = gl.getAttribLocation(spriteProg, 'aUV') + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0) + gl.enableVertexAttribArray(aUV) + gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8) + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf) + gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0) + } + + { + // Update Particles + for (let i = 0; i < PARTICLE_COUNT; i++) { + const p = particleData[i] + + // fade out + p.life -= delta + if (p.life <= 0) { + spawnParticle(i) + continue + } + particleColor[i * 4 + 3] = Math.min((p.life / p.maxLife) * 1.2, 1) + + // drift away + p.x += p.vx * delta * 40 + p.y += p.vy * delta * 20 + p.z += p.vz * delta * 40 + const pi = i * 3 + particlePos[pi + 0] = p.x + particlePos[pi + 1] = p.y + particlePos[pi + 2] = p.z + } + gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, particlePos) + gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleColor) + + // Draw Particles + gl.useProgram(particleProg) + const aPos = gl.getAttribLocation(particleProg, 'aPosition') + const aCol = gl.getAttribLocation(particleProg, 'aColor') + + gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf) + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0) + + gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf) + gl.enableVertexAttribArray(aCol) + gl.vertexAttribPointer(aCol, 4, gl.FLOAT, false, 0, 0) + + gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT) + } + + { + // Update Plane + for (let i = 0; i < planeVerts.length; i += 3) { + const x = planeOrig[i] + const z = planeOrig[i + 2] + const dist = Math.sqrt(x * x + z * z) + planeVerts[i] = x + planeVerts[i + 1] = Math.sin(dist * 0.5 - time) * 0.5 + -Math.exp(-dist * 0.1) * 3.5 + planeVerts[i + 2] = z + } + gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf) + gl.bufferSubData(gl.ARRAY_BUFFER, 0, planeVerts) + + // Draw Plane + gl.useProgram(planeProg) + gl.uniform1f(uTimePlane, time) + + const aPos = gl.getAttribLocation(planeProg, 'aPosition') + gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf) + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0) + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf) + gl.drawElements(gl.LINES, planeIdx.length, gl.UNSIGNED_SHORT, 0) + } + } + + // --- Prepare Canvas --- + window.addEventListener('resize', startup) + startup() + animate(0) + elemParent.append(canvas) +})() diff --git a/frontend/include/index/critical.css b/frontend/include/index/critical.css new file mode 100644 index 0000000..f110c7d --- /dev/null +++ b/frontend/include/index/critical.css @@ -0,0 +1,209 @@ +:root { + --animation-duration: 500ms; + --animation-transition: 200ms; + --animation-load-delay: 200ms; + --animation-step-delay: 50ms; + + --border-thickness: 1px; + + --background-tertiary: hsl(0, 0%, 0%); + --background-secondary: hsl(0, 0%, 16%); + --background-primary: hsl(0, 0%, 32%); + --background-highlight: hsl(0, 0%, 80%); + --background-translucent: hsla(0, 0%, 0%, 0.3); + + --font-color-accent: hsl(0, 50%, 80%); + --font-color-primary: hsl(0, 0%, 95%); + --font-color-secondary: hsl(0, 0%, 65%); + + --effect-glass-corner-thickness: 1px; + --effect-glass-corner-offset: -16px; + --effect-glass-corner-margin: 16px; + --effect-glass-corner-color: hsl(0, 0%, 30%); + --effect-glass-tint: hsla(0, 0%, 100%, 0.075); + --effect-glass-blur: 4px; +} + +::-webkit-scrollbar { + width: 0; +} + +html, +body { + margin: 0; + background-color: var(--background-tertiary); + padding: 0; +} + +p, +a, +pre, +code, +span, +input, +textarea, +label, +button { + display: block; + margin: 0; + border: none; + background-color: transparent; + padding: 0; + color: var(--font-color-primary); + font-style: normal; + font-weight: normal; + font-size: 1em; + line-height: 1em; + font-family: 'Terminus', monospace; +} + +input::placeholder { + color: var(--font-color-secondary); +} + +/* Global Layout */ +span.layout-tooltip { + border: var(--border-thickness) solid var(--background-primary); + background-color: var(--background-tertiary); + padding: 4px 8px; +} + +div.layout-wrapper { + display: grid; + width: 100%; + height: 100%; +} + +div.layout-background { + position: fixed; + width: 100vw; + height: 100vh; +} + +div.layout-foreground { + display: flex; + justify-content: center; + gap: 16px; + margin: auto; + margin-top: 16px; + width: 100%; + max-width: 1024px; + max-height: 100vh; +} + +.layout-scrolling { + max-height: calc(100vh - var(--effect-glass-corner-margin) * 4); + overflow-x: hidden; + overflow-y: scroll; +} + +nav.layout-sidebar { + box-sizing: border-box; + padding: 16px; + width: 300px; +} + +main.layout-content { + box-sizing: border-box; + padding: 16px; + width: 600px; +} + +/* Global Effects */ +.animation-blink { + animation: kf-blink 1s infinite step-start; +} + +@keyframes kf-blink { + 50% { + opacity: 0; + } +} + +.animation-scroll-in { + animation: kf-scroll-in 1s forwards linear; + box-sizing: border-box; + max-width: fit-content; + overflow: hidden; + text-wrap-mode: nowrap; +} + +@keyframes kf-scroll-in { + 0% { + width: 0%; + } + + 100% { + width: 100%; + } +} + +.animation-fade-in { + animation: kf-fade-in 500ms forwards linear; +} + +@keyframes kf-fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.animation-fall-in { + opacity: 0; + animation: kf-fall-in var(--animation-transition) ease forwards; +} + +@keyframes kf-fall-in { + from { + transform: scale(1.05); + opacity: 0; + } + + to { + transform: scale(1); + opacity: 1; + } +} + +.animation-caution { + position: relative; + overflow: visible; +} + +.animation-caution::before { + position: absolute; + transform: translate(16px, 16px); + clip-path: polygon( + calc(100% - 12px) 0, + 100% 0, + 100% 100%, + 0 100%, + 0 calc(100% - 12px), + calc(100% - 12px) calc(100% - 12px) + ); + filter: opacity(0.33); + animation: kf-caution 1200s infinite linear; + box-sizing: border-box; + inset: 0; + background: repeating-linear-gradient( + 45deg, + var(--background-primary) 0, + var(--background-primary) 8px, + black 8px, + black 16px + ); + content: ''; +} + +@keyframes kf-caution { + 0% { + background-position-y: 0px; + } + 100% { + background-position-y: 7200px; + } +} diff --git a/frontend/include/texture.png b/frontend/include/texture.png new file mode 100644 index 0000000..8b97b38 Binary files /dev/null and b/frontend/include/texture.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..53a8b34 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,125 @@ + + + + + + + + + + gifuu + + + + {{ include-env }} + + {{ include-article 'terms-of-service' 'include/articles/terms-of-service.html' }} + {{ include-article 'privacy-policy' 'include/articles/privacy-policy.html' }} + {{ include-article 'api-guide' 'include/articles/api-guide.html' }} + + {{ include-tag 'style' 'public/fonts/terminus/Terminus.css' }} + {{ include-tag 'style' 'include/index/critical.css' }} + + + + + +
+
+
+
+ + + {{ include-tag 'script' 'include/index/background.ts' }} + + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e678913 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2758 @@ +{ + "name": "gifuu-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gifuu-frontend", + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "@types/node": "^24.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "html-minifier-next": "^5.2.2", + "preact": "^10.29.0", + "prettier": "^3.8.1", + "prettier-plugin-css-order": "^2.2.0", + "typescript": "~5.9.3", + "vite": "^7.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-declaration-sorter": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz", + "integrity": "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-minifier-next": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/html-minifier-next/-/html-minifier-next-5.2.2.tgz", + "integrity": "sha512-oJ9TWgUuLg/7RZeJBQ2JxtG3iCipqu2z4XJjwhy6zYOskK9xCBXoUjHnXNqdohQeiUoDCmcjnrLYc87JmfvXCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "entities": "^7.0.1", + "lightningcss": "^1.32.0", + "svgo": "^4.0.1", + "terser": "^5.46.0" + }, + "bin": { + "html-minifier-next": "cli.js" + }, + "funding": { + "url": "https://github.com/j9t/html-minifier-next?sponsor=1" + }, + "peerDependencies": { + "@swc/core": "^1.15.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-less": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-6.0.0.tgz", + "integrity": "sha512-FPX16mQLyEjLzEuuJtxA8X3ejDLNGGEG503d2YGZR5Ask1SpDN8KmZUMpzCvyalWRywAn1n1VOA5dcqfCLo5rg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "postcss": "^8.3.5" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-css-order": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-css-order/-/prettier-plugin-css-order-2.2.0.tgz", + "integrity": "sha512-GCkwEgQ2roT7le+zpUFQThPDO4x5EXcZmY9Rj6rvO++I/nATTGBWdZdsooha/BlvIBbZclJzXsgJdlKWrys9+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-declaration-sorter": "^7.3.0", + "postcss-less": "^6.0.0", + "postcss-scss": "^4.0.9" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "prettier": "3.x" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/vite-prerender-plugin/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..532747a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "name": "gifuu-frontend", + "type": "module", + "scripts": { + "dev": "npx vite --host", + "build": "npx tsc --noEmit -p tsconfig.app.json && npx vite build", + "format": "npx prettier --write **/*.tsx **/*.ts **/*.js **/*.css" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "@types/node": "^24.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "html-minifier-next": "^5.2.2", + "preact": "^10.29.0", + "prettier": "^3.8.1", + "prettier-plugin-css-order": "^2.2.0", + "typescript": "~5.9.3", + "vite": "^7.1.7" + } +} diff --git a/frontend/public/fonts/terminus/LICENSE b/frontend/public/fonts/terminus/LICENSE new file mode 100644 index 0000000..07ae98f --- /dev/null +++ b/frontend/public/fonts/terminus/LICENSE @@ -0,0 +1,97 @@ +Copyright (c) 2010 Dimitar Toshkov Zhekov, +with Reserved Font Name "Terminus Font". + +Copyright (c) 2011-2023 Tilman Blumenbach, +with Reserved Font Name "Terminus (TTF)". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/public/fonts/terminus/Terminus.css b/frontend/public/fonts/terminus/Terminus.css new file mode 100644 index 0000000..17473d5 --- /dev/null +++ b/frontend/public/fonts/terminus/Terminus.css @@ -0,0 +1,33 @@ +/* Terminus Regular */ +@font-face { + font-style: normal; + font-weight: 400; + src: url(/fonts/terminus/TerminusRegular.woff2); + font-family: 'Terminus'; + font-display: swap; +} + +@font-face { + font-style: italic; + font-weight: 400; + src: url(/fonts/terminus/TerminusItalic.woff2); + font-family: 'Terminus'; + font-display: swap; +} + +/* Terminus Bold */ +@font-face { + font-style: normal; + font-weight: 700; + src: url(/fonts/terminus/TerminusBold.woff2); + font-family: 'Terminus'; + font-display: swap; +} + +@font-face { + font-style: italic; + font-weight: 700; + src: url(/fonts/terminus/TerminusBoldItalic.woff2); + font-family: 'Terminus'; + font-display: swap; +} diff --git a/frontend/public/fonts/terminus/TerminusBold.woff2 b/frontend/public/fonts/terminus/TerminusBold.woff2 new file mode 100644 index 0000000..4eea832 Binary files /dev/null and b/frontend/public/fonts/terminus/TerminusBold.woff2 differ diff --git a/frontend/public/fonts/terminus/TerminusBoldItalic.woff2 b/frontend/public/fonts/terminus/TerminusBoldItalic.woff2 new file mode 100644 index 0000000..41a0858 Binary files /dev/null and b/frontend/public/fonts/terminus/TerminusBoldItalic.woff2 differ diff --git a/frontend/public/fonts/terminus/TerminusItalic.woff2 b/frontend/public/fonts/terminus/TerminusItalic.woff2 new file mode 100644 index 0000000..ef74c3d Binary files /dev/null and b/frontend/public/fonts/terminus/TerminusItalic.woff2 differ diff --git a/frontend/public/fonts/terminus/TerminusRegular.woff2 b/frontend/public/fonts/terminus/TerminusRegular.woff2 new file mode 100644 index 0000000..1430a36 Binary files /dev/null and b/frontend/public/fonts/terminus/TerminusRegular.woff2 differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..98c8383 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,5 @@ +# \_/ +# ()o_o) <( beep boop ) + +User-agent: * +Disallow: diff --git a/frontend/public/worker-pow.js b/frontend/public/worker-pow.js new file mode 100644 index 0000000..369e15c --- /dev/null +++ b/frontend/public/worker-pow.js @@ -0,0 +1,32 @@ +self.onmessage = async (e) => { + const { nonce, difficulty } = e.data + const ENCODER = new TextEncoder() + const BATCH = 1000 + let counter = 0 + + while (true) { + const batch = await Promise.all( + Array.from({ length: BATCH }, (_, i) => { + const data = ENCODER.encode(nonce + (counter + i)) + return crypto.subtle.digest('SHA-256', data) + }), + ) + for (let i = 0; i < BATCH; i++) { + const hash = new Uint8Array(batch[i]) + let zeroBits = 0 + for (const byte of hash) { + if (byte === 0) { + zeroBits += 8 + } else { + zeroBits += Math.clz32(byte) - 24 + break + } + } + if (zeroBits >= difficulty) { + self.postMessage({ counter: counter + i }) + return + } + } + counter += BATCH + } +} diff --git a/frontend/source/components/inputs/Back.tsx b/frontend/source/components/inputs/Back.tsx new file mode 100644 index 0000000..bd0f75e --- /dev/null +++ b/frontend/source/components/inputs/Back.tsx @@ -0,0 +1,16 @@ +import { routeBack, routeBackURI } from '../../functions/Route' +import './styles/Back.css' + +export default function InputBack() { + return ( + { + e.preventDefault() + routeBack() + }}> + << BACK + + ) +} diff --git a/frontend/source/components/inputs/Button.tsx b/frontend/source/components/inputs/Button.tsx new file mode 100644 index 0000000..a6e2c16 --- /dev/null +++ b/frontend/source/components/inputs/Button.tsx @@ -0,0 +1,23 @@ +import { type MouseEventHandler } from 'react' +import './styles/Button.css' + +interface PropsForInputButton { + id: string + label: string + rainbow: boolean + disabled: boolean + selected: boolean + onClick: MouseEventHandler +} + +export default function InputButton({ id, label, disabled, selected, rainbow, onClick }: PropsForInputButton) { + return ( + + ) +} diff --git a/frontend/source/components/inputs/ButtonRow.tsx b/frontend/source/components/inputs/ButtonRow.tsx new file mode 100644 index 0000000..a11de0b --- /dev/null +++ b/frontend/source/components/inputs/ButtonRow.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' +import './styles/ButtonRow.css' + +interface PropsForInputButtonRow { + children: ReactNode + split: boolean +} + +export default function InputButtonRow({ children, split }: PropsForInputButtonRow) { + return
{children}
+} diff --git a/frontend/source/components/inputs/Description.tsx b/frontend/source/components/inputs/Description.tsx new file mode 100644 index 0000000..a318884 --- /dev/null +++ b/frontend/source/components/inputs/Description.tsx @@ -0,0 +1,9 @@ +import './styles/Description.css' + +interface PropsForInputDescription { + children: string | string[] +} + +export default function InputDescription({ children }: PropsForInputDescription) { + return

{children}

+} diff --git a/frontend/source/components/inputs/File.tsx b/frontend/source/components/inputs/File.tsx new file mode 100644 index 0000000..5a95f0c --- /dev/null +++ b/frontend/source/components/inputs/File.tsx @@ -0,0 +1,104 @@ +import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState, type RefObject } from 'react' +import VectorIconTop from '../../vectors/top.svg' +import './styles/File.css' +import type { BackendLimit } from '../../functions/BackendTypes' + +export interface PropsForInputFile { + limits: BackendLimit['upload'] | undefined +} + +export interface HandleForInputFile { + getPreview: () => HTMLVideoElement | HTMLImageElement | undefined + getValue: () => File | undefined +} + +const InputFile = forwardRef(({ limits }, ref) => { + const componentID = useId() + const previewRef = useRef(undefined) + const inputRef = useRef(null) + + const [fileInstance, setFileInstance] = useState() + const [fileObjectURL, setFileObjectURL] = useState() + + function updateInput(file?: File) { + if (fileObjectURL) { + URL.revokeObjectURL(fileObjectURL) + } + const accept = file && !!limits?.mime_types.find((t) => file.type === t) + if (accept) { + setFileObjectURL(URL.createObjectURL(file)) + setFileInstance(file) + } else { + setFileObjectURL(undefined) + setFileInstance(undefined) + } + } + + useImperativeHandle(ref, () => ({ + getPreview: () => previewRef.current, + getValue: () => fileInstance, + })) + + useEffect(() => { + return () => { + fileObjectURL && URL.revokeObjectURL(fileObjectURL) + } + }, [fileObjectURL]) + + return ( +
inputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault() + updateInput(e.dataTransfer.files.item(0)!) + }}> + { + e.preventDefault() + updateInput(e.target.files?.item(0) ?? undefined) + }} + /> + + {!fileInstance && ( +
+ + DRAG OR CLICK TO UPLOAD A FILE + {limits && ( + + MAX: {limits.video_width_max} × {limits.video_height_max}; SIZE:{' '} + {Math.floor(limits.filesize / 1024 / 1024)}MB; DURATION:{' '} + {Math.floor(limits.duration / 10) * 10} SECS; + + )} +
+ )} + + {fileInstance && ( +
+ {fileInstance.type.startsWith('video') && ( +
+ )} +
+ ) +}) + +export default InputFile diff --git a/frontend/source/components/inputs/Label.tsx b/frontend/source/components/inputs/Label.tsx new file mode 100644 index 0000000..0a1de17 --- /dev/null +++ b/frontend/source/components/inputs/Label.tsx @@ -0,0 +1,14 @@ +import './styles/Label.css' + +interface PropsForInputLabel { + for: string + label: string +} + +export default function InputLabel(p: PropsForInputLabel) { + return ( + + ) +} diff --git a/frontend/source/components/inputs/Tags.tsx b/frontend/source/components/inputs/Tags.tsx new file mode 100644 index 0000000..4b5f598 --- /dev/null +++ b/frontend/source/components/inputs/Tags.tsx @@ -0,0 +1,184 @@ +import { type KeyboardEvent, forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { formatTagInput, formatTagTextContent, formatTagUsage } from '../../functions/Format' +import type { BackendTag } from '../../functions/BackendTypes' +import { BackendFetch } from '../../functions/Backend' +import InputLabel from './Label' +import './styles/Label.css' +import './styles/Tags.css' + +const SEARCH_LIMIT = 5 +const SEARCH_CACHE = new Map() + +export interface HandleForInputTags { + getValue: () => BackendTag[] +} + +interface PropsForInputTags { + label: string + allowCustom: boolean + onChange: ((tags: BackendTag[]) => void) | undefined +} + +const InputTags = forwardRef(({ label, onChange, allowCustom }, ref) => { + const componentID = useId() + const timeoutRef = useRef>(null) + const inputRef = useRef(null) + + const [tagsSelected, setTagsSelected] = useState([]) + const [tagsAvailable, setTagsAvailable] = useState([]) + const [inputSelect, setInputSelect] = useState(0) + const [inputQuery, setInputQuery] = useState('') + + const indexHighlight = useMemo( + () => ((inputSelect % tagsAvailable.length) + tagsAvailable.length) % tagsAvailable.length, + [inputSelect], + ) + + useImperativeHandle(ref, () => ({ getValue: () => tagsSelected })) + + useEffect(() => { + // Cleanup State + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + if (inputQuery.length === 0) { + setTagsAvailable([]) + return + } + + // Small debounce window before search begins + timeoutRef.current = setTimeout(async () => { + // Pull Tags from Cache + const query = formatTagInput(inputQuery) + if (!query) return + + if (SEARCH_CACHE.has(query)) { + setInputSelect(0) + setTagsAvailable(selectDedupe(SEARCH_CACHE.get(query) ?? [])) + return + } + + // Pull Tags from API + const resp = await BackendFetch( + `/tags/autocomplete?limit=${SEARCH_LIMIT - (allowCustom ? 1 : 0)}&query=${query}`, + ) + if (!resp.success) { + console.error('Autocomplete error:', resp) + return + } + + // Store Results + if (resp.json.length) { + SEARCH_CACHE.set(query, resp.json) + } + if (allowCustom && !resp.json.find((t) => t.label == query)) { + resp.json.unshift({ id: 'CUSTOM', label: query, usage: 0 }) + } + + setTagsAvailable(selectDedupe(resp.json)) + setInputSelect(0) + }, 200) + }, [inputQuery]) + + // Append a tag to the currently selected + function selectAppendTag(tag: BackendTag) { + const next = [...tagsSelected, tag] + setTagsSelected(next) + setTagsAvailable([]) + setInputQuery('') + onChange?.(next) + } + // Remove a tag from the currently selected + function selectRemoveTag(tag: BackendTag) { + const next = tagsSelected.filter((t) => t.id !== tag.id) + setTagsSelected(next) + onChange?.(next) + } + + // Remove currently selected tags from the available list + function selectDedupe(list: BackendTag[]) { + return list.filter((t) => !tagsSelected.some((s) => s.label === t.label)) + } + + function handleInputKeyDown(ev: KeyboardEvent) { + // Remove latest tag + if (ev.code === 'Backspace' && !inputQuery.length) { + ev.preventDefault() + const last = tagsSelected.at(-1) + if (last) selectRemoveTag(last) + return + } + + // Append selected tag + if (ev.code === 'Enter' && tagsAvailable.length && !Number.isNaN(indexHighlight)) { + ev.preventDefault() + selectAppendTag(tagsAvailable[indexHighlight]) + return + } + if (ev.code === 'Space' && inputQuery.at(-1) === ' ' && tagsAvailable.length && !Number.isNaN(indexHighlight)) { + ev.preventDefault() + selectAppendTag(tagsAvailable[indexHighlight]) + return + } + + // Move Highlight + if (ev.code === 'ArrowUp') { + ev.preventDefault() + setInputSelect(inputSelect - 1) + return + } + if (ev.code === 'ArrowDown') { + ev.preventDefault() + setInputSelect(inputSelect + 1) + return + } + } + + return ( + <> + {label && } +
inputRef.current?.focus()}> + {/* Tag Search */} +
+ {tagsSelected.map((tag) => ( + + ))} + setInputQuery(e.currentTarget.value)} + value={inputQuery} + /> +
+ + {/* Tag Results */} +
+ {tagsAvailable.map((tag, i) => { + const classSelect = i === indexHighlight ? 'select' : '' + const classCustom = tag.id === 'CUSTOM' ? 'custom' : '' + return ( + <> + + + ) + })} +
+
+ + ) +}) + +export default InputTags diff --git a/frontend/source/components/inputs/Text.tsx b/frontend/source/components/inputs/Text.tsx new file mode 100644 index 0000000..de98f38 --- /dev/null +++ b/frontend/source/components/inputs/Text.tsx @@ -0,0 +1,31 @@ +import { forwardRef, useId, useImperativeHandle, useRef } from 'react' +import InputLabel from './Label' +import './styles/Label.css' +import './styles/Text.css' + +export interface HandleForInputText { + getValue(): string +} + +interface PropsForInputTags { + label: string + placeholder: string +} + +const InputText = forwardRef(({ label, placeholder }, ref) => { + const componentID = useId() + const inputRef = useRef(null) + + useImperativeHandle(ref, () => ({ + getValue: () => inputRef.current?.value ?? '', + })) + + return ( + <> + {label && } + + + ) +}) + +export default InputText diff --git a/frontend/source/components/inputs/styles/Back.css b/frontend/source/components/inputs/styles/Back.css new file mode 100644 index 0000000..ea60b61 --- /dev/null +++ b/frontend/source/components/inputs/styles/Back.css @@ -0,0 +1,9 @@ +a.input-back { + margin-bottom: 16px; + text-decoration: none; +} + +a.input-back:hover, +a.input-back:focus-visible { + text-decoration: underline; +} diff --git a/frontend/source/components/inputs/styles/Button.css b/frontend/source/components/inputs/styles/Button.css new file mode 100644 index 0000000..8fbe9af --- /dev/null +++ b/frontend/source/components/inputs/styles/Button.css @@ -0,0 +1,54 @@ +button.input-button { + cursor: pointer; + border: var(--border-thickness) solid var(--background-primary); + background-color: var(--background-tertiary); + padding: 8px; + width: 100%; +} + +button.input-button.selected { + background-color: var(--background-primary); + color: var(--font-color-primary); +} + +button.input-button:disabled { + cursor: not-allowed; +} + +button.input-button:not(.selected):not(.rainbow):disabled { + opacity: 0.5; + color: var(--font-color-secondary); +} + +button.input-button:not(:disabled):hover, +button.input-button:not(:disabled):focus-visible { + border-color: var(--background-highlight); +} + +button.input-button.rainbow { + position: relative; + animation: rainbow-border 1s linear infinite; + border: 2px solid transparent; + background-clip: padding-box; +} + +button.input-button.rainbow::before { + position: absolute; + z-index: -1; + animation: input-button-rainbow-shift 1s linear infinite; + inset: -2px; + border-radius: inherit; + background: linear-gradient(90deg, red, orange, yellow, green, cyan, blue, violet, red); + background-size: 200%; + content: ''; +} + +@keyframes input-button-rainbow-shift { + from { + background-position: 0%; + } + + to { + background-position: 200%; + } +} diff --git a/frontend/source/components/inputs/styles/ButtonRow.css b/frontend/source/components/inputs/styles/ButtonRow.css new file mode 100644 index 0000000..f6f4911 --- /dev/null +++ b/frontend/source/components/inputs/styles/ButtonRow.css @@ -0,0 +1,10 @@ +div.input-button-line.row { + display: inline-flex; + width: 100%; +} + +div.input-button-line.split { + display: inline-flex; + gap: 8px; + width: 100%; +} diff --git a/frontend/source/components/inputs/styles/Description.css b/frontend/source/components/inputs/styles/Description.css new file mode 100644 index 0000000..395ee82 --- /dev/null +++ b/frontend/source/components/inputs/styles/Description.css @@ -0,0 +1,4 @@ +p.input-description { + padding-bottom: 12px; + color: var(--font-color-secondary); +} diff --git a/frontend/source/components/inputs/styles/File.css b/frontend/source/components/inputs/styles/File.css new file mode 100644 index 0000000..90cc007 --- /dev/null +++ b/frontend/source/components/inputs/styles/File.css @@ -0,0 +1,56 @@ +div.input-file { + cursor: pointer; + box-sizing: border-box; + border: var(--border-thickness) solid var(--background-primary); + background-color: var(--background-tertiary); + aspect-ratio: 21 / 9; + width: 100%; + height: 100%; +} + +div.input-file input[type='file'] { + display: none; +} + +div.input-file div.prompt { + display: grid; + align-content: center; + justify-items: center; + gap: 8px; + width: 100%; + height: 100%; +} + +div.input-file div.prompt img.icon { + margin: 16px; + width: 32px; + height: 32px; +} + +div.input-file div.prompt span.hint { + font-size: large; +} + +div.input-file div.prompt span.header { + text-align: center; +} + +div.input-file div.prompt span.subheader { + color: var(--font-color-secondary); + font-size: small; + text-align: center; +} + +div.input-file div.preview { + display: flex; + justify-content: center; + width: 100%; + height: 100%; +} + +div.input-file div.preview img, +div.input-file div.preview video { + max-width: 100%; + height: 100%; + object-fit: contain; +} diff --git a/frontend/source/components/inputs/styles/Label.css b/frontend/source/components/inputs/styles/Label.css new file mode 100644 index 0000000..2d28891 --- /dev/null +++ b/frontend/source/components/inputs/styles/Label.css @@ -0,0 +1,7 @@ +label.input-label:first-child { + padding-top: 0; +} +label.input-label { + padding-top: 12px; + padding-bottom: 8px; +} diff --git a/frontend/source/components/inputs/styles/Tags.css b/frontend/source/components/inputs/styles/Tags.css new file mode 100644 index 0000000..e19c3fb --- /dev/null +++ b/frontend/source/components/inputs/styles/Tags.css @@ -0,0 +1,96 @@ +div.input-tags { + position: relative; + z-index: 999; + height: fit-content; +} + +div.input-tags div.search { + display: flex; + flex-wrap: wrap; + gap: 4px; + box-sizing: border-box; + border: var(--border-thickness) solid var(--background-primary); + border-bottom: none; + background-color: var(--background-tertiary); + padding: 8px; + width: 100%; + height: fit-content; +} + +div.input-tags div.search input.query { + border: none; + caret-color: var(--font-color-primary); + field-sizing: content; +} + +div.input-tags div.search button.item { + cursor: pointer; + box-sizing: border-box; + background-color: var(--background-secondary); + padding: 0 4px; +} + +div.input-tags div.search button.item:focus-visible, +div.input-tags div.search button.item:hover { + color: var(--font-color-accent); +} + +/* Tag Search Results */ + +div.input-tags div.results { + display: grid; + position: absolute; + top: 100%; + left: 0; + box-sizing: border-box; + border: var(--border-thickness) solid var(--background-primary); + border-right-width: 0; + border-bottom-width: 0; + border-left-width: 0; + background-color: var(--background-tertiary); + width: 100%; +} + +div.input-tags div.results:has(button) { + border-right-width: 1px; + border-bottom-width: 1px; + /* give the search results a border but not a chin while empty */ + border-left-width: 1px; +} + +div.input-tags div.results button.item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + cursor: pointer; + padding: 8px; + width: 100%; + height: 32px; + line-height: 32px; + text-align: left; +} + +div.input-tags div.results button.item span.label { + text-align: left; +} + +div.input-tags div.results button.item:focus-visible, +div.input-tags div.results button.item:hover, +div.input-tags div.results button.item.select { + background-color: var(--background-primary); +} + +div.input-tags div.results button.item span.usage { + color: var(--font-color-secondary); +} + +div.input-tags div.search input.query, +div.input-tags div.search button.item, +div.input-tags div.search button.item { + /* prevent input from overflowing */ + width: fit-content; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/frontend/source/components/inputs/styles/Text.css b/frontend/source/components/inputs/styles/Text.css new file mode 100644 index 0000000..66f356f --- /dev/null +++ b/frontend/source/components/inputs/styles/Text.css @@ -0,0 +1,7 @@ +input.input-text { + box-sizing: border-box; + border: var(--border-thickness) solid var(--background-primary); + background-color: var(--background-tertiary); + padding: 8px; + width: 100%; +} diff --git a/frontend/source/components/layout/EphemeralTooltip.tsx b/frontend/source/components/layout/EphemeralTooltip.tsx new file mode 100644 index 0000000..fecc066 --- /dev/null +++ b/frontend/source/components/layout/EphemeralTooltip.tsx @@ -0,0 +1,26 @@ +import { createPortal } from 'react-dom' +import './styles/EphemeralTooltip.css' + +interface PropsForEphemeralTooltip { + forId: string + message: string +} + +export default function EphemeralTooltip({ forId, message }: PropsForEphemeralTooltip) { + const rect = document.getElementById(forId)?.getBoundingClientRect() + if (!rect) return null + + return createPortal( +
+

{message}

+
, + document.body, + ) +} diff --git a/frontend/source/components/layout/FooterError.tsx b/frontend/source/components/layout/FooterError.tsx new file mode 100644 index 0000000..04545aa --- /dev/null +++ b/frontend/source/components/layout/FooterError.tsx @@ -0,0 +1,15 @@ +import vectorIconCross from '../../vectors/cross.svg' +import './styles/FooterError.css' + +interface PropsForFooterError { + reason: string +} + +export default function FooterError({ reason }: PropsForFooterError) { + return ( +
+ + {reason} +
+ ) +} diff --git a/frontend/source/components/layout/FooterLoading.tsx b/frontend/source/components/layout/FooterLoading.tsx new file mode 100644 index 0000000..f66be8c --- /dev/null +++ b/frontend/source/components/layout/FooterLoading.tsx @@ -0,0 +1,15 @@ +import vectorThrobbing from '../../vectors/throbber.svg' +import './styles/FooterLoading.css' + +interface PropsForFooterLoading { + reason: string | undefined +} + +export default function FooterLoading({ reason }: PropsForFooterLoading) { + return ( +
+ {(reason ?? 'Loading').toUpperCase()} + +
+ ) +} diff --git a/frontend/source/components/layout/FooterText.tsx b/frontend/source/components/layout/FooterText.tsx new file mode 100644 index 0000000..88c57c3 --- /dev/null +++ b/frontend/source/components/layout/FooterText.tsx @@ -0,0 +1,9 @@ +import './styles/FooterText.css' + +interface PropsForFooterText { + label: string +} + +export default function FooterText({ label }: PropsForFooterText) { + return {label} +} diff --git a/frontend/source/components/layout/HeaderError.tsx b/frontend/source/components/layout/HeaderError.tsx new file mode 100644 index 0000000..b6ace0e --- /dev/null +++ b/frontend/source/components/layout/HeaderError.tsx @@ -0,0 +1,24 @@ +import './styles/HeaderError.css' + +interface PropsForHeaderError { + reason: string +} + +export default function HeaderError({ reason }: PropsForHeaderError) { + const kamoji = [ + /* fishy */ `><> .o( blub blub )`, + /* sleepy */ `( _ _) .zZ`, + /* kitty! */ `(=^'w'^=) <( meow? )`, + /* clueless */ `(>_< ") <( eek! )`, + /* robot */ `  \\_/
()o_o) <( beep! )`, + /* bunny */ ` /)/)
( . .) sorry...
( づ♥`, + ] + const face = kamoji[Math.floor(Math.random() * kamoji.length)] + + return ( +
+ + +
+ ) +} diff --git a/frontend/source/components/layout/HeaderLoading.tsx b/frontend/source/components/layout/HeaderLoading.tsx new file mode 100644 index 0000000..86294f6 --- /dev/null +++ b/frontend/source/components/layout/HeaderLoading.tsx @@ -0,0 +1,15 @@ +import vectorIconThrobber from '../../vectors/throbber.svg' +import './styles/HeaderLoading.css' + +interface PropsForHeaderLoading { + reason: string | undefined +} + +export default function HeaderLoading({ reason }: PropsForHeaderLoading) { + return ( +
+ + {(reason ?? 'Loading').toUpperCase()} +
+ ) +} diff --git a/frontend/source/components/layout/HeaderMessage.tsx b/frontend/source/components/layout/HeaderMessage.tsx new file mode 100644 index 0000000..552e882 --- /dev/null +++ b/frontend/source/components/layout/HeaderMessage.tsx @@ -0,0 +1,18 @@ +import './styles/HeaderMessage.css' + +interface PropsForHeaderMessage { + label: string +} + +export default function HeaderMessage({ label }: PropsForHeaderMessage) { + return ( +
+
+ + {label.toUpperCase()} + _ + +
+
+ ) +} diff --git a/frontend/source/components/layout/LayoutBrowser.tsx b/frontend/source/components/layout/LayoutBrowser.tsx new file mode 100644 index 0000000..f319822 --- /dev/null +++ b/frontend/source/components/layout/LayoutBrowser.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from 'react' +import type { BackendArt } from '../../functions/BackendTypes' +import { useScrollRoot } from '../../functions/Context' +import { routeIntercept } from '../../functions/Route' +import { CDN_BASE } from '../../functions/Backend' +import './styles/LayoutBrowser.css' + +interface PropsForLayoutBrowser { + items: BackendArt[] + position: number + onEndReached?: () => void +} + +export interface RecoverForLayoutBrowser { + position: number + items: BackendArt[] +} + +export default function LayoutBrowser({ items, position, onEndReached }: PropsForLayoutBrowser) { + const [columnCount, setColumnCount] = useState(3) + const containerRef = useRef(null) + const scrollRoot = useScrollRoot() + const didRestore = useRef(false) + + // Endless Scrolling + useEffect(() => { + if (!onEndReached || !scrollRoot) return + const onScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollRoot + if (scrollTop + clientHeight >= scrollHeight - 100) { + onEndReached() + } + } + scrollRoot.addEventListener('scroll', onScroll) + return () => scrollRoot.removeEventListener('scroll', onScroll) + }, [onEndReached, scrollRoot]) + + // Restore Scrolling + useEffect(() => { + if (!scrollRoot || didRestore.current) return + + // avoid race conditions + const raf = requestAnimationFrame(() => { + scrollRoot.scrollTo({ top: position }) + didRestore.current = true + }) + + return () => { + cancelAnimationFrame(raf) + } + }, [scrollRoot, position, items]) + + // Calculate Column Count + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(([entry]) => { + setColumnCount(Math.max(1, Math.floor(entry.contentRect.width / 160))) + }) + ro.observe(el) + return () => ro.disconnect() + }, []) + + const columns: BackendArt[][] = Array.from({ length: columnCount }, () => []) + items.forEach((item, i) => columns[i % columnCount].push(item)) + + return ( +
+ {columns.map((column, columnIdx) => ( +
+ {column.map((item, itemIdx) => { + const animationOrder = Math.min(columnIdx + itemIdx * columnCount, 25) + const animationDelay = `calc(${animationOrder} * var(--animation-step-delay))` + return ( + + routeIntercept(e, item, { + position: scrollRoot?.scrollTop ?? 0, + items: items, + } as RecoverForLayoutBrowser) + }> + +
+
{item.title}
+
+
+ ) + })} +
+ ))} +
+ ) +} diff --git a/frontend/source/components/layout/MediaCanvas.tsx b/frontend/source/components/layout/MediaCanvas.tsx new file mode 100644 index 0000000..cf6988e --- /dev/null +++ b/frontend/source/components/layout/MediaCanvas.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from 'react' +import { CDN_BASE } from '../../functions/Backend' +import './styles/MediaCanvas.css' + +import vectorIconThrobber from '../../vectors/throbber.svg' +import vectorIconCross from '../../vectors/cross.svg' + +interface PropsForMediaCanvas { + id: string + background: boolean +} + +export default function MediaCanvas({ id, background }: PropsForMediaCanvas) { + const canvasRef = useRef(null) + const videoRef = useRef(null) + + const [fallback, setFallback] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + const defer: (() => void)[] = [] + const canvas = canvasRef.current! + const video = videoRef.current! + if (!canvas || !video) return + + video.onerror = () => { + console.warn('Failed to load video, using fallback...') + teardown() + setFallback(true) + return + } + + // --- Initialize Canvas --- + const gl = canvas.getContext('webgl', { + powerPreference: 'low-power', + preserveDrawingBuffer: true, // for download button + premultipliedAlpha: false, + antialias: false, + alpha: true, + depth: false, + })! + if (!gl) { + console.error('Context failed, using fallback...') + setFallback(true) + return + } + + try { + const VERT = ` + precision mediump float; + attribute vec2 aPos; + uniform mat3 uMatrix; + varying vec2 vUV; + void main() { + gl_Position = vec4(uMatrix * vec3(aPos, 1.0), 1.0); + vUV = aPos; + }` + + const FRAG = ` + precision mediump float; + uniform sampler2D uFrame; + varying vec2 vUV; + void main() { + vec2 colorUV = vec2(vUV.x, vUV.y * 0.5); + vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5); + vec4 color = texture2D(uFrame, colorUV); + float alpha = texture2D(uFrame, alphaUV).r; + gl_FragColor = vec4(color.rgb, alpha); + }` + + function compileShader(type: number, src: string) { + const s = gl.createShader(type)! + gl.shaderSource(s, src) + gl.compileShader(s) + return s + } + + const prog = gl.createProgram() + gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, VERT)) + gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, FRAG)) + gl.linkProgram(prog) + gl.useProgram(prog) + defer.push(() => gl.deleteProgram(prog)) + + // --- Quad --- + const buf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), gl.STATIC_DRAW) + defer.push(() => gl.deleteBuffer(buf)) + + const aPos = gl.getAttribLocation(prog, 'aPos') + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0) + gl.uniformMatrix3fv(gl.getUniformLocation(prog, 'uMatrix'), false, [2, 0, 0, 0, -2, 0, -1, 1, 1]) + + // --- Texture --- + const tex = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.uniform1i(gl.getUniformLocation(prog, 'uFrame'), 0) + defer.push(() => gl.deleteTexture(tex)) + defer.push(() => gl.getExtension('WEBGL_lose_context')?.loseContext()) + } catch (error) { + console.error('Init failed, using fallback...', error) + setFallback(true) + teardown() + return + } + + // --- Draw Loop --- + let cancel: number + let sized = false + let start = false + function tick() { + cancel = requestAnimationFrame(tick) + if (!start) { + setLoading(false) + start = true + } + try { + if (!sized && video.videoWidth > 0) { + sized = true + canvas.width = video.videoWidth + canvas.height = Math.floor(video.videoHeight / 2) + gl.viewport(0, 0, canvas.width, canvas.height) + } + if (!sized) return + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video) + gl.drawArrays(gl.TRIANGLES, 0, 6) + } catch (error) { + // bugfix for safari browsers + console.error('Draw failed, using fallback...', error) + setFallback(true) + teardown() + return + } + } + tick() + defer.push(() => cancelAnimationFrame(cancel)) + video.play().catch(() => {}) + + // --- Disposal Functions --- + function teardown() { + let func + while ((func = defer.shift())) { + try { + func() + } catch (error) { + console.error('Teardown Error:', error) + } + } + } + + return teardown + }, []) + + return ( +
+ {!error && loading && ( +
+ + LOADING +
+ )} + {error && ( +
+ + {error} +
+ )} + {!error && fallback && ( + setError('Cannot Load Image')} + onLoad={() => setLoading(false)} + /> + )} + {!error && !fallback && ( + <> +
+ ) +} diff --git a/frontend/source/components/layout/ModalEmbed.tsx b/frontend/source/components/layout/ModalEmbed.tsx new file mode 100644 index 0000000..531f4b4 --- /dev/null +++ b/frontend/source/components/layout/ModalEmbed.tsx @@ -0,0 +1,199 @@ +import { type MouseEventHandler, forwardRef, useEffect, useMemo, useState } from 'react' +import type { BackendArt } from '../../functions/BackendTypes' +import { WEB_BASE } from '../../functions/Backend' +import { wtEvent } from '../../functions/Watchtower' +import { toast } from '../../functions/Context' +import './styles/ModalEmbed.css' + +import VectorBackgroundEmbed from '../../vectors/background-embed.svg' +import HeaderMessage from './HeaderMessage' +import InputButtonRow from '../inputs/ButtonRow' +import InputButton from '../inputs/Button' +import InputDescription from '../inputs/Description' +import InputLabel from '../inputs/Label' + +interface PropsForModalEmbed { + item: BackendArt + onClose: MouseEventHandler +} + +export default forwardRef(function ModalEmbed( + { item, onClose }: PropsForModalEmbed, + ref, +) { + // Keep User Preferences + const KEY_QUALITY = 'preference_embed_quality' + const KEY_SCALE = 'preference_embed_scale' + + const [preferQuality, setQuality] = useState<'standard' | 'transparent'>( + (() => { + let raw = localStorage.getItem(KEY_QUALITY) ?? 'standard' + if (raw !== 'standard' && raw !== 'transparent') { + return 'standard' + } else { + return raw + } + })(), + ) + + const [preferScale, setScale] = useState( + (() => { + let raw = localStorage.getItem(KEY_SCALE) ?? String('1') + let val = parseFloat(raw) + if (isNaN(val) || val < 0 || val > 1) return 1 + return val + })(), + ) + + useEffect(() => localStorage.setItem(KEY_QUALITY, String(preferQuality)), [preferQuality]) + useEffect(() => localStorage.setItem(KEY_SCALE, String(preferScale)), [preferScale]) + + // Calculate Embed Values + const embedScale = useMemo(() => { + const maxDim = Math.max(item.width, item.height) + const baseScale = maxDim > 640 ? 640 / maxDim : 1 + return baseScale * preferScale + }, [item.width, item.height, preferScale]) + + const embedHeight = useMemo(() => (item.height * embedScale) | 0, [embedScale]) + const embedWidth = useMemo(() => (item.width * embedScale) | 0, [embedScale]) + + // const embedQuality = useMemo(() => { + // if (preferQuality === 'standard.avif') return 'standard' + // return 'transparent' + // }, [preferQuality]) + + const embedHTML = useMemo( + () => + ``, + [preferQuality, embedScale], + ) + + function onCopy() { + navigator.clipboard.writeText(embedHTML) + toast('action-copy', 'Copied Code to Clipboard!') + wtEvent('action_animation_embed_copy', { + id: item.id, + height: embedHeight, + width: embedWidth, + scale: (embedScale * 100) | 0, + quality: preferQuality, + }) + } + + return ( + + +
+ {/* Left-Pane */} +
+ +