diff --git a/README.md b/README.md index fd216f23..d39cea87 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ Some of these were slightly altered if the license allowed it. Treat each of the files in this repository with the same license terms as the original file. +**It seems iconfinder was renamed and all the URLs aren't correct anymore. If you are the author of any of the icons and want me to attribute you differently, create an issue please.** + * Logo - All rights reserved, excluded from BSD-3 licensing * Background - All rights reserved, excluded from BSD-3 licensing * Favicon - All rights reserved, excluded from BSD-3 licensing @@ -204,4 +206,5 @@ original file. * [Trash Icon](https://www.iconfinder.com/icons/315225/trash_can_icon) - Made by [Yannick Lung](https://yannicklung.com) * [Undo Icon](https://www.iconfinder.com/icons/308948/arrow_undo_icon) - Made by [Ivan Boyko](https://www.iconfinder.com/visualpharm) * [Alarmclock Icon](https://www.iconfinder.com/icons/4280508/alarm_outlined_alert_clock_icon) - Made by [Kit of Parts](https://www.iconfinder.com/kitofparts) -* https://www.iconfinder.com/icons/808399/load_turn_turnaround_icon TODO +* https://www.iconfinder.com/icons/808399/load_turn_turnaround_icon - Made by Pixel Bazaar +*[Gallery Icon](https://www.svgrepo.com/svg/528992/gallery-wide) - Made by [Solar Icons](https://www.svgrepo.com/author/Solar%20Icons/) diff --git a/internal/api/http.go b/internal/api/http.go index 315c12b5..2eff2b18 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -22,6 +22,7 @@ func (handler *V1Handler) SetupRoutes(rootPath string, register func(string, str register("GET", path.Join(v1, "lobby"), handler.getLobbies) register("POST", path.Join(v1, "lobby"), handler.postLobby) + register("GET", path.Join(v1, "lobby", "{lobby_id}", "gallery"), handler.getGallery) register("PATCH", path.Join(v1, "lobby", "{lobby_id}"), handler.patchLobby) // We support both path parameter and cookie. register("PATCH", path.Join(v1, "lobby"), handler.patchLobby) @@ -83,3 +84,29 @@ func GetIPAddressFromRequest(request *http.Request) string { return remoteAddressToSimpleIP(request.RemoteAddr) } + +func SetDiscordCookie( + w http.ResponseWriter, + key, value string, +) { + http.SetCookie(w, &http.Cookie{ + Name: key, + Value: value, + Domain: discordDomain, + Path: "/", + SameSite: http.SameSiteNoneMode, + Partitioned: true, + Secure: true, + }) +} +func SetNormalCookie( + w http.ResponseWriter, + key, value string, +) { + http.SetCookie(w, &http.Cookie{ + Name: key, + Value: value, + Path: "/", + SameSite: http.SameSiteStrictMode, + }) +} diff --git a/internal/api/v1.go b/internal/api/v1.go index 4203a647..c5e48d32 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -19,7 +19,10 @@ import ( "golang.org/x/text/language" ) -var ErrLobbyNotExistent = errors.New("the requested lobby doesn't exist") +var ( + ErrLobbyNotExistent = errors.New("the requested lobby doesn't exist") + ErrSessionNotMatching = errors.New("session doesn't match any player") +) type V1Handler struct { cfg *config.Config @@ -31,16 +34,20 @@ func NewHandler(cfg *config.Config) *V1Handler { } } +func writeJson(writer http.ResponseWriter, bytes []byte) error { + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Content-Length", strconv.Itoa(len(bytes))) + _, err := writer.Write(bytes) + return err +} + func marshalToHTTPWriter(data any, writer http.ResponseWriter) (bool, error) { bytes, err := json.Marshal(data) if err != nil { return false, err } - writer.Header().Set("Content-Type", "application/json") - writer.Header().Set("Content-Length", strconv.Itoa(len(bytes))) - _, err = writer.Write(bytes) - return true, err + return true, writeJson(writer, bytes) } type LobbyEntries []*LobbyEntry @@ -200,6 +207,73 @@ func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Re state.AddLobby(lobby) } +type Gallery []game.GalleryDrawing + +func (handler *V1Handler) getGallery(writer http.ResponseWriter, request *http.Request) { + // Cached lobbies should simply run into an error if they try to update. + userSession, err := GetUserSession(request) + if err != nil { + log.Printf("error getting user session: %v", err) + http.Error(writer, "no valid usersession supplied", http.StatusBadRequest) + return + } + + if userSession == uuid.Nil { + http.Error(writer, "no usersession supplied", http.StatusBadRequest) + return + } + + if err := request.ParseForm(); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + + rawLocalCacheCount := request.FormValue("local_cache_count") + localCacheCount, err := strconv.Atoi(rawLocalCacheCount) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + + lobby := state.GetLobby(GetLobbyId(request)) + if lobby == nil { + http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound) + return + } + + var after func() + lobby.Synchronized(func() { + // FIXME Improve these. + if localCacheCount == len(lobby.Drawings) { + after = func() { + http.Error(writer, "drawings unchanged", http.StatusNoContent) + } + return + } + + if lobby.GetPlayerBySession(userSession) == nil { + after = func() { + http.Error(writer, ErrSessionNotMatching.Error(), http.StatusForbidden) + } + return + } + + encodedJson, err := json.Marshal(Gallery(lobby.Drawings)) + after = func() { + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + + if err := writeJson(writer, encodedJson); err != nil { + log.Println("Error responding to gallery request:", err) + return + } + } + }) + after() +} + func (handler *V1Handler) postPlayer(writer http.ResponseWriter, request *http.Request) { lobby := state.GetLobby(request.PathValue("lobby_id")) if lobby == nil { @@ -263,15 +337,7 @@ const discordDomain = "1320396325925163070.discordsays.com" func SetDiscordCookies(w http.ResponseWriter, request *http.Request) { discordInstanceId := GetDiscordInstanceId(request) if discordInstanceId != "" { - http.SetCookie(w, &http.Cookie{ - Name: "discord-instance-id", - Value: discordInstanceId, - Domain: discordDomain, - Path: "/", - SameSite: http.SameSiteNoneMode, - Partitioned: true, - Secure: true, - }) + SetDiscordCookie(w, "discord-instance-id", discordInstanceId) } } @@ -285,39 +351,14 @@ func SetGameplayCookies( ) { discordInstanceId := GetDiscordInstanceId(request) if discordInstanceId != "" { - http.SetCookie(w, &http.Cookie{ - Name: "usersession", - Value: player.GetUserSession().String(), - Domain: discordDomain, - Path: "/", - SameSite: http.SameSiteNoneMode, - Partitioned: true, - Secure: true, - }) - http.SetCookie(w, &http.Cookie{ - Name: "lobby-id", - Value: lobby.LobbyID, - Domain: discordDomain, - Path: "/", - SameSite: http.SameSiteNoneMode, - Partitioned: true, - Secure: true, - }) + SetDiscordCookie(w, "usersession", player.GetUserSession().String()) + SetDiscordCookie(w, "lobby-id", lobby.LobbyID) } else { + // FIXME This comment seems nonsensical, am i not getting something? // For the discord case, we need both, as the discord specific cookies // aren't available during the readirect from ssrCreate to ssrEnter. - http.SetCookie(w, &http.Cookie{ - Name: "usersession", - Value: player.GetUserSession().String(), - Path: "/", - SameSite: http.SameSiteStrictMode, - }) - http.SetCookie(w, &http.Cookie{ - Name: "lobby-id", - Value: lobby.LobbyID, - Path: "/", - SameSite: http.SameSiteStrictMode, - }) + SetNormalCookie(w, "usersession", player.GetUserSession().String()) + SetNormalCookie(w, "lobby-id", lobby.LobbyID) } } diff --git a/internal/frontend/gallery.go b/internal/frontend/gallery.go new file mode 100644 index 00000000..c45512f9 --- /dev/null +++ b/internal/frontend/gallery.go @@ -0,0 +1,52 @@ +package frontend + +import ( + "log" + "net/http" + "strings" + + "github.com/scribble-rs/scribble.rs/internal/api" + "github.com/scribble-rs/scribble.rs/internal/translations" +) + +type galleryPageData struct { + *BasePageConfig + + LobbyID string + Translation *translations.Translation + Locale string +} + +func (handler *SSRHandler) ssrGallery(writer http.ResponseWriter, request *http.Request) { + userAgent := strings.ToLower(request.UserAgent()) + if !isHumanAgent(userAgent) { + translation, _ := determineTranslation(request) + writer.WriteHeader(http.StatusForbidden) + handler.userFacingError(writer, translation.Get("forbidden"), translation) + return + } + + lobbyId := request.PathValue("lobby_id") + + // FIXME Do we care about discord anymore? + api.SetNormalCookie(writer, "lobby-id", lobbyId) + api.SetNormalCookie(writer, "root-path", handler.basePageConfig.RootPath) + + // Note that this lobby doesn't have to exist necessarily, as the user can still have + // the data cached locally. + + translation, locale := determineTranslation(request) + pageData := &galleryPageData{ + BasePageConfig: handler.basePageConfig, + LobbyID: lobbyId, + Translation: translation, + Locale: locale, + } + + // If the pagedata isn't initialized, it means the synchronized block has exited. + // In this case we don't want to template the lobby, since an error has occurred + // and probably already has been handled. + if err := pageTemplates.ExecuteTemplate(writer, "gallery-page", pageData); err != nil { + log.Printf("Error templating lobby: %s\n", err) + } +} diff --git a/internal/frontend/http.go b/internal/frontend/http.go index 93a997f6..93ba3994 100644 --- a/internal/frontend/http.go +++ b/internal/frontend/http.go @@ -154,6 +154,7 @@ func (handler *SSRHandler) SetupRoutes(register func(string, string, http.Handle registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby.js"), handler.lobbyJs) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "index.js"), handler.indexJs) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}"), handler.ssrEnterLobby) + registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}", "gallery"), handler.ssrGallery) registerWithCsp("POST", path.Join(handler.cfg.RootPath, "lobby"), handler.ssrCreateLobby) } diff --git a/internal/frontend/index.go b/internal/frontend/index.go index a30b71ac..892455b1 100644 --- a/internal/frontend/index.go +++ b/internal/frontend/index.go @@ -73,7 +73,6 @@ func NewHandler(cfg *config.Config) (*SSRHandler, error) { if err != nil { return nil, fmt.Errorf("error parsing lobby js template: %w", err) } - lobbyJsRawTemplate.AddParseTree("footer", pageTemplates.Tree) entries, err := frontendResourcesFS.ReadDir("resources") diff --git a/internal/frontend/lobby.go b/internal/frontend/lobby.go index 43458e78..13ba0c6c 100644 --- a/internal/frontend/lobby.go +++ b/internal/frontend/lobby.go @@ -44,6 +44,7 @@ func (handler *SSRHandler) lobbyJs(writer http.ResponseWriter, request *http.Req if err := handler.lobbyJsRawTemplate.ExecuteTemplate(writer, "lobby-js", pageData); err != nil { log.Printf("error templating JS: %s\n", err) } + } // ssrEnterLobby opens a lobby, either opening it directly or asking for a lobby. diff --git a/internal/frontend/lobby.js b/internal/frontend/lobby.js index 1b5ab7eb..c78e74cc 100644 --- a/internal/frontend/lobby.js +++ b/internal/frontend/lobby.js @@ -3,6 +3,7 @@ String.prototype.format = function() { }; const discordInstanceId = getCookie("discord-instance-id") +const lobbyId = getCookie("lobby-id"); const rootPath = `${discordInstanceId ? ".proxy/" : ""}{{.RootPath}}` let socketIsConnecting = false; @@ -346,6 +347,12 @@ function toggleFullscreen() { } document.getElementById("toggle-fullscreen-button").addEventListener("click", toggleFullscreen); +function showGallery() { + window.open(`${rootPath}/lobby/${lobbyId}/gallery`,"_blank" ); +} + +document.getElementById("show-gallery-button").addEventListener("click", showGallery); + function showLobbySettingsDialog() { hideMenu(); lobbySettingsDialog.style.visibility = "visible"; diff --git a/internal/frontend/resources/gallery.css b/internal/frontend/resources/gallery.css new file mode 100644 index 00000000..33da6a2c --- /dev/null +++ b/internal/frontend/resources/gallery.css @@ -0,0 +1,40 @@ +.app { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + box-sizing: border-box; + height: 100%; +} + +#drawing-board { + border-radius: var(--component-border-radius); + min-height: 0; + max-width: 100%; + margin-left: auto; + margin-right: auto; +} + +.top-bar { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0.5rem; + background-color: var(--component-base-color); + border-radius: var(--component-border-radius); + + * { + font-size: 1.5rem; + } +} + +.prev, +.next { + border: 1px solid black; +} + +.prev { +} + +.next { +} diff --git a/internal/frontend/resources/gallery.js b/internal/frontend/resources/gallery.js new file mode 100644 index 00000000..c55cfb3e --- /dev/null +++ b/internal/frontend/resources/gallery.js @@ -0,0 +1,215 @@ +function getCookie(name) { + let cookie = {}; + document.cookie.split(";").forEach(function (el) { + let split = el.split("="); + cookie[split[0].trim()] = split.slice(1).join("="); + }); + return cookie[name]; +} + +const rootPath = getCookie("root-path"); +const lobbyId = getCookie("lobby-id"); + +document.getElementById("prev").addEventListener("click", () => { + prevDrawing(); +}); + +document.getElementById("next").addEventListener("click", () => { + nextDrawing(); +}); + +/** + * @returns {Promise} + */ +const openDB = () => { + const db = indexedDB.open("scribblers", 1); + + db.onupgradeneeded = (event) => { + const db = event.target.result; + const objectStore = db.createObjectStore("gallery", { keyPath: "id" }); + // No index, as we store an array. + }; + + return new Promise((resolve, reject) => { + db.onsuccess = () => { + resolve(db.result); + }; + db.onerror = () => { + reject(db.error); + }; + }); +}; + +const dbPromise = openDB(); + +const getGalleryEntry = async (store, id) => { + return new Promise((resolve, reject) => { + const gallery = store.get(id); + gallery.onsuccess = (event) => { + const galleryData = event.target.result; + resolve(galleryData); + }; + gallery.onerror = () => { + reject(gallery.error); + }; + }); +}; + +const getGallery = () => { + return new Promise(async (resolve, reject) => { + const db = await dbPromise; + const store = db.transaction("gallery").objectStore("gallery"); + const cachedGallery = await getGalleryEntry(store, lobbyId); + + fetch( + `${rootPath}/v1/lobby/${lobbyId}/gallery?` + + new URLSearchParams({ + local_cache_count: cachedGallery ? cachedGallery.data.length : 0, + }).toString(), + ) + .then((response) => { + if (response.status === 204) { + console.log(`No new gallery data for lobby ${lobbyId} available.`); + resolve(cachedGallery ? cachedGallery.data : []); + return; + } + + if (response.status === 200) { + response + .json() + .then((json) => { + const store = db + .transaction("gallery", "readwrite") + .objectStore("gallery"); + store.put({ + id: lobbyId, + data: json, + }); + console.log(`Latest gallery for lobby ${lobbyId} stored.`); + return json; + }) + .then(resolve); + return; + } + + console.log("Unknown error, falling back to cached value"); + resolve(cachedGallery.data); + }) + .catch((err) => { + if (cachedGallery && cachedGallery.data.length > 0) { + resolve(cachedGallery.data); + } else { + reject(err); + } + }); + }); +}; + +const word = document.getElementById("word"); +const drawer = document.getElementById("drawer"); + +const drawingBoard = document.getElementById("drawing-board"); +const context = drawingBoard.getContext("2d", { alpha: false }); +let imageData; + +function clear(context) { + context.fillStyle = "#FFFFFF"; + context.fillRect(0, 0, drawingBoard.width, drawingBoard.height); + // Refetch, as we don't manually fill here. + imageData = context.getImageData( + 0, + 0, + context.canvas.width, + context.canvas.height, + ); +} +clear(context); + +function setDrawing(drawing) { + clear(context); + + word.innerText = drawing.word; + if (drawing.drawer) { + drawer.innerText = `by ${drawing.drawer}`; + } else { + drawer.innerText = ""; + } + + drawing.events.forEach((drawElement) => { + const drawData = drawElement.data; + if (drawElement.type === "fill") { + floodfillUint8ClampedArray( + imageData.data, + drawData.x, + drawData.y, + indexToRgbColor(drawData.color), + imageData.width, + imageData.height, + ); + } else if (drawElement.type === "line") { + drawLineNoPut( + context, + imageData, + drawData.x, + drawData.y, + drawData.x2, + drawData.y2, + indexToRgbColor(drawData.color), + drawData.width, + ); + } else { + console.log("Unknown draw element type: " + drawData.type); + } + }); + + context.putImageData(imageData, 0, 0); +} + +let currentIndex = 0; +let galleryData; + +getGallery().then((data) => { + if (data.length > 0) { + setDrawing(data[0]); + } + galleryData = data; +}); + +function prevDrawing() { + if (!galleryData) { + return; + } + + if (currentIndex <= 0) { + return; + } + + currentIndex = currentIndex - 1; + setDrawing(galleryData[currentIndex]); +} + +function nextDrawing() { + if (!galleryData) { + return; + } + + if (currentIndex >= galleryData.length - 1) { + return; + } + + currentIndex = currentIndex + 1; + setDrawing(galleryData[currentIndex]); +} + +dbPromise.then((db) => { + const galleryStore = db.transaction("gallery").objectStore("gallery"); + const galleryCursor = galleryStore.openCursor(); + galleryCursor.onsuccess = async (event) => { + const cursor = event.target.result; + if (cursor) { + const entry = await cursor.value; + console.log(entry.id); + cursor.continue(); + } + }; +}); diff --git a/internal/frontend/resources/gallery.svg b/internal/frontend/resources/gallery.svg new file mode 100644 index 00000000..b99f00f3 --- /dev/null +++ b/internal/frontend/resources/gallery.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/internal/frontend/templates/gallery.html b/internal/frontend/templates/gallery.html new file mode 100644 index 00000000..ab38ad4c --- /dev/null +++ b/internal/frontend/templates/gallery.html @@ -0,0 +1,53 @@ +{{define "gallery-page"}} + + + + Scribble.rs - Gallery + + + {{template "non-static-css-decl" .}} + + + {{template "favicon-decl" .}} + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + +{{end}} diff --git a/internal/frontend/templates/lobby.html b/internal/frontend/templates/lobby.html index 908ca104..94be135c 100644 --- a/internal/frontend/templates/lobby.html +++ b/internal/frontend/templates/lobby.html @@ -71,6 +71,13 @@ class="header-button-image" /> {{.Translation.Get "toggle-fullscreen"}} +