Direct Message History

* Add support for storing DM history between users in a SQLite3 database.
* Opt-in by editing your settings.toml to set DirectMessageHistory/Enabled=true
* Retention days (default 90) will flush old DMs on app startup.
* On the front-end, DM history is checked when a DM thread is opened.
This commit is contained in:
Noah 2024-03-28 23:20:09 -07:00
parent 96d61614f4
commit f0dd1d952c
13 changed files with 660 additions and 56 deletions

View File

@ -230,3 +230,51 @@ The response JSON given to the chat page from /api/profile looks like:
]
}
```
## POST /api/message/history
Load prior history in a Direct Message conversation with another party.
Note: this API request is done by the BareRTC chat front-end page, as an
ajax request for a current logged-in user.
The request body payload looks like:
```json
{
"JWTToken": "the caller's chat jwt token",
"Username": "soandso",
"BeforeID": 1234
}
```
The JWT token is the current chat user's token. This API only works when
your BareRTC config requires the use of JWT tokens for authorization.
The "BeforeID" parameter is for pagination, and is optional. By default,
the first page of recent messages are returned. To get the next page, provide
the "BeforeID" which matches the MessageID of the oldest message from that
page. The endpoint will return messages having an ID before this ID.
The response JSON given to the chat page from /api/profile looks like:
```javascript
{
"OK": true,
"Error": "only on error messages",
"Messages": [
{
// Standard BareRTC Messages.
"username": "soandso",
"message": "hello!",
"msgID": 1234,
"timestamp": "2024-01-01 11:22:33"
}
],
"Remaining": 12
}
```
The "Remaining" integer in the result shows how many older messages still
remain to be retrieved, and tells the front-end page that it can request
another page.

12
go.mod
View File

@ -7,8 +7,9 @@ require (
github.com/BurntSushi/toml v1.3.2
github.com/aichaos/rivescript-go v0.4.0
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/uuid v1.3.1
github.com/google/uuid v1.5.0
github.com/mattn/go-shellwords v1.0.12
github.com/microcosm-cc/bluemonday v1.0.25
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
@ -23,10 +24,13 @@ require (
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
@ -43,7 +47,11 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
)

28
go.sum
View File

@ -29,6 +29,8 @@ github.com/dop251/goja v0.0.0-20230919151941-fc55792775de h1:lA38Xtzr1Wo+iQdkN2E
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -37,6 +39,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@ -76,8 +80,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
@ -99,8 +103,9 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
@ -119,6 +124,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@ -216,8 +223,9 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -243,8 +251,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -273,5 +281,13 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -30,6 +30,7 @@
<script type="text/javascript">
const Branding = {{.Config.Branding}};
const PublicChannels = {{.Config.GetChannels}};
const DMDisclaimer = {{.Config.DirectMessageHistory.DisclaimerMessage}};
const WebsiteURL = "{{.Config.WebsiteURL}}";
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
const TURN = {{.Config.TURN}};

View File

@ -13,6 +13,7 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Statistics (/api/statistics) returns info about the users currently logged onto the chat,
@ -748,6 +749,138 @@ func (s *Server) UserProfile() http.HandlerFunc {
})
}
// MessageHistory (/api/message/history) fetches past direct messages for a user.
//
// This endpoint looks up earlier chat messages between the current user and a target.
// It will only run with a valid JWT auth token, to protect users' privacy.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "JWTToken": "the caller's jwt token",
// "Username": "other party",
// "BeforeID": 1234,
// }
//
// The "BeforeID" parameter is for pagination and is optional: by default the most
// recent page of messages are returned. To retrieve an older page, the BeforeID will
// contain the MessageID of the oldest message you received so far, so that the message
// before that will be the first returned on the next page.
//
// The response JSON will look like the following:
//
// {
// "OK": true,
// "Error": "only on error responses",
// "Messages": [
// {
// // Standard BareRTC Message objects...
// "MessageID": 1234,
// "Username": "other party",
// "Message": "hello!",
// }
// ],
// "Remaining": 42,
// }
//
// The Remaining value is how many older messages still exist to be loaded.
func (s *Server) MessageHistory() http.HandlerFunc {
type request struct {
JWTToken string
Username string
BeforeID int64
}
type result struct {
OK bool
Error string `json:",omitempty"`
Messages []messages.Message
Remaining int
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
claims, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Get the user from the chat roster.
sub, err := s.GetSubscriber(claims.Subject)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "You are not logged into the chat room.",
})
return
}
// Fetch a page of message history.
messages, remaining, err := models.PaginateDirectMessages(sub.Username, params.Username, params.BeforeID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
enc.Encode(result{
Error: err.Error(),
})
return
}
enc.Encode(result{
OK: true,
Messages: messages,
Remaining: remaining,
})
})
}
// Blocklist cache sent over from your website.
var (
// Map of username to the list of usernames they block.

View File

@ -13,7 +13,7 @@ import (
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 10
var currentVersion = 11
// Config for your BareRTC app.
type Config struct {
@ -51,6 +51,8 @@ type Config struct {
MessageFilters []*MessageFilter
DirectMessageHistory DirectMessageHistory
Logging Logging
}
@ -67,6 +69,13 @@ type VIP struct {
MutuallySecret bool
}
type DirectMessageHistory struct {
Enabled bool
SQLiteDatabase string
RetentionDays int
DisclaimerMessage string
}
// GetChannels returns a JavaScript safe array of the default PublicChannels.
func (c Config) GetChannels() template.JS {
data, _ := json.Marshal(c.PublicChannels)
@ -185,6 +194,12 @@ func DefaultConfig() Config {
ChatServerResponse: "Watch your language.",
},
},
DirectMessageHistory: DirectMessageHistory{
Enabled: false,
SQLiteDatabase: "database.sqlite",
RetentionDays: 90,
DisclaimerMessage: `<i class="fa fa-info-circle mr-1"></i> <strong>Reminder:</strong> please conduct yourself honorably in Direct Messages.`,
},
Logging: Logging{
Directory: "./logs",
Channels: []string{"lobby", "offtopic"},

View File

@ -11,6 +11,7 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
"git.kirsle.net/apps/barertc/pkg/util"
)
@ -236,6 +237,11 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
LogMessage(rcpt, sub.Username, sub.Username, msg)
}
// Add it to the DM history SQLite database.
if err := (models.DirectMessage{}).LogMessage(sub.Username, rcpt.Username, message); err != nil && err != models.ErrNotInitialized {
log.Error("Logging DM history to SQLite: %s", err)
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
@ -253,16 +259,28 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
// OnTakeback handles takebacks (delete your message for everybody)
func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
// In case we're in a DM thread, remove this message ID from the history table
// if the username matches.
wasRemovedFromHistory, err := (models.DirectMessage{}).TakebackMessage(sub.Username, msg.MessageID, sub.IsAdmin())
if err != nil && err != models.ErrNotInitialized {
log.Error("Error taking back DM history message (%s, %d): %s", sub.Username, msg.MessageID, err)
}
// Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock()
_, ok := sub.messageIDs[msg.MessageID]
sub.midMu.Unlock()
if !ok {
// The messageID is not found in the current chat session, but did we remove
// it from past DM history for the correct current user?
if !wasRemovedFromHistory {
sub.ChatServer("That is not your message to take back.")
return
}
}
}
// Broadcast to everybody to remove this message.
s.Broadcast(messages.Message{

29
pkg/models/database.go Normal file
View File

@ -0,0 +1,29 @@
package models
import (
"database/sql"
"errors"
_ "github.com/glebarez/go-sqlite"
)
var (
DB *sql.DB
ErrNotInitialized = errors.New("database is not initialized")
)
func Initialize(connString string) error {
db, err := sql.Open("sqlite", connString)
if err != nil {
return err
}
DB = db
// Run table migrations
if err := (DirectMessage{}).CreateTable(); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,199 @@
package models
import (
"errors"
"fmt"
"math"
"sort"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
)
type DirectMessage struct {
MessageID int64
ChannelID string
Username string
Message string
Timestamp int64
}
const DirectMessagePerPage = 20
func (dm DirectMessage) CreateTable() error {
if DB == nil {
return ErrNotInitialized
}
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS direct_messages (
message_id INTEGER PRIMARY KEY,
channel_id TEXT,
username TEXT,
message TEXT,
timestamp INTEGER
);
CREATE INDEX IF NOT EXISTS idx_direct_messages_channel_id ON direct_messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_direct_messages_timestamp ON direct_messages(timestamp);
`)
if err != nil {
return err
}
// Delete old messages past the retention period.
if days := config.Current.DirectMessageHistory.RetentionDays; days > 0 {
cutoff := time.Now().Add(time.Duration(-days) * 24 * time.Hour)
log.Info("Deleting old DM history past %d days (cutoff: %s)", days, cutoff.Format(time.RFC3339))
_, err := DB.Exec(
"DELETE FROM direct_messages WHERE timestamp < ?",
cutoff.Unix(),
)
if err != nil {
log.Error("Error removing old DMs: %s", err)
}
}
return nil
}
// LogMessage adds a message to the DM history between two users.
func (dm DirectMessage) LogMessage(fromUsername, toUsername string, msg messages.Message) error {
if DB == nil {
return ErrNotInitialized
}
if msg.MessageID == 0 {
return errors.New("message did not have a MessageID")
}
var (
channelID = CreateChannelID(fromUsername, toUsername)
timestamp = time.Now().Unix()
)
_, err := DB.Exec(`
INSERT INTO direct_messages (message_id, channel_id, username, message, timestamp)
VALUES (?, ?, ?, ?, ?)
`, msg.MessageID, channelID, fromUsername, msg.Message, timestamp)
return err
}
// TakebackMessage removes a message by its MID from the DM history.
//
// Because the MessageID may have been from a previous chat session, the server can't immediately
// verify the current user had permission to take it back. This function instead will check whether
// a DM history exists sent by this username for that messageID, and if so, returns a
// boolean true that the username/messageID matched which will satisfy the permission check
// in the OnTakeback handler.
func (dm DirectMessage) TakebackMessage(username string, messageID int64, isAdmin bool) (bool, error) {
if DB == nil {
return false, ErrNotInitialized
}
// Does this messageID exist as sent by the user?
if !isAdmin {
var (
row = DB.QueryRow(
"SELECT message_id FROM direct_messages WHERE username = ? AND message_id = ?",
username, messageID,
)
foundMsgID int64
err = row.Scan(&foundMsgID)
)
if err != nil {
return false, errors.New("no such message ID found as owned by that user")
}
}
// Delete it.
_, err := DB.Exec(
"DELETE FROM direct_messages WHERE message_id = ?",
messageID,
)
// Return that it was successfully validated and deleted.
return err == nil, err
}
// PaginateDirectMessages returns a page of messages, the count of remaining, and an error.
func PaginateDirectMessages(fromUsername, toUsername string, beforeID int64) ([]messages.Message, int, error) {
if DB == nil {
return nil, 0, ErrNotInitialized
}
var (
result = []messages.Message{}
channelID = CreateChannelID(fromUsername, toUsername)
// Compute the remaining messages after finding the final messageID this page.
lastMessageID int64
remaining int
)
if beforeID == 0 {
beforeID = math.MaxInt64
}
rows, err := DB.Query(`
SELECT message_id, username, message, timestamp
FROM direct_messages
WHERE channel_id = ?
AND message_id < ?
ORDER BY message_id DESC
LIMIT ?
`, channelID, beforeID, DirectMessagePerPage)
if err != nil {
return nil, 0, err
}
for rows.Next() {
var row DirectMessage
if err := rows.Scan(
&row.MessageID,
&row.Username,
&row.Message,
&row.Timestamp,
); err != nil {
return nil, 0, err
}
msg := messages.Message{
MessageID: row.MessageID,
Username: row.Username,
Message: row.Message,
Timestamp: time.Unix(row.Timestamp, 0).Format(time.RFC3339),
}
result = append(result, msg)
lastMessageID = msg.MessageID
}
// Get a count of the remaining messages.
row := DB.QueryRow(`
SELECT COUNT(message_id)
FROM direct_messages
WHERE channel_id = ?
AND message_id < ?
`, channelID, lastMessageID)
if err := row.Scan(&remaining); err != nil {
return nil, 0, err
}
return result, remaining, nil
}
// CreateChannelID returns a deterministic channel ID for a direct message conversation.
//
// The usernames (passed in any order) are sorted alphabetically and composed into the channel ID.
func CreateChannelID(fromUsername, toUsername string) string {
var parts = []string{fromUsername, toUsername}
sort.Strings(parts)
return fmt.Sprintf(
"@%s:@%s",
parts[0],
parts[1],
)
}

View File

@ -4,6 +4,10 @@ import (
"io"
"net/http"
"sync"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Server is the primary back-end server struct for BareRTC, see main.go
@ -32,6 +36,13 @@ func NewServer() *Server {
// Setup the server: configure HTTP routes, etc.
func (s *Server) Setup() error {
// Enable the SQLite database for DM history?
if config.Current.DirectMessageHistory.Enabled {
if err := models.Initialize(config.Current.DirectMessageHistory.SQLiteDatabase); err != nil {
log.Error("Error initializing SQLite database: %s", err)
}
}
var mux = http.NewServeMux()
mux.Handle("/", IndexPage())
@ -46,6 +57,7 @@ func (s *Server) Setup() error {
mux.Handle("/api/authenticate", s.Authenticate())
mux.Handle("/api/shutdown", s.ShutdownAPI())
mux.Handle("/api/profile", s.UserProfile())
mux.Handle("/api/message/history", s.MessageHistory())
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))

View File

@ -74,6 +74,7 @@ export default {
config: {
branding: Branding,
channels: PublicChannels,
dmDisclaimer: DMDisclaimer,
website: WebsiteURL,
permitNSFW: PermitNSFW,
webhookURLs: WebhookURLs,
@ -241,7 +242,6 @@ export default {
},
// Chat history.
history: [],
channels: {
// There will be values here like:
// "lobby": {
@ -270,6 +270,17 @@ export default {
// }
},
// Loading older Direct Message chat history.
directMessageHistory: {
/* Will look like:
"username": {
"busy": false, // ajax request in flight
"beforeID": 1234, // next page cursor
"remaining": 50, // number of older messages remaining
}
*/
},
// Responsive CSS controls for mobile.
responsive: {
leftDrawerOpen: false,
@ -1324,6 +1335,8 @@ export default {
onMessage(msg) {
// Play sound effects if this is not the active channel or the window is not focused.
if (msg.channel.indexOf("@") === 0) {
this.initDirectMessageHistory(msg.channel, msg.msgID);
if (msg.channel !== this.channel || !this.windowFocused) {
// If we are ignoring unsolicited DMs, don't play the sound effect here.
if (this.prefs.closeDMs && this.channels[msg.channel] == undefined) {
@ -1786,6 +1799,7 @@ export default {
openDMs(user) {
let channel = "@" + user.username;
this.initHistory(channel);
this.initDirectMessageHistory(channel);
this.setChannel(channel);
// Responsive CSS: switch back to chat panel upon opening a DM.
@ -1881,6 +1895,7 @@ export default {
let channel = this.channel;
this.setChannel(this.config.channels[0].ID);
delete (this.channels[channel]);
delete (this.directMessageHistory[channel]);
},
/* Take back messages (for everyone) or remove locally */
@ -2711,12 +2726,19 @@ export default {
};
}
},
pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient, messageID }) {
pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient, messageID, timestamp = null, unshift = false }) {
// Default channel = your current channel.
if (!channel) {
channel = this.channel;
}
// Assign a timestamp locally?
if (timestamp === null) {
timestamp = new Date();
} else {
timestamp = new Date(timestamp);
}
// Are we ignoring DMs?
if (this.prefs.closeDMs && channel.indexOf('@') === 0) {
// Don't allow an (incoming) DM to initialize a new chat room for us.
@ -2761,17 +2783,22 @@ export default {
message = message.replace(/@(here|all)\b/ig, `<strong class="has-background-at-mention">@$1</strong>`);
// Append the message.
this.channels[channel].updated = new Date().getTime();
this.channels[channel].history.push({
let toAppend = {
action: action,
channel: channel,
username: username,
message: message,
msgID: messageID,
at: new Date(),
at: timestamp,
isChatServer,
isChatClient,
});
};
this.channels[channel].updated = new Date().getTime();
if (unshift) {
this.channels[channel].history.unshift(toAppend);
} else {
this.channels[channel].history.push(toAppend);
}
// Trim the history per the scrollback buffer.
if (this.scrollback > 0 && this.channels[channel].history.length > this.scrollback) {
@ -2781,12 +2808,15 @@ export default {
);
}
// Scroll the history down.
if (!unshift) {
this.scrollHistory(channel);
}
// Mark unread notifiers if this is not our channel.
if (this.channel !== channel || !this.windowFocused) {
// Don't notify about presence broadcasts.
if (action !== "presence" && !isChatServer) {
// Don't notify about presence broadcasts or history-backfilled messages.
if (action !== "presence" && action !== "notification" && !isChatServer && !unshift) {
this.channels[channel].unread++;
}
}
@ -3136,6 +3166,110 @@ export default {
}
},
/*
* Direct Message History Loading
*/
initDirectMessageHistory(channel, ignoreID) {
if (this.directMessageHistory[channel] == undefined) {
this.directMessageHistory[channel] = {
busy: false,
beforeID: 0,
ignoreID: ignoreID,
remaining: -1,
};
// Push the disclaimer message to the bottom of the chat history.
let disclaimer = this.config.dmDisclaimer;
this.pushHistory({
channel: channel,
username: "ChatServer",
message: disclaimer,
action: "notification",
});
// Immediately request the first page.
window.requestAnimationFrame(() => {
this.loadDirectMessageHistory(channel).then(() => {
setTimeout(() => {
this.scrollHistory(channel);
}, 200);
});
});
}
},
async loadDirectMessageHistory(channel) {
if (!this.jwt.valid) return;
this.directMessageHistory[channel].busy = true;
return fetch("/api/message/history", {
method: "POST",
mode: "same-origin",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"JWTToken": this.jwt.token,
"Username": this.normalizeUsername(channel),
"BeforeID": this.directMessageHistory[channel].beforeID,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.Error) {
console.error("DirectMessageHistory: ", data.Error);
return;
}
// No more messages?
if (data.Messages.length === 0) {
this.directMessageHistory[channel].remaining = data.Remaining;
return;
}
// If this is the first of historic messages, insert a dividing line
// (ChatServer presence message) to separate them.
if (this.directMessageHistory[channel].remaining === -1) {
this.pushHistory({
channel: channel,
username: "ChatServer",
action: "presence",
message: "Messages from your past chat session are available above this line.",
unshift: true,
});
}
// Prepend these messages to the chat log.
let beforeID = 0;
for (let msg of data.Messages) {
beforeID = msg.msgID;
// Deduplicate: if this DM thread was opened because somebody sent us a message, their
// message will appear on the history side of the banner as well as the current side.
if (msg.msgID === this.directMessageHistory[channel].ignoreID) {
continue;
}
this.pushHistory({
channel: channel,
username: msg.username,
message: msg.message,
messageID: msg.msgID,
timestamp: msg.timestamp,
unshift: true,
});
}
// Update pagination state information.
this.directMessageHistory[channel].remaining = data.Remaining;
this.directMessageHistory[channel].beforeID = beforeID;
}).catch(resp => {
console.error("DirectMessageHistory: ", resp);
}).finally(() => {
this.directMessageHistory[channel].busy = false;
});
},
/*
* Webhook methods
*/
@ -4089,38 +4223,8 @@ export default {
<div :class="fontSizeClass">
<!-- Disclaimer at the top of DMs -->
<!-- TODO: make this disclaimer configurable for other sites to modify -->
<div class="notification is-warning is-light" v-if="isDM">
<!-- If reporting is enabled -->
<div v-if="isWebhookEnabled('report')">
<div class="block">
<i class="fa fa-info-circle mr-1"></i>
<strong>PSA:</strong> If you see something, say something!
</div>
<div class="block">
If you encounter an inappropriate, abusive or criminal message in chat, please flag it
by clicking the red
<i class="fa fa-flag has-text-danger mx-1"></i> icon to report the message and
let your website administrator know. Server-side chat filters are engaged
to help flag messages automatically, but we appreciate our members helping to flag
messages in case the automated filters missed any.
</div>
<div class="block">
Do your part to help keep this website great! <i class="fa-regular fa-star"></i>
</div>
</div>
<div v-else>
<i class="fa fa-info-circle mr-1"></i>
<strong>Reminder:</strong> please conduct yourself honorably in Direct Messages.
Please refer to <span v-html="config.branding"></span>'s Privacy Policy or Terms of Service
with
regard to DMs.
</div>
</div>
<!-- No history? -->
<div v-if="chatHistory.length === 0">
<div v-if="chatHistory.length === 0 || (chatHistory.length === 1 && chatHistory[0].action === 'notification')">
<em v-if="isDM">
Starting a direct message chat with {{ channel }}. Type a message and say hello!
</em>
@ -4129,9 +4233,25 @@ export default {
</em>
</div>
<!-- Load more history link in DMs -->
<div v-if="isDM && directMessageHistory[channel] != undefined && jwt.valid" class="mb-2">
<div v-if="directMessageHistory[channel].busy" class="notification is-info is-light">
<i class="fa fa-spinner fa-spin mr-1"></i>
Loading...
</div>
<div v-else-if="directMessageHistory[channel].remaining !== 0">
<a href="#" @click.prevent="loadDirectMessageHistory(channel)">
Load more messages
<span v-if="directMessageHistory[channel].remaining > 0">
({{directMessageHistory[channel].remaining}} remaining)
</span>
</a>
</div>
</div>
<div v-for="(msg, i) in chatHistory" v-bind:key="i">
<MessageBox :message="msg" :is-presence="msg.action === 'presence'" :appearance="messageStyle"
<MessageBox :message="msg" :action="msg.action" :appearance="messageStyle"
:position="i" :user="getUser(msg.username)" :is-offline="isUserOffline(msg.username)"
:username="username" :website-url="config.website" :is-dnd="isUsernameDND(msg.username)"
:is-muted="isMutedUser(msg.username)" :reactions="getReactions(msg)"

View File

@ -5,7 +5,7 @@ import 'vue3-emoji-picker/css';
export default {
props: {
message: Object, // chat Message object
isPresence: Boolean, // presence message (joined/left room, kicked, etc.)
action: String, // presence, notification, or (default) normal chat message
appearance: String, // message style appearance (cards, compact, etc.)
user: Object, // User object of the Message author
isOffline: Boolean, // user is not currently online
@ -174,7 +174,7 @@ export default {
<template>
<!-- Presence message banners -->
<div v-if="isPresence" class="notification is-success is-light py-1 px-3 mb-2">
<div v-if="action === 'presence'" class="notification is-success is-light py-1 px-3 mb-2">
<!-- Tiny avatar next to name and action buttons -->
<div class="columns is-mobile">
@ -203,6 +203,11 @@ export default {
</div>
<!-- Notification message banners (e.g. DM disclaimer) -->
<div v-else-if="action === 'notification'" class="notification is-warning is-light mb-2">
<span v-html="message.message"></span>
</div>
<!-- Card Style (default) -->
<div v-else-if="appearance === 'cards' || !appearance" class="box mb-2 px-4 pt-3 pb-1 position-relative">
<div class="media mb-0">

View File

@ -71,7 +71,7 @@ export default {
<label class="label" for="classification">Report classification:</label>
<div class="select is-fullwidth">
<select id="classification" v-model="classification" :disabled="busy">
<option v-for="i in reportClassifications" :value="i">{{ i }}</option>
<option v-for="i in reportClassifications" v-bind:key="i" :value="i">{{ i }}</option>
</select>
</div>
</div>