rc-1
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.development
|
||||
_public
|
||||
_temp
|
||||
node_modules
|
||||
dist
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "golang.go"]
|
||||
}
|
||||
Vendored
+26
@@ -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"
|
||||
}
|
||||
}
|
||||
+17
@@ -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/
|
||||
@@ -0,0 +1,183 @@
|
||||
DO $MAIN$
|
||||
DECLARE _VERSION INTEGER;
|
||||
BEGIN
|
||||
/*
|
||||
* FETCH SCHEMA VERSION
|
||||
* Migration information is stored in the database using this nifty bit of
|
||||
* logic. Please note that there is no rollback procedure so make sure you
|
||||
* test as much as possible on your dev machine, thanks. @_@
|
||||
*/
|
||||
IF NOT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'kvs' AND table_schema = 'public') THEN
|
||||
CREATE TABLE kvs (
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT value::INTEGER INTO _VERSION FROM kvs WHERE key = 'gifuu_version';
|
||||
IF (SELECT _VERSION IS NULL) THEN
|
||||
INSERT INTO kvs VALUES ('gifuu_updated', CURRENT_TIMESTAMP::TEXT);
|
||||
INSERT INTO kvs VALUES ('gifuu_version', 0);
|
||||
_VERSION := 0;
|
||||
END IF;
|
||||
|
||||
/*
|
||||
* Version: 1.0.0
|
||||
* Name: Initial Release
|
||||
* Description: Initialize Database for Initial Release
|
||||
*/
|
||||
IF (SELECT _VERSION < 1) THEN
|
||||
_VERSION := 1;
|
||||
RAISE NOTICE 'Upgrading to Version %', _VERSION;
|
||||
|
||||
-- INITIALIZATION --
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'gifuu_backend') THEN
|
||||
DROP OWNED BY gifuu_backend CASCADE;
|
||||
DROP ROLE gifuu_backend;
|
||||
END IF;
|
||||
|
||||
DROP SCHEMA IF EXISTS gifuu CASCADE;
|
||||
CREATE SCHEMA gifuu;
|
||||
|
||||
-- TABLES --
|
||||
CREATE TABLE gifuu.upload (
|
||||
id BIGINT NOT NULL PRIMARY KEY, -- Upload ID
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
upload_address_hash TEXT NOT NULL, -- Relevant IP Address
|
||||
upload_token_hash TEXT NOT NULL, -- Relevant Edit Token
|
||||
flag_sticker BOOLEAN NOT NULL, -- Is Sticker?
|
||||
flag_audio BOOLEAN NOT NULL, -- Has Audio?
|
||||
flag_bypass BOOLEAN NOT NULL DEFAULT FALSE, -- Ignore Reports?
|
||||
encode_fps INT NOT NULL, -- Output FPS
|
||||
encode_width INT NOT NULL, -- Output Width
|
||||
encode_height INT NOT NULL, -- Output Height
|
||||
meta_rating REAL NOT NULL, -- Model Safety Rating
|
||||
meta_title TEXT NOT NULL -- User Provided Title
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.tag (
|
||||
id BIGSERIAL NOT NULL PRIMARY KEY, -- Tag ID
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
label TEXT NOT NULL UNIQUE, -- Tag Name
|
||||
usage INT NOT NULL DEFAULT 0 -- Tag Usage
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.upload_tag (
|
||||
gif_id BIGINT REFERENCES gifuu.upload (id) ON DELETE CASCADE, -- Relevant GIF ID
|
||||
tag_id BIGINT REFERENCES gifuu.tag (id) ON DELETE CASCADE, -- Relevant Tag ID
|
||||
PRIMARY KEY(gif_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.mod_key (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY, -- Moderator Token
|
||||
label TEXT NOT NULL -- Moderator Name / Label
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.mod_banned (
|
||||
address_hash TEXT NOT NULL PRIMARY KEY, -- Relevant IP Address
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
reason TEXT -- Moderator Note
|
||||
);
|
||||
|
||||
CREATE TABLE gifuu.mod_report (
|
||||
id BIGSERIAL NOT NULL PRIMARY KEY, -- Report ID
|
||||
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Created At
|
||||
upload_id BIGINT NOT NULL, -- Relevant GIF ID
|
||||
report_address_hash TEXT NOT NULL, -- Relevant IP Address
|
||||
reason_type INT NOT NULL, -- Report Type
|
||||
reason_text TEXT NOT NULL, -- Report Text
|
||||
FOREIGN KEY (upload_id) REFERENCES gifuu.upload (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- INDEXES --
|
||||
CREATE INDEX idx_tag_usage_popular ON gifuu.tag (usage DESC);
|
||||
CREATE INDEX gin_tag_metadata ON gifuu.tag USING GIN (label gin_trgm_ops);
|
||||
CREATE INDEX idx_upload_tag_gif ON gifuu.upload_tag (gif_id);
|
||||
CREATE INDEX idx_upload_tag_tag ON gifuu.upload_tag (tag_id);
|
||||
CREATE INDEX idx_upload_created ON gifuu.upload (created DESC);
|
||||
|
||||
-- TRIGGERS --
|
||||
CREATE OR REPLACE FUNCTION gifuu.update_tag_usage() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE gifuu.tag SET usage = usage + 1 WHERE id = NEW.tag_id;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE gifuu.tag SET usage = usage - 1 WHERE id = OLD.tag_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER gifuu_tag_usage_insert
|
||||
AFTER INSERT ON gifuu.upload_tag
|
||||
FOR EACH ROW EXECUTE FUNCTION gifuu.update_tag_usage();
|
||||
|
||||
CREATE TRIGGER gifuu_tag_usage_delete
|
||||
AFTER DELETE ON gifuu.upload_tag
|
||||
FOR EACH ROW EXECUTE FUNCTION gifuu.update_tag_usage();
|
||||
|
||||
-- USERS --
|
||||
CREATE ROLE gifuu_backend LOGIN NOINHERIT;
|
||||
GRANT USAGE ON SCHEMA gifuu TO gifuu_backend;
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA gifuu TO gifuu_backend;
|
||||
GRANT ALL ON ALL SEQUENCES IN SCHEMA gifuu TO gifuu_backend;
|
||||
|
||||
END IF;
|
||||
|
||||
/*
|
||||
* HOUSEKEEPING
|
||||
* Uses the "pg_cron" extension to enable automated maintenance without
|
||||
* requiring the use of an external service, see installation guide here:
|
||||
* https://github.com/citusdata/pg_cron#installing-pg_cron
|
||||
*/
|
||||
CREATE OR REPLACE PROCEDURE gifuu_reschedule (
|
||||
_SCHEDULE TEXT,
|
||||
_NAME TEXT,
|
||||
_COMMAND TEXT
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
BEGIN
|
||||
-- Task Scheduling --
|
||||
IF NOT EXISTS (SELECT FROM pg_available_extensions WHERE name = 'pg_cron') THEN
|
||||
RAISE WARNING 'Extension "pg_cron" is unavailable, command "%" unscheduled.', _NAME;
|
||||
ELSE
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
|
||||
IF EXISTS (SELECT FROM cron.job WHERE jobname = _NAME) THEN
|
||||
PERFORM cron.unschedule(_NAME);
|
||||
END IF;
|
||||
|
||||
IF _COMMAND IS NOT NULL THEN
|
||||
PERFORM cron.schedule(_NAME, _SCHEDULE, _COMMAND);
|
||||
RAISE NOTICE 'Scheduled "%" (%)', _NAME, _SCHEDULE;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Test Command --
|
||||
IF _COMMAND IS NOT NULL THEN
|
||||
RAISE NOTICE 'Testing command for task "%"', _NAME;
|
||||
EXECUTE _COMMAND;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CALL gifuu_reschedule('0 0 * * *', 'gifuu:delete_unused_tags', $$ DELETE FROM gifuu.tag WHERE usage = 0; $$);
|
||||
|
||||
/*
|
||||
* UPDATE SCHEMA VERSION
|
||||
* Disabled in development to make iterative changes less annoying. Use the
|
||||
* following query to enter production mode and make changes permanent:
|
||||
*
|
||||
* INSERT INTO kvs VALUES ('gifuu_production', 'true');
|
||||
*/
|
||||
IF EXISTS (SELECT FROM kvs WHERE key = 'gifuu_production') THEN
|
||||
RAISE NOTICE 'Mode: Production';
|
||||
UPDATE kvs SET value = _VERSION WHERE key = 'gifuu_version';
|
||||
UPDATE kvs SET value = CURRENT_TIMESTAMP::TEXT WHERE key = 'gifuu_updated';
|
||||
ELSE
|
||||
RAISE NOTICE 'Mode: Development';
|
||||
END IF;
|
||||
|
||||
END $MAIN$;
|
||||
@@ -0,0 +1,16 @@
|
||||
module gifuu
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/yalue/onnxruntime_go v1.27.0
|
||||
golang.org/x/sync v0.20.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
|
||||
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="theme-color" content="#a0a0a0" />
|
||||
<title>{{ .title }} - gifuu</title>
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ .uri_site }}">
|
||||
<meta property="og:title" content="{{ .title }}">
|
||||
<meta property="og:description" content="{{ .tags }}">
|
||||
<meta property="og:site_name" content="gifuu">
|
||||
|
||||
<meta property="og:image" content="{{ .uri_image }}">
|
||||
<meta property="og:image:width" content="{{ .width }}">
|
||||
<meta property="og:image:height" content="{{ .height }}">
|
||||
|
||||
<meta property="og:video" content="{{ .uri_embed }}">
|
||||
<meta property="og:video:type" content="text/html">
|
||||
<meta property="og:video:width" content="{{ .width }}">
|
||||
<meta property="og:video:height" content="{{ .height }}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .title }}">
|
||||
<meta name="twitter:description" content="{{ .tags }}">
|
||||
<meta name="twitter:image" content="{{ .uri_image }}">
|
||||
</head>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
package include
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed nsfw.onnx
|
||||
var MODEL_NSFW []byte
|
||||
|
||||
//go:embed ANIMATION_METADATA.html
|
||||
var TEMPLATE_ANIMATION_METADATA string
|
||||
Binary file not shown.
+199
@@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gifuu/routes"
|
||||
"gifuu/tools"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
time.Local = time.UTC
|
||||
|
||||
// Startup Services
|
||||
var stopCtx, stop = context.WithCancel(context.Background())
|
||||
var stopWg sync.WaitGroup
|
||||
var syncWg sync.WaitGroup
|
||||
|
||||
tools.LoggerInit.Log(tools.INFO, "Starting Services")
|
||||
for _, fn := range []func(stop context.Context, await *sync.WaitGroup){
|
||||
tools.SetupDatabase,
|
||||
tools.SetupModel,
|
||||
} {
|
||||
syncWg.Add(1)
|
||||
go func() {
|
||||
defer syncWg.Done()
|
||||
fn(stopCtx, &stopWg)
|
||||
}()
|
||||
}
|
||||
syncWg.Wait()
|
||||
go StartupHTTP(stopCtx, &stopWg)
|
||||
|
||||
// Await Shutdown Signal
|
||||
cancel := make(chan os.Signal, 1)
|
||||
signal.Notify(cancel, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-cancel
|
||||
stop()
|
||||
|
||||
// Begin Shutdown Process
|
||||
tools.LoggerInit.Log(tools.WARN, "Shutting Down!")
|
||||
timeout, finish := context.WithTimeout(context.Background(), tools.TIMEOUT_CONTEXT)
|
||||
defer finish()
|
||||
go func() {
|
||||
<-timeout.Done()
|
||||
if timeout.Err() == context.DeadlineExceeded {
|
||||
tools.LoggerInit.Log(tools.FATAL, "Shutdown Deadline Exceeded")
|
||||
return
|
||||
}
|
||||
}()
|
||||
stopWg.Wait()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func SetupMux() http.HandlerFunc {
|
||||
|
||||
var (
|
||||
mux = http.NewServeMux()
|
||||
limitPublic = tools.NewRatelimiter("PUBLIC", 900, 5*time.Minute)
|
||||
limitDelete = tools.NewRatelimiter("DELETE", 100, 5*time.Minute)
|
||||
limitStart = tools.NewRatelimiter("START", 100, 5*time.Minute)
|
||||
limitCreate = tools.NewRatelimiter("CREATE", 50, 5*time.Minute)
|
||||
limitModerators = tools.NewRatelimiter("MOD", 300, 5*time.Minute)
|
||||
gatekeepModerator = tools.NewGatekeeper(true)
|
||||
powExpensive = tools.NewChallenger(20)
|
||||
powNormal = tools.NewChallenger(18)
|
||||
)
|
||||
|
||||
// General
|
||||
mux.Handle("/uploads", tools.MethodHandler{
|
||||
http.MethodPost: tools.Chain(routes.POST_Uploads, limitCreate, powExpensive),
|
||||
})
|
||||
mux.Handle("/tags/popular", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Tags_Popular, limitPublic),
|
||||
})
|
||||
mux.Handle("/tags/autocomplete", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Tags_Autocomplete, limitPublic),
|
||||
})
|
||||
mux.Handle("/art/latest", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Art_Latest, limitPublic),
|
||||
})
|
||||
mux.Handle("/art/search", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Art_Search, limitPublic),
|
||||
})
|
||||
mux.Handle("/art/{id}", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Art_ID, limitPublic),
|
||||
http.MethodDelete: tools.Chain(routes.DELETE_Art_ID, limitDelete),
|
||||
})
|
||||
mux.Handle("/art/{id}/report", tools.MethodHandler{
|
||||
http.MethodPost: tools.Chain(routes.POST_Art_ID_Reports, limitCreate, powNormal),
|
||||
})
|
||||
mux.Handle("/metadata/{id}", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Metadata_ID, limitPublic),
|
||||
})
|
||||
|
||||
// Moderation
|
||||
mux.Handle("/moderation/art/{id}", tools.MethodHandler{
|
||||
http.MethodDelete: tools.Chain(routes.DELETE_Moderation_Art_ID, limitModerators, gatekeepModerator),
|
||||
})
|
||||
// mux.Handle("/moderation/art/{id}/rating", tools.MethodHandler{
|
||||
// // TODO: Sets the items moderation to 100% hiding it from the public
|
||||
// http.MethodPatch: tools.Chain(routes.PATCH_Moderation_Art_ID_Rating, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
// mux.Handle("/moderation/art/{id}/bypass", tools.MethodHandler{
|
||||
// // TODO: Disables Reporting for the item by setting the 'flag_bypass' flag
|
||||
// http.MethodPatch: tools.Chain(routes.PATCH_Moderation_Art_ID_Bypass, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
// mux.Handle("/moderation/art/{id}/reports", tools.MethodHandler{
|
||||
// // TODO: Get Latest Reports for an Item
|
||||
// http.MethodGet: tools.Chain(routes.GET_Moderation_Art_ID_Reports, limitModerators, gatekeepModerator),
|
||||
// // TODO: Delete all Reports for an Item
|
||||
// http.MethodDelete: tools.Chain(routes.DELETE_Moderation_Art_ID_Reports, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
// // TODO: Get the Latest Reports across Site
|
||||
// mux.Handle("/moderation/latest", tools.MethodHandler{
|
||||
// http.MethodGet: tools.Chain(routes.GET_Moderation_Latest, limitModerators, gatekeepModerator),
|
||||
// })
|
||||
|
||||
// Other
|
||||
mux.Handle("/challenge", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Challenge, limitStart),
|
||||
})
|
||||
mux.Handle("/limits", tools.MethodHandler{
|
||||
http.MethodGet: tools.Chain(routes.GET_Limits, limitPublic),
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
tools.SendClientError(w, r, tools.ERROR_UNKNOWN_ENDPOINT)
|
||||
})
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Inject CORS
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Challenge-Nonce, X-Challenge-Counter")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func StartupHTTP(stop context.Context, await *sync.WaitGroup) {
|
||||
var listener net.Listener
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(tools.HTTP_ADDRESS, "unix/") {
|
||||
path := strings.TrimPrefix(tools.HTTP_ADDRESS, "unix/")
|
||||
os.Remove(path)
|
||||
listener, err = net.Listen("unix", path)
|
||||
if err == nil {
|
||||
os.Chmod(path, 0660)
|
||||
}
|
||||
} else {
|
||||
listener, err = net.Listen("tcp", tools.HTTP_ADDRESS)
|
||||
}
|
||||
if err != nil {
|
||||
tools.LoggerHTTP.Log(tools.FATAL, "Listen failed: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
svr := http.Server{
|
||||
Handler: SetupMux(),
|
||||
MaxHeaderBytes: 4096,
|
||||
IdleTimeout: 10 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Shutdown Logic
|
||||
await.Add(1)
|
||||
go func() {
|
||||
defer await.Done()
|
||||
<-stop.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), tools.TIMEOUT_SHUTDOWN)
|
||||
defer cancel()
|
||||
|
||||
if err := svr.Shutdown(shutdownCtx); err != nil {
|
||||
tools.LoggerHTTP.Log(tools.ERROR, "Shutdown error: %s", err)
|
||||
}
|
||||
|
||||
tools.LoggerHTTP.Log(tools.INFO, "Closed")
|
||||
}()
|
||||
|
||||
tools.LoggerHTTP.Log(tools.INFO, "Listening @ %s", tools.HTTP_ADDRESS)
|
||||
if err := svr.Serve(listener); err != http.ErrServerClosed {
|
||||
tools.LoggerHTTP.Log(tools.FATAL, "Startup Failed: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func DELETE_Art_ID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramToken := query.Get("token")
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramToken == "" || paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete Art
|
||||
tag, err := tools.Database.Exec(ctx,
|
||||
`DELETE FROM gifuu.upload WHERE id = $1 AND upload_token_hash = $2`,
|
||||
paramID,
|
||||
tools.RequestHash(paramToken),
|
||||
)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_GENERIC_UNAUTHORIZED)
|
||||
return
|
||||
}
|
||||
|
||||
go RemoveFilesForArt(paramID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func RemoveFilesForArt(artID int64) {
|
||||
normal := strconv.FormatInt(artID, 10)
|
||||
target := path.Join(tools.STORAGE_DISK_PUBLIC, normal)
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
tools.LoggerStorage.Log(tools.WARN, "Failed to delete directory '%s': %s", target, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func DELETE_Moderation_Art_ID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete Art
|
||||
tag, err := tools.Database.Exec(ctx,
|
||||
`DELETE FROM gifuu.upload WHERE id = $1`,
|
||||
paramID,
|
||||
)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_GENERIC_UNAUTHORIZED)
|
||||
return
|
||||
}
|
||||
|
||||
go RemoveFilesForArt(paramID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func GET_Art_ID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(r.Context(),
|
||||
`SELECT jsonb_build_object(
|
||||
'id', u.id::text,
|
||||
'created', u.created::timestamptz,
|
||||
'sticker', u.flag_sticker,
|
||||
'audio', u.flag_audio,
|
||||
'framerate', u.encode_fps,
|
||||
'width', u.encode_width,
|
||||
'height', u.encode_height,
|
||||
'rating', u.meta_rating,
|
||||
'title', u.meta_title,
|
||||
'tags', COALESCE(
|
||||
jsonb_agg(
|
||||
CASE WHEN t.id IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', t.id::text,
|
||||
'label', t.label,
|
||||
'usage', t.usage
|
||||
)
|
||||
END
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'
|
||||
)
|
||||
)
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id`,
|
||||
paramID,
|
||||
).Scan(&Results)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
tools.SendClientError(w, r, tools.ERROR_UNKNOWN_ANIMATION)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Art_Latest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
paramAfter := tools.ParseSnowflake(query.Get("after"))
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t.row), '[]') FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id::text,
|
||||
'created', u.created::timestamptz,
|
||||
'sticker', u.flag_sticker,
|
||||
'audio', u.flag_audio,
|
||||
'framerate', u.encode_fps,
|
||||
'width', u.encode_width,
|
||||
'height', u.encode_height,
|
||||
'rating', u.meta_rating,
|
||||
'title', u.meta_title,
|
||||
'tags', COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id::text,
|
||||
'label', t.label,
|
||||
'usage', t.usage
|
||||
)
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'
|
||||
)
|
||||
) AS row
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE ($1::bigint = 0 OR u.id < $1::bigint) AND u.meta_rating < $3
|
||||
GROUP BY u.id
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $2
|
||||
) t`,
|
||||
paramAfter,
|
||||
paramLimit,
|
||||
tools.MODEL_THRESHOLD_HIDE,
|
||||
).Scan(&Results)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Art_Search(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
paramAfter := tools.ParseSnowflake(query.Get("after"))
|
||||
var paramTags []int64
|
||||
|
||||
if params, ok := query["tag"]; ok {
|
||||
paramTags = make([]int64, 0, len(params))
|
||||
indexTags := make(map[int64]struct{}, len(params))
|
||||
for _, raw := range params {
|
||||
id := tools.ParseSnowflake(raw)
|
||||
if _, exists := indexTags[id]; exists || id == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
indexTags[id] = struct{}{}
|
||||
paramTags = append(paramTags, id)
|
||||
}
|
||||
}
|
||||
if len(paramTags) == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_EMPTY)
|
||||
return
|
||||
}
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t.obj), '[]') FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id::text,
|
||||
'created', u.created::timestamptz,
|
||||
'sticker', u.flag_sticker,
|
||||
'audio', u.flag_audio,
|
||||
'framerate', u.encode_fps,
|
||||
'width', u.encode_width,
|
||||
'height', u.encode_height,
|
||||
'rating', u.meta_rating,
|
||||
'title', u.meta_title,
|
||||
'tags', COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id::text,
|
||||
'label', t.label,
|
||||
'usage', t.usage
|
||||
)
|
||||
ORDER BY t.usage DESC
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'
|
||||
)
|
||||
) AS obj
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE ut.tag_id = ANY($1::bigint[])
|
||||
AND ($2::bigint = 0 OR u.id < $2::bigint)
|
||||
AND u.meta_rating < $4
|
||||
GROUP BY u.id
|
||||
HAVING COUNT(DISTINCT ut.tag_id) = cardinality($1::bigint[])
|
||||
ORDER BY u.id DESC
|
||||
LIMIT $3::int
|
||||
) t`,
|
||||
paramTags,
|
||||
paramAfter,
|
||||
paramLimit,
|
||||
tools.MODEL_THRESHOLD_HIDE,
|
||||
).Scan(&Results)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GET_Challenge(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
|
||||
paramDifficulty, _ := strconv.Atoi(query.Get("difficulty"))
|
||||
if paramDifficulty < 18 {
|
||||
tools.SendClientError(w, r, tools.ERROR_CHALLENGE_TOO_EASY)
|
||||
return
|
||||
}
|
||||
|
||||
// Create Session
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
normal := hex.EncodeToString(nonce)
|
||||
expiry := time.Now().Add(5 * time.Minute).Unix()
|
||||
|
||||
// Store Session
|
||||
tools.ChallengeAtomic.Lock()
|
||||
tools.ChallengeSession[normal] = tools.ChallengeSessionData{
|
||||
Expires: expiry,
|
||||
Difficulty: paramDifficulty,
|
||||
}
|
||||
tools.ChallengeAtomic.Unlock()
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, map[string]any{
|
||||
"nonce": normal,
|
||||
"difficulty": paramDifficulty,
|
||||
"expires": expiry,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
REPORT_REASON_EXPLICIT = 0 // Sexual, Nudity, or Fetish Content
|
||||
REPORT_REASON_HARASSMENT = 1 // Targeted Harassment or Hate Speech
|
||||
REPORT_REASON_VIOLENCE = 2 // Violence, Gore, or Abuse
|
||||
REPORT_REASON_SPAM = 3 // Spam, Advertising, or Solicitation
|
||||
REPORT_REASON_HARMFUL = 4 // Seizure-Inducing, Self-Harm, or Dangerous Content
|
||||
REPORT_REASON_ILLEGAL = 5 // Illegal Content (CSAM, Threats, etc.)
|
||||
)
|
||||
|
||||
type normalizerItem struct {
|
||||
Match string `json:"match"`
|
||||
Replace string `json:"replace"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
var cachedLimitsJSON []byte
|
||||
var cachedLimitsGZIP []byte
|
||||
|
||||
func init() {
|
||||
json, gzip, err := tools.PrepareStaticJSON(map[string]any{
|
||||
"upload": map[string]any{
|
||||
"input_width_min": MEDIA_MIN_WIDTH,
|
||||
"input_height_min": MEDIA_MIN_HEIGHT,
|
||||
"video_width_max": VIDEO_MAX_WIDTH,
|
||||
"video_height_max": VIDEO_MAX_HEIGHT,
|
||||
"image_width_max": IMAGE_MAX_WIDTH,
|
||||
"image_height_max": IMAGE_MAX_HEIGHT,
|
||||
"duration": MEDIA_MAX_DURATION,
|
||||
"filesize": tools.LIMIT_FILE,
|
||||
"mime_types": tools.LIMIT_MIME_TYPE,
|
||||
},
|
||||
"title": map[string]any{
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^\s+` /*---*/, `` /**/, "Trim Left"},
|
||||
{ /**/ `\s+$` /*---*/, `` /**/, "Trim Right"},
|
||||
{ /**/ `\s{2,}` /**/, ` ` /**/, "Regulate Excessive Spaces"},
|
||||
},
|
||||
"matcher": `^[\S\s]{1,80}$`,
|
||||
"max_length": 80,
|
||||
"min_length": 1,
|
||||
},
|
||||
"tag": map[string]any{
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^_+` /*-*/, `` /*-*/, "Trim Left Underscores"},
|
||||
{ /**/ `_+$` /*-*/, `` /*-*/, "Trim Right Underscores"},
|
||||
{ /**/ `_+` /*--*/, `_` /**/, "Regulate Excessive Underscores"},
|
||||
},
|
||||
"matcher": `^[\p{L}\p{N}_]{1,32}$`,
|
||||
"max_length": 32,
|
||||
"min_length": 1,
|
||||
},
|
||||
"comment": map[string]any{
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^\s+` /*--*/, `` /*--*/, "Trim Left Spaces"},
|
||||
{ /**/ `\s+$` /*--*/, `` /*--*/, "Trim Right Spaces"},
|
||||
{ /**/ `\n{2,}` /**/, `\n` /**/, "Regulate Excessive Newlines"},
|
||||
},
|
||||
"matcher": `^[\S\s]{10,240}$`,
|
||||
"max_length": 240,
|
||||
"min_length": 10,
|
||||
},
|
||||
"report": map[string]any{
|
||||
"values": []any{
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_EXPLICIT,
|
||||
"title": "EXPLICIT",
|
||||
"description": "Sexual, Nudity, or Fetish Content"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_HARASSMENT,
|
||||
"title": "HARASSMENT",
|
||||
"description": "Targeted Harassment or Hate Speech"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_VIOLENCE,
|
||||
"title": "VIOLENCE",
|
||||
"description": "Violence, Gore, or Abuse"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_SPAM,
|
||||
"title": "SPAM",
|
||||
"description": "Spam, Advertising, or Solicitation"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_HARMFUL,
|
||||
"title": "HARMFUL",
|
||||
"description": "Seizure-Inducing, Self-Harm, or Dangerous Content"},
|
||||
map[string]any{
|
||||
"id": REPORT_REASON_ILLEGAL,
|
||||
"title": "ILLEGAL",
|
||||
"description": "Illegal Content (CSAM, Threats, etc.)"},
|
||||
},
|
||||
"normalizers": []normalizerItem{
|
||||
{ /**/ `^\s+` /*--*/, `` /*--*/, "Trim Left Spaces"},
|
||||
{ /**/ `\s+$` /*--*/, `` /*--*/, "Trim Right Spaces"},
|
||||
{ /**/ `\n{2,}` /**/, `\n` /**/, "Regulate Excessive Newlines"},
|
||||
},
|
||||
"matcher": `^[\S\s]{10,240}$`,
|
||||
"max_length": 240,
|
||||
"min_length": 10,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cachedLimitsJSON = json
|
||||
cachedLimitsGZIP = gzip
|
||||
}
|
||||
|
||||
func GET_Limits(w http.ResponseWriter, r *http.Request) {
|
||||
tools.SendStaticJSON(w, r, http.StatusOK, cachedLimitsJSON, cachedLimitsGZIP)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gifuu/include"
|
||||
"gifuu/tools"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var animTemplate = template.Must(template.New("").Parse(include.TEMPLATE_ANIMATION_METADATA))
|
||||
|
||||
func GET_Metadata_ID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
var animationData struct {
|
||||
Created string
|
||||
Width int
|
||||
Height int
|
||||
Rating float64
|
||||
Sticker bool
|
||||
Title string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Fetch Animation Metadata
|
||||
err := tools.Database.QueryRow(r.Context(),
|
||||
`SELECT
|
||||
u.encode_width,
|
||||
u.encode_height,
|
||||
u.meta_rating,
|
||||
u.flag_sticker,
|
||||
u.meta_title,
|
||||
COALESCE(array_agg(t.label ORDER BY t.usage) FILTER (WHERE t.id IS NOT NULL), '{}')
|
||||
FROM gifuu.upload u
|
||||
LEFT JOIN gifuu.upload_tag ut ON ut.gif_id = u.id
|
||||
LEFT JOIN gifuu.tag t ON t.id = ut.tag_id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id`,
|
||||
paramID,
|
||||
).Scan(
|
||||
&animationData.Width,
|
||||
&animationData.Height,
|
||||
&animationData.Rating,
|
||||
&animationData.Sticker,
|
||||
&animationData.Title,
|
||||
&animationData.Tags,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
tools.SendClientError(w, r, tools.ERROR_UNKNOWN_ANIMATION)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Render Webpage
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
err = animTemplate.Execute(w, map[string]any{
|
||||
"width": animationData.Width,
|
||||
"height": animationData.Height,
|
||||
"title": html.EscapeString(animationData.Title),
|
||||
"tags": html.EscapeString(strings.Join(animationData.Tags, ", ")),
|
||||
"uri_embed": fmt.Sprintf("%s/embed.html?id=%d&quality=standard", tools.TEMPLATE_BASE_WEB, paramID),
|
||||
"uri_image": fmt.Sprintf("%s/%d/standard.avif", tools.TEMPLATE_BASE_CDN, paramID),
|
||||
"uri_site": fmt.Sprintf("%s/art/%d", tools.TEMPLATE_BASE_WEB, paramID),
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Render Error:", r.URL.Path, err)
|
||||
http.Error(w, "Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Tags_Autocomplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
paramQuery := query.Get("query")
|
||||
|
||||
if str, ok := tools.NormalizeTag(paramQuery); !ok {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
} else {
|
||||
paramQuery = str
|
||||
}
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t), '[]') FROM (
|
||||
SELECT id::text AS id, label, usage
|
||||
FROM gifuu.tag
|
||||
ORDER BY word_similarity(label, $1) DESC, usage DESC
|
||||
LIMIT $2
|
||||
) t`,
|
||||
paramQuery,
|
||||
paramLimit,
|
||||
).Scan(&Results)
|
||||
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func GET_Tags_Popular(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query()
|
||||
|
||||
paramLimit := tools.ParseLimit(query.Get("limit"))
|
||||
|
||||
var Results []byte
|
||||
err := tools.Database.QueryRow(ctx,
|
||||
`SELECT COALESCE(jsonb_agg(t), '[]') FROM (
|
||||
SELECT id::text AS id, label, usage
|
||||
FROM gifuu.tag
|
||||
ORDER BY usage DESC
|
||||
LIMIT $1
|
||||
) t`,
|
||||
paramLimit,
|
||||
).Scan(&Results)
|
||||
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
tools.SendJSON(w, r, http.StatusOK, Results)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"gifuu/tools"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func POST_Art_ID_Reports(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var Body struct {
|
||||
ReasonType int `json:"type"`
|
||||
ReasonText string `json:"reason"`
|
||||
}
|
||||
|
||||
paramID := tools.ParseSnowflake(r.PathValue("id"))
|
||||
if paramID == 0 {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tools.ParseJSON(r.Body, &Body); err != nil {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_DATA)
|
||||
return
|
||||
}
|
||||
|
||||
if Body.ReasonType < REPORT_REASON_EXPLICIT || Body.ReasonType > REPORT_REASON_ILLEGAL {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
if str, ok := tools.NormalizeComment(Body.ReasonText); !ok {
|
||||
tools.SendClientError(w, r, tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
} else {
|
||||
Body.ReasonText = str
|
||||
}
|
||||
|
||||
// Attempt to store the users report. It may be discarded if another moderator
|
||||
// has previously review this item and deemed it didn't violate any rules.
|
||||
|
||||
if _, err := tools.Database.Exec(ctx,
|
||||
`INSERT INTO gifuu.mod_report (upload_id, reason_type, reason_text, report_address_hash)
|
||||
SELECT $1, $2, $3, $4 FROM gifuu.upload WHERE id = $1 AND flag_bypass = FALSE`,
|
||||
paramID,
|
||||
Body.ReasonType,
|
||||
Body.ReasonText,
|
||||
tools.RequestAddressHash(r),
|
||||
); err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gifuu/tools"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
VIDEO_MAX_WIDTH = 3840
|
||||
VIDEO_MAX_HEIGHT = 2160
|
||||
IMAGE_MAX_WIDTH = 7680
|
||||
IMAGE_MAX_HEIGHT = 4320
|
||||
MEDIA_MIN_WIDTH = 64
|
||||
MEDIA_MIN_HEIGHT = 64
|
||||
MEDIA_MAX_DURATION = 62
|
||||
MEDIA_COLOR_SPACE = "yuv420p"
|
||||
MEDIA_COLOR_RANGE = "tv"
|
||||
MEDIA_FILENAME_PREVIEW = "preview.avif"
|
||||
MEDIA_FILENAME_STANDARD = "standard.avif"
|
||||
MEDIA_FILENAME_ALPHA = "alpha.webm"
|
||||
MEDIA_FILENAME_AUDIO = "standard.ogg"
|
||||
MEDIA_BACKGROUND = "#ffffff"
|
||||
VIDEO_ENCODE_CODEC = "libsvtav1"
|
||||
VIDEO_ENCODE_EFFORT = "7"
|
||||
VIDEO_ENCODE_PARAMS = "lp=2"
|
||||
VIDEO_FILTERS = ""
|
||||
VIDEO_KEYFRAME_INTERVAL = 6
|
||||
VIDEO_PREVIEW_MAX_DURATION = 8
|
||||
VIDEO_PREVIEW_FPS = 16
|
||||
VIDEO_PREVIEW_SIZE = 240
|
||||
VIDEO_PREVIEW_QUALITY = "55"
|
||||
VIDEO_STANDARD_FPS = 60
|
||||
VIDEO_STANDARD_SIZE = 720
|
||||
VIDEO_STANDARD_QUALITY = "50"
|
||||
IMAGE_ENCODE_CODEC = "libsvtav1"
|
||||
IMAGE_ENCODE_EFFORT = "7"
|
||||
IMAGE_ENCODE_QUALITY = "45"
|
||||
IMAGE_ENCODE_PARAMS = "lp=2"
|
||||
IMAGE_FILTERS = "tpad=stop_mode=clone:stop_duration=1,"
|
||||
IMAGE_LARGE_SIZE = 2160
|
||||
IMAGE_PREVIEW_SIZE = 240
|
||||
)
|
||||
|
||||
func ternary[T any](cond bool, a, b T) T {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func POST_Uploads(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// --- Update Request Lifetime ---
|
||||
sse, err := tools.NewEventHelper(ctx, w, r)
|
||||
if err != nil {
|
||||
tools.SendServerError(w, r, err)
|
||||
}
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
if err := rc.SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Prevent Abuse ] ----------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
var (
|
||||
ClientAddress = tools.RequestAddressHash(r)
|
||||
ClientToken = tools.RequestToken()
|
||||
ClientLength = max(0, r.ContentLength)
|
||||
)
|
||||
{
|
||||
// --- Request Upload Capacity ---
|
||||
if ClientLength == 0 {
|
||||
sse.SendClientError(tools.ERROR_BODY_EMPTY)
|
||||
return
|
||||
}
|
||||
if ClientLength > int64(tools.LIMIT_FILE+tools.LIMIT_JSON) {
|
||||
sse.SendClientError(tools.ERROR_BODY_TOO_LARGE)
|
||||
return
|
||||
}
|
||||
|
||||
if !tools.SEMA_UPLOADS.TryAcquire(ClientLength) {
|
||||
sse.SendClientError(tools.ERROR_SERVER_RESOURCES_EXHAUSTED)
|
||||
return
|
||||
}
|
||||
defer tools.SEMA_UPLOADS.Release(ClientLength)
|
||||
|
||||
// --- Check Ban List ---
|
||||
var SubjectCount int
|
||||
err := tools.Database.
|
||||
QueryRow(ctx, "SELECT COUNT(*) FROM gifuu.mod_banned WHERE address_hash = $1", ClientAddress).
|
||||
Scan(&SubjectCount)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
if SubjectCount > 0 {
|
||||
sse.SendClientError(tools.ERROR_GENERIC_FORBIDDEN)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Parse Incoming Request ] -------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
var (
|
||||
TempID = tools.RequestSnowflake()
|
||||
TempIDString = strconv.FormatInt(TempID, 10)
|
||||
TempSuccess = false
|
||||
TempUpload *os.File
|
||||
TempLogger *os.File
|
||||
Body struct {
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
PathUpload = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+".bin")
|
||||
PathLogger = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+".log")
|
||||
PathAlpha = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_ALPHA)
|
||||
PathPreview = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_PREVIEW)
|
||||
PathStandard = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_STANDARD)
|
||||
PathAudio = filepath.Join(tools.STORAGE_DISK_TEMP, TempIDString+"_"+MEDIA_FILENAME_AUDIO)
|
||||
)
|
||||
{
|
||||
// --- Create Temporary Files ---
|
||||
flags := os.O_CREATE | os.O_TRUNC | os.O_WRONLY
|
||||
|
||||
if f, err := os.OpenFile(PathUpload, flags, tools.FILE_PUBLIC); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
} else {
|
||||
TempUpload = f
|
||||
defer os.Remove(PathUpload)
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
if f, err := os.OpenFile(PathLogger, flags, tools.FILE_PRIVATE); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
} else {
|
||||
TempLogger = f
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
defer os.Remove(PathAlpha)
|
||||
defer os.Remove(PathPreview)
|
||||
defer os.Remove(PathStandard)
|
||||
defer os.Remove(PathAudio)
|
||||
|
||||
// --- Parse Form Body ---
|
||||
var haveFile, haveData bool
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_DATA)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// --- Parse Incoming Metadata ---
|
||||
if part.FormName() == "data" {
|
||||
if haveData {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
haveData = true
|
||||
|
||||
if err := tools.ParseJSON(part, &Body); err != nil {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
break
|
||||
}
|
||||
|
||||
// Validate Struct
|
||||
if normal, ok := tools.NormalizeTitle(Body.Title); !ok {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
} else {
|
||||
Body.Title = normal
|
||||
}
|
||||
|
||||
indexTags := make(map[string]struct{}, len(Body.Tags))
|
||||
for i, given := range Body.Tags {
|
||||
normal, ok := tools.NormalizeTag(given)
|
||||
if _, exists := indexTags[normal]; exists || !ok {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
indexTags[normal] = struct{}{}
|
||||
Body.Tags[i] = normal
|
||||
}
|
||||
|
||||
fmt.Fprintf(TempLogger, "Collected JSON : %s\n", Body)
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Store Incoming Upload ---
|
||||
if part.FormName() == "file" {
|
||||
if haveFile {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
haveFile = true
|
||||
|
||||
// Check File Extension
|
||||
mediaAccept := false
|
||||
mediaType := part.Header.Get("Content-Type")
|
||||
for _, t := range tools.LIMIT_MIME_TYPE {
|
||||
if strings.EqualFold(t, mediaType) {
|
||||
mediaAccept = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !mediaAccept {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_CONTENT_TYPE)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy File
|
||||
mediaSize, err := io.Copy(TempUpload, io.LimitReader(part, ClientLength))
|
||||
if err != nil {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_DATA)
|
||||
return
|
||||
}
|
||||
TempUpload.Close()
|
||||
|
||||
fmt.Fprintf(TempLogger,
|
||||
"Collected File : %s (Type: %s) (Size: %db)\n",
|
||||
mediaType, part.FileName(), mediaSize,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
if !haveFile || !haveData {
|
||||
sse.SendClientError(tools.ERROR_BODY_INVALID_FIELD)
|
||||
return
|
||||
}
|
||||
|
||||
sse.SendJSON("id", TempIDString)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Media Validation ] -------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
var (
|
||||
ProbeResults tools.ProbeResults // Probe Results
|
||||
ProbeVideo *tools.ProbeStream // Relevant Video Stream
|
||||
ProbeAudio *tools.ProbeStream // Relevant Audio Stream
|
||||
MediaSticker bool // Is Static?
|
||||
MediaFramerate int // Approximate Framerate
|
||||
MediaHeight int // Approximate Scaled Height
|
||||
MediaWidth int // Approximate Scaled Width
|
||||
MediaRating float32 // Worst value from Classification
|
||||
)
|
||||
{
|
||||
sse.SendJSON("step", map[string]any{"id": "PROBE_QUEUE", "message": "Queued for Probing"})
|
||||
t := time.Now()
|
||||
|
||||
// --- Acquire Probe Slot ---
|
||||
if err := tools.SEMA_PROBES.Acquire(ctx, 1); err != nil {
|
||||
return
|
||||
}
|
||||
defer tools.SEMA_PROBES.Release(1)
|
||||
|
||||
// --- Probe Media Stream ---
|
||||
sse.SendJSON("step", map[string]any{"id": "PROBE_START", "message": "Probing"})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||
"-hide_banner",
|
||||
"-loglevel", "verbose",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-i", PathUpload,
|
||||
)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
fmt.Fprintf(TempLogger, "\n%s\n%s\nProbing completed in %s\n\n",
|
||||
// extra newline for proper log padding
|
||||
stdout.String(),
|
||||
stderr.String(),
|
||||
time.Since(t),
|
||||
)
|
||||
|
||||
if ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &ProbeResults); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Find Media Stream ---
|
||||
// isVideo: Only video streams (AV1, MP4, etc.)
|
||||
// isImage: Only select image streams if it's the only stream available
|
||||
for _, s := range ProbeResults.Streams {
|
||||
isVideo := (s.CodecType == "video")
|
||||
isImage := (s.CodecType == "image" && len(ProbeResults.Streams) == 1)
|
||||
isValid := true &&
|
||||
s.Width >= MEDIA_MIN_WIDTH && s.Height >= MEDIA_MIN_HEIGHT &&
|
||||
float64(s.Duration) <= float64(MEDIA_MAX_DURATION) &&
|
||||
ternary(s.NumberFrames < 2,
|
||||
s.Width < IMAGE_MAX_WIDTH && s.Height < IMAGE_MAX_HEIGHT,
|
||||
s.Width < VIDEO_MAX_WIDTH && s.Height < VIDEO_MAX_HEIGHT,
|
||||
)
|
||||
if isValid && (isVideo || isImage) && (ProbeVideo == nil || s.Duration > ProbeVideo.Duration) {
|
||||
ProbeVideo = &s
|
||||
}
|
||||
|
||||
isAudio := (s.CodecType == "audio")
|
||||
if isAudio {
|
||||
ProbeAudio = &s
|
||||
}
|
||||
}
|
||||
if ProbeVideo == nil {
|
||||
sse.SendClientError(tools.ERROR_MEDIA_INVALID)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Detect Properties ---
|
||||
|
||||
// Remove Audio from Stickers
|
||||
MediaSticker = (ProbeVideo.NumberFrames < 2)
|
||||
if MediaSticker {
|
||||
ProbeAudio = nil
|
||||
}
|
||||
|
||||
// Approximate Video Properties
|
||||
d := ternary(MediaSticker, IMAGE_LARGE_SIZE, VIDEO_STANDARD_SIZE)
|
||||
s := float64(min(ProbeVideo.Height, d)) / float64(ProbeVideo.Height)
|
||||
MediaWidth = int(float64(ProbeVideo.Width)*s) &^ 1
|
||||
MediaHeight = int(float64(ProbeVideo.Height)*s) &^ 1
|
||||
MediaFramerate = ternary(MediaSticker, 1, min(int(ProbeVideo.RFrameRate), VIDEO_STANDARD_FPS))
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Media Processing ] -------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
{
|
||||
sse.SendJSON("step", map[string]any{"id": "ENCODE_QUEUE", "message": "Queued for Processing"})
|
||||
t := time.Now()
|
||||
|
||||
// --- Acquire Upload Slot ---
|
||||
if err := tools.SEMA_ENCODES.Acquire(ctx, 1); err != nil {
|
||||
return
|
||||
}
|
||||
defer tools.SEMA_ENCODES.Release(1)
|
||||
|
||||
// --- Prepare For Encoding ---
|
||||
sse.SendJSON("step", map[string]any{"id": "ENCODE_START", "message": "Processing"})
|
||||
|
||||
args := []string{
|
||||
"-loglevel", "verbose",
|
||||
"-hide_banner",
|
||||
"-stats",
|
||||
|
||||
"-i", PathUpload,
|
||||
"-filter_complex", fmt.Sprintf(""+
|
||||
"[0:%d]format=rgba[fg];[fg]split[fg1][fg2];[fg2]drawbox=x=0:y=0:w=iw:h=ih:color=%s:t=fill[bg];[bg][fg1]overlay[base];"+
|
||||
"[base]split=4[v1][v2][v3][v4];"+
|
||||
"[v1]%sscale=-2:%d:flags=lanczos,fps=%d[v1o];"+
|
||||
"[v2]%sscale=-2:%d:flags=lanczos,fps=%d[v2o];"+
|
||||
"[v3]%sscale=-2:%d:flags=lanczos,fps=%d[v3a];[v3a]format=rgba[v3f];[v3f]split[v3color][v3mask];[v3mask]alphaextract[v3ae];[v3color][v3ae]vstack[v3o];"+
|
||||
"[v4]%sscale=%d:%d:flags=neighbor,fps=%d[v4o];",
|
||||
|
||||
// Import
|
||||
ProbeVideo.Index,
|
||||
MEDIA_BACKGROUND,
|
||||
|
||||
// Export: Preview
|
||||
ternary(MediaSticker, IMAGE_FILTERS, VIDEO_FILTERS),
|
||||
min(ProbeVideo.Height, ternary(MediaSticker, IMAGE_PREVIEW_SIZE, VIDEO_PREVIEW_SIZE)),
|
||||
min(int(ProbeVideo.RFrameRate), ternary(MediaSticker, 1, VIDEO_PREVIEW_FPS)),
|
||||
|
||||
// Export: Standard
|
||||
ternary(MediaSticker, IMAGE_FILTERS, VIDEO_FILTERS),
|
||||
min(ProbeVideo.Height, ternary(MediaSticker, IMAGE_LARGE_SIZE, VIDEO_STANDARD_SIZE)),
|
||||
min(int(ProbeVideo.RFrameRate), ternary(MediaSticker, 1, VIDEO_STANDARD_FPS)),
|
||||
|
||||
// Export: Alpha
|
||||
ternary(MediaSticker, IMAGE_FILTERS, VIDEO_FILTERS),
|
||||
min(ProbeVideo.Height, ternary(MediaSticker, IMAGE_LARGE_SIZE, VIDEO_STANDARD_SIZE)),
|
||||
min(int(ProbeVideo.RFrameRate), ternary(MediaSticker, 1, VIDEO_STANDARD_FPS)),
|
||||
|
||||
// Export: Inference
|
||||
IMAGE_FILTERS,
|
||||
tools.MODEL_SIZE,
|
||||
tools.MODEL_SIZE,
|
||||
tools.MODEL_FRAMERATE,
|
||||
),
|
||||
|
||||
// Export Preview
|
||||
"-map", "[v1o]", "-an", "-sn",
|
||||
"-c:v" /*----------*/, ternary(MediaSticker, IMAGE_ENCODE_CODEC, VIDEO_ENCODE_CODEC),
|
||||
"-preset" /*-------*/, ternary(MediaSticker, IMAGE_ENCODE_EFFORT, VIDEO_ENCODE_EFFORT),
|
||||
"-qp" /*-----------*/, ternary(MediaSticker, IMAGE_ENCODE_QUALITY, VIDEO_PREVIEW_QUALITY),
|
||||
"-g" /*------------*/, strconv.Itoa(VIDEO_PREVIEW_FPS * VIDEO_KEYFRAME_INTERVAL),
|
||||
"-pix_fmt" /*------*/, MEDIA_COLOR_SPACE,
|
||||
"-color_range" /*--*/, MEDIA_COLOR_RANGE,
|
||||
"-svtav1-params" /**/, ternary(MediaSticker, IMAGE_ENCODE_PARAMS, VIDEO_ENCODE_PARAMS),
|
||||
"-frames:v" /*-----*/, ternary(MediaSticker, "1", strconv.Itoa(VIDEO_PREVIEW_FPS*VIDEO_PREVIEW_MAX_DURATION)),
|
||||
"-loop" /*---------*/, ternary(MediaSticker, "1", "0"),
|
||||
"-map_metadata" /*-*/, "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathPreview,
|
||||
|
||||
// Export: Standard
|
||||
"-map", "[v2o]", "-an", "-sn",
|
||||
"-c:v" /*----------*/, ternary(MediaSticker, IMAGE_ENCODE_CODEC, VIDEO_ENCODE_CODEC),
|
||||
"-preset" /*-------*/, ternary(MediaSticker, IMAGE_ENCODE_EFFORT, VIDEO_ENCODE_EFFORT),
|
||||
"-qp" /*-----------*/, ternary(MediaSticker, IMAGE_ENCODE_QUALITY, VIDEO_STANDARD_QUALITY),
|
||||
"-g" /*------------*/, strconv.Itoa(VIDEO_STANDARD_FPS * VIDEO_KEYFRAME_INTERVAL),
|
||||
"-pix_fmt" /*------*/, MEDIA_COLOR_SPACE,
|
||||
"-color_range" /*--*/, MEDIA_COLOR_RANGE,
|
||||
"-svtav1-params" /**/, ternary(MediaSticker, IMAGE_ENCODE_PARAMS, VIDEO_ENCODE_PARAMS),
|
||||
"-frames:v" /*-----*/, ternary(MediaSticker, "1", strconv.Itoa(VIDEO_STANDARD_FPS*MEDIA_MAX_DURATION)),
|
||||
"-loop" /*---------*/, ternary(MediaSticker, "1", "0"),
|
||||
"-map_metadata" /*-*/, "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathStandard,
|
||||
|
||||
// Export: Alpha
|
||||
"-map", "[v3o]", "-an", "-sn",
|
||||
"-c:v" /*----------*/, ternary(MediaSticker, IMAGE_ENCODE_CODEC, VIDEO_ENCODE_CODEC),
|
||||
"-preset" /*-------*/, ternary(MediaSticker, IMAGE_ENCODE_EFFORT, VIDEO_ENCODE_EFFORT),
|
||||
"-qp" /*-----------*/, ternary(MediaSticker, IMAGE_ENCODE_QUALITY, VIDEO_STANDARD_QUALITY),
|
||||
"-g" /*------------*/, strconv.Itoa(VIDEO_STANDARD_FPS * VIDEO_KEYFRAME_INTERVAL),
|
||||
"-pix_fmt" /*------*/, MEDIA_COLOR_SPACE,
|
||||
"-color_range" /*--*/, MEDIA_COLOR_RANGE,
|
||||
"-svtav1-params" /**/, IMAGE_ENCODE_PARAMS,
|
||||
"-frames:v" /*-----*/, ternary(MediaSticker, "1", strconv.Itoa(VIDEO_STANDARD_FPS*MEDIA_MAX_DURATION)),
|
||||
"-loop" /*---------*/, ternary(MediaSticker, "1", "0"),
|
||||
"-map_metadata" /*-*/, "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathAlpha,
|
||||
|
||||
// Export: Moderation
|
||||
"-map", "[v4o]", "-an", "-sn",
|
||||
"-f" /*-------*/, "rawvideo",
|
||||
"-fps_mode" /**/, "vfr",
|
||||
"-pix_fmt" /*-*/, "rgb24",
|
||||
"-",
|
||||
}
|
||||
|
||||
if ProbeAudio != nil {
|
||||
args = append(args,
|
||||
"-map", fmt.Sprintf("0:%d", ProbeAudio.Index), "-vn", "-sn",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "64k",
|
||||
"-af", "loudnorm=I=-16:TP=-2:LRA=11",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-map_metadata", "-1",
|
||||
"-metadata", ("gifuu_machine=" + tools.MACHINE_HOSTNAME),
|
||||
"-metadata", ("gifuu_proverb=" + tools.MACHINE_PROVERB),
|
||||
PathAudio,
|
||||
)
|
||||
}
|
||||
|
||||
var encodeCtx, encodeCancel = context.WithCancel(ctx)
|
||||
defer encodeCancel()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(encodeCtx, "ffmpeg", args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Auto Moderation ---
|
||||
var (
|
||||
classifyError error
|
||||
classifyPercent float32
|
||||
classifyAllowed = true
|
||||
classifyComplete = make(chan struct{}, 1)
|
||||
frameSize = (tools.MODEL_SIZE * tools.MODEL_SIZE * 3)
|
||||
tensorSize = (tools.MODEL_FRAMERATE * frameSize)
|
||||
tensorData = make([]float32, tensorSize)
|
||||
frameData = make([]byte, tensorSize)
|
||||
)
|
||||
|
||||
go func() {
|
||||
defer close(classifyComplete)
|
||||
defer io.Copy(io.Discard, stdout)
|
||||
frameIndex := 0
|
||||
for {
|
||||
// Process Raw Frames for Model
|
||||
n, err := io.ReadFull(stdout, frameData)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
encodeCancel()
|
||||
return
|
||||
}
|
||||
|
||||
frameCount := n / frameSize
|
||||
for i := 0; i < n; i++ {
|
||||
tensorData[i] = float32(frameData[i]) / 255.0
|
||||
}
|
||||
|
||||
// Classify Frames
|
||||
logits, err := tools.ModelClassifyTensorBatch(
|
||||
tensorData[:frameCount*frameSize],
|
||||
frameCount,
|
||||
)
|
||||
if err != nil {
|
||||
classifyError = err
|
||||
encodeCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate Results
|
||||
for idx, results := range logits {
|
||||
classifyPercent = (results.Hentai + results.Porn + (results.Sexy * 0.9))
|
||||
classifyAllowed = (classifyPercent < tools.MODEL_THRESHOLD_DENY)
|
||||
|
||||
fmt.Fprintf(TempLogger,
|
||||
"#%02d | D: %.2f | H: %.2f | N: %.2f | P: %.2f | S: %.2f | T: %.2f%% | OK: %t\n",
|
||||
frameIndex+idx,
|
||||
results.Drawing,
|
||||
results.Hentai,
|
||||
results.Neutral,
|
||||
results.Porn,
|
||||
results.Sexy,
|
||||
classifyPercent,
|
||||
classifyAllowed,
|
||||
)
|
||||
|
||||
if classifyPercent > MediaRating {
|
||||
MediaRating = classifyPercent
|
||||
}
|
||||
|
||||
if !classifyAllowed {
|
||||
encodeCancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
frameIndex += frameCount
|
||||
|
||||
// Calculate Progress (Approximate)
|
||||
sse.SendJSON("progress", map[string]any{
|
||||
"percent": strconv.FormatFloat(
|
||||
min(100, (float64(frameIndex)/float64(tools.MODEL_FRAMERATE))/float64(ProbeVideo.Duration)*100),
|
||||
'f', 2, 64,
|
||||
),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// --- Wait for Results ---
|
||||
err = cmd.Wait()
|
||||
<-classifyComplete
|
||||
|
||||
fmt.Fprintf(TempLogger, "\n%s\nProcessing completed in %s\n",
|
||||
stderr.String(),
|
||||
time.Since(t),
|
||||
)
|
||||
|
||||
if ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
if !classifyAllowed {
|
||||
sse.SendClientError(tools.ERROR_MEDIA_INAPPROPRIATE)
|
||||
return
|
||||
}
|
||||
if classifyError != nil {
|
||||
sse.SendServerError(classifyError)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sse.SendJSON("step", map[string]any{"id": "SERVER_FINALIZE", "message": "Syncing"})
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Upload Objects ] ---------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
{
|
||||
var (
|
||||
TargetDir = filepath.Join(tools.STORAGE_DISK_PUBLIC, TempIDString)
|
||||
TargetAlpha = filepath.Join(TargetDir, MEDIA_FILENAME_ALPHA)
|
||||
TargetPreview = filepath.Join(TargetDir, MEDIA_FILENAME_PREVIEW)
|
||||
TargetStandard = filepath.Join(TargetDir, MEDIA_FILENAME_STANDARD)
|
||||
TargetAudio = filepath.Join(TargetDir, MEDIA_FILENAME_AUDIO)
|
||||
)
|
||||
|
||||
// --- Create Directory ---
|
||||
if err := os.MkdirAll(TargetDir, tools.FILE_PUBLIC); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if !TempSuccess {
|
||||
if err := os.RemoveAll(TargetDir); err != nil {
|
||||
tools.LoggerStorage.Log(tools.WARN, "Failed to delete incomplete files: %s", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// --- Move Files ---
|
||||
for _, op := range []struct {
|
||||
ShouldCopy bool
|
||||
PathSource string
|
||||
PathTarget string
|
||||
ContentType string
|
||||
}{
|
||||
{true, PathAlpha, TargetAlpha, "video/webm"},
|
||||
{true, PathPreview, TargetPreview, "image/avif"},
|
||||
{true, PathStandard, TargetStandard, "image/avif"},
|
||||
{ProbeAudio != nil, PathAudio, TargetAudio, "audio/ogg"},
|
||||
} {
|
||||
if !op.ShouldCopy {
|
||||
continue
|
||||
}
|
||||
|
||||
s, err := os.Open(op.PathSource)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
t, err := os.OpenFile(op.PathTarget, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, tools.FILE_PUBLIC)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer t.Close()
|
||||
|
||||
if _, err := io.Copy(t, s); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Upload Metadata ] --------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
{
|
||||
// --- Begin Transaction ---
|
||||
tx, err := tools.Database.Begin(ctx)
|
||||
if err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// --- Insert Row ---
|
||||
if _, err = tx.Exec(ctx,
|
||||
`INSERT INTO gifuu.upload (
|
||||
id,
|
||||
upload_address_hash,
|
||||
upload_token_hash,
|
||||
flag_sticker,
|
||||
flag_audio,
|
||||
encode_fps,
|
||||
encode_width,
|
||||
encode_height,
|
||||
meta_rating,
|
||||
meta_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
TempID,
|
||||
ClientAddress,
|
||||
tools.RequestHash(ClientToken),
|
||||
MediaSticker,
|
||||
ProbeAudio != nil,
|
||||
MediaFramerate,
|
||||
MediaWidth,
|
||||
MediaHeight,
|
||||
MediaRating,
|
||||
Body.Title,
|
||||
); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Upsert Tags ---
|
||||
if _, err := tx.Exec(ctx,
|
||||
`WITH upserted_tags AS (
|
||||
INSERT INTO gifuu.tag (label)
|
||||
SELECT unnest($1::text[])
|
||||
ON CONFLICT (label) DO UPDATE SET label = EXCLUDED.label
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO gifuu.upload_tag (gif_id, tag_id)
|
||||
SELECT $2, id FROM upserted_tags`,
|
||||
Body.Tags,
|
||||
TempID,
|
||||
); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Commit Transaction ---
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
sse.SendServerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
// --- [ Return Results ] ---------------------------------------------------------
|
||||
// --------------------------------------------------------------------------------
|
||||
TempSuccess = true
|
||||
sse.SendJSON("finish", map[string]any{
|
||||
"id": strconv.FormatInt(TempID, 10),
|
||||
"edit_token": ClientToken,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEventsUnsupported = errors.New("events unsupported")
|
||||
)
|
||||
|
||||
type EventHelper struct {
|
||||
m sync.Mutex
|
||||
c context.Context
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
f http.Flusher
|
||||
}
|
||||
|
||||
func NewEventHelper(ctx context.Context, w http.ResponseWriter, r *http.Request) (*EventHelper, error) {
|
||||
|
||||
// Setup Connection
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, ErrEventsUnsupported
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
sse := &EventHelper{f: flusher, c: ctx, w: w, r: r}
|
||||
|
||||
// Heartbeat Generator
|
||||
go func(h *EventHelper) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.c.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
h.m.Lock()
|
||||
fmt.Fprintf(h.w, ": ping\n\n")
|
||||
h.f.Flush()
|
||||
h.m.Unlock()
|
||||
}
|
||||
}
|
||||
}(sse)
|
||||
|
||||
return sse, nil
|
||||
}
|
||||
|
||||
func (h *EventHelper) SendJSON(eventName string, eventData any) {
|
||||
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"name": eventName,
|
||||
"data": eventData,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.m.Lock()
|
||||
fmt.Fprintf(h.w, "data: %s\n\n", b)
|
||||
h.f.Flush()
|
||||
h.m.Unlock()
|
||||
}
|
||||
|
||||
func (h *EventHelper) SendServerError(err error) {
|
||||
SendServerError(nil, h.r, err)
|
||||
h.SendClientError(ERROR_GENERIC_SERVER)
|
||||
}
|
||||
|
||||
func (h *EventHelper) SendClientError(err APIError) {
|
||||
h.SendJSON("error", err)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/bits"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type ChallengeSessionData struct {
|
||||
Expires int64
|
||||
Difficulty int
|
||||
}
|
||||
|
||||
type RatelimitEntry struct {
|
||||
Reset int64
|
||||
Usage int32
|
||||
}
|
||||
|
||||
type RatelimitShard struct {
|
||||
sync.Mutex
|
||||
data map[string]RatelimitEntry
|
||||
}
|
||||
|
||||
const (
|
||||
RatelimitShardCount = 256
|
||||
)
|
||||
|
||||
var (
|
||||
ChallengeAtomic sync.Mutex
|
||||
ChallengeSession = make(map[string]ChallengeSessionData, 1024)
|
||||
RatelimitShards = make([]RatelimitShard, RatelimitShardCount)
|
||||
)
|
||||
|
||||
func init() {
|
||||
for i := range RatelimitShards {
|
||||
RatelimitShards[i].data = make(map[string]RatelimitEntry, 128)
|
||||
}
|
||||
|
||||
// Cleanup Challenges
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
now := time.Now().UnixNano()
|
||||
ChallengeAtomic.Lock()
|
||||
for k, v := range ChallengeSession {
|
||||
if now > v.Expires {
|
||||
delete(ChallengeSession, k)
|
||||
}
|
||||
}
|
||||
ChallengeAtomic.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Cleanup Ratelimits
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
now := time.Now().UnixNano()
|
||||
for i := range RatelimitShards {
|
||||
s := &RatelimitShards[i]
|
||||
s.Lock()
|
||||
for k, v := range s.data {
|
||||
if now >= v.Reset {
|
||||
delete(s.data, k)
|
||||
}
|
||||
}
|
||||
s.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func NewChallenger(minimumDifficulty int) ChainMiddleware {
|
||||
return func(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
// --- Proof of Work ---
|
||||
var givenNonce = r.Header.Get("X-Challenge-Nonce")
|
||||
var givenCounterRaw = r.Header.Get("X-Challenge-Counter")
|
||||
var givenCounter = 0
|
||||
|
||||
if _, err := hex.DecodeString(givenNonce); err != nil {
|
||||
SendClientError(w, r, ERROR_BODY_INVALID_CHALLENGE)
|
||||
return false
|
||||
}
|
||||
if v, err := strconv.Atoi(givenCounterRaw); err != nil || v < 0 {
|
||||
SendClientError(w, r, ERROR_BODY_INVALID_CHALLENGE)
|
||||
return false
|
||||
} else {
|
||||
givenCounter = v
|
||||
}
|
||||
|
||||
// Consume Session
|
||||
ChallengeAtomic.Lock()
|
||||
session, exists := ChallengeSession[givenNonce]
|
||||
if !exists {
|
||||
ChallengeAtomic.Unlock()
|
||||
SendClientError(w, r, ERROR_UNKNOWN_CHALLENGE)
|
||||
return false
|
||||
}
|
||||
delete(ChallengeSession, givenNonce)
|
||||
ChallengeAtomic.Unlock()
|
||||
|
||||
if time.Now().Unix() >= session.Expires {
|
||||
SendClientError(w, r, ERROR_CHALLENGE_EXPIRED)
|
||||
return false
|
||||
}
|
||||
if session.Difficulty < minimumDifficulty {
|
||||
SendClientError(w, r, ERROR_CHALLENGE_TOO_EASY)
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate Results
|
||||
sessionInput := fmt.Sprintf("%s%d", givenNonce, givenCounter)
|
||||
sessionHash := sha256.Sum256([]byte(sessionInput))
|
||||
zeroBitsRequired := session.Difficulty
|
||||
zeroBitsFound := 0
|
||||
|
||||
for _, b := range sessionHash {
|
||||
if b == 0 {
|
||||
zeroBitsFound += 8
|
||||
} else {
|
||||
zeroBitsFound += bits.LeadingZeros8(b)
|
||||
break
|
||||
}
|
||||
}
|
||||
if zeroBitsFound < zeroBitsRequired {
|
||||
SendClientError(w, r, ERROR_CHALLENGE_INVALID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent Spam by Limiting Amount of Incoming Requests
|
||||
func NewRatelimiter(categoryName string, limit int32, period time.Duration) ChainMiddleware {
|
||||
limitStr := strconv.Itoa(int(limit))
|
||||
return func(w http.ResponseWriter, r *http.Request) bool {
|
||||
name := categoryName + ":" + RequestAddressHash(r)
|
||||
|
||||
// FNV-1a 32-bit
|
||||
var h uint64 = 2166136261
|
||||
for i := 0; i < len(name); i++ {
|
||||
h ^= uint64(name[i])
|
||||
h *= 16777619
|
||||
}
|
||||
|
||||
// Calculate Usage
|
||||
s := &RatelimitShards[h%RatelimitShardCount]
|
||||
s.Lock()
|
||||
now := time.Now()
|
||||
e, ok := s.data[name]
|
||||
if !ok || now.UnixNano() >= e.Reset {
|
||||
e = RatelimitEntry{Reset: now.Add(period).UnixNano(), Usage: 1}
|
||||
} else {
|
||||
e.Usage++
|
||||
}
|
||||
s.data[name] = e
|
||||
s.Unlock()
|
||||
|
||||
// Generate Headers
|
||||
resetSecs := strconv.FormatFloat(float64(e.Reset-now.UnixNano())/float64(time.Second), 'f', 2, 64)
|
||||
remaining := max(0, limit-e.Usage)
|
||||
|
||||
hdr := w.Header()
|
||||
hdr.Set("X-Ratelimit-Category", categoryName)
|
||||
hdr.Set("X-Ratelimit-Reset", resetSecs)
|
||||
hdr.Set("X-Ratelimit-Limit", limitStr)
|
||||
hdr.Set("X-Ratelimit-Remaining", strconv.Itoa(int(remaining)))
|
||||
|
||||
if e.Usage > int32(limit) {
|
||||
SendClientError(w, r, ERROR_GENERIC_RATELIMIT)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Create Gatekeeper
|
||||
func NewGatekeeper(allowModerators bool) ChainMiddleware {
|
||||
return func(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
if allowModerators {
|
||||
var Count int
|
||||
err := Database.QueryRow(r.Context(),
|
||||
"SELECT 1 FROM gifuu.mod_key WHERE token_hash = $1",
|
||||
RequestHash(r.Header.Get("Authorization")),
|
||||
).Scan(
|
||||
&Count,
|
||||
)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
SendServerError(w, r, err)
|
||||
return false
|
||||
}
|
||||
if err == pgx.ErrNoRows {
|
||||
SendClientError(w, r, ERROR_GENERIC_UNAUTHORIZED)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ChainMiddleware func(w http.ResponseWriter, r *http.Request) bool
|
||||
|
||||
// Apply Middleware before Processing Request
|
||||
func Chain(h http.HandlerFunc, wares ...ChainMiddleware) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, mw := range wares {
|
||||
if !mw(w, r) {
|
||||
return
|
||||
}
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type MethodHandler map[string]http.HandlerFunc
|
||||
|
||||
func (mh MethodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if handler, ok := mh[r.Method]; ok {
|
||||
handler(w, r)
|
||||
} else {
|
||||
SendClientError(w, r, ERROR_GENERIC_METHOD_NOT_ALLOWED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
snowflakeMutex sync.Mutex
|
||||
snowflakeMachineID int64
|
||||
snowflakeSequence int64
|
||||
snowflakeTimestamp int64
|
||||
|
||||
// Updates to normalizers here should be mirrored in `GET_Limits.go`!
|
||||
|
||||
RegexSpaces = regexp.MustCompile(`\s{2,}`)
|
||||
RegexUnderscores = regexp.MustCompile(`_+`)
|
||||
RegexNewlines = regexp.MustCompile(`\n{2,}`)
|
||||
RegexMatcherTitle = regexp.MustCompile(`^[\S\s]{1,80}$`)
|
||||
RegexMatcherTag = regexp.MustCompile(`^[\p{L}\p{N}_]{1,32}$`)
|
||||
RegexMatcherComment = regexp.MustCompile(`^[\S\s]{10,240}$`)
|
||||
)
|
||||
|
||||
func NormalizeTitle(str string) (string, bool) {
|
||||
if str == "" {
|
||||
return str, false
|
||||
}
|
||||
if !RegexMatcherTitle.MatchString(str) {
|
||||
return str, false
|
||||
}
|
||||
str = RegexSpaces.ReplaceAllString(str, " ")
|
||||
str = strings.TrimSpace(str)
|
||||
str = html.EscapeString(str)
|
||||
return str, true
|
||||
}
|
||||
|
||||
func NormalizeTag(str string) (string, bool) {
|
||||
if str == "" {
|
||||
return str, false
|
||||
}
|
||||
if !RegexMatcherTag.MatchString(str) {
|
||||
return str, false
|
||||
}
|
||||
str = RegexUnderscores.ReplaceAllString(str, "_")
|
||||
str = strings.Trim(str, "_")
|
||||
str = strings.ToUpper(str)
|
||||
return str, true
|
||||
}
|
||||
|
||||
func NormalizeComment(str string) (string, bool) {
|
||||
if str == "" {
|
||||
return str, false
|
||||
}
|
||||
if !RegexMatcherComment.MatchString(str) {
|
||||
return str, false
|
||||
}
|
||||
str = RegexNewlines.ReplaceAllString(str, " ")
|
||||
str = strings.TrimSpace(str)
|
||||
str = html.EscapeString(str)
|
||||
return str, true
|
||||
}
|
||||
|
||||
func ParseLimit(str string) int {
|
||||
v, _ := strconv.Atoi(str)
|
||||
return min(100, max(1, v))
|
||||
}
|
||||
|
||||
func ParseSnowflake(str string) int64 {
|
||||
v, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil || v < 1 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ParseJSON(r io.Reader, v any) error {
|
||||
l := io.LimitReader(r, int64(LIMIT_JSON))
|
||||
d := json.NewDecoder(l)
|
||||
d.DisallowUnknownFields()
|
||||
return d.Decode(v)
|
||||
}
|
||||
|
||||
// Generate a Unique Snowflake
|
||||
func RequestSnowflake() int64 {
|
||||
snowflakeMutex.Lock()
|
||||
defer snowflakeMutex.Unlock()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if now != snowflakeTimestamp {
|
||||
snowflakeSequence = 0
|
||||
} else {
|
||||
snowflakeSequence++
|
||||
if snowflakeSequence > SNOWFLAKE_MAX_SEQUENCE {
|
||||
for now <= snowflakeTimestamp {
|
||||
time.Sleep(time.Millisecond)
|
||||
now = time.Now().UnixMilli()
|
||||
}
|
||||
snowflakeSequence = 0
|
||||
}
|
||||
}
|
||||
|
||||
snowflakeTimestamp = now
|
||||
return ((now - SNOWFLAKE_EPOCH_MILLI) << 22) | (snowflakeMachineID << 12) | snowflakeSequence
|
||||
}
|
||||
|
||||
// Generate a Hex String out of random bytes
|
||||
func RequestToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
panic("failed to generate enough random bytes")
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Generate a SHA256 Hex String from a given string
|
||||
func RequestHash(str string) string {
|
||||
h := sha256.Sum256([]byte(str))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// Get the Client IP Address as a SHA256 Hex String
|
||||
func RequestAddressHash(r *http.Request) string {
|
||||
return RequestHash(RequestAddress(r))
|
||||
}
|
||||
|
||||
// Get the Client IP Address
|
||||
func RequestAddress(r *http.Request) string {
|
||||
if HTTP_PROXY != "" {
|
||||
return r.Header.Get(HTTP_PROXY)
|
||||
}
|
||||
addr, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type APIError struct {
|
||||
Status int `json:"-"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
ERROR_GENERIC_SERVER = APIError{Status: 500, Code: 10000, Message: "Server Error"}
|
||||
ERROR_GENERIC_NOT_FOUND = APIError{Status: 404, Code: 10001, Message: "Endpoint Not Found"}
|
||||
ERROR_GENERIC_RATELIMIT = APIError{Status: 429, Code: 10002, Message: "Too Many Requests"}
|
||||
ERROR_GENERIC_UNAUTHORIZED = APIError{Status: 401, Code: 10003, Message: "Unauthorized"}
|
||||
ERROR_GENERIC_FORBIDDEN = APIError{Status: 403, Code: 10004, Message: "Forbidden"}
|
||||
ERROR_GENERIC_METHOD_NOT_ALLOWED = APIError{Status: 405, Code: 10005, Message: "Method Not Allowed"}
|
||||
ERROR_SERVER_RESOURCES_EXHAUSTED = APIError{Status: 507, Code: 11006, Message: "Resources Exhausted"}
|
||||
ERROR_BODY_EMPTY = APIError{Status: 411, Code: 12000, Message: "Request Body is Empty"}
|
||||
ERROR_BODY_TOO_LARGE = APIError{Status: 413, Code: 12001, Message: "Request Body is Too Large"}
|
||||
ERROR_BODY_INVALID_CONTENT_TYPE = APIError{Status: 400, Code: 12002, Message: "Invalid 'Content-Type' Header"}
|
||||
ERROR_BODY_INVALID_CHALLENGE = APIError{Status: 400, Code: 12003, Message: "Invalid 'X-Challenge-*' Header"}
|
||||
ERROR_BODY_INVALID_FIELD = APIError{Status: 400, Code: 12004, Message: "Invalid Body Field"}
|
||||
ERROR_BODY_INVALID_DATA = APIError{Status: 422, Code: 12005, Message: "Invalid Body"}
|
||||
ERROR_UNKNOWN_ENDPOINT = APIError{Status: 404, Code: 13000, Message: "Unknown Endpoint"}
|
||||
ERROR_UNKNOWN_FUNCTION = APIError{Status: 404, Code: 13001, Message: "Unknown Function"}
|
||||
ERROR_UNKNOWN_ANIMATION = APIError{Status: 404, Code: 13002, Message: "Unknown Animation"}
|
||||
ERROR_UNKNOWN_TASK = APIError{Status: 404, Code: 13003, Message: "Unknown Task"}
|
||||
ERROR_UNKNOWN_CHALLENGE = APIError{Status: 404, Code: 13004, Message: "Unknown Challenge"}
|
||||
ERROR_CHALLENGE_INVALID = APIError{Status: 400, Code: 14000, Message: "Invalid Challenge Result"}
|
||||
ERROR_CHALLENGE_TOO_EASY = APIError{Status: 400, Code: 14001, Message: "Challenge Difficulty Too Low"}
|
||||
ERROR_CHALLENGE_EXPIRED = APIError{Status: 400, Code: 14002, Message: "Challenge Expired"}
|
||||
ERROR_MEDIA_INVALID = APIError{Status: 400, Code: 15000, Message: "Media Invalid"}
|
||||
ERROR_MEDIA_INAPPROPRIATE = APIError{Status: 400, Code: 15001, Message: "Media Inappropriate"}
|
||||
)
|
||||
|
||||
// Reject request due to a Client Mistake
|
||||
func SendClientError(w http.ResponseWriter, r *http.Request, err APIError) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(err.Status)
|
||||
fmt.Fprintf(w, `{"code":%d,"message":%q}`, err.Code, err.Message)
|
||||
}
|
||||
|
||||
// Reject request due to a Server Error
|
||||
// Additionally collects debug information and logs it to the console
|
||||
func SendServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
debugStack := strings.Split(string(debug.Stack()), "\n")
|
||||
for i, item := range debugStack {
|
||||
debugStack[i] = strings.ReplaceAll(item, "\t", " ")
|
||||
}
|
||||
if len(debugStack) > 5 {
|
||||
debugStack = debugStack[5:] // skip header
|
||||
}
|
||||
|
||||
reqHeader := make(map[string]string, len(r.Header))
|
||||
for key, header := range r.Header {
|
||||
reqHeader[key] = strings.Join(header, ", ")
|
||||
}
|
||||
|
||||
LoggerHTTP.Data(ERROR, err.Error(), map[string]any{
|
||||
"request": map[string]any{
|
||||
"method": r.Method,
|
||||
"url": r.URL.String(),
|
||||
"headers": reqHeader,
|
||||
},
|
||||
"error": map[string]any{
|
||||
"raw": err,
|
||||
"message": err.Error(),
|
||||
"stack": debugStack,
|
||||
},
|
||||
})
|
||||
|
||||
if w != nil {
|
||||
SendClientError(w, r, ERROR_GENERIC_SERVER)
|
||||
}
|
||||
}
|
||||
|
||||
// Respond to the request with a JSON object
|
||||
func SendJSON(w http.ResponseWriter, r *http.Request, statusCode int, responseObject any) (int, error) {
|
||||
|
||||
// Check Compression
|
||||
var g io.Writer
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
z := gzip.NewWriter(w)
|
||||
defer z.Close()
|
||||
g = z
|
||||
} else {
|
||||
g = w
|
||||
}
|
||||
|
||||
// Stream Object
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
if b, ok := responseObject.([]byte); ok {
|
||||
return g.Write(b)
|
||||
} else {
|
||||
j := json.NewEncoder(g)
|
||||
return 0, j.Encode(responseObject)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode Object as JSON and gzipped version
|
||||
func PrepareStaticJSON(responseObject any) ([]byte, []byte, error) {
|
||||
|
||||
// Encode Object
|
||||
buf, err := json.Marshal(responseObject)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Compress Object
|
||||
cmp := bytes.Buffer{}
|
||||
zip := gzip.NewWriter(&cmp)
|
||||
|
||||
if _, err := zip.Write(buf); err != nil {
|
||||
zip.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := zip.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return buf, cmp.Bytes(), nil
|
||||
}
|
||||
|
||||
// Respond to the request with a Static JSON Object
|
||||
func SendStaticJSON(w http.ResponseWriter, r *http.Request, statusCode int, content []byte, gzipped []byte) (int, error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if gzipped != nil && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
return w.Write(gzipped)
|
||||
}
|
||||
return w.Write(content)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var Database *pgxpool.Pool
|
||||
|
||||
func SetupDatabase(stop context.Context, await *sync.WaitGroup) {
|
||||
|
||||
var err error
|
||||
t := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create and Test Client
|
||||
cfg, err := pgxpool.ParseConfig(DATABASE_URL)
|
||||
if err != nil {
|
||||
LoggerDatabase.Log(FATAL, "Invalid Database URI: %s", err)
|
||||
return
|
||||
}
|
||||
if Database, err = pgxpool.NewWithConfig(ctx, cfg); err != nil {
|
||||
LoggerDatabase.Log(FATAL, "Failed to create pool: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if err = Database.Ping(ctx); err != nil {
|
||||
LoggerDatabase.Log(FATAL, "Failed to ping database: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Shutdown Logic
|
||||
await.Add(1)
|
||||
go func() {
|
||||
defer await.Done()
|
||||
<-stop.Done()
|
||||
Database.Close()
|
||||
LoggerDatabase.Log(INFO, "Closed")
|
||||
}()
|
||||
|
||||
LoggerDatabase.Log(INFO, "Ready in %s", time.Since(t))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoggerSeverity string
|
||||
|
||||
const (
|
||||
INFO LoggerSeverity = "INFO" // This is a basic informational alert.
|
||||
WARN LoggerSeverity = "WARN" // This is a warning, meaning the program has recovered from an error.
|
||||
DEBUG LoggerSeverity = "DEBUG" // This is a detailed alert containing information used for debugging.
|
||||
ERROR LoggerSeverity = "ERROR" // An error has occurred, please advise.
|
||||
FATAL LoggerSeverity = "FATAL" // An irrecoverable error has occured and the program must exit immediately.
|
||||
)
|
||||
|
||||
var (
|
||||
LoggerInit = &LoggerInstance{source: "INIT"}
|
||||
LoggerHTTP = &LoggerInstance{source: "HTTP"}
|
||||
LoggerModel = &LoggerInstance{source: "ONNX"}
|
||||
LoggerStorage = &LoggerInstance{source: "DISK"}
|
||||
LoggerDatabase = &LoggerInstance{source: "RMDB"}
|
||||
)
|
||||
|
||||
type LoggerInstance struct {
|
||||
source string
|
||||
}
|
||||
|
||||
func (p *LoggerInstance) entry(severity LoggerSeverity, source, message string) {
|
||||
target := os.Stdout
|
||||
if severity == ERROR || severity == FATAL {
|
||||
target = os.Stderr
|
||||
}
|
||||
fmt.Fprintf(target, "%s [%s] [%s] %s\n", time.Now().Format(time.DateTime), severity, source, message)
|
||||
}
|
||||
|
||||
func (p *LoggerInstance) Log(severity LoggerSeverity, format string, a ...any) {
|
||||
p.entry(severity, p.source, fmt.Sprintf(format, a...))
|
||||
if severity == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LoggerInstance) Data(severity LoggerSeverity, message string, data any) {
|
||||
if data == nil {
|
||||
p.entry(severity, p.source, message)
|
||||
} else {
|
||||
entryData := ""
|
||||
if b, err := json.MarshalIndent(data, "", " "); err != nil {
|
||||
entryData = fmt.Sprintf("marshal_error: %q", err)
|
||||
} else {
|
||||
entryData = string(b)
|
||||
}
|
||||
p.entry(severity, p.source, fmt.Sprintf("%s\n%s\n---", message, entryData))
|
||||
}
|
||||
if severity == FATAL {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gifuu/include"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
onnx "github.com/yalue/onnxruntime_go"
|
||||
)
|
||||
|
||||
const (
|
||||
MODEL_THRESHOLD_DENY = 0.95
|
||||
MODEL_THRESHOLD_HIDE = 0.75
|
||||
MODEL_SIZE = 224
|
||||
MODEL_FRAMERATE = 3
|
||||
)
|
||||
|
||||
var onnxSession *onnx.DynamicAdvancedSession
|
||||
|
||||
type ClassifyResults struct {
|
||||
Drawing float32
|
||||
Hentai float32
|
||||
Neutral float32
|
||||
Porn float32
|
||||
Sexy float32
|
||||
}
|
||||
|
||||
func SetupModel(stop context.Context, await *sync.WaitGroup) {
|
||||
if ONNX_RUNTIME_PATH == "" {
|
||||
LoggerModel.Log(WARN, "Set runtime path with envvar ONNX_RUNTIME_PATH to enable model")
|
||||
return
|
||||
}
|
||||
t := time.Now()
|
||||
|
||||
// Initialize Environment
|
||||
onnx.SetSharedLibraryPath(ONNX_RUNTIME_PATH)
|
||||
if err := onnx.InitializeEnvironment(); err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to initialize ONNX Runtime: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize Settings
|
||||
options, err := onnx.NewSessionOptions()
|
||||
if err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to create session options: %s", err)
|
||||
return
|
||||
}
|
||||
defer options.Destroy()
|
||||
|
||||
if ONNX_RUNTIME_CUDA {
|
||||
cudaOptions, err := onnx.NewCUDAProviderOptions()
|
||||
if err != nil {
|
||||
LoggerModel.Log(WARN, "CUDA unavailable, falling back to CPU: %s", err)
|
||||
} else {
|
||||
defer cudaOptions.Destroy()
|
||||
cudaOptions.Update(map[string]string{
|
||||
"cudnn_conv_algo_search": "DEFAULT", // use the only working frontend
|
||||
})
|
||||
options.AppendExecutionProviderCUDA(cudaOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Model
|
||||
session, err := onnx.NewDynamicAdvancedSessionWithONNXData(
|
||||
include.MODEL_NSFW,
|
||||
[]string{"input"},
|
||||
[]string{"prediction"},
|
||||
options,
|
||||
)
|
||||
if err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to load model: %s", err)
|
||||
return
|
||||
}
|
||||
onnxSession = session
|
||||
|
||||
// Test Model with Dummy Data
|
||||
dummy := make([]float32, MODEL_SIZE*MODEL_SIZE*3)
|
||||
if _, err := ModelClassifyTensorBatch(dummy, 1); err != nil {
|
||||
LoggerModel.Log(FATAL, "Failed to initialize model: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
await.Add(1)
|
||||
go func() {
|
||||
defer await.Done()
|
||||
<-stop.Done()
|
||||
onnxSession.Destroy()
|
||||
onnxSession = nil
|
||||
onnx.DestroyEnvironment()
|
||||
LoggerModel.Log(INFO, "Closed")
|
||||
}()
|
||||
|
||||
LoggerModel.Log(INFO, "Model ready in %s", time.Since(t))
|
||||
}
|
||||
|
||||
func ModelClassifyTensorBatch(data []float32, count int) ([]ClassifyResults, error) {
|
||||
|
||||
// Model is disabled, generate some dummy results.
|
||||
if onnxSession == nil {
|
||||
results := make([]ClassifyResults, count)
|
||||
for i := 0; i < count; i++ {
|
||||
results = append(results, ClassifyResults{Neutral: 1})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
inputTensor, err := onnx.NewTensor(
|
||||
onnx.NewShape(int64(count), MODEL_SIZE, MODEL_SIZE, 3),
|
||||
data,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer inputTensor.Destroy()
|
||||
|
||||
outputs := []onnx.ArbitraryTensor{nil}
|
||||
if err := onnxSession.Run([]onnx.ArbitraryTensor{inputTensor}, outputs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer outputs[0].Destroy()
|
||||
|
||||
raw := outputs[0].(*onnx.Tensor[float32]).GetData()
|
||||
results := make([]ClassifyResults, count)
|
||||
|
||||
for i := range results {
|
||||
base := i * 5
|
||||
results[i] = ClassifyResults{
|
||||
Drawing: raw[base+0],
|
||||
Hentai: raw[base+1],
|
||||
Neutral: raw[base+2],
|
||||
Porn: raw[base+3],
|
||||
Sexy: raw[base+4],
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
const (
|
||||
SNOWFLAKE_MAX_MACHINE_ID int64 = (1 << 10) - 1
|
||||
SNOWFLAKE_MAX_SEQUENCE int64 = (1 << 12) - 1
|
||||
SNOWFLAKE_EPOCH_MILLI = 1207008000000
|
||||
SNOWFLAKE_EPOCH_SECONDS = SNOWFLAKE_EPOCH_MILLI / 1000 // Apr 1st 2008 (Teto B-Day!)
|
||||
TIMEOUT_SHUTDOWN = 1 * time.Minute // Standard Timeout for Shutdowns
|
||||
TIMEOUT_CONTEXT = 10 * time.Second // Standard Timeout for Requests
|
||||
FILE_PUBLIC = os.FileMode(0770) // rwxrwx---
|
||||
FILE_PRIVATE = os.FileMode(0700) // rwx------
|
||||
)
|
||||
|
||||
var (
|
||||
TEMP_CAPACITY atomic.Int64
|
||||
LIMIT_JSON = EnvNumber("LIMIT_JSON", 8*1024) // ( 8KB) Size limit per incoming JSON string
|
||||
LIMIT_FILE = EnvNumber("LIMIT_FILE", 25*1024*1024) // (25MB) Size limit per incoming media file
|
||||
LIMIT_TEMP = EnvNumber("LIMIT_TEMP", 2*1024*1024*1024) // ( 2GB) Disk space allowed for temporary files
|
||||
LIMIT_ENCODES = EnvNumber("LIMIT_ENCODES", 1) // Concurrent Uploads
|
||||
LIMIT_PROBES = EnvNumber("LIMIT_PROBES", 4) // Concurrent Probes
|
||||
LIMIT_MIME_TYPE = EnvSlice("LIMIT_MIME_TYPE", ",", []string{
|
||||
/* STANDARD */ "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
|
||||
/* FUTURE */ "image/avif", "image/jxl",
|
||||
/* LEGACY */ "image/tiff", "image/bmp",
|
||||
/* STANDARD */ "video/mp4", "video/webm", "video/quicktime", "video/x-matroska",
|
||||
/* LEGACY */ "video/avi", "video/x-ms-wmv",
|
||||
})
|
||||
TEMPLATE_BASE_WEB = EnvString("TEMPLATE_BASE_WEB", "http://localhost:5173")
|
||||
TEMPLATE_BASE_CDN = EnvString("TEMPLATE_BASE_CDN", "http://localhost:3000")
|
||||
TEMPLATE_BASE_API = EnvString("TEMPLATE_BASE_API", "http://localhost:8080")
|
||||
MACHINE_ID = EnvString("MACHINE_ID", "0")
|
||||
MACHINE_HOSTNAME = EnvString("MACHINE_HOSTNAME", "le fishe")
|
||||
MACHINE_PROVERB = EnvString("MACHINE_PROVERB", "><> .o( blub blub)")
|
||||
DATABASE_URL = EnvString("DATABASE_URL", "postgresql://postgres:password@localhost:5432")
|
||||
STORAGE_DISK_TEMP = EnvString("STORAGE_DISK_TEMP", "_temp")
|
||||
STORAGE_DISK_PUBLIC = EnvString("STORAGE_DISK_PUBLIC", "_public")
|
||||
ONNX_RUNTIME_PATH = EnvString("ONNX_RUNTIME_PATH", "")
|
||||
ONNX_RUNTIME_CUDA = EnvString("ONNX_RUNTIME_CUDA", "") != ""
|
||||
HTTP_ADDRESS = EnvString("HTTP_ADDRESS", "127.0.0.1:8080")
|
||||
HTTP_PROXY = EnvString("HTTP_PROXY", "")
|
||||
)
|
||||
|
||||
var (
|
||||
SEMA_UPLOADS = semaphore.NewWeighted(int64(LIMIT_TEMP))
|
||||
SEMA_ENCODES = semaphore.NewWeighted(int64(LIMIT_ENCODES))
|
||||
SEMA_PROBES = semaphore.NewWeighted(int64(LIMIT_PROBES))
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Prepare Directories
|
||||
if err := os.MkdirAll(STORAGE_DISK_PUBLIC, FILE_PUBLIC); err != nil {
|
||||
LoggerInit.Log(FATAL, "Cannot Create Public Directory")
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(STORAGE_DISK_TEMP, FILE_PUBLIC); err != nil {
|
||||
LoggerInit.Log(FATAL, "Cannot Create Temp Directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Check Executables
|
||||
if err := exec.Command("ffmpeg", "--help").Run(); err != nil {
|
||||
LoggerInit.Log(FATAL, "FFmpeg failed to start: %s", err)
|
||||
return
|
||||
}
|
||||
if err := exec.Command("ffprobe", "--help").Run(); err != nil {
|
||||
LoggerInit.Log(FATAL, "FFprobe failed to start: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Read String from Environment
|
||||
func EnvString(field, initial string) string {
|
||||
if value := os.Getenv(field); value == "" {
|
||||
return initial
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// Read String from Environment and Parse it as a number
|
||||
func EnvNumber(field string, initial int) int {
|
||||
if value := os.Getenv(field); value == "" {
|
||||
return initial
|
||||
} else if number, err := strconv.Atoi(value); err != nil {
|
||||
return initial
|
||||
} else {
|
||||
return number
|
||||
}
|
||||
}
|
||||
|
||||
// Read String from Environment and Parse it as a slice using the given delimiter
|
||||
func EnvSlice(field, delimiter string, initial []string) []string {
|
||||
if value := os.Getenv(field); value == "" {
|
||||
return initial
|
||||
} else {
|
||||
return strings.Split(value, delimiter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StringInteger float64
|
||||
|
||||
func (d *StringInteger) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(string(data), `"`)
|
||||
v, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = StringInteger(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type StringFloat float64
|
||||
|
||||
func (d *StringFloat) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(string(data), `"`)
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = StringFloat(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type StringFramerate float64
|
||||
|
||||
func (d *StringFramerate) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(string(data), `"`)
|
||||
|
||||
if parts := strings.SplitN(s, "/", 2); len(parts) == 2 {
|
||||
|
||||
num, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid numerator: %s", err)
|
||||
}
|
||||
den, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid denominator: %s", err)
|
||||
}
|
||||
|
||||
if den == 0 {
|
||||
den = 1
|
||||
}
|
||||
|
||||
*d = StringFramerate(num / den)
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse framerate")
|
||||
}
|
||||
*d = StringFramerate(f)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProbeStream struct {
|
||||
Index int `json:"index"` // 0
|
||||
CodecType string `json:"codec_type"` // video
|
||||
Width int `json:"width"` // 1920
|
||||
Height int `json:"height"` // 1080
|
||||
NumberFrames StringInteger `json:"nb_frames"` // 1
|
||||
RFrameRate StringFramerate `json:"r_frame_rate"` // 15/1
|
||||
Duration StringFloat `json:"duration"` // 251.800
|
||||
}
|
||||
|
||||
type ProbeResults struct {
|
||||
Streams []ProbeStream `json:"streams"`
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>gifuu embed</title>
|
||||
{{ include-env }}
|
||||
{{ include-tag 'style' 'include/embed/critical.css' }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="layout-wrapper" title="Click me to visit gifuu!" target="_blank">
|
||||
<img class="watermark" src="{{ include-b64 'image/svg+xml' 'source/vectors/logo-full.svg' }}" />
|
||||
</a>
|
||||
{{ include-tag 'script' 'include/embed/foreground.ts' }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,405 @@
|
||||
<div class="document-section">
|
||||
<p class="document-header">API Guide</p>
|
||||
<p class="document-paragraph">Last Updated: April 13th 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Introduction ]</p>
|
||||
<p class="document-paragraph">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p>Use the following URLs for HTTP requests:</p>
|
||||
<pre class="document-codeblock">
|
||||
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
|
||||
</pre>
|
||||
|
||||
<p>Ratelimit headers are provided with each request:</p>
|
||||
<pre class="document-codeblock">
|
||||
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
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Transparency ]</p>
|
||||
<p class="document-paragraph">
|
||||
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.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
The <code>alpha.webm</code> 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.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
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:
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
// 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);
|
||||
</pre>
|
||||
<p class="document-paragraph">
|
||||
This technique was pioneered by
|
||||
<a target="_blank" href="https://jakearchibald.com/2024/video-with-transparency/">Jake Archibald</a>.
|
||||
If you don't want to implement this yourself, you can embed our player by clicking the
|
||||
<code>EMBED</code> button while viewing any animation.
|
||||
</p>
|
||||
|
||||
<p class="document-header">[ Audio ]</p>
|
||||
<p class="document-paragraph">
|
||||
You can check for audio by reading the <code>audio</code> field on Art objects, or by
|
||||
requesting it from the CDN and checking for a <code>404 Not Found</code> response.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
Content is encoded with the <a target="_blank" href="https://en.wikipedia.org/wiki/Opus_(audio_format)">Opus</a>
|
||||
codec inside an <a target="_blank" href="https://en.wikipedia.org/wiki/Ogg">Ogg</a> container
|
||||
for compatibility with Apple devices.
|
||||
</p>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Object Types ]</p>
|
||||
<p class="document-paragraph">The following types are returned as responses across API endpoints:</p>
|
||||
|
||||
<p class="document-subheader">Object: Tag</p>
|
||||
<pre class="document-codeblock">
|
||||
{
|
||||
"id": string // Tag ID (snowflake string)
|
||||
"label": string // Tag Name
|
||||
"usage": number // Number of animations using this tag
|
||||
}
|
||||
</pre>
|
||||
|
||||
<p class="document-subheader">Object: Art</p>
|
||||
<pre class="document-codeblock">
|
||||
{
|
||||
"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
|
||||
}
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Special ]</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /limits</p>
|
||||
<p class="document-paragraph">
|
||||
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.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
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
|
||||
}
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /challenge</p>
|
||||
<p class="document-paragraph">
|
||||
Returns a fresh Proof of Work challenge.
|
||||
You select the difficulty, the server enforces a minimum of <code>18</code>.
|
||||
Challenges expire after 5 minutes.
|
||||
They are consumed immediately upon use even if the request fails.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
Provide your completed counter and given nonce to endpoints that require PoW via the
|
||||
<code>X-Pow-Counter</code> and <code>X-Pow-Nonce</code> headers respectively.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
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
|
||||
}
|
||||
</pre>
|
||||
|
||||
<p class="document-paragraph">
|
||||
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:
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Endpoint Minimum Difficulty
|
||||
------------------------ ------------------
|
||||
POST /uploads 20
|
||||
</pre>
|
||||
|
||||
<p class="document-paragraph">Example Solver (JavaScript):</p>
|
||||
<pre class="document-codeblock">
|
||||
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
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Tags ]</p>
|
||||
<p class="document-paragraph">
|
||||
gifuu uses tags to make its database queryable. Sanitize tag strings against the rules
|
||||
provided by <code>limits.tags</code> or the server will reject your request.
|
||||
</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /tags/popular</p>
|
||||
<p class="document-paragraph">Returns the most popular tags (highest usage) on the platform.</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
limit number // Amount of results to return (range 1-100)
|
||||
|
||||
Response Body:
|
||||
Tag[]
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /tags/autocomplete</p>
|
||||
<p class="document-paragraph">Search for tags with a similar spelling using word similarity ranking.</p>
|
||||
<pre class="document-codeblock">
|
||||
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[]
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Art ]</p>
|
||||
<p class="document-paragraph">
|
||||
Content is processed and served as AVIF files for efficiency. Most modern web browsers
|
||||
and operating systems <a target="_blank" href="https://caniuse.com/avif">support this format</a>.
|
||||
</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /art/latest</p>
|
||||
<p class="document-paragraph">Returns the most recently uploaded animations, newest first.</p>
|
||||
<pre class="document-codeblock">
|
||||
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[]
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /art/search</p>
|
||||
<p class="document-paragraph">
|
||||
Returns animations matching all provided tags (AND logic). At least one
|
||||
<code>tag</code> parameter is required.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
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[]
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /art/{id}</p>
|
||||
<p class="document-paragraph">Returns metadata for a single animation.</p>
|
||||
<pre class="document-codeblock">
|
||||
Response Body:
|
||||
Art
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">DELETE /art/{id}</p>
|
||||
<p class="document-paragraph">
|
||||
Deletes an animation. Requires the edit token returned at upload time,
|
||||
passed as a query parameter. Responds with <code>204 No Content</code> on success.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
token string // Edit token from upload response
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">POST /art/{id}/reports</p>
|
||||
<p class="document-paragraph">
|
||||
Submits a moderation report for an animation.
|
||||
Valid reason type IDs are listed in <code>limits.report.values</code></code>.
|
||||
The reason text must pass the <code>report</code> validation rules from the same endpoint.
|
||||
Responds with <code>204 No Content</code> on success.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Request Body:
|
||||
{
|
||||
"type": number // Report reason ID (see 'limits.report.values')
|
||||
"reason": string // Description of the issue (10-240 characters)
|
||||
}
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Uploads ]</p>
|
||||
<p class="document-paragraph">Endpoints for creating and monitoring uploads.</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">POST /uploads</p>
|
||||
<p class="document-paragraph">
|
||||
Uploads an animation to the site. To prevent spam this endpoint requires a valid
|
||||
Proof of Work challenge solved via <code>GET /challenge</code> with a minimum difficulty of <code>20</code>.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
This endpoint responds as a <strong>Server-Sent Events (SSE) stream</strong>. Events are
|
||||
emitted throughout processing to report progress. The connection closes after the final
|
||||
<code>finish</code> event or on any error.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
NOTE: Requests exceeding the <code>limits.upload.filesize</code> limit will be aborted immediately.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
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
|
||||
</pre>
|
||||
|
||||
<pre class="document-codeblock">
|
||||
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 }
|
||||
</pre>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
<div class="document-section">
|
||||
<p class="document-header">Privacy Policy</p>
|
||||
<p class="document-paragraph">Last Updated: March 21st 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Data Collection ]</p>
|
||||
<p class="document-paragraph">We collect the following data in order to provide you with our services:</p>
|
||||
<div class="document-list">
|
||||
<p class="document-item">Content that you upload</p>
|
||||
<p class="document-item">Your edit tokens¹</p>
|
||||
<p class="document-item">Your IP address²</p>
|
||||
</div>
|
||||
<p class="document-paragraph">
|
||||
¹ 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.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
² 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Third Party ]</p>
|
||||
<p class="document-paragraph">
|
||||
We use a self-hosted instance of <a target="_blank" href="https://umami.is/">Umami</a>
|
||||
for analytics to see how people arrive at and interact with our site.
|
||||
We use <a href="https://en.wikipedia.org/wiki/UTM_parameters">UTM parameters</a>,
|
||||
which can be manually removed if desired.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
All of this data is stored anonymously on our own servers and isn’t shared with any third parties.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Contact ]</p>
|
||||
<p class="document-paragraph">
|
||||
gifuu is a personal project operated by bakonpancakz.
|
||||
For privacy or legal concerns, please visit:
|
||||
<a target="_blank" href="https://pancakz.net/">https://pancakz.net/</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="document-section">
|
||||
<p class="document-header">Terms of Service</p>
|
||||
<p class="document-paragraph">Last Updated: March 21st 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[1] Acceptance</p>
|
||||
<p class="document-paragraph">
|
||||
By using gifuu, you agree to the following terms of service.
|
||||
If you do not agree to these terms, do not use our platform.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
These terms may be updated at any time without prior notice. Continued use constitutes acceptance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[2] Content</p>
|
||||
<p class="document-paragraph">
|
||||
You are solely responsible for any content you upload to gifuu.
|
||||
You may not upload, distribute, or store content that:
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
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.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[3] Data Collection</p>
|
||||
<p class="document-paragraph">
|
||||
You agree to our data collection and privacy policies.
|
||||
A description of how we collect and process your data is available <a href="/text/privacy-policy">here</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[4] Availability</p>
|
||||
<p class="document-paragraph">
|
||||
We are not liable for any loss of data or damages resulting from use of the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[5] Other</p>
|
||||
<p class="document-paragraph">
|
||||
You agree that bunnies are adorable.
|
||||
</p>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
;(() => {
|
||||
let defer: (() => void)[] = []
|
||||
let running = false
|
||||
let paramQuality: 'standard' | 'transparent'
|
||||
let paramID: bigint
|
||||
|
||||
const elemHost = document.querySelector<HTMLAnchorElement>('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()
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 103.11 55.18">
|
||||
<path style="fill: #f0f0f0; stroke: #242424;" d="M54.36,48.13c0,3.62,2.93,6.55,6.55,6.55h22.75c6.86,0,10.07-1.56,13.62-4.68,3.55-3.12,5.33-7.66,5.33-13.61s-1.78-10.42-5.33-13.54c-3.55-3.12-6.76-4.68-13.62-4.68h-11.65c-3.62,0-6.55-2.93-6.55-6.55v-4.58c0-3.62-.93-6.55-4.55-6.55h0c-3.62,0-6.55,2.93-6.55,6.55v41.08ZM67.46,34.02c0-3.62,2.93-6.55,6.55-6.55h7.78c2.11,0,1.82.25,3.11.76,1.3.5,2.3,1.18,3.02,2.02.72.84,1.21,1.79,1.48,2.84.26,1.06.4,2.16.4,3.31s-.13,2.27-.4,3.35c-.26,1.08-.76,2.04-1.48,2.88-.72.84-1.73,1.51-3.02,2.02-1.3.5-1,.76-3.11.76h-7.78c-3.62,0-6.55-2.93-6.55-6.55v-4.82Z" />
|
||||
<path style="fill: #f0f0f0; stroke: #242424;" d="M48.76,48.13c0,3.62-2.93,6.55-6.55,6.55h-22.75c-6.86,0-10.07-1.56-13.62-4.68-3.55-3.12-5.33-7.66-5.33-13.61s1.78-10.42,5.33-13.54c3.55-3.12,6.76-4.68,13.62-4.68h11.65c3.62,0,6.55-2.93,6.55-6.55v-6.58c0-2.51,2.04-4.55,4.55-4.55h0c3.62,0,6.55,2.93,6.55,6.55v41.08ZM35.65,34.02c0-3.62-2.93-6.55-6.55-6.55h-7.78c-2.11,0-1.82.25-3.11.76-1.3.5-2.3,1.18-3.02,2.02-.72.84-1.21,1.79-1.48,2.84-.26,1.06-.4,2.16-.4,3.31s.13,2.27.4,3.35c.26,1.08.76,2.04,1.48,2.88.72.84,1.73,1.51,3.02,2.02,1.3.5,1,.76,3.11.76h7.78c3.62,0,6.55-2.93,6.55-6.55v-4.82Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,567 @@
|
||||
;(() => {
|
||||
const elemParent = document.querySelector('div.layout-background')
|
||||
const elemSprite = document.querySelector<HTMLLinkElement>('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)
|
||||
})()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1016 B |
@@ -0,0 +1,125 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta property="theme-color" content="#a0a0a0" />
|
||||
<meta property="description" content="gifuu (/gif-oo/) may contain animations and stickers" />
|
||||
<title>gifuu</title>
|
||||
|
||||
<link rel="icon" href="{{ include-b64 'image/svg+xml' 'include/favicon.svg' }}" />
|
||||
<link rel="texture" href="{{ include-b64 'image/png' 'include/texture.png' }}" />
|
||||
{{ 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' }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- Frontend -->
|
||||
<div class="layout-wrapper">
|
||||
<div class="layout-pane layout-background"></div>
|
||||
<div class="layout-pane layout-foreground"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/source/index.tsx"></script>
|
||||
{{ include-tag 'script' 'include/index/background.ts' }}
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
:root {
|
||||
--noscript-corner-thickness: 1px;
|
||||
--noscript-corner-margin: -16px;
|
||||
--noscript-corner-color: #606060;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
div.noscript-layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
margin: auto;
|
||||
padding: 16px 0;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
}
|
||||
|
||||
div.noscript-wrapper {
|
||||
position: relative;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
main.noscript-container {
|
||||
background-color: #161616;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Elements */
|
||||
p.noscript-message {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #f0f0f0;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Effects */
|
||||
div.noscript-corner {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
div.noscript-corner:nth-child(1) {
|
||||
top: var(--noscript-corner-margin);
|
||||
left: var(--noscript-corner-margin);
|
||||
border-top: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
border-left: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
}
|
||||
|
||||
div.noscript-corner:nth-child(2) {
|
||||
top: var(--noscript-corner-margin);
|
||||
right: var(--noscript-corner-margin);
|
||||
border-top: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
border-right: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
}
|
||||
|
||||
div.noscript-corner:nth-child(3) {
|
||||
bottom: var(--noscript-corner-margin);
|
||||
left: var(--noscript-corner-margin);
|
||||
border-bottom: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
border-left: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
}
|
||||
|
||||
div.noscript-corner:nth-child(4) {
|
||||
right: var(--noscript-corner-margin);
|
||||
bottom: var(--noscript-corner-margin);
|
||||
border-right: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
border-bottom: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="noscript-layout">
|
||||
<div class="noscript-wrapper">
|
||||
<div class="noscript-corner"></div>
|
||||
<div class="noscript-corner"></div>
|
||||
<div class="noscript-corner"></div>
|
||||
<div class="noscript-corner"></div>
|
||||
<main class="noscript-container">
|
||||
<p class="noscript-message"><b>NOTICE:</b> JavaScript is required to view this site.</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+2758
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
# \_/
|
||||
# ()o_o) <( beep boop )
|
||||
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { routeBack, routeBackURI } from '../../functions/Route'
|
||||
import './styles/Back.css'
|
||||
|
||||
export default function InputBack() {
|
||||
return (
|
||||
<a
|
||||
className="input-back"
|
||||
href={routeBackURI()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
routeBack()
|
||||
}}>
|
||||
<< BACK
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLButtonElement>
|
||||
}
|
||||
|
||||
export default function InputButton({ id, label, disabled, selected, rainbow, onClick }: PropsForInputButton) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
onClick={onClick}
|
||||
disabled={disabled || selected}
|
||||
className={`input-button ${selected ? 'selected' : ''} ${rainbow ? 'rainbow' : ''}`}>
|
||||
{label.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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 <div className={`input-button-line ${split ? 'split' : 'row'}`}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import './styles/Description.css'
|
||||
|
||||
interface PropsForInputDescription {
|
||||
children: string | string[]
|
||||
}
|
||||
|
||||
export default function InputDescription({ children }: PropsForInputDescription) {
|
||||
return <p className="input-description animation-fade-in">{children}</p>
|
||||
}
|
||||
@@ -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<HandleForInputFile, PropsForInputFile>(({ limits }, ref) => {
|
||||
const componentID = useId()
|
||||
const previewRef = useRef<HTMLVideoElement | HTMLImageElement>(undefined)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [fileInstance, setFileInstance] = useState<File>()
|
||||
const [fileObjectURL, setFileObjectURL] = useState<string>()
|
||||
|
||||
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 (
|
||||
<div
|
||||
id="input-file"
|
||||
className="input-file"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
updateInput(e.dataTransfer.files.item(0)!)
|
||||
}}>
|
||||
<input
|
||||
id={componentID}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={limits?.mime_types ? limits.mime_types.join(',') : '*/*'}
|
||||
onChange={(e) => {
|
||||
e.preventDefault()
|
||||
updateInput(e.target.files?.item(0) ?? undefined)
|
||||
}}
|
||||
/>
|
||||
|
||||
{!fileInstance && (
|
||||
<div className="prompt">
|
||||
<img className="icon animation-fade-in" src={VectorIconTop} />
|
||||
<span className="header animation-scroll-in">DRAG OR CLICK TO UPLOAD A FILE</span>
|
||||
{limits && (
|
||||
<span className="subheader animation-scroll-in">
|
||||
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;
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileInstance && (
|
||||
<div className="preview">
|
||||
{fileInstance.type.startsWith('video') && (
|
||||
<video
|
||||
ref={previewRef as RefObject<HTMLVideoElement>}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
src={fileObjectURL}
|
||||
/>
|
||||
)}
|
||||
{fileInstance.type.startsWith('image') && (
|
||||
<img ref={previewRef as RefObject<HTMLImageElement>} src={fileObjectURL} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputFile
|
||||
@@ -0,0 +1,14 @@
|
||||
import './styles/Label.css'
|
||||
|
||||
interface PropsForInputLabel {
|
||||
for: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function InputLabel(p: PropsForInputLabel) {
|
||||
return (
|
||||
<label htmlFor={p.for} className="input-label animation-scroll-in" aria-label={p.label}>
|
||||
{p.label.toUpperCase()}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -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<string, BackendTag[]>()
|
||||
|
||||
export interface HandleForInputTags {
|
||||
getValue: () => BackendTag[]
|
||||
}
|
||||
|
||||
interface PropsForInputTags {
|
||||
label: string
|
||||
allowCustom: boolean
|
||||
onChange: ((tags: BackendTag[]) => void) | undefined
|
||||
}
|
||||
|
||||
const InputTags = forwardRef<HandleForInputTags, PropsForInputTags>(({ label, onChange, allowCustom }, ref) => {
|
||||
const componentID = useId()
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [tagsSelected, setTagsSelected] = useState<BackendTag[]>([])
|
||||
const [tagsAvailable, setTagsAvailable] = useState<BackendTag[]>([])
|
||||
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<BackendTag[]>(
|
||||
`/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<HTMLInputElement>) {
|
||||
// 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 && <InputLabel for={componentID} label={label} />}
|
||||
<div className="input-tags" onClick={() => inputRef.current?.focus()}>
|
||||
{/* Tag Search */}
|
||||
<div className="search">
|
||||
{tagsSelected.map((tag) => (
|
||||
<button className="item" onClick={() => selectRemoveTag(tag)}>
|
||||
{formatTagTextContent(tag.label)}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
id={componentID}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="query"
|
||||
placeholder={tagsSelected.length ? '' : 'Search'}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onChange={(e) => setInputQuery(e.currentTarget.value)}
|
||||
value={inputQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tag Results */}
|
||||
<div className="results">
|
||||
{tagsAvailable.map((tag, i) => {
|
||||
const classSelect = i === indexHighlight ? 'select' : ''
|
||||
const classCustom = tag.id === 'CUSTOM' ? 'custom' : ''
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`item ${classSelect} ${classCustom}`}
|
||||
onClick={() => selectAppendTag(tag)}>
|
||||
<span className="label animation-scroll-in">{formatTagTextContent(tag.label)}</span>
|
||||
<span className="usage animation-fade-in">
|
||||
{classCustom ? '<CREATE>' : formatTagUsage(tag.usage)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputTags
|
||||
@@ -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<HandleForInputText, PropsForInputTags>(({ label, placeholder }, ref) => {
|
||||
const componentID = useId()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue: () => inputRef.current?.value ?? '',
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && <InputLabel for={componentID} label={label} />}
|
||||
<input id={componentID} ref={inputRef} type="text" className="input-text" placeholder={placeholder} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default InputText
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
p.input-description {
|
||||
padding-bottom: 12px;
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
label.input-label:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
label.input-label {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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(
|
||||
<div
|
||||
className="ephemeral-tooltip animation-scroll-in"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: rect.left + rect.width / 2,
|
||||
top: rect.top - 8,
|
||||
transform: 'translateX(-50%) translateY(-100%)',
|
||||
}}>
|
||||
<p>{message}</p>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="footer-error">
|
||||
<img className="icon" src={vectorIconCross} />
|
||||
<span className="text">{reason}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="footer-loading">
|
||||
<span className="text">{(reason ?? 'Loading').toUpperCase()}</span>
|
||||
<img className="icon" src={vectorThrobbing} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import './styles/FooterText.css'
|
||||
|
||||
interface PropsForFooterText {
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function FooterText({ label }: PropsForFooterText) {
|
||||
return <span className="footer-text">{label}</span>
|
||||
}
|
||||
@@ -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 */ ` \\_/<br>()o_o) <( beep! )`,
|
||||
/* bunny */ ` /)/)<br>( . .) sorry...<br>( づ♥`,
|
||||
]
|
||||
const face = kamoji[Math.floor(Math.random() * kamoji.length)]
|
||||
|
||||
return (
|
||||
<div className="header-error">
|
||||
<span className="emote" dangerouslySetInnerHTML={{ __html: face }} />
|
||||
<span className="message" dangerouslySetInnerHTML={{ __html: reason }}></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="header-loading animation-fade-in">
|
||||
<img className="icon" src={vectorIconThrobber} />
|
||||
<span className="hint">{(reason ?? 'Loading').toUpperCase()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import './styles/HeaderMessage.css'
|
||||
|
||||
interface PropsForHeaderMessage {
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function HeaderMessage({ label }: PropsForHeaderMessage) {
|
||||
return (
|
||||
<div className="header-message">
|
||||
<div className="wrapper">
|
||||
<span className="title animation-scroll-in">
|
||||
{label.toUpperCase()}
|
||||
<span className="cursor animation-blink">_</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="layout-browser" ref={containerRef}>
|
||||
{columns.map((column, columnIdx) => (
|
||||
<div key={columnIdx} className="column">
|
||||
{column.map((item, itemIdx) => {
|
||||
const animationOrder = Math.min(columnIdx + itemIdx * columnCount, 25)
|
||||
const animationDelay = `calc(${animationOrder} * var(--animation-step-delay))`
|
||||
return (
|
||||
<a
|
||||
className="item"
|
||||
href={`/art/${item.id}`}
|
||||
onClick={(e) =>
|
||||
routeIntercept(e, item, {
|
||||
position: scrollRoot?.scrollTop ?? 0,
|
||||
items: items,
|
||||
} as RecoverForLayoutBrowser)
|
||||
}>
|
||||
<img
|
||||
style={{ animationDelay }}
|
||||
className="preview animation-fall-in"
|
||||
src={`${CDN_BASE}/${item.id}/preview.avif`}
|
||||
/>
|
||||
<div className="metadata">
|
||||
<div className="title">{item.title}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLCanvasElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(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 (
|
||||
<div className={`media-canvas ${background ? 'background' : ''}`}>
|
||||
{!error && loading && (
|
||||
<div className="popup">
|
||||
<img className="icon" src={vectorIconThrobber} />
|
||||
<span className="hint">LOADING</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="popup">
|
||||
<img className="icon" src={vectorIconCross} />
|
||||
<span className="hint">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{!error && fallback && (
|
||||
<img
|
||||
className="render"
|
||||
src={`${CDN_BASE}/${id}/standard.avif`}
|
||||
onError={() => setError('Cannot Load Image')}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
)}
|
||||
{!error && !fallback && (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
crossOrigin="anonymous"
|
||||
className="decode"
|
||||
src={`${CDN_BASE}/${id}/alpha.webm`}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
<canvas ref={canvasRef} className="render" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLButtonElement>
|
||||
}
|
||||
|
||||
export default forwardRef<HTMLDialogElement, PropsForModalEmbed>(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<number>(
|
||||
(() => {
|
||||
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(
|
||||
() =>
|
||||
`<iframe src="${WEB_BASE}/embed.html?id=${item.id}&quality=${preferQuality}" width="${embedWidth}" height="${embedHeight}" style="border:none; background: transparent" allowtransparency="true"></iframe>`,
|
||||
[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 (
|
||||
<dialog ref={ref} className="modal-embed animation-fall-in animation-caution">
|
||||
<HeaderMessage label="MENU: Embed Generator" />
|
||||
<div className="wrapper">
|
||||
{/* Left-Pane */}
|
||||
<div className="preview">
|
||||
<img className="background animation-fade-in" src={VectorBackgroundEmbed} />
|
||||
<iframe
|
||||
className="animation-fall-in"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
src={`${WEB_BASE}/embed.html?id=${item.id}&quality=${preferQuality}`}
|
||||
width={embedWidth}
|
||||
height={embedHeight}
|
||||
allowTransparency
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right-Pane */}
|
||||
<div className="toggles">
|
||||
<InputLabel for="" label="Quality" />
|
||||
<InputDescription>
|
||||
We recommend using Standard quality, if you require transparency use Alpha quality.
|
||||
</InputDescription>
|
||||
<InputDescription>
|
||||
Using more than three Alpha embeds may slow down your site, and up to twelve can be displayed at
|
||||
any given time.
|
||||
</InputDescription>
|
||||
<InputButtonRow split={false}>
|
||||
<InputButton
|
||||
id="quality-alpha"
|
||||
label="Alpha"
|
||||
onClick={() => setQuality('transparent')}
|
||||
selected={preferQuality === 'transparent'}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="quality-standard"
|
||||
label="Standard"
|
||||
onClick={() => setQuality('standard')}
|
||||
selected={preferQuality === 'standard'}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
|
||||
<InputLabel for="" label="Scale" />
|
||||
<InputDescription>
|
||||
Sizing is as follows: Small @ 320px; Medium @ 480px; Large @ 640px.
|
||||
</InputDescription>
|
||||
<InputDescription>If an image is too small, it wont get any larger.</InputDescription>
|
||||
<InputButtonRow split={false}>
|
||||
<InputButton
|
||||
id="size-small"
|
||||
label="Small"
|
||||
selected={preferScale < 0.6}
|
||||
onClick={() => setScale(0.5)}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="size-medium"
|
||||
label="Medium"
|
||||
selected={preferScale > 0.6 && preferScale < 0.9}
|
||||
onClick={() => setScale(0.75)}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="size-large"
|
||||
label="Large"
|
||||
selected={preferScale > 0.9}
|
||||
onClick={() => setScale(1)}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
|
||||
<InputLabel for="" label="Code" />
|
||||
<InputDescription>
|
||||
Use this code snippet to display this {item.sticker ? 'sticker' : 'animation'} on your website.
|
||||
</InputDescription>
|
||||
<InputDescription>Clicking on the embed will direct users to gifuu in a new tab.</InputDescription>
|
||||
|
||||
<textarea
|
||||
id="input-url"
|
||||
className="input-url"
|
||||
value={embedHTML}
|
||||
onKeyDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
|
||||
<InputLabel for="" label="" /* lazy divider */ />
|
||||
|
||||
<InputButtonRow split={true}>
|
||||
<InputButton
|
||||
id="action-copy"
|
||||
label="Copy HTML"
|
||||
onClick={onCopy}
|
||||
selected={false}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-exit"
|
||||
label="Exit"
|
||||
onClick={onClose}
|
||||
selected={false}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import vectorClose from '../../vectors/category-close.svg'
|
||||
import vectorOpen from '../../vectors/category-open.svg'
|
||||
import './styles/SidebarCategory.css'
|
||||
|
||||
interface PropsForSidebarCategory {
|
||||
label: string
|
||||
header?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function SidebarCategory({ label, header, children }: PropsForSidebarCategory) {
|
||||
const key = `category_closed_${label.toLowerCase()}`
|
||||
const [closed, setClosed] = useState(localStorage.getItem(key) === 'Y')
|
||||
useEffect(() => localStorage.setItem(key, closed ? 'Y' : 'N'), [closed])
|
||||
|
||||
return (
|
||||
<div className={`category ${closed ? 'close' : 'open'}`}>
|
||||
{header && (
|
||||
<button className="toggle" onClick={() => setClosed(!closed)}>
|
||||
<span className="label">{label.toUpperCase()}</span>
|
||||
<img
|
||||
className="icon"
|
||||
alt={`Toggle visibility for ${label}`}
|
||||
src={closed ? vectorClose : vectorOpen}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="items">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import './styles/SidebarItemIcon.css'
|
||||
|
||||
interface PropsForSidebarItemIcon {
|
||||
icon: string
|
||||
label: string
|
||||
description: string
|
||||
location: string
|
||||
}
|
||||
|
||||
export default function SidebarItemIcon({ icon, label, description, location }: PropsForSidebarItemIcon) {
|
||||
return (
|
||||
<a className="item-icon" href={location} onClick={routeIntercept}>
|
||||
<div className="section-left">
|
||||
<span className="header animation-scroll-in" aria-label={label}>
|
||||
{label.toUpperCase()}
|
||||
</span>
|
||||
<span className="subheader animation-scroll-in" aria-label={description}>
|
||||
{description.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="section-right animation-fade-in">
|
||||
<img className="foreground" src={icon} />
|
||||
<div className="background"></div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import vectorLogoFull from '../../vectors/logo-full.svg'
|
||||
import './styles/SidebarItemLogo.css'
|
||||
|
||||
export default function SidebarItemLogo() {
|
||||
return (
|
||||
<a className="item-logo" href="/" onClick={routeIntercept}>
|
||||
<img className="logo" src={vectorLogoFull} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { formatTagTextContent, formatTagUsage } from '../../functions/Format'
|
||||
import type { BackendTag } from '../../functions/BackendTypes'
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import './styles/SidebarItemTag.css'
|
||||
|
||||
export default function SidebarItemTag({ id, label, usage }: BackendTag) {
|
||||
return (
|
||||
<a className="item-tag" href={`/search?tag=${id}`} onClick={routeIntercept}>
|
||||
<span className="label animation-scroll-in">{formatTagTextContent(label)}</span>
|
||||
<span className="usage animation-fade-in">{formatTagUsage(usage)}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import './styles/SidebarItemText.css'
|
||||
|
||||
interface PropsForSidebarItemText {
|
||||
location: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function SidebarItemText({ location, label }: PropsForSidebarItemText) {
|
||||
return (
|
||||
<a className="item-text animation-scroll-in" href={location} onClick={routeIntercept}>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.footer-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.footer-error span.text {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
div.footer-error img.icon {
|
||||
opacity: 0.5;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.footer-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.footer-loading span.text {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
div.footer-loading img.icon {
|
||||
opacity: 0.5;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
span.footer-text {
|
||||
padding: 8px 0;
|
||||
height: 16px;
|
||||
color: var(--font-color-secondary);
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.header-error {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
div.header-error span.emote {
|
||||
margin: 16px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
div.header-error span.message {
|
||||
color: var(--font-color-secondary);
|
||||
line-height: 2;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.header-loading {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
div.header-loading img.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
div.header-loading span.hint {
|
||||
color: var(--font-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
div.header-message {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
div.header-message div.wrapper {
|
||||
position: fixed;
|
||||
align-content: center;
|
||||
z-index: 999;
|
||||
box-sizing: border-box;
|
||||
inset: 0;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
div.header-message div.wrapper span.title {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
div.header-message div.wrapper span.title,
|
||||
div.header-message div.wrapper span.cursor {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
div.layout-browser {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
div.layout-browser div.column {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
div.layout-browser a.item {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
background-color: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.layout-browser a.item img.preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
div.layout-browser a.item div.metadata {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--animation-transition) ease-in-out;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 24px 8px 8px;
|
||||
}
|
||||
|
||||
div.layout-browser a.item:hover div.metadata,
|
||||
div.layout-browser a.item:focus-visible div.metadata {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
div.media-canvas {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.media-canvas div.popup {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.media-canvas div.popup img.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
div.media-canvas video.decode {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
div.media-canvas img.render.error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.media-canvas img.render,
|
||||
div.media-canvas canvas.render {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: inherit;
|
||||
height: 100%;
|
||||
max-height: inherit;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
div.media-canvas.background {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
dialog.modal-embed::backdrop {
|
||||
animation: kf-modal-backdrop 1s linear forwards;
|
||||
/* animation-delay: 2s; */
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
@keyframes kf-modal-backdrop {
|
||||
0% {
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
dialog.modal-embed {
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
width: 1024px;
|
||||
}
|
||||
|
||||
dialog.modal-embed:open {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.preview {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.preview > img.background {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.toggles {
|
||||
display: grid;
|
||||
flex-basis: 100%;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
max-width: 300px;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.toggles > textarea {
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
resize: none;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
nav.layout-sidebar div.category {
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category button.toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-bottom: var(--border-thickness) solid transparent;
|
||||
padding: 8px 8px 8px 0px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category button.toggle img.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category.open button.toggle {
|
||||
margin-bottom: 8px;
|
||||
border-color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category.close div.items {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
nav.layout-sidebar a.item-icon {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 8px 12px 4px;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon:hover,
|
||||
nav.layout-sidebar a.item-icon:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-left {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-left span.header,
|
||||
nav.layout-sidebar a.item-icon div.section-left span.subheader {
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
/* nav.layout-sidebar a.item-icon div.section-left span.header {} */
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-left span.subheader {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right div.background,
|
||||
nav.layout-sidebar a.item-icon div.section-right img.foreground {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right div.background {
|
||||
transform: rotate(45deg);
|
||||
background-color: var(--background-secondary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right img.foreground {
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: auto;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
nav.layout-sidebar a.item-logo img.logo {
|
||||
padding: 16px 0;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
nav.layout-sidebar a.item-tag {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-tag.dummy {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-tag[href]:hover,
|
||||
nav.layout-sidebar a.item-tag[href]:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-tag span.usage {
|
||||
color: var(--font-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
nav.layout-sidebar a.item-text {
|
||||
padding: 4px;
|
||||
color: var(--font-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-text:hover,
|
||||
nav.layout-sidebar a.item-text:focus-visible {
|
||||
color: var(--font-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { ScrollContext } from '../../functions/Context'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderError from '../layout/HeaderError'
|
||||
|
||||
import ViewAnimation from '../views/Animation'
|
||||
import ViewHomepage from '../views/Homepage'
|
||||
import ViewPersonal from '../views/Personal'
|
||||
import ViewSearch from '../views/Search'
|
||||
import ViewSettings from '../views/Settings'
|
||||
import ViewText from '../views/Text'
|
||||
import ViewUpload from '../views/Upload'
|
||||
|
||||
export default function PaneContent() {
|
||||
const [mainElem, setMainElem] = useState<HTMLElement | null>(null)
|
||||
const [path, setPath] = useState(window.location.pathname)
|
||||
const [key, setKey] = useState(window.location.href)
|
||||
|
||||
// Track Path
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
setPath(window.location.pathname)
|
||||
setKey(window.location.href)
|
||||
}
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [])
|
||||
|
||||
// Match Component
|
||||
const views = new Array<{ route: RegExp; scroll: boolean; component: (m: RegExpMatchArray) => ReactNode }>(
|
||||
{ route: /^\/art\/([0-9]+)$/, scroll: false, component: (m) => <ViewAnimation key={key} id={m[1]} /> },
|
||||
{ route: /^\/text\/([a-z-]+)$/, scroll: true, component: (m) => <ViewText key={key} id={m[1]} /> },
|
||||
{ route: /^\/personal$/, scroll: true, component: (_) => <ViewPersonal /> },
|
||||
{ route: /^\/upload$/, scroll: false, component: (_) => <ViewUpload key={key} /> },
|
||||
{ route: /^\/settings$/, scroll: true, component: (_) => <ViewSettings key={key} /> },
|
||||
{ route: /^\/search$/, scroll: true, component: (_) => <ViewSearch key={key} /> },
|
||||
{ route: /^\/$/, scroll: true, component: (_) => <ViewHomepage key={key} /> },
|
||||
)
|
||||
|
||||
const match = views.map((v) => ({ v, m: path.match(v.route) })).find(({ m }) => m !== null)
|
||||
const relevant = match ? { ...match.v, component: match.v.component(match.m!) } : null
|
||||
|
||||
// Render Content
|
||||
return (
|
||||
<ScrollContext.Provider value={mainElem}>
|
||||
<main ref={setMainElem} className={`layout-content ${relevant?.scroll ? 'layout-scrolling' : ''}`}>
|
||||
{relevant ? (
|
||||
relevant.component
|
||||
) : (
|
||||
<>
|
||||
<HeaderMessage label="System Message" />
|
||||
<HeaderError reason="The page you requested was not found." />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</ScrollContext.Provider>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user