pasting attachments
parent
d595cc7373
commit
13aff451a8
|
|
@ -59,3 +59,5 @@ thumbs.db
|
||||||
|
|
||||||
# docker data
|
# docker data
|
||||||
docker-volume
|
docker-volume
|
||||||
|
|
||||||
|
storage
|
||||||
|
|
@ -4,6 +4,7 @@ services:
|
||||||
db:
|
db:
|
||||||
image: mariadb
|
image: mariadb
|
||||||
restart: always
|
restart: always
|
||||||
|
command: --max_allowed_packet=200M
|
||||||
environment:
|
environment:
|
||||||
MARIADB_ROOT_PASSWORD: example
|
MARIADB_ROOT_PASSWORD: example
|
||||||
MARIADB_DATABASE: corner
|
MARIADB_DATABASE: corner
|
||||||
|
|
@ -20,15 +21,15 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
|
||||||
coturn:
|
# coturn:
|
||||||
image: coturn/coturn
|
# image: coturn/coturn
|
||||||
restart: always
|
# restart: always
|
||||||
ports:
|
# ports:
|
||||||
- 3478:3478
|
# - 3478:3478
|
||||||
- 3478:3478/udp
|
# - 3478:3478/udp
|
||||||
- 5349:5349
|
# - 5349:5349
|
||||||
- 5349:5349/udp
|
# - 5349:5349/udp
|
||||||
- 49160-49200:49160-49200/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 \
|
# 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 \
|
# coturn/coturn -n --log-file=stdout \
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -55,19 +55,25 @@
|
||||||
"vue-tsc": "0.38.8"
|
"vue-tsc": "0.38.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/express-ws": "^3.0.1",
|
||||||
"@types/mysql": "^2.15.21",
|
"@types/mysql": "^2.15.21",
|
||||||
"@types/qrcode": "^1.4.2",
|
"@types/qrcode": "^1.4.2",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/react-timeago": "^4.1.3",
|
"@types/react-timeago": "^4.1.3",
|
||||||
|
"@types/tmp": "^0.2.3",
|
||||||
"@types/totp-generator": "^0.0.4",
|
"@types/totp-generator": "^0.0.4",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"@types/ws": "^8.5.3",
|
"@types/ws": "^8.5.3",
|
||||||
"@vitejs/plugin-react": "^2.0.0",
|
"@vitejs/plugin-react": "^2.0.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
"cordova": "^11.0.0",
|
"cordova": "^11.0.0",
|
||||||
"electron-updater": "5.0.5",
|
"electron-updater": "5.0.5",
|
||||||
"eslint-plugin-react": "^7.30.1",
|
"eslint-plugin-react": "^7.30.1",
|
||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
|
"express-ws": "^5.0.2",
|
||||||
|
"fs-extra": "^10.1.0",
|
||||||
"get-port": "^6.1.2",
|
"get-port": "^6.1.2",
|
||||||
"local-storage": "^2.0.0",
|
"local-storage": "^2.0.0",
|
||||||
"mysql": "^2.18.1",
|
"mysql": "^2.18.1",
|
||||||
|
|
@ -76,9 +82,12 @@
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-icons": "^4.4.0",
|
"react-icons": "^4.4.0",
|
||||||
|
"react-spinners": "^0.13.4",
|
||||||
"react-time-ago": "^7.2.1",
|
"react-time-ago": "^7.2.1",
|
||||||
"react-timeago": "^7.1.0",
|
"react-timeago": "^7.1.0",
|
||||||
|
"react-toastify": "^9.0.8",
|
||||||
"reactjs-popup": "^2.0.5",
|
"reactjs-popup": "^2.0.5",
|
||||||
|
"tmp": "^0.2.1",
|
||||||
"totp-generator": "^0.0.13",
|
"totp-generator": "^0.0.13",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"vue": "3.2.37",
|
"vue": "3.2.37",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
default-src 'self' data: https://ssl.gstatic.com https://fonts.gstatic.com;
|
default-src 'self' data: https://ssl.gstatic.com https://fonts.gstatic.com;
|
||||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||||
media-src * data:;
|
media-src * data:;
|
||||||
img-src 'self' data: content: http://tinygraphs.com;
|
img-src 'self' data: content: *;
|
||||||
connect-src *;">
|
connect-src *;">
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
<title>Vite App</title>
|
<title>Vite App</title>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import ServerConnection from './components/ServerConnection';
|
||||||
import EphemeralState from './contexts/EphemeralState/EphemeralState';
|
import EphemeralState from './contexts/EphemeralState/EphemeralState';
|
||||||
import PersistentState from '/@/contexts/PersistentState/PersistentState';
|
import PersistentState from '/@/contexts/PersistentState/PersistentState';
|
||||||
import Router from './Router';
|
import Router from './Router';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [transparent, setTransparent] = useState(false);
|
const [transparent, setTransparent] = useState(false);
|
||||||
|
|
@ -64,6 +65,32 @@ export default function App() {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 0px 8px;
|
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>
|
`}</style>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: transparent ? 'rgba(0, 0, 0, 0)' : 'var(--neutral-3)',
|
background: transparent ? 'rgba(0, 0, 0, 0)' : 'var(--neutral-3)',
|
||||||
|
|
@ -78,6 +105,8 @@ export default function App() {
|
||||||
<Router></Router>
|
<Router></Router>
|
||||||
</EphemeralState>
|
</EphemeralState>
|
||||||
</PersistentState>
|
</PersistentState>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ export default function Channels() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(channels.length === 0) return;
|
if(channels.length === 0) return;
|
||||||
if(channel !== null) return;
|
if(channel !== null) return;
|
||||||
setChannel(channels[1].uid, channels[1].type);
|
setChannel(channels[0].uid, channels[0].type);
|
||||||
}, [channel, channels]);
|
}, [channel, channels]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { MdSend } from 'react-icons/md';
|
||||||
import useChannel from '../hooks/useChannel';
|
import useChannel from '../hooks/useChannel';
|
||||||
import useClientId from '../hooks/useClientId';
|
import useClientId from '../hooks/useClientId';
|
||||||
import useSessionToken from '../hooks/useSessionToken';
|
import useSessionToken from '../hooks/useSessionToken';
|
||||||
|
import ChatInput from '../components/ChatInput';
|
||||||
|
|
||||||
function createMessage(from: string, text: string,
|
function createMessage(from: string, text: string,
|
||||||
channel: string, t = 0): IMessage {
|
channel: string, t = 0): IMessage {
|
||||||
|
|
@ -21,13 +22,9 @@ function createMessage(from: string, text: string,
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const [messages, setMessages] = useState<IMessage[]>([]);
|
const [messages, setMessages] = useState<IMessage[]>([]);
|
||||||
const [hist, setHist] = useState(false);
|
|
||||||
const { sessionToken } = useSessionToken();
|
const { sessionToken } = useSessionToken();
|
||||||
|
|
||||||
const CHATBOX_SIZE = 64;
|
|
||||||
const PADDING = 8;
|
const PADDING = 8;
|
||||||
|
|
||||||
const textBoxRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { channel, setChannel } = useChannel();
|
const { channel, setChannel } = useChannel();
|
||||||
const { clientId } = useClientId();
|
const { clientId } = useClientId();
|
||||||
|
|
@ -36,38 +33,38 @@ export default () => {
|
||||||
'message:message'(data: IMessage) {
|
'message:message'(data: IMessage) {
|
||||||
if(data.channel !== channel) return;
|
if(data.channel !== channel) return;
|
||||||
|
|
||||||
setMessages([...messages, data]);
|
setMessages(messages => ([...messages, data]));
|
||||||
},
|
},
|
||||||
'message:recent'(data: { messages: IMessage[] }) {
|
'message:recent'(data: { messages: IMessage[] }) {
|
||||||
setMessages(data.messages.reverse());
|
setMessages(data.messages.reverse());
|
||||||
},
|
},
|
||||||
}, [messages]);
|
}, [channel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
send('message:recent', { channel });
|
send('message:recent', { channel });
|
||||||
}, [channel, sessionToken]);
|
}, [channel]);
|
||||||
|
|
||||||
const sendMessage = useCallback(() => {
|
// const sendMessage = useCallback(() => {
|
||||||
if(textBoxRef.current === null) return;
|
// if(textBoxRef.current === null) return;
|
||||||
if(channel === null) return;
|
// if(channel === null) return;
|
||||||
if(clientId === null) return;
|
// if(clientId === null) return;
|
||||||
if(sessionToken === null) return;
|
// if(sessionToken === null) return;
|
||||||
send(
|
// send(
|
||||||
'message:message',
|
// 'message:message',
|
||||||
createMessage(
|
// createMessage(
|
||||||
clientId,
|
// clientId,
|
||||||
textBoxRef.current.innerText,
|
// textBoxRef.current.innerText,
|
||||||
channel,
|
// channel,
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
textBoxRef.current.innerText = '';
|
// textBoxRef.current.innerText = '';
|
||||||
}, [channel, sessionToken]);
|
// }, [channel, sessionToken]);
|
||||||
|
|
||||||
const keyDown = useCallback((evt: any) => {
|
// const keyDown = useCallback((evt: any) => {
|
||||||
if(evt.key === 'Enter') {
|
// if(evt.key === 'Enter') {
|
||||||
sendMessage();
|
// sendMessage();
|
||||||
}
|
// }
|
||||||
}, [sendMessage]);
|
// }, [sendMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -76,8 +73,8 @@ export default () => {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
background: 'var(--neutral-4)',
|
background: 'var(--neutral-4)',
|
||||||
gridTemplateColumns: `1fr ${CHATBOX_SIZE}px`,
|
gridTemplateColumns: `1fr min-content`,
|
||||||
gridTemplateRows: `1fr ${CHATBOX_SIZE}px`,
|
gridTemplateRows: `1fr min-content`,
|
||||||
gridTemplateAreas: '"content content" "message send"',
|
gridTemplateAreas: '"content content" "message send"',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -97,45 +94,29 @@ export default () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={() => {
|
<ChatInput></ChatInput>
|
||||||
textBoxRef.current?.focus();
|
<SendButton></SendButton>
|
||||||
}}style={{
|
</div>
|
||||||
margin: PADDING + 'px',
|
);
|
||||||
marginRight: '0px',
|
};
|
||||||
borderRadius: ((CHATBOX_SIZE - PADDING*2) / 2) + 'px',
|
|
||||||
background: 'var(--neutral-5)',
|
|
||||||
gridArea: 'message',
|
function SendButton() {
|
||||||
display: 'grid',
|
return (
|
||||||
placeItems: 'center center',
|
<div style={{
|
||||||
padding: '0px 16px',
|
height: '100%',
|
||||||
cursor: 'text',
|
width: '64px',
|
||||||
overflow: 'auto',
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
<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>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '100%',
|
width: '64px',
|
||||||
height: '100%',
|
height: '64px',
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '0px',
|
||||||
}}>
|
}}>
|
||||||
<div onClick={sendMessage} style={{
|
<div style={{
|
||||||
background: '#bd93f9',
|
background: '#bd93f9',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|
@ -152,5 +133,5 @@ export default () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
import { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import TimeAgo from 'react-timeago';
|
import TimeAgo from 'react-timeago';
|
||||||
import { ClientsListContext } from '../contexts/EphemeralState/ClientsListState';
|
import { ClientsListContext } from '../contexts/EphemeralState/ClientsListState';
|
||||||
|
import useHover from '../hooks/useHover';
|
||||||
|
|
||||||
export interface IMessage {
|
export type IMessage = NewMessageResponse;
|
||||||
uid: string;
|
|
||||||
timestamp: number;
|
|
||||||
from: string;
|
|
||||||
text: string;
|
|
||||||
channel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
message: IMessage
|
message: IMessage
|
||||||
|
|
@ -16,54 +11,71 @@ interface MessageProps {
|
||||||
|
|
||||||
const firstLineIndent = '10px';
|
const firstLineIndent = '10px';
|
||||||
const multiLineIndent = '16px';
|
const multiLineIndent = '16px';
|
||||||
const rightMessagePagging = '16px';
|
const rightMessagePadding = '16px';
|
||||||
|
|
||||||
export function Message({
|
export function Message({
|
||||||
message,
|
message,
|
||||||
}: MessageProps) {
|
}: MessageProps) {
|
||||||
|
|
||||||
const { clientName } = useContext(ClientsListContext);
|
const { clientName } = useContext(ClientsListContext);
|
||||||
|
const [hoverRef, hover] = useHover<HTMLDivElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div ref={hoverRef} style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '4em 1fr',
|
gridTemplateColumns: '4em 1fr',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '1px 0px',
|
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={{
|
<span style={{
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
color: 'var(--neutral-6)',
|
color: 'var(--neutral-6)',
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
marginRight: '16px',
|
marginRight: '16px',
|
||||||
|
position: 'relative'
|
||||||
}}>
|
}}>
|
||||||
<TimeAgo
|
<TimeAgo
|
||||||
date={message.timestamp}
|
date={message.timestamp}
|
||||||
formatter={(t, u) => u === 'second' ? 'Now' : ('' + t + u[0])}
|
formatter={(t, u) => u === 'second' ? 'Now' : ('' + t + u[0])}
|
||||||
></TimeAgo>
|
></TimeAgo>
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<div style={{
|
||||||
|
// outline: '1px solid white',
|
||||||
|
marginRight: '16px',
|
||||||
|
position: 'relative',
|
||||||
|
paddingLeft: '1em',
|
||||||
|
textIndent: '-1em',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div>
|
||||||
fontWeight: '500',
|
<span style={{
|
||||||
float: 'left',
|
fontWeight: '500',
|
||||||
paddingRight: firstLineIndent,
|
paddingRight: '8px',
|
||||||
// marginRight: '16px',
|
}}>
|
||||||
// height: '100%'
|
{clientName[message.from]}
|
||||||
// borderBottom: '1px solid white'
|
</span>
|
||||||
}}>
|
<span style={{
|
||||||
{clientName[message.from]}
|
}}>
|
||||||
|
{message.text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
{!!message.file && (
|
||||||
marginRight: rightMessagePagging,
|
<div>
|
||||||
paddingLeft: multiLineIndent,
|
<img style={{
|
||||||
boxSizing: 'border-box',
|
maxWidth: '100%',
|
||||||
position: 'relative',
|
maxHeight: '20vh',
|
||||||
}}>
|
}} src={message.file.url}></img>
|
||||||
{message.text}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|
||||||
"types" : ["node"],
|
"types" : [],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"#preload": [
|
"#preload": [
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
@ -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_HOST = 'localhost';
|
||||||
export const DB_USER = 'root';
|
export const DB_USER = 'root';
|
||||||
export const DB_PASSWORD = 'example';
|
export const DB_PASSWORD = 'example';
|
||||||
export const DB_NAME = 'corner';
|
export const DB_NAME = 'corner';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ensureDirSync(STORAGE_PATH);
|
||||||
|
|
@ -8,7 +8,7 @@ export default function(sqlFile: any, ...args: any[]): Promise<any[] | null> {
|
||||||
if(!err) return resolve(results);
|
if(!err) return resolve(results);
|
||||||
console.error(err.errno, err.sqlMessage);
|
console.error(err.errno, err.sqlMessage);
|
||||||
console.error('--- Query ---');
|
console.error('--- Query ---');
|
||||||
console.error(err.sql);
|
console.error(err.sql?.substring(0, 10000));
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
INSERT INTO messages
|
INSERT INTO messages (
|
||||||
(`text`, sender_uid, `uid`, `t_sent`, channel_uid)
|
`text`,
|
||||||
VALUES ( ?, ?, ?, ?, ? );
|
sender_uid,
|
||||||
|
`uid`,
|
||||||
|
`t_sent`,
|
||||||
|
channel_uid,
|
||||||
|
attachment
|
||||||
|
)
|
||||||
|
VALUES ( ?, ?, ?, ?, ?, ? );
|
||||||
|
|
@ -4,9 +4,12 @@ SELECT
|
||||||
clients.uid as 'from',
|
clients.uid as 'from',
|
||||||
messages.`text` as 'text',
|
messages.`text` as 'text',
|
||||||
messages.channel_uid,
|
messages.channel_uid,
|
||||||
messages.uid as uid
|
messages.uid as uid,
|
||||||
|
files.type as file_type,
|
||||||
|
files.uid as file_uid
|
||||||
FROM messages
|
FROM messages
|
||||||
JOIN clients ON messages.sender_uid=clients.uid
|
JOIN clients ON messages.sender_uid=clients.uid
|
||||||
|
LEFT JOIN files ON messages.attachment=files.uid
|
||||||
WHERE messages.channel_uid=?
|
WHERE messages.channel_uid=?
|
||||||
ORDER BY -messages.t_sent
|
ORDER BY -messages.t_sent
|
||||||
LIMIT 100;
|
LIMIT 100;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import router from './lib/router';
|
import router from './lib/router';
|
||||||
import { expose, reply } from './lib/WebSocketServer';
|
import { expose, reply } from './lib/WebSocketServer';
|
||||||
|
|
||||||
|
// ws
|
||||||
import message from './routers/message';
|
import message from './routers/message';
|
||||||
import channel from './routers/channel';
|
import channel from './routers/channel';
|
||||||
import client from './routers/client';
|
import client from './routers/client';
|
||||||
import totp from './routers/totp';
|
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({
|
const api = router({
|
||||||
up() {
|
up() {
|
||||||
|
|
@ -21,16 +30,27 @@ const api = router({
|
||||||
totp: totp,
|
totp: totp,
|
||||||
session: session,
|
session: session,
|
||||||
sessions: 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 { update } from './db/migrate';
|
||||||
import session from './routers/session';
|
|
||||||
import voice from './routers/voice';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
update();
|
update();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,40 @@
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { inspect } from 'util';
|
import { inspect } from 'util';
|
||||||
import { validateSessionToken } from '../routers/session';
|
import { validateSessionToken } from '../routers/session';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
export function expose(router: Function, port: number) {
|
function str2color(str: string) {
|
||||||
const wss = new WebSocketServer({
|
const v = str.split('').reduce((acc, val) => acc + val.charCodeAt(0), 0);
|
||||||
port: 3000,
|
return (v % 213) + 17
|
||||||
}, () => {
|
}
|
||||||
console.log('ws chat server started on dev.valnet.xyz');
|
|
||||||
});
|
function log(prefix: string, action: string, data: object) {
|
||||||
|
if(action === 'up') return;
|
||||||
wss.on('connection', (ws) => {
|
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) => {
|
ws.on('message', async (str) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(str.toString());
|
const message = JSON.parse(str.toString());
|
||||||
|
|
@ -29,23 +54,21 @@ export function expose(router: Function, port: number) {
|
||||||
if(typeof data !== 'object') {
|
if(typeof data !== 'object') {
|
||||||
throw new Error('action ' + action + ' payload not an 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>);
|
const _return = await (router(action, data, ws, wss) as unknown as Promise<any>);
|
||||||
if(_return) {
|
if(_return) {
|
||||||
try {
|
try {
|
||||||
switch(_return.type) {
|
switch(_return.type) {
|
||||||
case ResponseType.BROADCAST: {
|
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) {
|
for(const client of wss.clients) {
|
||||||
send(client, action, _return.data);
|
send(client, action, _return.data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ResponseType.REPLY: {
|
case ResponseType.REPLY: {
|
||||||
console.log('[OUT]', action, inspect(_return.data, {
|
log(chalk.cyan('<<<'), action, _return.data);
|
||||||
depth: 0,
|
|
||||||
colors: true,
|
|
||||||
}));
|
|
||||||
send(ws, action, _return.data);
|
send(ws, action, _return.data);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +86,7 @@ export function expose(router: Function, port: number) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResponseType {
|
enum ResponseType {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
INSERT INTO files (
|
||||||
|
uid,
|
||||||
|
author,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
t_created,
|
||||||
|
t_uploaded,
|
||||||
|
t_deleted
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
NULL,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
NULL
|
||||||
|
)
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
INSERT INTO files (
|
||||||
|
uid,
|
||||||
|
author,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
t_created,
|
||||||
|
t_uploaded,
|
||||||
|
t_deleted
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
NULL
|
||||||
|
)
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
|
|
||||||
import getAllDisplayNames from './get/all/displayNames';
|
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 = {
|
const database = {
|
||||||
get: {
|
get: {
|
||||||
all: {
|
all: {
|
||||||
displayNames: getAllDisplayNames
|
displayNames: getAllDisplayNames
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
by: {
|
||||||
|
uid: getFileByUid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
add: {
|
||||||
|
file: {
|
||||||
|
raw: addFileRaw,
|
||||||
|
path: addFilePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
SELECT data, type, uid
|
||||||
|
FROM files
|
||||||
|
WHERE uid=?;
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ export const generateSessionToken = async (clientId: string) => {
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
token += rb32();
|
token += rb32();
|
||||||
}
|
}
|
||||||
console.log('created session token', clientId, token);
|
|
||||||
// scnd min hr day year
|
// scnd min hr day year
|
||||||
const year = 1000 * 60 * 60 * 24 * 365;
|
const year = 1000 * 60 * 60 * 24 * 365;
|
||||||
const expiration = Date.now() + year;
|
const expiration = Date.now() + year;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -3,13 +3,26 @@ import router from '../lib/router';
|
||||||
import newMessage from '../db/snippets/message/new.sql';
|
import newMessage from '../db/snippets/message/new.sql';
|
||||||
import recentMessages from '../db/snippets/message/recent.sql';
|
import recentMessages from '../db/snippets/message/recent.sql';
|
||||||
import { broadcast, reply } from '../lib/WebSocketServer';
|
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({
|
export default router({
|
||||||
async message(data: any) {
|
async message(data: NewMessageRequest) {
|
||||||
if(!('$clientId' in data)) {
|
if(!('$clientId' in data) || data.$clientId === undefined) {
|
||||||
console.error('unauthenticated message rejected.');
|
console.error('unauthenticated message rejected.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const file = data.file !== undefined ?
|
||||||
|
await database.get.file.by.uid(data.file) :
|
||||||
|
undefined;
|
||||||
|
|
||||||
const response = await query(
|
const response = await query(
|
||||||
newMessage,
|
newMessage,
|
||||||
data.text,
|
data.text,
|
||||||
|
|
@ -17,11 +30,26 @@ export default router({
|
||||||
data.uid,
|
data.uid,
|
||||||
data.timestamp,
|
data.timestamp,
|
||||||
data.channel,
|
data.channel,
|
||||||
|
data.file ?? null
|
||||||
);
|
);
|
||||||
if(response === null) return;
|
if(response === null) return;
|
||||||
|
|
||||||
data.from = data.$clientId;
|
const res: NewMessageResponse = {
|
||||||
return broadcast(data);
|
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) {
|
async recent(data: any) {
|
||||||
if(!('$clientId' in data)) {
|
if(!('$clientId' in data)) {
|
||||||
|
|
@ -30,13 +58,32 @@ export default router({
|
||||||
}
|
}
|
||||||
const messages = await query(recentMessages, data.channel);
|
const messages = await query(recentMessages, data.channel);
|
||||||
if(messages === null) return;
|
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({
|
return reply({
|
||||||
messages: messages.map(v => ({
|
messages: messages.map(convert),
|
||||||
from: v.from,
|
channel: data.channel
|
||||||
uid: v.uid,
|
|
||||||
timestamp: v.t_sent,
|
|
||||||
text: v.text,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -26,7 +26,6 @@ export default router({
|
||||||
err: 'Incorrect username or auth code'
|
err: 'Incorrect username or auth code'
|
||||||
});
|
});
|
||||||
const validTotp = await validateClientTotp(clientId, totp);
|
const validTotp = await validateClientTotp(clientId, totp);
|
||||||
console.log(username, clientId, validTotp);
|
|
||||||
if(!validTotp) return reply({
|
if(!validTotp) return reply({
|
||||||
err: 'Incorrect username or auth code'
|
err: 'Incorrect username or auth code'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ export default router({
|
||||||
async leave(data: any) {
|
async leave(data: any) {
|
||||||
const { $clientId } = data;
|
const { $clientId } = data;
|
||||||
const removed = filterInPlace(participants, (v) => v.clientId !== $clientId);
|
const removed = filterInPlace(participants, (v) => v.clientId !== $clientId);
|
||||||
console.log('removed', removed);
|
|
||||||
return broadcast(removed[0]);
|
return broadcast(removed[0]);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -34,7 +34,7 @@ const setupServerPackageWatcher = () => {
|
||||||
let spawnProcess = null;
|
let spawnProcess = null;
|
||||||
|
|
||||||
const processDied = () => {
|
const processDied = () => {
|
||||||
logger.error('Server has died.', {timestamp: true});
|
logger.error('Server has died.', {timestamp: false});
|
||||||
spawnProcess = null;
|
spawnProcess = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,10 +56,14 @@ const setupServerPackageWatcher = () => {
|
||||||
|
|
||||||
spawnProcess = spawn(node, ['./index.cjs'], {
|
spawnProcess = spawn(node, ['./index.cjs'], {
|
||||||
cwd: './packages/server/dist',
|
cwd: './packages/server/dist',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
FORCE_COLOR: "true"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Proxy all logs */
|
/** 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} */
|
/** Proxy error logs but stripe some noisy messages. See {@link stderrFilterPatterns} */
|
||||||
spawnProcess.stderr.on('data', d => {
|
spawnProcess.stderr.on('data', d => {
|
||||||
|
|
@ -67,7 +71,7 @@ const setupServerPackageWatcher = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const mayIgnore = stderrFilterPatterns.some((r) => r.test(data));
|
const mayIgnore = stderrFilterPatterns.some((r) => r.test(data));
|
||||||
if (mayIgnore) return;
|
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 */
|
/** Stops the watch script when the application has been quit */
|
||||||
|
|
@ -117,10 +121,15 @@ const setupMainPackageWatcher = ({resolvedUrls}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spawn new electron process */
|
/** Spawn new electron process */
|
||||||
spawnProcess = spawn(String(electronPath), ['.']);
|
spawnProcess = spawn(String(electronPath), ['.'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
FORCE_COLOR: "true"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Proxy all logs */
|
/** 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} */
|
/** Proxy error logs but stripe some noisy messages. See {@link stderrFilterPatterns} */
|
||||||
spawnProcess.stderr.on('data', d => {
|
spawnProcess.stderr.on('data', d => {
|
||||||
|
|
@ -128,7 +137,7 @@ const setupMainPackageWatcher = ({resolvedUrls}) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const mayIgnore = stderrFilterPatterns.some((r) => r.test(data));
|
const mayIgnore = stderrFilterPatterns.some((r) => r.test(data));
|
||||||
if (mayIgnore) return;
|
if (mayIgnore) return;
|
||||||
logger.error(data, {timestamp: true});
|
logger.error(data, {timestamp: false});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Stops the watch script when the application has been quit */
|
/** Stops the watch script when the application has been quit */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue