pasting attachments

main
Valerie 2022-08-24 22:02:29 -04:00
parent d595cc7373
commit 13aff451a8
36 changed files with 1372 additions and 876 deletions

2
.gitignore vendored
View File

@ -59,3 +59,5 @@ thumbs.db
# docker data
docker-volume
storage

View File

@ -4,6 +4,7 @@ services:
db:
image: mariadb
restart: always
command: --max_allowed_packet=200M
environment:
MARIADB_ROOT_PASSWORD: example
MARIADB_DATABASE: corner
@ -20,15 +21,15 @@ services:
ports:
- 8080:8080
coturn:
image: coturn/coturn
restart: always
ports:
- 3478:3478
- 3478:3478/udp
- 5349:5349
- 5349:5349/udp
- 49160-49200:49160-49200/udp
# coturn:
# image: coturn/coturn
# restart: always
# ports:
# - 3478:3478
# - 3478:3478/udp
# - 5349:5349
# - 5349:5349/udp
# - 49160-49200:49160-49200/udp
# docker run -d -p 3478:3478 -p 3478:3478/udp -p 5349:5349 -p 5349:5349/udp -p 49160-49200:49160-49200/udp \
# coturn/coturn -n --log-file=stdout \

1072
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -55,19 +55,25 @@
"vue-tsc": "0.38.8"
},
"dependencies": {
"@types/express": "^4.17.13",
"@types/express-ws": "^3.0.1",
"@types/mysql": "^2.15.21",
"@types/qrcode": "^1.4.2",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/react-timeago": "^4.1.3",
"@types/tmp": "^0.2.3",
"@types/totp-generator": "^0.0.4",
"@types/uuid": "^8.3.4",
"@types/ws": "^8.5.3",
"@vitejs/plugin-react": "^2.0.0",
"chalk": "^4.1.2",
"cordova": "^11.0.0",
"electron-updater": "5.0.5",
"eslint-plugin-react": "^7.30.1",
"express": "^4.18.1",
"express-ws": "^5.0.2",
"fs-extra": "^10.1.0",
"get-port": "^6.1.2",
"local-storage": "^2.0.0",
"mysql": "^2.18.1",
@ -76,9 +82,12 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.4.0",
"react-spinners": "^0.13.4",
"react-time-ago": "^7.2.1",
"react-timeago": "^7.1.0",
"react-toastify": "^9.0.8",
"reactjs-popup": "^2.0.5",
"tmp": "^0.2.1",
"totp-generator": "^0.0.13",
"uuid": "^8.3.2",
"vue": "3.2.37",

View File

@ -7,7 +7,7 @@
default-src 'self' data: https://ssl.gstatic.com https://fonts.gstatic.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
media-src * data:;
img-src 'self' data: content: http://tinygraphs.com;
img-src 'self' data: content: *;
connect-src *;">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Vite App</title>

View File

@ -7,6 +7,7 @@ import ServerConnection from './components/ServerConnection';
import EphemeralState from './contexts/EphemeralState/EphemeralState';
import PersistentState from '/@/contexts/PersistentState/PersistentState';
import Router from './Router';
import { ToastContainer } from 'react-toastify';
export default function App() {
const [transparent, setTransparent] = useState(false);
@ -64,6 +65,32 @@ export default function App() {
border-radius: 16px;
padding: 0px 8px;
}
/* width */
::-webkit-scrollbar {
width: 16px;
height: 16px;
}
/* Track */
::-webkit-scrollbar-track {
background: unset
}
/* Handle */
::-webkit-scrollbar-thumb {
background: white;
border-radius: 10px;
box-shadow: inset 0px -10px -10px 0px var(--neutral-3);
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
::-webkit-scrollbar-thumb:active {
background: var(--neutral-9);
}
`}</style>
<div style={{
background: transparent ? 'rgba(0, 0, 0, 0)' : 'var(--neutral-3)',
@ -78,6 +105,8 @@ export default function App() {
<Router></Router>
</EphemeralState>
</PersistentState>
<ToastContainer />
</div>
</>
);

View File

@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from "react";
import { v4 } from 'uuid';
import { GrFormClose } from 'react-icons/gr';
import { IoMdClose } from "react-icons/io";
import testImage from '../../assets/pfp.jpg'
import useFileUpload, { Upload } from "../hooks/useFileUpload";
import useChannel from "../hooks/useChannel";
import { useApi } from "../lib/useApi";
const exampleImages = [
{
id: v4(),
name: 'image.jpg',
data: testImage,
}
]
export default function ChatInput() {
const textBoxRef = useRef<HTMLDivElement>(null);
const { channel } = useChannel();
const [attachments, setAttachments] = useState<string[]>([]);
const { newFile, getFileInfo } = useFileUpload();
const PADDING = 8;
const CHATBOX_SIZE = 64;
const addAttachment = (id: string) => {
setAttachments(attachments => [...attachments, id])
}
const removeAttachment = (id: string) => {
setAttachments(attachments => attachments.filter(a => a !== id));
}
// useEffect(() => {
// addAttachment(newFile('image.jpg', testImage));
// }, []);
const pasta: React.ClipboardEventHandler<HTMLDivElement> = (event) => {
let pastedFiles = false;
for (let idx = 0; idx < event.clipboardData.items.length; idx ++) {
const item = event.clipboardData.items[idx];
const file = event.clipboardData.files[idx];
const type = event.clipboardData.types[idx]
if (item.kind === 'file') {
var blob = item.getAsFile();
if(blob === null) continue;
addAttachment(newFile(file.name, file.type, blob));
pastedFiles = true;
}
}
if(pastedFiles) {
event.preventDefault();
}
}
const { send } = useApi({});
const keyPress = (event: any) => {
if(event.code === 'Enter' && !event.shiftKey) {
event.stopPropagation();
event.preventDefault();
event.bubbles = false;
for(const attachment of attachments) {
const info = getFileInfo(attachment);
if(!info?.processed) {
return true;
}
}
const [file, ...restFiles] = attachments.map(a => getFileInfo(a)?.externalId) as string[];
const text = textBoxRef.current?.innerHTML ?? '';
if(text === '' && file === undefined) {
return true;
}
if(channel === null) return true;
const newMessage: NewMessageRequest = {
uid: v4(),
text,
channel,
timestamp: new Date().getTime(),
file
}
console.log(file, restFiles);
send('message:message', newMessage);
for(const file of restFiles) {
const newMessage: NewMessageRequest = {
uid: v4(),
text: '',
channel,
timestamp: new Date().getTime(),
file
}
send('message:message', newMessage);
}
setAttachments([]);
if(textBoxRef.current !== null) {
textBoxRef.current.innerHTML = '';
}
return true;
}
}
return (
<div style ={{
minWidth: '0px',
}}>
<div style={{
overflowX: 'auto',
}}>
{attachments.length > 0 && (
<div style={{
width: '100%',
padding: '8px',
boxSizing: 'border-box',
paddingBottom: '0px',
}}>
<div style={{
whiteSpace: 'nowrap'
}}>
{attachments.map(attachment => {
const info = getFileInfo(attachment);
if(!info) return <span>Poop</span>;
return (
<AttachmentBox
key={attachment}
attachment={info}
onClose={() => removeAttachment(attachment)}
></AttachmentBox>
);
})}
</div>
</div>
)}
</div>
<div
onClick={() => {
textBoxRef.current?.focus();
}}
style={{
margin: PADDING + 'px',
marginRight: '0px',
borderRadius: ((CHATBOX_SIZE - PADDING*2) / 2) + 'px',
background: 'var(--neutral-5)',
gridArea: 'message',
display: 'grid',
placeItems: 'center center',
padding: '8px 16px',
minHeight: '48px',
boxSizing: 'border-box',
cursor: 'text',
overflow: 'auto',
}}
onPaste={pasta}
>
<div
style={{
width: '100%',
border: 'none',
// outline: '1px solid white',
outline: 'none',
}}
onKeyPress={keyPress}
ref={textBoxRef}
contentEditable
></div>
</div>
</div>
)
}
function AttachmentBox(props: {
attachment: Upload,
onClose: React.MouseEventHandler<SVGElement>
}) {
return (
<div style={{
verticalAlign: 'top',
// padding: '8px',
background: 'var(--neutral-3)',
// border: '1px solid var(--neutral-6)',
fontSize: '0.8em',
display: 'inline-block',
boxSizing: 'border-box',
borderRadius: '8px',
marginRight: '8px',
position: 'relative',
overflow: 'hidden',
// textAlign: 'center',
}}>
<div style={{
transition: 'width 300ms, background 300ms, opacity 800ms',
width: `${props.attachment.progress * 100}%`,
height: '100%',
background: props.attachment.uploaded ? 'white' : 'var(--green)',
position: 'absolute',
top: '0px',
left: '0px',
zIndex: '0',
opacity: props.attachment.uploaded ? '0' : '0.3'
}}></div>
<div style={{
zIndex: '1',
padding: '8px',
position: 'relative',
}}>
<IoMdClose
size={16}
style={{
float: 'right',
color: 'var(--neutral-9)',
padding: '1px',
cursor: 'pointer',
}}
onClick={props.onClose}
></IoMdClose>
<div style={{
paddingBottom: '8px',
paddingRight: '24px',
}}>{props.attachment.name}</div>
{/* <img style={{
display: 'block',
height: '100px'
}} src={props.attachment.blob}></img> */}
</div>
</div>
)
}

View File

@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from "react";
import { useApi } from "../lib/useApi";
import { v4 } from 'uuid';
import { useLog } from "../components/useLog";
const b64 = async (data: Uint8Array) => {
const base64url = await new Promise((r) => {
const reader = new FileReader();
reader.onload = () => r(reader.result);
reader.readAsDataURL(new Blob([data]));
});
// @ts-ignore
return base64url.split(",", 2)[1];
};
export interface Upload {
internalId: string;
externalId: string | null;
data: Blob;
reader: any;
progress: number;
name: string;
sent: number;
rcvd: number;
uploaded: boolean;
processed: boolean;
}
// 400 kb
const CHUNK_SIZE = 4_000;
export default function useFileUpload() {
const [uploads, setUploads] = useState<Upload[]>([]);
const updateUpload = (clientId: string, newData: Partial<Upload>) => {
setUploads(uploads => uploads.map(upload => {
if(upload.internalId !== clientId) return upload;
return {
...upload,
...newData,
};
}));
};
const { send } = useApi({
'file:new'(data: NewFileResponse) {
updateUpload(data.clientId, { externalId: data.serverId });
},
'file:chunk'(data: FileChunkResponse) {
updateUpload(data.clientId, { rcvd: data.chunk, progress: data.progress });
},
'file:end'(data: FileEndResponse) {
updateUpload(data.clientId, { processed: true });
}
}, []);
type Shit = {
chunk: Uint8Array;
done: boolean;
}
const sendNextChunk = async (upload: Upload) => {
if (upload.externalId === null) return;
const {chunk, done}: Shit = await new Promise(async (res) => {
const { value, done } = await upload.reader.read();
res({
chunk: value,
done
})
});
if(chunk === undefined && done) {
updateUpload(upload.internalId, { uploaded: true });
const fileEndRequest: FileEndRequest = {
serverId: upload.externalId,
}
send('file:end', fileEndRequest)
return;
}
const chunkb64 = await b64(chunk);
updateUpload(upload.internalId, { sent: upload.sent + 1 });
const chunkReq: FileChunkRequest = {
chunk: upload.sent,
data: chunkb64,
serverId: upload.externalId,
};
send('file:chunk', chunkReq);
}
useEffect(() => {
for(const upload of uploads) {
if(upload.rcvd === upload.sent && !upload.uploaded) {
sendNextChunk(upload);
}
}
}, [uploads])
const newFile = useCallback((name: string, type: string, blob: Blob) => {
const id = v4();
const newFileReq: NewFileRequest = {
clientId: id,
length: blob.size,
name,
type,
};
// @ts-ignore
const reader = blob.stream().getReader();
setUploads(uploads => [...uploads, {
internalId: id,
externalId: null,
data: blob,
reader: reader,
progress: 0,
name,
sent: 0,
rcvd: 0,
uploaded: false,
processed: false,
}]);
send('file:new', newFileReq);
return id;
}, []);
const getInfo = useCallback((clientId: string) => {
const file = uploads.find(upload => upload.internalId === clientId);
return file ?? null;
}, [uploads])
// useLog(uploads, 'uploads');
return {
newFile,
getFileInfo: getInfo
}
}

View File

@ -58,7 +58,7 @@ export default function Channels() {
useEffect(() => {
if(channels.length === 0) return;
if(channel !== null) return;
setChannel(channels[1].uid, channels[1].type);
setChannel(channels[0].uid, channels[0].type);
}, [channel, channels]);
useEffect(() => {

View File

@ -7,6 +7,7 @@ import { MdSend } from 'react-icons/md';
import useChannel from '../hooks/useChannel';
import useClientId from '../hooks/useClientId';
import useSessionToken from '../hooks/useSessionToken';
import ChatInput from '../components/ChatInput';
function createMessage(from: string, text: string,
channel: string, t = 0): IMessage {
@ -21,14 +22,10 @@ function createMessage(from: string, text: string,
export default () => {
const [messages, setMessages] = useState<IMessage[]>([]);
const [hist, setHist] = useState(false);
const { sessionToken } = useSessionToken();
const CHATBOX_SIZE = 64;
const PADDING = 8;
const textBoxRef = useRef<HTMLDivElement>(null);
const { channel, setChannel } = useChannel();
const { clientId } = useClientId();
@ -36,38 +33,38 @@ export default () => {
'message:message'(data: IMessage) {
if(data.channel !== channel) return;
setMessages([...messages, data]);
setMessages(messages => ([...messages, data]));
},
'message:recent'(data: { messages: IMessage[] }) {
setMessages(data.messages.reverse());
},
}, [messages]);
}, [channel]);
useEffect(() => {
send('message:recent', { channel });
}, [channel, sessionToken]);
}, [channel]);
const sendMessage = useCallback(() => {
if(textBoxRef.current === null) return;
if(channel === null) return;
if(clientId === null) return;
if(sessionToken === null) return;
send(
'message:message',
createMessage(
clientId,
textBoxRef.current.innerText,
channel,
)
);
textBoxRef.current.innerText = '';
}, [channel, sessionToken]);
// const sendMessage = useCallback(() => {
// if(textBoxRef.current === null) return;
// if(channel === null) return;
// if(clientId === null) return;
// if(sessionToken === null) return;
// send(
// 'message:message',
// createMessage(
// clientId,
// textBoxRef.current.innerText,
// channel,
// )
// );
// textBoxRef.current.innerText = '';
// }, [channel, sessionToken]);
const keyDown = useCallback((evt: any) => {
if(evt.key === 'Enter') {
sendMessage();
}
}, [sendMessage]);
// const keyDown = useCallback((evt: any) => {
// if(evt.key === 'Enter') {
// sendMessage();
// }
// }, [sendMessage]);
return (
<div
@ -76,8 +73,8 @@ export default () => {
width: '100%',
display: 'grid',
background: 'var(--neutral-4)',
gridTemplateColumns: `1fr ${CHATBOX_SIZE}px`,
gridTemplateRows: `1fr ${CHATBOX_SIZE}px`,
gridTemplateColumns: `1fr min-content`,
gridTemplateRows: `1fr min-content`,
gridTemplateAreas: '"content content" "message send"',
}}
>
@ -97,45 +94,29 @@ export default () => {
))}
</div>
</div>
<div onClick={() => {
textBoxRef.current?.focus();
}}style={{
margin: PADDING + 'px',
marginRight: '0px',
borderRadius: ((CHATBOX_SIZE - PADDING*2) / 2) + 'px',
background: 'var(--neutral-5)',
gridArea: 'message',
display: 'grid',
placeItems: 'center center',
padding: '0px 16px',
cursor: 'text',
overflow: 'auto',
}}>
<div
ref={textBoxRef}
onKeyPress={keyDown}
className="input"
role="textbox"
contentEditable
style={{
background: 'inherit',
outline: 'none',
boxSizing: 'border-box',
// borderRadius: '8px',
// borderRadius: '50%',
width: '100%',
resize: 'none',
// border: '1px solid white',
}}
></div>
</div>
<ChatInput></ChatInput>
<SendButton></SendButton>
</div>
);
};
function SendButton() {
return (
<div style={{
height: '100%',
width: '64px',
position: 'relative',
}}>
<div style={{
width: '100%',
height: '100%',
width: '64px',
height: '64px',
padding: '8px',
boxSizing: 'border-box',
position: 'absolute',
bottom: '0px',
}}>
<div onClick={sendMessage} style={{
<div style={{
background: '#bd93f9',
width: '100%',
height: '100%',
@ -152,5 +133,5 @@ export default () => {
</div>
</div>
</div>
);
};
)
}

View File

@ -1,14 +1,9 @@
import { useContext, useEffect } from 'react';
import TimeAgo from 'react-timeago';
import { ClientsListContext } from '../contexts/EphemeralState/ClientsListState';
import useHover from '../hooks/useHover';
export interface IMessage {
uid: string;
timestamp: number;
from: string;
text: string;
channel: string;
}
export type IMessage = NewMessageResponse;
interface MessageProps {
message: IMessage
@ -16,54 +11,71 @@ interface MessageProps {
const firstLineIndent = '10px';
const multiLineIndent = '16px';
const rightMessagePagging = '16px';
const rightMessagePadding = '16px';
export function Message({
message,
}: MessageProps) {
const { clientName } = useContext(ClientsListContext);
const [hoverRef, hover] = useHover<HTMLDivElement>();
return (
<div style={{
<div ref={hoverRef} style={{
display: 'grid',
gridTemplateColumns: '4em 1fr',
width: '100%',
padding: '1px 0px',
position: 'relative',
}}>
<div style={{
width: '100%',
height: '100%',
background: hover ? 'var(--neutral-3)' : undefined,
position: 'absolute',
opacity: '0.5',
}}></div>
<span style={{
fontStyle: 'italic',
color: 'var(--neutral-6)',
textAlign: 'right',
userSelect: 'none',
marginRight: '16px',
position: 'relative'
}}>
<TimeAgo
date={message.timestamp}
formatter={(t, u) => u === 'second' ? 'Now' : ('' + t + u[0])}
></TimeAgo>
</span>
<span style={{
<div style={{
// outline: '1px solid white',
marginRight: '16px',
position: 'relative',
paddingLeft: '1em',
textIndent: '-1em',
}}>
<div style={{
fontWeight: '500',
float: 'left',
paddingRight: firstLineIndent,
// marginRight: '16px',
// height: '100%'
// borderBottom: '1px solid white'
}}>
{clientName[message.from]}
<div>
<span style={{
fontWeight: '500',
paddingRight: '8px',
}}>
{clientName[message.from]}
</span>
<span style={{
}}>
{message.text}
</span>
</div>
<div style={{
marginRight: rightMessagePagging,
paddingLeft: multiLineIndent,
boxSizing: 'border-box',
position: 'relative',
}}>
{message.text}
</div>
</span>
{!!message.file && (
<div>
<img style={{
maxWidth: '100%',
maxHeight: '20vh',
}} src={message.file.url}></img>
</div>
)}
</div>
</div>
);
}

View File

@ -8,7 +8,7 @@
"strict": true,
"isolatedModules": true,
"types" : ["node"],
"types" : [],
"baseUrl": ".",
"paths": {
"#preload": [

View File

@ -0,0 +1,27 @@
CREATE TABLE `files` (
`id` int NOT NULL AUTO_INCREMENT PRIMARY KEY,
`data` longblob NOT NULL,
`author` varchar(36) NOT NULL,
`t_uploaded` bigint unsigned NOT NULL,
`t_created` bigint unsigned NOT NULL,
`t_deleted` bigint unsigned NULL,
`type` varchar(255) NOT NULL
);
ALTER TABLE `files`
ADD `uid` varchar(36) NOT NULL AFTER `id`;
ALTER TABLE `files`
ADD UNIQUE `uid` (`uid`);
ALTER TABLE `files`
ADD FOREIGN KEY (`author`) REFERENCES `clients` (`uid`);
ALTER TABLE `messages`
ADD `attachment` varchar(36) COLLATE 'utf8mb4_general_ci' NULL AFTER `text`;
ALTER TABLE `files`
CHANGE `data` `data` longblob NULL AFTER `uid`;
ALTER TABLE `messages`
ADD FOREIGN KEY (`attachment`) REFERENCES `files` (`uid`);

View File

@ -1,6 +1,13 @@
import { ensureDirSync } from 'fs-extra';
import { resolve } from 'path';
export const STORAGE_PATH = resolve('../../../storage');
export const DB_HOST = 'localhost';
export const DB_USER = 'root';
export const DB_PASSWORD = 'example';
export const DB_NAME = 'corner';
ensureDirSync(STORAGE_PATH);

View File

@ -8,7 +8,7 @@ export default function(sqlFile: any, ...args: any[]): Promise<any[] | null> {
if(!err) return resolve(results);
console.error(err.errno, err.sqlMessage);
console.error('--- Query ---');
console.error(err.sql);
console.error(err.sql?.substring(0, 10000));
reject(err);
});
});

View File

@ -1,3 +1,9 @@
INSERT INTO messages
(`text`, sender_uid, `uid`, `t_sent`, channel_uid)
VALUES ( ?, ?, ?, ?, ? );
INSERT INTO messages (
`text`,
sender_uid,
`uid`,
`t_sent`,
channel_uid,
attachment
)
VALUES ( ?, ?, ?, ?, ?, ? );

View File

@ -4,9 +4,12 @@ SELECT
clients.uid as 'from',
messages.`text` as 'text',
messages.channel_uid,
messages.uid as uid
messages.uid as uid,
files.type as file_type,
files.uid as file_uid
FROM messages
JOIN clients ON messages.sender_uid=clients.uid
LEFT JOIN files ON messages.attachment=files.uid
WHERE messages.channel_uid=?
ORDER BY -messages.t_sent
LIMIT 100;

View File

@ -0,0 +1,25 @@
import { Router } from 'express';
import database from '../lib/dbHelpers/database';
import { resolve } from 'path';
import { STORAGE_PATH } from '../constants';
import { createReadStream, lstatSync } from 'fs';
const router = Router();
router.get('/:uid', async (req, res) => {
const info = await database.get.file.by.uid(req.params.uid);
res.contentType = info.type;
if(info.data !== null) {
res.end(info.data);
return;
} else {
// res.end('new hype');
const path = resolve(STORAGE_PATH, req.params.uid);
const size = lstatSync(path).size;
const stream = createReadStream(path);
res.header('Content-Length', '' + size);
stream.pipe(res);
}
})
export default router;

View File

@ -1,10 +1,19 @@
import router from './lib/router';
import { expose, reply } from './lib/WebSocketServer';
// ws
import message from './routers/message';
import channel from './routers/channel';
import client from './routers/client';
import totp from './routers/totp';
import session from './routers/session';
import voice from './routers/voice';
import file from './routers/file';
import express from 'express';
import expressWs from 'express-ws';
// http
import fileRouter from './http/file';
const api = router({
up() {
@ -21,16 +30,27 @@ const api = router({
totp: totp,
session: session,
sessions: session,
voice: voice
voice: voice,
file: file,
files: file,
});
expose(api, 3000);
// var express = require('express');
// var app = express();
// var expressWs = require('express-ws')(app);
const app = express();
const ewss = expressWs(app);
// @ts-ignore
app.ws('/', expose(api, ewss.getWss()));
app.use('/file', fileRouter);
app.listen(3000);
// -------------
import { update } from './db/migrate';
import session from './routers/session';
import voice from './routers/voice';
try {
update();

View File

@ -1,15 +1,40 @@
import { WebSocketServer, WebSocket } from 'ws';
import { inspect } from 'util';
import { validateSessionToken } from '../routers/session';
import chalk from 'chalk';
export function expose(router: Function, port: number) {
const wss = new WebSocketServer({
port: 3000,
}, () => {
console.log('ws chat server started on dev.valnet.xyz');
});
function str2color(str: string) {
const v = str.split('').reduce((acc, val) => acc + val.charCodeAt(0), 0);
return (v % 213) + 17
}
wss.on('connection', (ws) => {
function log(prefix: string, action: string, data: object) {
if(action === 'up') return;
const strAction = action.split(':').map(v => chalk.ansi256(str2color(v))(v)).join(':');
const filteredObject = Object.entries(data)
.filter(e => !e[0].startsWith('$'));
const keyCount = Object.keys(filteredObject).length;
if(keyCount === 0) return console.log(prefix, strAction);
const stringify = (key: string, value: any) => {
switch(typeof value) {
case 'string': return chalk.green(key);
case 'object':
if(value === null) return chalk.blackBright(key);
else if(Array.isArray(value)) return chalk.cyanBright(`[${key}]`);
else return chalk.magenta(key);
case 'number': return chalk.yellow(key);
default: return key;
}
}
const params = filteredObject.map(([k, v]) => stringify(k, v))
console.log(prefix, strAction, params.join(', '));
}
export function expose(router: Function, wss: WebSocketServer) {
return function(ws: WebSocket) {
ws.on('message', async (str) => {
try {
const message = JSON.parse(str.toString());
@ -29,23 +54,21 @@ export function expose(router: Function, port: number) {
if(typeof data !== 'object') {
throw new Error('action ' + action + ' payload not an object');
}
console.log('[IN]', action, data);
log(chalk.green('>>>'), action, data);
const _return = await (router(action, data, ws, wss) as unknown as Promise<any>);
if(_return) {
try {
switch(_return.type) {
case ResponseType.BROADCAST: {
console.log('[OUT_BROADCAST]', action, _return.data);
log(chalk.cyan('(\u25CF)'), action, _return.data)
// console.log('[OUT_BROADCAST]', action, _return.data);
for(const client of wss.clients) {
send(client, action, _return.data);
}
break;
}
case ResponseType.REPLY: {
console.log('[OUT]', action, inspect(_return.data, {
depth: 0,
colors: true,
}));
log(chalk.cyan('<<<'), action, _return.data);
send(ws, action, _return.data);
break;
}
@ -63,7 +86,7 @@ export function expose(router: Function, port: number) {
console.error(e);
}
});
});
}
}
enum ResponseType {

View File

@ -0,0 +1,18 @@
INSERT INTO files (
uid,
author,
type,
data,
t_created,
t_uploaded,
t_deleted
)
VALUES (
?,
?,
?,
NULL,
?,
?,
NULL
)

View File

@ -0,0 +1,7 @@
import query from "/@/db/query";
import sql from './addFilePath.sql';
export default function addFile(uid: string, author: string, type: string) {
const now = new Date().getTime();
return query(sql, uid, author, type, now, now);
}

View File

@ -0,0 +1,18 @@
INSERT INTO files (
uid,
author,
type,
data,
t_created,
t_uploaded,
t_deleted
)
VALUES (
?,
?,
?,
?,
?,
?,
NULL
)

View File

@ -0,0 +1,7 @@
import query from "/@/db/query";
import sql from './addFileRaw.sql';
export default function addFile(uid: string, author: string, data: Buffer, type: string) {
const now = new Date().getTime();
return query(sql, uid, author, type, data, now, now);
}

View File

@ -1,11 +1,24 @@
import getAllDisplayNames from './get/all/displayNames';
import addFileRaw from './add/file/raw/addFileRaw';
import addFilePath from './add/file/path/addFilePath';
import getFileByUid from './get/file/by/uid';
const database = {
get: {
all: {
displayNames: getAllDisplayNames
},
file: {
by: {
uid: getFileByUid
}
}
},
add: {
file: {
raw: addFileRaw,
path: addFilePath
}
}
};

View File

@ -0,0 +1,3 @@
SELECT data, type, uid
FROM files
WHERE uid=?;

View File

@ -0,0 +1,6 @@
import sql from './uid.sql';
import query from '/@/db/query';
export default async function getFileByUid(uid: string) {
return ((await query(sql, uid)) ?? [])[0];
}

View File

@ -7,7 +7,7 @@ export const generateSessionToken = async (clientId: string) => {
for (let i = 0; i < 64; i++) {
token += rb32();
}
console.log('created session token', clientId, token);
// scnd min hr day year
const year = 1000 * 60 * 60 * 24 * 365;
const expiration = Date.now() + year;

View File

@ -0,0 +1,143 @@
import router from "../lib/router";
import { reply } from "../lib/WebSocketServer";
import { v4 } from 'uuid';
import tmp from 'tmp';
import { writeFile, createWriteStream, WriteStream, readFile, createReadStream } from 'fs';
import database from "../lib/dbHelpers/database";
import { resolve } from 'path';
import { STORAGE_PATH } from "../constants";
function createFile(id: string): WriteStream {
return createWriteStream(resolve(STORAGE_PATH, id));
}
interface ServerUpload {
clientId: string;
serverId: string;
length: number;
authorId: string;
chunk: number;
progress: number;
type: string;
// tmp file
path: string;
writeStream: WriteStream,
remove: Function;
}
const tempFiles: ServerUpload[] = [];
export default router({
async 'new'(data: NewFileRequest) {
if(typeof data.$clientId === 'undefined') return;
const serverId = v4();
const temp = tmp.fileSync();
const writeStream = createWriteStream(temp.name, 'base64');
tempFiles.push({
clientId: data.clientId,
authorId: data.$clientId,
length: data.length,
path: temp.name,
writeStream,
remove: temp.removeCallback,
serverId,
chunk: 0,
progress: 0,
type: data.type,
});
const res: NewFileResponse = {
serverId,
clientId: data.clientId
};
return reply(res);
},
async 'chunk'(data: FileChunkRequest) {
const upload = tempFiles.find(upload => upload.serverId === data.serverId);
if(!upload) return;
if(data.chunk !== upload.chunk) return;
if(upload.writeStream.bytesWritten >= upload.length) return;
await new Promise((res, rej) => {
upload.writeStream.write(data.data, (err) => {
if(err) rej(err);
res(undefined);
});
});
// if(upload.buffer.length === upload.length) {
// await new Promise((res, rej) => {
// upload.writeStream.close((err) => {
// if(err) rej(err);
// console.log('upload file closed!');
// res(undefined);
// });
// });
// }
upload.chunk ++;
upload.progress = upload.writeStream.bytesWritten;
// upload.buffer += data.data;
// await new Promise(res => setTimeout(res, 300));
const res: FileChunkResponse = {
chunk: upload.chunk,
serverId: upload.serverId,
clientId: upload.clientId,
progress: (upload.progress / upload.length)
}
return reply(res);
},
async 'end'(data: FileEndRequest) {
const upload = tempFiles.find(upload => upload.serverId === data.serverId);
if(!upload) return;
// if(upload.buffer.length !== upload.length) return;
await new Promise(res => upload.writeStream.close(res));
if(upload.length > 100_000_000) {
const read = createReadStream(upload.path);
const write = createFile(upload.serverId);
const pipe = read.pipe(write);
await new Promise((res) => {
pipe.on('finish', () => {
res(undefined)
});
});
await database.add.file.path(
upload.serverId,
upload.authorId,
upload.type
);
} else {
const file: Buffer = await new Promise((res, rej) => {
readFile(upload.path, (err, data) => {
if(err) rej(err);
res(data);
})
});
await database.add.file.raw(
upload.serverId,
upload.authorId,
file,
upload.type
);
}
const res: FileEndResponse = {
serverId: upload.serverId,
clientId: upload.clientId,
url: `https://dev.valnet.xyz/files/${upload.serverId}`
}
return reply(res);
}
});
// 012345 67 0123 4567 01 234567
// 012345 01 2345 0123 45 012345

View File

@ -3,13 +3,26 @@ import router from '../lib/router';
import newMessage from '../db/snippets/message/new.sql';
import recentMessages from '../db/snippets/message/recent.sql';
import { broadcast, reply } from '../lib/WebSocketServer';
import database from '../lib/dbHelpers/database';
function transformMessage(text: string) {
if(text === '/shrug') {
return '¯\\_(ツ)_/¯';
}
else return text;
}
export default router({
async message(data: any) {
if(!('$clientId' in data)) {
async message(data: NewMessageRequest) {
if(!('$clientId' in data) || data.$clientId === undefined) {
console.error('unauthenticated message rejected.');
return null;
}
const file = data.file !== undefined ?
await database.get.file.by.uid(data.file) :
undefined;
const response = await query(
newMessage,
data.text,
@ -17,11 +30,26 @@ export default router({
data.uid,
data.timestamp,
data.channel,
data.file ?? null
);
if(response === null) return;
data.from = data.$clientId;
return broadcast(data);
const res: NewMessageResponse = {
uid: data.uid,
from: data.$clientId,
text: data.text,
timestamp: data.timestamp,
channel: data.channel,
}
if(file !== undefined) {
res.file = {
type: file.type,
url: `https://dev.valnet.xyz/file/${file.uid}`
}
}
return broadcast(res);
},
async recent(data: any) {
if(!('$clientId' in data)) {
@ -30,13 +58,32 @@ export default router({
}
const messages = await query(recentMessages, data.channel);
if(messages === null) return;
function convert(row: any) {
if(row.file_uid === null) {
return {
from: row.from,
uid: row.uid,
timestamp: row.t_sent,
text: row.text,
}
} else {
return {
from: row.from,
uid: row.uid,
timestamp: row.t_sent,
text: row.text,
file: {
type: row.file_type,
url: `https://dev.valnet.xyz/file/${row.file_uid}`
}
}
}
}
return reply({
messages: messages.map(v => ({
from: v.from,
uid: v.uid,
timestamp: v.t_sent,
text: v.text,
})),
messages: messages.map(convert),
channel: data.channel
});
},
});

View File

@ -26,7 +26,6 @@ export default router({
err: 'Incorrect username or auth code'
});
const validTotp = await validateClientTotp(clientId, totp);
console.log(username, clientId, validTotp);
if(!validTotp) return reply({
err: 'Incorrect username or auth code'
});

View File

@ -62,7 +62,6 @@ export default router({
async leave(data: any) {
const { $clientId } = data;
const removed = filterInPlace(participants, (v) => v.clientId !== $clientId);
console.log('removed', removed);
return broadcast(removed[0]);
},
})

View File

@ -34,7 +34,7 @@ const setupServerPackageWatcher = () => {
let spawnProcess = null;
const processDied = () => {
logger.error('Server has died.', {timestamp: true});
logger.error('Server has died.', {timestamp: false});
spawnProcess = null;
};
@ -56,10 +56,14 @@ const setupServerPackageWatcher = () => {
spawnProcess = spawn(node, ['./index.cjs'], {
cwd: './packages/server/dist',
env: {
...process.env,
FORCE_COLOR: "true"
}
});
/** Proxy all logs */
spawnProcess.stdout.on('data', d => d.toString().trim() && d.toString().trim().split('\n').forEach(str => logger.info(str, {timestamp: true})));
spawnProcess.stdout.on('data', d => d.toString().trim() && d.toString().trim().split('\n').forEach(str => logger.info(str, {timestamp: false})));
/** Proxy error logs but stripe some noisy messages. See {@link stderrFilterPatterns} */
spawnProcess.stderr.on('data', d => {
@ -67,7 +71,7 @@ const setupServerPackageWatcher = () => {
if (!data) return;
const mayIgnore = stderrFilterPatterns.some((r) => r.test(data));
if (mayIgnore) return;
data.split('\n').forEach(d => logger.error(d, {timestamp: true}));
data.split('\n').forEach(d => logger.error(d, {timestamp: false}));
});
/** Stops the watch script when the application has been quit */
@ -117,10 +121,15 @@ const setupMainPackageWatcher = ({resolvedUrls}) => {
}
/** Spawn new electron process */
spawnProcess = spawn(String(electronPath), ['.']);
spawnProcess = spawn(String(electronPath), ['.'], {
env: {
...process.env,
FORCE_COLOR: "true"
}
});
/** Proxy all logs */
spawnProcess.stdout.on('data', d => d.toString().trim() && logger.warn(d.toString(), {timestamp: true}));
spawnProcess.stdout.on('data', d => d.toString().trim() && logger.warn(d.toString(), {timestamp: false}));
/** Proxy error logs but stripe some noisy messages. See {@link stderrFilterPatterns} */
spawnProcess.stderr.on('data', d => {
@ -128,7 +137,7 @@ const setupMainPackageWatcher = ({resolvedUrls}) => {
if (!data) return;
const mayIgnore = stderrFilterPatterns.some((r) => r.test(data));
if (mayIgnore) return;
logger.error(data, {timestamp: true});
logger.error(data, {timestamp: false});
});
/** Stops the watch script when the application has been quit */

36
types/api/file.d.ts vendored 100644
View File

@ -0,0 +1,36 @@
interface NewFileRequest {
clientId: string;
length: number;
name: string;
type: string;
$clientId?: string;
}
interface NewFileResponse {
clientId: string;
serverId: string;
}
interface FileChunkRequest {
chunk: number;
serverId: string;
data: string;
}
interface FileChunkResponse {
chunk: number;
serverId: string;
clientId: string;
progress: number;
}
interface FileEndRequest {
serverId: string;
}
interface FileEndResponse {
serverId: string;
clientId: string;
url: string;
}

35
types/api/message.d.ts vendored 100644
View File

@ -0,0 +1,35 @@
interface NewMessageRequest {
file?: string;
text: string;
$clientId?: string;
uid: string;
timestamp: number;
channel: string;
}
interface NewMessageResponse {
uid: string;
from: string;
text: string;
timestamp: number;
channel: string;
file?: {
url: string;
type: string;
}
}
interface RecentMessagesResponse {
channel: string;
}
interface RecentMessagesResponse {
messages: {
uid: string;
from: string;
text: string;
timestamp: number;
}[];
channel: string;
}