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 ``` 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..e0fb92f --- /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 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 + + -- ASSUMPTION: no timestamp needed; better for user metadata privacy to not store it +); +ALTER TABLE tasks OWNER TO superuser; 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..1d2e552 --- /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..97f4213 --- /dev/null +++ b/src/components/right_panel/TodoListInput.js @@ -0,0 +1,125 @@ +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); + + const contents = { + title: title, + }; + + const fileBlob = new Blob( + [JSON.stringify(contents)], + {type: 'application/json'} + ); + + const 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..e0307c4 100644 --- a/src/static/sass/_layout.scss +++ b/src/static/sass/_layout.scss @@ -269,10 +269,10 @@ main { flex-direction: row; justify-content: start; height: 100vh; - width: 50vw !important; .content { - max-width: 100%; + width: calc(100% - 306px - 300px); + .message-box{ // scroll with flexbox needs revisiting // for now, message box is viewport height