├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── Classroom+Chair.jpg ├── add.gif ├── arch-shadow.png ├── arch.png ├── bubble.png ├── chat.gif ├── close.png ├── delete.ico ├── drag.png ├── dragArea.png ├── duplicate.png ├── ear_sit.gif ├── ear_stand.gif ├── ear_stand.png ├── ear_walk.gif ├── evil_stand.gif ├── evil_walk.gif ├── favicon.ico ├── fern.jpg ├── flower.png ├── gear.png ├── locked.png ├── move.gif ├── spiral.png ├── spiral.svg ├── stand1.gif ├── subtract.gif ├── text.png ├── unlocked.png ├── upload.png ├── walk1.gif └── walk2.gif ├── index.html ├── index.tsx ├── main.css ├── package.json ├── scraper ├── ambient_sounds.json └── scrape.js ├── server ├── config │ └── index.ts ├── cors.json ├── database.ts ├── email.ts ├── endpoints.ts ├── helpers.ts ├── package.json ├── serve.ts ├── tsconfig.json ├── yarn.lock └── yjs │ ├── callback.js │ ├── utils.ts │ └── ws-server.ts ├── src ├── Entity.tsx ├── SpaceSettings.tsx ├── audio.ts ├── camera.tsx ├── client.ts ├── entity.css ├── hooks.ts ├── imageUpload.ts ├── input.ts ├── movement.ts ├── render.tsx ├── settings.css ├── state.ts ├── tools.css ├── types.ts ├── ui.tsx ├── useAnimationFrame.tsx └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | server/uploads/ 2 | server/db.txt 3 | server/db.sqlite 4 | server/dbDir/ 5 | server/config/keys.json 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | docs 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.enabledLanguageIds": [ 3 | "asciidoc", 4 | "c", 5 | "cpp", 6 | "csharp", 7 | "git-commit", 8 | "go", 9 | "handlebars", 10 | "haskell", 11 | "html", 12 | "jade", 13 | "java", 14 | "javascript", 15 | "javascriptreact", 16 | "json", 17 | "jsonc", 18 | "latex", 19 | "less", 20 | "markdown", 21 | "php", 22 | "plaintext", 23 | "pug", 24 | "restructuredtext", 25 | "rust", 26 | "scala", 27 | "scss", 28 | "text", 29 | "yaml", 30 | "yml" 31 | ] 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Max Bittker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # walk & collage 2 | 3 | collage tool, flat game, MMO 4 | 5 | http://harmonyzone.org/ballwithfeet.html 6 | https://www.gabrielgambetta.com/client-server-game-architecture.html 7 | -------------------------------------------------------------------------------- /assets/Classroom+Chair.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/Classroom+Chair.jpg -------------------------------------------------------------------------------- /assets/add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/add.gif -------------------------------------------------------------------------------- /assets/arch-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/arch-shadow.png -------------------------------------------------------------------------------- /assets/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/arch.png -------------------------------------------------------------------------------- /assets/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/bubble.png -------------------------------------------------------------------------------- /assets/chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/chat.gif -------------------------------------------------------------------------------- /assets/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/close.png -------------------------------------------------------------------------------- /assets/delete.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/delete.ico -------------------------------------------------------------------------------- /assets/drag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/drag.png -------------------------------------------------------------------------------- /assets/dragArea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/dragArea.png -------------------------------------------------------------------------------- /assets/duplicate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/duplicate.png -------------------------------------------------------------------------------- /assets/ear_sit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/ear_sit.gif -------------------------------------------------------------------------------- /assets/ear_stand.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/ear_stand.gif -------------------------------------------------------------------------------- /assets/ear_stand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/ear_stand.png -------------------------------------------------------------------------------- /assets/ear_walk.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/ear_walk.gif -------------------------------------------------------------------------------- /assets/evil_stand.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/evil_stand.gif -------------------------------------------------------------------------------- /assets/evil_walk.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/evil_walk.gif -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/favicon.ico -------------------------------------------------------------------------------- /assets/fern.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/fern.jpg -------------------------------------------------------------------------------- /assets/flower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/flower.png -------------------------------------------------------------------------------- /assets/gear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/gear.png -------------------------------------------------------------------------------- /assets/locked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/locked.png -------------------------------------------------------------------------------- /assets/move.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/move.gif -------------------------------------------------------------------------------- /assets/spiral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/spiral.png -------------------------------------------------------------------------------- /assets/spiral.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/stand1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/stand1.gif -------------------------------------------------------------------------------- /assets/subtract.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/subtract.gif -------------------------------------------------------------------------------- /assets/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/text.png -------------------------------------------------------------------------------- /assets/unlocked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/unlocked.png -------------------------------------------------------------------------------- /assets/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/upload.png -------------------------------------------------------------------------------- /assets/walk1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/walk1.gif -------------------------------------------------------------------------------- /assets/walk2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaxBittker/walky/4f0d179750d48193608d229d61421afea8f8d912/assets/walk2.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | walky 5 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 |
21 |
22 |
23 | 24 | 25 | 26 | 31 | 36 | 37 | 38 | 39 | 44 | 50 | 51 | -------------------------------------------------------------------------------- /index.tsx: -------------------------------------------------------------------------------- 1 | import { Render } from "./src/render"; 2 | import * as React from "react"; 3 | import ReactDOM = require("react-dom"); 4 | 5 | import { startUI } from "./src/ui"; 6 | import "regenerator-runtime/runtime"; 7 | 8 | import { startInput } from "./src/input"; 9 | import { updateCamera } from "./src/camera"; 10 | import { updateAgent } from "./src/movement"; 11 | import { sendUpdate } from "./src/client"; 12 | import { getState } from "./src/state"; 13 | 14 | startInput(); 15 | startUI(); 16 | let i = 0; 17 | 18 | const rootElement = document.getElementById("window"); 19 | ReactDOM.render( 20 | 21 | 22 | , 23 | rootElement 24 | ); 25 | 26 | // // let debug = document.getElementById("debug"); 27 | 28 | let lasttick = Date.now(); 29 | function tick() { 30 | let state = getState(); 31 | let { me, agents } = state; 32 | 33 | let elapsedMillis = Date.now() - lasttick; 34 | let millisPerTick = 1000 / 60; 35 | let elapsedTicks = elapsedMillis / millisPerTick; 36 | 37 | state.me = updateAgent(me, elapsedTicks); 38 | state.agents = agents.map((agent) => updateAgent(agent, elapsedTicks)); 39 | 40 | // debug.innerHTML = state.tick; 41 | updateCamera(elapsedTicks); 42 | if (i % 10 == 0) { 43 | sendUpdate(); 44 | } 45 | 46 | i++; 47 | 48 | lasttick = Date.now(); 49 | } 50 | -------------------------------------------------------------------------------- /main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: "Libre Franklin", sans-serif; 4 | 5 | width: 100vw; 6 | height: 100vh; 7 | overflow: hidden; 8 | overscroll-behavior-x: none; 9 | overscroll-behavior-y: none; 10 | user-select: none; 11 | box-sizing: border-box; 12 | touch-action: none; 13 | -webkit-touch-callout: none; 14 | 15 | height: 100vh; 16 | height: -webkit-fill-available; 17 | height: fill-available; 18 | } 19 | textarea { 20 | font-family: "Libre Franklin", sans-serif; 21 | 22 | } 23 | * { 24 | -webkit-user-select: none; /* Safari */ 25 | user-select: none !important; 26 | touch-action: manipulation; 27 | } 28 | #window { 29 | overscroll-behavior-y: none; 30 | overscroll-behavior-x: none; 31 | position: absolute; 32 | overflow: hidden; 33 | 34 | left: 0; 35 | right: 0; 36 | top: 0; 37 | bottom: 0; 38 | height: 100vh; 39 | height: -webkit-fill-available; 40 | height: fill-available; 41 | /* background-color: bisque; */ 42 | } 43 | @media screen and (max-width: 600px) { 44 | #window { 45 | zoom: 0.6; 46 | /* overflow: visible; */ 47 | 48 | /* overflow: hidden; */ 49 | /* overscroll-behavior-y: none; */ 50 | } 51 | } 52 | 53 | #background { 54 | background-size: 20px 20px; 55 | position: absolute; 56 | /* overflow: hidden; */ 57 | left: 0; 58 | right: 0; 59 | top: 0; 60 | bottom: 0; 61 | background-image: linear-gradient(to right, #aaa 1px, transparent 1px), 62 | linear-gradient(to bottom, #aaa 1px, transparent 1px); 63 | } 64 | 65 | #entities { 66 | transform: translateZ(0); 67 | 68 | /* transition: transform 100ms; */ 69 | will-change: transform; 70 | } 71 | 72 | #entities img { 73 | /* box-shadow: 2px 2px 10px black; */ 74 | /* filter: drop-shadow(4px 4px 7px black); */ 75 | /* opacity: 0.5; */ 76 | /* will-change: filter; */ 77 | /* box-shadow: inset 0 0 0 0.01px white; */ 78 | /* mix-blend-mode: normal; */ 79 | /* image-rendering: pixelated; */ 80 | } 81 | 82 | * { 83 | color: #333; 84 | margin: 0; 85 | /* border: 1px red solid; */ 86 | } 87 | 88 | img { 89 | /* position: absolute; */ 90 | z-index: 1; 91 | /* mix-blend-mode: difference; */ 92 | /* mix-blend-mode: exclusion; */ 93 | pointer-events: none; 94 | user-select: none; 95 | /* transition: transform 0.05s; */ 96 | /* transform: translate(-50%, -50%); */ 97 | } 98 | 99 | .deleting img { 100 | pointer-events: all; 101 | } 102 | .deleting { 103 | z-index: 1; 104 | 105 | cursor: url(./assets/delete.ico), auto; 106 | } 107 | 108 | .deleting #entities img:hover { 109 | border: 3px red dashed; 110 | filter: drop-shadow(0px 0px 5px red); 111 | } 112 | 113 | .moving img { 114 | pointer-events: all; 115 | } 116 | .moving { 117 | z-index: 1; 118 | 119 | cursor: move; 120 | } 121 | 122 | .moving #entities img:hover { 123 | /* border: 3px greenyellow dashed; */ 124 | /* filter: drop-shadow(0px 0px 5px greenyellow); */ 125 | } 126 | 127 | #walker { 128 | position: absolute; 129 | width: 100px; 130 | height: 100px; 131 | z-index: 10; 132 | filter: contrast(120%); 133 | mix-blend-mode: normal; 134 | } 135 | .speech { 136 | font-size: 50px; 137 | position: absolute; 138 | z-index: 10; 139 | transform: translate(-50%, -130px); 140 | /* filter: drop-shadow(0px 0px 2px #fff); */ 141 | } 142 | .bubble { 143 | width: 120px; 144 | height: 120px; 145 | font-size: 50px; 146 | position: absolute; 147 | z-index: 9; 148 | filter: contrast(1.9); 149 | transform: translate(-50%, -150px); 150 | mix-blend-mode: normal; 151 | } 152 | 153 | #spawner{ 154 | position: absolute; 155 | z-index: 10006; 156 | top: -50px; 157 | /* transform: translate(-50%, -50%); */ 158 | width: 150px; 159 | height: 150px; 160 | /* border-radius: 100%; */ 161 | /* opacity: 0.5; */ 162 | /* border: 2px dashed rgba(100, 100, 100, 0.561) ; */ 163 | } 164 | #spawner.shadow{ 165 | z-index: 10; 166 | } 167 | 168 | .rotate{ 169 | animation: rotate 10s linear infinite; 170 | } 171 | @keyframes rotate{ 172 | to{ transform: rotate(360deg); } 173 | } 174 | 175 | #close { 176 | position: absolute; 177 | right: 1.5em; 178 | top: 1.5em; 179 | width: 32px; 180 | height: 32px; 181 | pointer-events: all; 182 | cursor: pointer; 183 | } 184 | 185 | .audio { 186 | position: absolute; 187 | opacity: 0.2; 188 | font-size: 60px; 189 | filter: blur(25px); 190 | position: "absolute"; 191 | } 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "walky", 3 | "version": "1.0.0", 4 | "description": "walk and collage", 5 | "main": "index.js", 6 | "dependencies": { 7 | "@graph-ts/vector2": "^1.3.0", 8 | "@stytch/stytch-react": "^3.0.7", 9 | "@types/classnames": "^2.3.1", 10 | "@types/react": "^16.9.41", 11 | "@types/react-dom": "^16.9.8", 12 | "@types/uuid": "^8.3.0", 13 | "assets": "^3.0.1", 14 | "browser-image-resizer": "github:ericnograles/browser-image-resizer", 15 | "classnames": "^2.2.6", 16 | "jotai": "^1.6.1", 17 | "morphdom": "^2.6.1", 18 | "parcel": "^1.12.4", 19 | "query-string": "^7.1.1", 20 | "react": "^16.13.1", 21 | "react-dom": "^16.13.1", 22 | "react-router-dom": "^6.3.0", 23 | "seedrandom": "^3.0.5", 24 | "xml2js": "^0.4.23", 25 | "y-websocket": "^1.4.0", 26 | "yjs": "^13.5.31" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.10.4", 30 | "@babel/preset-env": "^7.10.4", 31 | "@babel/preset-react": "^7.10.4", 32 | "@babel/preset-typescript": "^7.10.4", 33 | "babel-core": "^6.26.3", 34 | "babel-preset-env": "^1.7.0", 35 | "babel-preset-react": "^6.24.1", 36 | "parcel-bundler": "^1.12.4", 37 | "typescript": "^3.8.2" 38 | }, 39 | "scripts": { 40 | "start": "parcel develop index.html", 41 | "build": "parcel build index.html -d docs ", 42 | "watch": "parcel watch index.html -d docs " 43 | }, 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/MaxBittker/walky.git" 47 | }, 48 | "author": "", 49 | "license": "ISC", 50 | "bugs": {} 51 | } 52 | -------------------------------------------------------------------------------- /scraper/scrape.js: -------------------------------------------------------------------------------- 1 | const https = require("https"); 2 | const fs = require("fs"); 3 | const xml2js = require("xml2js"); 4 | var parser = new xml2js.Parser(); 5 | let queue = []; 6 | let files = []; 7 | 8 | function scrapeURL() { 9 | let url = queue.pop(); 10 | console.log(url); 11 | https 12 | .get(url, function(res) { 13 | // res.setEncoding("utf8"); 14 | 15 | var body = ""; 16 | res.on("data", function(chunk) { 17 | body += chunk; 18 | }); 19 | res.on("end", function() { 20 | parser.parseString(body, function(err, result) { 21 | console.log(err); 22 | if (result) { 23 | let audio_files = result["audio_files"]; 24 | console.dir(JSON.stringify(result)); 25 | if (audio_files) { 26 | let list = audio_files["audio_file"]; 27 | console.log(list); 28 | files = files.concat(list); 29 | } 30 | } 31 | 32 | if (queue.length > 0) { 33 | scrapeURL(); 34 | } else { 35 | let data = JSON.stringify(files); 36 | fs.writeFileSync("ambient_sounds.json", data); 37 | } 38 | 39 | console.log("Done"); 40 | }); 41 | }); 42 | }) 43 | .on("error", function(e) { 44 | console.log("Got error: " + e.message); 45 | }); 46 | } 47 | 48 | let url_stem = "https://xml.ambient-mixer.com/get-audio?id_category="; 49 | for (var i = 0; i < 300; i++) { 50 | let url = url_stem + i; 51 | queue.push(url); 52 | } 53 | console.log(queue); 54 | scrapeURL(); 55 | -------------------------------------------------------------------------------- /server/config/index.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "@google-cloud/storage"; 2 | import path from "path"; 3 | const serviceKey = path.join(__dirname, "./keys.json"); 4 | 5 | const storage = new Storage({ 6 | keyFilename: serviceKey, 7 | projectId: "walky-345702" 8 | }); 9 | 10 | export default storage; 11 | -------------------------------------------------------------------------------- /server/cors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "origin": ["*"], 4 | "method": ["*"], 5 | "responseHeader": ["*"], 6 | "maxAgeSeconds": 3600 7 | } 8 | ] -------------------------------------------------------------------------------- /server/database.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from "sqlite3"; 2 | 3 | const sqlite = sqlite3.verbose(); 4 | 5 | const DBSOURCE = "db.sqlite"; 6 | 7 | let db = new sqlite.Database(DBSOURCE, (err) => { 8 | if (err) { 9 | // Cannot open database 10 | console.error(err.message); 11 | throw err; 12 | } else { 13 | console.log("Connected to the SQLite database."); 14 | db.run( 15 | `CREATE TABLE IF NOT EXISTS space ( 16 | id INTEGER PRIMARY KEY AUTOINCREMENT, 17 | path text UNIQUE, 18 | email text, 19 | code text, 20 | opt INTEGER, 21 | CONSTRAINT path_unique UNIQUE (path) 22 | )`, 23 | (err) => { 24 | console.log(err); 25 | } 26 | ); 27 | } 28 | }); 29 | 30 | export default db; 31 | -------------------------------------------------------------------------------- /server/email.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | 4 | const serviceKey = JSON.parse( 5 | fs.readFileSync(path.join(__dirname, "./config/keys.json")).toString() 6 | ); 7 | 8 | const mailjet = require("node-mailjet").connect( 9 | (serviceKey as any)["mailjet_key"], 10 | (serviceKey as any)["mailjet_secret"] 11 | ); 12 | 13 | function sendEmail({ 14 | email, 15 | path, 16 | code, 17 | }: { 18 | email: string; 19 | path: string; 20 | code: string; 21 | }): void { 22 | const request = mailjet.post("send", { version: "v3.1" }).request({ 23 | Messages: [ 24 | { 25 | From: { 26 | Email: "noreply@walky.space", 27 | Name: "walky.space", 28 | }, 29 | To: [ 30 | { 31 | Email: email, 32 | Name: email, 33 | }, 34 | ], 35 | Subject: `You claimed walky.space/${path}`, 36 | TextPart: `Here's your edit code: ${code} `, 37 | HTMLPart: ` 38 |

You're now the gardener of walky.space/${path}!

39 |

Edit code: ${code}

40 |

You can also follow this link to unlock the space: walky.space/${path}?code=${code}

41 | 42 | 43 |

44 | If you have any questions or comments, you can send them to maxbittker@gmail.com! 45 |

46 |
47 |         
`, 48 | }, 49 | ], 50 | }); 51 | request 52 | .then((result: { body: any }) => { 53 | console.log(result.body); 54 | }) 55 | .catch((err: { statusCode: any }) => { 56 | console.log(err.statusCode); 57 | }); 58 | } 59 | export default sendEmail; 60 | -------------------------------------------------------------------------------- /server/endpoints.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as http from "http"; 3 | import * as https from "https"; 4 | import express from "express"; 5 | import multer from "multer"; 6 | import fs from "fs"; 7 | import db from "./database"; 8 | import dotenv from "dotenv"; 9 | dotenv.config({ path: __dirname + "/.env" }); 10 | 11 | // Certificate 12 | const privateKey = fs.readFileSync( 13 | "/etc/letsencrypt/live/walky.space/privkey.pem", 14 | "utf8" 15 | ); 16 | const certificate = fs.readFileSync( 17 | "/etc/letsencrypt/live/walky.space/cert.pem", 18 | "utf8" 19 | ); 20 | const ca = fs.readFileSync( 21 | "/etc/letsencrypt/live/walky.space/chain.pem", 22 | "utf8" 23 | ); 24 | 25 | const credentials = { 26 | key: privateKey, 27 | cert: certificate, 28 | ca: ca, 29 | }; 30 | 31 | import { uploadImage, uploadImageURl } from "./helpers"; 32 | import sendEmail from "./email"; 33 | 34 | function startEndpoints(PORT: number): https.Server { 35 | const app = express(); 36 | app.use(express.json()); 37 | app.use(express.urlencoded({ extended: true })); //Parse URL-encoded bodies 38 | 39 | const httpServer = http.createServer(app); 40 | const httpsServer = https.createServer(credentials, app); 41 | 42 | app.use(express.static("../docs")); 43 | app.use("/files", express.static("./uploads")); 44 | 45 | app.get("/claimed/:path?", async function (req, res) { 46 | let path = req.params.path ?? ""; 47 | const code = req.header("code"); 48 | 49 | // Query the space by path 50 | const query = "SELECT id, email, path, code FROM space WHERE path = ?"; 51 | const params = [path]; 52 | 53 | db.get(query, params, (err: any, row: object) => { 54 | if (err) return res.status(400).send(err); 55 | 56 | if (!row) { 57 | return res.status(201).send({ claimed: false, access: "public" }); 58 | } else { 59 | if ((row as any)["code"] === "") { 60 | res.status(200).send({ claimed: true, access: "public" }); 61 | return; 62 | } 63 | if ((row as any)["code"] === code) { 64 | res.status(200).send({ claimed: true, access: "editor" }); 65 | return; 66 | } 67 | 68 | // space was already saved in database. 69 | res.status(200).send({ claimed: true, access: "none" }); 70 | } 71 | }); 72 | }); 73 | 74 | app.post("/claim/:path?", async function (req, res) { 75 | let path = req.params.path ?? ""; 76 | const email = req.body["email"]; 77 | const code = req.body["code"]; 78 | const opt = req.body["opt"]; 79 | 80 | // Query the space by path 81 | const query = "SELECT id, email, path, code FROM space WHERE path = ?"; 82 | const params = [path]; 83 | 84 | console.log("claiming space: " + path); 85 | db.all(query, params, (err: any, rows: string | any[]) => { 86 | if (err) return res.status(400).send(err); 87 | 88 | // If space is not found, create an entry 89 | if (rows.length === 0) { 90 | const insertQuery = 91 | "INSERT INTO space (path, email, code, opt) VALUES (?, ?, ?, ?)"; 92 | const params = [path, email, code, opt]; 93 | db.run(insertQuery, params, (result: any, err: any) => { 94 | if (err) { 95 | return res.status(400).send(err); 96 | } else { 97 | console.log("space created"); 98 | sendEmail({ code, path, email }); 99 | return res.status(201).send({ path, email, code, opt }); 100 | } 101 | }); 102 | } else { 103 | // User was already saved in database. 104 | console.log("User retrieved"); 105 | res.status(200).send(rows[0]); 106 | } 107 | }); 108 | }); 109 | 110 | app.get("*", function (request, response) { 111 | response.sendFile(path.resolve(__dirname, "../docs/index.html")); 112 | }); 113 | 114 | httpServer.listen(80, () => { 115 | console.log(`Server is listening on port ${80}`); 116 | }); 117 | httpsServer.listen(443, () => { 118 | console.log(`Server is listening on port ${443}`); 119 | }); 120 | 121 | const handleError = (err: any, res: any) => { 122 | console.log(err); 123 | res 124 | .status(500) 125 | .contentType("text/plain") 126 | .end("Oops! Something went wrong!"); 127 | }; 128 | 129 | const upload = multer({ 130 | storage: multer.memoryStorage(), 131 | limits: { 132 | fileSize: 6 * 1024 * 1024, 133 | }, 134 | }); 135 | 136 | app.post( 137 | "/upload", 138 | upload.single( 139 | "image-upload" /* name attribute of element in your form */ 140 | ), 141 | async (req, res) => { 142 | let owner_uuid = req.body["owner"]; 143 | let name = req.body["name"]; 144 | let position = JSON.parse(req.body["position"]); 145 | let size = req.body["size"] && JSON.parse(req.body["size"]); 146 | 147 | const uuid = Math.random().toString().slice(2, 7); 148 | try { 149 | let imageUrl; 150 | if (req.file) { 151 | imageUrl = await uploadImage(req.file, uuid + name); 152 | } else { 153 | let url = req.body["url"]; 154 | let resp = (await uploadImageURl(url)) as any; 155 | size = resp.size; 156 | imageUrl = resp.publicUrl; 157 | } 158 | res 159 | .status(200) 160 | .contentType("text/json") 161 | .end( 162 | JSON.stringify({ 163 | uuid, 164 | value: imageUrl, 165 | type: "img", 166 | position, 167 | size, 168 | owner_uuid, 169 | }) 170 | ); 171 | } catch (error) { 172 | return handleError(error, res); 173 | } 174 | } 175 | ); 176 | 177 | return httpsServer; 178 | } 179 | 180 | export { startEndpoints }; 181 | -------------------------------------------------------------------------------- /server/helpers.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | import gc from "./config/"; 3 | const bucket = gc.bucket("walky-uploads"); // should be your bucket name 4 | import axios, { AxiosRequestConfig } from "axios"; 5 | import sizeOf from "image-size"; 6 | 7 | /** 8 | * 9 | * @param { File } object file object that will be uploaded 10 | * @description - This function does the following 11 | * - It uploads a file to the image bucket on Google Cloud 12 | * - It accepts an object as an argument with the 13 | * "originalname" and "buffer" as keys 14 | */ 15 | 16 | export const uploadImage = ( 17 | file: { originalname: any; buffer: any }, 18 | originalname: string 19 | ) => 20 | new Promise((resolve, reject) => { 21 | const { buffer } = file; 22 | let name = originalname.replace(/ /g, "_"); 23 | const blob = bucket.file(name); 24 | const blobStream = blob.createWriteStream({ 25 | resumable: false 26 | }); 27 | blobStream 28 | .on("finish", () => { 29 | const publicUrl = util.format( 30 | `https://storage.googleapis.com/${bucket.name}/${name}` 31 | ); 32 | resolve(publicUrl); 33 | }) 34 | .on("error", (e: any) => { 35 | console.log(e); 36 | reject(`Unable to upload image, something went wrong`); 37 | }) 38 | .end(buffer); 39 | }); 40 | 41 | export const uploadImageURl = (url: string) => 42 | new Promise(async (resolve, reject): Promise => { 43 | const config = { responseType: "arraybuffer" }; 44 | 45 | const resp = await axios.get(url, config as AxiosRequestConfig); 46 | resp.data; 47 | const name = encodeURI(url.substring(url.lastIndexOf("/") + 1)); 48 | 49 | const blob = bucket.file(name); 50 | const blobStream = blob.createWriteStream({ 51 | resumable: false 52 | }); 53 | const dimensions = await sizeOf(resp.data); 54 | const size = { x: dimensions.width, y: dimensions.height }; 55 | 56 | blobStream 57 | .on("finish", () => { 58 | const publicUrl = util.format( 59 | `https://storage.googleapis.com/${bucket.name}/${name}` 60 | ); 61 | resolve({ publicUrl, size }); 62 | }) 63 | .on("error", (e: any) => { 64 | console.log(e); 65 | reject(`Unable to upload image, something went wrong`); 66 | }) 67 | .end(resp.data); 68 | }); 69 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "serve.ts", 6 | "scripts": { 7 | "start": "ts-node serve.ts", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@google-cloud/storage": "^5.18.3", 14 | "@types/express": "^4.17.7", 15 | "@types/multer": "^1.4.4", 16 | "@types/uuid": "^8.3.0", 17 | "@types/websocket": "^1.0.1", 18 | "axios": "^0.26.1", 19 | "express": "^4.17.1", 20 | "image-size": "^1.0.1", 21 | "multer": "^1.4.2", 22 | "node-mailjet": "^3.3.7", 23 | "sqlite3": "^5.0.2", 24 | "stytch": "^3.13.0", 25 | "ts-node": "^10.7.0", 26 | "typescript": "^4.0.2", 27 | "websocket": "^1.0.31", 28 | "y-websocket": "^1.4.0" 29 | }, 30 | "devDependencies": { 31 | "@types/dotenv": "^8.2.0", 32 | "@types/sqlite3": "^3.1.8", 33 | "@types/ws": "^8.5.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/serve.ts: -------------------------------------------------------------------------------- 1 | // Node.js WebSocket server script 2 | import { startEndpoints } from "./endpoints"; 3 | import { startWsServer } from "./yjs/ws-server"; 4 | 5 | let basePort: number = parseInt(process.env?.PORT || "4000", 10); 6 | 7 | let server = startEndpoints(basePort); 8 | startWsServer(server); 9 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "ts-node/node16/tsconfig.json", 3 | // Or install directly with `npm i -D @tsconfig/node16` 4 | "extends": "@tsconfig/node16/tsconfig.json", 5 | "compilerOptions": { 6 | "esModuleInterop": true, 7 | "downlevelIteration": true 8 | 9 | }, 10 | } -------------------------------------------------------------------------------- /server/yjs/callback.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | 3 | const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null 4 | const CALLBACK_TIMEOUT = process.env.CALLBACK_TIMEOUT || 5000 5 | const CALLBACK_OBJECTS = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {} 6 | 7 | exports.isCallbackSet = !!CALLBACK_URL 8 | 9 | /** 10 | * @param {Uint8Array} update 11 | * @param {any} origin 12 | * @param {WSSharedDoc} doc 13 | */ 14 | exports.callbackHandler = (update, origin, doc) => { 15 | const room = doc.name 16 | const dataToSend = { 17 | room: room, 18 | data: {} 19 | } 20 | const sharedObjectList = Object.keys(CALLBACK_OBJECTS) 21 | sharedObjectList.forEach(sharedObjectName => { 22 | const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName] 23 | dataToSend.data[sharedObjectName] = { 24 | type: sharedObjectType, 25 | content: getContent(sharedObjectName, sharedObjectType, doc).toJSON() 26 | } 27 | }) 28 | callbackRequest(CALLBACK_URL, CALLBACK_TIMEOUT, dataToSend) 29 | } 30 | 31 | /** 32 | * @param {URL} url 33 | * @param {number} timeout 34 | * @param {Object} data 35 | */ 36 | const callbackRequest = (url, timeout, data) => { 37 | data = JSON.stringify(data) 38 | const options = { 39 | hostname: url.hostname, 40 | port: url.port, 41 | path: url.pathname, 42 | timeout: timeout, 43 | method: 'POST', 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | 'Content-Length': data.length 47 | } 48 | } 49 | const req = http.request(options) 50 | req.on('timeout', () => { 51 | console.warn('Callback request timed out.') 52 | req.abort() 53 | }) 54 | req.on('error', (e) => { 55 | console.error('Callback request error.', e) 56 | req.abort() 57 | }) 58 | req.write(data) 59 | req.end() 60 | } 61 | 62 | /** 63 | * @param {string} objName 64 | * @param {string} objType 65 | * @param {WSSharedDoc} doc 66 | */ 67 | const getContent = (objName, objType, doc) => { 68 | switch (objType) { 69 | case 'Array': return doc.getArray(objName) 70 | case 'Map': return doc.getMap(objName) 71 | case 'Text': return doc.getText(objName) 72 | case 'XmlFragment': return doc.getXmlFragment(objName) 73 | case 'XmlElement': return doc.getXmlElement(objName) 74 | default : return {} 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/yjs/utils.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "ws"; 2 | import { IncomingMessage } from "http"; 3 | const querystring = require("querystring"); 4 | const Y = require("yjs"); 5 | const syncProtocol = require("y-protocols/dist/sync.cjs"); 6 | const awarenessProtocol = require("y-protocols/dist/awareness.cjs"); 7 | 8 | const encoding = require("lib0/dist/encoding.cjs"); 9 | const decoding = require("lib0/dist/decoding.cjs"); 10 | const mutex = require("lib0/dist/mutex.cjs"); 11 | const map = require("lib0/dist/map.cjs"); 12 | 13 | const debounce = require("lodash.debounce"); 14 | 15 | import db from "../database"; 16 | 17 | const callbackHandler = require("./callback.js").callbackHandler; 18 | const isCallbackSet = require("./callback.js").isCallbackSet; 19 | 20 | const CALLBACK_DEBOUNCE_WAIT = 21 | parseInt(process.env.CALLBACK_DEBOUNCE_WAIT!) || 2000; 22 | const CALLBACK_DEBOUNCE_MAXWAIT = 23 | parseInt(process.env.CALLBACK_DEBOUNCE_MAXWAIT!) || 10000; 24 | 25 | const wsReadyStateConnecting = 0; 26 | const wsReadyStateOpen = 1; 27 | const wsReadyStateClosing = 2; // eslint-disable-line 28 | const wsReadyStateClosed = 3; // eslint-disable-line 29 | 30 | // disable gc when using snapshots! 31 | const gcEnabled = process.env.GC !== "false" && process.env.GC !== "0"; 32 | const persistenceDir = process.env.YPERSISTENCE; 33 | /** 34 | * @type {{bindState: function(string,WSSharedDoc):void, writeState:function(string,WSSharedDoc):Promise, provider: any}|null} 35 | */ 36 | let persistence: { 37 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 38 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 39 | provider: any; 40 | } | null = null; 41 | if (typeof persistenceDir === "string") { 42 | console.info('Persisting documents to "' + persistenceDir + '"'); 43 | // @ts-ignore 44 | const LeveldbPersistence = require("y-leveldb").LeveldbPersistence; 45 | const ldb = new LeveldbPersistence(persistenceDir); 46 | persistence = { 47 | provider: ldb, 48 | bindState: async (docName, ydoc) => { 49 | const persistedYdoc = await ldb.getYDoc(docName); 50 | const newUpdates = Y.encodeStateAsUpdate(ydoc); 51 | ldb.storeUpdate(docName, newUpdates); 52 | Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc)); 53 | ydoc.on("update", (update: any) => { 54 | ldb.storeUpdate(docName, update); 55 | }); 56 | }, 57 | writeState: async (docName, ydoc) => {}, 58 | }; 59 | } 60 | 61 | /** 62 | * @param {{bindState: function(string,WSSharedDoc):void, 63 | * writeState:function(string,WSSharedDoc):Promise,provider:any}|null} persistence_ 64 | */ 65 | exports.setPersistence = ( 66 | persistence_: { 67 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 68 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 69 | provider: any; 70 | } | null 71 | ) => { 72 | persistence = persistence_; 73 | }; 74 | 75 | /** 76 | * @return {null|{bindState: function(string,WSSharedDoc):void, 77 | * writeState:function(string,WSSharedDoc):Promise}|null} used persistence layer 78 | */ 79 | exports.getPersistence = (): null | { 80 | bindState: (arg0: string, arg1: WSSharedDoc) => void; 81 | writeState: (arg0: string, arg1: WSSharedDoc) => Promise; 82 | } | null => persistence; 83 | 84 | /** 85 | * @type {Map} 86 | */ 87 | const docs: Map = new Map(); 88 | // exporting docs so that others can use it 89 | exports.docs = docs; 90 | 91 | const messageSync = 0; 92 | const messageAwareness = 1; 93 | // const messageAuth = 2 94 | 95 | /** 96 | * @param {Uint8Array} update 97 | * @param {any} origin 98 | * @param {WSSharedDoc} doc 99 | */ 100 | const updateHandler = (update: Uint8Array, origin: any, doc: WSSharedDoc) => { 101 | const encoder = encoding.createEncoder(); 102 | encoding.writeVarUint(encoder, messageSync); 103 | syncProtocol.writeUpdate(encoder, update); 104 | const message = encoding.toUint8Array(encoder); 105 | doc.conns.forEach((_: any, conn: any) => send(doc, conn, message)); 106 | }; 107 | 108 | class WSSharedDoc extends Y.Doc { 109 | /** 110 | * @param {string} name 111 | */ 112 | constructor(name: string) { 113 | super({ gc: gcEnabled }); 114 | this.name = name; 115 | this.mux = mutex.createMutex(); 116 | /** 117 | * Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed 118 | * @type {Map>} 119 | */ 120 | this.conns = new Map(); 121 | /** 122 | * @type {awarenessProtocol.Awareness} 123 | */ 124 | this.awareness = new awarenessProtocol.Awareness(this); 125 | this.awareness.setLocalState(null); 126 | /** 127 | * @param {{ added: Array, updated: Array, removed: Array }} changes 128 | * @param {Object | null} conn Origin is the connection that made the change 129 | */ 130 | const awarenessChangeHandler = ( 131 | { 132 | added, 133 | updated, 134 | removed, 135 | }: { 136 | added: Array; 137 | updated: Array; 138 | removed: Array; 139 | }, 140 | conn: object | null 141 | ) => { 142 | const changedClients = added.concat(updated, removed); 143 | if (conn !== null) { 144 | const connControlledIDs = 145 | /** @type {Set} */ this.conns.get(conn); 146 | if (connControlledIDs !== undefined) { 147 | added.forEach((clientID) => { 148 | connControlledIDs.add(clientID); 149 | }); 150 | removed.forEach((clientID) => { 151 | connControlledIDs.delete(clientID); 152 | }); 153 | } 154 | } 155 | // broadcast awareness update 156 | const encoder = encoding.createEncoder(); 157 | encoding.writeVarUint(encoder, messageAwareness); 158 | encoding.writeVarUint8Array( 159 | encoder, 160 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients) 161 | ); 162 | const buff = encoding.toUint8Array(encoder); 163 | this.conns.forEach((_: any, c: any) => { 164 | send(this, c, buff); 165 | }); 166 | }; 167 | this.awareness.on("update", awarenessChangeHandler); 168 | this.on("update", updateHandler); 169 | if (isCallbackSet) { 170 | this.on( 171 | "update", 172 | debounce(callbackHandler, CALLBACK_DEBOUNCE_WAIT, { 173 | maxWait: CALLBACK_DEBOUNCE_MAXWAIT, 174 | }) 175 | ); 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * Gets a Y.Doc by name, whether in memory or on disk 182 | * 183 | * @param {string} docname - the name of the Y.Doc to find or create 184 | * @param {boolean} gc - whether to allow gc on the doc (applies only when created) 185 | * @return {WSSharedDoc} 186 | */ 187 | const getYDoc = (docname: string, gc: boolean = true): WSSharedDoc => 188 | map.setIfUndefined(docs, docname, () => { 189 | const doc = new WSSharedDoc(docname); 190 | doc.gc = gc; 191 | if (persistence !== null) { 192 | persistence.bindState(docname, doc); 193 | } 194 | docs.set(docname, doc); 195 | return doc; 196 | }); 197 | 198 | exports.getYDoc = getYDoc; 199 | 200 | /** 201 | * @param {WebSocket} conn 202 | * @param {WSSharedDoc} doc 203 | * @param {Uint8Array} message 204 | */ 205 | const messageListener = ( 206 | conn: WebSocket, 207 | doc: WSSharedDoc, 208 | message: Uint8Array 209 | ) => { 210 | try { 211 | const encoder = encoding.createEncoder(); 212 | const decoder = decoding.createDecoder(message); 213 | const messageType = decoding.readVarUint(decoder); 214 | 215 | let [a0, a1] = message; 216 | let readonly = !(conn as any)["UNLOCKED"]; 217 | if (readonly && a0 == 0 && (a1 == 1 || a1 == 2)) { 218 | return; 219 | } 220 | 221 | switch (messageType) { 222 | case messageSync: 223 | encoding.writeVarUint(encoder, messageSync); 224 | syncProtocol.readSyncMessage(decoder, encoder, doc, null); 225 | if (encoding.length(encoder) > 1) { 226 | send(doc, conn, encoding.toUint8Array(encoder)); 227 | } 228 | break; 229 | case messageAwareness: { 230 | awarenessProtocol.applyAwarenessUpdate( 231 | doc.awareness, 232 | decoding.readVarUint8Array(decoder), 233 | conn 234 | ); 235 | break; 236 | } 237 | } 238 | } catch (err) { 239 | console.error(err); 240 | doc.emit("error", [err]); 241 | } 242 | }; 243 | 244 | /** 245 | * @param {WSSharedDoc} doc 246 | * @param {any} conn 247 | */ 248 | const closeConn = (doc: WSSharedDoc, conn: any) => { 249 | if (doc.conns.has(conn)) { 250 | /** 251 | * @type {Set} 252 | */ 253 | // @ts-ignore 254 | const controlledIds: Set = doc.conns.get(conn); 255 | doc.conns.delete(conn); 256 | awarenessProtocol.removeAwarenessStates( 257 | doc.awareness, 258 | Array.from(controlledIds), 259 | null 260 | ); 261 | if (doc.conns.size === 0 && persistence !== null) { 262 | // if persisted, we store state and destroy ydocument 263 | persistence.writeState(doc.name, doc).then(() => { 264 | doc.destroy(); 265 | }); 266 | docs.delete(doc.name); 267 | } 268 | } 269 | conn.close(); 270 | }; 271 | 272 | /** 273 | * @param {WSSharedDoc} doc 274 | * @param {any} conn 275 | * @param {Uint8Array} m 276 | */ 277 | const send = (doc: WSSharedDoc, conn: any, m: Uint8Array) => { 278 | if ( 279 | conn.readyState !== wsReadyStateConnecting && 280 | conn.readyState !== wsReadyStateOpen 281 | ) { 282 | closeConn(doc, conn); 283 | } 284 | try { 285 | conn.send( 286 | m, 287 | /** @param {any} err */ (err: any) => { 288 | err != null && closeConn(doc, conn); 289 | } 290 | ); 291 | } catch (e) { 292 | closeConn(doc, conn); 293 | } 294 | }; 295 | 296 | const pingTimeout = 30000; 297 | 298 | async function checkCode(path: string, code: string): Promise { 299 | const query = "SELECT code FROM space WHERE path = ?"; 300 | const params = [path]; 301 | 302 | return new Promise((resolve, reject) => { 303 | db.get(query, params, (err: any, row: Object) => { 304 | if (err) { 305 | console.error(err); 306 | resolve(false); 307 | return; 308 | } 309 | 310 | if (!row) { 311 | resolve(true); 312 | } else { 313 | let returnedCode = (row as any)["code"]; 314 | let ok = returnedCode === code || returnedCode === ""; 315 | resolve(ok); 316 | } 317 | }); 318 | }); 319 | } 320 | const setupWSConnection = async ( 321 | conn: WebSocket, 322 | req: IncomingMessage, 323 | { docName = req?.url?.slice(1).split("?")[0], gc = true }: any = {} 324 | ) => { 325 | let qi = req.url?.lastIndexOf("?"); 326 | let authToken = null; 327 | let writeAccess = false; 328 | if (qi) { 329 | let qs = req.url?.slice(qi + 1); 330 | authToken = querystring.parse(qs)["authToken"]; 331 | writeAccess = await checkCode( 332 | req.url?.slice("walky-space-".length + 2, qi) || "", 333 | authToken 334 | ); 335 | if (writeAccess) { 336 | (conn as any)["UNLOCKED"] = true; 337 | } 338 | } 339 | 340 | conn.binaryType = "arraybuffer"; 341 | // get doc, initialize if it does not exist yet 342 | const doc = getYDoc(docName, gc); 343 | doc.conns.set(conn, new Set()); 344 | // listen and reply to events 345 | conn.on( 346 | "message", 347 | /** @param {ArrayBuffer} message */ (message: ArrayBuffer) => 348 | messageListener(conn, doc, new Uint8Array(message)) 349 | ); 350 | 351 | // Check if connection is still alive 352 | let pongReceived = true; 353 | const pingInterval = setInterval(() => { 354 | if (!pongReceived) { 355 | if (doc.conns.has(conn)) { 356 | closeConn(doc, conn); 357 | } 358 | clearInterval(pingInterval); 359 | } else if (doc.conns.has(conn)) { 360 | pongReceived = false; 361 | try { 362 | conn.ping(); 363 | } catch (e) { 364 | closeConn(doc, conn); 365 | clearInterval(pingInterval); 366 | } 367 | } 368 | }, pingTimeout); 369 | conn.on("close", () => { 370 | closeConn(doc, conn); 371 | clearInterval(pingInterval); 372 | }); 373 | conn.on("pong", () => { 374 | pongReceived = true; 375 | }); 376 | // put the following in a variables in a block so the interval handlers don't keep in in 377 | // scope 378 | { 379 | // send sync step 1 380 | const encoder = encoding.createEncoder(); 381 | encoding.writeVarUint(encoder, messageSync); 382 | syncProtocol.writeSyncStep1(encoder, doc); 383 | send(doc, conn, encoding.toUint8Array(encoder)); 384 | const awarenessStates = doc.awareness.getStates(); 385 | if (awarenessStates.size > 0) { 386 | const encoder = encoding.createEncoder(); 387 | encoding.writeVarUint(encoder, messageAwareness); 388 | encoding.writeVarUint8Array( 389 | encoder, 390 | awarenessProtocol.encodeAwarenessUpdate( 391 | doc.awareness, 392 | Array.from(awarenessStates.keys()) 393 | ) 394 | ); 395 | send(doc, conn, encoding.toUint8Array(encoder)); 396 | } 397 | } 398 | }; 399 | export { setupWSConnection }; 400 | -------------------------------------------------------------------------------- /server/yjs/ws-server.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "ws"; 2 | import * as https from "https"; 3 | import { setupWSConnection } from "./utils"; 4 | 5 | // const host = process.env.HOST || "localhost"; 6 | 7 | function startWsServer(server: https.Server) { 8 | const wss = new WebSocket.Server({ noServer: true }); 9 | 10 | wss.on("connection", setupWSConnection); 11 | 12 | server.on("upgrade", (request: any, socket: any, head: any) => { 13 | // You may check auth of request here.. 14 | // See https://github.com/websockets/ws#client-authentication 15 | 16 | const handleAuth = (ws: any) => { 17 | wss.emit("connection", ws, request); 18 | }; 19 | wss.handleUpgrade(request, socket, head, handleAuth); 20 | }); 21 | } 22 | 23 | export { startWsServer }; 24 | -------------------------------------------------------------------------------- /src/Entity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState, useCallback, useRef, useEffect } from "react"; 3 | import { Simulate } from "react-dom/test-utils"; 4 | import "./entity.css"; 5 | import classNames from "classnames"; 6 | import { sendEntityUpdate } from "./client"; 7 | import * as Vector from "@graph-ts/vector2"; 8 | 9 | import { getState, getEntity, writeEntity, lockedAtom } from "./state"; 10 | import { convertTarget, deconvertTarget } from "./input"; 11 | import { sendEntityDelete } from "./client"; 12 | import { v4 as uuidv4 } from "uuid"; 13 | import { useDelayedGate } from "./hooks"; 14 | import { EntityType } from "./types"; 15 | import { clamp } from "./utils"; 16 | import { useAtom } from "jotai"; 17 | 18 | let grabPos: Matter.Vector; 19 | 20 | let canvas = document.createElement("canvas"); 21 | let ctx = canvas.getContext("2d"); 22 | // document.body.appendChild(ctx.canvas); // used for debugging 23 | function checkImageCoord( 24 | img_element: HTMLElement, 25 | pos: Vector.Vector2, 26 | scale: number, 27 | rotation: number, 28 | event: MouseEvent 29 | ): any { 30 | if (!ctx) { 31 | return img_element; 32 | } 33 | // non-image elements are always considered opaque 34 | if (img_element?.tagName !== "IMG") { 35 | return img_element; 36 | } 37 | 38 | // Get click coordinates 39 | let x = event.clientX; 40 | let y = event.clientY; 41 | let w = (ctx.canvas.width = window.innerWidth); 42 | let h = (ctx.canvas.height = window.innerHeight); 43 | 44 | ctx.clearRect(0, 0, w, h); 45 | 46 | let pc = deconvertTarget(pos); 47 | ctx.translate(pc.x, pc.y); 48 | ctx.scale(scale * window.zoom, scale * window.zoom); 49 | ctx.rotate(rotation / (180 / Math.PI)); 50 | 51 | ctx.drawImage( 52 | img_element, 53 | -img_element.width / 2, 54 | -img_element.height / 2, 55 | img_element.width, 56 | img_element.height 57 | ); 58 | ctx.resetTransform(); 59 | let alpha = 1; 60 | try { 61 | alpha = ctx.getImageData(x, y, 1, 1).data[3]; // [0]R [1]G [2]B [3]A 62 | if (!img_element.complete) { 63 | alpha = 1; 64 | } 65 | } catch (e) { 66 | console.warn(`add crossorigin="anonymous" to your img`); 67 | } 68 | // If pixel is transparent, then retrieve the element underneath 69 | // and trigger it's click event 70 | if (alpha === 0) { 71 | img_element.style.pointerEvents = "none"; 72 | let nextTarget = document.elementFromPoint(event.clientX, event.clientY); 73 | let nextEl = null; 74 | if (nextTarget && nextTarget.classList.contains("draggable")) { 75 | //todo drop dom reads here 76 | let scale = getCurrentScale(nextTarget); 77 | let rotation = getCurrentRotation(nextTarget) * (180 / Math.PI); 78 | let styles = window.getComputedStyle(nextTarget); 79 | let pos = { 80 | x: parseFloat(styles.left), 81 | y: parseFloat(styles.top), 82 | }; 83 | 84 | nextEl = checkImageCoord(nextTarget, pos, scale, rotation, event); 85 | } 86 | img_element.style.pointerEvents = "auto"; 87 | return nextEl; 88 | } else { 89 | //image is opaque at location 90 | return img_element; 91 | } 92 | } 93 | 94 | function angle(b: Vector.Vector2) { 95 | return Math.atan2(b.y, b.x); //radians 96 | } 97 | 98 | function distance(a: Vector.Vector2, b: Vector.Vector2) { 99 | return Vector.length(Vector.subtract(a, b)); 100 | } 101 | 102 | interface IControlledTextArea { 103 | value: string; 104 | onChange: React.ChangeEventHandler | undefined; 105 | [x: string]: any; 106 | } 107 | 108 | const ControlledTextArea = ({ 109 | value, 110 | onChange, 111 | refa, 112 | ...rest 113 | }: IControlledTextArea) => { 114 | const [cursor, setCursor] = useState(0); 115 | // const ref = useRef(null) 116 | 117 | useEffect(() => { 118 | const input: any = refa.current; 119 | if (input) { 120 | input.setSelectionRange(cursor, cursor); 121 | } 122 | }, [refa, cursor, value]); 123 | 124 | const handleChange = (e: React.ChangeEvent) => { 125 | setCursor(e.target.selectionStart); 126 | onChange && onChange(e); 127 | }; 128 | 129 | return ( 130 |