This commit is contained in:
2026-05-23 17:17:56 -07:00
commit 448f2e33ef
135 changed files with 11817 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.development
_public
_temp
node_modules
dist
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "golang.go"]
}
+26
View File
@@ -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
View File
@@ -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/
+183
View File
@@ -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$;
+16
View File
@@ -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
)
+28
View File
@@ -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=
+30
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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
}
}
+48
View File
@@ -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)
}
+61
View File
@@ -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)
}
+57
View File
@@ -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)
}
+81
View File
@@ -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)
}
+43
View File
@@ -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,
})
}
+115
View File
@@ -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)
}
+84
View File
@@ -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
}
}
+40
View File
@@ -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)
}
+31
View File
@@ -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)
}
+55
View File
@@ -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)
}
+751
View File
@@ -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,
})
}
+82
View File
@@ -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)
}
+237
View File
@@ -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)
}
}
+148
View File
@@ -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
}
+145
View File
@@ -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)
}
+45
View File
@@ -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))
}
+62
View File
@@ -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)
}
}
+138
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+78
View File
@@ -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"`
}
+13
View File
@@ -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"]
}
+19
View File
@@ -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>
+405
View File
@@ -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&sup1;</p>
<p class="document-item">Your IP address&sup2;</p>
</div>
<p class="document-paragraph">
&sup1; 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">
&sup2; 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 isnt 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>
+106
View File
@@ -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;
}
+269
View File
@@ -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()
})
})()
+5
View File
@@ -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

+567
View File
@@ -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)
})()
+209
View File
@@ -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

+125
View File
@@ -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>
+2758
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+97
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
# \_/
# ()o_o) <( beep boop )
User-agent: *
Disallow:
+32
View File
@@ -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()
}}>
&lt;&lt; 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>
}
+104
View File
@@ -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} &times; {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>
)
}
+184
View File
@@ -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 */ `&gt;&lt;&gt; .o( blub blub )`,
/* sleepy */ `( _ _) .zZ`,
/* kitty! */ `(=^'w'^=) <( meow? )`,
/* clueless */ `(>_< ") <( eek! )`,
/* robot */ `&nbsp;&nbsp;\\_/<br>()o_o) <( beep! )`,
/* bunny */ `&nbsp;/)/)<br>( . .)&nbsp;sorry...<br>(&nbsp;づ&hearts;`,
]
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