1
0
Fork 0

Import existing project

This commit is contained in:
Caileb 2025-05-26 12:42:36 -05:00
parent 7887817595
commit 80b0cc4939
125 changed files with 16980 additions and 0 deletions

BIN
backup apr 17 2025.7z Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
{"hmac_secret":"HJ9EY5oN0pO71AUN/2faoYnJhZyTr875iWbw6Nl8rc8=","created_at":"2025-04-28T18:28:59.6204724-05:00","updated_at":"2025-04-28T18:33:55.3259956-05:00"}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
~t(ðæ4¥ ø2³Dî•æHello Badger

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
{"hmac_secret":"HJ9EY5oN0pO71AUN/2faoYnJhZyTr875iWbw6Nl8rc8=","created_at":"2025-04-28T18:28:59.6204724-05:00","updated_at":"2025-04-28T18:33:55.3259956-05:00"}

View file

@ -0,0 +1 @@
~t(ðæ4¥ ø2³Dî•æHello Badger

39
checkpoint_service/go.mod Normal file
View file

@ -0,0 +1,39 @@
module checkpoint_service
go 1.24.1
require (
github.com/BurntSushi/toml v1.5.0
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396
github.com/dgraph-io/badger/v4 v4.7.0
github.com/gofiber/fiber/v2 v2.52.6
github.com/mileusna/useragent v1.3.5
github.com/oschwald/geoip2-golang v1.11.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

76
checkpoint_service/go.sum Normal file
View file

@ -0,0 +1,76 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396 h1:W2HK1IdCnCGuLUeyizSCkwvBjdj0ZL7mxnJYQ3poyzI=
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396/go.mod h1:tGWUZLZp9ajsxUOnHmFFLnqnlKXsCn6GReG4jAD59H0=
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/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y=
github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,66 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"time"
"checkpoint_service/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/logger"
)
func main() {
// Parse command-line flags
port := flag.String("port", "8080", "Port to listen on")
skipCheckpoint := flag.Bool("skip-checkpoint", false, "Skip the checkpoint middleware")
flag.Parse()
// Create Fiber app
app := fiber.New()
// Request logging
app.Use(logger.New())
// Response compression
app.Use(compress.New())
// Load and apply middleware plugins
for _, handler := range middleware.LoadPlugins(*skipCheckpoint) {
app.Use(handler)
}
log.Println("Loaded middleware plugins")
// API group for proof-of-work endpoints
api := app.Group("/api")
api.Get("/pow/challenge", middleware.GetCheckpointChallengeHandler)
api.Post("/pow/verify", middleware.VerifyCheckpointHandler)
api.Get("/verify", middleware.VerifyCheckpointHandler)
// Start the server in a goroutine
go func() {
addr := ":" + *port
log.Printf("Checkpoint service starting on %s", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
// Graceful shutdown on SIGINT/SIGTERM
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.ShutdownWithContext(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exiting")
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
# -----------------------------------------------------------------------------
# Checkpoint Middleware Configuration (checkpoint.toml)
#
# All durations are parsed via time.ParseDuration (e.g. "24h").
# Arrays and tables map directly to the Config struct fields.
# -----------------------------------------------------------------------------
# === GENERAL SETTINGS ===
# Number of leading zeros required in PoW hash
Difficulty = 4
# Validity period for issued tokens
TokenExpiration = "24h"
# Name of the cookie used to store the checkpoint token
CookieName = "checkpoint_token"
# Domain attribute for the cookie; empty = host-only (localhost)
CookieDomain = ""
# Length of the random salt in bytes for challenges
SaltLength = 16
# === RATE LIMITING & EXPIRATION ===
# Max PoW verification attempts per IP per hour
MaxAttemptsPerHour = 10
# Max age for used nonces before cleanup
MaxNonceAge = "24h"
# Time allowed for solving a challenge
ChallengeExpiration = "5m"
# === PERSISTENCE PATHS ===
# File where HMAC secret is stored
SecretConfigPath = "./data/checkpoint_secret.json"
# Directory for BadgerDB token store
TokenStoreDBPath = "./data/checkpoint_tokendb"
# Ordered fallback paths for interstitial HTML
InterstitialPaths = [
"./public/static/pow-interstitial.html",
"./develop/static/pow-interstitial.html"
]
# === SECURITY SETTINGS ===
# Enable Proof-of-Space-Time consistency checks
CheckPoSTimes = true
# Allowed ratio between slowest and fastest PoS runs
PoSTimeConsistencyRatio = 1.35
# === HTML CHECKPOINT EXCLUSIONS ===
# Path prefixes to skip PoW interstitial
HTMLCheckpointExclusions = ["/api"]
# File extensions to skip PoW check
HTMLCheckpointExcludedExtensions = { ".jpg" = true, ".jpeg" = true, ".png" = true, ".gif" = true, ".svg" = true, ".webp" = true, ".ico" = true, ".bmp" = true, ".tif" = true, ".tiff" = true, ".mp4" = true, ".webm" = true, ".css" = true, ".js" = true, ".mjs" = true, ".woff" = true, ".woff2" = true, ".ttf" = true, ".otf" = true, ".eot" = true, ".json" = true, ".xml" = true, ".txt" = true, ".pdf" = true, ".map" = true, ".wasm" = true }
# === QUERY SANITIZATION ===
# Regex patterns (case-insensitive) to block in query strings
DangerousQueryPatterns = [
"(?i)union\\s+select",
"(?i)drop\\s+table",
"(?i)insert\\s+into",
"(?i)<script",
"(?i)javascript:",
"(?i)onerror=",
]
# Block queries containing ';', '`', or '\\'
BlockDangerousPathChars = true
# === USER-AGENT VALIDATION ===
# Path prefixes to skip UA validation
UserAgentValidationExclusions = ["/api"]
# Required UA prefix per path prefix
[UserAgentRequiredPrefixes]
"/demo1" = "Dart/"
# === REVERSE PROXY MAPPINGS ===
# Hostname-to-backend URL map
[ReverseProxyMappings]
"jellyfin.caileb.com" = "http://192.168.0.2:8096"
"archive.caileb.com" = "http://192.168.0.2:7461"
"music.caileb.com" = "http://192.168.0.2:4533"
"gallery.caileb.com" = "http://192.168.0.2:2283"

View file

@ -0,0 +1,39 @@
# IPFilter Configuration
# Page shown when a request is blocked
defaultBlockPage = "default-block.html"
# Cache block decisions (seconds)
ipBlockCacheTTLSec = 300
# Country codes to block
blockedCountryCodes = [
"IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ",
"LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG",
"TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL"
]
# === CONTINENT-BASED BLOCKING ===
blockedContinentCodes = ["AF", "SA", "AS", "AN"]
# === ASN NUMBER GROUPS ===
[blockedASNs]
# empty by default
# === ASN NAME GROUPS ===
[blockedASNNames]
"Data Center" = [
"Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS",
"Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247",
"Datacamp", "Datapacket", "Amanah", "Hern Labs"
]
# === CUSTOM BLOCK PAGES ===
[countryBlockPages]
IN = "india-block.html"
[continentBlockPages]
# none by default
# Custom pages by ASN group
[asnGroupBlockPages]
"Data Center" = "datacenter-block.html"

View file

@ -0,0 +1,498 @@
// IPFilter middleware blocks requests from unwanted IPs based on GeoIP, ASN, and other rules.
// Package middleware provides IP and User-Agent filtering to protect against abuse.
// Features:
// - GeoIP country/continent blocking
// - ASN blocking
// - Data center detection
// - Bot detection
// - Cache-optimized lookups
package middleware
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/cloudflare/ahocorasick"
"github.com/gofiber/fiber/v2"
geoip2 "github.com/oschwald/geoip2-golang"
)
// --- IP Block Cache --- //
// blockCacheEntry stores the result of a block check for an IP
type blockCacheEntry struct {
blocked bool
blockType string
blockValue string
customPage string
asnOrgName string
expiresAt time.Time
}
// Cache for IP block lookups (TTL set via config)
var (
ipBlockCache map[string]blockCacheEntry
ipBlockCacheMutex sync.RWMutex
ipBlockCacheTTL time.Duration
)
// --- GeoIP Configuration ---
var (
geoIPCountryDBPath = "./data/GeoLite2-Country.mmdb"
geoIPASNDBPath = "./data/GeoLite2-ASN.mmdb"
geoipCountryReader *geoip2.Reader
geoipASNReader *geoip2.Reader
// Lists and pages (populated from ipfilter.toml)
blockedCountryCodes map[string]bool
blockedContinentCodes map[string]bool
blockedASNs map[string][]uint
blockedASNNames map[string][]string
countryBlockPages map[string]string
continentBlockPages map[string]string
asnGroupBlockPages map[string]string
defaultBlockPage string
// Aho-Corasick matchers for ASN name groups
asnNameMatchers map[string]*ahocorasick.Matcher
asnNameMatchersMutex sync.RWMutex
)
// init loads ipfilter.toml, sets up all block lists, and hooks this into our plugin registry
func init() {
// Load full configuration from ipfilter.toml
type ipfilterConfig struct {
BlockedCountryCodes []string `toml:"blockedCountryCodes"`
BlockedContinentCodes []string `toml:"blockedContinentCodes"`
BlockedASNs map[string][]int `toml:"blockedASNs"`
BlockedASNNames map[string][]string `toml:"blockedASNNames"`
CountryBlockPages map[string]string `toml:"countryBlockPages"`
ContinentBlockPages map[string]string `toml:"continentBlockPages"`
ASNGroupBlockPages map[string]string `toml:"asnGroupBlockPages"`
DefaultBlockPage string `toml:"defaultBlockPage"`
IPBlockCacheTTLSec int `toml:"ipBlockCacheTTLSec"`
}
var cfg ipfilterConfig
if err := LoadConfig("ipfilter", &cfg); err != nil {
log.Fatalf("Failed to load ipfilter config: %v", err)
}
// override blockedCountryCodes
blockedCountryCodes = make(map[string]bool)
for _, c := range cfg.BlockedCountryCodes {
blockedCountryCodes[c] = true
}
// override blockedContinentCodes
blockedContinentCodes = make(map[string]bool)
for _, c := range cfg.BlockedContinentCodes {
blockedContinentCodes[c] = true
}
// override blockedASNs
newASNs := make(map[string][]uint)
for group, arr := range cfg.BlockedASNs {
uintArr := make([]uint, len(arr))
for i, v := range arr {
uintArr[i] = uint(v)
}
newASNs[group] = uintArr
}
blockedASNs = newASNs
// override blockedASNNames
blockedASNNames = cfg.BlockedASNNames
// override page maps
countryBlockPages = cfg.CountryBlockPages
continentBlockPages = cfg.ContinentBlockPages
asnGroupBlockPages = cfg.ASNGroupBlockPages
// override default page
defaultBlockPage = cfg.DefaultBlockPage
// override cache TTL
ipBlockCacheTTL = time.Duration(cfg.IPBlockCacheTTLSec) * time.Second
// Initialize the cache
ipBlockCache = make(map[string]blockCacheEntry)
// Initialize GeoIP databases
initGeoIP()
// Build Aho-Corasick matchers for ASN name matching
buildASNNameMatchers()
// Register IP-block plugin
RegisterPlugin("ipfilter", IPBlockMiddleware)
}
// IPBlockMiddleware returns a handler that stops requests coming from blocked IPs
// Use this early so bad traffic gets rejected fast
func IPBlockMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get the client IP
clientIP := getRealIP(c)
// Check IP blocklist (GeoIP/ASN)
if blocked, blockType, blockValue, customPage, asnOrgName := isBlockedIPExtended(clientIP); blocked {
// For API requests, return JSON response
if strings.HasPrefix(c.Path(), "/api") {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Access denied from your location or network.",
"reason": "geoip",
"type": blockType,
"value": blockValue,
"asn_org": asnOrgName,
})
}
// For HTML requests, serve a block page
// Log block information
log.Printf("Blocking access: type=%s, value=%s, custom_page=%s, asn_org=%s",
blockType, blockValue, customPage, asnOrgName)
// If no custom page specified, use the default
if customPage == "" {
customPage = defaultBlockPage
}
// Try to serve the block page
possiblePaths := []string{
"./public/static/" + customPage,
"./public/html/" + customPage,
"./public/" + customPage,
"./develop/static/" + customPage,
}
var htmlContentBytes []byte
var err error
foundPath := ""
for _, path := range possiblePaths {
if htmlContentBytes, err = os.ReadFile(path); err == nil {
// Found the file, serve it
foundPath = path
break
}
}
if foundPath != "" {
// Found the file, serve it
c.Status(fiber.StatusForbidden)
c.Set("Content-Type", "text/html; charset=utf-8")
// Replace placeholder with ASN Org Name if available
htmlContent := string(htmlContentBytes)
replaceValue := "Blocked Network" // Default fallback text
if asnOrgName != "" {
replaceValue = asnOrgName
}
htmlContent = strings.Replace(htmlContent, "{{.ASNName}}", replaceValue, -1)
return c.SendString(htmlContent)
}
// If no custom or default page was found, fall back to default message
return c.Status(fiber.StatusForbidden).SendString("Access denied from your location or network.")
}
// Not blocked, continue to next middleware
return c.Next()
}
}
// initGeoIP loads the GeoLite2 databases
func initGeoIP() {
var err error
geoipCountryReader, err = geoip2.Open(geoIPCountryDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 Country database at %s: %v. GeoIP country/continent blocking disabled.", geoIPCountryDBPath, err)
}
geoipASNReader, err = geoip2.Open(geoIPASNDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 ASN database at %s: %v. GeoIP ASN blocking disabled.", geoIPASNDBPath, err)
}
}
// isBlockedIP checks if the IP address is blocked based on GeoIP or ASN.
func isBlockedIP(ipStr string) bool {
blocked, _, _, _, _ := isBlockedIPExtended(ipStr)
return blocked
}
// isBlockedIPExtended checks if the IP address is blocked and returns detailed information.
// Returns:
// - blocked: true if IP is blocked
// - blockType: "country", "continent", "asn_number_group", or "asn_name_group"
// - blockValue: the country code, continent code, or ASN group name (that triggered the block)
// - customPage: path to custom block page if defined, or empty string
// - asnOrgName: the specific ASN Organization name if blockType is "asn_name_group"
func isBlockedIPExtended(ipStr string) (blocked bool, blockType string, blockValue string, customPage string, asnOrgName string) {
// Safeguard against empty IPs causing issues
if ipStr == "" {
log.Printf("ERROR: Empty IP passed to isBlockedIPExtended")
return false, "", "", "", ""
}
ip := net.ParseIP(ipStr)
if ip == nil {
log.Printf("WARNING: Could not parse IP address for GeoIP check: %s", ipStr)
return false, "", "", "", "" // Cannot check invalid IP
}
// --- Check Cache First --- //
ipBlockCacheMutex.RLock()
entry, found := ipBlockCache[ipStr]
ipBlockCacheMutex.RUnlock() // Unlock immediately after reading
if found && time.Now().Before(entry.expiresAt) {
// Return cached result
return entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName
}
// --- End Cache Check --- //
var countryRecord *geoip2.Country
var asnRecord *geoip2.ASN
var countryErr, asnErr error
var wg sync.WaitGroup
// Concurrently perform GeoIP lookups if readers are available
if geoipCountryReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
countryRecord, countryErr = geoipCountryReader.Country(ip)
}()
}
if geoipASNReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
asnRecord, asnErr = geoipASNReader.ASN(ip)
}()
}
// Wait for lookups to complete
wg.Wait()
// --- Process Results --- //
// 1. Check Country/Continent
if countryRecord != nil && countryErr == nil {
// Check country first
if blockedCountryCodes[countryRecord.Country.IsoCode] {
blockType = "country"
blockValue = countryRecord.Country.IsoCode
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = countryBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
// Then check continent
if blockedContinentCodes[countryRecord.Continent.Code] {
blockType = "continent"
blockValue = countryRecord.Continent.Code
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = continentBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
} else if countryErr != nil && !strings.Contains(countryErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP country lookup error for IP %s: %v", ipStr, countryErr)
}
// 2. Check ASN (Number and Name)
if asnRecord != nil && asnErr == nil {
clientASN := asnRecord.AutonomousSystemNumber
asnOrg := asnRecord.AutonomousSystemOrganization // Keep org name for potential use
// Check ASN Number Groups
for groupName, asnList := range blockedASNs {
for _, blockedASN := range asnList {
if clientASN == blockedASN {
blockType = "asn_number_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d - %s)", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
}
}
// Check ASN Name Groups (Case-Insensitive Substring Match)
if asnOrg != "" {
lowerASNOrg := strings.ToLower(asnOrg)
// Acquire read lock for accessing the global matchers map
asnNameMatchersMutex.RLock()
defer asnNameMatchersMutex.RUnlock()
for groupName, matcher := range asnNameMatchers {
if matcher == nil { // Check added during previous fix, keep it
log.Printf("WARNING: Nil matcher found for group: %s during check", groupName)
continue
}
// Use Aho-Corasick matcher - protect against any potential panic
var matches []int
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic in Aho-Corasick: %v", r)
matches = nil // Ensure it's empty if we panic
}
}()
// Match against the globally shared matcher (assumed read-safe)
matches = matcher.Match([]byte(lowerASNOrg))
}()
if len(matches) > 0 {
blockType = "asn_name_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d, Org: '%s')", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
// No need to unlock here, defer handles it
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, asnOrg)
}
}
// RUnlock happens via defer
}
} else if asnErr != nil && !strings.Contains(asnErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP ASN lookup error for IP %s: %v", ipStr, asnErr)
}
// --- Cache the result before returning --- //
computedEntry := blockCacheEntry{
blocked: false,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
ipBlockCacheMutex.Lock()
ipBlockCache[ipStr] = computedEntry
ipBlockCacheMutex.Unlock()
return false, "", "", "", "" // Not blocked
}
// Helper function to cache block results
func cacheAndReturnBlockResult(ipStr string, blockType string, blockValue string, customPage string, asnOrgName string) (bool, string, string, string, string) {
// Create the cache entry
computedEntry := blockCacheEntry{
blocked: true,
blockType: blockType,
blockValue: blockValue,
customPage: customPage,
asnOrgName: asnOrgName,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
// Use a separate defer+recover to ensure we don't crash the entire server
// if there's any issue with the cache
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic while caching result: %v", r)
}
}()
ipBlockCacheMutex.Lock()
defer ipBlockCacheMutex.Unlock() // Use defer to ensure unlock happens
ipBlockCache[ipStr] = computedEntry
}()
return true, blockType, blockValue, customPage, asnOrgName
}
// buildASNNameMatchers creates Aho-Corasick matchers for faster ASN name checking
func buildASNNameMatchers() {
// Acquire write lock before modifying the global map
asnNameMatchersMutex.Lock()
defer asnNameMatchersMutex.Unlock()
// Clear any existing matchers first
asnNameMatchers = make(map[string]*ahocorasick.Matcher)
for groupName, nameList := range blockedASNNames {
// Skip if the name list is empty
if len(nameList) == 0 {
log.Printf("Skipping matcher build for empty group: %s", groupName)
continue
}
// Convert names to lowercase byte slices for case-insensitive matching
dict := make([][]byte, 0, len(nameList))
for _, name := range nameList {
if name != "" {
dict = append(dict, []byte(strings.ToLower(name)))
}
}
// Only create a matcher if we have patterns
if len(dict) > 0 {
// Use a recovery mechanism in case the matcher creation fails
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC while building Aho-Corasick matcher for group %s: %v", groupName, r)
// Ensure the entry for this group is nil if creation failed
asnNameMatchers[groupName] = nil
}
}()
// This assignment happens under the write lock
asnNameMatchers[groupName] = ahocorasick.NewMatcher(dict)
log.Printf("Built Aho-Corasick matcher for ASN name group: %s (%d patterns)", groupName, len(dict))
}()
} else {
log.Printf("No valid patterns found for ASN name group: %s", groupName)
}
}
// Unlock happens via defer
}
// ReloadGeoIPDatabases closes and reopens the GeoIP database readers
// to load updated database files. Safe to call while the server is running.
func ReloadGeoIPDatabases() {
// Close existing readers if they're open
if geoipCountryReader != nil {
geoipCountryReader.Close()
geoipCountryReader = nil
}
if geoipASNReader != nil {
geoipASNReader.Close()
geoipASNReader = nil
}
// Re-initialize the readers
initGeoIP()
log.Printf("GeoIP databases reloaded")
}
// getRealIP gets the real client IP when behind a reverse proxy
// It checks X-Forwarded-For header first, then falls back to c.IP()
func getRealIP(c *fiber.Ctx) string {
// Check X-Forwarded-For header first
if xff := c.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// The first one is the original client IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
// Get the first IP and trim whitespace
clientIP := strings.TrimSpace(ips[0])
// Validate it's a real IP
if net.ParseIP(clientIP) != nil {
log.Printf("Using X-Forwarded-For IP: %s (original: %s)", clientIP, c.IP())
return clientIP
}
}
}
// Also check for custom Remote-Addr header that might be set by some proxies
if remoteAddr := c.Get("$remote_addr"); remoteAddr != "" {
// Validate it's a real IP
if net.ParseIP(remoteAddr) != nil {
log.Printf("Using $remote_addr IP: %s (original: %s)", remoteAddr, c.IP())
return remoteAddr
}
}
// Fallback to default IP
return c.IP()
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
# -----------------------------------------------------------------------------
# Checkpoint Middleware Configuration (checkpoint.toml)
#
# All durations are parsed via time.ParseDuration (e.g. "24h").
# Arrays and tables map directly to the Config struct fields.
# -----------------------------------------------------------------------------
# === GENERAL SETTINGS ===
# Number of leading zeros required in PoW hash
Difficulty = 4
# Validity period for issued tokens
TokenExpiration = "24h"
# Name of the cookie used to store the checkpoint token
CookieName = "checkpoint_token"
# Domain attribute for the cookie; empty = host-only (localhost)
CookieDomain = ""
# Length of the random salt in bytes for challenges
SaltLength = 16
# === RATE LIMITING & EXPIRATION ===
# Max PoW verification attempts per IP per hour
MaxAttemptsPerHour = 10
# Max age for used nonces before cleanup
MaxNonceAge = "24h"
# Time allowed for solving a challenge
ChallengeExpiration = "5m"
# === PERSISTENCE PATHS ===
# File where HMAC secret is stored
SecretConfigPath = "./data/checkpoint_secret.json"
# Directory for BadgerDB token store
TokenStoreDBPath = "./data/checkpoint_tokendb"
# Ordered fallback paths for interstitial HTML
InterstitialPaths = [
"./public/static/pow-interstitial.html",
"./develop/static/pow-interstitial.html"
]
# === SECURITY SETTINGS ===
# Enable Proof-of-Space-Time consistency checks
CheckPoSTimes = true
# Allowed ratio between slowest and fastest PoS runs
PoSTimeConsistencyRatio = 1.35
# === HTML CHECKPOINT EXCLUSIONS ===
# Path prefixes to skip PoW interstitial
HTMLCheckpointExclusions = ["/api"]
# File extensions to skip PoW check
HTMLCheckpointExcludedExtensions = { ".jpg" = true, ".jpeg" = true, ".png" = true, ".gif" = true, ".svg" = true, ".webp" = true, ".ico" = true, ".bmp" = true, ".tif" = true, ".tiff" = true, ".mp4" = true, ".webm" = true, ".css" = true, ".js" = true, ".mjs" = true, ".woff" = true, ".woff2" = true, ".ttf" = true, ".otf" = true, ".eot" = true, ".json" = true, ".xml" = true, ".txt" = true, ".pdf" = true, ".map" = true, ".wasm" = true }
# === QUERY SANITIZATION ===
# Regex patterns (case-insensitive) to block in query strings
DangerousQueryPatterns = [
"(?i)union\\s+select",
"(?i)drop\\s+table",
"(?i)insert\\s+into",
"(?i)<script",
"(?i)javascript:",
"(?i)onerror=",
]
# Block queries containing ';', '`', or '\\'
BlockDangerousPathChars = true
# === USER-AGENT VALIDATION ===
# Path prefixes to skip UA validation
UserAgentValidationExclusions = ["/api"]
# Required UA prefix per path prefix
[UserAgentRequiredPrefixes]
"/demo1" = "Dart/"
# === REVERSE PROXY MAPPINGS ===
# Hostname-to-backend URL map
[ReverseProxyMappings]
"jellyfin.caileb.com" = "http://192.168.0.2:8096"
"archive.caileb.com" = "http://192.168.0.2:7461"
"music.caileb.com" = "http://192.168.0.2:4533"
"gallery.caileb.com" = "http://192.168.0.2:2283"

View file

@ -0,0 +1,39 @@
# IPFilter Configuration
# Page shown when a request is blocked
defaultBlockPage = "default-block.html"
# Cache block decisions (seconds)
ipBlockCacheTTLSec = 300
# Country codes to block
blockedCountryCodes = [
"IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ",
"LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG",
"TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL"
]
# === CONTINENT-BASED BLOCKING ===
blockedContinentCodes = ["AF", "SA", "AS", "AN"]
# === ASN NUMBER GROUPS ===
[blockedASNs]
# empty by default
# === ASN NAME GROUPS ===
[blockedASNNames]
"Data Center" = [
"Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS",
"Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247",
"Datacamp", "Datapacket", "Amanah", "Hern Labs"
]
# === CUSTOM BLOCK PAGES ===
[countryBlockPages]
IN = "india-block.html"
[continentBlockPages]
# none by default
# Custom pages by ASN group
[asnGroupBlockPages]
"Data Center" = "datacenter-block.html"

View file

@ -0,0 +1,498 @@
// IPFilter middleware blocks requests from unwanted IPs based on GeoIP, ASN, and other rules.
// Package middleware provides IP and User-Agent filtering to protect against abuse.
// Features:
// - GeoIP country/continent blocking
// - ASN blocking
// - Data center detection
// - Bot detection
// - Cache-optimized lookups
package middleware
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/cloudflare/ahocorasick"
"github.com/gofiber/fiber/v2"
geoip2 "github.com/oschwald/geoip2-golang"
)
// --- IP Block Cache --- //
// blockCacheEntry stores the result of a block check for an IP
type blockCacheEntry struct {
blocked bool
blockType string
blockValue string
customPage string
asnOrgName string
expiresAt time.Time
}
// Cache for IP block lookups (TTL set via config)
var (
ipBlockCache map[string]blockCacheEntry
ipBlockCacheMutex sync.RWMutex
ipBlockCacheTTL time.Duration
)
// --- GeoIP Configuration ---
var (
geoIPCountryDBPath = "./data/GeoLite2-Country.mmdb"
geoIPASNDBPath = "./data/GeoLite2-ASN.mmdb"
geoipCountryReader *geoip2.Reader
geoipASNReader *geoip2.Reader
// Lists and pages (populated from ipfilter.toml)
blockedCountryCodes map[string]bool
blockedContinentCodes map[string]bool
blockedASNs map[string][]uint
blockedASNNames map[string][]string
countryBlockPages map[string]string
continentBlockPages map[string]string
asnGroupBlockPages map[string]string
defaultBlockPage string
// Aho-Corasick matchers for ASN name groups
asnNameMatchers map[string]*ahocorasick.Matcher
asnNameMatchersMutex sync.RWMutex
)
// init loads ipfilter.toml, sets up all block lists, and hooks this into our plugin registry
func init() {
// Load full configuration from ipfilter.toml
type ipfilterConfig struct {
BlockedCountryCodes []string `toml:"blockedCountryCodes"`
BlockedContinentCodes []string `toml:"blockedContinentCodes"`
BlockedASNs map[string][]int `toml:"blockedASNs"`
BlockedASNNames map[string][]string `toml:"blockedASNNames"`
CountryBlockPages map[string]string `toml:"countryBlockPages"`
ContinentBlockPages map[string]string `toml:"continentBlockPages"`
ASNGroupBlockPages map[string]string `toml:"asnGroupBlockPages"`
DefaultBlockPage string `toml:"defaultBlockPage"`
IPBlockCacheTTLSec int `toml:"ipBlockCacheTTLSec"`
}
var cfg ipfilterConfig
if err := LoadConfig("ipfilter", &cfg); err != nil {
log.Fatalf("Failed to load ipfilter config: %v", err)
}
// override blockedCountryCodes
blockedCountryCodes = make(map[string]bool)
for _, c := range cfg.BlockedCountryCodes {
blockedCountryCodes[c] = true
}
// override blockedContinentCodes
blockedContinentCodes = make(map[string]bool)
for _, c := range cfg.BlockedContinentCodes {
blockedContinentCodes[c] = true
}
// override blockedASNs
newASNs := make(map[string][]uint)
for group, arr := range cfg.BlockedASNs {
uintArr := make([]uint, len(arr))
for i, v := range arr {
uintArr[i] = uint(v)
}
newASNs[group] = uintArr
}
blockedASNs = newASNs
// override blockedASNNames
blockedASNNames = cfg.BlockedASNNames
// override page maps
countryBlockPages = cfg.CountryBlockPages
continentBlockPages = cfg.ContinentBlockPages
asnGroupBlockPages = cfg.ASNGroupBlockPages
// override default page
defaultBlockPage = cfg.DefaultBlockPage
// override cache TTL
ipBlockCacheTTL = time.Duration(cfg.IPBlockCacheTTLSec) * time.Second
// Initialize the cache
ipBlockCache = make(map[string]blockCacheEntry)
// Initialize GeoIP databases
initGeoIP()
// Build Aho-Corasick matchers for ASN name matching
buildASNNameMatchers()
// Register IP-block plugin
RegisterPlugin("ipfilter", IPBlockMiddleware)
}
// IPBlockMiddleware returns a handler that stops requests coming from blocked IPs
// Use this early so bad traffic gets rejected fast
func IPBlockMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get the client IP
clientIP := getRealIP(c)
// Check IP blocklist (GeoIP/ASN)
if blocked, blockType, blockValue, customPage, asnOrgName := isBlockedIPExtended(clientIP); blocked {
// For API requests, return JSON response
if strings.HasPrefix(c.Path(), "/api") {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Access denied from your location or network.",
"reason": "geoip",
"type": blockType,
"value": blockValue,
"asn_org": asnOrgName,
})
}
// For HTML requests, serve a block page
// Log block information
log.Printf("Blocking access: type=%s, value=%s, custom_page=%s, asn_org=%s",
blockType, blockValue, customPage, asnOrgName)
// If no custom page specified, use the default
if customPage == "" {
customPage = defaultBlockPage
}
// Try to serve the block page
possiblePaths := []string{
"./public/static/" + customPage,
"./public/html/" + customPage,
"./public/" + customPage,
"./develop/static/" + customPage,
}
var htmlContentBytes []byte
var err error
foundPath := ""
for _, path := range possiblePaths {
if htmlContentBytes, err = os.ReadFile(path); err == nil {
// Found the file, serve it
foundPath = path
break
}
}
if foundPath != "" {
// Found the file, serve it
c.Status(fiber.StatusForbidden)
c.Set("Content-Type", "text/html; charset=utf-8")
// Replace placeholder with ASN Org Name if available
htmlContent := string(htmlContentBytes)
replaceValue := "Blocked Network" // Default fallback text
if asnOrgName != "" {
replaceValue = asnOrgName
}
htmlContent = strings.Replace(htmlContent, "{{.ASNName}}", replaceValue, -1)
return c.SendString(htmlContent)
}
// If no custom or default page was found, fall back to default message
return c.Status(fiber.StatusForbidden).SendString("Access denied from your location or network.")
}
// Not blocked, continue to next middleware
return c.Next()
}
}
// initGeoIP loads the GeoLite2 databases
func initGeoIP() {
var err error
geoipCountryReader, err = geoip2.Open(geoIPCountryDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 Country database at %s: %v. GeoIP country/continent blocking disabled.", geoIPCountryDBPath, err)
}
geoipASNReader, err = geoip2.Open(geoIPASNDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 ASN database at %s: %v. GeoIP ASN blocking disabled.", geoIPASNDBPath, err)
}
}
// isBlockedIP checks if the IP address is blocked based on GeoIP or ASN.
func isBlockedIP(ipStr string) bool {
blocked, _, _, _, _ := isBlockedIPExtended(ipStr)
return blocked
}
// isBlockedIPExtended checks if the IP address is blocked and returns detailed information.
// Returns:
// - blocked: true if IP is blocked
// - blockType: "country", "continent", "asn_number_group", or "asn_name_group"
// - blockValue: the country code, continent code, or ASN group name (that triggered the block)
// - customPage: path to custom block page if defined, or empty string
// - asnOrgName: the specific ASN Organization name if blockType is "asn_name_group"
func isBlockedIPExtended(ipStr string) (blocked bool, blockType string, blockValue string, customPage string, asnOrgName string) {
// Safeguard against empty IPs causing issues
if ipStr == "" {
log.Printf("ERROR: Empty IP passed to isBlockedIPExtended")
return false, "", "", "", ""
}
ip := net.ParseIP(ipStr)
if ip == nil {
log.Printf("WARNING: Could not parse IP address for GeoIP check: %s", ipStr)
return false, "", "", "", "" // Cannot check invalid IP
}
// --- Check Cache First --- //
ipBlockCacheMutex.RLock()
entry, found := ipBlockCache[ipStr]
ipBlockCacheMutex.RUnlock() // Unlock immediately after reading
if found && time.Now().Before(entry.expiresAt) {
// Return cached result
return entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName
}
// --- End Cache Check --- //
var countryRecord *geoip2.Country
var asnRecord *geoip2.ASN
var countryErr, asnErr error
var wg sync.WaitGroup
// Concurrently perform GeoIP lookups if readers are available
if geoipCountryReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
countryRecord, countryErr = geoipCountryReader.Country(ip)
}()
}
if geoipASNReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
asnRecord, asnErr = geoipASNReader.ASN(ip)
}()
}
// Wait for lookups to complete
wg.Wait()
// --- Process Results --- //
// 1. Check Country/Continent
if countryRecord != nil && countryErr == nil {
// Check country first
if blockedCountryCodes[countryRecord.Country.IsoCode] {
blockType = "country"
blockValue = countryRecord.Country.IsoCode
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = countryBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
// Then check continent
if blockedContinentCodes[countryRecord.Continent.Code] {
blockType = "continent"
blockValue = countryRecord.Continent.Code
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = continentBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
} else if countryErr != nil && !strings.Contains(countryErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP country lookup error for IP %s: %v", ipStr, countryErr)
}
// 2. Check ASN (Number and Name)
if asnRecord != nil && asnErr == nil {
clientASN := asnRecord.AutonomousSystemNumber
asnOrg := asnRecord.AutonomousSystemOrganization // Keep org name for potential use
// Check ASN Number Groups
for groupName, asnList := range blockedASNs {
for _, blockedASN := range asnList {
if clientASN == blockedASN {
blockType = "asn_number_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d - %s)", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
}
}
// Check ASN Name Groups (Case-Insensitive Substring Match)
if asnOrg != "" {
lowerASNOrg := strings.ToLower(asnOrg)
// Acquire read lock for accessing the global matchers map
asnNameMatchersMutex.RLock()
defer asnNameMatchersMutex.RUnlock()
for groupName, matcher := range asnNameMatchers {
if matcher == nil { // Check added during previous fix, keep it
log.Printf("WARNING: Nil matcher found for group: %s during check", groupName)
continue
}
// Use Aho-Corasick matcher - protect against any potential panic
var matches []int
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic in Aho-Corasick: %v", r)
matches = nil // Ensure it's empty if we panic
}
}()
// Match against the globally shared matcher (assumed read-safe)
matches = matcher.Match([]byte(lowerASNOrg))
}()
if len(matches) > 0 {
blockType = "asn_name_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d, Org: '%s')", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
// No need to unlock here, defer handles it
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, asnOrg)
}
}
// RUnlock happens via defer
}
} else if asnErr != nil && !strings.Contains(asnErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP ASN lookup error for IP %s: %v", ipStr, asnErr)
}
// --- Cache the result before returning --- //
computedEntry := blockCacheEntry{
blocked: false,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
ipBlockCacheMutex.Lock()
ipBlockCache[ipStr] = computedEntry
ipBlockCacheMutex.Unlock()
return false, "", "", "", "" // Not blocked
}
// Helper function to cache block results
func cacheAndReturnBlockResult(ipStr string, blockType string, blockValue string, customPage string, asnOrgName string) (bool, string, string, string, string) {
// Create the cache entry
computedEntry := blockCacheEntry{
blocked: true,
blockType: blockType,
blockValue: blockValue,
customPage: customPage,
asnOrgName: asnOrgName,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
// Use a separate defer+recover to ensure we don't crash the entire server
// if there's any issue with the cache
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic while caching result: %v", r)
}
}()
ipBlockCacheMutex.Lock()
defer ipBlockCacheMutex.Unlock() // Use defer to ensure unlock happens
ipBlockCache[ipStr] = computedEntry
}()
return true, blockType, blockValue, customPage, asnOrgName
}
// buildASNNameMatchers creates Aho-Corasick matchers for faster ASN name checking
func buildASNNameMatchers() {
// Acquire write lock before modifying the global map
asnNameMatchersMutex.Lock()
defer asnNameMatchersMutex.Unlock()
// Clear any existing matchers first
asnNameMatchers = make(map[string]*ahocorasick.Matcher)
for groupName, nameList := range blockedASNNames {
// Skip if the name list is empty
if len(nameList) == 0 {
log.Printf("Skipping matcher build for empty group: %s", groupName)
continue
}
// Convert names to lowercase byte slices for case-insensitive matching
dict := make([][]byte, 0, len(nameList))
for _, name := range nameList {
if name != "" {
dict = append(dict, []byte(strings.ToLower(name)))
}
}
// Only create a matcher if we have patterns
if len(dict) > 0 {
// Use a recovery mechanism in case the matcher creation fails
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC while building Aho-Corasick matcher for group %s: %v", groupName, r)
// Ensure the entry for this group is nil if creation failed
asnNameMatchers[groupName] = nil
}
}()
// This assignment happens under the write lock
asnNameMatchers[groupName] = ahocorasick.NewMatcher(dict)
log.Printf("Built Aho-Corasick matcher for ASN name group: %s (%d patterns)", groupName, len(dict))
}()
} else {
log.Printf("No valid patterns found for ASN name group: %s", groupName)
}
}
// Unlock happens via defer
}
// ReloadGeoIPDatabases closes and reopens the GeoIP database readers
// to load updated database files. Safe to call while the server is running.
func ReloadGeoIPDatabases() {
// Close existing readers if they're open
if geoipCountryReader != nil {
geoipCountryReader.Close()
geoipCountryReader = nil
}
if geoipASNReader != nil {
geoipASNReader.Close()
geoipASNReader = nil
}
// Re-initialize the readers
initGeoIP()
log.Printf("GeoIP databases reloaded")
}
// getRealIP gets the real client IP when behind a reverse proxy
// It checks X-Forwarded-For header first, then falls back to c.IP()
func getRealIP(c *fiber.Ctx) string {
// Check X-Forwarded-For header first
if xff := c.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// The first one is the original client IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
// Get the first IP and trim whitespace
clientIP := strings.TrimSpace(ips[0])
// Validate it's a real IP
if net.ParseIP(clientIP) != nil {
log.Printf("Using X-Forwarded-For IP: %s (original: %s)", clientIP, c.IP())
return clientIP
}
}
}
// Also check for custom Remote-Addr header that might be set by some proxies
if remoteAddr := c.Get("$remote_addr"); remoteAddr != "" {
// Validate it's a real IP
if net.ParseIP(remoteAddr) != nil {
log.Printf("Using $remote_addr IP: %s (original: %s)", remoteAddr, c.IP())
return remoteAddr
}
}
// Fallback to default IP
return c.IP()
}

View file

@ -0,0 +1,47 @@
// Package middleware contains a simple plugin system for Fiber middleware.
// Register plugins by name and factory, then main.go will load them automatically.
package middleware
import (
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/gofiber/fiber/v2"
)
// Plugin holds a plugin's name and a function that makes its handler.
type Plugin struct {
Name string
Factory func() fiber.Handler
}
// registry stores every plugin we've registered.
var registry []Plugin
// RegisterPlugin tags a plugin with a name and a factory so we can use it in the app.
func RegisterPlugin(name string, factory func() fiber.Handler) {
registry = append(registry, Plugin{Name: name, Factory: factory})
}
// LoadPlugins returns the handler functions for each plugin.
// If skipCheckpoint is true, it skips the plugin named "checkpoint".
func LoadPlugins(skipCheckpoint bool) []fiber.Handler {
var handlers []fiber.Handler
for _, p := range registry {
if skipCheckpoint && p.Name == "checkpoint" {
continue
}
handlers = append(handlers, p.Factory())
}
return handlers
}
// LoadConfig loads the TOML file at middleware/config/[name].toml
// and decodes it into the struct you provide.
func LoadConfig(name string, v interface{}) error {
path := filepath.Join("middleware", "config", name+".toml")
if _, err := toml.DecodeFile(path, v); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,47 @@
// Package middleware contains a simple plugin system for Fiber middleware.
// Register plugins by name and factory, then main.go will load them automatically.
package middleware
import (
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/gofiber/fiber/v2"
)
// Plugin holds a plugin's name and a function that makes its handler.
type Plugin struct {
Name string
Factory func() fiber.Handler
}
// registry stores every plugin we've registered.
var registry []Plugin
// RegisterPlugin tags a plugin with a name and a factory so we can use it in the app.
func RegisterPlugin(name string, factory func() fiber.Handler) {
registry = append(registry, Plugin{Name: name, Factory: factory})
}
// LoadPlugins returns the handler functions for each plugin.
// If skipCheckpoint is true, it skips the plugin named "checkpoint".
func LoadPlugins(skipCheckpoint bool) []fiber.Handler {
var handlers []fiber.Handler
for _, p := range registry {
if skipCheckpoint && p.Name == "checkpoint" {
continue
}
handlers = append(handlers, p.Factory())
}
return handlers
}
// LoadConfig loads the TOML file at middleware/config/[name].toml
// and decodes it into the struct you provide.
func LoadConfig(name string, v interface{}) error {
path := filepath.Join("middleware", "config", name+".toml")
if _, err := toml.DecodeFile(path, v); err != nil {
return err
}
return nil
}

BIN
data/GeoLite2-ASN.mmdb Normal file

Binary file not shown.

BIN
data/GeoLite2-Country.mmdb Normal file

Binary file not shown.

View file

@ -0,0 +1 @@
{"hmac_secret":"HJ9EY5oN0pO71AUN/2faoYnJhZyTr875iWbw6Nl8rc8=","created_at":"2025-04-28T18:28:59.6204724-05:00","updated_at":"2025-04-28T18:33:55.3259956-05:00"}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
~t(ðæ4¥ ø2³Dî•æHello Badger

Binary file not shown.

466
develop/css/docs.css Normal file
View file

@ -0,0 +1,466 @@
/*
* Documentation Pages Specific Styling
* Complements u.css without overriding its styles
*/
:root {
--background-color: #121212;
--card-gradient-start: #1e1e1e;
--card-gradient-end: #333;
--header-background: #262626;
--text-color: #fff;
--accent-color: #4285F4;
--subtext-color: #ccc;
--code-background: #2c2c2c;
--border-color: #444;
--note-background: rgba(33, 150, 243, 0.15);
--note-border: #2196F3;
--warning-background: rgba(255, 193, 7, 0.15);
--warning-border: #FFC107;
--security-background: rgba(29, 39, 30, 0.7);
--security-border: #4CAF50;
--overlay-background: rgba(0, 0, 0, 0.85);
}
/* Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--background-color);
color: var(--text-color);
line-height: 1.6;
padding: 0;
margin: 0;
}
/* Documentation Layout */
.container {
max-width: 1000px;
margin: 0 auto;
padding: 1rem 2rem;
padding-top: 0.5rem;
}
/* Documentation Typography */
h1, h2, h3, h4 {
color: var(--accent-color);
font-weight: 600;
text-align: center;
}
h1 {
margin-top: 0.5em;
border-bottom: 2px solid var(--border-color);
padding-bottom: 0.3em;
font-size: 2.2rem;
margin-bottom: 1.5rem;
}
h2 {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.3em;
font-size: 1.8rem;
margin-top: 2.5rem;
}
h3 {
font-size: 1.4rem;
margin-top: 0.7rem;
margin-bottom: 0.7rem;
}
p, ul, ol {
margin: 1em 0;
font-size: 1.05rem;
line-height: 1.7;
}
ol {
padding-left: 2.5rem;
}
ul {
padding-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
ol li {
padding-left: 0.5rem;
}
ol li ul {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
a {
color: var(--accent-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Code Formatting */
code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
background-color: var(--code-background);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
text-wrap: nowrap;
}
pre {
background-color: var(--code-background);
border-radius: 5px;
padding: 1.2rem;
overflow: auto;
margin: 1.5em 0;
border: 1px solid var(--border-color);
}
pre code {
background-color: transparent;
padding: 0;
font-size: 0.95rem;
line-height: 1.5;
}
/* Diagram/Example Sections */
.diagram, .example {
text-align: center;
margin: 35px 0;
padding: 25px;
background-color: rgba(30, 30, 30, 0.5);
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.diagram img, .example img {
max-width: 100%;
height: auto;
cursor: pointer;
transition: opacity 0.2s ease;
}
.diagram img:hover, .example img:hover {
opacity: 0.85;
}
.example h3 {
text-align: left;
margin-top: 0;
margin-bottom: 15px;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
margin: 25px 0;
background-color: rgba(30, 30, 30, 0.5);
border-radius: 5px;
overflow: hidden;
}
.table-container {
width: 100%;
overflow-x: auto;
margin: 25px 0;
border-radius: 5px;
border: 1px solid var(--border-color);
background-color: rgba(30, 30, 30, 0.5);
}
.table-container table {
margin: 0;
border: none;
}
th, td {
border: 1px solid var(--border-color);
padding: 12px 16px;
text-align: left;
}
th {
background-color: var(--header-background);
font-weight: 600;
}
tr:nth-child(even) {
background-color: rgba(40, 40, 40, 0.5);
}
/* Callout Boxes */
.note, .warning, .security {
padding: 18px 22px;
margin: 1.8rem 0;
border-radius: 5px;
border-left: 4px solid;
}
.note {
background-color: var(--note-background);
border-color: var(--note-border);
}
.warning {
background-color: var(--warning-background);
border-color: var(--warning-border);
}
.security {
background-color: var(--security-background);
border-color: var(--security-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.security h3 {
color: #6FCF7C;
margin-top: 0;
}
.security ul {
margin-bottom: 0;
}
/* Footer */
footer {
text-align: center;
padding: 20px 0;
border-top: 1px solid var(--border-color);
color: var(--subtext-color);
font-size: 0.9rem;
}
/* Table of Contents */
.toc {
background-color: rgba(30, 30, 30, 0.5);
border-radius: 8px;
padding: 20px;
margin: 20px 0 30px 0;
border: 1px solid var(--border-color);
}
.toc h2 {
margin-top: 0;
text-align: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
margin-bottom: 15px;
color: var(--accent-color);
}
.toc ul {
list-style-type: none;
padding-left: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.toc li {
margin-bottom: 8px;
flex: 0 0 auto;
}
.toc a {
display: block;
padding: 5px 15px;
border-radius: 4px;
transition: background-color 0.2s ease;
background-color: rgba(20, 20, 20, 0.5);
white-space: nowrap;
}
.toc a:hover {
background-color: rgba(50, 50, 50, 0.5);
text-decoration: none;
}
/* Code Examples */
.code-example {
position: relative;
}
.code-label {
position: absolute;
top: -12px;
right: 10px;
background-color: var(--accent-color);
color: white;
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 4px;
}
/* Button Group Styles (from lazy-video.html) */
.lv-btn-group {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
margin: 36px 0 10px 0;
}
.lv-btn {
display: inline-flex;
align-items: center;
padding: 10px 24px;
border-radius: 8px;
font-weight: 700;
font-size: 1.05rem;
text-decoration: none;
transition: background 0.18s, color 0.18s, border 0.18s;
cursor: pointer;
}
.lv-btn-primary {
border: none;
background: var(--accent-color, #4285F4);
color: #fff;
}
.lv-btn-primary:hover {
background: #2563eb; /* Slightly darker blue for hover */
}
.lv-btn-outline {
background: transparent;
color: #fff;
border: 1.5px solid var(--accent-color, #4285F4);
}
.lv-btn-outline:hover {
background: rgba(66,133,244,0.08); /* Subtle background on hover */
}
.lv-size-info {
font-size: 1rem;
color: #888;
background: rgba(0,0,0,0.05);
border-radius: 4px;
padding: 7px 18px;
}
.lv-size-info span {
color: var(--accent-color, #4285F4);
font-weight: 600;
}
/* Feature Cards */
.feature-card {
background-color: rgba(40, 40, 40, 0.5);
border-radius: 8px;
padding: 20px;
border: 1px solid var(--border-color);
margin-bottom: 15px;
}
.feature-card h3 {
color: var(--accent-color);
margin-top: 0;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
/* Section Styling */
.section {
scroll-margin-top: 20px;
}
/* Version Note in Footer */
.doc-version-note {
text-align: center;
color: #888;
font-size: 0.98rem;
margin-bottom: 10px;
}
/* Lazy Video Component Styling */
lazy-video {
display: block;
margin: 30px auto;
max-width: 600px;
}
.url-patterns {
list-style: none;
padding: 0;
margin: 0;
}
.url-patterns li {
padding: 3px 0;
}
.url-patterns code {
background: rgba(0,0,0,0.05);
padding: 2px 5px;
border-radius: 3px;
font-size: 0.9em;
font-family: monospace;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
h1 {
font-size: 1.8rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.2rem;
}
.toc ul {
flex-direction: column;
align-items: stretch;
}
.toc a {
text-align: center;
white-space: normal;
}
/* Mobile styles for button group */
.lv-btn-group {
flex-direction: column !important;
gap: 14px;
margin: 30px 0;
align-items: stretch;
width: 100%;
}
.lv-btn {
justify-content: center;
text-align: center;
width: 100%;
}
.lv-size-info {
text-align: center;
width: 100%;
}
}

144
develop/css/lightbox.css Normal file
View file

@ -0,0 +1,144 @@
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 0;
}
.lightbox.active {
display: flex;
}
.lightbox-content {
position: relative;
width: 90%;
max-width: 1200px;
height: 85vh;
background-color: #121212;
padding: 20px;
border-radius: 10px;
box-shadow: 0 5px 30px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
overflow: hidden;
}
.lightbox-img-container {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.lightbox-img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: grab;
border-radius: 5px;
transform-origin: center center;
user-select: none;
will-change: transform;
}
.lightbox-img.grabbing {
cursor: grabbing;
}
.lightbox-close {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
background-color: #9B59B6;
color: white;
border-radius: 50%;
text-align: center;
line-height: 32px;
cursor: pointer;
font-weight: bold;
font-size: 18px;
z-index: 1010;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.lightbox-caption {
margin-top: 15px;
text-align: center;
color: #ccc;
font-size: 0.9rem;
}
.zoom-controls {
display: flex;
align-items: center;
justify-content: center;
margin-top: 15px;
padding: 10px 0;
border-top: 1px solid #444;
}
.zoom-label {
margin-right: 10px;
font-size: 0.9rem;
color: #ccc;
}
.zoom-slider {
-webkit-appearance: none;
width: 70%;
height: 6px;
border-radius: 3px;
background: #444;
outline: none;
}
.zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #9B59B6;
cursor: pointer;
}
.zoom-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #9B59B6;
cursor: pointer;
border: none;
}
.zoom-value {
margin-left: 10px;
font-size: 0.9rem;
min-width: 40px;
color: #ccc;
}
@media (max-width: 768px), (max-width: 1024px) and (orientation: landscape) {
.lightbox-content {
width: 100%;
height: 100%;
padding: 15px;
border-radius: 0;
}
.zoom-controls {
display: flex;
}
}

120
develop/css/u.css Normal file
View file

@ -0,0 +1,120 @@
/* Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Common Font Declarations */
@font-face {
font-family: Poppins;
src: url(/webfonts/Poppins-Regular.woff2) format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Poppins;
src: url(/webfonts/Poppins-SemiBold.woff2) format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
body {
font-family: Poppins, sans-serif;
}
/* External Link Indicator */
a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"])::after,
a[target="_blank"]:not([href^="mailto:"]):not([href^="tel:"])::after {
content: "";
display: inline-block;
width: 1.1em;
height: 1.1em;
margin-left: .25em;
margin-bottom: .25em;
vertical-align: middle;
background-color: currentColor;
-webkit-mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMS43NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgNkg2YTIgMiAwIDAgMC0yIDJ2MTBhMiAyIDAgMCAwIDIgMmgxMGEyIDIgMCAwIDAgMi0ydi02bS03IDFsOS05bS01IDBoNXY1Ii8+PC9zdmc+") no-repeat center;
mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMS43NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgNkg2YTIgMiAwIDAgMC0yIDJ2MTBhMiAyIDAgMCAwIDIgMmgxMGEyIDIgMCAwIDAgMi0ydi02bS03IDFsOS05bS01IDBoNXY1Ii8+PC9zdmc+") no-repeat center;
-webkit-mask-size: contain;
mask-size: contain;
}
@media (hover:hover) and (pointer:fine) {
a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"]),
a[target="_blank"]:not([href^="mailto:"]):not([href^="tel:"]) {
position: relative;
}
a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"])::before,
a[target="_blank"]:not([href^="mailto:"]):not([href^="tel:"])::before {
content: "Opens in new tab";
position: absolute;
top: 50%;
left: 100%;
transform: translateY(-50%);
margin-left: 5px;
background-color: rgba(25, 25, 25, .9);
color: #fff;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity .18s ease-in-out;
z-index: 10;
}
a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"]):hover::before,
a[target="_blank"]:not([href^="mailto:"]):not([href^="tel:"]):hover::before {
transition-delay: 60ms;
opacity: 1;
}
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: #2d2d2d;
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: #4d9cfa;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #3971a3;
}
/* Base HTML behaviors */
html {
scroll-behavior: smooth;
}
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.44s; /* Increase duration to 0.44 seconds */
}
/* Disable view transitions for users with reduced motion preference enabled */
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}

224
develop/html/ai-san.html Normal file
View file

@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Text Cleaner - caileb.com</title>
<meta name="description" content="Clean and convert formatted text to plain text">
<link rel="icon" href="/images/favi.png" type="image/png">
<link rel="apple-touch-icon" href="/images/favi.png">
<link rel="shortcut icon" href="/images/favi.png">
<link rel="preload" href="/webfonts/Poppins-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/webfonts/Poppins-SemiBold.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/u.css">
<style>
:root {
--background-color: #121212;
--card-gradient-start: #1e1e1e;
--card-gradient-end: #333;
--header-background: #262626;
--text-color: #fff;
--accent-color: #9B59B6;
--subtext-color: #ccc;
}
body {
background: #1c1c1c;
color: var(--text-color);
font-family: Poppins, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 800px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--accent-color);
}
header p {
font-size: 1.25rem;
color: var(--subtext-color);
}
.cleaner-card {
background: linear-gradient(135deg, rgba(30, 30, 30, 0.8), rgba(51, 51, 51, 0.8));
border-radius: 12px;
padding: 2rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
label {
font-weight: 600;
display: block;
margin-bottom: 0.8rem;
color: var(--accent-color);
font-size: 1.1rem;
}
textarea {
width: 100%;
height: 200px;
margin-bottom: 1.5rem;
padding: 1rem;
font-size: 1rem;
font-family: Poppins, sans-serif;
border-radius: 8px;
border: 1px solid #444;
background: rgba(0, 0, 0, 0.2);
color: var(--text-color);
resize: vertical;
transition: border-color 0.2s ease;
}
textarea:focus {
outline: none;
border-color: var(--accent-color);
}
.button-row {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
button {
background: var(--accent-color);
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-family: Poppins, sans-serif;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
button:hover {
background: #8e44ad;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(155, 89, 182, 0.4);
}
footer {
text-align: center;
padding: 1rem 0;
color: var(--subtext-color);
font-size: 0.9rem;
margin-top: 2rem;
}
</style>
<script src="/js/u.js" async></script>
</head>
<body>
<div class="container">
<header>
<h1>Text Cleaner</h1>
<p>Convert formatted text to clean plain text</p>
</header>
<div class="cleaner-card">
<label for="inputText">Input Text</label>
<textarea id="inputText" placeholder="Paste text with smart quotes, dashes, or other special characters here..."></textarea>
<div class="button-row">
<button onclick="cleanText()">Clean Text</button>
<button onclick="copyToClipboard()">Copy to Clipboard</button>
</div>
<label for="outputText">Clean Text</label>
<textarea id="outputText" readonly placeholder="Cleaned text will appear here..."></textarea>
</div>
</div>
<script async>
function cleanText() {
const input = document.getElementById('inputText').value;
let cleaned = input;
// Fix all quotes
const quoteReplacements = {
'\u201C': '"', '\u201D': '"',
'\u2018': "'", '\u2019': "'",
'\u00AB': '"', '\u00BB': '"',
'\u2039': "'", '\u203A': "'"
};
cleaned = cleaned.replace(/[\u201C\u201D\u2018\u2019\u00AB\u00BB\u2039\u203A]/g, function(char) {
return quoteReplacements[char] || char;
});
// Fix all dashes
const dashReplacements = {
'\u2013': '-', '\u2014': '-',
'\u2010': '-', '\u2011': '-',
'\u2012': '-', '\u2015': '-',
'\u2212': '-'
};
cleaned = cleaned.replace(/[\u2013\u2014\u2010\u2011\u2012\u2015\u2212]/g, function(char) {
return dashReplacements[char] || char;
});
// Fix ellipses
cleaned = cleaned.replace(/\u2026/g, '...');
// Normalize line breaks (but preserve them)
cleaned = cleaned.replace(/\r\n|\r/g, '\n');
// Remove extra spaces within each line but preserve newlines
cleaned = cleaned.split('\n').map(line => line.replace(/\s+/g, ' ').trim()).join('\n');
// Remove lines that are just whitespace
cleaned = cleaned.replace(/^\s*[\r\n]/gm, '\n');
// Fix other common characters
cleaned = cleaned.replace(/[\u00B6\u00A7]/g, ''); // Remove paragraph and section marks
cleaned = cleaned.replace(/[\u00AE\u00A9\u2122]/g, ''); // Remove trademark symbols
cleaned = cleaned.replace(/[\u2022\u25E6\u2023\u2043]/g, '-'); // Replace bullets with hyphens
document.getElementById('outputText').value = cleaned;
}
function copyToClipboard() {
const outputText = document.getElementById('outputText');
if (!outputText.value) {
cleanText();
}
outputText.select();
document.execCommand('copy');
const copyButton = document.getElementsByTagName('button')[1];
copyButton.textContent = "Copied!";
setTimeout(function() {
copyButton.textContent = "Copy to Clipboard";
}, 1500);
}
</script>
</body>
</html>

View file

@ -0,0 +1,743 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Checkpoint Documentation</title>
<meta name=description content="Documentation for the Checkpoint Protection System, a secure Proof-of-Work solution to prevent automated abuse.">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel="preload" href="/js/u.js" as="script">
<link rel="stylesheet" href="/css/u.css">
<link rel="stylesheet" href="/css/docs.css">
<link rel="stylesheet" href="https://unpkg.com/@speed-highlight/core@1.2.7/dist/themes/github-dark.css">
<style>
:root {
--background-color: #1a1a1a;
--overlay-bg: rgba(28, 28, 28, 0.95);
--text-color: #fff;
--subtext-color: #ccc;
--accent-color: #9B59B6;
}
body {
background-color: var(--background-color);
color: var(--text-color);
font-family: 'Poppins', sans-serif;
}
.container {
background: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(8px);
border-radius: 20px;
padding: 20px;
margin: 40px auto;
box-shadow: 0 10px 30px rgba(0,0,0,0.3), 0 1px 2px rgba(155,89,182,0.2);
max-width: 960px;
}
</style>
<script async src=/js/u.js></script>
<script type="module" src="/js/docs.js"></script>
<script async src="/js/lightbox.js"></script>
</head>
<body>
<div class="container">
<div class="disclaimer note">
<p><strong>Disclaimer:</strong> Some internal fields and implementation details are omitted here for security reasons.</p>
</div>
<h1>Checkpoint Protection System</h1>
<div class="toc">
<h2>Contents</h2>
<ul>
<li><a href="#overview">Overview</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="#challenge-generation">Challenge Generation</a></li>
<li><a href="#proof-verification">Proof Verification</a></li>
<li><a href="#token-structure">Token Structure</a></li>
<li><a href="#security-features">Security Features</a></li>
<li><a href="#configuration">Configuration Options</a></li>
<li><a href="#middleware">Middleware Integration</a></li>
<li><a href="#client-side">Client-Side Implementation</a></li>
<li><a href="#api-endpoints">API Endpoints</a></li>
</ul>
</div>
<section id="overview" class="section">
<h2>Overview</h2>
<p>Checkpoint Protection asks visitors to solve a quick puzzle before letting them through, cutting down on automated traffic while keeping the experience smooth for real users.</p>
<ul>
<li>No account or personal data needed</li>
<li>Privacy-focused and lightweight</li>
<li>Blocks bots and scripts effectively</li>
<li>Works seamlessly in modern browsers</li>
</ul>
</section>
<section id="how-it-works" class="section">
<h2>How It Works</h2>
<p>When you navigate to a protected page, the middleware checks for a valid token cookie (<code>__Host-checkpoint_token</code>).</p>
<ol>
<li>If the token is present, the server verifies its signature and confirms it's bound to your device.</li>
<li>Missing or invalid tokens trigger an interstitial page with a request ID.</li>
<li>The browser fetches challenge data from <code>/api/pow/challenge?id=REQUEST_ID</code>. This payload includes a random challenge, salt, difficulty, and hidden parameters.</li>
<li>The client runs two proofs in parallel:
<ul>
<li><strong>Proof of Work:</strong> finds a nonce such that <code>SHA256(challenge + salt + nonce)</code> meets the difficulty.</li>
<li><strong>Proof of Space:</strong> allocates and hashes large memory buffers to confirm resource availability.</li>
</ul>
</li>
<li>Results are sent to <code>/api/pow/verify</code> along with the request ID.</li>
<li>On success, the server issues a signed token (valid for 24h) and sets it as a cookie for future visits.</li>
</ol>
<div class="diagram">
<h3>Checkpoint Protection Flow</h3>
<img src="/images/Basic-POW-Overview.excalidraw.svg" alt="Checkpoint Protection Flow Diagram" id="flowDiagram" loading="lazy">
</div>
</section>
<section id="challenge-generation" class="section">
<h2>Challenge Generation</h2>
<p>
Challenges are generated using cryptographically secure random bytes combined with a salt for additional entropy:
</p>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>func generateChallenge() (string, string) {
// Generate a random challenge
randomBytes := make([]byte, 16)
_, err := cryptorand.Read(randomBytes)
if err != nil {
log.Fatalf("CRITICAL: Failed to generate secure random challenge: %v", err)
}
// Generate a random salt for additional entropy
saltBytes := make([]byte, saltLength)
_, err = cryptorand.Read(saltBytes)
if err != nil {
log.Fatalf("CRITICAL: Failed to generate secure random salt: %v", err)
}
return hex.EncodeToString(randomBytes), hex.EncodeToString(saltBytes)
}</code></pre>
</div>
<div class="note">
<p>
<strong>Security Note:</strong> The system uses Go's crypto/rand package for secure random number generation, ensuring challenges cannot be predicted even by sophisticated attackers.
</p>
</div>
<h3>Challenge Parameters</h3>
<p>
Challenges are stored with a unique request ID and include parameters for verification:
</p>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>type ChallengeParams struct {
Challenge string `json:"challenge"` // Base64 encoded
Salt string `json:"salt"` // Base64 encoded
Difficulty int `json:"difficulty"`
ExpiresAt time.Time `json:"expires_at"`
ClientIP string `json:"-"`
PoSSeed string `json:"pos_seed"` // Hex encoded
}</code></pre>
</div>
<p>
When a client requests a challenge, the parameters are delivered in an obfuscated format to prevent automated analysis:
</p>
<div class="code-example">
<span class="code-label">JSON</span>
<pre><code>{
"a": "base64-encoded-challenge",
"b": "base64-encoded-salt",
"c": 4,
"d": "hex-encoded-pos-seed"
}</code></pre>
</div>
</section>
<section id="proof-verification" class="section">
<h2>Proof Verification</h2>
<p>
The system performs a two-step verification process:
</p>
<h3>1. Computational Proof (Proof of Work)</h3>
<p>
Verification checks that the hash of the challenge, salt, and nonce combination has the required number of leading zeros:
</p>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>func verifyProofOfWork(challenge, salt, nonce string, difficulty int) bool {
input := challenge + salt + nonce
hash := calculateHash(input)
// Check if the hash has the required number of leading zeros
prefix := strings.Repeat("0", difficulty)
return strings.HasPrefix(hash, prefix)
}
func calculateHash(input string) string {
hash := sha256.Sum256([]byte(input))
return hex.EncodeToString(hash[:])
}</code></pre>
</div>
<h3>2. Memory Proof (Proof of Space)</h3>
<p>
In addition to the computational work, clients must prove they can allocate and manipulate significant memory resources:
</p>
<ul>
<li>Clients allocate between 48MB to 160MB of memory (size determined by the PoS seed)</li>
<li>Client divides memory into 4-8 chunks and performs deterministic filling operations</li>
<li>The process is run three times, hashing the entire buffer each time</li>
<li>The resulting hashes and execution times are submitted for verification</li>
</ul>
<p>
The server verifies:
</p>
<ul>
<li>All three hashes are identical (proving deterministic execution)</li>
<li>Each hash is 64 characters (valid SHA-256)</li>
<li>Execution times are consistent (within 20% variation)</li>
</ul>
<div class="note">
<p>
The dual-verification approach makes the system resistant to specialized hardware acceleration. While the computational proof can be solved by ASICs or GPUs, the memory proof is specifically designed to be inefficient on such hardware.
</p>
</div>
</section>
<section id="token-structure" class="section">
<h2>Token Structure</h2>
<p>
Checkpoint tokens contain various fields for security and binding:
</p>
<div class="table-container">
<table>
<tr>
<th>Field</th>
<th>Description</th>
<th>Purpose</th>
</tr>
<tr>
<td>Nonce</td>
<td>The solution to the challenge</td>
<td>Verification proof</td>
</tr>
<tr>
<td>ExpiresAt</td>
<td>Token expiration timestamp</td>
<td>Enforces time-limited access (24 hours)</td>
</tr>
<tr>
<td>ClientIP</td>
<td>Hashed full client IP</td>
<td>Device binding (first 8 bytes of SHA-256)</td>
</tr>
<tr>
<td>UserAgent</td>
<td>Hashed user agent</td>
<td>Browser binding</td>
</tr>
<tr>
<td>BrowserHint</td>
<td>Derived from Sec-CH-UA headers</td>
<td>Additional client identity verification</td>
</tr>
<tr>
<td>Entropy</td>
<td>Random data</td>
<td>Prevents token prediction/correlation</td>
</tr>
<tr>
<td>Created</td>
<td>Token creation timestamp</td>
<td>Token age tracking</td>
</tr>
<tr>
<td>LastVerified</td>
<td>Last verification timestamp</td>
<td>Token usage tracking</td>
</tr>
<tr>
<td>Signature</td>
<td>HMAC signature</td>
<td>Prevents token forgery</td>
</tr>
<tr>
<td>TokenFormat</td>
<td>Version number</td>
<td>Backward compatibility support</td>
</tr>
</table>
</div>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>type CheckpointToken struct {
Nonce string `json:"g"` // Nonce
ExpiresAt time.Time `json:"exp"`
ClientIP string `json:"cip,omitempty"`
UserAgent string `json:"ua,omitempty"`
BrowserHint string `json:"bh,omitempty"`
Entropy string `json:"ent,omitempty"`
Created time.Time `json:"crt"`
LastVerified time.Time `json:"lvf,omitempty"`
Signature string `json:"sig,omitempty"`
TokenFormat int `json:"fmt"`
}</code></pre>
</div>
<h3>Token Security</h3>
<p>
Every token is cryptographically signed using HMAC-SHA256 with a server-side secret:
</p>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>func computeTokenSignature(token CheckpointToken, tokenBytes []byte) string {
tokenCopy := token
tokenCopy.Signature = "" // Ensure signature field is empty for signing
tokenToSign, _ := json.Marshal(tokenCopy)
h := hmac.New(sha256.New, hmacSecret)
h.Write(tokenToSign)
return hex.EncodeToString(h.Sum(nil))
}
func verifyTokenSignature(token CheckpointToken, tokenBytes []byte) bool {
if token.Signature == "" {
return false
}
expectedSignature := computeTokenSignature(token, tokenBytes)
return hmac.Equal([]byte(token.Signature), []byte(expectedSignature))
}</code></pre>
</div>
<h3>Token Storage</h3>
<p>
Successfully verified tokens are stored in a persistent store for faster validation:
</p>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>// TokenStore manages persistent storage of verified tokens
type TokenStore struct {
VerifiedTokens map[string]time.Time `json:"verified_tokens"`
Mutex sync.RWMutex `json:"-"`
FilePath string `json:"-"`
}
// Each token is identified by a unique hash
func calculateTokenHash(token CheckpointToken) string {
data := fmt.Sprintf("%s:%s:%d",
token.Nonce, // Use nonce as part of the key
token.Entropy, // Use entropy as part of the key
token.Created.UnixNano()) // Use creation time
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}</code></pre>
</div>
</section>
<section id="security-features" class="section">
<h2>Security Features</h2>
<div class="security">
<h3>Anti-Forgery Protections</h3>
<ul>
<li><strong>HMAC Signatures:</strong> Each token is cryptographically signed using HMAC-SHA256 to prevent tampering</li>
<li><strong>Token Binding:</strong> Tokens are bound to client properties (hashed full IP, hashed user agent, browser client hints)</li>
<li><strong>Random Entropy:</strong> Each token contains unique entropy to prevent token prediction or correlation</li>
<li><strong>Format Versioning:</strong> Tokens include a format version to support evolving security requirements</li>
</ul>
</div>
<div class="security">
<h3>Replay Prevention</h3>
<ul>
<li><strong>Nonce Tracking:</strong> Used nonces are tracked to prevent replay attacks</li>
<li><strong>Expiration Times:</strong> All tokens and challenges have expiration times</li>
<li><strong>Token Cleanup:</strong> Expired tokens are automatically purged from the system</li>
<li><strong>Challenge Invalidation:</strong> Challenges are immediately invalidated after successful verification</li>
</ul>
</div>
<div class="security">
<h3>Rate Limiting</h3>
<ul>
<li><strong>IP-Based Limits:</strong> Maximum verification attempts per hour (default: 10)</li>
<li><strong>Request ID Binding:</strong> Challenge parameters are bound to the requesting IP</li>
<li><strong>Challenge Expiration:</strong> Challenges expire after 5 minutes to prevent stockpiling</li>
</ul>
</div>
<div class="security">
<h3>Advanced Verification</h3>
<ul>
<li><strong>Proof of Space:</strong> Memory-intensive operations prevent GPU/ASIC acceleration</li>
<li><strong>Browser Fingerprinting:</strong> Secure client-hint headers verify legitimate browsers</li>
<li><strong>Challenge Obfuscation:</strong> Challenges are encoded and structured to resist automated analysis</li>
<li><strong>Persistent Secret:</strong> The system uses a persistent HMAC secret stored securely on disk</li>
</ul>
</div>
</section>
<section id="configuration" class="section">
<h2>Configuration Options</h2>
<p>
The Checkpoint system can be configured through these constants:
</p>
<div class="table-container">
<table>
<tr>
<th>Constant</th>
<th>Description</th>
<th>Default</th>
</tr>
<tr>
<td>Difficulty</td>
<td>Number of leading zeros required in the hash</td>
<td>4</td>
</tr>
<tr>
<td>TokenExpiration</td>
<td>Duration for which a token is valid</td>
<td>24 hours</td>
</tr>
<tr>
<td>Cookie Name</td>
<td>__Host-checkpoint_token</td>
<td>The cookie name storing the issued token</td>
</tr>
<tr>
<td>maxAttemptsPerHour</td>
<td>Rate limit for verification attempts</td>
<td>10</td>
</tr>
<tr>
<td>saltLength</td>
<td>Length of the random salt in bytes</td>
<td>16</td>
</tr>
<tr>
<td>maxNonceAge</td>
<td>Time before nonces are cleaned up</td>
<td>24 hours</td>
</tr>
<tr>
<td>challengeExpiration</td>
<td>Time before a challenge expires</td>
<td>5 minutes</td>
</tr>
</table>
</div>
<div class="warning">
<p>
<strong>Warning:</strong> Increasing the Difficulty significantly increases the computational work required by clients.
A value that's too high may result in poor user experience, especially on mobile devices.
</p>
</div>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>const (
// Difficulty defines the number of leading zeros required in hash
Difficulty = 4
// TokenExpiration sets token validity period
TokenExpiration = 24 * time.Hour
// CookieName defines the cookie name for tokens
CookieName = "__Host-checkpoint_token"
// Max verification attempts per IP per hour
maxAttemptsPerHour = 10
// Salt length for additional entropy
saltLength = 16
)</code></pre>
</div>
</section>
<section id="middleware" class="section">
<h2>Middleware Integration</h2>
<p>
The Checkpoint system provides a middleware handler that automatically protects HTML routes while bypassing API routes and static assets:
</p>
<h3>HTMLCheckpointMiddleware</h3>
<p>
This middleware is optimized for HTML routes, with smart content-type detection and automatic exclusions for static assets and API endpoints.
</p>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>// HTMLCheckpointMiddleware handles challenges specifically for HTML pages
func HTMLCheckpointMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Allow certain paths to bypass verification
path := c.Path()
if path == "/video-player" || path == "/video-player.html" || strings.HasPrefix(path, "/videos/") {
return c.Next()
}
if strings.HasPrefix(path, "/api") {
return c.Next()
}
if path == "/favicon.ico" || (strings.Contains(path, ".") && !strings.HasSuffix(path, ".html")) {
return c.Next()
}
// Only apply to HTML routes
isHtmlRoute := strings.HasSuffix(path, ".html") || path == "/" ||
(len(path) > 0 && !strings.Contains(path, "."))
if !isHtmlRoute {
return c.Next()
}
token := c.Cookies(CookieName)
if token != "" {
valid, err := validateToken(token, c)
if err == nil && valid {
return c.Next()
}
}
return serveInterstitial(c)
}
}</code></pre>
</div>
<h3>Usage in Application</h3>
<div class="code-example">
<span class="code-label">Go</span>
<pre><code>// Enable HTML checkpoint protection for all routes
app.Use(middleware.HTMLCheckpointMiddleware())
// API group with verification endpoints
api := app.Group("/api")
// Verification endpoints
api.Post("/pow/verify", middleware.VerifyCheckpointHandler)
api.Get("/pow/challenge", middleware.GetCheckpointChallengeHandler)
// Example protected API endpoint
api.Get("/protected", func(c *fiber.Ctx) error {
// Access is already verified by cookie presence
return c.JSON(fiber.Map{
"message": "You have accessed the protected endpoint!",
"time": time.Now(),
})
})</code></pre>
</div>
</section>
<section id="client-side" class="section">
<h2>Client-Side Implementation</h2>
<p>
The client-side implementation is handled by the interstitial page and its associated JavaScript:
</p>
<ol>
<li>Client attempts to access a protected resource</li>
<li>Server serves the interstitial page with a request ID</li>
<li>JavaScript fetches challenge parameters from <code>/api/pow/challenge?id=REQUEST_ID</code></li>
<li>Two verification stages run in parallel:
<ul>
<li>Computational proof: Using Web Workers to find a valid nonce</li>
<li>Memory proof: Allocating and manipulating memory buffers</li>
</ul>
</li>
<li>Results are submitted to <code>/api/pow/verify</code> endpoint</li>
<li>On success, the server sets a cookie and redirects to the original URL</li>
</ol>
<h3>Web Worker Implementation</h3>
<p>
Computational proof is handled by Web Workers to avoid freezing the UI:
</p>
<div class="code-example">
<span class="code-label">JavaScript</span>
<pre><code>function workerFunction() {
self.onmessage = function(e) {
const { type, data } = e.data;
if (type === 'pow') {
// PoW calculation
const { challenge, salt, startNonce, endNonce, target, batchId } = data;
let count = 0;
let solution = null;
processNextNonce(startNonce);
function processNextNonce(nonce) {
const input = String(challenge) + String(salt) + nonce.toString();
const msgBuffer = new TextEncoder().encode(input);
crypto.subtle.digest('SHA-256', msgBuffer)
.then(hashBuffer => {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const result = hashArray.map(b =>
b.toString(16).padStart(2, '0')).join('');
count++;
if (result.startsWith(target)) {
solution = { nonce: nonce.toString(), found: true };
self.postMessage({
type: 'pow_result',
solution: solution,
count: count,
batchId: batchId
});
return;
}
if (nonce < endNonce && !solution) {
setTimeout(() => processNextNonce(nonce + 1), 0);
} else if (!solution) {
self.postMessage({
type: 'pow_result',
solution: null,
count: count,
batchId: batchId
});
}
});
}
}
};
}</code></pre>
</div>
<h3>Memory Proof Implementation</h3>
<p>
The memory proof allocates and manipulates large buffers to verify client capabilities:
</p>
<div class="code-example">
<span class="code-label">JavaScript</span>
<pre><code>async function runProofOfSpace(seedHex, isDecoy) {
// Deterministic memory size (48MB to 160MB) based on seed
const minMB = 48, maxMB = 160;
let seedInt = parseInt(seedHex.slice(0, 8), 16);
const CHUNK_MB = minMB + (seedInt % (maxMB - minMB + 1));
const CHUNK_SIZE = CHUNK_MB * 1024 * 1024;
// Chunk memory for controlled allocation
const chunkCount = 4 + (seedInt % 5); // 4-8 chunks
const chunkSize = Math.floor(CHUNK_SIZE / chunkCount);
// Run the proof multiple times to verify consistency
const runs = 3;
const hashes = [];
const times = [];
// For each run...
for (let r = 0; r < runs; r++) {
// Generate deterministic chunk order
let prng = seededPRNG(seedHex + r.toString(16));
let order = Array.from({length: chunkCount}, (_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = prng() % (i + 1);
[order[i], order[j]] = [order[j], order[i]];
}
// Allocate and fill memory buffer
let t0 = performance.now();
let buf = new ArrayBuffer(CHUNK_SIZE);
let view = new Uint8Array(buf);
// Fill buffer with deterministic pattern
for (let c = 0; c < chunkCount; c++) {
let chunkIdx = order[c];
let start = chunkIdx * chunkSize;
let end = (chunkIdx + 1) * chunkSize;
for (let i = start; i < end; i += 4096) {
view[i] = prng() & 0xFF;
}
}
// Hash the entire buffer
let hashBuf = await crypto.subtle.digest('SHA-256', view);
let t2 = performance.now();
// Convert hash to hex string
let hashHex = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, '0')).join('');
// Store results
hashes.push(hashHex);
times.push(Math.round(t2 - t0));
// Clean up
buf = null; view = null;
}
return { hashes, times };
}</code></pre>
</div>
<div class="note">
<p>
The client-side implementation is designed to be difficult to reverse-engineer. The obfuscated API responses, minimal logging, and anti-debugging measures prevent automated circumvention.
</p>
</div>
</section>
<section id="api-endpoints" class="section">
<h2>API Endpoints</h2>
<p>
The Checkpoint system exposes two primary API endpoints:
</p>
<h3>1. Challenge Endpoint</h3>
<p>
Retrieves challenge parameters for a verification request:
</p>
<div class="code-example">
<span class="code-label">HTTP</span>
<pre><code>GET /api/pow/challenge?id=REQUEST_ID
Response:
{
"a": "base64-encoded-challenge",
"b": "base64-encoded-salt",
"c": 4,
"d": "hex-encoded-pos-seed"
}</code></pre>
</div>
<h3>2. Verification Endpoint</h3>
<p>
Accepts proof solutions and issues tokens when valid:
</p>
<div class="code-example">
<span class="code-label">HTTP</span>
<pre><code>POST /api/pow/verify
Request:
{
"request_id": "unique-request-id",
"g": "nonce-solution",
"h": ["pos-hash1", "pos-hash2", "pos-hash3"],
"i": [time1, time2, time3]
}
Response:
{
"token": "base64-encoded-token",
"expires_at": "2025-04-17T18:57:48Z"
}</code></pre>
</div>
<div class="note">
<p>
<strong>Backwards Compatibility:</strong> The older endpoint <code>/api/verify</code> is maintained for compatibility with existing clients.
</p>
</div>
</section>
<footer>
<div class="doc-version-note">These docs reflect version 2.0 of the <strong>Checkpoint Protection System</strong>.</div>
<p>Last updated: <span id="last-updated">Tuesday, April 16, 2025</span></p>
</footer>
</div>
</body>
</html>

213
develop/html/index.html Normal file
View file

@ -0,0 +1,213 @@
<!doctype html>
<html lang=en>
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>caileb.com</title>
<meta name=description content="Public-facing services hosted on caileb.com">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel="preload" href="/js/u.js" as="script">
<link rel=preload as=image href=/images/logos/immich.svg>
<link rel=preload as=image href=/images/logos/jellyfin.svg>
<link rel=preload as=image type=image/webp href=/images/logos/linkwarden.webp>
<link rel=preload as=image href=/images/logos/navidrome.svg>
<link rel=manifest href=/manifest.json>
<link rel="stylesheet" href="/css/u.css">
<style>
:root {
--background-color: #121212;
--card-gradient-start: #1e1e1e;
--card-gradient-end: #333;
--header-background: #262626;
--text-color: #fff;
--accent-color: #9B59B6;
--subtext-color: #ccc
}
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
body {
background: #1c1c1c;
color: var(--text-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
margin: 0;
padding: 20px 20px 0 20px
}
@media (min-width:1024px) {
body {
height: 100vh;
justify-content: center;
padding: 20px 20px 60px
}
footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%
}
}
footer {
font-size: 1.2rem
}
.container {
max-width: 1200px;
width: 100%
}
header {
text-align: center;
background: var(--header-background);
padding: 2rem;
border-radius: 10px;
margin-bottom: 2rem;
box-shadow: 0 4px 10px rgba(0, 0, 0, .3)
}
header h1 {
font-size: 3rem;
margin-bottom: .5rem;
color: var(--accent-color)
}
header p {
font-size: 1.25rem;
color: var(--subtext-color)
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: center;
}
.card {
flex-basis: 280px;
border-radius: 12px;
padding: 1.5rem;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
align-items: center
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, .25)
}
.card .icon {
width: 112px;
height: 112px;
margin-bottom: 1rem;
object-fit: contain
}
.card h2 {
font-size: 1.5rem;
margin-bottom: .5rem
}
.card p {
font-size: 1rem;
text-align: center
}
footer {
text-align: center;
padding: 10px 0;
background-color: transparent;
color: #c3c3c3;
font-size: 1.2rem
}
footer a {
color: #cf7bf1;
text-decoration: none
}
footer a:hover {
text-decoration: underline
}
.no-hover .card {
transition: none !important
}
.no-hover .card:hover {
transform: none !important;
box-shadow: none !important
}
@keyframes waveColor {
0% {
color: grey
}
50% {
color: #fff
}
100% {
color: grey
}
}
.highlight-letter {
display: inline-block;
animation-name: waveColor;
animation-duration: 1.2s;
animation-iteration-count: infinite;
animation-timing-function: linear
}
</style>
<script src="/js/u.js" async></script>
</head>
<body>
<div class=container>
<section class=grid>
<a class="card eel" href=https://gallery.caileb.com
style=background:linear-gradient(135deg,rgba(252,87,94,.55),rgba(247,180,44,.55))>
<img alt="Immich Logo" class=icon src=/images/logos/immich.svg sizes=112x112>
<h1>Immich</h1>
</a>
<a class="card eel" href=https://jellyfin.caileb.com
style=background:linear-gradient(135deg,rgba(99,49,148,.55),rgba(43,131,237,.55))>
<img alt="Jellyfin Logo" class=icon src=/images/logos/jellyfin.svg sizes=112x112>
<h1>Jellyfin</h1>
</a>
<a class="card eel" href=https://archive.caileb.com
style=background:linear-gradient(135deg,rgba(15,76,129,.55),rgba(22,191,253,.55))>
<img alt="Linkwarden Logo" class=icon src=/images/logos/linkwarden.webp sizes=112x112>
<h1>Linkwarden</h1>
</a>
<a class="card eel" href=https://music.caileb.com
style=background:linear-gradient(135deg,rgba(33,150,243,.55),rgba(3,218,197,.55))>
<img alt="Navidrome Logo" class=icon src=/images/logos/navidrome.svg sizes=112x112>
<h1>Navidrome</h1>
</a>
</section>
</div>
<footer id="email-footer">Email: <a href="mailto:a@caileb.com">a@caileb.com</a></footer>
</body>
</html>

View file

@ -0,0 +1,550 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Integrity Checker - caileb.com</title>
<meta name="description" content="Demonstrates the SRI integrity checker feature">
<link rel="icon" href="/images/favi.png" type="image/png">
<link rel="apple-touch-icon" href="/images/favi.png">
<link rel="shortcut icon" href="/images/favi.png">
<link rel="preload" href="/webfonts/Poppins-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/webfonts/Poppins-SemiBold.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/u.css">
<link rel="stylesheet" href="/css/docs.css">
<!-- External stylesheet (will get integrity) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<!-- Local preloaded script (won't get integrity) -->
<link rel="preload" href="/js/u.js" as="script">
<!-- External preloaded script (will get integrity) -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" as="script">
<!-- Add Lodash for another test -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js" as="script">
<script src="https://cdnjs.cloudflare.com/ajax/libs/quicklink/2.3.0/quicklink.umd.js"></script>
<style>
:root {
--background-color: #121212;
--card-gradient-start: #1e1e1e;
--card-gradient-end: #333;
--header-background: #262626;
--text-color: #fff;
--accent-color: #9B59B6;
--subtext-color: #ccc;
--success-color: #2ecc71;
--error-color: #e74c3c;
--warning-color: #f39c12;
}
body {
background: #1c1c1c;
color: var(--text-color);
font-family: Poppins, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 800px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--accent-color);
}
header p {
font-size: 1.25rem;
color: var(--subtext-color);
}
.info-box {
background: linear-gradient(135deg, rgba(30, 30, 30, 0.8), rgba(51, 51, 51, 0.8));
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
}
h2 {
color: var(--accent-color);
margin: 1.5rem 0 1rem;
font-size: 1.8rem;
font-weight: 600;
}
h3 {
color: var(--accent-color);
margin: 1rem 0;
font-size: 1.4rem;
font-weight: 600;
}
p, li {
color: var(--text-color);
margin-bottom: 0.75rem;
line-height: 1.6;
}
code {
background-color: rgba(0, 0, 0, 0.3);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
color: #e0e0e0;
}
.resource-table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
background: rgba(30, 30, 30, 0.6);
border-radius: 8px;
overflow: hidden;
}
.resource-table th, .resource-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #444;
}
.resource-table tr:last-child td {
border-bottom: none;
}
.resource-table th {
background-color: rgba(0, 0, 0, 0.3);
color: var(--accent-color);
font-weight: 600;
}
.external {
color: #e74c3c;
}
.local {
color: #2ecc71;
}
ol, ul {
padding-left: 1.5rem;
margin-bottom: 1.5rem;
}
.demo-section {
margin: 2rem 0;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-loaded {
background-color: var(--success-color);
}
.status-error {
background-color: var(--error-color);
}
.status-pending {
background-color: var(--warning-color);
}
.script-status {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
}
.demo-card {
background: linear-gradient(135deg, rgba(30, 30, 30, 0.6), rgba(51, 51, 51, 0.6));
border-radius: 12px;
padding: 1.5rem;
margin: 1rem 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.demo-card h4 {
color: var(--accent-color);
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.2rem;
font-weight: 600;
}
.demo-result {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
font-family: monospace;
color: #e0e0e0;
min-height: 24px;
}
button {
background: var(--accent-color);
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-family: Poppins, sans-serif;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover {
background: #8e44ad;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(155, 89, 182, 0.4);
}
.flex-container {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flex-container > div {
flex: 1;
min-width: 250px;
}
.toc {
background-color: rgba(30, 30, 30, 0.5);
border-radius: 8px;
padding: 20px;
margin: 20px 0 30px 0;
border: 1px solid var(--border-color);
}
.toc h2 {
margin-top: 0;
text-align: center;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
margin-bottom: 15px;
color: var(--accent-color);
}
.toc ul {
list-style-type: none;
padding-left: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.toc li {
margin-bottom: 8px;
flex: 0 0 auto;
}
.toc a {
display: block;
padding: 5px 15px;
border-radius: 4px;
transition: background-color 0.2s ease;
background-color: rgba(20, 20, 20, 0.5);
white-space: nowrap;
}
.toc a:hover {
background-color: rgba(50, 50, 50, 0.5);
text-decoration: none;
}
.section {
scroll-margin-top: 20px;
margin-bottom: 2.5rem;
}
.feature-card {
background-color: rgba(40, 40, 40, 0.5);
border-radius: 8px;
padding: 20px;
border: 1px solid var(--border-color);
margin-bottom: 15px;
}
.feature-card h3 {
color: var(--accent-color);
margin-top: 0;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
.code-example {
position: relative;
margin: 1.5rem 0;
}
.code-label {
position: absolute;
top: -12px;
right: 10px;
background-color: var(--accent-color);
color: white;
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 4px;
}
pre {
background-color: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
margin: 0;
}
code {
font-family: monospace;
color: #e0e0e0;
}
@media (max-width: 768px) {
.toc ul {
flex-direction: column;
align-items: stretch;
}
.toc a {
text-align: center;
white-space: normal;
}
.resource-status,
.demo-cards {
grid-template-columns: 1fr;
}
}
</style>
<script src="/js/u.js" async></script>
</head>
<body>
<div class="container">
<h1>Auto-Integrity Hash Demo</h1>
<div class="info-box">
<p><strong>This is a live demonstration of automatic SRI hash generation.</strong></p>
<p>The server automatically adds integrity hashes to all external resources when the site is built - no manual work required.</p>
<p>If you view the source code of this page, you'll see all external CSS and JavaScript files have <code>integrity</code> and <code>crossorigin</code> attributes that were added automatically during build.</p>
<p>This security feature protects against compromised CDNs and ensures resources haven't been tampered with.</p>
</div>
<h2>External Scripts Working</h2>
<p>These demos confirm that the external scripts are loaded and working correctly with their integrity hashes:</p>
<div class="demo-cards">
<div class="demo-card">
<h3>jQuery Demo</h3>
<p>jQuery provides DOM manipulation and animation capabilities.</p>
<div class="demo-result" id="jquery-result">Running jQuery test...</div>
</div>
<div class="demo-card">
<h3>Lodash Demo</h3>
<p>Lodash provides utility functions for common programming tasks.</p>
<div class="demo-result" id="lodash-result">Running Lodash test...</div>
</div>
</div>
<div class="demo-cards">
<div class="demo-card">
<h3>Bootstrap Components</h3>
<p>Bootstrap provides responsive UI components.</p>
<div class="demo-result" id="bootstrap-result">
<div class="alert alert-info">
This is a Bootstrap alert component
</div>
<div class="progress" style="height: 20px; background-color: #444;">
<div class="progress-bar bg-success" role="progressbar" style="width: 75%;" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100">75%</div>
</div>
</div>
</div>
<div class="demo-card">
<h3>Quicklink Demo</h3>
<p>Quicklink prefetches links that are in the viewport.</p>
<div class="demo-result" id="quicklink-result">Running Quicklink test...</div>
</div>
</div>
<h2>Monitored Resources</h2>
<p>The following resources have integrity checks automatically applied during build:</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Resource Type</th>
<th>Location</th>
<th>Integrity Added?</th>
</tr>
</thead>
<tbody>
<tr>
<td>Stylesheet</td>
<td class="local">/css/u.css</td>
<td>No (Local)</td>
</tr>
<tr>
<td>Stylesheet</td>
<td class="external">Bootstrap CSS (CDN)</td>
<td>Yes (External)</td>
</tr>
<tr>
<td>Preloaded Script</td>
<td class="local">/js/u.js</td>
<td>No (Local)</td>
</tr>
<tr>
<td>Preloaded Script</td>
<td class="external">jQuery (CDN)</td>
<td>Yes (External)</td>
</tr>
<tr>
<td>Preloaded Script</td>
<td class="external">Lodash (CDN)</td>
<td>Yes (External)</td>
</tr>
<tr>
<td>Script</td>
<td class="external">Quicklink (CDN)</td>
<td>Yes (External)</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Local script (won't get integrity) -->
<script src="/js/u.js"></script>
<!-- External scripts (will get integrity) -->
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
<script>
// Auto-run jQuery demo
function runJqueryDemo() {
const resultElement = document.getElementById('jquery-result');
try {
if (typeof jQuery !== 'undefined') {
resultElement.textContent = '';
const demoText = document.createElement('div');
demoText.textContent = 'jQuery ' + jQuery.fn.jquery + ' loaded successfully! This color animation is powered by jQuery.';
resultElement.appendChild(demoText);
// Use jQuery for color animation
jQuery(demoText).css('color', '#e74c3c')
.animate({ color: '#2ecc71' }, 1000)
.animate({ color: '#3498db' }, 1000)
.animate({ color: '#f39c12' }, 1000)
.animate({ color: '#9b59b6' }, 1000);
} else {
resultElement.textContent = 'Error: jQuery is not loaded';
}
} catch (e) {
resultElement.textContent = 'Error: ' + e.message;
}
}
// Auto-run Lodash demo
function runLodashDemo() {
const resultElement = document.getElementById('lodash-result');
try {
if (typeof _ !== 'undefined') {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const chunked = _.chunk(array, 3);
const shuffled = _.shuffle([...array]);
const summed = _.sum(array);
resultElement.innerHTML =
'<div>Lodash ' + _.VERSION + ' loaded successfully!</div>' +
'<div>• Chunking [1-10] into groups of 3: ' + JSON.stringify(chunked) + '</div>' +
'<div>• Shuffled array: ' + JSON.stringify(shuffled) + '</div>' +
'<div>• Sum of array: ' + summed + '</div>';
} else {
resultElement.textContent = 'Error: Lodash is not loaded';
}
} catch (e) {
resultElement.textContent = 'Error: ' + e.message;
}
}
// Auto-run Quicklink check
function runQuicklinkCheck() {
const resultElement = document.getElementById('quicklink-result');
try {
if (typeof quicklink !== 'undefined') {
// Call quicklink to prefetch
quicklink.listen();
resultElement.innerHTML =
'<div>Quicklink loaded successfully!</div>' +
'<div>Now prefetching links as you scroll near them.</div>' +
'<div style="margin-top: 10px; font-size: 0.9em; color: #aaa;">Check network tab in dev tools to see prefetch requests.</div>';
} else {
resultElement.textContent = 'Error: Quicklink is not loaded';
}
} catch (e) {
resultElement.textContent = 'Error: ' + e.message;
}
}
// Check when DOM is fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Small delay to ensure scripts are fully initialized
setTimeout(function() {
runJqueryDemo();
runLodashDemo();
runQuicklinkCheck();
}, 300);
});
// Fallback if DOMContentLoaded already fired
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(function() {
runJqueryDemo();
runLodashDemo();
runQuicklinkCheck();
}, 300);
}
</script>
</body>
</html>

398
develop/html/kb.html Normal file
View file

@ -0,0 +1,398 @@
<!doctype html>
<html lang=en>
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Caileb's Knowledgebase</title>
<meta name=description content="Caileb's Knowledgebase">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel="preload" href="/js/u.js" as="script">
<link rel="stylesheet" href="/css/u.css">
<style>
:root {
--background-color: #1e1e1e;
--text-color: #cfcfcf;
--heading-color: #ffffff;
--code-block-bg: #333;
--code-block-border: #4d9cfa;
--code-color: #dcdcdc;
--link-color: #4d9cfa;
--link-hover-color: #4d9cfa;
--input-border: #f39c12;
--input-focus-border: #e67e22;
--collapsible-bg: #2d2d2d;
--collapsible-hover-bg: #3a3a3a;
--scrollbar-bg: #2d2d2d;
--scrollbar-thumb-bg: #4d9cfa;
--scrollbar-thumb-hover-bg: #3971a3;
--section-divider: #333;
--section-accent: #f39c12;
}
body {
background-color: var(--background-color);
color: var(--text-color);
margin: 0;
line-height: 1.4;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.container {
max-width: 700px;
width: 100%;
}
h1, h3, h4 {
color: var(--heading-color);
margin-top: 0;
}
h2 {
color: var(--heading-color);
margin-top: 0;
margin-bottom: 15px;
position: relative;
padding-bottom: 10px;
display: inline-block;
cursor: pointer;
}
h2:hover::before {
content: "#";
position: absolute;
left: -1.6rem;
color: var(--link-color);
opacity: 0.7;
}
h2::after {
content: "";
position: absolute;
bottom: 12px;
left: 0;
width: 100%;
height: 2px;
background: var(--section-accent);
}
.section {
margin-bottom: 40px;
padding-bottom: 20px;
position: relative;
}
.section::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent,
var(--section-divider) 10%,
var(--section-divider) 90%,
transparent);
}
.section:last-child::after {
display: none;
}
.code-block {
background-color: var(--code-block-bg);
color: var(--heading-color);
padding: 10px;
border-radius: 5px;
overflow-x: auto;
white-space: nowrap;
width: 100%;
box-sizing: border-box;
position: relative;
margin-bottom: 20px;
border: 1px solid var(--code-block-border)
}
.code-block.command::before {
content: "$";
color: var(--code-block-border);
position: absolute;
left: 10px;
top: 11px;
font-family: Consolas, "Courier New", monospace
}
code {
font-family: Consolas, "Courier New", monospace;
color: var(--code-color)
}
code.command {
padding-left: 15px
}
a {
color: var(--link-color);
text-decoration: none
}
a:hover {
text-decoration: underline
}
.collapsible-label {
background-color: var(--collapsible-bg);
color: var(--text-color);
cursor: pointer;
padding: 10px;
width: 100%;
border: 2px solid var(--input-border);
text-align: left;
outline: 0;
font-size: 18px;
border-radius: 5px 5px 0 0;
margin-bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
max-width: 700px;
box-sizing: border-box;
transition: background-color .3s, border-color .3s;
position: relative;
z-index: 2
}
.collapsible-label:hover {
background-color: var(--collapsible-hover-bg);
border-color: var(--input-focus-border)
}
.collapsible-input {
display: none
}
.content-wrapper {
width: 100%;
position: relative;
z-index: 1;
margin-bottom: 20px
}
.content {
max-height: 0;
overflow: hidden;
background-color: var(--collapsible-bg);
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-top: none;
transition: max-height .45s ease, padding .45s ease, border-color .45s ease;
padding: 0 10px
}
.collapsible-input:checked+.collapsible-label+.content {
border-color: var(--input-border);
padding: 10px
}
.arrow {
transition: transform .3s
}
.collapsible-input:checked+.collapsible-label .arrow {
transform: rotate(180deg)
}
.step {
margin-bottom: 20px
}
.step h4 {
margin-bottom: 10px
}
</style>
<script src="/js/u.js" async></script>
</head>
<body>
<div class="container">
<div class="section" id="fail2ban">
<h2>Fail2ban</h2>
<div class="content-wrapper">
<input type="checkbox" id="collapsible-fail2ban" class="collapsible-input">
<label for="collapsible-fail2ban" class="collapsible-label">
Setup Fail2ban
<span class="arrow">&#9660;</span>
</label>
<div class="content">
<div class="step">
<h4>Step 1: Install Fail2ban (Debian/Ubuntu)</h4>
<p>First, install Fail2ban by running:</p>
<div class="code-block command">
<code class="command">sudo apt install fail2ban</code>
</div>
</div>
<div class="step">
<h4>Step 2: Navigate to the Fail2ban Directory</h4>
<p>Change to the Fail2ban configuration directory:</p>
<div class="code-block command">
<code class="command">cd /etc/fail2ban/</code>
</div>
</div>
<div class="step">
<h4>Step 3: Copy the Example Configuration File</h4>
<p>Copy the example configuration file as a base for your custom configuration:</p>
<div class="code-block command">
<code class="command">sudo cp jail.conf jail.local</code>
</div>
</div>
<div class="step">
<h4>Step 4: Create a New Filter</h4>
<p>Navigate to the filter.d directory and create a new filter file:</p>
<div class="code-block command">
<code class="command">cd filter.d/</code>
</div>
<div class="code-block command">
<code class="command">sudo nano nginx-4xx.conf</code>
</div>
</div>
<div class="step">
<h4>Step 5: Define the Filter to Block Repeated 4xx Errors</h4>
<p>Add the following content to the <code>nginx-4xx.conf</code> file:</p>
<div class="code-block">
<code>[Definition]<br>failregex = ^&lt;HOST&gt;.*"(GET|POST|HEAD|CONNECT).*" (404|444|403|400) .*<br>ignoreregex = 127.0.0.1 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16</code>
</div>
</div>
<div class="step">
<h4>Step 6: Edit the Jail Configuration to Use the New Filter</h4>
<p>Go back to the previous directory and edit <code>jail.local</code>:</p>
<div class="code-block command">
<code class="command">cd ..</code>
</div>
<div class="code-block command">
<code class="command">sudo nano jail.local</code>
</div>
<p>Add the following section:</p>
<div class="code-block">
<code>#<br># Repeated 4xx errors (Nginx)<br>#<br>[nginx-4xx]<br>enabled = true<br>port = http,https<br>logpath = /var/log/nginx/access.log<br>maxretry = 4</code>
</div>
</div>
<div class="step">
<h4>Step 7: Restart Fail2ban for the Changes to Take Effect</h4>
<p>Restart the Fail2ban service:</p>
<div class="code-block command">
<code class="command">sudo systemctl restart fail2ban</code>
</div>
</div>
<div class="step">
<h4>Step 8: Check the Filter Status</h4>
<p>Verify the filter is working:</p>
<div class="code-block command">
<code class="command">sudo fail2ban-client status nginx-4xx</code>
</div>
<h4>OR</h4>
<p>For a prettified output:</p>
<div class="code-block command">
<code class="command">sudo fail2ban-client get nginx-4xx banip | tr ' ' '\n'</code>
</div>
</div>
</div>
</div>
</div>
<div class="section" id="pm2">
<h2>Node PM2</h2>
<p>Restart</p>
<pre class="code-block command">
<code class="command">pm2 restart caileb.com</code>
</pre>
</div>
<div class="section" id="ffmpeg">
<h2>FFmpeg</h2>
<p>Highest quality AV1</p>
<div class="code-block command">
<code class="command">ffmpeg -i input -c:v av1_nvenc -preset p7 -cq 1 -b:v 0 -qmin 1 -qmax 5 -rc-lookahead 250 -spatial-aq 1 -aq-strength 15 -refs 16 -temporal-aq 1 -c:a flac -compression_level 8 highest_quality.mkv</code>
</div>
<p>Standard compression</p>
<div class="code-block command">
<code class="command">ffmpeg -i input -vf "mpdecimate" -fps_mode vfr -c:v av1_nvenc -preset p7 -cq 30 -b:v 0 -maxrate 18.5M -bufsize 25M -g 240 -keyint_min 24 -rc vbr -c:a libopus -b:a 128k compressed.webm</code>
</div>
<p>Extreme compression</p>
<div class="code-block command">
<code class="command">ffmpeg -i input -vf "mpdecimate,scale=-1:1080" -fps_mode vfr -c:v av1_nvenc -preset p7 -rc vbr -b:v 6M -maxrate 12M -bufsize 18M -g 300 -keyint_min 34 -c:a libopus -b:a 96k compressed.webm</code>
</div>
<p>Rocket.Chat</p>
<div class="code-block command">
<code class="command">ffmpeg -i input -vf "mpdecimate,scale=-1:1440" -fps_mode vfr -c:v av1_nvenc -preset p7 -rc vbr -b:v 8M -maxrate 15M -bufsize 22M -g 270 -keyint_min 28 -c:a libopus -b:a 112k rocket_chat.webm</code>
</div>
</div>
<div class="section" id="html">
<h2>Useful HTML Stuffs</h2>
<p>Make iFrames/Images Lazy Load <a href="https://developer.mozilla.org/docs/Web/Performance/Lazy_loading#images_and_iframes" target="_blank" rel="noopener noreferrer">MDN</a></p>
<p>Replace FitVids or other similar JS libraries with CSS' aspect-ratio <a href="https://developer.mozilla.org/docs/Web/CSS/aspect-ratio" target="_blank" rel="noopener noreferrer">MDN</a></p>
</div>
<div class="section" id="malware-removal">
<h2>Malware Removal</h2>
<ol>
<li>
<strong><a href="https://www.malwarebytes.com/mwb-download" target="_blank" rel="noopener noreferrer">Malwarebytes Free</a></strong>
<p>Easy-to-use tool that quickly detects and removes a broad range of malware.</p>
</li>
<li>
<strong><a href="https://www.emsisoft.com/home/emergency-kit/" target="_blank" rel="noopener noreferrer">Emsisoft Emergency Kit</a></strong>
<p>Utilizes Bitdefender's engine on top of their own for a strong all-in-one cleanup.</p>
</li>
<li>
<strong><a href="https://www.sophos.com/free-tools/virus-removal-tool" target="_blank" rel="noopener noreferrer">Sophos Scan &amp; Clean</a></strong>
<p>Portable scanner with effective heuristic analysis for detecting malware.</p>
</li>
<li>
<strong><a href="https://www.kaspersky.com/downloads/free-virus-removal-tool" target="_blank" rel="noopener noreferrer">Kaspersky Virus Removal Tool</a></strong>
<p>Efficiently finds and removes stubborn malware threats.</p>
</li>
</ol>
</div>
</div>
<script defer>
const collapsibleInputs = document.querySelectorAll(".collapsible-input");
collapsibleInputs.forEach((input) => {
input.addEventListener("change", function() {
const content = this.nextElementSibling.nextElementSibling;
content.style.maxHeight = this.checked ? content.scrollHeight + "px" : "0";
});
});
// Add click event to headers to update URL with section ID
document.addEventListener('DOMContentLoaded', function() {
// Get all h2 elements
const headers = document.querySelectorAll('h2');
// Add click event listeners to each header
headers.forEach(header => {
header.addEventListener('click', function() {
// Find the parent section with an ID
const section = this.closest('.section');
if (section && section.id) {
// Update the URL without reloading the page
history.pushState(null, null, `#${section.id}`);
}
});
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Lazy Video Component - Multi-Platform Video Embedding</title>
<meta name=description content="A lightweight, customizable web component for optimized video embeds from YouTube, Bitchute and more platforms with lazy loading for performance.">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel="preload" href="/js/u.js" as="script">
<link rel="preload" href="/js/lv.js" as="script">
<link rel="stylesheet" href="/css/u.css">
<link rel="stylesheet" href="/css/docs.css">
<link rel="stylesheet" href="https://unpkg.com/@speed-highlight/core@1.2.7/dist/themes/github-dark.css">
<script async src=/js/u.js></script>
<script async src="/js/lv.js"></script>
<script type="module" src="/js/docs.js"></script>
</head>
<body>
<div class="container">
<h1>Lazy Video Docs</h1>
<div class="toc">
<h2>Contents</h2>
<ul>
<li><a href="#overview">Overview</a></li>
<li><a href="#basic-usage">Basic Usage</a></li>
<li><a href="#platforms">Supported Platforms</a></li>
<li><a href="#attributes">Attributes</a></li>
<li><a href="#styling">Styling & CSS Variables</a></li>
<li><a href="#examples">Examples</a></li>
<li><a href="#converting">Converting Existing iframes</a></li>
<li><a href="#security">Security & Privacy</a></li>
<li><a href="#browser-support">Browser Support</a></li>
<li><a href="#breaking-change">Breaking Change</a></li>
</ul>
</div>
<section id="overview" class="section">
<h2>Overview</h2>
<p>
Embedding videos with standard <code>&lt;iframe&gt;</code> tags can dramatically slow down your site and consume large amounts of data. Each iframe loads the full video player and related resources immediately-even if the user never interacts with it. On pages with several videos, this can add <strong>hundreds of megabytes</strong> to the initial page load, resulting in a sluggish and costly experience, especially for users on mobile devices or limited networks.
</p>
<h3>How Lazy Video Helps</h3>
<p>
The <code>&lt;lazy-video&gt;</code> component solves this by loading only a lightweight thumbnail and play button at first. The actual video player is loaded only when the user clicks play (or when the video scrolls into view if <code>autoload</code> is enabled). This keeps your pages fast, responsive, and bandwidth-friendly.
</p>
<div class="lv-btn-group">
<a href="/js/lv.js" target="_blank" rel="noopener" class="lv-btn lv-btn-primary">View Source</a>
<a href="/js/lv.js" download class="lv-btn lv-btn-outline">Download</a>
<span class="lv-size-info">~17.0kB / <span>6.0kB</span> (Gzip)</span>
</div>
</section>
<section id="basic-usage" class="section">
<h2>Basic Usage</h2>
<p>
To get started, include the script on your page and use the custom element as shown below:
</p>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Till We Have Faces by Silent Planet"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class="note">
<p>
Always add a <code>title</code> for accessibility and better alt text on thumbnails.
</p>
</div>
</section>
<section id="platforms" class="section">
<h2>Officially Supported Platforms</h2>
<div class="table-container">
<table>
<tr>
<th>Platform</th>
<th>URL Pattern</th>
<th>Notes</th>
</tr>
<tr>
<td>YouTube</td>
<td>
<ul class="url-patterns">
<li><code>youtube.com/embed/ID</code></li>
<li><code>youtube.com/watch?v=ID</code></li>
<li><code>youtu.be/ID</code></li>
</ul>
</td>
<td>Full support for thumbnails and parameters.</td>
</tr>
<tr>
<td>Bitchute</td>
<td>
<ul class="url-patterns">
<li><code>bitchute.com/video/ID/</code></li>
<li><code>bitchute.com/embed/ID/</code></li>
</ul>
</td>
<td>Custom thumbnails are only needed if autoload is disabled.</td>
</tr>
</table>
</div>
</section>
<section id="attributes" class="section">
<h2>Attributes</h2>
<div class="table-container">
<table>
<tr>
<th>Attribute</th>
<th>Description</th>
<th>Default</th>
</tr>
<tr>
<td>src</td>
<td>Video embed URL (required)</td>
<td>N/A</td>
</tr>
<tr>
<td>title</td>
<td>Video title</td>
<td>"Video"</td>
</tr>
<tr>
<td>width</td>
<td>Width in pixels or percent</td>
<td>100% (responsive)</td>
</tr>
<tr>
<td>height</td>
<td>Height in pixels</td>
<td>16:9 ratio</td>
</tr>
<tr>
<td>thumbnail</td>
<td>Custom thumbnail URL</td>
<td>Auto-detected per platform</td>
</tr>
<tr>
<td>thumbnail-quality</td>
<td>YouTube thumbnail quality (default, hq, mq, sd, maxres)</td>
<td>Auto (maxres on desktop, hq on mobile)</td>
</tr>
<tr>
<td>service</td>
<td>Force a specific service (youtube, bitchute)</td>
<td>Auto-detected</td>
</tr>
<tr>
<td>sandbox</td>
<td>Extra security for the iframe. Restricts what the embedded player can do. See <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox" target="_blank">MDN</a> for details.</td>
<td>allow-scripts allow-same-origin allow-popups allow-forms allow-presentation</td>
</tr>
<tr>
<td>no-cookie</td>
<td>Use youtube-nocookie.com for YouTube (privacy-friendly)</td>
<td>true</td>
</tr>
<tr>
<td>autoload</td>
<td>Load video when scrolled into view</td>
<td>false (YouTube), true (Bitchute)</td>
</tr>
<tr>
<td>hide-title</td>
<td>Hide the video title bar</td>
<td>false</td>
</tr>
<tr>
<td>align</td>
<td>Set alignment (left, right, center)</td>
<td>center</td>
</tr>
<tr>
<td>container-fit</td>
<td>Make video fill the container (FitVids style)</td>
<td>false</td>
</tr>
</table>
</div>
<div class="warning">
<p>
<strong>Warning:</strong> Using <code>autoload</code> with many videos on one page can impact performance as users scroll. Use with care!
</p>
</div>
<div class="note">
<p>
<strong>Note:</strong> With <code>container-fit</code>, the component overrides max-width to 100% and sets max-height to auto, making it fill its container while keeping the aspect ratio.
</p>
</div>
</section>
<section id="styling" class="section">
<h2>Styling & CSS Variables</h2>
<p>
You can customize the look of <code>&lt;lazy-video&gt;</code> using CSS variables:
</p>
<div class="code-example">
<span class="code-label">CSS</span>
<pre><code>lazy-video {
--lv-max-width: 600px;
--lv-border-radius: 8px;
--lv-play-button-color: #f00;
--lv-play-button-bg: rgba(0, 0, 0, 0.7);
--lv-show-title: none;
}</code></pre>
</div>
<div class="table-container">
<h3>Available CSS Variables</h3>
<table>
<tr>
<th>CSS Variable</th>
<th>Description</th>
<th>Default</th>
</tr>
<tr>
<td>--lv-max-width</td>
<td>Maximum width of the video</td>
<td>560px</td>
</tr>
<tr>
<td>--lv-aspect-ratio</td>
<td>Aspect ratio</td>
<td>16 / 9</td>
</tr>
<tr>
<td>--lv-display</td>
<td>Display type</td>
<td>block</td>
</tr>
<tr>
<td>--lv-position</td>
<td>CSS position</td>
<td>relative</td>
</tr>
<tr>
<td>--lv-border-radius</td>
<td>Border radius for the container</td>
<td>0</td>
</tr>
<tr>
<td>--lv-margin</td>
<td>Container margin</td>
<td>0 auto</td>
</tr>
<tr>
<td>--lv-margin-left</td>
<td>Margin for left alignment</td>
<td>0</td>
</tr>
<tr>
<td>--lv-margin-right</td>
<td>Margin for right alignment</td>
<td>0 0 0 auto</td>
</tr>
<tr>
<td>--lv-margin-center</td>
<td>Margin for center alignment</td>
<td>0 auto</td>
</tr>
<tr>
<td>--lv-align</td>
<td>Set alignment (left, right, center)</td>
<td>center</td>
</tr>
<tr>
<td>--lv-background</td>
<td>Background color</td>
<td>#000</td>
</tr>
<tr>
<td>--lv-thumbnail-opacity</td>
<td>Thumbnail opacity</td>
<td>0.85</td>
</tr>
<tr>
<td>--lv-thumbnail-hover-opacity</td>
<td>Opacity on hover</td>
<td>1</td>
</tr>
<tr>
<td>--lv-thumbnail-object-fit</td>
<td>Object-fit for thumbnail</td>
<td>cover</td>
</tr>
<tr>
<td>--lv-play-button-width</td>
<td>Play button width</td>
<td>68px</td>
</tr>
<tr>
<td>--lv-play-button-height</td>
<td>Play button height</td>
<td>48px</td>
</tr>
<tr>
<td>--lv-play-button-bg</td>
<td>Play button background</td>
<td>rgba(33, 33, 33, 0.8)</td>
</tr>
<tr>
<td>--lv-play-button-bg-hover</td>
<td>Play button hover background</td>
<td>rgba(230, 33, 23, 1)</td>
</tr>
<tr>
<td>--lv-play-button-color</td>
<td>Play button arrow color</td>
<td>rgba(255, 255, 255, 0.9)</td>
</tr>
<tr>
<td>--lv-play-button-radius</td>
<td>Play button border radius</td>
<td>8px</td>
</tr>
<tr>
<td>--lv-play-button-arrow-size</td>
<td>Play button arrow size</td>
<td>12px 0 12px 20px</td>
</tr>
<tr>
<td>--lv-title-padding</td>
<td>Title bar padding</td>
<td>10px 12px</td>
</tr>
<tr>
<td>--lv-title-bg</td>
<td>Title background</td>
<td>rgba(0, 0, 0, 0.75)</td>
</tr>
<tr>
<td>--lv-title-color</td>
<td>Title text color</td>
<td>white</td>
</tr>
<tr>
<td>--lv-title-font-family</td>
<td>Title font family</td>
<td>Roboto, Arial, sans-serif</td>
</tr>
<tr>
<td>--lv-title-font-size</td>
<td>Title font size</td>
<td>18px</td>
</tr>
<tr>
<td>--lv-title-font-weight</td>
<td>Title font weight</td>
<td>500</td>
</tr>
<tr>
<td>--lv-title-line-height</td>
<td>Title line height</td>
<td>1.2</td>
</tr>
<tr>
<td>--lv-focus-outline</td>
<td>Focus outline</td>
<td>2px solid #4285F4</td>
</tr>
<tr>
<td>--lv-focus-outline-offset</td>
<td>Focus outline offset</td>
<td>2px</td>
</tr>
<tr>
<td>--lv-show-title</td>
<td>Show/hide title bar (use 'none' to hide)</td>
<td>block</td>
</tr>
<tr>
<td>--lv-timestamp-right</td>
<td>Timestamp right position</td>
<td>10px</td>
</tr>
<tr>
<td>--lv-timestamp-bottom</td>
<td>Timestamp bottom position</td>
<td>10px</td>
</tr>
<tr>
<td>--lv-timestamp-bg</td>
<td>Timestamp background</td>
<td>rgba(0, 0, 0, 0.7)</td>
</tr>
<tr>
<td>--lv-timestamp-color</td>
<td>Timestamp text color</td>
<td>white</td>
</tr>
<tr>
<td>--lv-timestamp-padding</td>
<td>Timestamp padding</td>
<td>2px 6px</td>
</tr>
<tr>
<td>--lv-timestamp-radius</td>
<td>Timestamp border radius</td>
<td>3px</td>
</tr>
<tr>
<td>--lv-timestamp-font-size</td>
<td>Timestamp font size</td>
<td>12px</td>
</tr>
<tr>
<td>--lv-timestamp-font-family</td>
<td>Timestamp font family</td>
<td>system-ui, sans-serif</td>
</tr>
<tr>
<td>--lv-loading-bg</td>
<td>Loading background</td>
<td>rgba(0,0,0,0.7)</td>
</tr>
<tr>
<td>--lv-loading-color</td>
<td>Loading text color</td>
<td>white</td>
</tr>
<tr>
<td>--lv-loading-font-family</td>
<td>Loading font family</td>
<td>system-ui, sans-serif</td>
</tr>
<tr>
<td>--lv-fallback-bg</td>
<td>Fallback background</td>
<td>#1a1a1a</td>
</tr>
<tr>
<td>--lv-fallback-color</td>
<td>Fallback text color</td>
<td>white</td>
</tr>
<tr>
<td>--lv-fallback-font-family</td>
<td>Fallback font family</td>
<td>system-ui, sans-serif</td>
</tr>
<tr>
<td>--lv-fallback-font-size</td>
<td>Fallback font size</td>
<td>14px</td>
</tr>
</table>
</div>
</section>
<section id="examples" class="section">
<h2>Examples</h2>
<h3>YouTube Embed with Custom Size</h3>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Till We Have Faces by Silent Planet"
width="50%"
height="260px"
thumbnail-quality="maxres"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class="example">
<lazy-video src="https://www.youtube.com/embed/wPr3kws2prM" title="Till We Have Faces by Silent Planet" width="50%" height="260px"></lazy-video>
</div>
<h3>Bitchute with Autoload Off</h3>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;lazy-video
src="https://www.bitchute.com/video/zSfeNPF-OpY"
title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?"
autoload="false"
thumbnail="https://static-3.bitchute.com/live/cover_images/nDPZqzyLkFKW/zSfeNPF-OpY_640x360.jpg"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class="example">
<lazy-video
src="https://www.bitchute.com/video/zSfeNPF-OpY"
title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?"
autoload="false"
thumbnail="https://static-3.bitchute.com/live/cover_images/nDPZqzyLkFKW/zSfeNPF-OpY_640x360.jpg">
</lazy-video>
<div class="note">
<p>
With <code>autoload="false"</code> on Bitchute, users need to click twice: once to load the player, and again to play. This saves bandwidth but may be less convenient.
</p>
</div>
</div>
<h3>Bitchute with Autoload</h3>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;lazy-video
src="https://www.bitchute.com/video/zSfeNPF-OpY"
title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class="example">
<lazy-video
src="https://www.bitchute.com/video/zSfeNPF-OpY"
title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?">
</lazy-video>
</div>
<h3>Responsive Container (FitVids Style)</h3>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;div style="max-width: 100%; width: 100%;"&gt;
&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Responsive container example"
container-fit="true"&gt;
&lt;/lazy-video&gt;
&lt;/div&gt;</code></pre>
</div>
<div class="example">
<div style="max-width: 100%; width: 100%;">
<lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Till We Have Faces by Silent Planet"
container-fit="true">
</lazy-video>
</div>
<div class="note">
<p>
<code>container-fit="true"</code> makes the video fill its parent container while keeping the aspect ratio. Great for fluid layouts.
</p>
</div>
</div>
<h3>YouTube with Hidden Title Bar</h3>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Hidden title example"
hide-title&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class="example">
<lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Hidden title example"
hide-title>
</lazy-video>
</div>
<h3>Global Title Control with CSS</h3>
<div class="code-example">
<span class="code-label">CSS</span>
<pre><code>/* Hide titles for all videos */
lazy-video {
--lv-show-title: none;
}
/* Hide titles for a group */
.article-videos lazy-video {
--lv-show-title: none;
}</code></pre>
</div>
<h3>Global Alignment Control with CSS</h3>
<div class="code-example">
<span class="code-label">CSS</span>
<pre><code>/* Set alignment for all videos */
lazy-video {
--lv-align: left;
}
/* Responsive alignment */
@media (max-width: 768px) {
lazy-video {
--lv-align: center;
}
}
/* Different alignments for different contexts */
.sidebar lazy-video {
--lv-align: right;
}</code></pre>
</div>
</section>
<section id="converting" class="section">
<h2>Converting Existing iframes</h2>
<p>
You can convert existing video iframes to <code>&lt;lazy-video&gt;</code> by simply changing the tag name.
</p>
<p>Standard YouTube iframe:</p>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;iframe
src="https://www.youtube.com/embed/wPr3kws2prM?start=30&rel=0&controls=0"
width="560"
height="315"
title="Till We Have Faces by Silent Planet"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen&gt;
&lt;/iframe&gt;
</code></pre>
</div>
<p>Converted to <code>&lt;lazy-video&gt;</code> (just change the tag):</p>
<div class="code-example">
<span class="code-label">HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM?start=30&rel=0&controls=0"
width="560"
height="315"
title="Till We Have Faces by Silent Planet"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
</section>
<section id="security" class="section">
<h2>Security & Privacy</h2>
<p>
<code>&lt;lazy-video&gt;</code> is built with modern web security and privacy best practices:
</p>
<ul>
<li>
All embedded iframes use the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/credentialless" target="_blank"><code>credentialless</code></a> attribute. This helps prevent credential leaks and keeps third-party content isolated from your site's cookies and storage.
</li>
<li>
The <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox" target="_blank"><code>sandbox</code></a> attribute is set by default, restricting what the embedded player can do and reducing risk from third-party content.
</li>
<li>
For YouTube, the <code>youtube-nocookie.com</code> domain is used by default, so no tracking cookies are set unless the user interacts with the video.
</li>
</ul>
<div class="note">
<p>
<strong>Note:</strong> You can override the <code>sandbox</code> attribute if you need to enable additional features, but the default is designed for maximum safety.
</p>
</div>
</section>
<section id="browser-support" class="section">
<h2>Browser Support</h2>
<p>
Works in all modern browsers (Chrome, Firefox, Safari, Edge). Uses standard web component APIs. For IE11 or older, use the <a href="https://github.com/webcomponents/polyfills/tree/master/packages/custom-elements" target="_blank">custom-elements polyfill</a>.
</p>
</section>
<section id="breaking-change" class="section">
<h2>Breaking Change</h2>
<p>
<strong>April 3, 2025:</strong> The old <code>&lt;lazy-youtube&gt;</code> element is no longer supported. Please update any code to use <code>&lt;lazy-video&gt;</code> instead.
</p>
</section>
<footer>
<div class="doc-version-note">These docs reflect the latest release version of <strong>@lv.js</strong>.</div>
<p>Last updated: Friday, April 11th, 2025</p>
</footer>
</div>
</body>
</html>

396
develop/js/c.js Normal file
View file

@ -0,0 +1,396 @@
// Web Worker Script for hash computation
function workerFunction() {
self.onmessage = function(e) {
const { type, data } = e.data;
if (type === 'pow') {
// PoW calculation
const { challenge, salt, startNonce, endNonce, target, batchId } = data;
let count = 0;
let solution = null;
processNextNonce(startNonce);
function processNextNonce(nonce) {
const input = String(challenge) + String(salt) + nonce.toString();
const msgBuffer = new TextEncoder().encode(input);
crypto.subtle.digest('SHA-256', msgBuffer)
.then(hashBuffer => {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const result = hashArray.map(b =>
b.toString(16).padStart(2, '0')).join('');
count++;
if (result.startsWith(target)) {
solution = { nonce: nonce.toString(), found: true };
self.postMessage({
type: 'pow_result',
solution: solution,
count: count,
batchId: batchId
});
return;
}
if (count % 1000 === 0) {
self.postMessage({
type: 'progress',
count: count,
batchId: batchId
});
}
if (nonce < endNonce && !solution) {
setTimeout(() => processNextNonce(nonce + 1), 0);
} else if (!solution) {
self.postMessage({
type: 'pow_result',
solution: null,
count: count,
batchId: batchId
});
}
})
.catch(err => {
self.postMessage({
type: 'error',
error: 'Crypto API error: ' + err.message
});
});
}
} else {
// Handle other message types if needed in the future
self.postMessage({ type: 'error', error: 'Unknown message type: ' + type });
}
};
}
const workerCode = "(" + workerFunction.toString() + ")()";
// Proof-of-Space Worker script with buffer pooling
function posWorkerFunction() {
self.onmessage = async function(e) {
const { type, seedHex, isDecoy } = e.data;
if (type === 'pos') {
const minMB = 48, maxMB = 160;
let seedInt = parseInt(seedHex.slice(0, 8), 16);
if (isNaN(seedInt)) seedInt = Math.floor(Math.random() * (maxMB - minMB + 1));
const CHUNK_MB = isDecoy
? (minMB + ((seedInt * 3 + 17) % (maxMB - minMB + 1)))
: (minMB + (seedInt % (maxMB - minMB + 1)));
const CHUNK_SIZE = CHUNK_MB * 1024 * 1024;
const chunkCount = 4 + (seedInt % 5);
const chunkSize = Math.floor(CHUNK_SIZE / chunkCount);
const FILL_STEP_4K = 4096, FILL_STEP_1K = 1024;
const FILL_STEP_SWITCH = 35 * 1024 * 1024;
const runs = 3;
// Pre-allocate buffers
// Removed baseBuf as cpuBase calculation is unused
const mainBuf = new ArrayBuffer(CHUNK_SIZE);
const view = new Uint8Array(mainBuf);
const pressureBuf = new ArrayBuffer(16 * 1024 * 1024);
const pressureView = new Uint8Array(pressureBuf);
// Removed CPU baseline calculation as it's unused upstream
const hashes = [];
const times = [];
for (let r = 0; r < runs; r++) {
const prng = seededPRNG(seedHex + r.toString(16));
// generate deterministic chunk order
const order = Array.from({ length: chunkCount }, (_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = prng() % (i + 1);
[order[i], order[j]] = [order[j], order[i]];
}
// fill view
const t0 = performance.now();
for (let c = 0; c < chunkCount; c++) {
const idx = order[c];
const start = idx * chunkSize;
const end = (idx === chunkCount - 1) ? CHUNK_SIZE : start + chunkSize;
const step = (start < FILL_STEP_SWITCH) ? FILL_STEP_4K : FILL_STEP_1K;
for (let i = start; i < end; i += step) view[i] = prng() & 0xFF;
}
const hashBuf = await crypto.subtle.digest('SHA-256', view);
const t2 = performance.now();
hashes.push(Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join(''));
times.push(Math.round(t2 - t0));
// memory pressure
for (let i = 0; i < pressureView.length; i += 4096) pressureView[i] = prng() & 0xFF;
}
// Removed cpuBase from postMessage
self.postMessage({ type: 'pos_result', hashes, times });
}
};
function seededPRNG(seedHex) {
const s = [];
for (let i = 0; i < 4; i++) s[i] = parseInt(seedHex.substr(i * 8, 8), 16) >>> 0;
function rotl(x, k) { return ((x << k) | (x >>> (32 - k))) >>> 0; }
return function() {
const t = s[1] << 9;
let r = (s[0] * 5) >>> 0;
r = rotl(r, 7) * 9 >>> 0;
const tmp = s[0] ^ s[2];
s[2] ^= s[1]; s[1] ^= s[3]; s[0] ^= s[1];
s[3] ^= tmp; s[2] ^= t; s[3] = rotl(s[3], 11);
return r >>> 0;
};
}
}
const posWorkerCode = "(" + posWorkerFunction.toString() + ")()";
// Main verification script
document.addEventListener('DOMContentLoaded', function() {
setTimeout(initVerification, 650);
function initVerification() {
const dataEl = document.getElementById('verification-data');
const targetPath = dataEl.getAttribute('data-target');
const requestID = dataEl.getAttribute('data-request-id');
startVerification();
async function startVerification() {
try {
const challengeResponse = await fetch('/api/pow/challenge?id=' + encodeURIComponent(requestID), {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!challengeResponse.ok) {
throw new Error('Failed to get challenge parameters');
}
const challengeData = await challengeResponse.json();
// Extract real and decoy seeds using obfuscated keys
const realPosSeed = challengeData.d; // 'd' is pos_seed
const decoySeed = challengeData.e || (Math.random().toString(16).slice(2, 18)); // 'e' is decoy_seed
const decoyFields = challengeData.f || []; // 'f' is decoy_fields
const verifier = new Verifier(challengeData, targetPath, requestID, decoySeed, decoyFields);
verifier.start();
} catch (error) {
showError('Verification setup failed: ' + error.message);
}
}
function createWorker() {
const blob = new Blob([workerCode], { type: 'text/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function createPosWorker() {
const blob = new Blob([posWorkerCode], { type: 'text/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function showError(message) {
const container = document.querySelector('.container');
container.classList.add('error');
container.classList.remove('success');
// Let CSS pseudo-elements render the ring and X on the existing spinner
const spinnerEl = document.querySelector('.spinner');
const statusEl = document.getElementById('status');
statusEl.style.display = 'inline-block';
statusEl.textContent = ''; // Keep this behavior as original, even if odd
statusEl.classList.add('error');
statusEl.classList.remove('success');
const spinnerContainer = document.querySelector('.spinner-container');
let errorDetails = document.getElementById('error-details');
if (!errorDetails) {
errorDetails = document.createElement('div');
errorDetails.id = 'error-details';
errorDetails.className = 'error-details';
spinnerContainer.appendChild(errorDetails);
}
// Hide error details to match success state layout
errorDetails.style.display = 'none'; // Keep this behavior as original
}
function showSuccess() {
document.querySelector('.container').classList.add('success');
document.getElementById('status').textContent = 'Redirecting';
}
function Verifier(params, targetPath, requestID, decoySeed, decoyFields) {
const workers = [];
const activeBatches = {};
let powSolution = null;
let isRunning = false;
const cpuCount = navigator.hardwareConcurrency || 4;
const workerCount = Math.max(1, Math.floor(cpuCount * 0.8));
const REDIRECT_DELAY = 1488;
this.start = function() {
setTimeout(findProofOfWork, 100);
};
async function findProofOfWork() {
try {
isRunning = true;
let decodedChallenge, decodedSalt;
try {
decodedChallenge = atob(params.a);
decodedSalt = atob(params.b);
} catch (e) {
throw new Error(`Failed to decode challenge/salt: ${e.message}`);
}
const target = '0'.repeat(params.c);
for (let i = 0; i < workerCount; i++) {
const worker = createWorker();
// Pass only 'e.data' as workerId parameter was unused
worker.onmessage = e => handleWorkerMessage(e.data);
worker.onerror = error => {
// Silently handle worker errors
};
workers.push(worker);
}
const totalRange = Number.MAX_SAFE_INTEGER;
const rangePerWorker = Math.floor(totalRange / workerCount);
for (let i = 0; i < workers.length; i++) {
const startNonce = i * rangePerWorker;
const endNonce = (i === workers.length - 1) ?
totalRange : (i + 1) * rangePerWorker - 1;
const workerId = `pow-worker-${i}`;
activeBatches[workerId] = {
// Keep workerId here as it's used as key in activeBatches
workerId: i,
startNonce,
endNonce
};
workers[i].postMessage({
type: 'pow',
data: {
challenge: decodedChallenge,
salt: decodedSalt,
startNonce,
endNonce,
target,
batchId: workerId
}
});
}
} catch (error) {
terminateWorkers();
showError(error.message);
}
}
// Removed unused 'workerId' parameter
function handleWorkerMessage(data) {
if (!isRunning) return;
if (data.type === 'pow_result') {
if (activeBatches[data.batchId]) {
delete activeBatches[data.batchId];
if (data.solution && data.solution.found) {
if (!powSolution) {
powSolution = data.solution;
proofOfWorkFound(powSolution);
}
}
}
} else if (data.type === 'error') {
showError('Compatibility error: ' + data.error);
terminateWorkers();
}
}
async function proofOfWorkFound(solution) {
isRunning = false;
terminateWorkers();
try {
// PoS via Worker
const posResult = await new Promise(res => {
const w = createPosWorker();
w.onmessage = e => { if (e.data.type==='pos_result') { res(e.data); w.terminate(); }};
w.postMessage({ type:'pos', seedHex: params.d, isDecoy:false });
});
const decoyResult = await new Promise(res => {
const w = createPosWorker();
w.onmessage = e => { if (e.data.type==='pos_result') { res(e.data); w.terminate(); }};
w.postMessage({ type:'pos', seedHex: decoySeed, isDecoy:true });
});
// Submit results
await submitSolution({ requestID, g: solution.nonce,
h: posResult.hashes, i: posResult.times,
j: decoyResult.hashes, k: decoyResult.times,
l: decoyFields });
} catch (error) {
showError(error.message);
}
}
function terminateWorkers() {
workers.forEach(worker => worker.terminate());
}
async function submitSolution(solutionData) {
try {
const response = await fetch('/api/pow/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
request_id: solutionData.requestID, // Keep descriptive
g: solutionData.g, // Nonce
h: solutionData.h, // Real PoS Hashes
i: solutionData.i, // Real PoS Times
j: solutionData.j, // Decoy Hashes
k: solutionData.k, // Decoy Times
l: solutionData.l // Decoy Fields
})
});
if (!response.ok) {
let errorMsg = `Verification failed: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMsg += ` - ${errorData.error}`;
} else {
const text = await response.text();
errorMsg += ` - Response: ${text}`;
}
} catch (parseError) {
// Silent catch
}
showError(errorMsg);
return;
}
showSuccess();
setTimeout(() => {
window.location.href = targetPath;
}, REDIRECT_DELAY);
} catch (error) {
showError('Verification failed. Please refresh the page.');
}
}
}
}
});

858
develop/js/cc.js Normal file
View file

@ -0,0 +1,858 @@
/**
* Credit Card Tracker
* Tracks reward categories, spending limits, and payment history for credit cards
*/
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const cardsContainer = document.getElementById('cards-container');
const emptyState = document.getElementById('empty-state');
const searchInput = document.getElementById('search-input');
const addCardBtn = document.getElementById('add-card-btn');
const addFirstCardBtn = document.getElementById('add-first-card-btn');
const cardModal = document.getElementById('card-modal');
const closeModalBtn = document.getElementById('close-modal');
const cardForm = document.getElementById('card-form');
const modalTitle = document.getElementById('modal-title');
const cardIdInput = document.getElementById('card-id');
const addCategoryBtn = document.getElementById('add-category-btn');
const categoriesContainer = document.getElementById('categories-container');
// Payment Modal Elements
const paymentModal = document.getElementById('payment-modal');
const closePaymentModalBtn = document.getElementById('close-payment-modal');
const paymentForm = document.getElementById('payment-form');
const paymentCardId = document.getElementById('payment-card-id');
const paymentCardName = document.getElementById('payment-card-name');
const paymentCategory = document.getElementById('payment-category');
const paymentAmount = document.getElementById('payment-amount');
const paymentDate = document.getElementById('payment-date');
// Create Payment History Modal - Will be added to DOM later
const paymentHistoryModal = document.createElement('div');
paymentHistoryModal.className = 'modal';
paymentHistoryModal.id = 'payment-history-modal';
// Initialize cards from localStorage
let cards = loadCards();
// Display cards or empty state
renderCards();
// Set today's date as default for payment date
paymentDate.valueAsDate = new Date();
// Event Listeners
addCardBtn.addEventListener('click', () => openAddCardModal());
addFirstCardBtn.addEventListener('click', () => openAddCardModal());
closeModalBtn.addEventListener('click', () => closeModal(cardModal));
closePaymentModalBtn.addEventListener('click', () => closeModal(paymentModal));
cardForm.addEventListener('submit', handleCardFormSubmit);
addCategoryBtn.addEventListener('click', () => addCategoryField('', '', ''));
paymentForm.addEventListener('submit', handlePaymentFormSubmit);
searchInput.addEventListener('input', handleSearch);
// Global event delegation for dynamically added card buttons
cardsContainer.addEventListener('click', handleCardActions);
// Check for monthly resets on page load
checkMonthlyResets();
// Initialize payment history modal
initPaymentHistoryModal();
/**
* Initialize payment history modal
*/
function initPaymentHistoryModal() {
paymentHistoryModal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Payment History</h2>
<button class="close-modal" id="close-history-modal">&times;</button>
</div>
<div id="payment-history-container">
<!-- Payment history will be loaded here -->
</div>
</div>
`;
document.body.appendChild(paymentHistoryModal);
// Add event listener to close button
document.getElementById('close-history-modal').addEventListener('click', () => {
closeModal(paymentHistoryModal);
});
}
/**
* Handle card action buttons via event delegation
*/
function handleCardActions(e) {
const target = e.target;
// Find closest card element
const cardElement = target.closest('.credit-card');
if (!cardElement) return;
const cardId = cardElement.dataset.id;
const card = cards.find(c => c.id === cardId);
if (!card) return;
// Payment button
if (target.closest('.payment-btn')) {
e.stopPropagation();
openPaymentModal(card);
return;
}
// Edit button
if (target.closest('.edit-btn')) {
e.stopPropagation();
openEditCardModal(card);
return;
}
// Delete button
if (target.closest('.delete-btn')) {
e.stopPropagation();
deleteCard(cardId);
return;
}
// View payment history (clicking on a category item)
const categoryItem = target.closest('.category-item');
if (categoryItem && categoryItem.dataset.categoryName) {
const categoryName = categoryItem.dataset.categoryName;
const category = card.categories.find(c => c.name === categoryName);
if (category) {
openPaymentHistoryModal(card, category);
}
}
}
/**
* Load cards from localStorage
*/
function loadCards() {
const storedCards = localStorage.getItem('creditCards');
return storedCards ? JSON.parse(storedCards) : [];
}
/**
* Save cards to localStorage
*/
function saveCards() {
localStorage.setItem('creditCards', JSON.stringify(cards));
}
/**
* Render all cards or empty state
*/
function renderCards() {
// Clear cards container except for the empty state
Array.from(cardsContainer.children).forEach(child => {
if (!child.classList.contains('empty-state')) {
child.remove();
}
});
// Show empty state if no cards
if (cards.length === 0) {
emptyState.style.display = 'block';
return;
}
// Hide empty state and render cards
emptyState.style.display = 'none';
// Filter cards if search input has value
let filteredCards = cards;
const searchTerm = searchInput.value.toLowerCase().trim();
if (searchTerm) {
filteredCards = cards.filter(card => {
const nameMatch = card.name.toLowerCase().includes(searchTerm);
const bankMatch = card.bank.toLowerCase().includes(searchTerm);
const categoryMatch = card.categories.some(cat =>
cat.name.toLowerCase().includes(searchTerm)
);
return nameMatch || bankMatch || categoryMatch;
});
}
// Render filtered cards
filteredCards.forEach(card => {
const cardElement = createCardElement(card);
cardsContainer.insertBefore(cardElement, emptyState);
});
}
/**
* Create a card element
*/
function createCardElement(card) {
const cardElement = document.createElement('div');
cardElement.className = 'credit-card';
cardElement.dataset.id = card.id;
// Calculate days until cycle resets
const today = new Date();
const currentDay = today.getDate();
const cycleDay = parseInt(card.statementDate);
let daysUntilReset;
if (currentDay === cycleDay) {
daysUntilReset = 0;
} else if (currentDay < cycleDay) {
daysUntilReset = cycleDay - currentDay;
} else {
// Calculate days until next month's cycle date
const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
daysUntilReset = (lastDayOfMonth - currentDay) + cycleDay;
}
// Create card HTML
cardElement.innerHTML = `
<div class="card-header">
<div class="card-details">
<h2 class="card-title">${card.name}</h2>
<div class="card-bank">${card.bank}${card.lastDigits ? ` •••• ${card.lastDigits}` : ''}</div>
</div>
<div class="card-actions">
<button type="button" class="action-btn payment-btn" title="Record Payment" aria-label="Record Payment">💰</button>
<button type="button" class="action-btn edit-btn" title="Edit Card" aria-label="Edit Card"></button>
<button type="button" class="action-btn delete-btn" title="Delete Card" aria-label="Delete Card">🗑</button>
</div>
</div>
<div class="card-info">
<div class="info-row">
<span class="info-label">Statement Cycle:</span>
<span class="info-value">Day ${card.statementDate}</span>
</div>
<div class="info-row">
<span class="info-label">Cycle Resets:</span>
<span class="info-value">${daysUntilReset === 0 ? 'Today' : `In ${daysUntilReset} day${daysUntilReset !== 1 ? 's' : ''}`}</span>
</div>
${card.expiryDate ? `
<div class="info-row">
<span class="info-label">Expires:</span>
<span class="info-value">${formatExpiryDate(card.expiryDate)}</span>
</div>
` : ''}
</div>
<div class="rewards-categories">
${card.categories.map(category => {
const payments = category.payments; // Assumed to be an array now
const spent = payments.reduce((total, p) => total + parseFloat(p.amount), 0);
const hasLimit = category.limit > 0;
const percentUsed = hasLimit ? (spent / category.limit) * 100 : 0;
const isNearLimit = percentUsed >= 75 && percentUsed < 100;
const isAtLimit = percentUsed >= 100;
const hasPayments = payments.length > 0;
// Calculate cash back amounts
const cashbackEarned = (spent * category.rate / 100).toFixed(2);
const maxCashback = hasLimit ? (category.limit * category.rate / 100).toFixed(2) : 0;
const cashbackDisplay = hasLimit ? ` ($${cashbackEarned}/$${maxCashback})` : ` ($${cashbackEarned})`;
return `
<div class="category-item ${hasPayments ? 'has-payments' : ''}" data-category-name="${category.name}">
<div class="category-header">
<span class="category-tag">${category.name}: ${category.rate}%</span>
${hasLimit ? `
<span class="info-value">$${spent.toFixed(2)} / $${category.limit.toFixed(2)}${cashbackDisplay}</span>
` : `<span class="info-value">$${spent.toFixed(2)}${cashbackDisplay}</span>`}
</div>
${hasLimit ? `
<div class="progress-bar ${isNearLimit ? 'near-limit' : ''} ${isAtLimit ? 'at-limit' : ''}">
<div class="progress-fill" style="width: ${Math.min(percentUsed, 100)}%"></div>
</div>
` : ''}
${hasPayments ? `<div class="view-payments-link">View ${payments.length} payment${payments.length !== 1 ? 's' : ''}</div>` : ''}
</div>
`;
}).join('')}
</div>
`;
return cardElement;
}
/**
* Format expiry date (YYYY-MM to MM/YYYY)
*/
function formatExpiryDate(dateString) {
const [year, month] = dateString.split('-');
return `${month}/${year}`;
}
/**
* Format a date for display (YYYY-MM-DD to MM/DD/YYYY)
*/
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
}
/**
* Open modal to add a new card
*/
function openAddCardModal() {
// Reset form
cardForm.reset();
cardIdInput.value = '';
modalTitle.textContent = 'Add New Card';
// Clear existing category fields and add one empty one
clearCategoryFields();
// Open modal
openModal(cardModal);
}
/**
* Open modal to edit an existing card
*/
function openEditCardModal(card) {
// Set form values
cardForm.reset();
cardIdInput.value = card.id;
document.getElementById('card-name').value = card.name;
document.getElementById('card-bank').value = card.bank;
document.getElementById('last-digits').value = card.lastDigits || '';
document.getElementById('expiry-date').value = card.expiryDate || '';
document.getElementById('statement-date').value = card.statementDate;
modalTitle.textContent = 'Edit Card';
// Clear existing category fields
clearCategoryFields();
// Add category fields for existing categories
if (card.categories.length === 0) {
} else {
card.categories.forEach(category => {
addCategoryField(category.name, category.rate, category.limit);
});
}
// Open modal
openModal(cardModal);
}
/**
* Open modal to record a payment
*/
function openPaymentModal(card) {
paymentCardId.value = card.id;
paymentCardName.textContent = card.name;
// Clear and populate category dropdown
paymentCategory.innerHTML = '';
card.categories.forEach(category => {
if (!category) return;
const option = document.createElement('option');
option.value = category.name;
option.textContent = `${category.name} (${category.rate}%)`;
// Add info about limit if one exists
if (category.limit > 0) {
const payments = category.payments; // Assumed to be an array now
const spent = payments.reduce((total, p) => total + parseFloat(p.amount), 0);
const remaining = Math.max(0, category.limit - spent);
option.textContent += ` - $${remaining.toFixed(2)} remaining`;
}
paymentCategory.appendChild(option);
});
// Reset other fields
paymentAmount.value = '';
paymentDate.valueAsDate = new Date();
document.getElementById('payment-note').value = '';
// Set modal title and button text for new payment
paymentModal.querySelector('.modal-title').textContent = 'Record Payment';
paymentModal.querySelector('.save-btn').textContent = 'Record Payment';
// Open modal
openModal(paymentModal);
}
/**
* Open payment history modal for a category
*/
function openPaymentHistoryModal(card, category) {
if (!card || !category) return;
const container = document.getElementById('payment-history-container');
const modalTitle = paymentHistoryModal.querySelector('.modal-title');
modalTitle.textContent = `${card.name} - ${category.name} Payments`;
container.innerHTML = ''; // Clear previous content
const payments = category.payments; // Assumed to be an array now
if (payments.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No payment history</h3>
<p>No payments have been recorded for this category yet.</p>
</div>
`;
} else {
const sortedPayments = [...payments].sort((a, b) =>
new Date(b.date) - new Date(a.date)
);
const historyHtml = `
<div class="payment-history-list">
<div class="payment-history-header">
<span>Date</span>
<span>Amount</span>
<span>Note</span>
<span>Actions</span>
</div>
${sortedPayments.map(payment => `
<div class="payment-history-item" data-payment-id="${payment.id}">
<span class="payment-date">${formatDate(payment.date)}</span>
<span class="payment-amount">$${parseFloat(payment.amount).toFixed(2)}</span>
<span class="payment-note">${payment.note || '-'}</span>
<div class="payment-actions">
<button type="button" class="action-btn edit-payment-btn" title="Edit Payment" aria-label="Edit Payment"></button>
<button type="button" class="action-btn delete-payment-btn" title="Delete Payment" aria-label="Delete Payment">🗑</button>
</div>
</div>
`).join('')}
</div>
`;
container.innerHTML = historyHtml;
// Add event listeners for edit and delete payment buttons
container.querySelectorAll('.edit-payment-btn').forEach(btn => {
btn.addEventListener('click', e => {
const paymentId = e.target.closest('.payment-history-item').dataset.paymentId;
const payment = payments.find(p => p.id === paymentId);
if (payment) {
editPayment(card.id, category.name, payment);
}
});
});
container.querySelectorAll('.delete-payment-btn').forEach(btn => {
btn.addEventListener('click', e => {
const paymentId = e.target.closest('.payment-history-item').dataset.paymentId;
const payment = payments.find(p => p.id === paymentId);
if (payment) {
deletePayment(card.id, category.name, payment.id);
}
});
});
}
// Open the modal
openModal(paymentHistoryModal);
}
/**
* Edit a payment
*/
function editPayment(cardId, categoryName, payment) {
// Set up payment modal for editing
paymentCardId.value = cardId;
const card = cards.find(c => c.id === cardId);
if (!card) return;
paymentCardName.textContent = card.name;
// Populate category dropdown (with only the selected category)
paymentCategory.innerHTML = '';
const category = card.categories.find(c => c.name === categoryName);
if (!category) return;
const option = document.createElement('option');
option.value = category.name;
option.textContent = `${category.name} (${category.rate}%)`;
paymentCategory.appendChild(option);
// Set payment details
paymentAmount.value = payment.amount;
paymentDate.value = payment.date;
document.getElementById('payment-note').value = payment.note || '';
// Add payment ID to the form
const paymentIdInput = document.getElementById('payment-id') || document.createElement('input');
paymentIdInput.type = 'hidden';
paymentIdInput.id = 'payment-id';
paymentIdInput.value = payment.id;
if (!document.getElementById('payment-id')) {
paymentForm.appendChild(paymentIdInput);
}
// Change modal title and button text
paymentModal.querySelector('.modal-title').textContent = 'Edit Payment';
paymentModal.querySelector('.save-btn').textContent = 'Update Payment';
// Close history modal and open payment modal with a slight delay for better transition
closeModal(paymentHistoryModal);
setTimeout(() => {
openModal(paymentModal);
}, 300);
}
/**
* Delete a payment
*/
function deletePayment(cardId, categoryName, paymentId) {
if (!confirm('Are you sure you want to delete this payment?')) return;
const cardIndex = cards.findIndex(c => c.id === cardId);
if (cardIndex === -1) return;
const categoryIndex = cards[cardIndex].categories.findIndex(c => c.name === categoryName);
if (categoryIndex === -1) return;
// Remove the payment from the array
const payments = cards[cardIndex].categories[categoryIndex].payments; // Assumed to be array
const paymentIndex = payments.findIndex(p => p.id === paymentId);
if (paymentIndex === -1) return;
// Get payment amount for confirmation
const amount = payments[paymentIndex].amount;
// Remove the payment
payments.splice(paymentIndex, 1);
// Save changes
saveCards();
// Close the history modal
closeModal(paymentHistoryModal);
// Show confirmation toast
showToast(`Payment of $${amount.toFixed(2)} has been deleted`);
// Re-render cards
renderCards();
}
/**
* Open a modal
*/
function openModal(modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent background scrolling
// Focus first input in modal for better accessibility
setTimeout(() => {
const firstInput = modal.querySelector('input:not([type="hidden"])');
if (firstInput) firstInput.focus();
}, 100);
}
/**
* Close a modal
*/
function closeModal(modal) {
modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
}
/**
* Clear all category fields
*/
function clearCategoryFields() {
categoriesContainer.innerHTML = '';
}
/**
* Add a category field to the form
*/
function addCategoryField(name = '', rate = '', limit = '') {
const categoryField = document.createElement('div');
categoryField.className = 'category-inputs';
categoryField.innerHTML = `
<input type="text" class="form-input category-name" placeholder="Category (e.g. Dining)" value="${name}">
<input type="number" class="form-input category-rate" placeholder="Rate %" step="0.1" min="0" value="${rate}">
<input type="number" class="form-input category-limit" placeholder="Limit $" step="0.01" min="0" value="${limit}">
<button type="button" class="action-btn delete-btn remove-category" title="Remove Category" aria-label="Remove Category">×</button>
`;
// Add event listener to remove button
categoryField.querySelector('.remove-category').addEventListener('click', function() {
// Allow removing the last category field
categoryField.remove();
});
categoriesContainer.appendChild(categoryField);
}
/**
* Handle card form submission
*/
function handleCardFormSubmit(e) {
e.preventDefault();
// Get form values
const cardId = cardIdInput.value || generateId();
const isEditing = !!cardIdInput.value;
const name = document.getElementById('card-name').value;
const bank = document.getElementById('card-bank').value;
const lastDigits = document.getElementById('last-digits').value;
const expiryDate = document.getElementById('expiry-date').value;
const statementDate = document.getElementById('statement-date').value;
// Get categories
const categories = [];
const categoryInputs = categoriesContainer.querySelectorAll('.category-inputs');
categoryInputs.forEach(input => {
const catName = input.querySelector('.category-name').value.trim();
const catRate = input.querySelector('.category-rate').value.trim();
const catLimit = input.querySelector('.category-limit').value.trim();
// Only add this category if any reward detail is provided
if (!catName && !catRate && !catLimit) {
return;
}
// Determine payments: Use existing if editing and found, otherwise default to empty array
let payments = [];
const existingCardData = isEditing ? cards.find(c => c.id === cardId) : null;
if (isEditing && existingCardData && existingCardData.categories) {
const existingCategoryData = existingCardData.categories.find(c => c.name === catName);
if (existingCategoryData && Array.isArray(existingCategoryData.payments)) {
payments = existingCategoryData.payments;
}
}
categories.push({
name: catName,
rate: catRate ? parseFloat(catRate) : 0,
limit: catLimit ? parseFloat(catLimit) : null,
payments: payments // Ensured to be an array
});
});
// Create card object
const card = {
id: cardId,
name,
bank,
lastDigits,
expiryDate,
statementDate,
categories, // categories array now guaranteed to have .payments arrays
createdAt: new Date().toISOString()
};
// Update or add card
const existingCardIndex = isEditing ? cards.findIndex(c => c.id === cardId) : -1;
if (existingCardIndex !== -1) {
// Preserve archived payments if editing
card.archivedPayments = cards[existingCardIndex].archivedPayments;
cards[existingCardIndex] = card;
} else {
cards.push(card);
}
// Save and render cards
saveCards();
renderCards();
// Show feedback toast
showToast(isEditing ? 'Card updated successfully' : 'Card added successfully');
// Close modal
closeModal(cardModal);
}
/**
* Handle payment form submission
*/
function handlePaymentFormSubmit(e) {
e.preventDefault();
// Get form values
const cardId = paymentCardId.value;
const categoryName = paymentCategory.value;
const amount = parseFloat(paymentAmount.value);
const date = paymentDate.value;
const note = document.getElementById('payment-note').value;
const paymentId = document.getElementById('payment-id')?.value;
// Find card and category
const cardIndex = cards.findIndex(c => c.id === cardId);
if (cardIndex === -1) return;
const categoryIndex = cards[cardIndex].categories.findIndex(c => c.name === categoryName);
if (categoryIndex === -1) return;
// Check if editing existing payment or adding new one
const payments = cards[cardIndex].categories[categoryIndex].payments; // Assumed to be array
if (paymentId) {
// Find the payment
const paymentIndex = payments.findIndex(p => p.id === paymentId);
if (paymentIndex !== -1) {
// Update payment
payments[paymentIndex] = {
id: paymentId,
amount,
date,
note,
createdAt: payments[paymentIndex].createdAt, // Keep original creation date
updatedAt: new Date().toISOString()
};
// Show toast
showToast('Payment updated successfully');
}
} else {
// Add new payment
const payment = {
id: generateId(),
amount,
date,
note,
createdAt: new Date().toISOString()
};
payments.push(payment);
// Show toast
showToast(`Payment of $${amount.toFixed(2)} recorded`);
}
// Save and render cards
saveCards();
renderCards();
// Remove payment ID if it exists
if (document.getElementById('payment-id')) {
document.getElementById('payment-id').remove();
}
// Reset modal title
paymentModal.querySelector('.modal-title').textContent = 'Record Payment';
// Close modal
closeModal(paymentModal);
}
/**
* Delete a card
*/
function deleteCard(cardId) {
if (!confirm('Are you sure you want to delete this card?')) return;
const cardName = cards.find(card => card.id === cardId)?.name || 'Card';
cards = cards.filter(card => card.id !== cardId);
saveCards();
renderCards();
// Show feedback toast
showToast(`${cardName} has been deleted`);
}
/**
* Show a toast notification
*/
function showToast(message) {
// Remove existing toast if any
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// Create toast element
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Auto remove after 3 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* Handle search
*/
function handleSearch() {
renderCards();
}
/**
* Generate a unique ID
*/
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
/**
* Check for monthly cycle resets
*/
function checkMonthlyResets() {
const today = new Date();
const lastCheck = localStorage.getItem('lastCycleCheck');
// If we haven't checked today
if (!lastCheck || new Date(lastCheck).toDateString() !== today.toDateString()) {
const currentDay = today.getDate();
// Check each card for cycle reset
cards.forEach(card => {
const cycleDay = parseInt(card.statementDate);
// If today is the cycle reset day
if (currentDay === cycleDay) {
// Mark payments as from previous cycle
card.categories.forEach(category => {
// Only archive if there are payments
if (category.payments.length > 0) {
// Create an archive if none exists
if (!card.archivedPayments) {
card.archivedPayments = [];
}
// Archive current cycle payments
const cycleData = {
date: today.toISOString(),
categories: [{
name: category.name,
rate: category.rate,
payments: [...category.payments]
}]
};
// Add to archived payments
card.archivedPayments.push(cycleData);
// Clear current payments
category.payments = [];
}
});
}
});
// Save changes
saveCards();
// Update last check date
localStorage.setItem('lastCycleCheck', today.toISOString());
}
}
});

48
develop/js/docs.js Normal file
View file

@ -0,0 +1,48 @@
import { highlightElement } from 'https://unpkg.com/@speed-highlight/core@1.2.7/dist/index.js';
document.addEventListener('DOMContentLoaded', () => {
// Initialize Syntax Highlighting
initSyntaxHighlighting();
});
/**
* Initialize syntax highlighting using Speed Highlight JS
* This is a reusable function that applies syntax highlighting to code blocks
*/
async function initSyntaxHighlighting() {
try {
// Get all code blocks
const codeBlocks = document.querySelectorAll('.code-example pre code');
codeBlocks.forEach(block => {
// Determine language from code-label
let lang = 'html'; // Default to HTML
const example = block.closest('.code-example');
if (example) {
const label = example.querySelector('.code-label');
if (label) {
const labelText = label.textContent.trim().toLowerCase();
if (labelText === 'css') lang = 'css';
if (labelText === 'js' || labelText === 'javascript') lang = 'js';
if (labelText === 'go' || labelText === 'golang') lang = 'go';
if (labelText === 'json') lang = 'json';
if (labelText === 'http') lang = 'http';
}
}
// Create a new element to hold the highlighted code
const highlighted = document.createElement('div');
highlighted.className = `shj-lang-${lang}`;
highlighted.textContent = block.textContent;
// Replace the pre with our new element
const pre = block.parentElement;
pre.parentNode.replaceChild(highlighted, pre);
// Apply highlighting directly
highlightElement(highlighted, lang);
});
} catch (error) {
console.warn('Syntax highlighting failed to initialize:', error);
}
}

427
develop/js/lightbox.js Normal file
View file

@ -0,0 +1,427 @@
/**
* EasyLightbox - A simple, lightweight lightbox for images
*/
(function() {
// Default options
const defaultOptions = {
selector: '.lightbox-img, #flowDiagram', // Images that should trigger lightbox
captionAttribute: 'data-caption', // Attribute to retrieve caption from
zoomable: true, // Whether to enable zoom controls
maxZoom: 300, // Maximum zoom percentage
minZoom: 100, // Minimum zoom percentage
closeOnEsc: true, // Close on escape key
closeOnOutsideClick: true // Close when clicking outside image
};
// Create global object
window.EasyLightbox = {
options: { ...defaultOptions },
// Initialize with custom options
init: function(customOptions = {}) {
// Merge default options with custom options
this.options = { ...defaultOptions, ...customOptions };
// Create lightbox container if it doesn't exist
this._createLightbox();
// Initialize listeners for all matching elements
this._initImageListeners();
return this;
},
// Create the lightbox HTML structure if it doesn't exist
_createLightbox: function() {
// Check if lightbox already exists
if (document.getElementById("imageLightbox")) {
return;
}
// Create lightbox container
const lightbox = document.createElement("div");
lightbox.id = "imageLightbox";
lightbox.className = "lightbox";
// Create lightbox content with simplified HTML
lightbox.innerHTML = `
<div class="lightbox-content">
<div class="lightbox-close" id="lightboxClose">X</div>
<div class="lightbox-img-container">
<img class="lightbox-img" id="lightboxImg" src="" alt="Enlarged image" draggable="false">
</div>
<div class="lightbox-caption" id="lightboxCaption"></div>
${
this.options.zoomable
? `
<div class="zoom-controls">
<span class="zoom-label">Zoom:</span>
<input type="range" min="${this.options.minZoom}" max="${this.options.maxZoom}" value="100" class="zoom-slider" id="zoomSlider">
<span class="zoom-value" id="zoomValue">100%</span>
</div>`
: ""
}
</div>
`;
// Add lightbox CSS link if not already present
if (!document.getElementById("lightbox-styles")) {
const link = document.createElement("link");
link.id = "lightbox-styles";
link.rel = "stylesheet";
link.href = "/css/lightbox.css";
document.head.appendChild(link);
}
// Add to document
document.body.appendChild(lightbox);
// Cache DOM elements
this.elements = {
lightbox: lightbox,
lightboxImg: document.getElementById("lightboxImg"),
lightboxCaption: document.getElementById("lightboxCaption"),
lightboxClose: document.getElementById("lightboxClose"),
zoomSlider: document.getElementById("zoomSlider"),
zoomValue: document.getElementById("zoomValue")
};
// Initialize event handlers inside the lightbox
this._initLightboxHandlers();
},
// Initialize listeners for images that should open the lightbox
_initImageListeners: function() {
const images = document.querySelectorAll(this.options.selector);
const self = this;
images.forEach(img => {
// *** FIX: Skip the actual lightbox image itself ***
if (img.id === "lightboxImg") return;
// Skip if already initialized
if (img.dataset.lightboxInitialized) return;
img.dataset.lightboxInitialized = "true";
img.style.cursor = "pointer";
img.addEventListener("click", function() {
let caption = this.getAttribute(self.options.captionAttribute);
if (this.id === "flowDiagram" || !caption) {
caption = "Basic POW Flow Diagram";
}
self.open(this, caption);
});
});
// Special handling for flowDiagram if not caught by selector
const flowDiagram = document.getElementById("flowDiagram");
if (flowDiagram && !flowDiagram.dataset.lightboxInitialized) {
flowDiagram.dataset.lightboxInitialized = "true";
flowDiagram.style.cursor = "pointer";
flowDiagram.addEventListener("click", function() {
self.open(this, "Basic POW Flow Diagram");
});
}
},
// Initialize lightbox event handlers for zooming, closing, etc.
_initLightboxHandlers: function() {
const self = this;
const elements = this.elements;
let isDragging = false;
let startX, startY, startPanX, startPanY;
let panX = 0,
panY = 0;
// Add zoom slider handler
if (this.options.zoomable && elements.zoomSlider) {
elements.zoomSlider.addEventListener("input", function() {
const value = this.value;
elements.zoomValue.textContent = value + "%";
updateTransform();
});
}
// Add close button handler
if (elements.lightboxClose) {
elements.lightboxClose.addEventListener("click", function(e) {
e.preventDefault();
e.stopPropagation();
self.close();
});
}
// Add outside click handler
if (this.options.closeOnOutsideClick) {
elements.lightbox.addEventListener("click", function(e) {
if (e.target === elements.lightbox) {
self.close();
}
});
}
// Add escape key handler
if (this.options.closeOnEsc) {
document.addEventListener("keydown", function(e) {
if (e.key === "Escape" && elements.lightbox.classList.contains("active")) {
self.close();
}
});
}
// Add drag handlers for panning when zoomed
if (elements.lightboxImg) {
elements.lightboxImg.addEventListener("mousedown", startDrag);
elements.lightboxImg.addEventListener("touchstart", startDrag);
}
function startDrag(e) {
// Only allow dragging when zoomed in
if (!self.options.zoomable || parseInt(elements.zoomSlider.value) <= 100)
return;
e.preventDefault();
if (e.type === "touchstart") {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
} else {
startX = e.clientX;
startY = e.clientY;
}
startPanX = panX;
startPanY = panY;
isDragging = true;
elements.lightboxImg.classList.add("grabbing");
document.addEventListener("mousemove", doDrag);
document.addEventListener("touchmove", doDrag);
document.addEventListener("mouseup", stopDrag);
document.addEventListener("touchend", stopDrag);
document.addEventListener("mouseleave", stopDrag);
}
function doDrag(e) {
if (!isDragging) return;
// Prevent default scroll/zoom behavior on touch devices
e.preventDefault();
let clientX, clientY;
if (e.type === "touchmove") {
// Ensure there's a touch point
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const deltaX = clientX - startX;
const deltaY = clientY - startY;
panX = startPanX + deltaX;
panY = startPanY + deltaY;
// Apply the transform immediately for live dragging
updateTransform();
}
function stopDrag() {
if (!isDragging) return;
isDragging = false;
elements.lightboxImg.classList.remove("grabbing");
document.removeEventListener("mousemove", doDrag);
document.removeEventListener("touchmove", doDrag);
document.removeEventListener("mouseup", stopDrag);
document.removeEventListener("touchend", stopDrag);
document.removeEventListener("mouseleave", stopDrag);
}
function updateTransform() {
if (!self.options.zoomable) return;
const scale = parseInt(elements.zoomSlider.value) / 100;
elements.lightboxImg.style.transform = `scale(${scale}) translate(${
panX / scale
}px, ${panY / scale}px)`;
}
// Prevent scrolling on mobile when interacting with the lightbox
const isMobile = window.matchMedia(
"(max-width: 768px), (max-width: 1024px) and (orientation: landscape)"
).matches;
if (isMobile && elements.lightboxImg) {
elements.lightboxImg.addEventListener("touchmove", function(e) {
if (e.touches.length > 1) {
e.preventDefault();
}
});
}
},
// Open the lightbox with a specific image
open: async function(imageElement, caption) {
if (!imageElement || !this.elements) return;
const elements = this.elements;
let panX = 0, panY = 0;
// Remove any previous SVG
if (elements.lightboxImg && elements.lightboxImg.parentNode) {
elements.lightboxImg.style.display = '';
const prevSvg = elements.lightboxImg.parentNode.querySelector('svg.injected-svg');
if (prevSvg) prevSvg.remove();
}
const src = imageElement.src || imageElement.getAttribute("data-fullsize") || "";
const isSVG = src.toLowerCase().endsWith('.svg');
// Helper for zoom slider value
function getZoom() {
return elements.zoomSlider ? parseInt(elements.zoomSlider.value) / 100 : 1;
}
// Helper to update SVG transform
function updateSVGTransform(svg, svgPanX, svgPanY, scale) {
svg.style.transform = `scale(${scale}) translate(${svgPanX/scale}px, ${svgPanY/scale}px)`;
}
if (isSVG) {
elements.lightboxImg.style.display = 'none';
try {
const resp = await fetch(src);
let svgText = await resp.text();
const tempDiv = document.createElement('div');
tempDiv.innerHTML = svgText;
const svg = tempDiv.querySelector('svg');
if (svg) {
svg.classList.add('injected-svg');
svg.style.transformOrigin = 'center center';
svg.style.maxWidth = '100%';
svg.style.maxHeight = '100%';
svg.style.display = 'block';
svg.style.cursor = 'grab';
svg.style.userSelect = 'none';
svg.removeAttribute('width');
svg.removeAttribute('height');
elements.lightboxImg.parentNode.appendChild(svg);
// Set default zoom to 1.0x (100%)
if (elements.zoomSlider) {
elements.zoomSlider.value = 100;
elements.zoomValue.textContent = '100%';
}
let svgPanX = 0, svgPanY = 0;
let isDragging = false, startX, startY, startPanX = 0, startPanY = 0;
let currentScale = getZoom();
updateSVGTransform(svg, svgPanX, svgPanY, currentScale);
// Drag logic for SVG
svg.addEventListener('mousedown', startDrag);
svg.addEventListener('touchstart', startDrag);
function startDrag(e) {
e.preventDefault();
isDragging = true;
svg.classList.add('grabbing');
if (e.type === 'touchstart') {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
} else {
startX = e.clientX;
startY = e.clientY;
}
startPanX = svgPanX;
startPanY = svgPanY;
document.addEventListener('mousemove', doDrag);
document.addEventListener('touchmove', doDrag);
document.addEventListener('mouseup', stopDrag);
document.addEventListener('touchend', stopDrag);
document.addEventListener('mouseleave', stopDrag);
}
function doDrag(e) {
if (!isDragging) return;
e.preventDefault();
let clientX, clientY;
if (e.type === 'touchmove') {
if (e.touches.length === 0) return;
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const deltaX = clientX - startX;
const deltaY = clientY - startY;
svgPanX = startPanX + deltaX;
svgPanY = startPanY + deltaY;
updateSVGTransform(svg, svgPanX, svgPanY, getZoom());
}
function stopDrag() {
if (!isDragging) return;
isDragging = false;
svg.classList.remove('grabbing');
document.removeEventListener('mousemove', doDrag);
document.removeEventListener('touchmove', doDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('touchend', stopDrag);
document.removeEventListener('mouseleave', stopDrag);
}
// Zoom slider controls SVG scale
if (elements.zoomSlider) {
elements.zoomSlider.oninput = function() {
currentScale = getZoom();
elements.zoomValue.textContent = Math.round(currentScale * 100) + '%';
updateSVGTransform(svg, svgPanX, svgPanY, currentScale);
};
}
}
} catch (e) {
elements.lightboxImg.style.display = '';
}
} else {
elements.lightboxImg.src = src;
elements.lightboxImg.style.display = '';
elements.lightboxImg.style.transform = "scale(1) translate(0px, 0px)";
if (this.options.zoomable && elements.zoomSlider) {
elements.zoomSlider.value = 100;
elements.zoomValue.textContent = "100%";
}
}
const captionText =
caption ||
imageElement.getAttribute(this.options.captionAttribute) ||
imageElement.alt ||
imageElement.getAttribute("title") ||
"";
elements.lightboxCaption.textContent = captionText;
elements.lightbox.classList.add("active");
document.body.style.overflow = "hidden";
},
// Close the lightbox
close: function() {
if (!this.elements) return;
this.elements.lightbox.classList.remove("active");
document.body.style.overflow = "";
}
};
// Auto-initialize on load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function() {
window.EasyLightbox.init();
});
} else {
window.EasyLightbox.init();
}
})();

949
develop/js/lv.js Normal file
View file

@ -0,0 +1,949 @@
// Shared resources
const THUMBNAIL_CACHE = new Map();
const THUMBNAIL_REGISTRY = new Map();
const VIDEO_SERVICES = new Map();
// Common constants
const DEFAULT_ALLOW = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share";
const DEFAULT_SANDBOX = "allow-scripts allow-same-origin allow-popups allow-forms allow-presentation";
// Efficient image checking with modern fetch API
async function checkImage(url) {
if (THUMBNAIL_CACHE.has(url)) return THUMBNAIL_CACHE.get(url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(url, { method: 'HEAD', signal: controller.signal });
clearTimeout(timeoutId);
const valid = response.ok;
THUMBNAIL_CACHE.set(url, valid);
return valid;
} catch {
clearTimeout(timeoutId);
THUMBNAIL_CACHE.set(url, false);
return false;
}
}
// Helper for parsing URLs safely
function parseUrl(url) {
try { return new URL(url); } catch { return null; }
}
/**
* Service Provider base class - each video service extends this
*/
class VideoServiceProvider {
constructor() {
this.name = 'generic';
}
canHandle(url) { return false; }
getVideoId(url) { return null; }
getEmbedUrl(videoId, params, element) { return ''; }
getThumbnailUrls(videoId, quality, element) {
const customThumbnail = element.getAttribute("thumbnail");
return customThumbnail ? [customThumbnail] : [];
}
parseParams() { return {}; }
getIframeAttributes(element) {
return {
frameborder: element.getAttribute("frameborder") || "0",
allow: element.getAttribute("allow") || DEFAULT_ALLOW,
sandbox: element.getAttribute("sandbox") || DEFAULT_SANDBOX
};
}
getDefaults() { return { autoload: false }; }
}
/**
* YouTube service provider
*/
class YouTubeProvider extends VideoServiceProvider {
constructor() {
super();
this.name = 'youtube';
this.THUMBNAIL_QUALITIES = {
maxres: 'maxresdefault.jpg',
sd: 'sddefault.jpg',
hq: 'hqdefault.jpg',
mq: 'mqdefault.jpg',
default: 'default.jpg'
};
this.URL_PATTERNS = [
/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
/youtu\.be\/([a-zA-Z0-9_-]{11})/
];
}
canHandle(url) {
return url && /youtube\.com|youtu\.be/.test(url);
}
getVideoId(url) {
if (!url) return null;
const parsedUrl = parseUrl(url);
if (parsedUrl) {
// Path-based ID extraction (/embed/ID or youtu.be/ID)
if (parsedUrl.pathname.startsWith("/embed/") || parsedUrl.hostname === "youtu.be") {
const parts = parsedUrl.pathname.split("/");
return parts[parts.length > 2 ? 2 : 1];
}
// Query-based ID extraction (?v=ID)
const videoId = parsedUrl.searchParams.get("v");
if (videoId) return videoId;
}
// Fallback to regex matching
for (const pattern of this.URL_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) return match[1];
}
return null;
}
getEmbedUrl(videoId, params = {}, element) {
// Determine domain based on cookie preference
const useNoCookie = element.getAttribute("no-cookie") !== "false";
const domain = useNoCookie ? "youtube-nocookie.com" : "youtube.com";
// Build URL with parameters
let url = `https://www.${domain}/embed/${videoId}?autoplay=1`;
// Add parameters
for (const [key, value] of Object.entries(params)) {
if (key !== 'autoplay' && key && value) {
url += `&${key}=${encodeURIComponent(value)}`;
}
}
return url;
}
getThumbnailUrls(videoId, quality, element) {
// Check for custom thumbnail first
const customThumbnail = element.getAttribute("thumbnail");
if (customThumbnail) return [customThumbnail];
const baseUrl = `https://img.youtube.com/vi/${videoId}`;
const urls = [];
// Choose quality based on device and user preference
if (quality && this.THUMBNAIL_QUALITIES[quality]) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES[quality]}`);
} else if (window.matchMedia("(max-width: 767px)").matches) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`);
} else {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.maxres}`);
}
// Only add fallbacks if they're different from what we already have
if (!urls.includes(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`)) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`);
}
if (!urls.includes(`${baseUrl}/${this.THUMBNAIL_QUALITIES.default}`)) {
urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.default}`);
}
return urls;
}
parseParams(url) {
const params = {};
const parsedUrl = parseUrl(url);
if (!parsedUrl) return params;
// Extract parameters from URL
for (const [key, value] of parsedUrl.searchParams.entries()) {
params[key] = value;
}
// Handle YouTube-specific parameters
if (params.t || params.start) params.start = params.t || params.start;
if (params.list) params.playlist = params.list;
return params;
}
}
/**
* Bitchute service provider
*/
class BitchuteProvider extends VideoServiceProvider {
constructor() {
super();
this.name = 'bitchute';
this.URL_PATTERNS = [
/bitchute\.com\/video\/([a-zA-Z0-9_-]+)/,
/bitchute\.com\/embed\/([a-zA-Z0-9_-]+)/
];
}
canHandle(url) {
return url && /bitchute\.com/.test(url);
}
getVideoId(url) {
if (!url) return null;
const parsedUrl = parseUrl(url);
if (parsedUrl) {
// Extract from path segments
const segments = parsedUrl.pathname.split('/').filter(Boolean);
for (let i = 0; i < segments.length - 1; i++) {
if ((segments[i] === "embed" || segments[i] === "video") && i + 1 < segments.length) {
return segments[i + 1];
}
}
}
// Fallback to regex matching
for (const pattern of this.URL_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) return match[1];
}
return null;
}
getEmbedUrl(videoId) {
return `https://www.bitchute.com/embed/${videoId}/`;
}
// Use parent class implementations for other methods
getDefaults() {
return { autoload: true };
}
}
// Register service providers
VIDEO_SERVICES.set('youtube', new YouTubeProvider());
VIDEO_SERVICES.set('bitchute', new BitchuteProvider());
class LazyVideo extends HTMLElement {
// Observable attributes
static get observedAttributes() {
return [
"src", "title", "width", "height", "thumbnail-quality",
"no-cookie", "autoload", "frameborder", "allow", "loading",
"hide-title", "thumbnail", "service", "align", "container-fit"
];
}
// CSS styles definition
static get styles() {
return `
:host {
--lv-aspect-ratio: 16 / 9;
display: var(--lv-display, block);
position: var(--lv-position, relative);
width: var(--lv-width, 100%);
max-width: var(--lv-max-width, 560px);
aspect-ratio: var(--lv-aspect-ratio);
background: var(--lv-background, #000);
overflow: var(--lv-overflow, hidden);
border-radius: var(--lv-border-radius, 0);
margin: var(--lv-margin, 0 auto);
}
:host([container-fit]) {
max-width: 100% !important;
max-height: auto !important;
width: 100%;
margin: 0;
}
/* Alignment control through attribute */
:host([align="left"]) { margin: var(--lv-margin-left, 0); }
:host([align="right"]) { margin: var(--lv-margin-right, 0 0 0 auto); }
:host([align="center"]) { margin: var(--lv-margin-center, 0 auto); }
/* Alignment classes for CSS variable-based alignment */
:host(.lv-align-left) { margin: var(--lv-margin-left, 0); }
:host(.lv-align-right) { margin: var(--lv-margin-right, 0 0 0 auto); }
:host(.lv-align-center) { margin: var(--lv-margin-center, 0 auto); }
:host([hide-title]), :host(:where(:not([hide-title]))) {
--lv-show-title: var(--lv-show-title, 1);
}
:host([hide-title]) [part="title-bar"] {
display: none;
}
:host([style*="height"]) { aspect-ratio: auto; }
[part="placeholder"], [part="iframe"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
[part="placeholder"] {
cursor: pointer;
background: var(--lv-placeholder-bg, #000);
}
[part="placeholder"]:focus {
outline: var(--lv-focus-outline, 2px solid #4285F4);
outline-offset: var(--lv-focus-outline-offset, 2px);
}
[part="thumbnail"] {
width: 100%;
height: 100%;
object-fit: var(--lv-thumbnail-object-fit, cover);
opacity: var(--lv-thumbnail-opacity, 0.85);
}
[part="placeholder"]:hover [part="thumbnail"],
[part="placeholder"]:focus [part="thumbnail"] {
opacity: var(--lv-thumbnail-hover-opacity, 1);
}
[part="title-bar"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
padding: var(--lv-title-padding, 10px 12px);
background: var(--lv-title-bg, rgba(0, 0, 0, 0.75));
color: var(--lv-title-color, white);
font-family: var(--lv-title-font-family, Roboto, Arial, sans-serif);
font-size: var(--lv-title-font-size, 18px);
font-weight: var(--lv-title-font-weight, 500);
line-height: var(--lv-title-line-height, 1.2);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
z-index: 2;
box-sizing: border-box;
display: var(--lv-show-title, block);
}
[part="play-button"] {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--lv-play-button-width, 68px);
height: var(--lv-play-button-height, 48px);
background: var(--lv-play-button-bg, rgba(33, 33, 33, 0.8));
border-radius: var(--lv-play-button-radius, 8px);
}
[part="play-button"]::after {
content: '';
position: absolute;
top: 50%;
left: 55%;
transform: translate(-50%, -50%);
border-style: solid;
border-width: var(--lv-play-button-arrow-size, 12px 0 12px 20px);
border-color: transparent transparent transparent var(--lv-play-button-color, rgba(255, 255, 255, 0.9));
}
[part="placeholder"]:hover [part="play-button"] {
background: var(--lv-play-button-bg-hover, rgba(230, 33, 23, 1));
}
[part="timestamp"] {
position: absolute;
right: var(--lv-timestamp-right, 10px);
bottom: var(--lv-timestamp-bottom, 10px);
background: var(--lv-timestamp-bg, rgba(0, 0, 0, 0.7));
color: var(--lv-timestamp-color, white);
padding: var(--lv-timestamp-padding, 2px 6px);
border-radius: var(--lv-timestamp-radius, 3px);
font-size: var(--lv-timestamp-font-size, 12px);
font-family: var(--lv-timestamp-font-family, system-ui, sans-serif);
}
[part="iframe"] {
opacity: 0;
animation: fadeIn 0.3s ease forwards;
}
@keyframes fadeIn { to { opacity: 1; } }
[part="loading"], [part="fallback-thumbnail"] {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
}
[part="loading"] {
background: var(--lv-loading-bg, rgba(0,0,0,0.7));
color: var(--lv-loading-color, white);
font-family: var(--lv-loading-font-family, system-ui, sans-serif);
}
[part="fallback-thumbnail"] {
background: var(--lv-fallback-bg, #1a1a1a);
color: var(--lv-fallback-color, white);
font-family: var(--lv-fallback-font-family, system-ui, sans-serif);
font-size: var(--lv-fallback-font-size, 14px);
}
`;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this._loaded = false;
this._placeholder = null;
this._observer = null;
this._handlers = new Map();
this._videoService = null;
}
connectedCallback() {
if (!this.isConnected) return;
if (!this._loaded && !this._placeholder) {
this._createPlaceholder();
}
// Setup autoloading if needed
if (this._getServiceOption('autoload')) {
this._setupObserver();
}
// Check for alignment from CSS variables
this._updateAlignmentFromCSS();
// Set up mutation observer for style changes
this._setupStyleObserver();
}
disconnectedCallback() {
this._cleanupObserver();
this._cleanupEventHandlers();
// Clean up style observer
if (this._styleObserver) {
this._styleObserver.disconnect();
this._styleObserver = null;
}
// Cancel any animation frames
if (this._styleFrameId) {
cancelAnimationFrame(this._styleFrameId);
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this.isConnected) return;
switch (name) {
case "src":
if (oldValue !== newValue && newValue !== null) {
this._loaded = false;
this._createPlaceholder();
}
break;
case "width":
case "height":
this._updateStyles();
break;
case "autoload":
newValue === "true" || newValue === "" ? this._setupObserver() : this._cleanupObserver();
break;
case "thumbnail":
if (oldValue !== newValue) {
this._updateThumbnail();
}
break;
case "service":
if (oldValue !== newValue) {
this._loaded = false;
this._createPlaceholder();
}
break;
}
}
_getServiceProvider(url) {
// Check for explicit service attribute first
const serviceName = this.getAttribute("service");
if (serviceName && VIDEO_SERVICES.has(serviceName)) {
return VIDEO_SERVICES.get(serviceName);
}
// Auto-detect from URL
if (url) {
for (const provider of VIDEO_SERVICES.values()) {
if (provider.canHandle(url)) {
return provider;
}
}
}
// Default to YouTube if nothing else matches
return VIDEO_SERVICES.get('youtube');
}
_getServiceOption(option) {
// First check if attribute exists
if (this.hasAttribute(option)) {
const value = this.getAttribute(option);
// Handle boolean attributes
return value === "" || value === "true" || value !== "false";
}
// Then check service defaults
if (this._videoService?.getDefaults()[option] !== undefined) {
return this._videoService.getDefaults()[option];
}
return false;
}
_cleanupObserver() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
_cleanupEventHandlers() {
this._handlers.forEach((handler, key) => {
const [element, event] = key.split('|');
if (element && element.removeEventListener) {
element.removeEventListener(event, handler);
}
});
this._handlers.clear();
}
_setupObserver() {
if (!window.IntersectionObserver) return;
this._cleanupObserver();
this._observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && !this._loaded) {
this._loadVideo();
this._cleanupObserver();
}
}, {
rootMargin: "300px",
threshold: 0.1
});
this._observer.observe(this);
}
_updateThumbnail() {
const img = this._placeholder?.querySelector('[part="thumbnail"]');
if (!img) return;
const customThumbnail = this.getAttribute("thumbnail");
if (customThumbnail) {
img.src = customThumbnail;
// Remove any fallback thumbnail if present
const fallback = this._placeholder.querySelector('[part="fallback-thumbnail"]');
if (fallback) fallback.remove();
return;
}
// Get service thumbnails
const videoId = this._placeholder.dataset.videoId;
if (videoId && this._videoService) {
const thumbnailQuality = this.getAttribute("thumbnail-quality");
const thumbnailUrls = this._videoService.getThumbnailUrls(videoId, thumbnailQuality, this);
if (thumbnailUrls.length > 0) {
this._loadThumbnail(thumbnailUrls, img);
} else {
this._createFallbackThumbnail();
}
}
}
_createFallbackThumbnail() {
if (!this._placeholder || this._placeholder.querySelector('[part="fallback-thumbnail"]')) {
return; // Already exists or no placeholder
}
const fallback = document.createElement('div');
fallback.setAttribute('part', 'fallback-thumbnail');
// Service-specific branding
if (this._videoService) {
const serviceName = this._videoService.name;
fallback.innerHTML = `
<div style="text-align: center;">
<div style="font-size: 18px; margin-bottom: 8px;">${serviceName.charAt(0).toUpperCase() + serviceName.slice(1)}</div>
<div>Click to play video</div>
</div>
`;
} else {
fallback.textContent = 'No thumbnail available';
}
this._placeholder.appendChild(fallback);
}
async _createPlaceholder() {
const src = this.getAttribute("src");
// Determine service provider & video ID
this._videoService = this._getServiceProvider(src);
const videoId = this._videoService?.getVideoId(src);
if (!videoId) {
this.shadowRoot.innerHTML = `
<style>:host{display:block;padding:10px;color:red;background:#222;border-radius:var(--lv-border-radius,0)}</style>
<p>Error: Can't find video ID. Check the 'src' attribute.</p>
`;
return;
}
// Get parameters and create elements
this._videoParams = this._videoService.parseParams(src);
const title = this.getAttribute("title") || "Video";
// Build Shadow DOM
const style = document.createElement("style");
style.textContent = LazyVideo.styles;
const placeholder = this._buildPlaceholder(videoId, title);
this.shadowRoot.innerHTML = '';
this.shadowRoot.append(style, placeholder);
this._updateStyles();
}
_buildPlaceholder(videoId, title) {
// Create placeholder container
const placeholder = document.createElement("div");
placeholder.setAttribute("part", "placeholder");
placeholder.setAttribute("role", "button");
placeholder.setAttribute("aria-label", `Play: ${title}`);
placeholder.setAttribute("tabindex", "0");
placeholder.dataset.videoId = videoId;
placeholder.dataset.service = this._videoService.name;
this._placeholder = placeholder;
// Create thumbnail image
const thumbnailQuality = this.getAttribute("thumbnail-quality");
const thumbnailUrls = this._videoService.getThumbnailUrls(videoId, thumbnailQuality, this);
// Add thumbnail image
const img = document.createElement("img");
img.setAttribute("part", "thumbnail");
img.alt = `Thumbnail for ${title}`;
img.loading = "lazy";
img.decoding = "async";
img.fetchPriority = "low";
img.style.backgroundColor = "#111";
placeholder.appendChild(img);
// Start thumbnail loading process
if (thumbnailUrls.length > 0) {
this._setupThumbnailObserver(img, thumbnailUrls);
} else {
this._createFallbackThumbnail();
}
// Add title bar if not disabled
if (!this.hasAttribute("hide-title")) {
const titleBar = document.createElement("div");
titleBar.setAttribute("part", "title-bar");
titleBar.textContent = title;
placeholder.appendChild(titleBar);
}
// Add play button
const playButton = document.createElement("div");
playButton.setAttribute("part", "play-button");
placeholder.appendChild(playButton);
// Add timestamp if present in params
const startTime = parseInt(this._videoParams.start || this._videoParams.t, 10);
if (!isNaN(startTime) && startTime > 0) {
const timestamp = document.createElement("div");
timestamp.setAttribute("part", "timestamp");
timestamp.textContent = this._formatTime(startTime);
placeholder.appendChild(timestamp);
}
// Set up interaction handlers
const handleInteraction = (e) => {
if (e.type === "click" || e.key === "Enter" || e.key === " ") {
if (e.type !== "click") e.preventDefault();
this._loadVideo();
}
};
placeholder.addEventListener("click", handleInteraction);
placeholder.addEventListener("keydown", handleInteraction);
// Track handlers for cleanup
this._handlers.set(`${placeholder}|click`, handleInteraction);
this._handlers.set(`${placeholder}|keydown`, handleInteraction);
return placeholder;
}
_setupThumbnailObserver(imgElement, urls) {
if (!window.IntersectionObserver) {
this._loadThumbnail(urls, imgElement);
return;
}
this._thumbnailLoadAttempted = false;
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting && !this._thumbnailLoadAttempted) {
this._thumbnailLoadAttempted = true;
try {
await this._loadThumbnail(urls, imgElement);
} catch {
this._thumbnailLoadAttempted = false;
} finally {
observer.disconnect();
}
}
}, {
rootMargin: "300px",
threshold: 0.1
});
observer.observe(imgElement);
}
async _loadThumbnail(urls, imgElement) {
// Custom thumbnails bypass validation
if (urls.length === 1 && urls[0] === this.getAttribute("thumbnail")) {
imgElement.src = urls[0];
return true;
}
// Cache key for shared thumbnails
const videoId = this._placeholder?.dataset?.videoId;
const service = this._placeholder?.dataset?.service;
const cacheKey = videoId && service ? `${service}:${videoId}` : null;
// Try to use cached result
if (cacheKey && THUMBNAIL_REGISTRY.has(cacheKey)) {
try {
const bestUrl = await THUMBNAIL_REGISTRY.get(cacheKey);
if (bestUrl) {
imgElement.src = bestUrl;
return true;
}
} catch {
THUMBNAIL_REGISTRY.delete(cacheKey);
}
}
// Find best thumbnail
let bestUrl = null;
// Try parallel loading first
try {
const results = await Promise.all(
urls.map(url => checkImage(url)
.then(valid => ({ url, valid }))
.catch(() => ({ valid: false }))
)
);
const bestResult = results.find(result => result.valid);
if (bestResult) bestUrl = bestResult.url;
} catch {
// Try sequential loading if parallel fails
for (const url of urls) {
try {
if (await checkImage(url)) {
bestUrl = url;
break;
}
} catch {}
}
}
// Set the best URL or create fallback
if (bestUrl) {
imgElement.src = bestUrl;
if (cacheKey) THUMBNAIL_REGISTRY.set(cacheKey, Promise.resolve(bestUrl));
return true;
} else {
this._createFallbackThumbnail();
if (cacheKey) THUMBNAIL_REGISTRY.set(cacheKey, Promise.resolve(null));
return false;
}
}
_formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0
? `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
: `${minutes}:${secs.toString().padStart(2, "0")}`;
}
_updateStyles() {
const width = this.getAttribute("width");
const height = this.getAttribute("height");
// Helper to check if a value already includes a CSS unit
const hasUnit = (value) => value && /[a-z%$]/.test(value);
if (width) {
this.style.setProperty("width", hasUnit(width) ? width : `${width}px`);
} else {
this.style.removeProperty("width");
}
if (height) {
this.style.setProperty("height", hasUnit(height) ? height : `${height}px`);
} else {
this.style.removeProperty("height");
}
// For aspect ratio, use numeric values from width/height if possible
if (width && height) {
const numericWidth = parseFloat(width);
const numericHeight = parseFloat(height);
if (!isNaN(numericWidth) && !isNaN(numericHeight)) {
this.style.setProperty("--lv-aspect-ratio", `${numericWidth} / ${numericHeight}`);
}
}
}
_loadVideo() {
if (this._loaded || !this._placeholder) return;
// Create loading indicator
const loading = document.createElement("div");
loading.setAttribute("part", "loading");
loading.textContent = "Loading...";
this.shadowRoot.appendChild(loading);
const videoId = this._placeholder.dataset.videoId;
const title = this.getAttribute("title") || "Video";
// Get the service if not already set
if (!this._videoService) {
const serviceName = this._placeholder.dataset.service;
this._videoService = VIDEO_SERVICES.get(serviceName) || VIDEO_SERVICES.get('youtube');
}
// Get embed URL and create iframe
const url = this._videoService.getEmbedUrl(videoId, this._videoParams, this);
// Create iframe
const iframe = document.createElement("iframe");
iframe.setAttribute("part", "iframe");
iframe.loading = "lazy";
iframe.src = url;
iframe.title = title;
// Add credentialless attribute for enhanced security
iframe.setAttribute("credentialless", "");
// Add service-specific attributes
const iframeAttrs = this._videoService.getIframeAttributes(this);
for (const [name, value] of Object.entries(iframeAttrs)) {
iframe.setAttribute(name, value);
}
// Handle loading indicator removal
const loadHandler = () => loading.parentNode?.removeChild(loading);
iframe.addEventListener("load", loadHandler, { once: true });
this._handlers.set(`${iframe}|load`, loadHandler);
// Replace placeholder with iframe
this._placeholder.replaceWith(iframe);
this._loaded = true;
this._placeholder = null;
// Notify that video is loaded
this.dispatchEvent(new CustomEvent("video-loaded", {
bubbles: true,
detail: { videoId, service: this._videoService.name }
}));
}
_setupStyleObserver() {
if (this._styleObserver) return;
// Create a MutationObserver to watch for style attribute changes
this._styleObserver = new MutationObserver(() => {
this._updateAlignmentFromCSS();
});
this._styleObserver.observe(this, {
attributes: true,
attributeFilter: ['style']
});
// Also observe document/body style changes that might affect CSS variables
if (window.getComputedStyle) {
// Use requestAnimationFrame to limit performance impact
let frameId;
const checkStyles = () => {
frameId = requestAnimationFrame(() => {
this._updateAlignmentFromCSS();
frameId = requestAnimationFrame(checkStyles);
});
};
checkStyles();
// Store the frame ID for cleanup
this._styleFrameId = frameId;
}
}
_updateAlignmentFromCSS() {
if (this.hasAttribute('container-fit')) return;
// Get computed style
const computedStyle = window.getComputedStyle(this);
const alignValue = computedStyle.getPropertyValue('--lv-align').trim();
// Remove existing alignment classes
this.classList.remove('lv-align-left', 'lv-align-right', 'lv-align-center');
// Add appropriate class based on the CSS variable
if (alignValue === 'left') {
this.classList.add('lv-align-left');
} else if (alignValue === 'right') {
this.classList.add('lv-align-right');
} else if (alignValue === 'center') {
this.classList.add('lv-align-center');
}
}
}
// Register the component
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => customElements.define("lazy-video", LazyVideo));
} else {
customElements.define("lazy-video", LazyVideo);
}

132
develop/js/u.js Normal file
View file

@ -0,0 +1,132 @@
// Smooth scroll to ID
window.addEventListener("load", function () { setTimeout(() => { if (window.location.hash) { let t = window.location.hash.substring(1), o = document.getElementById(t); o && o.scrollIntoView({ behavior: "smooth", block: "start" }) } }, 135) });
// No card hover on touch
("ontouchstart" in window || navigator.maxTouchPoints > 0) && window.addEventListener("touchstart", function t() { document.body.classList.add("no-hover"), window.removeEventListener("touchstart", t, !1) }, !1);
// Auto-add target="_blank" and secure rel (noopener & noreferrer) to external links,
// except those with the "eel" class
(() => { let e = document.baseURI, t = document.querySelectorAll("a[href]:not(.eel)"), r = window.location.hostname; for (let l = 0, n = t.length; l < n; l++) { let o = t[l]; try { let b = new URL(o.getAttribute("href"), e); if (b.hostname !== r) { "_blank" !== o.getAttribute("target") && o.setAttribute("target", "_blank"); let a = o.getAttribute("rel") || ""; /\bnoopener\b/.test(a) || (a += " noopener"), /\bnoreferrer\b/.test(a) || (a += " noreferrer"), o.setAttribute("rel", a.trim()) } } catch (i) { } } })();
// Switch to JPG for devices that don't support WebP
!async function () { await async function () { return new Promise((function (n) { const e = new Image; e.onload = function () { n(1 === e.width && 1 === e.height) }, e.onerror = function () { n(!1) }, e.src = "data:image/webp;base64,UklGRhYAAABXRUJQVlA4TAoAAAAvAAAAAEX/I/of" })) }() || document.querySelectorAll('img[src$=".webp"]').forEach((function (n) { n.src = n.src.replace(/\.webp$/i, ".jpg") })) }()
// Link redirect animation
document.addEventListener('DOMContentLoaded', function () {
// Create and inject CSS for the animation
const style = document.createElement('style');
style.textContent = `
.link-arrow-container {
position: absolute;
pointer-events: none;
z-index: 9999;
width: 20px;
height: 20px;
right: 0px;
opacity: 0;
transform: translateX(-5px);
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
/* Vertical alignment handled by parent flex settings */
}
.link-arrow-container.animate {
opacity: 1;
transform: translateX(5px);
}
.link-arrow-container svg {
width: 100%;
height: 100%;
fill: currentColor;
display: block;
}
a[href]:not(.no-arrow-padding):not(a[target="_blank"]) {
position: relative;
padding-right: 24px;
display: inline-flex;
align-items: center;
}
`;
document.head.appendChild(style);
// SVG arrow icon data
const svgArrow = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>`;
// Add arrow containers to eligible links on load
document.querySelectorAll('a[href]').forEach(link => {
const href = link.getAttribute('href');
// Skip links that open in new tabs, are anchors, or javascript calls
if (link.getAttribute('target') === '_blank' || href.startsWith('#') || href.startsWith('javascript:')) {
return;
}
// Create and append arrow container
const arrowContainer = document.createElement('div');
arrowContainer.className = 'link-arrow-container';
arrowContainer.innerHTML = svgArrow;
link.appendChild(arrowContainer);
});
// Delegated click listener on the body
document.body.addEventListener('click', function (e) {
// Find the closest ancestor link
const link = e.target.closest('a[href]');
// If no link was clicked, or checks fail, do nothing
if (!link) return;
const href = link.getAttribute('href');
if (link.getAttribute('target') === '_blank' || href.startsWith('#') || href.startsWith('javascript:')) {
return;
}
// Skip if modifier keys are pressed
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
// Find the arrow container within this link
const arrowContainer = link.querySelector('.link-arrow-container');
if (!arrowContainer) return; // Should exist, but safety check
// Prevent default navigation
e.preventDefault();
// Animate the arrow
arrowContainer.classList.add('animate');
// Navigate after a delay
setTimeout(() => {
window.location.href = href;
}, 100);
});
// Reset animation state on page show (handles bfcache)
window.addEventListener('pageshow', function (event) {
if (event.persisted) {
document.querySelectorAll('.link-arrow-container.animate').forEach(arrow => {
arrow.classList.remove('animate');
});
}
});
});
// Quicklink 2.3.0
!function (e, n) { "object" == typeof exports && "undefined" != typeof module ? n(exports) : "function" == typeof define && define.amd ? define(["exports"], n) : n(e.quicklink = {}) }(this, function (e) { function n(e) { return new Promise(function (n, r, t) { (t = new XMLHttpRequest).open("GET", e, t.withCredentials = !0), t.onload = function () { 200 === t.status ? n() : r() }, t.send() }) } var r, t = (r = document.createElement("link")).relList && r.relList.supports && r.relList.supports("prefetch") ? function (e) { return new Promise(function (n, r, t) { (t = document.createElement("link")).rel = "prefetch", t.href = e, t.onload = n, t.onerror = r, document.head.appendChild(t) }) } : n, o = window.requestIdleCallback || function (e) { var n = Date.now(); return setTimeout(function () { e({ didTimeout: !1, timeRemaining: function () { return Math.max(0, 50 - (Date.now() - n)) } }) }, 1) }, i = new Set, c = new Set, u = !1; function a(e) { if (e) { if (e.saveData) return new Error("Save-Data is enabled"); if (/2g/.test(e.effectiveType)) return new Error("network conditions are poor") } return !0 } function s(e, r, o) { var s = a(navigator.connection); return s instanceof Error ? Promise.reject(new Error("Cannot prefetch, " + s.message)) : (c.size > 0 && !u && console.warn("[Warning] You are using both prefetching and prerendering on the same document"), Promise.all([].concat(e).map(function (e) { if (!i.has(e)) return i.add(e), (r ? function (e) { return window.fetch ? fetch(e, { credentials: "include" }) : n(e) } : t)(new URL(e, location.href).toString()) }))) } function f(e, n) { var r = a(navigator.connection); if (r instanceof Error) return Promise.reject(new Error("Cannot prerender, " + r.message)); if (!HTMLScriptElement.supports("speculationrules")) return s(e), Promise.reject(new Error("This browser does not support the speculation rules API. Falling back to prefetch.")); if (document.querySelector('script[type="speculationrules"]')) return Promise.reject(new Error("Speculation Rules is already defined and cannot be altered.")); for (var t = 0, o = [].concat(e); t < o.length; t += 1) { var f = o[t]; if (window.location.origin !== new URL(f, window.location.href).origin) return Promise.reject(new Error("Only same origin URLs are allowed: " + f)); c.add(f) } i.size > 0 && !u && console.warn("[Warning] You are using both prefetching and prerendering on the same document"); var l = function (e) { var n = document.createElement("script"); n.type = "speculationrules", n.text = '{"prerender":[{"source": "list","urls": ["' + Array.from(e).join('","') + '"]}]}'; try { document.head.appendChild(n) } catch (e) { return e } return !0 }(c); return !0 === l ? Promise.resolve() : Promise.reject(l) } e.listen = function (e) { if (e || (e = {}), window.IntersectionObserver) { var n = function (e) { e = e || 1; var n = [], r = 0; function t() { r < e && n.length > 0 && (n.shift()(), r++) } return [function (e) { n.push(e) > 1 || t() }, function () { r--, t() }] }(e.throttle || 1 / 0), r = n[0], t = n[1], a = e.limit || 1 / 0, l = e.origins || [location.hostname], d = e.ignores || [], h = e.delay || 0, p = [], m = e.timeoutFn || o, w = "function" == typeof e.hrefFn && e.hrefFn, g = e.prerender || !1; u = e.prerenderAndPrefetch || !1; var v = new IntersectionObserver(function (n) { n.forEach(function (n) { if (n.isIntersecting) p.push((n = n.target).href), function (e, n) { n ? setTimeout(e, n) : e() }(function () { -1 !== p.indexOf(n.href) && (v.unobserve(n), (u || g) && c.size < 1 ? f(w ? w(n) : n.href).catch(function (n) { if (!e.onError) throw n; e.onError(n) }) : i.size < a && !g && r(function () { s(w ? w(n) : n.href, e.priority).then(t).catch(function (n) { t(), e.onError && e.onError(n) }) })) }, h); else { var o = p.indexOf((n = n.target).href); o > -1 && p.splice(o) } }) }, { threshold: e.threshold || 0 }); return m(function () { (e.el || document).querySelectorAll("a").forEach(function (e) { l.length && !l.includes(e.hostname) || function e(n, r) { return Array.isArray(r) ? r.some(function (r) { return e(n, r) }) : (r.test || r).call(r, n.href, n) }(e, d) || v.observe(e) }) }, { timeout: e.timeout || 2e3 }), function () { i.clear(), v.disconnect() } } }, e.prefetch = s, e.prerender = f });
quicklink.listen({
origins: [],
ignores: [
// Don't prefetch URL fragments from my own site
uri => uri.includes('caileb.com') && uri.includes('#'),
// Don't prefetch hosted services
uri => uri.includes('gallery.caileb.com'),
uri => uri.includes('jellyfin.caileb.com'),
uri => uri.includes('archive.caileb.com'),
uri => uri.includes('music.caileb.com'),
// Don't prefetch API's
/\/api\/?/,
/^api\./,
// Don't prefetch these file types
uri => /\.(zip|tar|7z|rar|js|apk|xapk|woff2|tff|otf|pdf|mp3|mp4|wav|exe|msi|bat|deb|rpm|bin|dmg|iso|csv|log|sql|xml|key|odp|ods|pps|ppt|xls|doc|jpg|jpeg|jpe|jif|jfif|jfi|png|gif|webp|tif|psd|raw|arw|cr2|nrw|k25|bmp|dib|heif|heic|ind|indd|indt|jp2|j2k|jpf|jpx|jpm|mj2|svg|ai|eps)$/i.test(uri),
// Don't prefetch these protocols
uri => /^(http|file|ftp|mailto|tel):/i.test(uri),
]
});

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Blocked</title>
<h2>Connections from within Datacenter IP ranges are blocked due to spam</h2>
<p><strong>Detected as: {{.ASNName}}</strong></p>
</div>
</body>
</html>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Blocked</title>
<h1>Access Blocked</h1>
</body>
</html>

22
develop/static/error.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Error</title>
<meta name=description content="Error page.">
<link rel=icon href=/images/favi.svg type=image/svg+xml>
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel="stylesheet" href="/css/u.css">
</head>
<body>
<h1 style="text-align:center">Something appears to have gone wrong.</h1>
</body>
</html>

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Access Restricted</title>
<img src="/images/india-block.jpg" alt="Access Restricted">
</div>
</body>
</html>

View file

@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Security Checkpoint</title>
<meta name=description content="Security checkpoint. Prove you're not a robot.">
<link rel=icon href=/images/favi.svg type=image/svg+xml>
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel="stylesheet" href="/css/u.css">
<script defer src="/js/c.js"></script>
<style>
:root {
--background-color: #1a1a1a;
--overlay-bg: rgba(28, 28, 28, 0.95);
--text-color: #fff;
--subtext-color: #ccc;
--accent-color: #9B59B6;
--success-color: #4CAF50;
--error-color: #F44336;
}
/* Prevent scrolling and hide scrollbars on all browsers */
html, body {
background-color: #1a1a1a; background-image: radial-gradient(circle at top right, rgba(155, 89, 182, 0.1), transparent 70%),
linear-gradient(135deg, #121212, #1a1a1a);
overflow: hidden;
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
/* Hide scrollbars for Webkit/Blink browsers */
::-webkit-scrollbar {
display: none;
}
/* Hide scrollbars for Firefox */
* {
scrollbar-width: none;
}
/* Hide scrollbars for IE/Edge */
* {
-ms-overflow-style: none;
}
body {
color: var(--text-color);
line-height: 1.6;
text-align: center;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.container {
background: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(8px);
border-radius: 20px;
padding: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3),
0 1px 2px rgba(155, 89, 182, 0.2),
0 0 0 1px rgba(155, 89, 182, 0.2),
0 0 15px rgba(155, 89, 182, 0.15);
max-width: 555px;
width: 100%;
animation: floatIn 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
position: relative;
overflow: hidden;
}
h1 {
color: var(--accent-color);
margin-bottom: 1.25rem;
font-size: clamp(1.75rem, 5vw, 2.5rem);
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
p {
margin-bottom: 1.75rem;
color: var(--subtext-color);
font-size: clamp(1rem, 2.5vw, 1.1rem);
}
/* Spinner container */
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 20px 0;
height: clamp(48px, 12vw, 56px); /* Fixed height matching spinner */
transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
will-change: transform, opacity;
position: relative;
}
/* Spinner */
.spinner {
border: 3px solid rgba(155, 89, 182, 0.08);
border-radius: 50%;
border-top: 3px solid var(--accent-color);
width: clamp(48px, 12vw, 56px);
height: clamp(48px, 12vw, 56px);
animation: spin 1s linear infinite, pulse 2s ease-in-out infinite;
margin: 0 auto;
box-shadow: 0 0 15px rgba(155, 89, 182, 0.3);
transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1);
will-change: transform, box-shadow;
position: relative;
}
.spinner::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(155, 89, 182, 0.2) 0%, transparent 60%);
opacity: 0.3;
filter: blur(4px);
z-index: -1;
}
/* Status text styles */
.status {
display: none;
font-weight: 600;
font-size: clamp(1.5rem, 4vw, 2rem);
margin: 0;
min-height: 1.5em;
text-align: center;
opacity: 0;
}
/* ---------- Success state ---------- */
.success .spinner-container {
flex-direction: row;
justify-content: center;
align-items: center;
max-width: none;
width: 100%;
margin: 1.5rem auto;
gap: 12px;
animation: container-animate 0.3s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
}
.success .spinner-container .error-details {
display: none;
}
.success .spinner {
border-color: transparent;
animation: none;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(76, 175, 80, 0.05);
box-shadow: 0 0 20px rgba(76, 175, 80, 0.4);
width: clamp(48px, 12vw, 56px);
height: clamp(48px, 12vw, 56px);
margin: 0;
animation: ui-animate 0.35s forwards cubic-bezier(0.25, 0.1, 0.25, 1), float-animate 3s ease-in-out infinite 0.4s;
}
.success .spinner::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle, rgba(76, 175, 80, 0.05) 0%, rgba(76, 175, 80, 0.1) 100%);
box-shadow: inset 0 0 0 3px rgba(76, 175, 80, 0);
animation: circle-animate 0.35s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
opacity: 0;
inset: 0;
will-change: opacity, transform, box-shadow;
}
.success .spinner::after {
content: '';
width: clamp(22px, 6vw, 28px);
height: clamp(22px, 6vw, 28px);
display: block;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234CAF50'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z'/%3E%3C/svg%3E");
background-size: contain;
animation: checkmark-animate 0.35s cubic-bezier(0.25, 0.1, 0.25, 1) forwards 0.05s;
opacity: 0;
transform: scale(0);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
will-change: transform, opacity;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
}
.success .status {
display: inline-block;
color: var(--success-color);
animation: text-animate 0.4s forwards cubic-bezier(0.25, 0.1, 0.25, 1);
animation-delay: 0.15s;
letter-spacing: 0.3px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
opacity: 0;
transform: translateX(-15px);
white-space: nowrap;
}
.success .status::after {
content: ""; display: inline-block; width: 24px; text-align: left; animation: ellipsis-animate 0.88s infinite cubic-bezier(0.25, 0.1, 0.25, 1);
}
.error .status {
display: block;
color: var(--error-color);
margin-bottom: 0.5rem;
}
.error .spinner {
border-color: transparent;
animation: none;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(244, 67, 54, 0.05);
box-shadow: 0 0 20px rgba(244, 67, 54, 0.4);
width: clamp(48px, 12vw, 56px);
height: clamp(48px, 12vw, 56px);
margin: 0;
animation: ui-animate 0.35s forwards cubic-bezier(0.25, 0.1, 0.25, 1), float-animate 3s ease-in-out infinite 0.4s;
}
.error .spinner::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle, rgba(244, 67, 54, 0.05) 0%, rgba(244, 67, 54, 0.1) 100%);
box-shadow: inset 0 0 0 3px rgba(244, 67, 54, 0);
animation: error-circle-animate 0.35s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
opacity: 0;
inset: 0;
will-change: opacity, transform, box-shadow;
}
.error .spinner::after {
content: '';
width: clamp(22px, 6vw, 28px);
height: clamp(22px, 6vw, 28px);
display: block;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke='%23F44336' stroke-width='3' fill='none'%3E%3Cpath stroke-linecap='round' d='M6 6 L18 18 M18 6 L6 18'/%3E%3C/svg%3E");
background-size: contain;
animation: checkmark-animate 0.35s cubic-bezier(0.25, 0.1, 0.25, 1) forwards 0.05s;
opacity: 0;
transform: scale(0);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
will-change: transform, opacity;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
}
.error-details {
margin: 10px auto 0;
padding: 0.75rem 1.25rem;
background-color: rgba(244, 67, 54, 0.08);
border-left: 3px solid var(--error-color);
color: var(--subtext-color);
font-size: 0.9rem;
text-align: center;
border-radius: 4px;
width: 90%;
line-height: 1.5;
animation: fadeIn 0.3s ease-in-out forwards;
}
.error-x-icon {
width: clamp(22px, 6vw, 28px);
height: clamp(22px, 6vw, 28px);
fill: none;
stroke: var(--error-color);
stroke-width: 2;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
overflow: visible;
box-sizing: border-box;
padding: 0;
animation: fadeInScale 0.5s ease-in-out forwards;
}
/* ---------- Animations ---------- */
@keyframes floatIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 15px rgba(155, 89, 182, 0.3); }
50% { box-shadow: 0 0 25px rgba(155, 89, 182, 0.5); }
}
@keyframes container-animate {
0% { opacity: 0.95; }
100% { opacity: 1; }
}
@keyframes ui-animate {
0% { transform: translate(60px, 0) scale(0.9); opacity: 0; }
40% { opacity: 1; }
100% { transform: translate(0, 0) scale(1); opacity: 1; }
}
@keyframes float-animate {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes circle-animate {
0% { opacity: 0; transform: scale(0.5); box-shadow: inset 0 0 0 3px rgba(76, 175, 80, 0); }
60% { opacity: 1; transform: scale(1.1); box-shadow: inset 0 0 0 3px rgba(76, 175, 80, 0.9); }
100% { opacity: 1; transform: scale(1); box-shadow: inset 0 0 0 3px var(--success-color); }
}
@keyframes checkmark-animate {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0); }
60% { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@keyframes text-animate {
0% { opacity: 0; transform: translateX(-15px); }
70% { opacity: 1; transform: translateX(1px); }
100% { opacity: 1; transform: translateX(0); }
}
@keyframes ellipsis-animate {
0%, 5% { content: ""; }
20%, 30% { content: "."; }
45%, 55% { content: ".."; }
70%, 80% { content: "..."; }
95%, 100% { content: ""; }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.5); }
to { opacity: 1; transform: scale(1); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes error-circle-animate {
0% { opacity: 0; transform: scale(0.5); box-shadow: inset 0 0 0 3px rgba(244, 67, 54, 0); }
60% { opacity: 1; transform: scale(1.1); box-shadow: inset 0 0 0 3px rgba(244, 67, 54, 0.9); }
100% { opacity: 1; transform: scale(1); box-shadow: inset 0 0 0 3px var(--error-color); }
}
/* Responsive adjustments */
@media (max-width: 480px) {
.container {
padding: 20px;
border-radius: 16px;
}
h1 { font-size: 1.5rem; }
}
/* Replace existing Error state adjustments with these simpler rules */
.error .spinner-container,
.success .spinner-container {
height: 56px;
margin: 20px 0;
}
/* GPU Acceleration for smoother animations */
.container,
.spinner,
.spinner-container,
.success .spinner,
.success .status {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
</style>
</head>
<body>
<div id="overlay">
<div class="container">
<h1>Security Checkpoint</h1>
<p>Verifying your browser to protect from automated abuse. This may take a few seconds...</p>
<div class="spinner-container">
<div class="spinner"></div>
<div id="status" class="status">Redirecting</div>
</div>
</div>
</div>
<!-- Store only target path and a request token for security -->
<div id="verification-data" data-target="{{.TargetPath}}" data-request-id="{{.RequestID}}"></div>
</body>
</html>

44
go.mod Normal file
View file

@ -0,0 +1,44 @@
module caileb
go 1.24.1
require (
github.com/BurntSushi/toml v0.4.1
github.com/andybalholm/brotli v1.1.1
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396
github.com/dgraph-io/badger/v4 v4.7.0
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/template/html/v2 v2.1.3
github.com/mileusna/useragent v1.3.5
github.com/oschwald/geoip2-golang v1.11.0
github.com/tdewolff/minify/v2 v2.22.4
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tdewolff/parse/v2 v2.7.21 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

90
go.sum Normal file
View file

@ -0,0 +1,90 @@
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396 h1:W2HK1IdCnCGuLUeyizSCkwvBjdj0ZL7mxnJYQ3poyzI=
github.com/cloudflare/ahocorasick v0.0.0-20240916140611-054963ec9396/go.mod h1:tGWUZLZp9ajsxUOnHmFFLnqnlKXsCn6GReG4jAD59H0=
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/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y=
github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.22.4 h1:0/8K2fheOuYr5B4e5oCE1hGBVX6DQHLP0EGzdsDlYeg=
github.com/tdewolff/minify/v2 v2.22.4/go.mod h1:K/R8TT7aivpcU8QCNUU1UdR6etfnFPr7L11TO/X7shk=
github.com/tdewolff/parse/v2 v2.7.21 h1:OCuPFtGr4mXdnfKikQlUb0n654ROJANhBqCk+wioJ/A=
github.com/tdewolff/parse/v2 v2.7.21/go.mod h1:I7TXO37t3aSG9SlPUBefAhgIF8nt7yYUwVGgETIoBcA=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

604
main.go Normal file
View file

@ -0,0 +1,604 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"caileb/middleware"
"caileb/utils"
"github.com/andybalholm/brotli"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/template/html/v2"
)
// getEnvInt tries to read an integer from the environment.
// If it's missing or invalid, it returns the fallback.
func getEnvInt(key string, fallback int) int {
if value, exists := os.LookupEnv(key); exists {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return fallback
}
// getEnvBool reads a boolean from the environment.
// Returns fallback if missing; accepts "true" or "1" as true.
func getEnvBool(key string, fallback bool) bool {
if value, exists := os.LookupEnv(key); exists {
return strings.ToLower(value) == "true" || value == "1"
}
return fallback
}
// validatePathParam rejects path params with unsafe characters (../, slashes, etc.).
func validatePathParam(paramName string) fiber.Handler {
return func(c *fiber.Ctx) error {
param := c.Params(paramName)
// Clean and validate path
cleanedParam := filepath.Clean(param)
// Security checks
if cleanedParam != param || strings.Contains(param, "..") ||
strings.Contains(param, "/") || strings.Contains(param, "\\") {
return c.Status(fiber.StatusForbidden).SendString("Forbidden")
}
// Alphanumeric validation
validChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
for _, char := range param {
if !strings.ContainsRune(validChars, char) {
return c.Status(fiber.StatusForbidden).SendString("Forbidden")
}
}
return c.Next()
}
}
// customCompression adds Brotli or gzip compression depending on client support.
func customCompression() fiber.Handler {
return func(c *fiber.Ctx) error {
// Check Accept-Encoding header
acceptEncoding := c.Get("Accept-Encoding")
// Set Vary header
c.Append("Vary", "Accept-Encoding")
// Process after the handler has been executed
if err := c.Next(); err != nil {
return err
}
// Only compress if response is successful and has body
if c.Response().StatusCode() != 200 || len(c.Response().Body()) < 1024 {
return nil
}
// Check content type for compressibility
contentType := string(c.Response().Header.ContentType())
if !isCompressible(contentType) {
return nil
}
body := c.Response().Body()
// Apply compression based on client support
if strings.Contains(acceptEncoding, "br") {
// Brotli compression
var buf bytes.Buffer
writer := brotli.NewWriterLevel(&buf, 7)
if _, err := writer.Write(body); err != nil {
return nil // Skip compression on error
}
if err := writer.Close(); err != nil {
return nil // Skip compression on error
}
compressed := buf.Bytes()
// Only use compression if it's actually smaller
if len(compressed) < len(body) {
c.Response().Header.Set("Content-Encoding", "br")
c.Response().SetBodyRaw(compressed)
}
} else if strings.Contains(acceptEncoding, "gzip") {
// Let the built-in gzip middleware handle it
c.Response().Header.Set("Content-Encoding", "gzip")
}
return nil
}
}
// isCompressible returns true for common types that benefit from compression.
func isCompressible(contentType string) bool {
compressibleTypes := []string{
"text/", "application/json", "application/javascript",
"application/xml", "image/svg", "font/",
"application/wasm", "application/xhtml", "application/rss",
}
for _, t := range compressibleTypes {
if strings.Contains(contentType, t) {
return true
}
}
return false
}
// main parses flags, sets up middleware/plugins, and starts the server.
// It also handles graceful shutdown signals.
func main() {
// Parse command line flags
prodMode := flag.Bool("p", false, "Run in production mode")
devMode := flag.Bool("d", false, "Run in development mode")
daemonMode := flag.Bool("b", false, "Run as a daemon (background process)")
port := flag.String("port", "1488", "Port to listen on")
skipPOW := flag.Bool("skip-pow", false, "Skip proof-of-work protection")
flag.Parse()
// Handle daemon mode - fork a new process and exit the parent
if *daemonMode && os.Getenv("_DAEMON_CHILD") != "1" {
// Prepare the command to run this program again as a child
cmd := exec.Command(os.Args[0], os.Args[1:]...)
cmd.Env = append(os.Environ(), "_DAEMON_CHILD=1")
cmd.Start()
log.Printf("Server started in daemon mode with PID: %d\n", cmd.Process.Pid)
// Write the PID to a file for later reference
pidFile := "server.pid"
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
log.Printf("Warning: Could not write PID file: %v\n", err)
}
// Exit the parent process
os.Exit(0)
}
// Set environment based on flags
if *prodMode {
os.Setenv("APP_ENV", "production")
} else if *devMode {
os.Setenv("APP_ENV", "development")
} else {
// Default to production mode if no mode is specified
os.Setenv("APP_ENV", "production")
}
// Configure minification options from environment variables
opts := utils.DefaultMinifierOptions()
opts.MaxWorkers = getEnvInt("MINIFY_WORKERS", opts.MaxWorkers)
opts.SkipUnchanged = getEnvBool("MINIFY_SKIP_UNCHANGED", opts.SkipUnchanged)
opts.RemoveComments = getEnvBool("MINIFY_REMOVE_COMMENTS", opts.RemoveComments)
opts.KeepConditionalComments = getEnvBool("MINIFY_KEEP_CONDITIONAL_COMMENTS", opts.KeepConditionalComments)
opts.KeepSpecialComments = getEnvBool("MINIFY_KEEP_SPECIAL_COMMENTS", opts.KeepSpecialComments)
// Minify assets from develop to public
log.Println("Minifying assets from /develop to /public directories...")
if err := utils.MinifyAssetsWithOptions(opts); err != nil {
log.Fatalf("Failed to minify assets: %v", err)
}
// Setup the template engine
engine := html.New("./public/static", ".html")
engine.Reload(os.Getenv("APP_ENV") != "production") // Enable reloading in development mode
// Create a new Fiber app with a custom error handler (serving error.html).
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).SendFile(filepath.Join("public", "html", "error.html"))
},
StrictRouting: true, // Enable strict routing for better path validation
Views: engine, // Set the template engine
ProxyHeader: "X-Forwarded-For", // Trust X-Forwarded-For header
EnableTrustedProxyCheck: true, // Enable proxy checking
TrustedProxies: []string{"127.0.0.1", "::1"}, // Add your NAS IP here
})
// Logger middleware only in development mode
if os.Getenv("APP_ENV") != "production" {
// API routes: log method, path and latency only (no status)
app.Use(logger.New(logger.Config{
Format: "${time} ${method} ${path} - ${latency}",
TimeFormat: "2006-01-02T15:04:05",
TimeZone: "Local",
Next: func(c *fiber.Ctx) bool {
// skip this logger for non-API paths
return !strings.HasPrefix(c.Path(), "/api")
},
}))
// Non-API routes: log full details including status
app.Use(logger.New(logger.Config{
Format: "${time} ${status} | ${method} ${path} - ${latency}",
TimeFormat: "2006-01-02T15:04:05",
TimeZone: "Local",
Next: func(c *fiber.Ctx) bool {
// skip this logger for API paths
return strings.HasPrefix(c.Path(), "/api")
},
}))
log.Printf("Logger middleware enabled (%s mode)\n", os.Getenv("APP_ENV"))
}
// Force text/html content type for HTML files for better compression detection
app.Use(func(c *fiber.Ctx) error {
path := c.Path()
// Only set content type for HTML files
if strings.HasSuffix(path, ".html") || path == "/" || (len(path) > 0 && !strings.Contains(path, ".")) {
c.Set("Content-Type", "text/html; charset=utf-8")
} else if strings.HasSuffix(path, ".json") {
c.Set("Content-Type", "application/json")
}
return c.Next()
})
// Built-in compression for non-Brotli clients
app.Use(compress.New(compress.Config{
Level: 7,
Next: func(c *fiber.Ctx) bool {
// Skip if client accepts Brotli
return strings.Contains(c.Get("Accept-Encoding"), "br")
},
}))
// Custom Brotli compression for supported clients
app.Use(customCompression())
// Security headers middleware (improves site security)
app.Use(func(c *fiber.Ctx) error {
c.Set("X-Frame-Options", "SAMEORIGIN")
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Set("X-Permitted-Cross-Domain-Policies", "none")
c.Set("Cross-Origin-Opener-Policy", "same-origin")
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
//c.Set("Content-Security-Policy", "base-uri 'self'; default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' blob: *.caileb.com; img-src * data:; font-src 'self'; worker-src 'self' blob:; frame-src *.youtube.com *.youtube-nocookie.com *.bitchute.com *.rumble.com rumble.com; connect-src 'self' *.youtube.com *.youtube-nocookie.com *.ytimg.com *.bitchute.com *.rumble.com *.caileb.com;")
return c.Next()
})
// Serve static files from the public directory with optimized caching
// This should be before HTML POW middleware to correctly handle static files
app.Static("/", "./public", fiber.Static{
Compress: true, // Enable compression for static files
ByteRange: true, // Enable byte range requests
CacheDuration: 24 * time.Hour,
MaxAge: 86400,
})
// Special handler for favicon.ico to ensure it's properly served
app.Get("/favicon.ico", func(c *fiber.Ctx) error {
return c.SendFile("./public/favicon.ico", false)
})
// Load and apply registered middleware plugins
for _, handler := range middleware.LoadPlugins(*skipPOW) {
app.Use(handler)
}
log.Println("Loaded middleware plugins")
// API group with POW protection
api := app.Group("/api")
// Endpoint to verify POW solutions and issue tokens
api.Post("/pow/verify", middleware.VerifyCheckpointHandler)
// Challenge endpoint for secure POW parameters
api.Get("/pow/challenge", middleware.GetCheckpointChallengeHandler)
// Backwards compatibility for existing clients
api.Get("/verify", middleware.VerifyCheckpointHandler)
// Homepage route: serve index.html from public/html/ with compression
app.Get("/", func(c *fiber.Ctx) error {
c.Set("Content-Type", "text/html; charset=utf-8")
c.Response().Header.Add("Vary", "Accept-Encoding")
return c.SendFile(filepath.Join("public", "html", "index.html"))
})
// Dynamic page route using the validation middleware
app.Get("/:page", validatePathParam("page"), func(c *fiber.Ctx) error {
page := c.Params("page")
c.Set("Content-Type", "text/html; charset=utf-8")
c.Response().Header.Add("Vary", "Accept-Encoding")
return c.SendFile(filepath.Join("public", "html", page+".html"))
})
// Catch-all: serve a 404 error page for unmatched routes
app.Use(func(c *fiber.Ctx) error {
c.Set("Content-Type", "text/html; charset=utf-8")
c.Response().Header.Add("Vary", "Accept-Encoding")
return c.Status(404).SendFile(filepath.Join("public", "html", "error.html"))
})
// Start the server
go func() {
addr := ":" + *port
log.Printf("Server starting on %s in %s mode\n", addr, os.Getenv("APP_ENV"))
if err := app.Listen(addr); err != nil {
log.Fatalf("Server error: %v", err)
}
}()
// Start the GeoIP database update routine
go startGeoIPUpdateRoutine()
// If running as daemon child, no need to wait for signals in foreground
if os.Getenv("_DAEMON_CHILD") == "1" {
// In daemon mode, we still need to wait for signals
// but we can close stdout/stderr
if f, err := os.OpenFile("/dev/null", os.O_RDWR, 0); err == nil {
// Redirect stdout/stderr to /dev/null for true daemon behavior
os.Stdout = f
os.Stderr = f
// Don't close f as it's now used by os.Stdout and os.Stderr
}
}
// Graceful shutdown handling
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.ShutdownWithContext(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
// Close the token store database
if err := middleware.CloseTokenStore(); err != nil {
log.Printf("Error closing token store: %v", err)
}
log.Println("Server exiting")
}
// startGeoIPUpdateRoutine starts a goroutine that updates GeoIP databases daily
func startGeoIPUpdateRoutine() {
// Start immediately after server startup to ensure databases are fresh
updateGeoIPDatabases()
// Then schedule daily updates
ticker := time.NewTicker(24 * time.Hour)
go func() {
for range ticker.C {
updateGeoIPDatabases()
}
}()
}
// updateGeoIPDatabases downloads the latest GeoLite2 Country and ASN databases
func updateGeoIPDatabases() {
// MaxMind account credentials
accountID := "1015174"
licenseKey := "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"
// Database paths and URLs
databases := []struct {
name string
url string
destFile string
}{
{
name: "GeoLite2-Country",
url: "https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz",
destFile: "./data/GeoLite2-Country.mmdb",
},
{
name: "GeoLite2-ASN",
url: "https://download.maxmind.com/geoip/databases/GeoLite2-ASN/download?suffix=tar.gz",
destFile: "./data/GeoLite2-ASN.mmdb",
},
}
// Ensure data directory exists
if err := os.MkdirAll("./data", 0755); err != nil {
log.Printf("ERROR: Failed to create data directory: %v", err)
return
}
// Create HTTP client that follows redirects
client := &http.Client{
Timeout: 5 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// MaxMind uses Cloudflare R2 for redirects, follow them
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
// Add basic auth to the redirected request if needed
if req.URL.Host != "mm-prod-geoip-databases.a2649acb697e2c09b632799562c076f2.r2.cloudflarestorage.com" {
req.SetBasicAuth(accountID, licenseKey)
}
return nil
},
}
// Download and process each database
for _, db := range databases {
log.Printf("Checking for updates to %s...", db.name)
// First, check if an update is needed via HEAD request
headReq, err := http.NewRequest("HEAD", db.url, nil)
if err != nil {
log.Printf("ERROR: Failed to create HEAD request for %s: %v", db.name, err)
continue
}
headReq.SetBasicAuth(accountID, licenseKey)
headResp, err := client.Do(headReq)
if err != nil {
log.Printf("ERROR: Failed to make HEAD request for %s: %v", db.name, err)
continue
}
headResp.Body.Close()
// Check if file exists and get its modification time
updateNeeded := true
if fileInfo, err := os.Stat(db.destFile); err == nil {
lastModified := headResp.Header.Get("Last-Modified")
if lastModified != "" {
remoteTime, err := time.Parse(time.RFC1123, lastModified)
if err == nil {
// Only update if remote file is newer
if !remoteTime.After(fileInfo.ModTime()) {
log.Printf("No update needed for %s, local copy is current", db.name)
updateNeeded = false
}
}
}
}
if !updateNeeded {
continue
}
// Download the database
log.Printf("Downloading %s...", db.name)
req, err := http.NewRequest("GET", db.url, nil)
if err != nil {
log.Printf("ERROR: Failed to create request for %s: %v", db.name, err)
continue
}
req.SetBasicAuth(accountID, licenseKey)
resp, err := client.Do(req)
if err != nil {
log.Printf("ERROR: Failed to download %s: %v", db.name, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("ERROR: Failed to download %s: HTTP %d", db.name, resp.StatusCode)
continue
}
// Create a temporary file to store the downloaded archive
tempFile, err := os.CreateTemp("", "geoip-*.tar.gz")
if err != nil {
log.Printf("ERROR: Failed to create temp file for %s: %v", db.name, err)
continue
}
defer os.Remove(tempFile.Name())
// Copy the response body to the temporary file
_, err = io.Copy(tempFile, resp.Body)
if err != nil {
log.Printf("ERROR: Failed to save downloaded %s: %v", db.name, err)
tempFile.Close()
continue
}
tempFile.Close()
// Extract the .mmdb file from the tar.gz archive
extracted, err := extractMMDBFromTarGz(tempFile.Name(), db.name)
if err != nil {
log.Printf("ERROR: Failed to extract %s: %v", db.name, err)
continue
}
// Move the extracted file to the destination
err = os.Rename(extracted, db.destFile)
if err != nil {
log.Printf("ERROR: Failed to move %s to destination: %v", db.name, err)
os.Remove(extracted) // Clean up
continue
}
log.Printf("Successfully updated %s", db.name)
}
// Reload the databases in the middleware
middleware.ReloadGeoIPDatabases()
}
// extractMMDBFromTarGz extracts the .mmdb file from a tar.gz archive
func extractMMDBFromTarGz(tarGzPath, dbName string) (string, error) {
file, err := os.Open(tarGzPath)
if err != nil {
return "", err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return "", err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
// Create a temporary directory for extraction
tempDir, err := os.MkdirTemp("", "geoip-extract-")
if err != nil {
return "", err
}
// Find and extract the .mmdb file
var mmdbPath string
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
os.RemoveAll(tempDir)
return "", err
}
// Look for the .mmdb file in the archive
if strings.HasSuffix(header.Name, ".mmdb") && strings.Contains(header.Name, dbName) {
// Extract to temporary directory
mmdbPath = filepath.Join(tempDir, filepath.Base(header.Name))
outFile, err := os.Create(mmdbPath)
if err != nil {
os.RemoveAll(tempDir)
return "", err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
os.RemoveAll(tempDir)
return "", err
}
outFile.Close()
break
}
}
if mmdbPath == "" {
os.RemoveAll(tempDir)
return "", fmt.Errorf("no .mmdb file found in archive for %s", dbName)
}
return mmdbPath, nil
}

1482
middleware/checkpoint.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
# -----------------------------------------------------------------------------
# Checkpoint Middleware Configuration (checkpoint.toml)
#
# All durations are parsed via time.ParseDuration (e.g. "24h").
# Arrays and tables map directly to the Config struct fields.
# -----------------------------------------------------------------------------
# === GENERAL SETTINGS ===
# Number of leading zeros required in PoW hash
Difficulty = 4
# Validity period for issued tokens
TokenExpiration = "24h"
# Name of the cookie used to store the checkpoint token
CookieName = "checkpoint_token"
# Domain attribute for the cookie; empty = host-only (localhost)
CookieDomain = ""
# Length of the random salt in bytes for challenges
SaltLength = 16
# === RATE LIMITING & EXPIRATION ===
# Max PoW verification attempts per IP per hour
MaxAttemptsPerHour = 10
# Max age for used nonces before cleanup
MaxNonceAge = "24h"
# Time allowed for solving a challenge
ChallengeExpiration = "5m"
# === PERSISTENCE PATHS ===
# File where HMAC secret is stored
SecretConfigPath = "./data/checkpoint_secret.json"
# Directory for BadgerDB token store
TokenStoreDBPath = "./data/checkpoint_tokendb"
# Ordered fallback paths for interstitial HTML
InterstitialPaths = [
"./public/static/pow-interstitial.html",
"./develop/static/pow-interstitial.html"
]
# === SECURITY SETTINGS ===
# Enable Proof-of-Space-Time consistency checks
CheckPoSTimes = true
# Allowed ratio between slowest and fastest PoS runs
PoSTimeConsistencyRatio = 1.35
# === HTML CHECKPOINT EXCLUSIONS ===
# Path prefixes to skip PoW interstitial
HTMLCheckpointExclusions = ["/api"]
# File extensions to skip PoW check
HTMLCheckpointExcludedExtensions = { ".jpg" = true, ".jpeg" = true, ".png" = true, ".gif" = true, ".svg" = true, ".webp" = true, ".ico" = true, ".bmp" = true, ".tif" = true, ".tiff" = true, ".mp4" = true, ".webm" = true, ".css" = true, ".js" = true, ".mjs" = true, ".woff" = true, ".woff2" = true, ".ttf" = true, ".otf" = true, ".eot" = true, ".json" = true, ".xml" = true, ".txt" = true, ".pdf" = true, ".map" = true, ".wasm" = true }
# === QUERY SANITIZATION ===
# Regex patterns (case-insensitive) to block in query strings
DangerousQueryPatterns = [
"(?i)union\\s+select",
"(?i)drop\\s+table",
"(?i)insert\\s+into",
"(?i)<script",
"(?i)javascript:",
"(?i)onerror=",
]
# Block queries containing ';', '`', or '\\'
BlockDangerousPathChars = true
# === USER-AGENT VALIDATION ===
# Path prefixes to skip UA validation
UserAgentValidationExclusions = ["/api"]
# Required UA prefix per path prefix
[UserAgentRequiredPrefixes]
"/demo1" = "Dart/"
# === REVERSE PROXY MAPPINGS ===
# Hostname-to-backend URL map
[ReverseProxyMappings]
"jellyfin.caileb.com" = "http://192.168.0.2:8096"
"archive.caileb.com" = "http://192.168.0.2:7461"
"music.caileb.com" = "http://192.168.0.2:4533"
"gallery.caileb.com" = "http://192.168.0.2:2283"

View file

@ -0,0 +1,39 @@
# IPFilter Configuration
# Page shown when a request is blocked
defaultBlockPage = "default-block.html"
# Cache block decisions (seconds)
ipBlockCacheTTLSec = 300
# Country codes to block
blockedCountryCodes = [
"IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ",
"LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG",
"TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL"
]
# === CONTINENT-BASED BLOCKING ===
blockedContinentCodes = ["AF", "SA", "AS", "AN"]
# === ASN NUMBER GROUPS ===
[blockedASNs]
# empty by default
# === ASN NAME GROUPS ===
[blockedASNNames]
"Data Center" = [
"Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS",
"Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247",
"Datacamp", "Datapacket", "Amanah", "Hern Labs"
]
# === CUSTOM BLOCK PAGES ===
[countryBlockPages]
IN = "india-block.html"
[continentBlockPages]
# none by default
# Custom pages by ASN group
[asnGroupBlockPages]
"Data Center" = "datacenter-block.html"

498
middleware/ipfilter.go Normal file
View file

@ -0,0 +1,498 @@
// IPFilter middleware blocks requests from unwanted IPs based on GeoIP, ASN, and other rules.
// Package middleware provides IP and User-Agent filtering to protect against abuse.
// Features:
// - GeoIP country/continent blocking
// - ASN blocking
// - Data center detection
// - Bot detection
// - Cache-optimized lookups
package middleware
import (
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/cloudflare/ahocorasick"
"github.com/gofiber/fiber/v2"
geoip2 "github.com/oschwald/geoip2-golang"
)
// --- IP Block Cache --- //
// blockCacheEntry stores the result of a block check for an IP
type blockCacheEntry struct {
blocked bool
blockType string
blockValue string
customPage string
asnOrgName string
expiresAt time.Time
}
// Cache for IP block lookups (TTL set via config)
var (
ipBlockCache map[string]blockCacheEntry
ipBlockCacheMutex sync.RWMutex
ipBlockCacheTTL time.Duration
)
// --- GeoIP Configuration ---
var (
geoIPCountryDBPath = "./data/GeoLite2-Country.mmdb"
geoIPASNDBPath = "./data/GeoLite2-ASN.mmdb"
geoipCountryReader *geoip2.Reader
geoipASNReader *geoip2.Reader
// Lists and pages (populated from ipfilter.toml)
blockedCountryCodes map[string]bool
blockedContinentCodes map[string]bool
blockedASNs map[string][]uint
blockedASNNames map[string][]string
countryBlockPages map[string]string
continentBlockPages map[string]string
asnGroupBlockPages map[string]string
defaultBlockPage string
// Aho-Corasick matchers for ASN name groups
asnNameMatchers map[string]*ahocorasick.Matcher
asnNameMatchersMutex sync.RWMutex
)
// init loads ipfilter.toml, sets up all block lists, and hooks this into our plugin registry
func init() {
// Load full configuration from ipfilter.toml
type ipfilterConfig struct {
BlockedCountryCodes []string `toml:"blockedCountryCodes"`
BlockedContinentCodes []string `toml:"blockedContinentCodes"`
BlockedASNs map[string][]int `toml:"blockedASNs"`
BlockedASNNames map[string][]string `toml:"blockedASNNames"`
CountryBlockPages map[string]string `toml:"countryBlockPages"`
ContinentBlockPages map[string]string `toml:"continentBlockPages"`
ASNGroupBlockPages map[string]string `toml:"asnGroupBlockPages"`
DefaultBlockPage string `toml:"defaultBlockPage"`
IPBlockCacheTTLSec int `toml:"ipBlockCacheTTLSec"`
}
var cfg ipfilterConfig
if err := LoadConfig("ipfilter", &cfg); err != nil {
log.Fatalf("Failed to load ipfilter config: %v", err)
}
// override blockedCountryCodes
blockedCountryCodes = make(map[string]bool)
for _, c := range cfg.BlockedCountryCodes {
blockedCountryCodes[c] = true
}
// override blockedContinentCodes
blockedContinentCodes = make(map[string]bool)
for _, c := range cfg.BlockedContinentCodes {
blockedContinentCodes[c] = true
}
// override blockedASNs
newASNs := make(map[string][]uint)
for group, arr := range cfg.BlockedASNs {
uintArr := make([]uint, len(arr))
for i, v := range arr {
uintArr[i] = uint(v)
}
newASNs[group] = uintArr
}
blockedASNs = newASNs
// override blockedASNNames
blockedASNNames = cfg.BlockedASNNames
// override page maps
countryBlockPages = cfg.CountryBlockPages
continentBlockPages = cfg.ContinentBlockPages
asnGroupBlockPages = cfg.ASNGroupBlockPages
// override default page
defaultBlockPage = cfg.DefaultBlockPage
// override cache TTL
ipBlockCacheTTL = time.Duration(cfg.IPBlockCacheTTLSec) * time.Second
// Initialize the cache
ipBlockCache = make(map[string]blockCacheEntry)
// Initialize GeoIP databases
initGeoIP()
// Build Aho-Corasick matchers for ASN name matching
buildASNNameMatchers()
// Register IP-block plugin
RegisterPlugin("ipfilter", IPBlockMiddleware)
}
// IPBlockMiddleware returns a handler that stops requests coming from blocked IPs
// Use this early so bad traffic gets rejected fast
func IPBlockMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Get the client IP
clientIP := getRealIP(c)
// Check IP blocklist (GeoIP/ASN)
if blocked, blockType, blockValue, customPage, asnOrgName := isBlockedIPExtended(clientIP); blocked {
// For API requests, return JSON response
if strings.HasPrefix(c.Path(), "/api") {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "Access denied from your location or network.",
"reason": "geoip",
"type": blockType,
"value": blockValue,
"asn_org": asnOrgName,
})
}
// For HTML requests, serve a block page
// Log block information
log.Printf("Blocking access: type=%s, value=%s, custom_page=%s, asn_org=%s",
blockType, blockValue, customPage, asnOrgName)
// If no custom page specified, use the default
if customPage == "" {
customPage = defaultBlockPage
}
// Try to serve the block page
possiblePaths := []string{
"./public/static/" + customPage,
"./public/html/" + customPage,
"./public/" + customPage,
"./develop/static/" + customPage,
}
var htmlContentBytes []byte
var err error
foundPath := ""
for _, path := range possiblePaths {
if htmlContentBytes, err = os.ReadFile(path); err == nil {
// Found the file, serve it
foundPath = path
break
}
}
if foundPath != "" {
// Found the file, serve it
c.Status(fiber.StatusForbidden)
c.Set("Content-Type", "text/html; charset=utf-8")
// Replace placeholder with ASN Org Name if available
htmlContent := string(htmlContentBytes)
replaceValue := "Blocked Network" // Default fallback text
if asnOrgName != "" {
replaceValue = asnOrgName
}
htmlContent = strings.Replace(htmlContent, "{{.ASNName}}", replaceValue, -1)
return c.SendString(htmlContent)
}
// If no custom or default page was found, fall back to default message
return c.Status(fiber.StatusForbidden).SendString("Access denied from your location or network.")
}
// Not blocked, continue to next middleware
return c.Next()
}
}
// initGeoIP loads the GeoLite2 databases
func initGeoIP() {
var err error
geoipCountryReader, err = geoip2.Open(geoIPCountryDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 Country database at %s: %v. GeoIP country/continent blocking disabled.", geoIPCountryDBPath, err)
}
geoipASNReader, err = geoip2.Open(geoIPASNDBPath)
if err != nil {
log.Printf("WARNING: Could not open GeoLite2 ASN database at %s: %v. GeoIP ASN blocking disabled.", geoIPASNDBPath, err)
}
}
// isBlockedIP checks if the IP address is blocked based on GeoIP or ASN.
func isBlockedIP(ipStr string) bool {
blocked, _, _, _, _ := isBlockedIPExtended(ipStr)
return blocked
}
// isBlockedIPExtended checks if the IP address is blocked and returns detailed information.
// Returns:
// - blocked: true if IP is blocked
// - blockType: "country", "continent", "asn_number_group", or "asn_name_group"
// - blockValue: the country code, continent code, or ASN group name (that triggered the block)
// - customPage: path to custom block page if defined, or empty string
// - asnOrgName: the specific ASN Organization name if blockType is "asn_name_group"
func isBlockedIPExtended(ipStr string) (blocked bool, blockType string, blockValue string, customPage string, asnOrgName string) {
// Safeguard against empty IPs causing issues
if ipStr == "" {
log.Printf("ERROR: Empty IP passed to isBlockedIPExtended")
return false, "", "", "", ""
}
ip := net.ParseIP(ipStr)
if ip == nil {
log.Printf("WARNING: Could not parse IP address for GeoIP check: %s", ipStr)
return false, "", "", "", "" // Cannot check invalid IP
}
// --- Check Cache First --- //
ipBlockCacheMutex.RLock()
entry, found := ipBlockCache[ipStr]
ipBlockCacheMutex.RUnlock() // Unlock immediately after reading
if found && time.Now().Before(entry.expiresAt) {
// Return cached result
return entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName
}
// --- End Cache Check --- //
var countryRecord *geoip2.Country
var asnRecord *geoip2.ASN
var countryErr, asnErr error
var wg sync.WaitGroup
// Concurrently perform GeoIP lookups if readers are available
if geoipCountryReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
countryRecord, countryErr = geoipCountryReader.Country(ip)
}()
}
if geoipASNReader != nil {
wg.Add(1)
go func() {
defer wg.Done()
asnRecord, asnErr = geoipASNReader.ASN(ip)
}()
}
// Wait for lookups to complete
wg.Wait()
// --- Process Results --- //
// 1. Check Country/Continent
if countryRecord != nil && countryErr == nil {
// Check country first
if blockedCountryCodes[countryRecord.Country.IsoCode] {
blockType = "country"
blockValue = countryRecord.Country.IsoCode
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = countryBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
// Then check continent
if blockedContinentCodes[countryRecord.Continent.Code] {
blockType = "continent"
blockValue = countryRecord.Continent.Code
log.Printf("INFO: Blocking IP %s based on %s: %s", ipStr, blockType, blockValue)
customPage = continentBlockPages[blockValue]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
} else if countryErr != nil && !strings.Contains(countryErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP country lookup error for IP %s: %v", ipStr, countryErr)
}
// 2. Check ASN (Number and Name)
if asnRecord != nil && asnErr == nil {
clientASN := asnRecord.AutonomousSystemNumber
asnOrg := asnRecord.AutonomousSystemOrganization // Keep org name for potential use
// Check ASN Number Groups
for groupName, asnList := range blockedASNs {
for _, blockedASN := range asnList {
if clientASN == blockedASN {
blockType = "asn_number_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d - %s)", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, "")
}
}
}
// Check ASN Name Groups (Case-Insensitive Substring Match)
if asnOrg != "" {
lowerASNOrg := strings.ToLower(asnOrg)
// Acquire read lock for accessing the global matchers map
asnNameMatchersMutex.RLock()
defer asnNameMatchersMutex.RUnlock()
for groupName, matcher := range asnNameMatchers {
if matcher == nil { // Check added during previous fix, keep it
log.Printf("WARNING: Nil matcher found for group: %s during check", groupName)
continue
}
// Use Aho-Corasick matcher - protect against any potential panic
var matches []int
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic in Aho-Corasick: %v", r)
matches = nil // Ensure it's empty if we panic
}
}()
// Match against the globally shared matcher (assumed read-safe)
matches = matcher.Match([]byte(lowerASNOrg))
}()
if len(matches) > 0 {
blockType = "asn_name_group"
blockValue = groupName
log.Printf("INFO: Blocking IP %s based on %s: %s (ASN: %d, Org: '%s')", ipStr, blockType, blockValue, clientASN, asnOrg)
customPage = asnGroupBlockPages[groupName]
// No need to unlock here, defer handles it
return cacheAndReturnBlockResult(ipStr, blockType, blockValue, customPage, asnOrg)
}
}
// RUnlock happens via defer
}
} else if asnErr != nil && !strings.Contains(asnErr.Error(), "cannot be found in the database") {
// Log errors other than "not found"
log.Printf("WARNING: GeoIP ASN lookup error for IP %s: %v", ipStr, asnErr)
}
// --- Cache the result before returning --- //
computedEntry := blockCacheEntry{
blocked: false,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
ipBlockCacheMutex.Lock()
ipBlockCache[ipStr] = computedEntry
ipBlockCacheMutex.Unlock()
return false, "", "", "", "" // Not blocked
}
// Helper function to cache block results
func cacheAndReturnBlockResult(ipStr string, blockType string, blockValue string, customPage string, asnOrgName string) (bool, string, string, string, string) {
// Create the cache entry
computedEntry := blockCacheEntry{
blocked: true,
blockType: blockType,
blockValue: blockValue,
customPage: customPage,
asnOrgName: asnOrgName,
expiresAt: time.Now().Add(ipBlockCacheTTL),
}
// Use a separate defer+recover to ensure we don't crash the entire server
// if there's any issue with the cache
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("RECOVERED from panic while caching result: %v", r)
}
}()
ipBlockCacheMutex.Lock()
defer ipBlockCacheMutex.Unlock() // Use defer to ensure unlock happens
ipBlockCache[ipStr] = computedEntry
}()
return true, blockType, blockValue, customPage, asnOrgName
}
// buildASNNameMatchers creates Aho-Corasick matchers for faster ASN name checking
func buildASNNameMatchers() {
// Acquire write lock before modifying the global map
asnNameMatchersMutex.Lock()
defer asnNameMatchersMutex.Unlock()
// Clear any existing matchers first
asnNameMatchers = make(map[string]*ahocorasick.Matcher)
for groupName, nameList := range blockedASNNames {
// Skip if the name list is empty
if len(nameList) == 0 {
log.Printf("Skipping matcher build for empty group: %s", groupName)
continue
}
// Convert names to lowercase byte slices for case-insensitive matching
dict := make([][]byte, 0, len(nameList))
for _, name := range nameList {
if name != "" {
dict = append(dict, []byte(strings.ToLower(name)))
}
}
// Only create a matcher if we have patterns
if len(dict) > 0 {
// Use a recovery mechanism in case the matcher creation fails
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC while building Aho-Corasick matcher for group %s: %v", groupName, r)
// Ensure the entry for this group is nil if creation failed
asnNameMatchers[groupName] = nil
}
}()
// This assignment happens under the write lock
asnNameMatchers[groupName] = ahocorasick.NewMatcher(dict)
log.Printf("Built Aho-Corasick matcher for ASN name group: %s (%d patterns)", groupName, len(dict))
}()
} else {
log.Printf("No valid patterns found for ASN name group: %s", groupName)
}
}
// Unlock happens via defer
}
// ReloadGeoIPDatabases closes and reopens the GeoIP database readers
// to load updated database files. Safe to call while the server is running.
func ReloadGeoIPDatabases() {
// Close existing readers if they're open
if geoipCountryReader != nil {
geoipCountryReader.Close()
geoipCountryReader = nil
}
if geoipASNReader != nil {
geoipASNReader.Close()
geoipASNReader = nil
}
// Re-initialize the readers
initGeoIP()
log.Printf("GeoIP databases reloaded")
}
// getRealIP gets the real client IP when behind a reverse proxy
// It checks X-Forwarded-For header first, then falls back to c.IP()
func getRealIP(c *fiber.Ctx) string {
// Check X-Forwarded-For header first
if xff := c.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// The first one is the original client IP
ips := strings.Split(xff, ",")
if len(ips) > 0 {
// Get the first IP and trim whitespace
clientIP := strings.TrimSpace(ips[0])
// Validate it's a real IP
if net.ParseIP(clientIP) != nil {
log.Printf("Using X-Forwarded-For IP: %s (original: %s)", clientIP, c.IP())
return clientIP
}
}
}
// Also check for custom Remote-Addr header that might be set by some proxies
if remoteAddr := c.Get("$remote_addr"); remoteAddr != "" {
// Validate it's a real IP
if net.ParseIP(remoteAddr) != nil {
log.Printf("Using $remote_addr IP: %s (original: %s)", remoteAddr, c.IP())
return remoteAddr
}
}
// Fallback to default IP
return c.IP()
}

47
middleware/plugin.go Normal file
View file

@ -0,0 +1,47 @@
// Package middleware contains a simple plugin system for Fiber middleware.
// Register plugins by name and factory, then main.go will load them automatically.
package middleware
import (
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/gofiber/fiber/v2"
)
// Plugin holds a plugin's name and a function that makes its handler.
type Plugin struct {
Name string
Factory func() fiber.Handler
}
// registry stores every plugin we've registered.
var registry []Plugin
// RegisterPlugin tags a plugin with a name and a factory so we can use it in the app.
func RegisterPlugin(name string, factory func() fiber.Handler) {
registry = append(registry, Plugin{Name: name, Factory: factory})
}
// LoadPlugins returns the handler functions for each plugin.
// If skipCheckpoint is true, it skips the plugin named "checkpoint".
func LoadPlugins(skipCheckpoint bool) []fiber.Handler {
var handlers []fiber.Handler
for _, p := range registry {
if skipCheckpoint && p.Name == "checkpoint" {
continue
}
handlers = append(handlers, p.Factory())
}
return handlers
}
// LoadConfig loads the TOML file at middleware/config/[name].toml
// and decodes it into the struct you provide.
func LoadConfig(name string, v interface{}) error {
path := filepath.Join("middleware", "config", name+".toml")
if _, err := toml.DecodeFile(path, v); err != nil {
return err
}
return nil
}

1
public/css/docs.css Normal file

File diff suppressed because one or more lines are too long

1
public/css/lightbox.css Normal file
View file

@ -0,0 +1 @@
.lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.85);z-index:1000;justify-content:center;align-items:center;padding:0}.lightbox.active{display:flex}.lightbox-content{position:relative;width:90%;max-width:1200px;height:85vh;background-color:#121212;padding:20px;border-radius:10px;box-shadow:0 5px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}.lightbox-img-container{flex:1;overflow:hidden;position:relative;display:flex;justify-content:center;align-items:center}.lightbox-img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:grab;border-radius:5px;transform-origin:center center;user-select:none;will-change:transform}.lightbox-img.grabbing{cursor:grabbing}.lightbox-close{position:absolute;top:10px;right:10px;width:32px;height:32px;background-color:#9b59b6;color:#fff;border-radius:50%;text-align:center;line-height:32px;cursor:pointer;font-weight:700;font-size:18px;z-index:1010;box-shadow:0 2px 5px rgba(0,0,0,.3)}.lightbox-caption{margin-top:15px;text-align:center;color:#ccc;font-size:.9rem}.zoom-controls{display:flex;align-items:center;justify-content:center;margin-top:15px;padding:10px 0;border-top:1px solid #444}.zoom-label{margin-right:10px;font-size:.9rem;color:#ccc}.zoom-slider{-webkit-appearance:none;width:70%;height:6px;border-radius:3px;background:#444;outline:none}.zoom-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;border-radius:50%;background:#9b59b6;cursor:pointer}.zoom-slider::-moz-range-thumb{width:18px;height:18px;border-radius:50%;background:#9b59b6;cursor:pointer;border:none}.zoom-value{margin-left:10px;font-size:.9rem;min-width:40px;color:#ccc}@media(max-width:768px),(max-width:1024px) and (orientation:landscape){.lightbox-content{width:100%;height:100%;padding:15px;border-radius:0}.zoom-controls{display:flex}}

2
public/css/u.css Normal file
View file

@ -0,0 +1,2 @@
*{margin:0;padding:0;box-sizing:border-box}@font-face{font-family:Poppins;src:url(/webfonts/Poppins-Regular.woff2)format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Poppins;src:url(/webfonts/Poppins-SemiBold.woff2)format("woff2");font-weight:600;font-style:normal;font-display:swap}body{font-family:Poppins,sans-serif}a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"])::after,a[target=_blank]:not([href^="mailto:"]):not([href^="tel:"])::after{content:"";display:inline-block;width:1.1em;height:1.1em;margin-left:.25em;margin-bottom:.25em;vertical-align:middle;background-color:currentColor;-webkit-mask:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMS43NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgNkg2YTIgMiAwIDAgMC0yIDJ2MTBhMiAyIDAgMCAwIDIgMmgxMGEyIDIgMCAwIDAgMi0ydi02bS03IDFsOS05bS01IDBoNXY1Ii8+PC9zdmc+)no-repeat center;mask:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMS43NSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgNkg2YTIgMiAwIDAgMC0yIDJ2MTBhMiAyIDAgMCAwIDIgMmgxMGEyIDIgMCAwIDAgMi0ydi02bS03IDFsOS05bS01IDBoNXY1Ii8+PC9zdmc+)no-repeat center;-webkit-mask-size:contain;mask-size:contain}@media(hover:hover) and (pointer:fine){a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"]),a[target=_blank]:not([href^="mailto:"]):not([href^="tel:"]){position:relative}a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"])::before,a[target=_blank]:not([href^="mailto:"]):not([href^="tel:"])::before{content:"Opens in new tab";position:absolute;top:50%;left:100%;transform:translateY(-50%);margin-left:5px;background-color:rgba(25,25,25,.9);color:#fff;padding:5px 8px;border-radius:4px;font-size:12px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .18s ease-in-out;z-index:10}a[rel~=external]:not([href^="mailto:"]):not([href^="tel:"]):hover::before,a[target=_blank]:not([href^="mailto:"]):not([href^="tel:"]):hover::before{transition-delay:60ms;opacity:1}}::-webkit-scrollbar{width:4px;height:4px}::-webkit-scrollbar-track{background:#2d2d2d;border-radius:5px}::-webkit-scrollbar-thumb{background:#4d9cfa;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:#3971a3}html{scroll-behavior:smooth}@view-transition{navigation: auto;
}::view-transition-old(root),::view-transition-new(root){animation-duration:.44s}@media(prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}

BIN
public/css/u.css.fiber.gz Normal file

Binary file not shown.

97
public/html/ai-san.html Normal file
View file

@ -0,0 +1,97 @@
<!doctype html><html lang=en>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Text Cleaner - caileb.com</title>
<meta name=description content="Clean and convert formatted text to plain text">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel=stylesheet href=/css/u.css>
<style>:root{--background-color:#121212;--card-gradient-start:#1e1e1e;--card-gradient-end:#333;--header-background:#262626;--text-color:#fff;--accent-color:#9B59B6;--subtext-color:#ccc}body{background:#1c1c1c;color:var(--text-color);font-family:Poppins,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:0;padding:20px;min-height:100vh}.container{max-width:800px;width:100%}header{text-align:center;margin-bottom:2rem}header h1{font-size:2.5rem;margin-bottom:.5rem;color:var(--accent-color)}header p{font-size:1.25rem;color:var(--subtext-color)}.cleaner-card{background:linear-gradient(135deg,rgba(30,30,30,.8),rgba(51,51,51,.8));border-radius:12px;padding:2rem;box-shadow:0 8px 24px rgba(0,0,0,.3);backdrop-filter:blur(10px)}label{font-weight:600;display:block;margin-bottom:.8rem;color:var(--accent-color);font-size:1.1rem}textarea{width:100%;height:200px;margin-bottom:1.5rem;padding:1rem;font-size:1rem;font-family:Poppins,sans-serif;border-radius:8px;border:1px solid #444;background:rgba(0,0,0,.2);color:var(--text-color);resize:vertical;transition:border-color .2s ease}textarea:focus{outline:none;border-color:var(--accent-color)}.button-row{display:flex;gap:1rem;margin-bottom:1.5rem}button{background:var(--accent-color);color:#fff;border:none;padding:.75rem 1.5rem;border-radius:8px;font-size:1rem;font-family:Poppins,sans-serif;font-weight:600;cursor:pointer;transition:all .2s ease;flex:1;display:flex;align-items:center;justify-content:center}button:hover{background:#8e44ad;transform:translateY(-2px);box-shadow:0 4px 12px rgba(155,89,182,.4)}footer{text-align:center;padding:1rem 0;color:var(--subtext-color);font-size:.9rem;margin-top:2rem}</style>
<script src=/js/u.js async></script>
<div class=container>
<header>
<h1>Text Cleaner</h1>
<p>Convert formatted text to clean plain text
</header>
<div class=cleaner-card>
<label for=inputText>Input Text</label>
<textarea id=inputText placeholder="Paste text with smart quotes, dashes, or other special characters here..."></textarea>
<div class=button-row>
<button onclick=cleanText()>Clean Text</button>
<button onclick=copyToClipboard()>Copy to Clipboard</button>
</div>
<label for=outputText>Clean Text</label>
<textarea id=outputText readonly placeholder="Cleaned text will appear here..."></textarea>
</div>
</div>
<script async>
function cleanText() {
const input = document.getElementById('inputText').value;
let cleaned = input;
// Fix all quotes
const quoteReplacements = {
'\u201C': '"', '\u201D': '"',
'\u2018': "'", '\u2019': "'",
'\u00AB': '"', '\u00BB': '"',
'\u2039': "'", '\u203A': "'"
};
cleaned = cleaned.replace(/[\u201C\u201D\u2018\u2019\u00AB\u00BB\u2039\u203A]/g, function(char) {
return quoteReplacements[char] || char;
});
// Fix all dashes
const dashReplacements = {
'\u2013': '-', '\u2014': '-',
'\u2010': '-', '\u2011': '-',
'\u2012': '-', '\u2015': '-',
'\u2212': '-'
};
cleaned = cleaned.replace(/[\u2013\u2014\u2010\u2011\u2012\u2015\u2212]/g, function(char) {
return dashReplacements[char] || char;
});
// Fix ellipses
cleaned = cleaned.replace(/\u2026/g, '...');
// Normalize line breaks (but preserve them)
cleaned = cleaned.replace(/\r\n|\r/g, '\n');
// Remove extra spaces within each line but preserve newlines
cleaned = cleaned.split('\n').map(line => line.replace(/\s+/g, ' ').trim()).join('\n');
// Remove lines that are just whitespace
cleaned = cleaned.replace(/^\s*[\r\n]/gm, '\n');
// Fix other common characters
cleaned = cleaned.replace(/[\u00B6\u00A7]/g, ''); // Remove paragraph and section marks
cleaned = cleaned.replace(/[\u00AE\u00A9\u2122]/g, ''); // Remove trademark symbols
cleaned = cleaned.replace(/[\u2022\u25E6\u2023\u2043]/g, '-'); // Replace bullets with hyphens
document.getElementById('outputText').value = cleaned;
}
function copyToClipboard() {
const outputText = document.getElementById('outputText');
if (!outputText.value) {
cleanText();
}
outputText.select();
document.execCommand('copy');
const copyButton = document.getElementsByTagName('button')[1];
copyButton.textContent = "Copied!";
setTimeout(function() {
copyButton.textContent = "Copy to Clipboard";
}, 1500);
}
</script>

635
public/html/checkpoint.html Normal file
View file

@ -0,0 +1,635 @@
<!doctype html><html lang=en>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Checkpoint Documentation</title>
<meta name=description content="Documentation for the Checkpoint Protection System, a secure Proof-of-Work solution to prevent automated abuse.">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/js/u.js as=script>
<link rel=stylesheet href=/css/u.css>
<link rel=stylesheet href=/css/docs.css>
<link rel=stylesheet href=https://unpkg.com/@speed-highlight/core@1.2.7/dist/themes/github-dark.css integrity=sha384-8HPbLmchBzGvBxngSZZwtxFdhC9KpkvDvgJvjR51kdEvVC7Pn2wLsWuFXRgDpQUh crossorigin=anonymous>
<style>:root{--background-color:#1a1a1a;--overlay-bg:rgba(28, 28, 28, 0.95);--text-color:#fff;--subtext-color:#ccc;--accent-color:#9B59B6}body{background-color:var(--background-color);color:var(--text-color);font-family:poppins,sans-serif}.container{background:rgba(30,30,30,.85);backdrop-filter:blur(8px);border-radius:20px;padding:20px;margin:40px auto;box-shadow:0 10px 30px rgba(0,0,0,.3),0 1px 2px rgba(155,89,182,.2);max-width:960px}</style>
<script async src=/js/u.js></script>
<script type=module src=/js/docs.js></script>
<script async src=/js/lightbox.js></script>
<div class=container>
<div class="disclaimer note">
<p><strong>Disclaimer:</strong> Some internal fields and implementation details are omitted here for security reasons.
</div>
<h1>Checkpoint Protection System</h1>
<div class=toc>
<h2>Contents</h2>
<ul>
<li><a href=#overview>Overview</a>
<li><a href=#how-it-works>How It Works</a>
<li><a href=#challenge-generation>Challenge Generation</a>
<li><a href=#proof-verification>Proof Verification</a>
<li><a href=#token-structure>Token Structure</a>
<li><a href=#security-features>Security Features</a>
<li><a href=#configuration>Configuration Options</a>
<li><a href=#middleware>Middleware Integration</a>
<li><a href=#client-side>Client-Side Implementation</a>
<li><a href=#api-endpoints>API Endpoints</a>
</ul>
</div>
<section id=overview class=section>
<h2>Overview</h2>
<p>Checkpoint Protection asks visitors to solve a quick puzzle before letting them through, cutting down on automated traffic while keeping the experience smooth for real users.
<ul>
<li>No account or personal data needed
<li>Privacy-focused and lightweight
<li>Blocks bots and scripts effectively
<li>Works seamlessly in modern browsers
</ul>
</section>
<section id=how-it-works class=section>
<h2>How It Works</h2>
<p>When you navigate to a protected page, the middleware checks for a valid token cookie (<code>__Host-checkpoint_token</code>).
<ol>
<li>If the token is present, the server verifies its signature and confirms it's bound to your device.
<li>Missing or invalid tokens trigger an interstitial page with a request ID.
<li>The browser fetches challenge data from <code>/api/pow/challenge?id=REQUEST_ID</code>. This payload includes a random challenge, salt, difficulty, and hidden parameters.
<li>The client runs two proofs in parallel:
<ul>
<li><strong>Proof of Work:</strong> finds a nonce such that <code>SHA256(challenge + salt + nonce)</code> meets the difficulty.
<li><strong>Proof of Space:</strong> allocates and hashes large memory buffers to confirm resource availability.
</ul>
<li>Results are sent to <code>/api/pow/verify</code> along with the request ID.
<li>On success, the server issues a signed token (valid for 24h) and sets it as a cookie for future visits.
</ol>
<div class=diagram>
<h3>Checkpoint Protection Flow</h3>
<img src=/images/Basic-POW-Overview.excalidraw.svg alt="Checkpoint Protection Flow Diagram" id=flowDiagram loading=lazy>
</div>
</section>
<section id=challenge-generation class=section>
<h2>Challenge Generation</h2>
<p>
Challenges are generated using cryptographically secure random bytes combined with a salt for additional entropy:
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>func generateChallenge() (string, string) {
// Generate a random challenge
randomBytes := make([]byte, 16)
_, err := cryptorand.Read(randomBytes)
if err != nil {
log.Fatalf("CRITICAL: Failed to generate secure random challenge: %v", err)
}
// Generate a random salt for additional entropy
saltBytes := make([]byte, saltLength)
_, err = cryptorand.Read(saltBytes)
if err != nil {
log.Fatalf("CRITICAL: Failed to generate secure random salt: %v", err)
}
return hex.EncodeToString(randomBytes), hex.EncodeToString(saltBytes)
}</code></pre>
</div>
<div class=note>
<p>
<strong>Security Note:</strong> The system uses Go's crypto/rand package for secure random number generation, ensuring challenges cannot be predicted even by sophisticated attackers.
</div>
<h3>Challenge Parameters</h3>
<p>
Challenges are stored with a unique request ID and include parameters for verification:
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>type ChallengeParams struct {
Challenge string `json:"challenge"` // Base64 encoded
Salt string `json:"salt"` // Base64 encoded
Difficulty int `json:"difficulty"`
ExpiresAt time.Time `json:"expires_at"`
ClientIP string `json:"-"`
PoSSeed string `json:"pos_seed"` // Hex encoded
}</code></pre>
</div>
<p>
When a client requests a challenge, the parameters are delivered in an obfuscated format to prevent automated analysis:
<div class=code-example>
<span class=code-label>JSON</span>
<pre><code>{
"a": "base64-encoded-challenge",
"b": "base64-encoded-salt",
"c": 4,
"d": "hex-encoded-pos-seed"
}</code></pre>
</div>
</section>
<section id=proof-verification class=section>
<h2>Proof Verification</h2>
<p>
The system performs a two-step verification process:
<h3>1. Computational Proof (Proof of Work)</h3>
<p>
Verification checks that the hash of the challenge, salt, and nonce combination has the required number of leading zeros:
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>func verifyProofOfWork(challenge, salt, nonce string, difficulty int) bool {
input := challenge + salt + nonce
hash := calculateHash(input)
// Check if the hash has the required number of leading zeros
prefix := strings.Repeat("0", difficulty)
return strings.HasPrefix(hash, prefix)
}
func calculateHash(input string) string {
hash := sha256.Sum256([]byte(input))
return hex.EncodeToString(hash[:])
}</code></pre>
</div>
<h3>2. Memory Proof (Proof of Space)</h3>
<p>
In addition to the computational work, clients must prove they can allocate and manipulate significant memory resources:
<ul>
<li>Clients allocate between 48MB to 160MB of memory (size determined by the PoS seed)
<li>Client divides memory into 4-8 chunks and performs deterministic filling operations
<li>The process is run three times, hashing the entire buffer each time
<li>The resulting hashes and execution times are submitted for verification
</ul>
<p>
The server verifies:
<ul>
<li>All three hashes are identical (proving deterministic execution)
<li>Each hash is 64 characters (valid SHA-256)
<li>Execution times are consistent (within 20% variation)
</ul>
<div class=note>
<p>
The dual-verification approach makes the system resistant to specialized hardware acceleration. While the computational proof can be solved by ASICs or GPUs, the memory proof is specifically designed to be inefficient on such hardware.
</div>
</section>
<section id=token-structure class=section>
<h2>Token Structure</h2>
<p>
Checkpoint tokens contain various fields for security and binding:
<div class=table-container>
<table>
<tr>
<th>Field
<th>Description
<th>Purpose
<tr>
<td>Nonce
<td>The solution to the challenge
<td>Verification proof
<tr>
<td>ExpiresAt
<td>Token expiration timestamp
<td>Enforces time-limited access (24 hours)
<tr>
<td>ClientIP
<td>Hashed full client IP
<td>Device binding (first 8 bytes of SHA-256)
<tr>
<td>UserAgent
<td>Hashed user agent
<td>Browser binding
<tr>
<td>BrowserHint
<td>Derived from Sec-CH-UA headers
<td>Additional client identity verification
<tr>
<td>Entropy
<td>Random data
<td>Prevents token prediction/correlation
<tr>
<td>Created
<td>Token creation timestamp
<td>Token age tracking
<tr>
<td>LastVerified
<td>Last verification timestamp
<td>Token usage tracking
<tr>
<td>Signature
<td>HMAC signature
<td>Prevents token forgery
<tr>
<td>TokenFormat
<td>Version number
<td>Backward compatibility support
</table>
</div>
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>type CheckpointToken struct {
Nonce string `json:"g"` // Nonce
ExpiresAt time.Time `json:"exp"`
ClientIP string `json:"cip,omitempty"`
UserAgent string `json:"ua,omitempty"`
BrowserHint string `json:"bh,omitempty"`
Entropy string `json:"ent,omitempty"`
Created time.Time `json:"crt"`
LastVerified time.Time `json:"lvf,omitempty"`
Signature string `json:"sig,omitempty"`
TokenFormat int `json:"fmt"`
}</code></pre>
</div>
<h3>Token Security</h3>
<p>
Every token is cryptographically signed using HMAC-SHA256 with a server-side secret:
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>func computeTokenSignature(token CheckpointToken, tokenBytes []byte) string {
tokenCopy := token
tokenCopy.Signature = "" // Ensure signature field is empty for signing
tokenToSign, _ := json.Marshal(tokenCopy)
h := hmac.New(sha256.New, hmacSecret)
h.Write(tokenToSign)
return hex.EncodeToString(h.Sum(nil))
}
func verifyTokenSignature(token CheckpointToken, tokenBytes []byte) bool {
if token.Signature == "" {
return false
}
expectedSignature := computeTokenSignature(token, tokenBytes)
return hmac.Equal([]byte(token.Signature), []byte(expectedSignature))
}</code></pre>
</div>
<h3>Token Storage</h3>
<p>
Successfully verified tokens are stored in a persistent store for faster validation:
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>// TokenStore manages persistent storage of verified tokens
type TokenStore struct {
VerifiedTokens map[string]time.Time `json:"verified_tokens"`
Mutex sync.RWMutex `json:"-"`
FilePath string `json:"-"`
}
// Each token is identified by a unique hash
func calculateTokenHash(token CheckpointToken) string {
data := fmt.Sprintf("%s:%s:%d",
token.Nonce, // Use nonce as part of the key
token.Entropy, // Use entropy as part of the key
token.Created.UnixNano()) // Use creation time
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}</code></pre>
</div>
</section>
<section id=security-features class=section>
<h2>Security Features</h2>
<div class=security>
<h3>Anti-Forgery Protections</h3>
<ul>
<li><strong>HMAC Signatures:</strong> Each token is cryptographically signed using HMAC-SHA256 to prevent tampering
<li><strong>Token Binding:</strong> Tokens are bound to client properties (hashed full IP, hashed user agent, browser client hints)
<li><strong>Random Entropy:</strong> Each token contains unique entropy to prevent token prediction or correlation
<li><strong>Format Versioning:</strong> Tokens include a format version to support evolving security requirements
</ul>
</div>
<div class=security>
<h3>Replay Prevention</h3>
<ul>
<li><strong>Nonce Tracking:</strong> Used nonces are tracked to prevent replay attacks
<li><strong>Expiration Times:</strong> All tokens and challenges have expiration times
<li><strong>Token Cleanup:</strong> Expired tokens are automatically purged from the system
<li><strong>Challenge Invalidation:</strong> Challenges are immediately invalidated after successful verification
</ul>
</div>
<div class=security>
<h3>Rate Limiting</h3>
<ul>
<li><strong>IP-Based Limits:</strong> Maximum verification attempts per hour (default: 10)
<li><strong>Request ID Binding:</strong> Challenge parameters are bound to the requesting IP
<li><strong>Challenge Expiration:</strong> Challenges expire after 5 minutes to prevent stockpiling
</ul>
</div>
<div class=security>
<h3>Advanced Verification</h3>
<ul>
<li><strong>Proof of Space:</strong> Memory-intensive operations prevent GPU/ASIC acceleration
<li><strong>Browser Fingerprinting:</strong> Secure client-hint headers verify legitimate browsers
<li><strong>Challenge Obfuscation:</strong> Challenges are encoded and structured to resist automated analysis
<li><strong>Persistent Secret:</strong> The system uses a persistent HMAC secret stored securely on disk
</ul>
</div>
</section>
<section id=configuration class=section>
<h2>Configuration Options</h2>
<p>
The Checkpoint system can be configured through these constants:
<div class=table-container>
<table>
<tr>
<th>Constant
<th>Description
<th>Default
<tr>
<td>Difficulty
<td>Number of leading zeros required in the hash
<td>4
<tr>
<td>TokenExpiration
<td>Duration for which a token is valid
<td>24 hours
<tr>
<td>Cookie Name
<td>__Host-checkpoint_token
<td>The cookie name storing the issued token
<tr>
<td>maxAttemptsPerHour
<td>Rate limit for verification attempts
<td>10
<tr>
<td>saltLength
<td>Length of the random salt in bytes
<td>16
<tr>
<td>maxNonceAge
<td>Time before nonces are cleaned up
<td>24 hours
<tr>
<td>challengeExpiration
<td>Time before a challenge expires
<td>5 minutes
</table>
</div>
<div class=warning>
<p>
<strong>Warning:</strong> Increasing the Difficulty significantly increases the computational work required by clients.
A value that's too high may result in poor user experience, especially on mobile devices.
</div>
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>const (
// Difficulty defines the number of leading zeros required in hash
Difficulty = 4
// TokenExpiration sets token validity period
TokenExpiration = 24 * time.Hour
// CookieName defines the cookie name for tokens
CookieName = "__Host-checkpoint_token"
// Max verification attempts per IP per hour
maxAttemptsPerHour = 10
// Salt length for additional entropy
saltLength = 16
)</code></pre>
</div>
</section>
<section id=middleware class=section>
<h2>Middleware Integration</h2>
<p>
The Checkpoint system provides a middleware handler that automatically protects HTML routes while bypassing API routes and static assets:
<h3>HTMLCheckpointMiddleware</h3>
<p>
This middleware is optimized for HTML routes, with smart content-type detection and automatic exclusions for static assets and API endpoints.
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>// HTMLCheckpointMiddleware handles challenges specifically for HTML pages
func HTMLCheckpointMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// Allow certain paths to bypass verification
path := c.Path()
if path == "/video-player" || path == "/video-player.html" || strings.HasPrefix(path, "/videos/") {
return c.Next()
}
if strings.HasPrefix(path, "/api") {
return c.Next()
}
if path == "/favicon.ico" || (strings.Contains(path, ".") && !strings.HasSuffix(path, ".html")) {
return c.Next()
}
// Only apply to HTML routes
isHtmlRoute := strings.HasSuffix(path, ".html") || path == "/" ||
(len(path) > 0 && !strings.Contains(path, "."))
if !isHtmlRoute {
return c.Next()
}
token := c.Cookies(CookieName)
if token != "" {
valid, err := validateToken(token, c)
if err == nil && valid {
return c.Next()
}
}
return serveInterstitial(c)
}
}</code></pre>
</div>
<h3>Usage in Application</h3>
<div class=code-example>
<span class=code-label>Go</span>
<pre><code>// Enable HTML checkpoint protection for all routes
app.Use(middleware.HTMLCheckpointMiddleware())
// API group with verification endpoints
api := app.Group("/api")
// Verification endpoints
api.Post("/pow/verify", middleware.VerifyCheckpointHandler)
api.Get("/pow/challenge", middleware.GetCheckpointChallengeHandler)
// Example protected API endpoint
api.Get("/protected", func(c *fiber.Ctx) error {
// Access is already verified by cookie presence
return c.JSON(fiber.Map{
"message": "You have accessed the protected endpoint!",
"time": time.Now(),
})
})</code></pre>
</div>
</section>
<section id=client-side class=section>
<h2>Client-Side Implementation</h2>
<p>
The client-side implementation is handled by the interstitial page and its associated JavaScript:
<ol>
<li>Client attempts to access a protected resource
<li>Server serves the interstitial page with a request ID
<li>JavaScript fetches challenge parameters from <code>/api/pow/challenge?id=REQUEST_ID</code>
<li>Two verification stages run in parallel:
<ul>
<li>Computational proof: Using Web Workers to find a valid nonce
<li>Memory proof: Allocating and manipulating memory buffers
</ul>
<li>Results are submitted to <code>/api/pow/verify</code> endpoint
<li>On success, the server sets a cookie and redirects to the original URL
</ol>
<h3>Web Worker Implementation</h3>
<p>
Computational proof is handled by Web Workers to avoid freezing the UI:
<div class=code-example>
<span class=code-label>JavaScript</span>
<pre><code>function workerFunction() {
self.onmessage = function(e) {
const { type, data } = e.data;
if (type === 'pow') {
// PoW calculation
const { challenge, salt, startNonce, endNonce, target, batchId } = data;
let count = 0;
let solution = null;
processNextNonce(startNonce);
function processNextNonce(nonce) {
const input = String(challenge) + String(salt) + nonce.toString();
const msgBuffer = new TextEncoder().encode(input);
crypto.subtle.digest('SHA-256', msgBuffer)
.then(hashBuffer => {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const result = hashArray.map(b =>
b.toString(16).padStart(2, '0')).join('');
count++;
if (result.startsWith(target)) {
solution = { nonce: nonce.toString(), found: true };
self.postMessage({
type: 'pow_result',
solution: solution,
count: count,
batchId: batchId
});
return;
}
if (nonce < endNonce && !solution) {
setTimeout(() => processNextNonce(nonce + 1), 0);
} else if (!solution) {
self.postMessage({
type: 'pow_result',
solution: null,
count: count,
batchId: batchId
});
}
});
}
}
};
}</code></pre>
</div>
<h3>Memory Proof Implementation</h3>
<p>
The memory proof allocates and manipulates large buffers to verify client capabilities:
<div class=code-example>
<span class=code-label>JavaScript</span>
<pre><code>async function runProofOfSpace(seedHex, isDecoy) {
// Deterministic memory size (48MB to 160MB) based on seed
const minMB = 48, maxMB = 160;
let seedInt = parseInt(seedHex.slice(0, 8), 16);
const CHUNK_MB = minMB + (seedInt % (maxMB - minMB + 1));
const CHUNK_SIZE = CHUNK_MB * 1024 * 1024;
// Chunk memory for controlled allocation
const chunkCount = 4 + (seedInt % 5); // 4-8 chunks
const chunkSize = Math.floor(CHUNK_SIZE / chunkCount);
// Run the proof multiple times to verify consistency
const runs = 3;
const hashes = [];
const times = [];
// For each run...
for (let r = 0; r < runs; r++) {
// Generate deterministic chunk order
let prng = seededPRNG(seedHex + r.toString(16));
let order = Array.from({length: chunkCount}, (_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = prng() % (i + 1);
[order[i], order[j]] = [order[j], order[i]];
}
// Allocate and fill memory buffer
let t0 = performance.now();
let buf = new ArrayBuffer(CHUNK_SIZE);
let view = new Uint8Array(buf);
// Fill buffer with deterministic pattern
for (let c = 0; c < chunkCount; c++) {
let chunkIdx = order[c];
let start = chunkIdx * chunkSize;
let end = (chunkIdx + 1) * chunkSize;
for (let i = start; i < end; i += 4096) {
view[i] = prng() & 0xFF;
}
}
// Hash the entire buffer
let hashBuf = await crypto.subtle.digest('SHA-256', view);
let t2 = performance.now();
// Convert hash to hex string
let hashHex = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, '0')).join('');
// Store results
hashes.push(hashHex);
times.push(Math.round(t2 - t0));
// Clean up
buf = null; view = null;
}
return { hashes, times };
}</code></pre>
</div>
<div class=note>
<p>
The client-side implementation is designed to be difficult to reverse-engineer. The obfuscated API responses, minimal logging, and anti-debugging measures prevent automated circumvention.
</div>
</section>
<section id=api-endpoints class=section>
<h2>API Endpoints</h2>
<p>
The Checkpoint system exposes two primary API endpoints:
<h3>1. Challenge Endpoint</h3>
<p>
Retrieves challenge parameters for a verification request:
<div class=code-example>
<span class=code-label>HTTP</span>
<pre><code>GET /api/pow/challenge?id=REQUEST_ID
Response:
{
"a": "base64-encoded-challenge",
"b": "base64-encoded-salt",
"c": 4,
"d": "hex-encoded-pos-seed"
}</code></pre>
</div>
<h3>2. Verification Endpoint</h3>
<p>
Accepts proof solutions and issues tokens when valid:
<div class=code-example>
<span class=code-label>HTTP</span>
<pre><code>POST /api/pow/verify
Request:
{
"request_id": "unique-request-id",
"g": "nonce-solution",
"h": ["pos-hash1", "pos-hash2", "pos-hash3"],
"i": [time1, time2, time3]
}
Response:
{
"token": "base64-encoded-token",
"expires_at": "2025-04-17T18:57:48Z"
}</code></pre>
</div>
<div class=note>
<p>
<strong>Backwards Compatibility:</strong> The older endpoint <code>/api/verify</code> is maintained for compatibility with existing clients.
</div>
</section>
<footer>
<div class=doc-version-note>These docs reflect version 2.0 of the <strong>Checkpoint Protection System</strong>.</div>
<p>Last updated: <span id=last-updated>Tuesday, April 16, 2025</span>
</footer>
</div>

41
public/html/index.html Normal file
View file

@ -0,0 +1,41 @@
<!doctype html><html lang=en>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>caileb.com</title>
<meta name=description content="Public-facing services hosted on caileb.com">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/js/u.js as=script>
<link rel=preload as=image href=/images/logos/immich.svg>
<link rel=preload as=image href=/images/logos/jellyfin.svg>
<link rel=preload as=image type=image/webp href=/images/logos/linkwarden.webp>
<link rel=preload as=image href=/images/logos/navidrome.svg>
<link rel=manifest href=/manifest.json>
<link rel=stylesheet href=/css/u.css>
<style>:root{--background-color:#121212;--card-gradient-start:#1e1e1e;--card-gradient-end:#333;--header-background:#262626;--text-color:#fff;--accent-color:#9B59B6;--subtext-color:#ccc}*{margin:0;padding:0;box-sizing:border-box}body{background:#1c1c1c;color:var(--text-color);display:flex;flex-direction:column;align-items:center;justify-content:flex-start;margin:0;padding:20px 20px 0}@media(min-width:1024px){body{height:100vh;justify-content:center;padding:20px 20px 60px}footer{position:fixed;bottom:0;left:0;width:100%}}footer{font-size:1.2rem}.container{max-width:1200px;width:100%}header{text-align:center;background:var(--header-background);padding:2rem;border-radius:10px;margin-bottom:2rem;box-shadow:0 4px 10px rgba(0,0,0,.3)}header h1{font-size:3rem;margin-bottom:.5rem;color:var(--accent-color)}header p{font-size:1.25rem;color:var(--subtext-color)}.grid{display:flex;flex-wrap:wrap;gap:1.5rem;justify-content:center}.card{flex-basis:280px;border-radius:12px;padding:1.5rem;text-decoration:none;color:inherit;display:flex;flex-direction:column;align-items:center}.card:hover{transform:translateY(-5px);box-shadow:0 10px 20px rgba(0,0,0,.25)}.card .icon{width:112px;height:112px;margin-bottom:1rem;object-fit:contain}.card h2{font-size:1.5rem;margin-bottom:.5rem}.card p{font-size:1rem;text-align:center}footer{text-align:center;padding:10px 0;background-color:initial;color:#c3c3c3;font-size:1.2rem}footer a{color:#cf7bf1;text-decoration:none}footer a:hover{text-decoration:underline}.no-hover .card{transition:none!important}.no-hover .card:hover{transform:none!important;box-shadow:none!important}@keyframes waveColor{0%{color:grey}50%{color:#fff}100%{color:grey}}.highlight-letter{display:inline-block;animation-name:waveColor;animation-duration:1.2s;animation-iteration-count:infinite;animation-timing-function:linear}</style>
<script src=/js/u.js async></script>
<div class=container>
<section class=grid>
<a class="card eel" href=https://gallery.caileb.com style=background:linear-gradient(135deg,rgba(252,87,94,.55),rgba(247,180,44,.55))>
<img alt="Immich Logo" class=icon src=/images/logos/immich.svg sizes=112x112>
<h1>Immich</h1>
</a>
<a class="card eel" href=https://jellyfin.caileb.com style=background:linear-gradient(135deg,rgba(99,49,148,.55),rgba(43,131,237,.55))>
<img alt="Jellyfin Logo" class=icon src=/images/logos/jellyfin.svg sizes=112x112>
<h1>Jellyfin</h1>
</a>
<a class="card eel" href=https://archive.caileb.com style=background:linear-gradient(135deg,rgba(15,76,129,.55),rgba(22,191,253,.55))>
<img alt="Linkwarden Logo" class=icon src=/images/logos/linkwarden.webp sizes=112x112>
<h1>Linkwarden</h1>
</a>
<a class="card eel" href=https://music.caileb.com style=background:linear-gradient(135deg,rgba(33,150,243,.55),rgba(3,218,197,.55))>
<img alt="Navidrome Logo" class=icon src=/images/logos/navidrome.svg sizes=112x112>
<h1>Navidrome</h1>
</a>
</section>
</div>
<footer id=email-footer>Email: <a href=mailto:a@caileb.com>a@caileb.com</a></footer>

View file

@ -0,0 +1,190 @@
<!doctype html><html lang=en>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Integrity Checker - caileb.com</title>
<meta name=description content="Demonstrates the SRI integrity checker feature">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel=stylesheet href=/css/u.css>
<link rel=stylesheet href=/css/docs.css>
<link rel=stylesheet href=https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css integrity=sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN crossorigin=anonymous>
<link rel=preload href=/js/u.js as=script>
<link rel=preload href=https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js as=script integrity=sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs crossorigin=anonymous>
<link rel=preload href=https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js as=script integrity=sha384-H6KKS1H1WwuERMSm+54dYLzjg0fKqRK5ZRyASdbrI/lwrCc6bXEmtGYr5SwvP1pZ crossorigin=anonymous>
<script src=https://cdnjs.cloudflare.com/ajax/libs/quicklink/2.3.0/quicklink.umd.js integrity=sha384-aD7FsuQkS1ohgFKY41fJfeA+Wd/QRNnrOd9Bs58K3FzKdJJv8yPnYU8Tnp5z1agS crossorigin=anonymous></script>
<style>:root{--background-color:#121212;--card-gradient-start:#1e1e1e;--card-gradient-end:#333;--header-background:#262626;--text-color:#fff;--accent-color:#9B59B6;--subtext-color:#ccc;--success-color:#2ecc71;--error-color:#e74c3c;--warning-color:#f39c12}body{background:#1c1c1c;color:var(--text-color);font-family:Poppins,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;margin:0;padding:20px;min-height:100vh}.container{max-width:800px;width:100%}header{text-align:center;margin-bottom:2rem}header h1{font-size:2.5rem;margin-bottom:.5rem;color:var(--accent-color)}header p{font-size:1.25rem;color:var(--subtext-color)}.info-box{background:linear-gradient(135deg,rgba(30,30,30,.8),rgba(51,51,51,.8));border-radius:12px;padding:1.5rem;margin:1.5rem 0;box-shadow:0 4px 10px rgba(0,0,0,.25)}h2{color:var(--accent-color);margin:1.5rem 0 1rem;font-size:1.8rem;font-weight:600}h3{color:var(--accent-color);margin:1rem 0;font-size:1.4rem;font-weight:600}p,li{color:var(--text-color);margin-bottom:.75rem;line-height:1.6}code{background-color:rgba(0,0,0,.3);padding:2px 6px;border-radius:4px;font-family:monospace;color:#e0e0e0}.resource-table{width:100%;border-collapse:collapse;margin:1.5rem 0;background:rgba(30,30,30,.6);border-radius:8px;overflow:hidden}.resource-table th,.resource-table td{padding:12px 15px;text-align:left;border-bottom:1px solid #444}.resource-table tr:last-child td{border-bottom:none}.resource-table th{background-color:rgba(0,0,0,.3);color:var(--accent-color);font-weight:600}.external{color:#e74c3c}.local{color:#2ecc71}ol,ul{padding-left:1.5rem;margin-bottom:1.5rem}.demo-section{margin:2rem 0}.status-indicator{display:inline-block;width:12px;height:12px;border-radius:50%;margin-right:8px}.status-loaded{background-color:var(--success-color)}.status-error{background-color:var(--error-color)}.status-pending{background-color:var(--warning-color)}.script-status{display:flex;align-items:center;margin-bottom:.75rem;padding:.75rem;border-radius:8px;background:rgba(0,0,0,.2)}.demo-card{background:linear-gradient(135deg,rgba(30,30,30,.6),rgba(51,51,51,.6));border-radius:12px;padding:1.5rem;margin:1rem 0;box-shadow:0 4px 8px rgba(0,0,0,.2)}.demo-card h4{color:var(--accent-color);margin-top:0;margin-bottom:1rem;font-size:1.2rem;font-weight:600}.demo-result{background:rgba(0,0,0,.3);border-radius:8px;padding:1rem;margin-top:1rem;font-family:monospace;color:#e0e0e0;min-height:24px}button{background:var(--accent-color);color:#fff;border:none;padding:.75rem 1.5rem;border-radius:8px;font-size:1rem;font-family:Poppins,sans-serif;font-weight:600;cursor:pointer;transition:all .2s ease}button:hover{background:#8e44ad;transform:translateY(-2px);box-shadow:0 4px 12px rgba(155,89,182,.4)}.flex-container{display:flex;gap:1rem;flex-wrap:wrap}.flex-container>div{flex:1;min-width:250px}.toc{background-color:rgba(30,30,30,.5);border-radius:8px;padding:20px;margin:20px 0 30px;border:1px solid var(--border-color)}.toc h2{margin-top:0;text-align:center;border-bottom:1px solid var(--border-color);padding-bottom:10px;margin-bottom:15px;color:var(--accent-color)}.toc ul{list-style-type:none;padding-left:0;margin:0;display:flex;flex-wrap:wrap;gap:10px;justify-content:center}.toc li{margin-bottom:8px;flex:none}.toc a{display:block;padding:5px 15px;border-radius:4px;transition:background-color .2s ease;background-color:rgba(20,20,20,.5);white-space:nowrap}.toc a:hover{background-color:rgba(50,50,50,.5);text-decoration:none}.section{scroll-margin-top:20px;margin-bottom:2.5rem}.feature-card{background-color:rgba(40,40,40,.5);border-radius:8px;padding:20px;border:1px solid var(--border-color);margin-bottom:15px}.feature-card h3{color:var(--accent-color);margin-top:0;text-align:left;border-bottom:1px solid rgba(255,255,255,.1);padding-bottom:10px}.code-example{position:relative;margin:1.5rem 0}.code-label{position:absolute;top:-12px;right:10px;background-color:var(--accent-color);color:#fff;font-size:.8rem;padding:2px 8px;border-radius:4px}pre{background-color:rgba(0,0,0,.3);border-radius:8px;padding:1rem;overflow-x:auto;margin:0}code{font-family:monospace;color:#e0e0e0}@media(max-width:768px){.toc ul{flex-direction:column;align-items:stretch}.toc a{text-align:center;white-space:normal}.resource-status,.demo-cards{grid-template-columns:1fr}}</style>
<script src=/js/u.js async></script>
<div class=container>
<h1>Auto-Integrity Hash Demo</h1>
<div class=info-box>
<p><strong>This is a live demonstration of automatic SRI hash generation.</strong>
<p>The server automatically adds integrity hashes to all external resources when the site is built - no manual work required.
<p>If you view the source code of this page, you'll see all external CSS and JavaScript files have <code>integrity</code> and <code>crossorigin</code> attributes that were added automatically during build.
<p>This security feature protects against compromised CDNs and ensures resources haven't been tampered with.
</div>
<h2>External Scripts Working</h2>
<p>These demos confirm that the external scripts are loaded and working correctly with their integrity hashes:
<div class=demo-cards>
<div class=demo-card>
<h3>jQuery Demo</h3>
<p>jQuery provides DOM manipulation and animation capabilities.
<div class=demo-result id=jquery-result>Running jQuery test...</div>
</div>
<div class=demo-card>
<h3>Lodash Demo</h3>
<p>Lodash provides utility functions for common programming tasks.
<div class=demo-result id=lodash-result>Running Lodash test...</div>
</div>
</div>
<div class=demo-cards>
<div class=demo-card>
<h3>Bootstrap Components</h3>
<p>Bootstrap provides responsive UI components.
<div class=demo-result id=bootstrap-result>
<div class="alert alert-info">
This is a Bootstrap alert component
</div>
<div class=progress style=height:20px;background-color:#444>
<div class="progress-bar bg-success" role=progressbar style=width:75% aria-valuenow=75 aria-valuemin=0 aria-valuemax=100>75%</div>
</div>
</div>
</div>
<div class=demo-card>
<h3>Quicklink Demo</h3>
<p>Quicklink prefetches links that are in the viewport.
<div class=demo-result id=quicklink-result>Running Quicklink test...</div>
</div>
</div>
<h2>Monitored Resources</h2>
<p>The following resources have integrity checks automatically applied during build:
<div class=table-container>
<table>
<thead>
<tr>
<th>Resource Type
<th>Location
<th>Integrity Added?
<tbody>
<tr>
<td>Stylesheet
<td class=local>/css/u.css
<td>No (Local)
<tr>
<td>Stylesheet
<td class=external>Bootstrap CSS (CDN)
<td>Yes (External)
<tr>
<td>Preloaded Script
<td class=local>/js/u.js
<td>No (Local)
<tr>
<td>Preloaded Script
<td class=external>jQuery (CDN)
<td>Yes (External)
<tr>
<td>Preloaded Script
<td class=external>Lodash (CDN)
<td>Yes (External)
<tr>
<td>Script
<td class=external>Quicklink (CDN)
<td>Yes (External)
</table>
</div>
</div>
<script src=/js/u.js></script>
<script src=https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js integrity=sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs crossorigin=anonymous></script>
<script src=https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js integrity=sha384-H6KKS1H1WwuERMSm+54dYLzjg0fKqRK5ZRyASdbrI/lwrCc6bXEmtGYr5SwvP1pZ crossorigin=anonymous></script>
<script>
// Auto-run jQuery demo
function runJqueryDemo() {
const resultElement = document.getElementById('jquery-result');
try {
if (typeof jQuery !== 'undefined') {
resultElement.textContent = '';
const demoText = document.createElement('div');
demoText.textContent = 'jQuery ' + jQuery.fn.jquery + ' loaded successfully! This color animation is powered by jQuery.';
resultElement.appendChild(demoText);
// Use jQuery for color animation
jQuery(demoText).css('color', '#e74c3c')
.animate({ color: '#2ecc71' }, 1000)
.animate({ color: '#3498db' }, 1000)
.animate({ color: '#f39c12' }, 1000)
.animate({ color: '#9b59b6' }, 1000);
} else {
resultElement.textContent = 'Error: jQuery is not loaded';
}
} catch (e) {
resultElement.textContent = 'Error: ' + e.message;
}
}
// Auto-run Lodash demo
function runLodashDemo() {
const resultElement = document.getElementById('lodash-result');
try {
if (typeof _ !== 'undefined') {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const chunked = _.chunk(array, 3);
const shuffled = _.shuffle([...array]);
const summed = _.sum(array);
resultElement.innerHTML =
'<div>Lodash ' + _.VERSION + ' loaded successfully!</div>' +
'<div>• Chunking [1-10] into groups of 3: ' + JSON.stringify(chunked) + '</div>' +
'<div>• Shuffled array: ' + JSON.stringify(shuffled) + '</div>' +
'<div>• Sum of array: ' + summed + '</div>';
} else {
resultElement.textContent = 'Error: Lodash is not loaded';
}
} catch (e) {
resultElement.textContent = 'Error: ' + e.message;
}
}
// Auto-run Quicklink check
function runQuicklinkCheck() {
const resultElement = document.getElementById('quicklink-result');
try {
if (typeof quicklink !== 'undefined') {
// Call quicklink to prefetch
quicklink.listen();
resultElement.innerHTML =
'<div>Quicklink loaded successfully!</div>' +
'<div>Now prefetching links as you scroll near them.</div>' +
'<div style="margin-top: 10px; font-size: 0.9em; color: #aaa;">Check network tab in dev tools to see prefetch requests.</div>';
} else {
resultElement.textContent = 'Error: Quicklink is not loaded';
}
} catch (e) {
resultElement.textContent = 'Error: ' + e.message;
}
}
// Check when DOM is fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Small delay to ensure scripts are fully initialized
setTimeout(function() {
runJqueryDemo();
runLodashDemo();
runQuicklinkCheck();
}, 300);
});
// Fallback if DOMContentLoaded already fired
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(function() {
runJqueryDemo();
runLodashDemo();
runQuicklinkCheck();
}, 300);
}
</script>

175
public/html/kb.html Normal file
View file

@ -0,0 +1,175 @@
<!doctype html><html lang=en>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Caileb's Knowledgebase</title>
<meta name=description content="Caileb's Knowledgebase">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/js/u.js as=script>
<link rel=stylesheet href=/css/u.css>
<style>:root{--background-color:#1e1e1e;--text-color:#cfcfcf;--heading-color:#ffffff;--code-block-bg:#333;--code-block-border:#4d9cfa;--code-color:#dcdcdc;--link-color:#4d9cfa;--link-hover-color:#4d9cfa;--input-border:#f39c12;--input-focus-border:#e67e22;--collapsible-bg:#2d2d2d;--collapsible-hover-bg:#3a3a3a;--scrollbar-bg:#2d2d2d;--scrollbar-thumb-bg:#4d9cfa;--scrollbar-thumb-hover-bg:#3971a3;--section-divider:#333;--section-accent:#f39c12}body{background-color:var(--background-color);color:var(--text-color);margin:0;line-height:1.4;display:flex;flex-direction:column;align-items:center;padding:20px}.container{max-width:700px;width:100%}h1,h3,h4{color:var(--heading-color);margin-top:0}h2{color:var(--heading-color);margin-top:0;margin-bottom:15px;position:relative;padding-bottom:10px;display:inline-block;cursor:pointer}h2:hover::before{content:"#";position:absolute;left:-1.6rem;color:var(--link-color);opacity:.7}h2::after{content:"";position:absolute;bottom:12px;left:0;width:100%;height:2px;background:var(--section-accent)}.section{margin-bottom:40px;padding-bottom:20px;position:relative}.section::after{content:"";position:absolute;bottom:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--section-divider) 10%,var(--section-divider) 90%,transparent)}.section:last-child::after{display:none}.code-block{background-color:var(--code-block-bg);color:var(--heading-color);padding:10px;border-radius:5px;overflow-x:auto;white-space:nowrap;width:100%;box-sizing:border-box;position:relative;margin-bottom:20px;border:1px solid var(--code-block-border)}.code-block.command::before{content:"$";color:var(--code-block-border);position:absolute;left:10px;top:11px;font-family:Consolas,courier new,monospace}code{font-family:Consolas,courier new,monospace;color:var(--code-color)}code.command{padding-left:15px}a{color:var(--link-color);text-decoration:none}a:hover{text-decoration:underline}.collapsible-label{background-color:var(--collapsible-bg);color:var(--text-color);cursor:pointer;padding:10px;width:100%;border:2px solid var(--input-border);text-align:left;outline:0;font-size:18px;border-radius:5px 5px 0 0;margin-bottom:0;display:flex;justify-content:space-between;align-items:center;max-width:700px;box-sizing:border-box;transition:background-color .3s,border-color .3s;position:relative;z-index:2}.collapsible-label:hover{background-color:var(--collapsible-hover-bg);border-color:var(--input-focus-border)}.collapsible-input{display:none}.content-wrapper{width:100%;position:relative;z-index:1;margin-bottom:20px}.content{max-height:0;overflow:hidden;background-color:var(--collapsible-bg);box-sizing:border-box;width:100%;border:2px solid transparent;border-top:none;transition:max-height .45s ease,padding .45s ease,border-color .45s ease;padding:0 10px}.collapsible-input:checked+.collapsible-label+.content{border-color:var(--input-border);padding:10px}.arrow{transition:transform .3s}.collapsible-input:checked+.collapsible-label .arrow{transform:rotate(180deg)}.step{margin-bottom:20px}.step h4{margin-bottom:10px}</style>
<script src=/js/u.js async></script>
<div class=container>
<div class=section id=fail2ban>
<h2>Fail2ban</h2>
<div class=content-wrapper>
<input type=checkbox id=collapsible-fail2ban class=collapsible-input>
<label for=collapsible-fail2ban class=collapsible-label>
Setup Fail2ban
<span class=arrow>&#9660;</span>
</label>
<div class=content>
<div class=step>
<h4>Step 1: Install Fail2ban (Debian/Ubuntu)</h4>
<p>First, install Fail2ban by running:
<div class="code-block command">
<code class=command>sudo apt install fail2ban</code>
</div>
</div>
<div class=step>
<h4>Step 2: Navigate to the Fail2ban Directory</h4>
<p>Change to the Fail2ban configuration directory:
<div class="code-block command">
<code class=command>cd /etc/fail2ban/</code>
</div>
</div>
<div class=step>
<h4>Step 3: Copy the Example Configuration File</h4>
<p>Copy the example configuration file as a base for your custom configuration:
<div class="code-block command">
<code class=command>sudo cp jail.conf jail.local</code>
</div>
</div>
<div class=step>
<h4>Step 4: Create a New Filter</h4>
<p>Navigate to the filter.d directory and create a new filter file:
<div class="code-block command">
<code class=command>cd filter.d/</code>
</div>
<div class="code-block command">
<code class=command>sudo nano nginx-4xx.conf</code>
</div>
</div>
<div class=step>
<h4>Step 5: Define the Filter to Block Repeated 4xx Errors</h4>
<p>Add the following content to the <code>nginx-4xx.conf</code> file:
<div class=code-block>
<code>[Definition]<br>failregex = ^&lt;HOST>.*"(GET|POST|HEAD|CONNECT).*" (404|444|403|400) .*<br>ignoreregex = 127.0.0.1 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16</code>
</div>
</div>
<div class=step>
<h4>Step 6: Edit the Jail Configuration to Use the New Filter</h4>
<p>Go back to the previous directory and edit <code>jail.local</code>:
<div class="code-block command">
<code class=command>cd ..</code>
</div>
<div class="code-block command">
<code class=command>sudo nano jail.local</code>
</div>
<p>Add the following section:
<div class=code-block>
<code>#<br># Repeated 4xx errors (Nginx)<br>#<br>[nginx-4xx]<br>enabled = true<br>port = http,https<br>logpath = /var/log/nginx/access.log<br>maxretry = 4</code>
</div>
</div>
<div class=step>
<h4>Step 7: Restart Fail2ban for the Changes to Take Effect</h4>
<p>Restart the Fail2ban service:
<div class="code-block command">
<code class=command>sudo systemctl restart fail2ban</code>
</div>
</div>
<div class=step>
<h4>Step 8: Check the Filter Status</h4>
<p>Verify the filter is working:
<div class="code-block command">
<code class=command>sudo fail2ban-client status nginx-4xx</code>
</div>
<h4>OR</h4>
<p>For a prettified output:
<div class="code-block command">
<code class=command>sudo fail2ban-client get nginx-4xx banip | tr ' ' '\n'</code>
</div>
</div>
</div>
</div>
</div>
<div class=section id=pm2>
<h2>Node PM2</h2>
<p>Restart
<pre class="code-block command">
<code class=command>pm2 restart caileb.com</code>
</pre>
</div>
<div class=section id=ffmpeg>
<h2>FFmpeg</h2>
<p>Highest quality AV1
<div class="code-block command">
<code class=command>ffmpeg -i input -c:v av1_nvenc -preset p7 -cq 1 -b:v 0 -qmin 1 -qmax 5 -rc-lookahead 250 -spatial-aq 1 -aq-strength 15 -refs 16 -temporal-aq 1 -c:a flac -compression_level 8 highest_quality.mkv</code>
</div>
<p>Standard compression
<div class="code-block command">
<code class=command>ffmpeg -i input -vf "mpdecimate" -fps_mode vfr -c:v av1_nvenc -preset p7 -cq 30 -b:v 0 -maxrate 18.5M -bufsize 25M -g 240 -keyint_min 24 -rc vbr -c:a libopus -b:a 128k compressed.webm</code>
</div>
<p>Extreme compression
<div class="code-block command">
<code class=command>ffmpeg -i input -vf "mpdecimate,scale=-1:1080" -fps_mode vfr -c:v av1_nvenc -preset p7 -rc vbr -b:v 6M -maxrate 12M -bufsize 18M -g 300 -keyint_min 34 -c:a libopus -b:a 96k compressed.webm</code>
</div>
<p>Rocket.Chat
<div class="code-block command">
<code class=command>ffmpeg -i input -vf "mpdecimate,scale=-1:1440" -fps_mode vfr -c:v av1_nvenc -preset p7 -rc vbr -b:v 8M -maxrate 15M -bufsize 22M -g 270 -keyint_min 28 -c:a libopus -b:a 112k rocket_chat.webm</code>
</div>
</div>
<div class=section id=html>
<h2>Useful HTML Stuffs</h2>
<p>Make iFrames/Images Lazy Load <a href=https://developer.mozilla.org/docs/Web/Performance/Lazy_loading#images_and_iframes target=_blank rel="noopener noreferrer">MDN</a>
<p>Replace FitVids or other similar JS libraries with CSS' aspect-ratio <a href=https://developer.mozilla.org/docs/Web/CSS/aspect-ratio target=_blank rel="noopener noreferrer">MDN</a>
</div>
<div class=section id=malware-removal>
<h2>Malware Removal</h2>
<ol>
<li>
<strong><a href=https://www.malwarebytes.com/mwb-download target=_blank rel="noopener noreferrer">Malwarebytes Free</a></strong>
<p>Easy-to-use tool that quickly detects and removes a broad range of malware.
<li>
<strong><a href=https://www.emsisoft.com/home/emergency-kit/ target=_blank rel="noopener noreferrer">Emsisoft Emergency Kit</a></strong>
<p>Utilizes Bitdefender's engine on top of their own for a strong all-in-one cleanup.
<li>
<strong><a href=https://www.sophos.com/free-tools/virus-removal-tool target=_blank rel="noopener noreferrer">Sophos Scan & Clean</a></strong>
<p>Portable scanner with effective heuristic analysis for detecting malware.
<li>
<strong><a href=https://www.kaspersky.com/downloads/free-virus-removal-tool target=_blank rel="noopener noreferrer">Kaspersky Virus Removal Tool</a></strong>
<p>Efficiently finds and removes stubborn malware threats.
</ol>
</div>
</div>
<script defer>
const collapsibleInputs = document.querySelectorAll(".collapsible-input");
collapsibleInputs.forEach((input) => {
input.addEventListener("change", function() {
const content = this.nextElementSibling.nextElementSibling;
content.style.maxHeight = this.checked ? content.scrollHeight + "px" : "0";
});
});
// Add click event to headers to update URL with section ID
document.addEventListener('DOMContentLoaded', function() {
// Get all h2 elements
const headers = document.querySelectorAll('h2');
// Add click event listeners to each header
headers.forEach(header => {
header.addEventListener('click', function() {
// Find the parent section with an ID
const section = this.closest('.section');
if (section && section.id) {
// Update the URL without reloading the page
history.pushState(null, null, `#${section.id}`);
}
});
});
});
</script>

550
public/html/lazy-video.html Normal file
View file

@ -0,0 +1,550 @@
<!doctype html><html lang=en>
<meta charset=UTF-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Lazy Video Component - Multi-Platform Video Embedding</title>
<meta name=description content="A lightweight, customizable web component for optimized video embeds from YouTube, Bitchute and more platforms with lazy loading for performance.">
<link rel=icon href=/images/favi.png type=image/png>
<link rel=apple-touch-icon href=/images/favi.png>
<link rel="shortcut icon" href=/images/favi.png>
<link rel=preload href=/webfonts/Poppins-Regular.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/webfonts/Poppins-SemiBold.woff2 as=font type=font/woff2 crossorigin>
<link rel=preload href=/js/u.js as=script>
<link rel=preload href=/js/lv.js as=script>
<link rel=stylesheet href=/css/u.css>
<link rel=stylesheet href=/css/docs.css>
<link rel=stylesheet href=https://unpkg.com/@speed-highlight/core@1.2.7/dist/themes/github-dark.css integrity=sha384-8HPbLmchBzGvBxngSZZwtxFdhC9KpkvDvgJvjR51kdEvVC7Pn2wLsWuFXRgDpQUh crossorigin=anonymous>
<script async src=/js/u.js></script>
<script async src=/js/lv.js></script>
<script type=module src=/js/docs.js></script>
<div class=container>
<h1>Lazy Video Docs</h1>
<div class=toc>
<h2>Contents</h2>
<ul>
<li><a href=#overview>Overview</a>
<li><a href=#basic-usage>Basic Usage</a>
<li><a href=#platforms>Supported Platforms</a>
<li><a href=#attributes>Attributes</a>
<li><a href=#styling>Styling & CSS Variables</a>
<li><a href=#examples>Examples</a>
<li><a href=#converting>Converting Existing iframes</a>
<li><a href=#security>Security & Privacy</a>
<li><a href=#browser-support>Browser Support</a>
<li><a href=#breaking-change>Breaking Change</a>
</ul>
</div>
<section id=overview class=section>
<h2>Overview</h2>
<p>
Embedding videos with standard <code>&lt;iframe></code> tags can dramatically slow down your site and consume large amounts of data. Each iframe loads the full video player and related resources immediately-even if the user never interacts with it. On pages with several videos, this can add <strong>hundreds of megabytes</strong> to the initial page load, resulting in a sluggish and costly experience, especially for users on mobile devices or limited networks.
<h3>How Lazy Video Helps</h3>
<p>
The <code>&lt;lazy-video></code> component solves this by loading only a lightweight thumbnail and play button at first. The actual video player is loaded only when the user clicks play (or when the video scrolls into view if <code>autoload</code> is enabled). This keeps your pages fast, responsive, and bandwidth-friendly.
<div class=lv-btn-group>
<a href=/js/lv.js target=_blank rel=noopener class="lv-btn lv-btn-primary">View Source</a>
<a href=/js/lv.js download class="lv-btn lv-btn-outline">Download</a>
<span class=lv-size-info>~17.0kB / <span>6.0kB</span> (Gzip)</span>
</div>
</section>
<section id=basic-usage class=section>
<h2>Basic Usage</h2>
<p>
To get started, include the script on your page and use the custom element as shown below:
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Till We Have Faces by Silent Planet"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class=note>
<p>
Always add a <code>title</code> for accessibility and better alt text on thumbnails.
</div>
</section>
<section id=platforms class=section>
<h2>Officially Supported Platforms</h2>
<div class=table-container>
<table>
<tr>
<th>Platform
<th>URL Pattern
<th>Notes
<tr>
<td>YouTube
<td>
<ul class=url-patterns>
<li><code>youtube.com/embed/ID</code>
<li><code>youtube.com/watch?v=ID</code>
<li><code>youtu.be/ID</code>
</ul>
<td>Full support for thumbnails and parameters.
<tr>
<td>Bitchute
<td>
<ul class=url-patterns>
<li><code>bitchute.com/video/ID/</code>
<li><code>bitchute.com/embed/ID/</code>
</ul>
<td>Custom thumbnails are only needed if autoload is disabled.
</table>
</div>
</section>
<section id=attributes class=section>
<h2>Attributes</h2>
<div class=table-container>
<table>
<tr>
<th>Attribute
<th>Description
<th>Default
<tr>
<td>src
<td>Video embed URL (required)
<td>N/A
<tr>
<td>title
<td>Video title
<td>"Video"
<tr>
<td>width
<td>Width in pixels or percent
<td>100% (responsive)
<tr>
<td>height
<td>Height in pixels
<td>16:9 ratio
<tr>
<td>thumbnail
<td>Custom thumbnail URL
<td>Auto-detected per platform
<tr>
<td>thumbnail-quality
<td>YouTube thumbnail quality (default, hq, mq, sd, maxres)
<td>Auto (maxres on desktop, hq on mobile)
<tr>
<td>service
<td>Force a specific service (youtube, bitchute)
<td>Auto-detected
<tr>
<td>sandbox
<td>Extra security for the iframe. Restricts what the embedded player can do. See <a href=https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox target=_blank>MDN</a> for details.
<td>allow-scripts allow-same-origin allow-popups allow-forms allow-presentation
<tr>
<td>no-cookie
<td>Use youtube-nocookie.com for YouTube (privacy-friendly)
<td>true
<tr>
<td>autoload
<td>Load video when scrolled into view
<td>false (YouTube), true (Bitchute)
<tr>
<td>hide-title
<td>Hide the video title bar
<td>false
<tr>
<td>align
<td>Set alignment (left, right, center)
<td>center
<tr>
<td>container-fit
<td>Make video fill the container (FitVids style)
<td>false
</table>
</div>
<div class=warning>
<p>
<strong>Warning:</strong> Using <code>autoload</code> with many videos on one page can impact performance as users scroll. Use with care!
</div>
<div class=note>
<p>
<strong>Note:</strong> With <code>container-fit</code>, the component overrides max-width to 100% and sets max-height to auto, making it fill its container while keeping the aspect ratio.
</div>
</section>
<section id=styling class=section>
<h2>Styling & CSS Variables</h2>
<p>
You can customize the look of <code>&lt;lazy-video></code> using CSS variables:
<div class=code-example>
<span class=code-label>CSS</span>
<pre><code>lazy-video {
--lv-max-width: 600px;
--lv-border-radius: 8px;
--lv-play-button-color: #f00;
--lv-play-button-bg: rgba(0, 0, 0, 0.7);
--lv-show-title: none;
}</code></pre>
</div>
<div class=table-container>
<h3>Available CSS Variables</h3>
<table>
<tr>
<th>CSS Variable
<th>Description
<th>Default
<tr>
<td>--lv-max-width
<td>Maximum width of the video
<td>560px
<tr>
<td>--lv-aspect-ratio
<td>Aspect ratio
<td>16 / 9
<tr>
<td>--lv-display
<td>Display type
<td>block
<tr>
<td>--lv-position
<td>CSS position
<td>relative
<tr>
<td>--lv-border-radius
<td>Border radius for the container
<td>0
<tr>
<td>--lv-margin
<td>Container margin
<td>0 auto
<tr>
<td>--lv-margin-left
<td>Margin for left alignment
<td>0
<tr>
<td>--lv-margin-right
<td>Margin for right alignment
<td>0 0 0 auto
<tr>
<td>--lv-margin-center
<td>Margin for center alignment
<td>0 auto
<tr>
<td>--lv-align
<td>Set alignment (left, right, center)
<td>center
<tr>
<td>--lv-background
<td>Background color
<td>#000
<tr>
<td>--lv-thumbnail-opacity
<td>Thumbnail opacity
<td>0.85
<tr>
<td>--lv-thumbnail-hover-opacity
<td>Opacity on hover
<td>1
<tr>
<td>--lv-thumbnail-object-fit
<td>Object-fit for thumbnail
<td>cover
<tr>
<td>--lv-play-button-width
<td>Play button width
<td>68px
<tr>
<td>--lv-play-button-height
<td>Play button height
<td>48px
<tr>
<td>--lv-play-button-bg
<td>Play button background
<td>rgba(33, 33, 33, 0.8)
<tr>
<td>--lv-play-button-bg-hover
<td>Play button hover background
<td>rgba(230, 33, 23, 1)
<tr>
<td>--lv-play-button-color
<td>Play button arrow color
<td>rgba(255, 255, 255, 0.9)
<tr>
<td>--lv-play-button-radius
<td>Play button border radius
<td>8px
<tr>
<td>--lv-play-button-arrow-size
<td>Play button arrow size
<td>12px 0 12px 20px
<tr>
<td>--lv-title-padding
<td>Title bar padding
<td>10px 12px
<tr>
<td>--lv-title-bg
<td>Title background
<td>rgba(0, 0, 0, 0.75)
<tr>
<td>--lv-title-color
<td>Title text color
<td>white
<tr>
<td>--lv-title-font-family
<td>Title font family
<td>Roboto, Arial, sans-serif
<tr>
<td>--lv-title-font-size
<td>Title font size
<td>18px
<tr>
<td>--lv-title-font-weight
<td>Title font weight
<td>500
<tr>
<td>--lv-title-line-height
<td>Title line height
<td>1.2
<tr>
<td>--lv-focus-outline
<td>Focus outline
<td>2px solid #4285F4
<tr>
<td>--lv-focus-outline-offset
<td>Focus outline offset
<td>2px
<tr>
<td>--lv-show-title
<td>Show/hide title bar (use 'none' to hide)
<td>block
<tr>
<td>--lv-timestamp-right
<td>Timestamp right position
<td>10px
<tr>
<td>--lv-timestamp-bottom
<td>Timestamp bottom position
<td>10px
<tr>
<td>--lv-timestamp-bg
<td>Timestamp background
<td>rgba(0, 0, 0, 0.7)
<tr>
<td>--lv-timestamp-color
<td>Timestamp text color
<td>white
<tr>
<td>--lv-timestamp-padding
<td>Timestamp padding
<td>2px 6px
<tr>
<td>--lv-timestamp-radius
<td>Timestamp border radius
<td>3px
<tr>
<td>--lv-timestamp-font-size
<td>Timestamp font size
<td>12px
<tr>
<td>--lv-timestamp-font-family
<td>Timestamp font family
<td>system-ui, sans-serif
<tr>
<td>--lv-loading-bg
<td>Loading background
<td>rgba(0,0,0,0.7)
<tr>
<td>--lv-loading-color
<td>Loading text color
<td>white
<tr>
<td>--lv-loading-font-family
<td>Loading font family
<td>system-ui, sans-serif
<tr>
<td>--lv-fallback-bg
<td>Fallback background
<td>#1a1a1a
<tr>
<td>--lv-fallback-color
<td>Fallback text color
<td>white
<tr>
<td>--lv-fallback-font-family
<td>Fallback font family
<td>system-ui, sans-serif
<tr>
<td>--lv-fallback-font-size
<td>Fallback font size
<td>14px
</table>
</div>
</section>
<section id=examples class=section>
<h2>Examples</h2>
<h3>YouTube Embed with Custom Size</h3>
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Till We Have Faces by Silent Planet"
width="50%"
height="260px"
thumbnail-quality="maxres"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class=example>
<lazy-video src=https://www.youtube.com/embed/wPr3kws2prM title="Till We Have Faces by Silent Planet" width=50% height=260px></lazy-video>
</div>
<h3>Bitchute with Autoload Off</h3>
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;lazy-video
src="https://www.bitchute.com/video/zSfeNPF-OpY"
title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?"
autoload="false"
thumbnail="https://static-3.bitchute.com/live/cover_images/nDPZqzyLkFKW/zSfeNPF-OpY_640x360.jpg"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class=example>
<lazy-video src=https://www.bitchute.com/video/zSfeNPF-OpY title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?" autoload=false thumbnail=https://static-3.bitchute.com/live/cover_images/nDPZqzyLkFKW/zSfeNPF-OpY_640x360.jpg>
</lazy-video>
<div class=note>
<p>
With <code>autoload="false"</code> on Bitchute, users need to click twice: once to load the player, and again to play. This saves bandwidth but may be less convenient.
</div>
</div>
<h3>Bitchute with Autoload</h3>
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;lazy-video
src="https://www.bitchute.com/video/zSfeNPF-OpY"
title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?"&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class=example>
<lazy-video src=https://www.bitchute.com/video/zSfeNPF-OpY title="Trump Assassination Attempt Documents LOCKED Away. What are they Hiding?">
</lazy-video>
</div>
<h3>Responsive Container (FitVids Style)</h3>
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;div style="max-width: 100%; width: 100%;"&gt;
&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Responsive container example"
container-fit="true"&gt;
&lt;/lazy-video&gt;
&lt;/div&gt;</code></pre>
</div>
<div class=example>
<div style=max-width:100%;width:100%>
<lazy-video src=https://www.youtube.com/embed/wPr3kws2prM title="Till We Have Faces by Silent Planet" container-fit=true>
</lazy-video>
</div>
<div class=note>
<p>
<code>container-fit="true"</code> makes the video fill its parent container while keeping the aspect ratio. Great for fluid layouts.
</div>
</div>
<h3>YouTube with Hidden Title Bar</h3>
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM"
title="Hidden title example"
hide-title&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
<div class=example>
<lazy-video src=https://www.youtube.com/embed/wPr3kws2prM title="Hidden title example" hide-title>
</lazy-video>
</div>
<h3>Global Title Control with CSS</h3>
<div class=code-example>
<span class=code-label>CSS</span>
<pre><code>/* Hide titles for all videos */
lazy-video {
--lv-show-title: none;
}
/* Hide titles for a group */
.article-videos lazy-video {
--lv-show-title: none;
}</code></pre>
</div>
<h3>Global Alignment Control with CSS</h3>
<div class=code-example>
<span class=code-label>CSS</span>
<pre><code>/* Set alignment for all videos */
lazy-video {
--lv-align: left;
}
/* Responsive alignment */
@media (max-width: 768px) {
lazy-video {
--lv-align: center;
}
}
/* Different alignments for different contexts */
.sidebar lazy-video {
--lv-align: right;
}</code></pre>
</div>
</section>
<section id=converting class=section>
<h2>Converting Existing iframes</h2>
<p>
You can convert existing video iframes to <code>&lt;lazy-video></code> by simply changing the tag name.
<p>Standard YouTube iframe:
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;iframe
src="https://www.youtube.com/embed/wPr3kws2prM?start=30&rel=0&controls=0"
width="560"
height="315"
title="Till We Have Faces by Silent Planet"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen&gt;
&lt;/iframe&gt;
</code></pre>
</div>
<p>Converted to <code>&lt;lazy-video></code> (just change the tag):
<div class=code-example>
<span class=code-label>HTML</span>
<pre><code>&lt;lazy-video
src="https://www.youtube.com/embed/wPr3kws2prM?start=30&rel=0&controls=0"
width="560"
height="315"
title="Till We Have Faces by Silent Planet"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen&gt;
&lt;/lazy-video&gt;</code></pre>
</div>
</section>
<section id=security class=section>
<h2>Security & Privacy</h2>
<p>
<code>&lt;lazy-video></code> is built with modern web security and privacy best practices:
<ul>
<li>
All embedded iframes use the <a href=https://developer.mozilla.org/en-US/docs/Web/API/Window/credentialless target=_blank><code>credentialless</code></a> attribute. This helps prevent credential leaks and keeps third-party content isolated from your site's cookies and storage.
<li>
The <a href=https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox target=_blank><code>sandbox</code></a> attribute is set by default, restricting what the embedded player can do and reducing risk from third-party content.
<li>
For YouTube, the <code>youtube-nocookie.com</code> domain is used by default, so no tracking cookies are set unless the user interacts with the video.
</ul>
<div class=note>
<p>
<strong>Note:</strong> You can override the <code>sandbox</code> attribute if you need to enable additional features, but the default is designed for maximum safety.
</div>
</section>
<section id=browser-support class=section>
<h2>Browser Support</h2>
<p>
Works in all modern browsers (Chrome, Firefox, Safari, Edge). Uses standard web component APIs. For IE11 or older, use the <a href=https://github.com/webcomponents/polyfills/tree/master/packages/custom-elements target=_blank>custom-elements polyfill</a>.
</section>
<section id=breaking-change class=section>
<h2>Breaking Change</h2>
<p>
<strong>April 3, 2025:</strong> The old <code>&lt;lazy-youtube></code> element is no longer supported. Please update any code to use <code>&lt;lazy-video></code> instead.
</section>
<footer>
<div class=doc-version-note>These docs reflect the latest release version of <strong>@lv.js</strong>.</div>
<p>Last updated: Friday, April 11th, 2025
</footer>
</div>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

1
public/images/copy.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path fill="#ffffff" d="M384 336H192c-8.8 0-16-7.2-16-16V64c0-8.8 7.2-16 16-16l140.1 0L400 115.9V320c0 8.8-7.2 16-16 16zM192 384H384c35.3 0 64-28.7 64-64V115.9c0-12.7-5.1-24.9-14.1-33.9L366.1 14.1c-9-9-21.2-14.1-33.9-14.1H192c-35.3 0-64 28.7-64 64V320c0 35.3 28.7 64 64 64zM64 128c-35.3 0-64 28.7-64 64V448c0 35.3 28.7 64 64 64H256c35.3 0 64-28.7 64-64V416H272v32c0 8.8-7.2 16-16 16H64c-8.8 0-16-7.2-16-16V192c0-8.8 7.2-16 16-16H96V128H64z"/></svg>

After

Width:  |  Height:  |  Size: 661 B

BIN
public/images/favi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

4
public/images/favi.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="362" height="362" viewBox="28 28 200 200" fill="none" stroke="#6c63ff" stroke-linejoin="round" stroke-width="22">
<path d="M128 40l82 44v88l-82 44-82-44V84z"/>
<path d="M128 128V80m0 48l-36 30m36-30l36 30" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FA2921;}
.st1{fill:#ED79B5;}
.st2{fill:#FFB400;}
.st3{fill:#1E83F7;}
.st4{fill:#18C249;}
</style>
<g id="Flower_00000077325900055813483940000000694823054982625702_">
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3
c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72
C300.01,209.24,339.15,235.47,375.48,267.63z"/>
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84
c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15
c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15
c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14
c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76
c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24
c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5
c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24
c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
<defs>
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
<stop offset="0" style="stop-color:#AA5CC3"/>
<stop offset="1" style="stop-color:#00A4DC"/>
</linearGradient>
</defs>
<title>icon-transparent</title>
<g id="icon-transparent">
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View file

@ -0,0 +1 @@
<svg height="256.024" viewBox="0 0 67.74 67.74" width="256.024" xmlns="http://www.w3.org/2000/svg"><g stroke="#000"><circle cx="33.86958" cy="33.869496" fill="#0084ff" r="32.499" stroke-width="2.74219"/><g stroke-width="2.64583"><circle cx="33.86958" cy="33.869496" fill="#fff" r="11.573"/><path d="m161.58928 117.1873c0 10.77898-8.73809 19.51707-19.51707 19.51707m-19.51706-19.51707c0-10.77898 8.73809-19.517068 19.51706-19.517067" fill="none" stroke-linecap="round" transform="translate(-108.20242 -83.317504)"/><path d="m167.71209 117.1873c0 .32994-.006.65843-.0186.98535m-.60107 4.64276c-2.56514 11.4532-12.7932 20.01177-25.02022 20.01177m-25.63986-25.63988c0-.29458.005-.58801.0148-.88019m.49655-4.2403c2.37274-11.707 12.7215-20.519383 25.1285-20.519383" fill="none" stroke-linecap="round" transform="translate(-108.20242 -83.317504)"/><circle cx="33.86958" cy="33.869496" fill="#fff" r="2.391"/></g></g></svg>

After

Width:  |  Height:  |  Size: 915 B

View file

@ -0,0 +1,16 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px" fill="#3BBCED">
<path d="M49.32,29.44h-1.88v9.75h1.88V29.44z M57.41,29.44h-1.88v9.75h1.88V29.44z M68.47,10.84L66.54,7.5L34.36,26.1
l1.93,3.34L68.47,10.84z" />
<path d="M68.28,10.84l1.93-3.34l32.18,18.6l-1.93,3.34L68.28,10.84z" />
<path d="M108.08,29.49v-3.86H19.92v3.86H108.08z" />
<path d="M73.17,84.01V26.8h3.86v59.98C76,85.61,74.68,84.76,73.17,84.01z M66.59,83.02V2.32h3.86v81.17
C69.36,82.97,66.73,83.02,66.59,83.02z M30.04,91.07c-4.7-3.48-7.81-9.04-7.81-15.35c0-3.34,0.89-6.64,2.54-9.51H57.7
c1.69,2.87,2.54,6.17,2.54,9.51c0,2.92-0.38,5.65-1.55,8.1c-2.49-2.4-6.16-3.44-9.88-3.44c-6.59,0-12.23,4.1-13.69,10.22
c-0.52-0.05-0.85-0.09-1.36-0.09C32.48,90.55,31.26,90.74,30.04,91.07L30.04,91.07z" />
<path d="M46.22,43.52h-9.97v10.03h9.97V43.52z M34.97,43.52H25v10.03h9.97V43.52z M34.97,54.68H25v10.03h9.97V54.68z
M46.22,54.68h-9.97v10.03h9.97V54.68z M57.41,54.68h-9.97v10.03h9.97V54.68z M57.41,38.15h-9.97v10.03h9.97V38.15z" />
<path d="M36.76,92.58c1.36-5.79,6.59-10.12,12.8-10.12c4,0,7.57,1.79,10.02,4.62c2.12-1.46,4.66-2.31,7.43-2.31
c7.24,0,13.12,5.88,13.12,13.14c0,1.51-0.23,2.92-0.7,4.28c1.6,2.17,2.59,4.9,2.59,7.82c0,7.25-5.88,13.14-13.13,13.14
c-3.2,0-6.12-1.13-8.37-3.01c-2.4,3.34-6.3,5.56-10.73,5.56c-5.08,0-9.5-2.92-11.71-7.16c-0.89,0.19-1.79,0.28-2.73,0.28
c-7.24,0-13.17-5.89-13.17-13.14s5.88-13.14,13.17-13.14C35.82,92.49,36.29,92.49,36.76,92.58L36.76,92.58z" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 792 792" viewBox="0 0 792 792"><path d="M375.48 267.63c38.64 34.21 69.78 70.87 89.82 105.42 34.42-61.56 57.42-134.71 57.71-181.3v-.91c0-68.94-68.77-95.77-128.01-95.77s-128.01 26.83-128.01 95.77v3.72c33.02 14.68 72.16 40.91 108.49 73.07z" style="fill:#fa2921"/><path d="M164.7 455.63c24.15-26.87 61.2-55.99 103.01-80.61 44.48-26.18 88.97-44.47 128.02-52.84-47.91-51.76-110.37-96.24-154.6-110.91-.31-.1-.6-.19-.86-.28-65.57-21.3-112.34 35.81-130.64 92.15-18.3 56.34-14.04 130.04 51.53 151.34.89.29 2.09.68 3.54 1.15z" style="fill:#ed79b5"/><path d="M681.07 302.19c-18.3-56.34-65.07-113.45-130.64-92.15-.9.29-2.1.68-3.54 1.15-3.75 35.93-16.6 81.27-35.96 125.76-20.59 47.32-45.84 88.27-72.51 118 69.18 13.72 145.86 12.98 190.26-1.14.31-.1.6-.2.86-.28 65.57-21.31 69.83-95.01 51.53-151.34z" style="fill:#ffb400"/><path d="M336.54 510.71c-11.15-50.39-14.8-98.36-10.7-138.08-64.03 29.57-125.63 75.23-153.26 112.76-.19.26-.37.51-.53.73-40.52 55.78-.66 117.91 47.27 152.72 47.92 34.82 119.33 53.54 159.86-2.24.56-.76 1.3-1.78 2.19-3.01-18.09-31.27-34.35-75.51-44.83-122.88z" style="fill:#1e83f7"/><path d="M617.57 482.52c-35.33 7.54-82.42 9.33-130.72 4.66-51.37-4.96-98.11-16.32-134.63-32.5 8.33 70.03 32.73 142.73 59.88 180.6.19.26.37.51.53.73 40.52 55.78 111.93 37.06 159.86 2.24 47.92-34.82 87.79-96.95 47.27-152.72-.56-.76-1.3-1.78-2.19-3.01z" style="fill:#18c249"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="110.25" x2="496.14" y1="213.3" y2="436.09" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#aa5cc3"/><stop offset="1" style="stop-color:#00a4dc"/></linearGradient></defs><g fill="url(#a)"><path d="M256 201.6c-20.4 0-86.2 119.3-76.2 139.4s142.5 19.9 152.4 0-55.7-139.4-76.2-139.4z"/><path d="M256 23.3c-61.6 0-259.8 359.4-229.6 420.1s429.3 60 459.2 0S317.6 23.3 256 23.3zm150.5 367.5c-19.6 39.3-281.1 39.8-300.9 0S215.7 115.5 256 115.5s170.1 235.9 150.5 275.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 585 B

Some files were not shown because too many files have changed in this diff Show more