summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--srv/api/api.go2
-rw-r--r--srv/api/chat.go61
-rw-r--r--srv/api/csrf.go8
-rw-r--r--srv/api/middleware.go9
-rw-r--r--srv/api/pow.go4
-rw-r--r--srv/chat/chat.go13
-rw-r--r--srv/default.nix2
-rw-r--r--srv/go.mod1
-rw-r--r--srv/go.sum2
-rw-r--r--static/src/assets/api.js52
-rw-r--r--static/src/chat.md126
11 files changed, 265 insertions, 15 deletions
diff --git a/srv/api/api.go b/srv/api/api.go
index 6ba7ce0..adaf6a1 100644
--- a/srv/api/api.go
+++ b/srv/api/api.go
@@ -172,7 +172,7 @@ func (a *api) handler() http.Handler {
)))
var apiHandler http.Handler = apiMux
- apiHandler = allowedMethod("POST", apiHandler)
+ apiHandler = postOnlyMiddleware(apiHandler)
apiHandler = checkCSRFMiddleware(apiHandler)
apiHandler = logMiddleware(a.params.Logger, apiHandler)
apiHandler = annotateMiddleware(apiHandler)
diff --git a/srv/api/chat.go b/srv/api/chat.go
index 4ac32e4..a1acc5a 100644
--- a/srv/api/chat.go
+++ b/srv/api/chat.go
@@ -1,12 +1,14 @@
package api
import (
+ "context"
"errors"
"fmt"
"net/http"
"strings"
"unicode"
+ "github.com/gorilla/websocket"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutils"
"github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
)
@@ -16,6 +18,8 @@ type chatHandler struct {
room chat.Room
userIDCalc *chat.UserIDCalculator
+
+ wsUpgrader websocket.Upgrader
}
func newChatHandler(
@@ -26,11 +30,14 @@ func newChatHandler(
ServeMux: http.NewServeMux(),
room: room,
userIDCalc: userIDCalc,
+
+ wsUpgrader: websocket.Upgrader{},
}
c.Handle("/history", c.historyHandler())
c.Handle("/user-id", requirePowMiddleware(c.userIDHandler()))
c.Handle("/append", requirePowMiddleware(c.appendHandler()))
+ c.Handle("/listen", c.listenHandler())
return c
}
@@ -148,3 +155,57 @@ func (c *chatHandler) appendHandler() http.Handler {
})
})
}
+
+func (c *chatHandler) listenHandler() http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ ctx := r.Context()
+ sinceID := r.FormValue("sinceID")
+
+ conn, err := c.wsUpgrader.Upgrade(rw, r, nil)
+ if err != nil {
+ apiutils.BadRequest(rw, r, err)
+ return
+ }
+ defer conn.Close()
+
+ it, err := c.room.Listen(ctx, sinceID)
+
+ if errors.As(err, new(chat.ErrInvalidArg)) {
+ apiutils.BadRequest(rw, r, err)
+ return
+
+ } else if errors.Is(err, context.Canceled) {
+ return
+
+ } else if err != nil {
+ apiutils.InternalServerError(rw, r, err)
+ return
+ }
+
+ defer it.Close()
+
+ for {
+
+ msg, err := it.Next(ctx)
+ if errors.Is(err, context.Canceled) {
+ return
+
+ } else if err != nil {
+ apiutils.InternalServerError(rw, r, err)
+ return
+ }
+
+ err = conn.WriteJSON(struct {
+ Message chat.Message `json:"message"`
+ }{
+ Message: msg,
+ })
+
+ if err != nil {
+ apiutils.GetRequestLogger(r).Error(ctx, "couldn't write message", err)
+ return
+ }
+ }
+ })
+}
diff --git a/srv/api/csrf.go b/srv/api/csrf.go
index 0802d8a..13b6ec6 100644
--- a/srv/api/csrf.go
+++ b/srv/api/csrf.go
@@ -41,8 +41,14 @@ func checkCSRFMiddleware(h http.Handler) http.Handler {
if err != nil {
apiutils.InternalServerError(rw, r, err)
return
+ }
+
+ givenCSRFTok := r.Header.Get(csrfTokenHeaderName)
+ if givenCSRFTok == "" {
+ givenCSRFTok = r.FormValue("csrfToken")
+ }
- } else if csrfTok == "" || r.Header.Get(csrfTokenHeaderName) != csrfTok {
+ if csrfTok == "" || givenCSRFTok != csrfTok {
apiutils.BadRequest(rw, r, errors.New("invalid CSRF token"))
return
}
diff --git a/srv/api/middleware.go b/srv/api/middleware.go
index 2605d93..6ea0d13 100644
--- a/srv/api/middleware.go
+++ b/srv/api/middleware.go
@@ -40,12 +40,15 @@ func annotateMiddleware(h http.Handler) http.Handler {
type logResponseWriter struct {
http.ResponseWriter
+ http.Hijacker
statusCode int
}
func newLogResponseWriter(rw http.ResponseWriter) *logResponseWriter {
+ h, _ := rw.(http.Hijacker)
return &logResponseWriter{
ResponseWriter: rw,
+ Hijacker: h,
statusCode: 200,
}
}
@@ -78,9 +81,11 @@ func logMiddleware(logger *mlog.Logger, h http.Handler) http.Handler {
})
}
-func allowedMethod(method string, h http.Handler) http.Handler {
+func postOnlyMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- if r.Method == method {
+
+ // we allow websockets to not be POSTs because, well, they can't be
+ if r.Method == "POST" || r.Header.Get("Upgrade") == "websocket" {
h.ServeHTTP(rw, r)
return
}
diff --git a/srv/api/pow.go b/srv/api/pow.go
index 6d11061..1b232b1 100644
--- a/srv/api/pow.go
+++ b/srv/api/pow.go
@@ -27,14 +27,14 @@ func (a *api) newPowChallengeHandler() http.Handler {
func (a *api) requirePowMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- seedHex := r.PostFormValue("powSeed")
+ seedHex := r.FormValue("powSeed")
seed, err := hex.DecodeString(seedHex)
if err != nil || len(seed) == 0 {
apiutils.BadRequest(rw, r, errors.New("invalid powSeed"))
return
}
- solutionHex := r.PostFormValue("powSolution")
+ solutionHex := r.FormValue("powSolution")
solution, err := hex.DecodeString(solutionHex)
if err != nil || len(seed) == 0 {
apiutils.BadRequest(rw, r, errors.New("invalid powSolution"))
diff --git a/srv/chat/chat.go b/srv/chat/chat.go
index acb7b2d..0a88d3b 100644
--- a/srv/chat/chat.go
+++ b/srv/chat/chat.go
@@ -31,9 +31,10 @@ var (
// Message describes a message which has been posted to a Room.
type Message struct {
- ID string `json:"id"`
- UserID UserID `json:"userID"`
- Body string `json:"body"`
+ ID string `json:"id"`
+ UserID UserID `json:"userID"`
+ Body string `json:"body"`
+ CreatedAt int64 `json:"createdAt,omitempty"`
}
func msgFromStreamEntry(entry radix.StreamEntry) (Message, error) {
@@ -59,6 +60,7 @@ func msgFromStreamEntry(entry radix.StreamEntry) (Message, error) {
}
msg.ID = entry.ID.String()
+ msg.CreatedAt = int64(entry.ID.Time / 1000)
return msg, nil
}
@@ -211,7 +213,7 @@ func (r *room) Append(ctx context.Context, msg Message) (Message, error) {
maxLen := strconv.Itoa(r.params.MaxMessages)
body := string(b)
- var id string
+ var id radix.StreamEntryID
err = r.params.Redis.Do(ctx, radix.Cmd(
&id, "XADD", key, "MAXLEN", "=", maxLen, "*", "json", body,
@@ -221,7 +223,8 @@ func (r *room) Append(ctx context.Context, msg Message) (Message, error) {
return Message{}, fmt.Errorf("posting message to redis: %w", err)
}
- msg.ID = id
+ msg.ID = id.String()
+ msg.CreatedAt = int64(id.Time / 1000)
return msg, nil
}
diff --git a/srv/default.nix b/srv/default.nix
index a36739a..bc828a0 100644
--- a/srv/default.nix
+++ b/srv/default.nix
@@ -23,7 +23,7 @@
pname = "mediocre-blog-srv";
version = "dev";
src = ./.;
- vendorSha256 = "0c6j989q6r2q967gx90cl4l8skflkx2npmxd3f5l16bwj2ldw11j";
+ vendorSha256 = "02szg1lisfjk8pk9pflbyv97ykg9362r4fhd0w0p2a7c81kf9b8y";
# disable tests
checkPhase = '''';
diff --git a/srv/go.mod b/srv/go.mod
index 8587186..313e1fe 100644
--- a/srv/go.mod
+++ b/srv/go.mod
@@ -6,6 +6,7 @@ require (
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0
github.com/google/uuid v1.3.0
+ github.com/gorilla/websocket v1.4.2 // indirect
github.com/mattn/go-sqlite3 v1.14.8
github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0
github.com/mediocregopher/radix/v4 v4.0.0-beta.1.0.20210726230805-d62fa1b2e3cb // indirect
diff --git a/srv/go.sum b/srv/go.sum
index 6c96538..1c51163 100644
--- a/srv/go.sum
+++ b/srv/go.sum
@@ -61,6 +61,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
diff --git a/static/src/assets/api.js b/static/src/assets/api.js
index 7ce2e89..4447bb1 100644
--- a/static/src/assets/api.js
+++ b/static/src/assets/api.js
@@ -1,5 +1,7 @@
import * as utils from "/assets/utils.js";
+const csrfTokenCookie = "csrf_token";
+
const doFetch = async (req) => {
let res, jsonRes;
try {
@@ -53,13 +55,13 @@ const call = async (route, opts = {}) => {
requiresPow = false,
} = opts;
- if (!utils.cookies["csrf_token"])
- throw "csrf_token cookie not set, can't make api call";
+ if (!utils.cookies[csrfTokenCookie])
+ throw `${csrfTokenCookie} cookie not set, can't make api call`;
const reqOpts = {
method,
headers: {
- "X-CSRF-Token": utils.cookies["csrf_token"],
+ "X-CSRF-Token": utils.cookies[csrfTokenCookie],
},
};
@@ -80,6 +82,50 @@ const call = async (route, opts = {}) => {
return doFetch(req);
}
+const ws = async (route, opts = {}) => {
+ const {
+ requiresPow = false,
+ } = opts;
+
+ const docURL = new URL(document.URL);
+ const protocol = docURL.protocol == "http:" ? "ws:" : "wss:";
+
+ const params = new URLSearchParams();
+ const csrfToken = utils.cookies[csrfTokenCookie];
+
+ if (!csrfToken)
+ throw `${csrfTokenCookie} cookie not set, can't make api call`;
+
+ params.set("csrfToken", csrfToken);
+
+ if (requiresPow) {
+ const {seed, solution} = await solvePow();
+ params.set("powSeed", seed);
+ params.set("powSolution", solution);
+ }
+
+ const rawConn = new WebSocket(`${protocol}//${docURL.host}${route}?${params.toString()}`);
+
+ const conn = {
+ next: () => new Promise((resolve, reject) => {
+ rawConn.onmessage = (m) => {
+ const mj = JSON.parse(m.data);
+ resolve(mj);
+ };
+ rawConn.onerror = reject;
+ rawConn.onclose = reject;
+ }),
+
+ close: rawConn.close,
+ };
+
+ return new Promise((resolve, reject) => {
+ rawConn.onopen = () => resolve(conn);
+ rawConn.onerror = reject;
+ });
+}
+
export {
call,
+ ws
}
diff --git a/static/src/chat.md b/static/src/chat.md
new file mode 100644
index 0000000..c7471ef
--- /dev/null
+++ b/static/src/chat.md
@@ -0,0 +1,126 @@
+---
+layout: page
+---
+
+<script async type="module" src="/assets/api.js"></script>
+
+<style>
+ #messages {
+ max-height: 65vh;
+ overflow: auto;
+ }
+
+ #messages .message {
+ border: 1px solid #AAA;
+ border-radius: 10px;
+ margin-bottom: 1rem;
+ padding: 2rem;
+ overflow: auto;
+ }
+
+ #messages .message .title {
+ font-weight: bold;
+ font-size: 120%;
+ }
+
+ #messages .message .secondaryTitle {
+ font-family: monospace;
+ color: #CCC;
+ }
+
+ #messages .message p {
+ font-family: monospace;
+ margin: 1rem 0 0 0;
+ }
+
+</style>
+
+<div id="messages"></div>
+
+<span id="fail" style="color: red;"></span>
+
+<script>
+
+const messagesEl = document.getElementById("messages");
+
+function renderMessages(msgs) {
+
+ msgs = [...msgs].reverse();
+
+ messagesEl.innerHTML = '';
+
+ msgs.forEach((msg) => {
+ console.log(msg);
+ const el = document.createElement("div");
+ el.className = "row message"
+
+ const elWithTextContents = (tag, body) => {
+ const el = document.createElement(tag);
+ el.appendChild(document.createTextNode(body));
+ return el;
+ };
+
+ const titleEl = document.createElement("div");
+ titleEl.className = "title";
+ el.appendChild(titleEl);
+
+ const userNameEl = elWithTextContents("span", msg.userID.name);
+ titleEl.appendChild(userNameEl);
+
+ const secondaryTitleEl = document.createElement("div");
+ secondaryTitleEl.className = "secondaryTitle";
+ el.appendChild(secondaryTitleEl);
+
+ const dt = new Date(msg.createdAt*1000);
+ const dtStr
+ = `${dt.getFullYear()}-${dt.getMonth()+1}-${dt.getDate()}`
+ + ` ${dt.getHours()}:${dt.getMinutes()}:${dt.getSeconds()}`;
+
+ const userIDEl = elWithTextContents("span", `userID:${msg.userID.id} @ ${dtStr}`);
+ secondaryTitleEl.appendChild(userIDEl);
+
+ const bodyEl = document.createElement("p");
+
+ const bodyParts = msg.body.split("\n");
+ for (const i in bodyParts) {
+ if (i > 0) bodyEl.appendChild(document.createElement("br"));
+ bodyEl.appendChild(document.createTextNode(bodyParts[i]));
+ }
+
+ el.appendChild(bodyEl);
+
+ messagesEl.appendChild(el);
+ });
+}
+
+
+(async () => {
+
+ const failEl = document.getElementById("fail");
+
+ setErr = (msg) => failEl.innerHTML = `${msg} (please refresh the page to retry)`;
+
+ const api = await import("/assets/api.js");
+
+ try {
+
+ const history = await api.call("/api/chat/global/history");
+ renderMessages(history.messages);
+
+ } catch (e) {
+ e = `Failed to fetch message history: ${e}`
+ setErr(e);
+ console.error(e);
+ return;
+ }
+
+ //const ws = await api.ws("/api/chat/global/listen");
+
+ //while (true) {
+ // const msg = await ws.next();
+ // console.log("got msg", msg);
+ //}
+
+})()
+
+</script>