├── .gitignore ├── LICENSE ├── README.md ├── canvas.go ├── cmd.go ├── demo.gif ├── files ├── frontend.js └── index.html ├── frontend_js.go ├── index_html.go ├── limiter.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | pocketplace 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Ola Holmström 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pocketplace 2 | 3 | > :fireworks: Draw pixels on a canvas with friends. 4 | 5 | ![Demo](https://cdn.rawgit.com/olahol/pocketplace/master/demo.gif "Demo") 6 | 7 | * [x] Completely in-memory, no need for a database. 8 | * [x] Statically linked, everything you need in one binary, including the frontend. 9 | 10 | # Install 11 | 12 | ```bash 13 | $ go get github.com/olahol/pocketplace 14 | ``` 15 | 16 | # Example 17 | 18 | ```bash 19 | $ pocketplace -port 8080 -size 200 -cooldown 0 20 | Canvas pixel size 200x200 21 | Drawing cooldown 0s 22 | Listening on port 8080 23 | ``` 24 | 25 | # Development 26 | 27 | To build the frontend into the binary I use [`file2const`](https://github.com/bouk/file2const) 28 | and `go:generate` directives. So if you are modifying the frontend don't forget to: 29 | 30 | ```bash 31 | $ go generate 32 | $ go build 33 | ``` 34 | 35 | to build the binary correctly with the updated frontend. 36 | -------------------------------------------------------------------------------- /canvas.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type Canvas struct { 8 | Size int 9 | Data []byte 10 | RWMutex *sync.RWMutex 11 | } 12 | 13 | func NewCanvas(size int) *Canvas { 14 | bi := &Canvas{ 15 | Size: size, 16 | Data: make([]byte, size*size*3), 17 | RWMutex: &sync.RWMutex{}, 18 | } 19 | 20 | for i, _ := range bi.Data { 21 | bi.Data[i] = 0 22 | } 23 | 24 | return bi 25 | } 26 | 27 | func (c *Canvas) Set(x, y int, r, g, b byte) error { 28 | j := (y*c.Size + x) * 3 29 | c.Data[j] = r 30 | c.Data[j+1] = g 31 | c.Data[j+2] = b 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type Cmd struct { 10 | x, y int 11 | r, g, b byte 12 | } 13 | 14 | func ParseCmd(size int, msg []byte) (Cmd, error) { 15 | str := string(msg) 16 | parts := strings.Split(str, " ") 17 | cmd := Cmd{} 18 | 19 | if len(parts) != 5 { 20 | return cmd, errors.New("should be of the format 'x y r g b'") 21 | } 22 | 23 | args := make([]int, 5) 24 | 25 | for i, p := range parts { 26 | a, err := strconv.Atoi(p) 27 | 28 | if err != nil { 29 | return cmd, err 30 | } 31 | 32 | if a < 0 { 33 | return cmd, errors.New("no arguments should be negative") 34 | } 35 | 36 | if i < 2 && a >= size { 37 | return cmd, errors.New("x, y should not be larger than size of canvas") 38 | } 39 | 40 | if i > 1 && a > 255 { 41 | return cmd, errors.New("r, g, b should not be larger than 255") 42 | } 43 | 44 | args[i] = a 45 | } 46 | 47 | cmd.x = args[0] 48 | cmd.y = args[1] 49 | cmd.r = byte(args[2]) 50 | cmd.g = byte(args[3]) 51 | cmd.b = byte(args[4]) 52 | 53 | return cmd, nil 54 | } 55 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olahol/pocketplace/661c5a9c209c416f0d99b97e74f20307698ef7c6/demo.gif -------------------------------------------------------------------------------- /files/frontend.js: -------------------------------------------------------------------------------- 1 | // Globals 2 | var SCALE = 20; 3 | 4 | var CANVAS = null; 5 | var LOADING = null; 6 | var PICKER = null; 7 | 8 | var CONTEXT = null; 9 | var CONNECTION = null; 10 | 11 | var MY_COLOR = {r: 255, g: 0, b: 0}; 12 | var ON_COOLDOWN = false; 13 | 14 | // Initialize 15 | window.onload = function () { 16 | CANVAS = document.getElementById("canvas"); 17 | LOADING = document.getElementById("loading"); 18 | PICKER = document.getElementById("picker"); 19 | 20 | setupCanvas(); 21 | setupWebsocket(); 22 | setupPicker(); 23 | }; 24 | 25 | function setupCanvas () { 26 | CONTEXT = CANVAS.getContext("2d"); 27 | 28 | CANVAS.width = SIZE; 29 | CANVAS.height = SIZE; 30 | CANVAS.style.transform = "scale(" + SCALE + ")"; 31 | 32 | CANVAS.onclick = function (e) { 33 | var x = Math.floor(e.pageX / SCALE); 34 | var y = Math.floor(e.pageY / SCALE); 35 | if (!ON_COOLDOWN) { 36 | sendPixel(x, y, MY_COLOR.r, MY_COLOR.g, MY_COLOR.b); 37 | toggleCooldown(); 38 | } 39 | }; 40 | 41 | CANVAS.oncontextmenu = function (e) { 42 | e.preventDefault(); 43 | }; 44 | } 45 | 46 | function toggleCooldown () { 47 | if (ON_COOLDOWN) { return; } 48 | if (COOLDOWN <= 0) { return; } 49 | 50 | ON_COOLDOWN = true; 51 | 52 | var el = document.getElementById("cooldown"); 53 | 54 | var cooldownMs = COOLDOWN * 1000; 55 | var cooldownEnd = Date.now() + cooldownMs; 56 | 57 | el.style.display = "block"; 58 | el.textContent = cooldownMs + "ms"; 59 | 60 | var intervalId = setInterval(() => { 61 | el.textContent = (cooldownEnd - Date.now()) + "ms"; 62 | }, 5); 63 | 64 | setTimeout(() => { 65 | ON_COOLDOWN = false; 66 | clearInterval(intervalId); 67 | el.style.display = "none"; 68 | }, cooldownMs); 69 | } 70 | 71 | function setupWebsocket () { 72 | function processCmd (data) { 73 | var parts = data.split(" "); 74 | if (parts.length === 5) { 75 | setPixel(parts[0], parts[1], parts[2], parts[3], parts[4]); 76 | } 77 | } 78 | 79 | function fillCanvas (data) { 80 | var dv = new DataView(data); 81 | var image = CONTEXT.createImageData(SIZE, SIZE); 82 | 83 | var i, j, k; 84 | for (i = 0; i < (dv.byteLength / 3); i += 1) { 85 | j = i*3; 86 | k = i*4; 87 | image.data[k] = dv.getUint8(j); 88 | image.data[k+1] = dv.getUint8(j+1); 89 | image.data[k+2] = dv.getUint8(j+2); 90 | image.data[k+3] = 255; 91 | } 92 | 93 | CONTEXT.putImageData(image, 0, 0); 94 | 95 | LOADING.style.display = "none"; 96 | CANVAS.style.display = "block"; 97 | PICKER.style.display = "block"; 98 | } 99 | 100 | var url = "ws://" + window.location.host + "/ws"; 101 | 102 | CONNECTION = new WebSocket(url); 103 | CONNECTION.binaryType = "arraybuffer"; 104 | 105 | CONNECTION.onmessage = function (e) { 106 | var data = e.data; 107 | 108 | if (typeof data === "string") { 109 | return processCmd(data); 110 | } 111 | 112 | if (data instanceof ArrayBuffer) { 113 | return fillCanvas(data); 114 | } 115 | }; 116 | } 117 | 118 | function setupPicker () { 119 | var input = document.getElementById("picker-input"); 120 | var preview = document.getElementById("picker-preview"); 121 | 122 | preview.style.backgroundColor = "#FF0000"; 123 | input.value = "#FF0000"; 124 | 125 | input.onchange = function (e) { 126 | var value = e.target.value; 127 | var color = hexToRgb(value); 128 | 129 | if (color) { 130 | MY_COLOR = color; 131 | preview.style.backgroundColor = value; 132 | } 133 | }; 134 | } 135 | 136 | // Canvas methods. 137 | function sendPixel (x, y, r, g, b) { 138 | CONNECTION.send([x, y, r, g, b].join(" ")); 139 | } 140 | 141 | function setPixel (x, y, r, g, b) { 142 | if (!this.id) { 143 | this.id = CONTEXT.createImageData(1, 1); 144 | this.idd = this.id.data; 145 | this.idd[3] = 255; 146 | } 147 | 148 | this.idd[0] = r; 149 | this.idd[1] = g; 150 | this.idd[2] = b; 151 | 152 | CONTEXT.putImageData(this.id, x, y); 153 | } 154 | 155 | // Util 156 | function hexToRgb (hex) { 157 | var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; 158 | 159 | hex = hex.replace(shorthandRegex, function(m, r, g, b) { 160 | return r + r + g + g + b + b; 161 | }); 162 | 163 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 164 | 165 | return result ? { 166 | r: parseInt(result[1], 16), 167 | g: parseInt(result[2], 16), 168 | b: parseInt(result[3], 16) 169 | } : null; 170 | } 171 | -------------------------------------------------------------------------------- /files/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | pocketplace 4 | 55 | 56 | 57 | 58 | 59 |
Loading ...
60 | 61 |
62 | 63 |
64 |
65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /frontend_js.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | frontendJs = "// Globals\nvar SCALE = 20;\n\nvar CANVAS = null;\nvar LOADING = null;\nvar PICKER = null;\n\nvar CONTEXT = null;\nvar CONNECTION = null;\n\nvar MY_COLOR = {r: 255, g: 0, b: 0};\nvar ON_COOLDOWN = false;\n\n// Initialize\nwindow.onload = function () {\n CANVAS = document.getElementById(\"canvas\");\n LOADING = document.getElementById(\"loading\");\n PICKER = document.getElementById(\"picker\");\n\n setupCanvas();\n setupWebsocket();\n setupPicker();\n};\n\nfunction setupCanvas () {\n CONTEXT = CANVAS.getContext(\"2d\");\n\n CANVAS.width = SIZE;\n CANVAS.height = SIZE;\n CANVAS.style.transform = \"scale(\" + SCALE + \")\";\n\n CANVAS.onclick = function (e) {\n var x = Math.floor(e.pageX / SCALE);\n var y = Math.floor(e.pageY / SCALE);\n if (!ON_COOLDOWN) {\n sendPixel(x, y, MY_COLOR.r, MY_COLOR.g, MY_COLOR.b);\n toggleCooldown();\n }\n };\n\n CANVAS.oncontextmenu = function (e) {\n e.preventDefault();\n };\n}\n\nfunction toggleCooldown () {\n if (ON_COOLDOWN) { return; }\n if (COOLDOWN <= 0) { return; }\n\n ON_COOLDOWN = true;\n\n var el = document.getElementById(\"cooldown\");\n\n var cooldownMs = COOLDOWN * 1000;\n var cooldownEnd = Date.now() + cooldownMs;\n\n el.style.display = \"block\";\n el.textContent = cooldownMs + \"ms\";\n\n var intervalId = setInterval(() => {\n el.textContent = (cooldownEnd - Date.now()) + \"ms\";\n }, 5);\n\n setTimeout(() => {\n ON_COOLDOWN = false;\n clearInterval(intervalId);\n el.style.display = \"none\";\n }, cooldownMs);\n}\n\nfunction setupWebsocket () {\n function processCmd (data) {\n var parts = data.split(\" \");\n if (parts.length === 5) {\n setPixel(parts[0], parts[1], parts[2], parts[3], parts[4]);\n }\n }\n\n function fillCanvas (data) {\n var dv = new DataView(data);\n var image = CONTEXT.createImageData(SIZE, SIZE);\n\n var i, j, k;\n for (i = 0; i < (dv.byteLength / 3); i += 1) {\n j = i*3;\n k = i*4;\n image.data[k] = dv.getUint8(j);\n image.data[k+1] = dv.getUint8(j+1);\n image.data[k+2] = dv.getUint8(j+2);\n image.data[k+3] = 255;\n }\n\n CONTEXT.putImageData(image, 0, 0);\n\n LOADING.style.display = \"none\";\n CANVAS.style.display = \"block\";\n PICKER.style.display = \"block\";\n }\n\n var url = \"ws://\" + window.location.host + \"/ws\";\n\n CONNECTION = new WebSocket(url);\n CONNECTION.binaryType = \"arraybuffer\";\n\n CONNECTION.onmessage = function (e) {\n var data = e.data;\n\n if (typeof data === \"string\") {\n return processCmd(data);\n }\n\n if (data instanceof ArrayBuffer) {\n return fillCanvas(data);\n }\n };\n}\n\nfunction setupPicker () {\n var input = document.getElementById(\"picker-input\");\n var preview = document.getElementById(\"picker-preview\");\n\n preview.style.backgroundColor = \"#FF0000\";\n input.value = \"#FF0000\";\n\n input.onchange = function (e) {\n var value = e.target.value;\n var color = hexToRgb(value);\n\n if (color) {\n MY_COLOR = color;\n preview.style.backgroundColor = value;\n }\n };\n}\n\n// Canvas methods.\nfunction sendPixel (x, y, r, g, b) {\n CONNECTION.send([x, y, r, g, b].join(\" \"));\n}\n\nfunction setPixel (x, y, r, g, b) {\n if (!this.id) {\n this.id = CONTEXT.createImageData(1, 1);\n this.idd = this.id.data;\n this.idd[3] = 255;\n }\n\n this.idd[0] = r;\n this.idd[1] = g;\n this.idd[2] = b;\n\n CONTEXT.putImageData(this.id, x, y);\n}\n\n// Util\nfunction hexToRgb (hex) {\n\tvar shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n\n\thex = hex.replace(shorthandRegex, function(m, r, g, b) {\n\t\treturn r + r + g + g + b + b;\n\t});\n\n\tvar result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n\n\treturn result ? {\n\t\tr: parseInt(result[1], 16),\n\t\tg: parseInt(result[2], 16),\n\t\tb: parseInt(result[3], 16)\n\t} : null;\n}\n" 5 | ) 6 | -------------------------------------------------------------------------------- /index_html.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | indexHtml = "\n \n pocketplace\n \n \n \n \n \n
Loading ...
\n \n
\n \n
\n
\n
\n \n\n" 5 | ) 6 | -------------------------------------------------------------------------------- /limiter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Limiter struct { 8 | Ids map[string]int64 9 | Cooldown int64 10 | } 11 | 12 | func NewLimiter(cooldown int) *Limiter { 13 | return &Limiter{ 14 | Ids: make(map[string]int64), 15 | Cooldown: int64(cooldown) * int64(time.Second), 16 | } 17 | } 18 | 19 | func (l *Limiter) Check(id string) bool { 20 | now := time.Now().UnixNano() 21 | updated, ok := l.Ids[id] 22 | return !ok || (now-updated) > l.Cooldown 23 | } 24 | 25 | func (l *Limiter) Add(id string) { 26 | l.Ids[id] = time.Now().UnixNano() 27 | } 28 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | "github.com/olahol/melody" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | //go:generate file2const -package main files/index.html:indexHtml index_html.go 13 | //go:generate file2const -package main files/frontend.js:frontendJs frontend_js.go 14 | 15 | var port = flag.Int("port", 5000, "port to listen on") 16 | var size = flag.Int("size", 1000, "canvas pixel size") 17 | var cooldown = flag.Int("cooldown", 2, "drawing cooldown in seconds") 18 | 19 | func RequestToId(req *http.Request) string { 20 | proxy := req.Header.Get("X-Forwarded-For") 21 | 22 | if proxy != "" { 23 | return proxy 24 | } 25 | 26 | addr := req.RemoteAddr 27 | parts := strings.Split(addr, ":") 28 | return parts[0] 29 | } 30 | 31 | func Authorize(req *http.Request) bool { 32 | return true 33 | } 34 | 35 | func main() { 36 | gin.SetMode(gin.ReleaseMode) 37 | flag.Parse() 38 | 39 | r := gin.New() 40 | m := melody.New() 41 | l := NewLimiter(*cooldown) 42 | c := NewCanvas(*size) 43 | 44 | r.GET("/", func(c *gin.Context) { 45 | c.Writer.Header().Set("Content-Type", "text/html") 46 | c.String(200, indexHtml) 47 | }) 48 | 49 | r.GET("/frontend.js", func(c *gin.Context) { 50 | c.Writer.Header().Set("Content-Type", "application/js") 51 | c.String(200, frontendJs) 52 | }) 53 | 54 | optionsJs := fmt.Sprintf("var SIZE = %d;\n var COOLDOWN = %d;", *size, *cooldown) 55 | r.GET("/options.js", func(c *gin.Context) { 56 | c.Writer.Header().Set("Content-Type", "application/js") 57 | c.String(200, optionsJs) 58 | }) 59 | 60 | r.GET("/ws", func(c *gin.Context) { 61 | m.HandleRequest(c.Writer, c.Request) 62 | }) 63 | 64 | m.HandleConnect(func(s *melody.Session) { 65 | if !Authorize(s.Request) { 66 | return 67 | } 68 | 69 | s.Set("id", RequestToId(s.Request)) 70 | 71 | c.RWMutex.RLock() 72 | s.WriteBinary(c.Data) 73 | c.RWMutex.RUnlock() 74 | }) 75 | 76 | m.HandleMessage(func(s *melody.Session, msg []byte) { 77 | if !Authorize(s.Request) { 78 | return 79 | } 80 | 81 | cmd, err := ParseCmd(*size, msg) 82 | 83 | if err != nil { 84 | fmt.Println(err) 85 | return 86 | } 87 | 88 | id := s.MustGet("id").(string) 89 | 90 | if !l.Check(id) { 91 | return 92 | } 93 | 94 | l.Add(id) 95 | 96 | c.RWMutex.Lock() 97 | c.Set(cmd.x, cmd.y, cmd.r, cmd.g, cmd.b) 98 | c.RWMutex.Unlock() 99 | 100 | m.Broadcast(msg) 101 | }) 102 | 103 | fmt.Printf("Canvas pixel size %dx%d\n", *size, *size) 104 | fmt.Printf("Drawing cooldown %ds\n", *cooldown) 105 | fmt.Printf("Listening on port %d\n", *port) 106 | r.Run(fmt.Sprintf(":%d", *port)) 107 | } 108 | --------------------------------------------------------------------------------