From 78883d06cc976c15360a127be0a14e9eccca9947 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Mon, 13 Feb 2023 14:13:21 -0800 Subject: [PATCH 01/13] Task management: Added SQL tables for storing todo lists and tasks Contributes mightily to #264 --- db/sql/table03_todo_lists.sql | 13 +++++++++++++ db/sql/table04_tasks.sql | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 db/sql/table03_todo_lists.sql create mode 100644 db/sql/table04_tasks.sql diff --git a/db/sql/table03_todo_lists.sql b/db/sql/table03_todo_lists.sql new file mode 100644 index 0000000..d8b5364 --- /dev/null +++ b/db/sql/table03_todo_lists.sql @@ -0,0 +1,13 @@ +CREATE TABLE todo_lists ( + id uuid NOT NULL UNIQUE PRIMARY KEY DEFAULT uuid_generate_v4(), + room_id text NOT NULL REFERENCES rooms ON DELETE CASCADE, + + -- This is called 'title_enc' (encrypted title) but stores + -- miniLock ciphertext, which can also store metadata + title_enc text NOT NULL -- base64-encoded ciphertext + + -- ASSUMPTION: todo lists don't expire and must be manually deleted by the user + + -- ASSUMPTION: no timestamp needed; better for user metadata privacy to not store it +); +ALTER TABLE todo_lists OWNER TO superuser; diff --git a/db/sql/table04_tasks.sql b/db/sql/table04_tasks.sql new file mode 100644 index 0000000..c8b812a --- /dev/null +++ b/db/sql/table04_tasks.sql @@ -0,0 +1,16 @@ +CREATE TABLE tasks ( + id uuid NOT NULL UNIQUE PRIMARY KEY DEFAULT uuid_generate_v4(), + room_id text NOT NULL REFERENCES rooms ON DELETE CASCADE, + + -- This is called 'title_enc' (encrypted title) but stores + -- miniLock ciphertext, which can also store metadata + title_enc text NOT NULL, -- base64-encoded ciphertext + + list_id text NOT NULL REFERENCES todo_lists ON DELETE CASCADE, + index double precision NOT NULL -- index in the list with list_id + + -- ASSUMPTION: tasks don't expire and must be manually deleted by the user + + -- ASSUMPTION: no timestamp needed; better for user metadata privacy to not store it +); +ALTER TABLE tasks OWNER TO superuser; From 8423e9537f9d22348372a359024a40021631422a Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Wed, 15 Feb 2023 23:12:32 -0800 Subject: [PATCH 02/13] README.md: Use postgrest v10.1.1, not v7.0.0 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 59d245b..0aaeab3 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ cd $(go env GOPATH)/src/github.com/cryptag/leapchat/db chmod a+rx ~/ createdb sudo -u $USER bash init_sql.sh -wget https://github.com/PostgREST/postgrest/releases/download/v7.0.0/postgrest-v7.0.0-osx.tar.xz -tar xvf postgrest-v7.0.0-osx.tar.xz +wget https://github.com/PostgREST/postgrest/releases/download/v10.1.1/postgrest-v10.1.1-macos-x64.tar.xz +tar xvf postgrest-v10.1.1-macos-x64.tar.xz ./postgrest postgrest.conf ``` @@ -157,8 +157,8 @@ and have `postgrest` connect to Postgres: cd $(go env GOPATH)/src/github.com/cryptag/leapchat/db chmod a+rx ~/ sudo -u postgres bash init_sql.sh -wget https://github.com/PostgREST/postgrest/releases/download/v7.0.0/postgrest-v7.0.0-ubuntu.tar.xz -tar xvf postgrest-v7.0.0-ubuntu.tar.xz +wget https://github.com/PostgREST/postgrest/releases/download/v10.1.1/postgrest-v10.1.1-linux-static-x64.tar.xz +tar xvf postgrest-v10.1.1-linux-static-x64.tar.xz ./postgrest postgrest.conf ``` From a273c0b1f627448b305a6e0fae7d6977e540566d Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:15:16 -0800 Subject: [PATCH 03/13] WIP: RightPanel exists + Todo list creation works! :tada: Gets encrypted, added to DB correctly. The correctly-created todo list even gets send to us from Go over the WebSocket, but we do not yet add that todo list to the app state nor even try to render todo lists (nor tasks) that exist in the state store. --- messages.go | 37 +++++- pg_types.go | 6 + room.go | 77 +++++++++++-- src/components/App.js | 3 + src/components/layout/RightPanel.js | 43 +++++++ src/components/right_panel/TodoListInput.js | 121 ++++++++++++++++++++ src/epics/helpers/ChatHandler.js | 34 +++++- src/reducers/index.js | 2 + src/reducers/taskReducer.js | 29 +++++ src/static/sass/_layout.scss | 5 +- 10 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 src/components/layout/RightPanel.js create mode 100644 src/components/right_panel/TodoListInput.js create mode 100644 src/reducers/taskReducer.js diff --git a/messages.go b/messages.go index 2000860..4a4ca83 100644 --- a/messages.go +++ b/messages.go @@ -11,6 +11,10 @@ import ( type Message []byte +type TodoList struct { + TitleEnc string `json:"title_enc"` // base64-encoded, encrypted title +} + type OutgoingPayload struct { Ephemeral []Message `json:"ephemeral"` FromServer FromServer `json:"from_server,omitempty"` @@ -26,8 +30,10 @@ type ToServer struct { } type IncomingPayload struct { - Ephemeral []Message `json:"ephemeral"` - ToServer ToServer `json:"to_server"` + Ephemeral []Message `json:"ephemeral"` + TodoLists []TodoList `json:"todo_lists"` + // Tasks []string `json:"tasks"` + ToServer ToServer `json:"to_server"` } func WSMessagesHandler(rooms *RoomManager) func(w http.ResponseWriter, r *http.Request) { @@ -92,12 +98,31 @@ func messageReader(room *Room, client *Client) { continue } - err = room.AddMessages(payload.Ephemeral, payload.ToServer.TTL) - if err != nil { - log.Debugf("Error from AddMessages: %v", err) - continue + if len(payload.Ephemeral) > 0 { + err = room.AddMessages(payload.Ephemeral, payload.ToServer.TTL) + if err != nil { + log.Debugf("Error from AddMessages: %v", err) + continue + } } + if len(payload.TodoLists) > 0 { + jsonToBroadcast, err := room.AddTodoLists(payload.TodoLists) + if err != nil { + log.Debugf("Error from AddTodoLists: %v", err) + continue + } + room.BroadcastJSON(client, jsonToBroadcast) + } + + // if len(payload.Tasks) > 0 { + // err = room.AddTasks(payload.Tasks) + // if err != nil { + // log.Debugf("Error from AddTasks: %v", err) + // continue + // } + // } + room.BroadcastMessages(client, payload.Ephemeral...) case websocket.BinaryMessage: diff --git a/pg_types.go b/pg_types.go index 83b9bd7..475f758 100644 --- a/pg_types.go +++ b/pg_types.go @@ -127,6 +127,12 @@ type PGMessage struct { Created *time.Time `json:"created,omitempty"` } +type PGTodoList struct { + ID *string `json:"id,omitempty"` + RoomID string `json:"room_id"` + TitleEnc string `json:"title_enc"` +} + type pgPostMessage PGMessage func (msg *PGMessage) MarshalJSON() ([]byte, error) { diff --git a/room.go b/room.go index 754ebfd..5f1044f 100644 --- a/room.go +++ b/room.go @@ -1,11 +1,14 @@ package main import ( + "bytes" "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io/ioutil" + "net/http" "sync" "time" @@ -110,17 +113,57 @@ func (r *Room) GetMessages() ([]Message, error) { } func (r *Room) AddMessages(msgs []Message, ttlSecs *int) error { - post := make(PGMessages, len(msgs)) + toPost := make(PGMessages, len(msgs)) for i := 0; i < len(msgs); i++ { - post[i] = &PGMessage{ + toPost[i] = &PGMessage{ RoomID: r.ID, MessageEnc: string(msgs[i]), TTL: ttlSecs, } } - return post.Create(r.pgClient) + return toPost.Create(r.pgClient) +} + +func (r *Room) AddTodoLists(lists []TodoList) (toBroadcast []byte, err error) { + toPost := make([]PGTodoList, len(lists)) + + for i := 0; i < len(lists); i++ { + toPost[i].RoomID = r.ID + toPost[i].TitleEnc = lists[i].TitleEnc + } + + return MarshalToPostgrestResp("/todo_lists", toPost, http.StatusCreated) +} + +func MarshalToPostgrestResp(urlSuffix string, toPost interface{}, wantedCode int) (respBytes []byte, err error) { + toPostBytes, err := json.Marshal(toPost) + if err != nil { + return nil, err + } + + r := bytes.NewReader(toPostBytes) + req, _ := http.NewRequest("POST", POSTGREST_BASE_URL+urlSuffix, r) + req.Header.Add("Prefer", "return=representation") + req.Header.Add("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBytes, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != wantedCode { + return nil, errors.New(string(respBytes)) + } + + return } func byteaToBytes(hexdata string) ([]byte, error) { @@ -183,6 +226,20 @@ func (r *Room) BroadcastMessages(sender *Client, msgs ...Message) { } } +func (r *Room) BroadcastJSON(sender *Client, jsonToBroadcast []byte) { + r.clientLock.RLock() + defer r.clientLock.RUnlock() + + for _, client := range r.Clients { + go func(client *Client) { + err := client.SendJSON(jsonToBroadcast) + if err != nil { + log.Debugf("Error sending message. Err: %s", err) + } + }(client) + } +} + func (r *Room) DeleteAllMessages() error { resp, err := r.pgClient.Delete("/messages?room_id=eq." + r.ID) if err != nil { @@ -224,9 +281,6 @@ type Client struct { } func (c *Client) SendMessages(msgs ...Message) error { - c.writeLock.Lock() - defer c.writeLock.Unlock() - outgoing := OutgoingPayload{Ephemeral: msgs} body, err := json.Marshal(outgoing) @@ -234,9 +288,16 @@ func (c *Client) SendMessages(msgs ...Message) error { return err } - err = c.wsConn.WriteMessage(websocket.TextMessage, body) + return c.SendJSON(body) +} + +func (c *Client) SendJSON(body []byte) error { + c.writeLock.Lock() + defer c.writeLock.Unlock() + + err := c.wsConn.WriteMessage(websocket.TextMessage, body) if err != nil { - log.Debugf("Error sending message to client. Removing client from room. Err: %s", err) + log.Debugf("Error sending JSON to client. Removing client from room. Err: %s", err) c.room.RemoveClient(c) return err } diff --git a/src/components/App.js b/src/components/App.js index eab8253..55615b7 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -12,6 +12,7 @@ import { } from '../actions/alertActions'; import Header from './layout/Header'; +import RightPanel from './layout/RightPanel'; import ChatContainer from './chat/ChatContainer'; @@ -170,6 +171,8 @@ class App extends Component { isAudioEnabled={isAudioEnabled} onSetIsAudioEnabled={this.onSetIsAudioEnabled} /> + + diff --git a/src/components/layout/RightPanel.js b/src/components/layout/RightPanel.js new file mode 100644 index 0000000..1baac4a --- /dev/null +++ b/src/components/layout/RightPanel.js @@ -0,0 +1,43 @@ +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import { connect } from 'react-redux'; +import { chatHandler } from '../../epics/chatEpics'; + +import TodoListInput from '../right_panel/TodoListInput'; + + +class RightPanel extends Component { + constructor(props) { + super(props); + + // So we can create todo lists and tasks without 9 layers of + // abstraction + this.getWsConn = chatHandler.getWsConn; + this.getCryptoInfo = chatHandler.getCryptoInfo; + } + + render() { + return ( +
+

Todo Lists

+ + {/* TODO: Iterate over this.props.task.todoLists */} + + +
+ ); + } +} + +const styleRightPanel = { + padding: '16px', + width: '30vw', + minWidth: '300px' +} + +export default connect(({ chat, task }) => ({ chat, task }))(RightPanel); diff --git a/src/components/right_panel/TodoListInput.js b/src/components/right_panel/TodoListInput.js new file mode 100644 index 0000000..d1a146d --- /dev/null +++ b/src/components/right_panel/TodoListInput.js @@ -0,0 +1,121 @@ +import React, { Component } from 'react'; + +import miniLock from '../../utils/miniLock'; + + +class TodoListInput extends Component { + constructor(props) { + super(props); + + this.state = { + title: '' + } + } + + createTodoList = (e) => { + const title = this.state.title; + if (!title.trim()) { + return; + } + + console.log("Creating todo list with title `%s`", title); + + let contents = { + title: title, + }; + let fileBlob = new Blob([JSON.stringify(contents)], + {type: 'application/json'}) + let saveName = [ + 'from:' + this.props.chat.username, + 'type:tasklist' + ].join('|||'); + + fileBlob.name = saveName; + + console.log("Encrypting file blob"); + + const { mID, secretKey } = this.props.getCryptoInfo(); + + miniLock.crypto.encryptFile( + fileBlob, + saveName, + [mID], + mID, + secretKey, + this.sendTodoListToServer + ); + } + + sendTodoListToServer = (fileBlob, saveName, senderMinilockID) => { + const that = this; + + const reader = new FileReader(); + reader.addEventListener("loadend", function() { + // From https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string#comment55137593_11562550 + const b64encMinilockFile = btoa([].reduce.call( + new Uint8Array(reader.result), + function(p, c) { + return p + String.fromCharCode(c) + }, '')); + + const forServer = { + todo_lists: [{ + title_enc: b64encMinilockFile + }] + }; + + // ASSUMPTION: getWsConn() !== undefined + that.props.getWsConn().send( JSON.stringify(forServer) ); + }) + + reader.readAsArrayBuffer(fileBlob); // TODO: Add error handling + } + + onTitleChange = (e) => { + this.setState({ title: e.target.value }); + }; + + render() { + return ( +
+

New Todo List

+ + + + + {/* TODO: Replace button using Bootstrap */} + + +
+ ); + } +} + +const styleTodoListInputCtn = { + display: 'flex', + flexDirection: 'column' +} + +const styleTodoListInputRow = { + display: 'flex', + flexDirection: 'row' +} + +const styleTodoListInput = { + width: '100%', + height: '36px', + lineHeight: '32px', + border: 'solid 1px #eee', + borderRadius: '7px', + paddingLeft: '8px' +} + +export default TodoListInput; diff --git a/src/epics/helpers/ChatHandler.js b/src/epics/helpers/ChatHandler.js index c360613..d5aaf07 100644 --- a/src/epics/helpers/ChatHandler.js +++ b/src/epics/helpers/ChatHandler.js @@ -36,7 +36,13 @@ class ChatHandler { onWsMessage = (event) => { const data = JSON.parse(event.data); - if (data && data.ephemeral && data.ephemeral.length && data.ephemeral.length > 0) { + console.log('onWsMessage:', {data}); + + if (!data) { + return; + } + + if (data.ephemeral && data.ephemeral.length && data.ephemeral.length > 0) { Observable.from(data.ephemeral) .mergeMap(ephemeral => this.createDecryptEphemeralObservable({ ephemeral, mID: this.mID, secretKey: this.secretKey })) @@ -44,7 +50,20 @@ class ChatHandler { .catch(error => console.error('An error occurred in ChatHandler', error)) .subscribe(); } - if (data && data.from_server) { + + if (data.todo_lists && data.todo_lists.length && data.todo_lists.length > 0) { + console.log('onWsMessage: Got', data.todo_lists.length, 'todo lists!'); + + // TODO: Decrypt each member of data.todo_lists + } + + if (data.tasks && data.tasks.length && data.tasks.length > 0) { + console.log('onWsMessage: Got', data.tasks.length, 'tasks!'); + + // TODO: Decrypt each member of data.tasks + } + + if (data.from_server) { if (data.from_server.all_messages_deleted) { alert("All messages deleted from server! (Refresh this page to remove them from this browser tab.)"); } @@ -222,6 +241,17 @@ class ChatHandler { return this.wsUserStatusSubject; } + getWsConn = () => { + return this.ws; + } + + getCryptoInfo = () => { + return { + mID: this.mID, + secretKey: this.secretKey + }; + } + } export default ChatHandler; diff --git a/src/reducers/index.js b/src/reducers/index.js index 7e936d1..faa4398 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,8 +1,10 @@ import { combineReducers } from 'redux'; import chatReducer from './chatReducer'; +import taskReducer from './taskReducer'; import alertReducer from './alertReducer'; export default combineReducers({ chat: chatReducer, + task: taskReducer, alert: alertReducer }); diff --git a/src/reducers/taskReducer.js b/src/reducers/taskReducer.js new file mode 100644 index 0000000..20989ca --- /dev/null +++ b/src/reducers/taskReducer.js @@ -0,0 +1,29 @@ +const initialState = { + todoLists: [], + taskMap: {} +}; + +function taskReducer(state = initialState, action) { + + switch (action.type) { + + // TODO: Accept new encrypted todo list or task from server, + // decrypt, update state + + // case 'TASK_CREATE_NEW_TASK': + // return { + // ...state, + // taskMap: { + // ...state.taskMap, + // [action.payload.id]: { + // title: '', + // } + // } + // }; + + default: + return state; + } +} + +export default taskReducer; diff --git a/src/static/sass/_layout.scss b/src/static/sass/_layout.scss index 8a818cc..f17e108 100644 --- a/src/static/sass/_layout.scss +++ b/src/static/sass/_layout.scss @@ -269,10 +269,11 @@ main { flex-direction: row; justify-content: start; height: 100vh; - width: 50vw !important; .content { - max-width: 100%; + width: calc(100% - 306px - 300px); + max-width: calc(100% - 306px - 300px); + .message-box{ // scroll with flexbox needs revisiting // for now, message box is viewport height From 42cff7f772c1d1881e13e31239ffa4d16911a185 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:26:28 -0800 Subject: [PATCH 04/13] Added 10 half-colons --- src/components/layout/RightPanel.js | 2 +- src/components/right_panel/TodoListInput.js | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/layout/RightPanel.js b/src/components/layout/RightPanel.js index 1baac4a..1d2e552 100644 --- a/src/components/layout/RightPanel.js +++ b/src/components/layout/RightPanel.js @@ -38,6 +38,6 @@ const styleRightPanel = { padding: '16px', width: '30vw', minWidth: '300px' -} +}; export default connect(({ chat, task }) => ({ chat, task }))(RightPanel); diff --git a/src/components/right_panel/TodoListInput.js b/src/components/right_panel/TodoListInput.js index d1a146d..898108a 100644 --- a/src/components/right_panel/TodoListInput.js +++ b/src/components/right_panel/TodoListInput.js @@ -9,7 +9,7 @@ class TodoListInput extends Component { this.state = { title: '' - } + }; } createTodoList = (e) => { @@ -24,7 +24,7 @@ class TodoListInput extends Component { title: title, }; let fileBlob = new Blob([JSON.stringify(contents)], - {type: 'application/json'}) + {type: 'application/json'}); let saveName = [ 'from:' + this.props.chat.username, 'type:tasklist' @@ -44,7 +44,7 @@ class TodoListInput extends Component { secretKey, this.sendTodoListToServer ); - } + }; sendTodoListToServer = (fileBlob, saveName, senderMinilockID) => { const that = this; @@ -55,7 +55,7 @@ class TodoListInput extends Component { const b64encMinilockFile = btoa([].reduce.call( new Uint8Array(reader.result), function(p, c) { - return p + String.fromCharCode(c) + return p + String.fromCharCode(c); }, '')); const forServer = { @@ -66,10 +66,10 @@ class TodoListInput extends Component { // ASSUMPTION: getWsConn() !== undefined that.props.getWsConn().send( JSON.stringify(forServer) ); - }) + }); reader.readAsArrayBuffer(fileBlob); // TODO: Add error handling - } + }; onTitleChange = (e) => { this.setState({ title: e.target.value }); @@ -102,12 +102,12 @@ class TodoListInput extends Component { const styleTodoListInputCtn = { display: 'flex', flexDirection: 'column' -} +}; const styleTodoListInputRow = { display: 'flex', flexDirection: 'row' -} +}; const styleTodoListInput = { width: '100%', @@ -116,6 +116,6 @@ const styleTodoListInput = { border: 'solid 1px #eee', borderRadius: '7px', paddingLeft: '8px' -} +}; export default TodoListInput; From 5c75218d51aa6e6f73bf56b4b35ccdefc134b1f6 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:29:26 -0800 Subject: [PATCH 05/13] Linter: Fixed indentation, plus let => const where appropriate --- src/components/right_panel/TodoListInput.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/right_panel/TodoListInput.js b/src/components/right_panel/TodoListInput.js index 898108a..97f4213 100644 --- a/src/components/right_panel/TodoListInput.js +++ b/src/components/right_panel/TodoListInput.js @@ -20,12 +20,16 @@ class TodoListInput extends Component { console.log("Creating todo list with title `%s`", title); - let contents = { + const contents = { title: title, }; - let fileBlob = new Blob([JSON.stringify(contents)], - {type: 'application/json'}); - let saveName = [ + + const fileBlob = new Blob( + [JSON.stringify(contents)], + {type: 'application/json'} + ); + + const saveName = [ 'from:' + this.props.chat.username, 'type:tasklist' ].join('|||'); From 3981269b5c49ff1773f0800b70be4d6db3dd9dfc Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:51:05 -0800 Subject: [PATCH 06/13] Layout/responsiveness: Better width --- src/static/sass/_layout.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/static/sass/_layout.scss b/src/static/sass/_layout.scss index f17e108..e38aace 100644 --- a/src/static/sass/_layout.scss +++ b/src/static/sass/_layout.scss @@ -272,7 +272,6 @@ main { .content { width: calc(100% - 306px - 300px); - max-width: calc(100% - 306px - 300px); .message-box{ // scroll with flexbox needs revisiting From 88e46d08ee9cc670c6cf085025659b0d8c47c815 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Mon, 13 Feb 2023 14:13:21 -0800 Subject: [PATCH 07/13] Task management: Added SQL tables for storing todo lists and tasks Contributes mightily to #264 --- db/sql/table03_todo_lists.sql | 13 +++++++++++++ db/sql/table04_tasks.sql | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 db/sql/table03_todo_lists.sql create mode 100644 db/sql/table04_tasks.sql diff --git a/db/sql/table03_todo_lists.sql b/db/sql/table03_todo_lists.sql new file mode 100644 index 0000000..d8b5364 --- /dev/null +++ b/db/sql/table03_todo_lists.sql @@ -0,0 +1,13 @@ +CREATE TABLE todo_lists ( + id uuid NOT NULL UNIQUE PRIMARY KEY DEFAULT uuid_generate_v4(), + room_id text NOT NULL REFERENCES rooms ON DELETE CASCADE, + + -- This is called 'title_enc' (encrypted title) but stores + -- miniLock ciphertext, which can also store metadata + title_enc text NOT NULL -- base64-encoded ciphertext + + -- ASSUMPTION: todo lists don't expire and must be manually deleted by the user + + -- ASSUMPTION: no timestamp needed; better for user metadata privacy to not store it +); +ALTER TABLE todo_lists OWNER TO superuser; diff --git a/db/sql/table04_tasks.sql b/db/sql/table04_tasks.sql new file mode 100644 index 0000000..c8b812a --- /dev/null +++ b/db/sql/table04_tasks.sql @@ -0,0 +1,16 @@ +CREATE TABLE tasks ( + id uuid NOT NULL UNIQUE PRIMARY KEY DEFAULT uuid_generate_v4(), + room_id text NOT NULL REFERENCES rooms ON DELETE CASCADE, + + -- This is called 'title_enc' (encrypted title) but stores + -- miniLock ciphertext, which can also store metadata + title_enc text NOT NULL, -- base64-encoded ciphertext + + list_id text NOT NULL REFERENCES todo_lists ON DELETE CASCADE, + index double precision NOT NULL -- index in the list with list_id + + -- ASSUMPTION: tasks don't expire and must be manually deleted by the user + + -- ASSUMPTION: no timestamp needed; better for user metadata privacy to not store it +); +ALTER TABLE tasks OWNER TO superuser; From 5b83d4a9c05780bf5091eef9b716fc2eadbc482e Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Wed, 15 Feb 2023 23:12:32 -0800 Subject: [PATCH 08/13] README.md: Use postgrest v10.1.1, not v7.0.0 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d310575..260dce3 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ cd $(go env GOPATH)/src/github.com/cryptag/leapchat/db chmod a+rx ~/ createdb sudo -u $USER bash init_sql.sh -wget https://github.com/PostgREST/postgrest/releases/download/v7.0.0/postgrest-v7.0.0-osx.tar.xz -tar xvf postgrest-v7.0.0-osx.tar.xz +wget https://github.com/PostgREST/postgrest/releases/download/v10.1.1/postgrest-v10.1.1-macos-x64.tar.xz +tar xvf postgrest-v10.1.1-macos-x64.tar.xz ./postgrest postgrest.conf ``` @@ -157,8 +157,8 @@ and have `postgrest` connect to Postgres: cd $(go env GOPATH)/src/github.com/cryptag/leapchat/db chmod a+rx ~/ sudo -u postgres bash init_sql.sh -wget https://github.com/PostgREST/postgrest/releases/download/v7.0.0/postgrest-v7.0.0-ubuntu.tar.xz -tar xvf postgrest-v7.0.0-ubuntu.tar.xz +wget https://github.com/PostgREST/postgrest/releases/download/v10.1.1/postgrest-v10.1.1-linux-static-x64.tar.xz +tar xvf postgrest-v10.1.1-linux-static-x64.tar.xz ./postgrest postgrest.conf ``` From 375401b02d22105123699b6ccab6589899e974cb Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:15:16 -0800 Subject: [PATCH 09/13] WIP: RightPanel exists + Todo list creation works! :tada: Gets encrypted, added to DB correctly. The correctly-created todo list even gets send to us from Go over the WebSocket, but we do not yet add that todo list to the app state nor even try to render todo lists (nor tasks) that exist in the state store. --- messages.go | 37 +++++- pg_types.go | 6 + room.go | 77 +++++++++++-- src/components/App.js | 3 + src/components/layout/RightPanel.js | 43 +++++++ src/components/right_panel/TodoListInput.js | 121 ++++++++++++++++++++ src/epics/helpers/ChatHandler.js | 34 +++++- src/reducers/index.js | 2 + src/reducers/taskReducer.js | 29 +++++ src/static/sass/_layout.scss | 5 +- 10 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 src/components/layout/RightPanel.js create mode 100644 src/components/right_panel/TodoListInput.js create mode 100644 src/reducers/taskReducer.js diff --git a/messages.go b/messages.go index 2000860..4a4ca83 100644 --- a/messages.go +++ b/messages.go @@ -11,6 +11,10 @@ import ( type Message []byte +type TodoList struct { + TitleEnc string `json:"title_enc"` // base64-encoded, encrypted title +} + type OutgoingPayload struct { Ephemeral []Message `json:"ephemeral"` FromServer FromServer `json:"from_server,omitempty"` @@ -26,8 +30,10 @@ type ToServer struct { } type IncomingPayload struct { - Ephemeral []Message `json:"ephemeral"` - ToServer ToServer `json:"to_server"` + Ephemeral []Message `json:"ephemeral"` + TodoLists []TodoList `json:"todo_lists"` + // Tasks []string `json:"tasks"` + ToServer ToServer `json:"to_server"` } func WSMessagesHandler(rooms *RoomManager) func(w http.ResponseWriter, r *http.Request) { @@ -92,12 +98,31 @@ func messageReader(room *Room, client *Client) { continue } - err = room.AddMessages(payload.Ephemeral, payload.ToServer.TTL) - if err != nil { - log.Debugf("Error from AddMessages: %v", err) - continue + if len(payload.Ephemeral) > 0 { + err = room.AddMessages(payload.Ephemeral, payload.ToServer.TTL) + if err != nil { + log.Debugf("Error from AddMessages: %v", err) + continue + } } + if len(payload.TodoLists) > 0 { + jsonToBroadcast, err := room.AddTodoLists(payload.TodoLists) + if err != nil { + log.Debugf("Error from AddTodoLists: %v", err) + continue + } + room.BroadcastJSON(client, jsonToBroadcast) + } + + // if len(payload.Tasks) > 0 { + // err = room.AddTasks(payload.Tasks) + // if err != nil { + // log.Debugf("Error from AddTasks: %v", err) + // continue + // } + // } + room.BroadcastMessages(client, payload.Ephemeral...) case websocket.BinaryMessage: diff --git a/pg_types.go b/pg_types.go index 83b9bd7..475f758 100644 --- a/pg_types.go +++ b/pg_types.go @@ -127,6 +127,12 @@ type PGMessage struct { Created *time.Time `json:"created,omitempty"` } +type PGTodoList struct { + ID *string `json:"id,omitempty"` + RoomID string `json:"room_id"` + TitleEnc string `json:"title_enc"` +} + type pgPostMessage PGMessage func (msg *PGMessage) MarshalJSON() ([]byte, error) { diff --git a/room.go b/room.go index 754ebfd..5f1044f 100644 --- a/room.go +++ b/room.go @@ -1,11 +1,14 @@ package main import ( + "bytes" "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io/ioutil" + "net/http" "sync" "time" @@ -110,17 +113,57 @@ func (r *Room) GetMessages() ([]Message, error) { } func (r *Room) AddMessages(msgs []Message, ttlSecs *int) error { - post := make(PGMessages, len(msgs)) + toPost := make(PGMessages, len(msgs)) for i := 0; i < len(msgs); i++ { - post[i] = &PGMessage{ + toPost[i] = &PGMessage{ RoomID: r.ID, MessageEnc: string(msgs[i]), TTL: ttlSecs, } } - return post.Create(r.pgClient) + return toPost.Create(r.pgClient) +} + +func (r *Room) AddTodoLists(lists []TodoList) (toBroadcast []byte, err error) { + toPost := make([]PGTodoList, len(lists)) + + for i := 0; i < len(lists); i++ { + toPost[i].RoomID = r.ID + toPost[i].TitleEnc = lists[i].TitleEnc + } + + return MarshalToPostgrestResp("/todo_lists", toPost, http.StatusCreated) +} + +func MarshalToPostgrestResp(urlSuffix string, toPost interface{}, wantedCode int) (respBytes []byte, err error) { + toPostBytes, err := json.Marshal(toPost) + if err != nil { + return nil, err + } + + r := bytes.NewReader(toPostBytes) + req, _ := http.NewRequest("POST", POSTGREST_BASE_URL+urlSuffix, r) + req.Header.Add("Prefer", "return=representation") + req.Header.Add("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBytes, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != wantedCode { + return nil, errors.New(string(respBytes)) + } + + return } func byteaToBytes(hexdata string) ([]byte, error) { @@ -183,6 +226,20 @@ func (r *Room) BroadcastMessages(sender *Client, msgs ...Message) { } } +func (r *Room) BroadcastJSON(sender *Client, jsonToBroadcast []byte) { + r.clientLock.RLock() + defer r.clientLock.RUnlock() + + for _, client := range r.Clients { + go func(client *Client) { + err := client.SendJSON(jsonToBroadcast) + if err != nil { + log.Debugf("Error sending message. Err: %s", err) + } + }(client) + } +} + func (r *Room) DeleteAllMessages() error { resp, err := r.pgClient.Delete("/messages?room_id=eq." + r.ID) if err != nil { @@ -224,9 +281,6 @@ type Client struct { } func (c *Client) SendMessages(msgs ...Message) error { - c.writeLock.Lock() - defer c.writeLock.Unlock() - outgoing := OutgoingPayload{Ephemeral: msgs} body, err := json.Marshal(outgoing) @@ -234,9 +288,16 @@ func (c *Client) SendMessages(msgs ...Message) error { return err } - err = c.wsConn.WriteMessage(websocket.TextMessage, body) + return c.SendJSON(body) +} + +func (c *Client) SendJSON(body []byte) error { + c.writeLock.Lock() + defer c.writeLock.Unlock() + + err := c.wsConn.WriteMessage(websocket.TextMessage, body) if err != nil { - log.Debugf("Error sending message to client. Removing client from room. Err: %s", err) + log.Debugf("Error sending JSON to client. Removing client from room. Err: %s", err) c.room.RemoveClient(c) return err } diff --git a/src/components/App.js b/src/components/App.js index eab8253..55615b7 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -12,6 +12,7 @@ import { } from '../actions/alertActions'; import Header from './layout/Header'; +import RightPanel from './layout/RightPanel'; import ChatContainer from './chat/ChatContainer'; @@ -170,6 +171,8 @@ class App extends Component { isAudioEnabled={isAudioEnabled} onSetIsAudioEnabled={this.onSetIsAudioEnabled} /> + + diff --git a/src/components/layout/RightPanel.js b/src/components/layout/RightPanel.js new file mode 100644 index 0000000..1baac4a --- /dev/null +++ b/src/components/layout/RightPanel.js @@ -0,0 +1,43 @@ +import React, { Component } from 'react'; +import { PropTypes } from 'prop-types'; +import { connect } from 'react-redux'; +import { chatHandler } from '../../epics/chatEpics'; + +import TodoListInput from '../right_panel/TodoListInput'; + + +class RightPanel extends Component { + constructor(props) { + super(props); + + // So we can create todo lists and tasks without 9 layers of + // abstraction + this.getWsConn = chatHandler.getWsConn; + this.getCryptoInfo = chatHandler.getCryptoInfo; + } + + render() { + return ( +
+

Todo Lists

+ + {/* TODO: Iterate over this.props.task.todoLists */} + + +
+ ); + } +} + +const styleRightPanel = { + padding: '16px', + width: '30vw', + minWidth: '300px' +} + +export default connect(({ chat, task }) => ({ chat, task }))(RightPanel); diff --git a/src/components/right_panel/TodoListInput.js b/src/components/right_panel/TodoListInput.js new file mode 100644 index 0000000..d1a146d --- /dev/null +++ b/src/components/right_panel/TodoListInput.js @@ -0,0 +1,121 @@ +import React, { Component } from 'react'; + +import miniLock from '../../utils/miniLock'; + + +class TodoListInput extends Component { + constructor(props) { + super(props); + + this.state = { + title: '' + } + } + + createTodoList = (e) => { + const title = this.state.title; + if (!title.trim()) { + return; + } + + console.log("Creating todo list with title `%s`", title); + + let contents = { + title: title, + }; + let fileBlob = new Blob([JSON.stringify(contents)], + {type: 'application/json'}) + let saveName = [ + 'from:' + this.props.chat.username, + 'type:tasklist' + ].join('|||'); + + fileBlob.name = saveName; + + console.log("Encrypting file blob"); + + const { mID, secretKey } = this.props.getCryptoInfo(); + + miniLock.crypto.encryptFile( + fileBlob, + saveName, + [mID], + mID, + secretKey, + this.sendTodoListToServer + ); + } + + sendTodoListToServer = (fileBlob, saveName, senderMinilockID) => { + const that = this; + + const reader = new FileReader(); + reader.addEventListener("loadend", function() { + // From https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string#comment55137593_11562550 + const b64encMinilockFile = btoa([].reduce.call( + new Uint8Array(reader.result), + function(p, c) { + return p + String.fromCharCode(c) + }, '')); + + const forServer = { + todo_lists: [{ + title_enc: b64encMinilockFile + }] + }; + + // ASSUMPTION: getWsConn() !== undefined + that.props.getWsConn().send( JSON.stringify(forServer) ); + }) + + reader.readAsArrayBuffer(fileBlob); // TODO: Add error handling + } + + onTitleChange = (e) => { + this.setState({ title: e.target.value }); + }; + + render() { + return ( +
+

New Todo List

+ + + + + {/* TODO: Replace button using Bootstrap */} + + +
+ ); + } +} + +const styleTodoListInputCtn = { + display: 'flex', + flexDirection: 'column' +} + +const styleTodoListInputRow = { + display: 'flex', + flexDirection: 'row' +} + +const styleTodoListInput = { + width: '100%', + height: '36px', + lineHeight: '32px', + border: 'solid 1px #eee', + borderRadius: '7px', + paddingLeft: '8px' +} + +export default TodoListInput; diff --git a/src/epics/helpers/ChatHandler.js b/src/epics/helpers/ChatHandler.js index c360613..d5aaf07 100644 --- a/src/epics/helpers/ChatHandler.js +++ b/src/epics/helpers/ChatHandler.js @@ -36,7 +36,13 @@ class ChatHandler { onWsMessage = (event) => { const data = JSON.parse(event.data); - if (data && data.ephemeral && data.ephemeral.length && data.ephemeral.length > 0) { + console.log('onWsMessage:', {data}); + + if (!data) { + return; + } + + if (data.ephemeral && data.ephemeral.length && data.ephemeral.length > 0) { Observable.from(data.ephemeral) .mergeMap(ephemeral => this.createDecryptEphemeralObservable({ ephemeral, mID: this.mID, secretKey: this.secretKey })) @@ -44,7 +50,20 @@ class ChatHandler { .catch(error => console.error('An error occurred in ChatHandler', error)) .subscribe(); } - if (data && data.from_server) { + + if (data.todo_lists && data.todo_lists.length && data.todo_lists.length > 0) { + console.log('onWsMessage: Got', data.todo_lists.length, 'todo lists!'); + + // TODO: Decrypt each member of data.todo_lists + } + + if (data.tasks && data.tasks.length && data.tasks.length > 0) { + console.log('onWsMessage: Got', data.tasks.length, 'tasks!'); + + // TODO: Decrypt each member of data.tasks + } + + if (data.from_server) { if (data.from_server.all_messages_deleted) { alert("All messages deleted from server! (Refresh this page to remove them from this browser tab.)"); } @@ -222,6 +241,17 @@ class ChatHandler { return this.wsUserStatusSubject; } + getWsConn = () => { + return this.ws; + } + + getCryptoInfo = () => { + return { + mID: this.mID, + secretKey: this.secretKey + }; + } + } export default ChatHandler; diff --git a/src/reducers/index.js b/src/reducers/index.js index 7e936d1..faa4398 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,8 +1,10 @@ import { combineReducers } from 'redux'; import chatReducer from './chatReducer'; +import taskReducer from './taskReducer'; import alertReducer from './alertReducer'; export default combineReducers({ chat: chatReducer, + task: taskReducer, alert: alertReducer }); diff --git a/src/reducers/taskReducer.js b/src/reducers/taskReducer.js new file mode 100644 index 0000000..20989ca --- /dev/null +++ b/src/reducers/taskReducer.js @@ -0,0 +1,29 @@ +const initialState = { + todoLists: [], + taskMap: {} +}; + +function taskReducer(state = initialState, action) { + + switch (action.type) { + + // TODO: Accept new encrypted todo list or task from server, + // decrypt, update state + + // case 'TASK_CREATE_NEW_TASK': + // return { + // ...state, + // taskMap: { + // ...state.taskMap, + // [action.payload.id]: { + // title: '', + // } + // } + // }; + + default: + return state; + } +} + +export default taskReducer; diff --git a/src/static/sass/_layout.scss b/src/static/sass/_layout.scss index d71ca2f..be8cfaa 100644 --- a/src/static/sass/_layout.scss +++ b/src/static/sass/_layout.scss @@ -269,10 +269,11 @@ main { flex-direction: row; justify-content: start; height: 100vh; - width: 50vw !important; .content { - max-width: 100%; + width: calc(100% - 306px - 300px); + max-width: calc(100% - 306px - 300px); + .message-box{ // scroll with flexbox needs revisiting // for now, message box is viewport height From 53a96305fedee245f7643962a81ef804716fd343 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:26:28 -0800 Subject: [PATCH 10/13] Added 10 half-colons --- src/components/layout/RightPanel.js | 2 +- src/components/right_panel/TodoListInput.js | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/layout/RightPanel.js b/src/components/layout/RightPanel.js index 1baac4a..1d2e552 100644 --- a/src/components/layout/RightPanel.js +++ b/src/components/layout/RightPanel.js @@ -38,6 +38,6 @@ const styleRightPanel = { padding: '16px', width: '30vw', minWidth: '300px' -} +}; export default connect(({ chat, task }) => ({ chat, task }))(RightPanel); diff --git a/src/components/right_panel/TodoListInput.js b/src/components/right_panel/TodoListInput.js index d1a146d..898108a 100644 --- a/src/components/right_panel/TodoListInput.js +++ b/src/components/right_panel/TodoListInput.js @@ -9,7 +9,7 @@ class TodoListInput extends Component { this.state = { title: '' - } + }; } createTodoList = (e) => { @@ -24,7 +24,7 @@ class TodoListInput extends Component { title: title, }; let fileBlob = new Blob([JSON.stringify(contents)], - {type: 'application/json'}) + {type: 'application/json'}); let saveName = [ 'from:' + this.props.chat.username, 'type:tasklist' @@ -44,7 +44,7 @@ class TodoListInput extends Component { secretKey, this.sendTodoListToServer ); - } + }; sendTodoListToServer = (fileBlob, saveName, senderMinilockID) => { const that = this; @@ -55,7 +55,7 @@ class TodoListInput extends Component { const b64encMinilockFile = btoa([].reduce.call( new Uint8Array(reader.result), function(p, c) { - return p + String.fromCharCode(c) + return p + String.fromCharCode(c); }, '')); const forServer = { @@ -66,10 +66,10 @@ class TodoListInput extends Component { // ASSUMPTION: getWsConn() !== undefined that.props.getWsConn().send( JSON.stringify(forServer) ); - }) + }); reader.readAsArrayBuffer(fileBlob); // TODO: Add error handling - } + }; onTitleChange = (e) => { this.setState({ title: e.target.value }); @@ -102,12 +102,12 @@ class TodoListInput extends Component { const styleTodoListInputCtn = { display: 'flex', flexDirection: 'column' -} +}; const styleTodoListInputRow = { display: 'flex', flexDirection: 'row' -} +}; const styleTodoListInput = { width: '100%', @@ -116,6 +116,6 @@ const styleTodoListInput = { border: 'solid 1px #eee', borderRadius: '7px', paddingLeft: '8px' -} +}; export default TodoListInput; From 0d6aa866a62c33ac140d060928f39051825d5eda Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:29:26 -0800 Subject: [PATCH 11/13] Linter: Fixed indentation, plus let => const where appropriate --- src/components/right_panel/TodoListInput.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/right_panel/TodoListInput.js b/src/components/right_panel/TodoListInput.js index 898108a..97f4213 100644 --- a/src/components/right_panel/TodoListInput.js +++ b/src/components/right_panel/TodoListInput.js @@ -20,12 +20,16 @@ class TodoListInput extends Component { console.log("Creating todo list with title `%s`", title); - let contents = { + const contents = { title: title, }; - let fileBlob = new Blob([JSON.stringify(contents)], - {type: 'application/json'}); - let saveName = [ + + const fileBlob = new Blob( + [JSON.stringify(contents)], + {type: 'application/json'} + ); + + const saveName = [ 'from:' + this.props.chat.username, 'type:tasklist' ].join('|||'); From 34c488383eb67576d91a3646e5345abb137b9701 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Sun, 19 Feb 2023 03:51:05 -0800 Subject: [PATCH 12/13] Layout/responsiveness: Better width --- src/static/sass/_layout.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/static/sass/_layout.scss b/src/static/sass/_layout.scss index be8cfaa..e0307c4 100644 --- a/src/static/sass/_layout.scss +++ b/src/static/sass/_layout.scss @@ -272,7 +272,6 @@ main { .content { width: calc(100% - 306px - 300px); - max-width: calc(100% - 306px - 300px); .message-box{ // scroll with flexbox needs revisiting From af6985debc4f243d4cbad90fa5dddf8b0398ea28 Mon Sep 17 00:00:00 2001 From: Steve Phillips Date: Mon, 20 Feb 2023 16:27:44 -0800 Subject: [PATCH 13/13] db/sql/table04_tasks.sql: Typo fix (referenced id is a `uuid`, not `text` type) --- db/sql/table04_tasks.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/sql/table04_tasks.sql b/db/sql/table04_tasks.sql index c8b812a..e0fb92f 100644 --- a/db/sql/table04_tasks.sql +++ b/db/sql/table04_tasks.sql @@ -6,8 +6,8 @@ CREATE TABLE tasks ( -- miniLock ciphertext, which can also store metadata title_enc text NOT NULL, -- base64-encoded ciphertext - list_id text NOT NULL REFERENCES todo_lists ON DELETE CASCADE, - index double precision NOT NULL -- index in the list with list_id + list_id uuid NOT NULL REFERENCES todo_lists ON DELETE CASCADE, + index double precision NOT NULL -- index in the todo list with list_id -- ASSUMPTION: tasks don't expire and must be manually deleted by the user