commit de640894533e3f1ee227879ef6432e240ccf6cda Author: Ian Gulliver Date: Sun May 3 22:51:16 2020 +0000 Initial commit diff --git a/main.go b/main.go new file mode 100644 index 0000000..6d27c99 --- /dev/null +++ b/main.go @@ -0,0 +1,642 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "log" + "math/rand" + "net/http" + "path" + "sync" + "time" + + _ "net/http/pprof" + + "github.com/google/uuid" +) + +type activeRequest struct { + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` + PublicClientId string `json:"public_client_id"` + Active bool `json:"active"` +} + +type adminRequest struct { + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` + PublicClientId string `json:"public_client_id"` +} + +type announceRequest struct { + RoomId string `json:"room_id"` + ClientId string `json:"client_id"` + AdminSecret string `json:"admin_secret"` + Name string `json:"name"` +} + +type controlRequest struct { + RoomId string `json:"room_id"` + ClientId string `json:"client_id"` + Control string `json:"control"` +} + +type createResponse struct { + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` +} + +type removeRequest struct { + RoomId string `json:"room_id"` + ClientId string `json:"client_id"` +} + +type client struct { + PublicClientId string `json:"public_client_id"` + Name string `json:"name"` + Admin bool `json:"admin"` + Active bool `json:"active"` + + clientId string + room *room + lastSeen time.Time + eventChan chan *event +} + +type event struct { + AdminEvent *adminEvent `json:"admin_event"` + StandardEvent *standardEvent `json:"standard_event"` +} + +type adminEvent struct { + Client *client `json:"client"` + Remove bool `json:"remove"` +} + +type standardEvent struct { + Active bool `json:"active"` + AdminSecret string `json:"admin_secret"` +} + +type controlEvent struct { + Control string `json:"control"` +} + +type room struct { + roomId string + clientById map[string]*client + clientByPublicId map[string]*client + present map[*presentState]bool +} + +type watchState struct { + responseWriter http.ResponseWriter + request *http.Request + flusher http.Flusher + room *room + client *client + admin bool + eventChan chan *event +} + +type presentState struct { + responseWriter http.ResponseWriter + request *http.Request + flusher http.Flusher + room *room + controlChan chan *controlEvent +} + +var key []byte +var roomById = map[string]*room{} +var mu = sync.Mutex{} + +func main() { + rand.Seed(time.Now().UnixNano()) + + keyFlag := flag.String("key", "", "secret key") + + flag.Parse() + + if *keyFlag == "" { + log.Fatalf("please specify --key (suggestion: %x)", rand.Uint64()) + } + key = []byte(*keyFlag) + + go scanLoop() + + registerFile("/", "index.html") + registerFile("/remote.js", "remote.js") + registerFile("/remote.css", "remote.css") + + http.HandleFunc("/api/active", active) + http.HandleFunc("/api/admin", admin) + http.HandleFunc("/api/announce", announce) + http.HandleFunc("/api/control", control) + http.HandleFunc("/api/create", create) + http.HandleFunc("/api/present", present) + http.HandleFunc("/api/remove", remove) + http.HandleFunc("/api/watch", watch) + + server := http.Server{ + Addr: ":2000", + } + err := server.ListenAndServe() + if err != nil { + log.Fatalf("ListenAndServe(): %s", err) + } +} + +func registerFile(urlPath, filename string) { + http.HandleFunc(urlPath, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == urlPath { + serveStatic(w, r, path.Join("static", filename)) + } else { + w.WriteHeader(404) + } + }) +} + +func serveStatic(resp http.ResponseWriter, req *http.Request, path string) { + resp.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeFile(resp, req, path) +} + +func scanLoop() { + ticker := time.NewTicker(5 * time.Second) + for { + <-ticker.C + scan() + } +} + +func scan() { + mu.Lock() + defer mu.Unlock() + + cutoff := time.Now().UTC().Add(-30 * time.Second) + + for _, rm := range roomById { + for _, c := range rm.clientById { + if c.lastSeen.Before(cutoff) { + c.remove() + } + } + } +} + +func active(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &activeRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + + if req.AdminSecret != rm.adminSecret() { + http.Error(w, "invalid admin_secret", http.StatusBadRequest) + return + } + + c := rm.clientByPublicId[req.PublicClientId] + if c == nil { + http.Error(w, "invalid public_client_id", http.StatusBadRequest) + return + } + + c.Active = req.Active + c.update() + rm.sendAdminEvent(&adminEvent{ + Client: c, + }) +} + +func admin(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &adminRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + + if req.AdminSecret != rm.adminSecret() { + http.Error(w, "invalid admin_secret", http.StatusBadRequest) + return + } + + c := rm.clientByPublicId[req.PublicClientId] + if c == nil { + http.Error(w, "invalid public_client_id", http.StatusBadRequest) + return + } + + c.Admin = true + c.update() + rm.sendAdminEvent(&adminEvent{ + Client: c, + }) +} + +func announce(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &announceRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + + admin := false + if req.AdminSecret != "" { + if req.AdminSecret == rm.adminSecret() { + admin = true + } else { + http.Error(w, "invalid admin_secret", http.StatusBadRequest) + return + } + } + + c := rm.getClient(req.ClientId) + + changed := false + if c.Name != req.Name { + c.Name = req.Name + changed = true + } + + if c.Admin != admin { + c.Admin = admin + changed = true + } + + if changed { + rm.sendAdminEvent(&adminEvent{ + Client: c, + }) + } +} + +func control(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &controlRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + c := rm.getClient(req.ClientId) + + if !c.Active { + http.Error(w, "client is not active", http.StatusBadRequest) + } + + rm.sendControlEvent(&controlEvent{ + Control: req.Control, + }) +} + +func create(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + + resp := &createResponse{ + RoomId: uuid.New().String(), + } + + rm := newRoom(resp.RoomId) + resp.AdminSecret = rm.adminSecret() + + enc := json.NewEncoder(w) + err := enc.Encode(resp) + if err != nil { + log.Fatal(err) + } +} + +func present(w http.ResponseWriter, r *http.Request) { + ps := newPresentState(w, r) + if ps == nil { + return + } + + closeChan := w.(http.CloseNotifier).CloseNotify() + ticker := time.NewTicker(15 * time.Second) + + for { + select { + case <-closeChan: + ps.close() + return + + case <-ticker.C: + mu.Lock() + ps.sendHeartbeat() + mu.Unlock() + + case ctrl := <-ps.controlChan: + mu.Lock() + ps.sendEvent(ctrl) + mu.Unlock() + } + } +} + +func remove(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &removeRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + c := rm.getClient(req.ClientId) + c.remove() +} + +func watch(w http.ResponseWriter, r *http.Request) { + ws := newWatchState(w, r) + if ws == nil { + return + } + + closeChan := w.(http.CloseNotifier).CloseNotify() + ticker := time.NewTicker(15 * time.Second) + + ws.sendInitial() + + for { + select { + case <-closeChan: + ws.close() + return + + case <-ticker.C: + mu.Lock() + ws.sendHeartbeat() + mu.Unlock() + + case event := <-ws.eventChan: + mu.Lock() + ws.sendEvent(event) + mu.Unlock() + } + } +} + +func (c *client) sendEvent(e *event) { + if c.eventChan != nil { + c.eventChan <- e + } +} + +func (c *client) remove() { + delete(c.room.clientById, c.clientId) + delete(c.room.clientByPublicId, c.PublicClientId) + + c.room.sendAdminEvent(&adminEvent{ + Client: c, + Remove: true, + }) +} + +func (c *client) update() { + e := &event{ + StandardEvent: &standardEvent{ + Active: c.Active, + }, + } + if c.Admin { + e.StandardEvent.AdminSecret = c.room.adminSecret() + } + c.sendEvent(e) +} + +func newRoom(roomId string) *room { + return &room{ + roomId: roomId, + clientById: map[string]*client{}, + clientByPublicId: map[string]*client{}, + present: map[*presentState]bool{}, + } +} + +func getRoom(roomId string) *room { + r := roomById[roomId] + if r == nil { + r = newRoom(roomId) + roomById[roomId] = r + } + return r +} + +func (rm *room) adminSecret() string { + h := hmac.New(sha256.New, key) + return base64.StdEncoding.EncodeToString(h.Sum([]byte(rm.roomId))) +} + +func (rm *room) getClient(clientId string) *client { + c := rm.clientById[clientId] + if c == nil { + c = &client{ + clientId: clientId, + PublicClientId: uuid.New().String(), + room: rm, + } + rm.clientById[clientId] = c + rm.clientByPublicId[c.PublicClientId] = c + + rm.sendAdminEvent(&adminEvent{ + Client: c, + }) + } + + c.lastSeen = time.Now().UTC() + + return c +} + +func (rm *room) sendAdminEvent(ae *adminEvent) { + for _, client := range rm.clientById { + if !client.Admin { + continue + } + client.sendEvent(&event{ + AdminEvent: ae, + }) + } +} + +func (rm *room) sendControlEvent(ce *controlEvent) { + for present, _ := range rm.present { + present.sendEvent(ce) + } +} + +func newWatchState(w http.ResponseWriter, r *http.Request) *watchState { + mu.Lock() + defer mu.Unlock() + + ws := &watchState{ + responseWriter: w, + request: r, + eventChan: make(chan *event, 100), + } + + var ok bool + ws.flusher, ok = w.(http.Flusher) + if !ok { + http.Error(ws.responseWriter, "streaming unsupported", http.StatusBadRequest) + return nil + } + + roomId := r.URL.Query().Get("room_id") + ws.room = getRoom(roomId) + + clientId := r.URL.Query().Get("client_id") + ws.client = ws.room.getClient(clientId) + + adminSecret := r.URL.Query().Get("admin_secret") + if adminSecret != "" { + if adminSecret == ws.room.adminSecret() { + ws.admin = true + } else { + http.Error(w, "invalid admin_secret", http.StatusBadRequest) + return nil + } + } + + ws.client.eventChan = ws.eventChan + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + return ws +} + +func (ws *watchState) sendInitial() { + mu.Lock() + defer mu.Unlock() + + if !ws.admin { + return + } + + for _, client := range ws.room.clientById { + ws.sendEvent(&event{ + AdminEvent: &adminEvent{ + Client: client, + }, + }) + } + + ws.flusher.Flush() +} + +func (ws *watchState) sendHeartbeat() { + fmt.Fprintf(ws.responseWriter, ":\n\n") + ws.flusher.Flush() +} + +func (ws *watchState) sendEvent(e *event) { + j, err := json.Marshal(e) + if err != nil { + log.Fatal(err) + } + fmt.Fprintf(ws.responseWriter, "data: %s\n\n", j) + ws.flusher.Flush() +} + +func (ws *watchState) close() { + mu.Lock() + defer mu.Unlock() + + if ws.client.eventChan == ws.eventChan { + ws.client.eventChan = nil + close(ws.eventChan) + } +} + +func newPresentState(w http.ResponseWriter, r *http.Request) *presentState { + mu.Lock() + defer mu.Unlock() + + ps := &presentState{ + responseWriter: w, + request: r, + controlChan: make(chan *controlEvent, 100), + } + + var ok bool + ps.flusher, ok = w.(http.Flusher) + if !ok { + http.Error(ps.responseWriter, "streaming unsupported", http.StatusBadRequest) + return nil + } + + roomId := r.URL.Query().Get("room_id") + ps.room = getRoom(roomId) + + ps.room.present[ps] = true + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + return ps +} + +func (ps *presentState) sendHeartbeat() { + fmt.Fprintf(ps.responseWriter, ":\n\n") + ps.flusher.Flush() +} + +func (ps *presentState) sendEvent(e *controlEvent) { + j, err := json.Marshal(e) + if err != nil { + log.Fatal(err) + } + fmt.Fprintf(ps.responseWriter, "data: %s\n\n", j) + ps.flusher.Flush() +} + +func (ps *presentState) close() { + mu.Lock() + defer mu.Unlock() + + delete(ps.room.present, ps) + close(ps.controlChan) +} diff --git a/present/present.py b/present/present.py new file mode 100755 index 0000000..855345c --- /dev/null +++ b/present/present.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import json +import pyautogui +import requests +import sseclient +import sys +import time +import urllib + +ALLOWED_CONTROLS = {'left', 'right'} + +if len(sys.argv) != 2: + print(f'usage: {sys.argv[0]} ') + sys.exit(1) + +url = urllib.parse.urlparse(sys.argv[1]) +qs = urllib.parse.parse_qs(url.query) + +if 'room' not in qs or len(qs['room']) != 1: + print(f'invald url: {sys.argv[1]}') + +room = qs['room'][0] + +presentUrl = urllib.parse.urlunparse([ + url.scheme, + url.netloc, + '/api/present', + url.params, + urllib.parse.urlencode({'room_id': room}), + url.fragment, +]) + +while True: + try: + response = requests.get(presentUrl, stream=True) + client = sseclient.SSEClient(response) + for event in client.events(): + parsed = json.loads(event.data) + control = parsed['control'] + if control not in ALLOWED_CONTROLS: + print(f'INVALID CONTROL: {control}') + continue + print(control) + pyautogui.press(control) + except Exception as e: + print(e) + time.sleep(2) diff --git a/static/.stylelintrc.json b/static/.stylelintrc.json new file mode 100644 index 0000000..0dbf674 --- /dev/null +++ b/static/.stylelintrc.json @@ -0,0 +1,14 @@ +{ + "extends": "stylelint-config-standard", + "plugins": [ + "stylelint-order" + ], + "rules": { + "order/order": [ + "custom-properties", + "declarations" + ], + "order/properties-alphabetical-order": true + } +} + diff --git a/static/Makefile b/static/Makefile new file mode 100644 index 0000000..79e25f1 --- /dev/null +++ b/static/Makefile @@ -0,0 +1,16 @@ +all: check remote.js + +remote.js: *.ts *.json + tsc + +check: *.ts *.json *.css + tslint --project . --fix + stylelint --fix *.css + +clean: + rm -f remote.js + +ci: + tslint --project . + stylelint *.css + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3081cd0 --- /dev/null +++ b/static/index.html @@ -0,0 +1,8 @@ + + + + Slide Together + + + + diff --git a/static/remote.css b/static/remote.css new file mode 100644 index 0000000..22f2fcc --- /dev/null +++ b/static/remote.css @@ -0,0 +1,79 @@ +:root { + --background-color: #222831; + --subtle-color: #2d4059; + --normal-color: #eee; + --highlight-color: #d65a31; +} + +body { + background-color: var(--background-color); + color: var(--normal-color); + font-family: Courier, monospace; + font-size: 16px; + line-height: 1.4; + margin: 20px; +} + +input { + background-color: var(--background-color); + border: 1px dotted var(--highlight-color); + color: var(--normal-color); + font-family: Courier, monospace; + font-size: 16px; + margin: 5px; + padding: 5px; +} + +table { + border: 3px solid var(--highlight-color); + border-collapse: collapse; + margin-top: 30px; +} + +th, +td { + border: 1px solid var(--subtle-color); + padding: 7px; +} + +thead, +tfoot tr { + border: 3px solid var(--highlight-color); +} + +.admin, +.active { + cursor: pointer; + opacity: 0.3; + user-select: none; +} + +.enable { + opacity: 1 !important; +} + +.admin.enable { + cursor: default; +} + +.controls { + opacity: 0.3; +} + +.control-button { + border: 3px solid var(--highlight-color); + display: inline-block; + margin: 20px; + padding: 10px; + transition: all 0.8s; + user-select: none; +} + +.control-button:active { + background: var(--subtle-color); + transition: none; +} + +.controls.enable .control-button { + cursor: pointer; +} diff --git a/static/remote.js b/static/remote.js new file mode 100644 index 0000000..bd128af --- /dev/null +++ b/static/remote.js @@ -0,0 +1,230 @@ +"use strict"; +function main() { + const url = new URL(location.href); + if (url.searchParams.has("room")) { + renderRoom(url.searchParams.get("room")); + } + else { + newRoom(); + } +} +function renderRoom(roomId) { + const clientId = uuid(); + const adminSecret = localStorage.getItem(`admin_secret:${roomId}`); + const prnt = document.body; + const nameLabel = create(prnt, "label", "Name: "); + const name = create(nameLabel, "input"); + name.type = "text"; + name.size = 30; + name.value = localStorage.getItem("name") || ""; + name.addEventListener("change", () => { + localStorage.setItem("name", name.value); + }); + addEventListener("unload", () => remove(roomId, clientId)); + announce(roomId, clientId, adminSecret, name); + watch(roomId, clientId, adminSecret, prnt); +} +function newRoom() { + fetch("/api/create", { method: "POST" }) + .then(resp => resp.json()) + .then(data => { + const resp = data; + localStorage.setItem(`admin_secret:${resp.room_id}`, resp.admin_secret); + const url = new URL(location.href); + url.searchParams.set("room", resp.room_id); + location.href = url.toString(); + }); +} +function announce(roomId, clientId, adminSecret, name) { + const req = { + room_id: roomId, + client_id: clientId, + admin_secret: adminSecret, + name: name.value, + }; + fetch("/api/announce", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }) + .then(() => { + setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); + }); +} +function watch(roomId, clientId, adminSecret, prnt) { + const url = new URL("/api/watch", location.href); + url.searchParams.set("room_id", roomId); + url.searchParams.set("client_id", clientId); + if (adminSecret) { + url.searchParams.set("admin_secret", adminSecret); + } + const es = new EventSource(url.toString()); + renderControls(roomId, clientId, adminSecret, prnt, es); + if (adminSecret) { + renderAdmin(roomId, adminSecret, prnt, es); + } +} +function renderControls(roomId, clientId, adminSecret, prnt, es) { + const controls = create(prnt, "div", undefined, ["controls"]); + const left = create(controls, "span", "<<<", ["control-button"]); + left.addEventListener("click", () => control(roomId, clientId, controls, "left")); + const right = create(controls, "span", ">>>", ["control-button"]); + right.addEventListener("click", () => control(roomId, clientId, controls, "right")); + document.addEventListener("keydown", (e) => { + switch (e.key) { + case "ArrowLeft": + control(roomId, clientId, controls, "left"); + break; + case " ": + case "ArrowRight": + control(roomId, clientId, controls, "right"); + break; + } + }); + es.addEventListener("message", (e) => { + const event = JSON.parse(e.data); + if (!event.standard_event) { + return; + } + if (event.standard_event.admin_secret && !adminSecret) { + localStorage.setItem(`admin_secret:${roomId}`, event.standard_event.admin_secret); + location.reload(); + } + if (event.standard_event.active) { + controls.classList.add("enable"); + } + else { + controls.classList.remove("enable"); + } + }); +} +function renderAdmin(roomId, adminSecret, prnt, es) { + const table = create(prnt, "table", undefined, ["users"]); + const head = create(table, "thead"); + const head1 = create(head, "tr"); + create(head1, "th", "Name"); + create(head1, "th", "👑"); + create(head1, "th", "👆"); + const body = create(table, "tbody"); + const rows = new Map(); + es.addEventListener("message", (e) => { + const event = JSON.parse(e.data); + if (!event.admin_event) { + return; + } + const client = event.admin_event.client; + let row = rows.get(client.public_client_id); + if (row) { + row.remove(); + rows.delete(client.public_client_id); + } + if (event.admin_event.remove) { + return; + } + row = document.createElement("tr"); + row.dataset.name = client.name; + let before = null; + for (const iter of body.children) { + const iterRow = iter; + if (iterRow.dataset.name.localeCompare(row.dataset.name) > 0) { + before = iter; + break; + } + } + body.insertBefore(row, before); + create(row, "td", client.name); + const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]); + adminCell.addEventListener("click", () => { + if (!client.admin) { + if (!confirm(`Grant admin access to ${client.name}?`)) { + return; + } + admin(roomId, adminSecret, client.public_client_id); + } + }); + const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]); + activeCell.addEventListener("click", () => { + active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable")); + }); + rows.set(client.public_client_id, row); + }); +} +function active(roomId, adminSecret, publicClientId, val) { + const req = { + room_id: roomId, + admin_secret: adminSecret, + public_client_id: publicClientId, + active: val, + }; + fetch("/api/active", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); +} +function admin(roomId, adminSecret, publicClientId) { + const req = { + room_id: roomId, + admin_secret: adminSecret, + public_client_id: publicClientId, + }; + fetch("/api/admin", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); +} +function control(roomId, clientId, controls, ctrl) { + if (!controls.classList.contains("enable")) { + return; + } + const req = { + room_id: roomId, + client_id: clientId, + control: ctrl, + }; + fetch("/api/control", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); +} +function create(prnt, tag, text, classes) { + const elem = document.createElement(tag); + prnt.appendChild(elem); + if (text) { + elem.innerText = text; + } + for (const cls of classes !== null && classes !== void 0 ? classes : []) { + elem.classList.add(cls); + } + return elem; +} +function remove(roomId, clientId) { + const req = { + room_id: roomId, + client_id: clientId, + }; + navigator.sendBeacon("/api/remove", JSON.stringify(req)); +} +function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} +if (document.readyState === "loading") { + addEventListener("DOMContentLoaded", () => main()); +} +else { + main(); +} diff --git a/static/remote.ts b/static/remote.ts new file mode 100644 index 0000000..87c35a9 --- /dev/null +++ b/static/remote.ts @@ -0,0 +1,336 @@ +interface ActiveRequest { + room_id: string; + admin_secret: string; + public_client_id: string; + active: boolean; +} + +interface AdminRequest { + room_id: string; + admin_secret: string; + public_client_id: string; +} + +interface AnnounceRequest { + room_id: string; + client_id: string; + admin_secret: string | null; + name: string; +} + +interface ControlRequest { + room_id: string; + client_id: string; + control: string; +} + +interface CreateResponse { + room_id: string; + admin_secret: string; +} + +interface RemoveRequest { + room_id: string; + client_id: string; +} + +interface Event { + standard_event?: StandardEvent; + admin_event?: AdminEvent; +} + +interface StandardEvent { + active?: boolean; + admin_secret?: string; +} + +interface AdminEvent { + client: Client; + remove: boolean; +} + +interface Client { + public_client_id: string; + name: string; + admin: boolean; + active: boolean; +} + +function main() { + const url = new URL(location.href); + + if (url.searchParams.has("room")) { + renderRoom(url.searchParams.get("room")!); + } else { + newRoom(); + } +} + +function renderRoom(roomId: string) { + const clientId = uuid(); + const adminSecret = localStorage.getItem(`admin_secret:${roomId}`); + + const prnt = document.body; + + const nameLabel = create(prnt, "label", "Name: ") as HTMLLabelElement; + const name = create(nameLabel, "input") as HTMLInputElement; + name.type = "text"; + name.size = 30; + name.value = localStorage.getItem("name") || ""; + name.addEventListener("change", () => { + localStorage.setItem("name", name.value); + }); + + addEventListener("unload", () => remove(roomId, clientId!)); + + announce(roomId, clientId!, adminSecret, name); + + watch(roomId, clientId!, adminSecret, prnt); +} + +function newRoom() { + fetch("/api/create", {method: "POST"}) + .then(resp => resp.json()) + .then(data => { + const resp = data as CreateResponse; + + localStorage.setItem(`admin_secret:${resp.room_id}`, resp.admin_secret); + + const url = new URL(location.href); + url.searchParams.set("room", resp.room_id); + location.href = url.toString(); + }); +} + +function announce(roomId: string, clientId: string, adminSecret: string | null, name: HTMLInputElement) { + const req: AnnounceRequest = { + room_id: roomId, + client_id: clientId, + admin_secret: adminSecret, + name: name.value, + }; + + fetch("/api/announce", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }) + .then(() => { + setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); + }); +} + +function watch(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement) { + const url = new URL("/api/watch", location.href); + url.searchParams.set("room_id", roomId); + url.searchParams.set("client_id", clientId); + if (adminSecret) { + url.searchParams.set("admin_secret", adminSecret); + } + const es = new EventSource(url.toString()); + + renderControls(roomId, clientId, adminSecret, prnt, es); + + if (adminSecret) { + renderAdmin(roomId, adminSecret, prnt, es); + } +} + +function renderControls(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) { + const controls = create(prnt, "div", undefined, ["controls"]) as HTMLDivElement; + + const left = create(controls, "span", "<<<", ["control-button"]) as HTMLDivElement; + left.addEventListener("click", () => control(roomId, clientId, controls, "left")); + + const right = create(controls, "span", ">>>", ["control-button"]) as HTMLDivElement; + right.addEventListener("click", () => control(roomId, clientId, controls, "right")); + + document.addEventListener("keydown", (e) => { + switch (e.key) { + case "ArrowLeft": + control(roomId, clientId, controls, "left"); + break; + + case " ": + case "ArrowRight": + control(roomId, clientId, controls, "right"); + break; + } + }); + + es.addEventListener("message", (e) => { + const event = JSON.parse(e.data) as Event; + + if (!event.standard_event) { + return; + } + + if (event.standard_event.admin_secret && !adminSecret) { + localStorage.setItem(`admin_secret:${roomId}`, event.standard_event.admin_secret); + location.reload(); + } + + if (event.standard_event.active) { + controls.classList.add("enable"); + } else { + controls.classList.remove("enable"); + } + }); +} + +function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: EventSource) { + const table = create(prnt, "table", undefined, ["users"]) as HTMLTableElement; + const head = create(table, "thead"); + const head1 = create(head, "tr"); + create(head1, "th", "Name"); + create(head1, "th", "👑"); + create(head1, "th", "👆"); + + const body = create(table, "tbody"); + + const rows: Map = new Map(); + + es.addEventListener("message", (e) => { + const event = JSON.parse(e.data) as Event; + + if (!event.admin_event) { + return; + } + + const client = event.admin_event.client; + let row = rows.get(client.public_client_id); + + if (row) { + row.remove(); + rows.delete(client.public_client_id); + } + + if (event.admin_event.remove) { + return; + } + + row = document.createElement("tr") as HTMLTableRowElement; + row.dataset.name = client.name; + + let before = null; + for (const iter of body.children) { + const iterRow = iter as HTMLTableRowElement; + if (iterRow.dataset.name!.localeCompare(row.dataset.name) > 0) { + before = iter; + break; + } + } + body.insertBefore(row, before); + + create(row, "td", client.name); + + const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]) as HTMLTableCellElement; + adminCell.addEventListener("click", () => { + if (!client.admin) { + if (!confirm(`Grant admin access to ${client.name}?`)) { + return; + } + admin(roomId, adminSecret, client.public_client_id); + } + }); + + const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]) as HTMLTableCellElement; + activeCell.addEventListener("click", () => { + active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable")); + }); + + rows.set(client.public_client_id, row); + }); +} + +function active(roomId: string, adminSecret: string, publicClientId: string, val: boolean) { + const req: ActiveRequest = { + room_id: roomId, + admin_secret: adminSecret, + public_client_id: publicClientId, + active: val, + }; + + fetch("/api/active", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }) +} + +function admin(roomId: string, adminSecret: string, publicClientId: string) { + const req: AdminRequest = { + room_id: roomId, + admin_secret: adminSecret, + public_client_id: publicClientId, + }; + + fetch("/api/admin", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }) +} + +function control(roomId: string, clientId: string, controls: HTMLElement, ctrl: string) { + if (!controls.classList.contains("enable")) { + return; + } + + const req: ControlRequest = { + room_id: roomId, + client_id: clientId, + control: ctrl, + }; + + fetch("/api/control", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }) +} + +function create(prnt: HTMLElement, tag: string, text?: string, classes?: string[]): HTMLElement { + const elem = document.createElement(tag); + prnt.appendChild(elem); + + if (text) { + elem.innerText = text; + } + + for (const cls of classes ?? []) { + elem.classList.add(cls); + } + + return elem; +} + +function remove(roomId: string, clientId: string) { + const req: RemoveRequest = { + room_id: roomId, + client_id: clientId, + } + navigator.sendBeacon("/api/remove", JSON.stringify(req)); +} + +function uuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +if (document.readyState === "loading") { + addEventListener("DOMContentLoaded", () => main()); +} else { + main(); +} diff --git a/static/tsconfig.json b/static/tsconfig.json new file mode 100644 index 0000000..c0d6cc3 --- /dev/null +++ b/static/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "outFile": "remote.js", + "strict": true, + "target": "es2017" + }, + "files": [ + "remote.ts" + ] +} + diff --git a/static/tslint.json b/static/tslint.json new file mode 100644 index 0000000..ecde982 --- /dev/null +++ b/static/tslint.json @@ -0,0 +1,10 @@ +{ + "extends": "tslint:recommended", + "rules": { + "interface-name": false, + "max-line-length": false, + "no-bitwise": false, + "no-console": false, + "no-unused-expression": false + } +}