├── public ├── favicon.ico ├── img │ ├── nick-pet.gif │ └── nick-love.png └── humans.txt ├── dub.json ├── .gitignore ├── README.md ├── source ├── config.d └── app.d ├── LICENSE.md └── views ├── style.css └── main.dt /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebFreak001/WantsPet/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/img/nick-pet.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebFreak001/WantsPet/HEAD/public/img/nick-pet.gif -------------------------------------------------------------------------------- /public/img/nick-love.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebFreak001/WantsPet/HEAD/public/img/nick-love.png -------------------------------------------------------------------------------- /dub.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "vibe-d": "~>0.9.3" 4 | }, 5 | "name": "wants-pet", 6 | "license": "public domain" 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .dub 2 | docs.json 3 | __dummy.html 4 | *.o 5 | *.obj 6 | clicks_db.bin* 7 | tmpconfig.json 8 | dub.selections.json 9 | /wants-pet 10 | 11 | -------------------------------------------------------------------------------- /public/humans.txt: -------------------------------------------------------------------------------- 1 | Design & Frontend: 2 | Toby https://instagram.com/3colorsdesign/ 3 | 4 | Backend & Frontend: 5 | @WebFreak001 https://webfreak.org 6 | 7 | 8 | made for twitch.tv/SimplyNick <3 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wants.pet 2 | 3 | This is the website server backend + frontend which generates the various *.wants.pet sites. 4 | 5 | Features: 6 | - Real-Time clicks synchronization through WebSockets, tested across dozens of clients 7 | - Various frontend effects (currently particles) and theme customization features 8 | 9 | TODO: 10 | - Registration on Website (with post-submit mod queue) 11 | - Petting generator with graphics from [@stvpvd](https://twitter.com/stvpvd) (petpet generator) 12 | -------------------------------------------------------------------------------- /source/config.d: -------------------------------------------------------------------------------- 1 | module config; 2 | 3 | @safe: 4 | 5 | struct PrivateConfig 6 | { 7 | string namespace; 8 | PublicConfig config; 9 | } 10 | 11 | struct PublicConfig 12 | { 13 | string title; 14 | string titleShort; 15 | string description; 16 | string gif; 17 | string gifAlt; 18 | string twitterUser; 19 | Particle[] particles; 20 | Style style; 21 | string tweetTeaser; 22 | } 23 | 24 | struct Particle 25 | { 26 | string image; 27 | int width, height; 28 | } 29 | 30 | struct Style 31 | { 32 | string bg; 33 | string color; 34 | string color_hover; 35 | string color_pressed; 36 | string font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"; 37 | 38 | string applyVariables(string code) 39 | { 40 | import std.array : replace; 41 | 42 | foreach (i, member; this.tupleof) 43 | { 44 | enum ident = "$" ~ __traits(identifier, this.tupleof[i]) ~ "$"; 45 | code = code.replace(ident, member); 46 | } 47 | 48 | return code; 49 | } 50 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | All code in this project is dual-licensed under CC0 and unlicense (public domain), whichever you want to use as they are both very similar. 2 | 3 | The resources in public/img and public/favicon.ico, among all other user-submitted content on the actual website are NOT licensed under these terms. Regular copyright law applies for any of these assets. 4 | 5 | unlicense: 6 | 7 | This is free and unencumbered software released into the public domain. 8 | 9 | Anyone is free to copy, modify, publish, use, compile, sell, or 10 | distribute this software, either in source code form or as a compiled 11 | binary, for any purpose, commercial or non-commercial, and by any 12 | means. 13 | 14 | In jurisdictions that recognize copyright laws, the author or authors 15 | of this software dedicate any and all copyright interest in the 16 | software to the public domain. We make this dedication for the benefit 17 | of the public at large and to the detriment of our heirs and 18 | successors. We intend this dedication to be an overt act of 19 | relinquishment in perpetuity of all present and future rights to this 20 | software under copyright law. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 24 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 25 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 26 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 27 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 28 | OTHER DEALINGS IN THE SOFTWARE. 29 | 30 | For more information, please refer to -------------------------------------------------------------------------------- /views/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | background-color: $bg$; 9 | color: $color$; 10 | } 11 | 12 | body, button, input { 13 | font-family: $font$; 14 | } 15 | 16 | a { 17 | color: $color$; 18 | } 19 | 20 | body, html, .wrapper { 21 | width: 100%; 22 | height: 100%; 23 | } 24 | 25 | h1 { 26 | text-transform: uppercase; 27 | font-size: 2rem; 28 | text-align: center; 29 | grid-area: title; 30 | margin: auto; 31 | } 32 | 33 | .wrapper { 34 | display: flex; 35 | flex-direction: column; 36 | align-items: center; 37 | justify-content: flex-start; 38 | } 39 | 40 | .content { 41 | display: flex; 42 | flex-direction: column; 43 | align-items: center; 44 | justify-content: center; 45 | width: 100%; 46 | max-width: 550px; 47 | min-height: 300px; 48 | height: 100vh; 49 | max-height: 500px; 50 | padding: 40px 0; 51 | box-sizing: border-box; 52 | } 53 | 54 | .filler { 55 | flex: 1; 56 | } 57 | 58 | .social { 59 | padding: 1em 0 4em 0; 60 | } 61 | 62 | .social a { 63 | color: #aaa; 64 | text-decoration: none; 65 | } 66 | 67 | .social svg, .social img { 68 | width: 24px; 69 | height: 24px; 70 | } 71 | 72 | @media screen and (max-width: 600px) { 73 | .filler { 74 | display: block; 75 | flex: unset; 76 | flex-grow: 0; 77 | flex-shrink: 10; 78 | height: 50px; 79 | } 80 | } 81 | 82 | .img-wrapper { 83 | margin: 1em auto; 84 | 85 | } 86 | 87 | #clicker { 88 | touch-action: none; 89 | } 90 | 91 | button { 92 | margin: auto; 93 | position: relative; 94 | margin-top: 1rem; 95 | margin-bottom: 3rem; 96 | height: 200px; 97 | min-height: 150px; 98 | width: 80%; 99 | border-radius: .5rem; 100 | background-color: $color$; 101 | color: $bg$; 102 | font-size: 4rem; 103 | border: none; 104 | letter-spacing: 1rem; 105 | cursor: pointer; 106 | } 107 | 108 | button:hover { 109 | background-color: $color_hover$; 110 | } 111 | 112 | button:active { 113 | box-shadow: 0 2px 0 $color_pressed$ inset; 114 | padding-top: 2px; 115 | padding-bottom: -2px; 116 | } 117 | 118 | button b { 119 | font-weight: 500; 120 | position: relative; 121 | left: 0.25ex; 122 | } 123 | 124 | .button-small { 125 | font-size: 1rem; 126 | letter-spacing: normal; 127 | } 128 | 129 | .totalpets { 130 | margin: auto; 131 | } 132 | 133 | .sessionpets { 134 | margin: auto; 135 | margin-right: 0; 136 | } 137 | 138 | .no-connection .hide-no-global { 139 | display: none; 140 | } 141 | 142 | #click_animation { 143 | pointer-events: none; 144 | position: absolute; 145 | left: 0; 146 | top: 0; 147 | width: 100%; 148 | height: 100%; 149 | } 150 | 151 | #click_animation img { 152 | position: absolute; 153 | opacity: 0; 154 | } 155 | 156 | #click_animation img.visible { 157 | animation: click_animation 500ms ease-out; 158 | } 159 | 160 | @keyframes click_animation { 161 | 0% { transform: translate(-24px, -24px) scale(0.4); opacity: 0; } 162 | 10% { transform: translate(-24px, -24px) scale(0.8); opacity: 1; } 163 | 70% { transform: translate(-24px, -72px) scale(1); opacity: 1; } 164 | 100% { transform: translate(-24px, -72px) scale(0.8); opacity: 0; } 165 | } 166 | -------------------------------------------------------------------------------- /views/main.dt: -------------------------------------------------------------------------------- 1 | doctype html 2 | 3 | html(lang="en") 4 | - import std.uri : encodeComponent; 5 | - import vibe.data.json; 6 | 7 | head 8 | meta(charset="UTF-8") 9 | meta(name="viewport", content="width=device-width, initial-scale=1.0") 10 | meta(http-equiv="X-UA-Compatible", content="ie=edge") 11 | title= config.title 12 | //- meta(http-equiv="Content-Security-Policy", content="default-src 'self'; style-src 'nonce-" ~ nonce ~ "'; script-src 'nonce-" ~ nonce ~ "'; connect-src wss://" ~ req.host ~ "/ws") 13 | link(rel="author", href="humans.txt") 14 | link(rel="preload", href=config.gif, as="image") 15 | - foreach (particle; config.particles) 16 | link(rel="preload", href=particle.image, as="image") 17 | meta(name="theme-color", content=config.style.color) 18 | 19 | // 20 | All HTML, CSS and JS code on this website is licensed under CC0 (public domain) 21 | 22 | The server backend is licensed under CC0 and can be found at https://github.com/WebFreak001/WantsPet 23 | 24 | Pet Gif Base: https://benisland.neocities.org/petpet/ 25 | Twitter Logo: materialdesignicons 26 | 27 | meta(itemprop="name", content=config.title) 28 | meta(itemprop="description", content=config.description) 29 | meta(itemprop="image", content="https://" ~ req.host ~ config.gif) 30 | 31 | meta(name="twitter:card", content="summary") 32 | meta(name="twitter:site", content=config.twitterUser) 33 | meta(name="twitter:creator", content="@WebFreak001") 34 | meta(name="twitter:url", content="https://" ~ req.host) 35 | meta(name="twitter:title", content=config.title) 36 | meta(name="twitter:description", content=config.description) 37 | meta(name="twitter:image", content="https://" ~ req.host ~ config.gif) 38 | meta(name="twitter:image:alt", content=config.gifAlt) 39 | 40 | style(nonce=nonce)!= config.style.applyVariables(import("style.css")) 41 | body.no-connection 42 | .wrapper 43 | .filler 44 | .content 45 | h1= config.titleShort 46 | .img-wrapper 47 | img(src=config.gif, alt=config.gifAlt) 48 | noscript Sorry, you can only pet with JavaScript enabled! 49 | button#clicker 50 | b PET 51 | br 52 | p.button-small Session Pets: #[span#session 0] 53 | - if (config.particles.length) 54 | #click_animation 55 | p.totalpets.hide-no-global Global Pets: #[span#global= stats.global] 56 | .filler 57 | .social 58 | a#twitter(href="https://twitter.com/intent/tweet?text=" ~ encodeComponent(config.title) ~ "&url=https%3A%2F%2F" ~ encodeComponent(req.host) ~ "&hashtags=WantsPet", target="_blank") 59 | svg(width="24", height="24", viewBox="0 0 24 24", alt="Tweet", title="Tweet", aria-label="Tweet") 60 | path(fill="currentColor", d="M22.46,6C21.69,6.35 20.86,6.58 20,6.69C20.88,6.16 21.56,5.32 21.88,4.31C21.05,4.81 20.13,5.16 19.16,5.36C18.37,4.5 17.26,4 16,4C13.65,4 11.73,5.92 11.73,8.29C11.73,8.63 11.77,8.96 11.84,9.27C8.28,9.09 5.11,7.38 3,4.79C2.63,5.42 2.42,6.16 2.42,6.94C2.42,8.43 3.17,9.75 4.33,10.5C3.62,10.5 2.96,10.3 2.38,10C2.38,10 2.38,10 2.38,10.03C2.38,12.11 3.86,13.85 5.82,14.24C5.46,14.34 5.08,14.39 4.69,14.39C4.42,14.39 4.15,14.36 3.89,14.31C4.43,16 6,17.26 7.89,17.29C6.43,18.45 4.58,19.13 2.56,19.13C2.22,19.13 1.88,19.11 1.54,19.07C3.44,20.29 5.7,21 8.12,21C16,21 20.33,14.46 20.33,8.79C20.33,8.6 20.33,8.42 20.32,8.23C21.16,7.63 21.88,6.87 22.46,6Z") 61 | 62 | script(nonce=nonce)!= "var config = " ~ serializeToJsonString(config) 63 | script(nonce=nonce). 64 | (function() { 65 | /** @type {WebSocket} */ 66 | var ws = null; 67 | 68 | if (!window.BigInt) 69 | window.BigInt = parseInt; 70 | 71 | function connect() { 72 | ws = new WebSocket((window.location.protocol == "https:" ? "wss://" : "ws://") + window.location.host + "/ws"); 73 | ws.binaryType = 'arraybuffer'; 74 | ws.onmessage = function(m) { 75 | globalNum = window.BigInt64Array ? new BigInt64Array(m.data)[0] : BigInt(new Int32Array(m.data)[0]); 76 | localOffset = localClicks; 77 | update(); 78 | } 79 | ws.onopen = function(m) { 80 | document.body.classList.remove("no-connection"); 81 | } 82 | ws.onclose = function() { 83 | ws = null; 84 | document.body.classList.add("no-connection"); 85 | setTimeout(connect, 1000); 86 | } 87 | } 88 | setTimeout(connect, 1); 89 | 90 | var globalNum = BigInt(0); 91 | var totalLocalNum = parseInt(window.sessionStorage.getItem("clicks") || 0); 92 | var localClicks = 0; 93 | var localOffset = 0; 94 | var clickTimeout = null; 95 | var isDown = 0; 96 | 97 | function mouseDown(e) { 98 | if (e && e.preventDefault) 99 | e.preventDefault(); 100 | 101 | // parallel clicks with up to 2 keys 102 | if (isDown >= 2) 103 | return; 104 | isDown++; 105 | doClick(); 106 | } 107 | 108 | function mouseUp() { 109 | isDown--; 110 | } 111 | 112 | function doClick() { 113 | if (!ws) return; 114 | totalLocalNum++; 115 | localClicks++; 116 | localOffset++; 117 | update(); 118 | if (config.particles.length > 0) 119 | animateClick(); 120 | if (clickTimeout === null) 121 | { 122 | clickTimeout = setTimeout(function() { 123 | clickTimeout = null; 124 | window.sessionStorage.setItem("clicks", totalLocalNum); 125 | if (!ws || ws.readyState != WebSocket.OPEN) return; 126 | var sent = localClicks; 127 | var v = new Int8Array(1); 128 | v[0] = localClicks; 129 | localClicks = 0; 130 | ws.send(v); 131 | }, 100); 132 | } 133 | } 134 | document.getElementById("clicker").onmousedown = mouseDown; 135 | document.getElementById("clicker").onmouseup = mouseUp; 136 | document.getElementById("clicker").pointermove = function(e) { e.preventDefault(); }; 137 | document.onkeydown = function(e) { 138 | if (!e.repeat && (e.key == 'x' || e.key == 'z' || e.key == 'Enter')) mouseDown(e); 139 | } 140 | document.onkeyup = function(e) { 141 | if (e.key == 'x' || e.key == 'z' || e.key == 'Enter') mouseUp(e); 142 | } 143 | 144 | function animateClick() { 145 | var container = document.getElementById("click_animation"); 146 | var child; 147 | if (container.childElementCount < 20) { 148 | child = document.createElement("img"); 149 | child.src = config.particles[container.childElementCount % config.particles.length].image; 150 | container.appendChild(child); 151 | } 152 | 153 | for (var i = 0; i < container.children.length; i++) { 154 | var child = container.children[i]; 155 | if (!child.classList.contains("visible")) { 156 | child.style.left = (Math.random() * 100) + "%"; 157 | child.style.top = (Math.random() * 100) + "%"; 158 | var particle = config.particles[Math.floor(Math.random() * config.particles.length)]; 159 | child.src = particle.image; 160 | child.style.width = particle.width + "px"; 161 | child.style.height = particle.height + "px"; 162 | child.classList.add("visible"); 163 | setTimeout(function() { 164 | child.classList.remove("visible"); 165 | }, 500); 166 | return; 167 | } 168 | } 169 | } 170 | 171 | var currentTmp = BigInt(0); 172 | var goal = BigInt(0); 173 | function update() { 174 | var n = globalNum + BigInt(localOffset); 175 | 176 | goal = n; 177 | if (currentTmp + BigInt(100) < goal) 178 | currentTmp = goal; 179 | else if (currentTmp < goal) 180 | { 181 | var diff = goal - currentTmp; 182 | var delay = 1; 183 | if (diff >= 50) 184 | delay = 2; 185 | else if (diff >= 30) 186 | delay = 5; 187 | else if (diff >= 15) 188 | delay = 10; 189 | else 190 | delay = 40; 191 | currentTmp++; 192 | if (currentTmp < goal) 193 | setTimeout(update, 2 * delay + Math.random() * delay); 194 | } 195 | document.getElementById("session").textContent = totalLocalNum; 196 | document.getElementById("global").textContent = currentTmp; 197 | 198 | if (totalLocalNum) 199 | document.getElementById("twitter").href = "https://twitter.com/intent/tweet?text=" 200 | + encodeURIComponent(config.tweetTeaser.replace("{num}", totalLocalNum)) 201 | + "&url=https%3A%2F%2F" + encodeURIComponent(window.location.hostname) + "&hashtags=WantsPet"; 202 | } 203 | update(); 204 | })(); 205 | -------------------------------------------------------------------------------- /source/app.d: -------------------------------------------------------------------------------- 1 | @safe: 2 | 3 | import vibe.vibe; 4 | import vibe.core.sync; 5 | 6 | import std.bitmanip; 7 | import std.range; 8 | 9 | import config; 10 | 11 | //enum ReverseProxy = true; 12 | enum ReverseProxy = false; 13 | static immutable string ANY_HOST = "wants.pet"; 14 | 15 | struct Database 16 | { 17 | static immutable DB_PATH = "clicks_db.bin"; 18 | 19 | struct Entry 20 | { 21 | ulong clicks; 22 | long createDate; 23 | long lastClickDate; 24 | } 25 | 26 | bool dirty; 27 | MonoTime lastSave; 28 | Entry[string] _clicks; 29 | InterruptibleTaskMutex mutex; 30 | 31 | Entry get(string host) 32 | { 33 | if (host.length == 0 || host.length >= 255) 34 | throw new Exception("Invalid host length"); 35 | 36 | with (mutex.scopedMutexLock) 37 | { 38 | return _clicks.get(host, Entry.init); 39 | } 40 | } 41 | 42 | void incr(string host, int num) 43 | { 44 | if (host.length == 0 || host.length >= 255) 45 | throw new Exception("Invalid host length"); 46 | 47 | with (mutex.scopedMutexLock) 48 | { 49 | auto now = Clock.currStdTime(); 50 | _clicks.update(host, delegate() { return Entry(num, now, now); }, delegate( 51 | ref Entry v) { v.clicks += num; v.lastClickDate = now; }); 52 | dirty = true; 53 | } 54 | } 55 | 56 | void save() 57 | { 58 | if (!dirty) 59 | return; 60 | 61 | with (mutex.scopedMutexLock) 62 | { 63 | if (dirty) 64 | { 65 | { 66 | scope db = openFile(DB_PATH, FileMode.createTrunc); 67 | db.write([cast(ubyte) 1]); // version 68 | 69 | uint numClicks = cast(uint) _clicks.length; 70 | db.write(numClicks.nativeToLittleEndian[]); 71 | 72 | foreach (host, clicks; _clicks) 73 | { 74 | ubyte hostLength = cast(ubyte) host.length; 75 | db.write(hostLength.nativeToLittleEndian[]); 76 | db.write(host); 77 | db.write(clicks.clicks.nativeToLittleEndian[]); 78 | db.write(clicks.createDate.nativeToLittleEndian[]); 79 | db.write(clicks.lastClickDate.nativeToLittleEndian[]); 80 | } 81 | 82 | dirty = false; 83 | } 84 | auto now = MonoTime.currTime; 85 | if (now - lastSave > 55.minutes) 86 | { 87 | copyFile(DB_PATH, DB_PATH ~ '_' ~ Clock.currTime.toISOString); 88 | } 89 | } 90 | } 91 | } 92 | 93 | static Database load() 94 | { 95 | Database ret; 96 | ret.mutex = new InterruptibleTaskMutex(); 97 | ret.lastSave = MonoTime.currTime; 98 | 99 | if (existsFile(DB_PATH)) 100 | { 101 | scope db = openFile(DB_PATH, FileMode.read); 102 | 103 | ubyte[256] buffer; 104 | 105 | db.read(buffer[0 .. 1]); 106 | enforce(buffer[0] == 1, "Database version mismatch"); 107 | 108 | db.read(buffer[0 .. 4]); 109 | uint numClicks = buffer[0 .. 4].littleEndianToNative!uint; 110 | foreach (i; 0 .. numClicks) 111 | { 112 | db.read(buffer[0 .. 1]); 113 | ubyte nameLen = buffer[0]; 114 | db.read(buffer[0 .. nameLen]); 115 | string name = cast(string) buffer[0 .. nameLen].idup; 116 | db.read(buffer[0 .. 3 * 8]); 117 | ret._clicks[name] = (() @trusted => Entry( 118 | buffer[].peek!(ulong, Endian.littleEndian)(0), 119 | buffer[].peek!(long, Endian.littleEndian)(1), 120 | buffer[].peek!(long, Endian.littleEndian)(2)))(); 121 | } 122 | } 123 | 124 | return ret; 125 | } 126 | } 127 | 128 | __gshared Database database; 129 | 130 | struct Sites 131 | { 132 | PrivateConfig[] configs; 133 | } 134 | 135 | __gshared Sites sites; 136 | 137 | void main() 138 | { 139 | (() @trusted { 140 | database = Database.load(); 141 | setTimer(10.minutes, &database.save, true); 142 | 143 | sites = deserializeJson!Sites(readFileUTF8("tmpconfig.json")); 144 | })(); 145 | 146 | scope (exit) 147 | { 148 | (() @trusted => database.save())(); 149 | } 150 | 151 | auto settings = new HTTPServerSettings; 152 | settings.port = 3000; 153 | settings.bindAddresses = ["::1", "127.0.0.1"]; 154 | auto router = new URLRouter(); 155 | router.get("/ws", handleWebSockets(&handleWSClient)); 156 | router.get("/", &index); 157 | router.get("*", serveStaticFiles("public/")); 158 | listenHTTP(settings, router); 159 | 160 | runApplication(); 161 | } 162 | 163 | string makeNonce() 164 | { 165 | import vibe.crypto.cryptorand; 166 | 167 | auto rng = secureRNG(); 168 | ubyte[20] buf; 169 | rng.read(buf); 170 | return Base64URLNoPadding.encode(buf).idup; 171 | } 172 | 173 | void index(HTTPServerRequest req, HTTPServerResponse res) 174 | { 175 | if (req.host == ANY_HOST) 176 | { 177 | res.writeBody("TODO: registration, message @WebFreak#0001 on discord or @WebFreak001 on twitter for now for your own petting site"); 178 | } 179 | else 180 | { 181 | auto config = findConfig(req.host).config; 182 | if (config == typeof(config).init) 183 | { 184 | return; 185 | } 186 | auto nonce = makeNonce(); 187 | Stats stats; 188 | res.render!("main.dt", config, req, nonce, stats); 189 | } 190 | } 191 | 192 | version (Posix) 193 | { 194 | import core.sys.posix.netinet.in_; 195 | struct IPBan 196 | { 197 | ubyte[sockaddr_in6.sizeof] ip; 198 | ubyte ipLen; 199 | MonoTime until; 200 | Duration d; 201 | 202 | bool expired() const 203 | { 204 | return MonoTime.currTime >= until; 205 | } 206 | } 207 | IPBan[64] ipBans; 208 | 209 | ubyte[sockaddr_in6.sizeof] getIP(scope const HTTPServerRequest req, out ubyte ipLen) @trusted 210 | { 211 | typeof(return) ret; 212 | auto addr = req.clientAddress; 213 | static if (ReverseProxy) 214 | { 215 | string ip = req.headers.get("X-Forwarded-For", req.headers.get("X-Real-IP", "")); 216 | if (ip.length) 217 | { 218 | import std.algorithm : all; 219 | if (ip.all!(a => (a >= '0' && a <= '9') || a == '.')) 220 | { 221 | ipLen = 4; 222 | int i = 0; 223 | foreach (c; ip) 224 | { 225 | if (c == '.') 226 | { 227 | i++; 228 | if (i >= 4) 229 | break; // malformed 230 | continue; 231 | } 232 | else 233 | { 234 | int n = c - '0'; 235 | ret[i] *= 10; 236 | ret[i] += n; 237 | } 238 | } 239 | return ret; 240 | } 241 | else 242 | { 243 | import core.sys.posix.arpa.inet : inet_pton, AF_INET6; 244 | inet_pton(AF_INET6, ip.ptr, &ret[0]); 245 | ipLen = 128 / 8; 246 | return ret; 247 | } 248 | } 249 | } 250 | ipLen = cast(ubyte)addr.sockAddrLen; 251 | ret[0 .. ipLen] = (cast(ubyte*)addr.sockAddr)[0 .. ipLen]; 252 | return ret; 253 | } 254 | 255 | bool warnRequestIP(scope const HTTPServerRequest req) 256 | { 257 | ubyte len; 258 | auto ip = req.getIP(len); 259 | 260 | size_t free = size_t.max; 261 | 262 | foreach (i, ref ban; ipBans) 263 | { 264 | if (ban.ipLen == 0) 265 | { 266 | free = i; 267 | } 268 | else if (ban.ip[0 .. ban.ipLen] == ip[0 .. len]) 269 | { 270 | if (ban.expired) 271 | ban.d = 10.seconds; 272 | else 273 | { 274 | if (ban.d >= 10.seconds && ban.d < 20.seconds) 275 | logInfo("Banned ip %s", ip[0 .. len]); 276 | 277 | if (ban.d < 10.minutes) 278 | ban.d *= 2; 279 | } 280 | ban.until = MonoTime.currTime + ban.d; 281 | return true; 282 | } 283 | else if (ban.expired) 284 | { 285 | free = i; 286 | } 287 | } 288 | 289 | if (free == size_t.max) 290 | return false; 291 | 292 | ipBans[free].ipLen = len; 293 | ipBans[free].ip = ip; 294 | ipBans[free].d = 5.seconds; 295 | ipBans[free].until = MonoTime.currTime + ipBans[free].d; 296 | return true; 297 | } 298 | 299 | enum BanState 300 | { 301 | none, 302 | warned, 303 | banned 304 | } 305 | 306 | BanState checkRequestIP(scope const HTTPServerRequest req) 307 | { 308 | ubyte len; 309 | auto ip = req.getIP(len); 310 | 311 | foreach (i, ref ban; ipBans) 312 | { 313 | if (ban.ipLen && ban.ip[0 .. ban.ipLen] == ip[0 .. len]) 314 | { 315 | if (ban.expired) 316 | return BanState.none; 317 | else if (ban.d >= 20.seconds) 318 | { 319 | ban.until = MonoTime.currTime + ban.d; 320 | return BanState.banned; 321 | } 322 | else 323 | return BanState.warned; 324 | } 325 | } 326 | 327 | return BanState.none; 328 | } 329 | } 330 | else 331 | { 332 | BanState checkRequestIP(scope const HTTPServerRequest req) 333 | { 334 | return BanState.none; 335 | } 336 | 337 | bool warnRequestIP(scope const HTTPServerRequest req) 338 | { 339 | return false; 340 | } 341 | } 342 | 343 | void handleWSClient(scope WebSocket socket) 344 | { 345 | auto privConfig = findConfig(socket.request.host); 346 | if (privConfig == typeof(privConfig).init) 347 | { 348 | return; 349 | } 350 | 351 | MonoTime last = MonoTime.currTime - 5.seconds; 352 | ulong lastValue = ulong.max; 353 | 354 | Duration loopTimeout = 500.msecs; 355 | 356 | void send(ulong value) 357 | { 358 | if (lastValue != value) 359 | { 360 | loopTimeout = 200.msecs; 361 | lastValue = value; 362 | ubyte[8] buf = nativeToLittleEndian(value); 363 | if (socket.connected) 364 | socket.send(buf[]); 365 | } 366 | } 367 | 368 | int numSent = 0; 369 | auto state = checkRequestIP(socket.request); 370 | if (state == BanState.banned) 371 | return; 372 | else if (state == BanState.warned) 373 | { 374 | sleep(500.msecs); 375 | if (!socket.connected) 376 | return; 377 | if (socket.dataAvailableForRead) 378 | goto DataReceiver; 379 | } 380 | 381 | while (true) 382 | { 383 | while (socket.connected && !socket.dataAvailableForRead) 384 | { 385 | send(privConfig.readClicks()); 386 | 387 | if (!socket.connected) 388 | break; 389 | socket.waitForData(loopTimeout); 390 | if (loopTimeout < 500.msecs) 391 | loopTimeout += 50.msecs; 392 | } 393 | DataReceiver: 394 | if (!socket.connected) 395 | break; 396 | auto b = socket.receiveBinary(); 397 | auto now = MonoTime.currTime; 398 | auto dur = now - last; 399 | if (dur > 5.seconds) 400 | dur = 5.seconds; 401 | last = now; 402 | int n = b.length ? b[0] : 0; 403 | numSent++; 404 | if (n > 0 && n * 20.msecs < dur) 405 | { 406 | if (numSent == 0 && n > 70) 407 | { 408 | warnRequestIP(socket.request); 409 | if (state == BanState.warned) 410 | { 411 | socket.close(); 412 | return; 413 | } 414 | } 415 | privConfig.increment(n); 416 | } 417 | } 418 | 419 | if (numSent <= 2) 420 | { 421 | warnRequestIP(socket.request); 422 | } 423 | } 424 | 425 | PrivateConfig findConfig(string host) @trusted 426 | { 427 | if (host.endsWith(chain(".", ANY_HOST))) 428 | { 429 | string domain = host[0 .. $ - ".wants.pet".length]; 430 | 431 | foreach (ref site; sites.configs) 432 | { 433 | // lul linear search 434 | if (site.namespace == domain) 435 | { 436 | return site; 437 | } 438 | } 439 | } 440 | else 441 | { 442 | return sites.configs[0]; 443 | } 444 | 445 | return PrivateConfig.init; 446 | } 447 | 448 | ulong readClicks(ref PrivateConfig c) @trusted 449 | { 450 | return database.get(c.namespace).clicks; 451 | } 452 | 453 | void increment(ref PrivateConfig c, int n) @trusted 454 | { 455 | database.incr(c.namespace, n); 456 | } 457 | 458 | struct Stats 459 | { 460 | int global; 461 | } 462 | --------------------------------------------------------------------------------