Compare commits

...

28 Commits

Author SHA1 Message Date
lamp 3c6a549a5f sanitize socket event types 2025-05-16 01:27:27 -07:00
lamp 85d2189595 fix 2025-03-26 23:14:07 -07:00
lamp bb85b00b9c more limits 2025-03-26 23:09:53 -07:00
lamp 5df9a65ecf fix theme select 2025-03-12 16:25:07 -07:00
lamp cefad7dcdd enforce user data types 2025-03-12 16:13:38 -07:00
lamp 59aef22205 create readme 2024-12-30 00:38:29 -08:00
lamp 2f8f59c881 add link to repo 2024-12-30 00:27:34 -08:00
lamp bdd41b2db7 limit message size 2024-12-30 00:20:50 -08:00
lamp 80dca24a47 mongoose 2023-11-28 09:06:52 +00:00
lamp 3196d47569 refacotr server 2023-11-22 09:06:27 +00:00
lamp fc26fcf778 move all components to separate files 2023-11-22 05:48:08 +00:00
lamp 61420ddd4e fix 2023-11-22 05:36:06 +00:00
lamp 1e79ea9e91 fixed emoji form with memo... 2023-11-21 09:50:24 +00:00
lamp ec84354aea fix uuid, add secret 2023-11-21 07:27:00 +00:00
lamp 8bd1eeb0b7 a 2023-11-21 06:26:00 +00:00
lamp be12d92e86 fix youtube size 2022-10-24 14:39:34 -05:00
lamp 8168ab2ebd youtube embed 2022-10-23 22:57:43 -05:00
lamp c9893501b6 fix cursor font 2022-10-18 16:02:15 -05:00
lamp c1207c0e13 mice 2022-10-17 19:01:24 -05:00
lamp 466bfa4625 basic replying 2022-10-06 03:08:36 -05:00
lamp bbb0e79a91 nothing much 2022-10-05 21:42:54 -05:00
lamp b3bdaae496 include user agent string in user 2022-10-05 16:52:16 -05:00
lamp 9a2bc648c4 unread message indicator 2022-09-27 22:25:27 -05:00
lamp 79d12b4f51 fix bug 2022-09-25 00:31:51 -05:00
lamp 96c670e39f theme override 2022-09-25 00:21:16 -05:00
lamp 2ad4a05527 Better file uploading 2022-09-23 15:46:16 -05:00
lamp b6d8babe94 object storage and media embedding 2022-09-23 02:47:32 -05:00
lamp 330a78dfff somewhat usable history scroll 2022-09-18 17:20:29 -05:00
35 changed files with 31285 additions and 30225 deletions
+1 -21
View File
@@ -1,24 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# production
build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
data
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+22
View File
@@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Server",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/server/index.js",
"cwd": "${workspaceFolder}/server/",
"env": {
"ADDRESS": "127.0.0.1",
"PORT": "8535"
}
}
]
}
+27900 -27857
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,8 +1,8 @@
{
"name": "chat",
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^0.27.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1",
+5 -5
View File
@@ -2,16 +2,16 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link id="favicon" rel="icon" href="%PUBLIC_URL%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="chat app"
/>
<meta name="description" content="chat app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Symbols+2&display=swap" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+13 -317
View File
@@ -1,238 +1,31 @@
import {useEffect, useState, useRef} from 'react';
import InitPage from './InitPage';
import io from "socket.io-client";
import reactStringReplace from 'react-string-replace';
import {v1 as uuidv1} from "uuid";
import {useState} from 'react';
import { InitPage } from './InitPage';
import { Chat } from './Chat';
var BASE_URL = window.location.hostname === "localhost" ? "http://localhost:8535" : "";
export const SERVER_BASE_URL = "https://chat.owo69.me";
function App() {
export function App() {
var [user, _setUser] = useState(localStorage.user ? JSON.parse(localStorage.user) : {
uuid: uuidv1()
uuid: crypto.randomUUID(),
secret: crypto.randomUUID()
});
function setUser(u) {
_setUser(u);
localStorage.user = JSON.stringify(u);
}
if (!user.uuid) {
setUser(Object.assign(user, {uuid: uuidv1()}));
}
return <div className="App h-full dark:bg-black dark:text-white">
{user.name ? <Chat user={user} setUser={setUser} /> : <InitPage setUser={setUser} />}
</div>
}
export default App;
function Chat({user, setUser}) {
var [messages, setMessages] = useState([]);
var [users, setUsers] = useState([]);
var [socket, setSocket] = useState();
var [settingsModalOpen, setSettingsModalOpen] = useState(false);
useEffect(() => {
var socket = io(BASE_URL);
setSocket(socket);
global.socket = socket;
socket.on("connect", () => {
console.debug("socket connect, id: " + socket.id);
socket.emit("user", user);
});
socket.on("disconnect", () => console.debug("socket disconnect"));
{let emit = socket.emit; socket.emit = function() {
emit.apply(socket, arguments);
console.debug("send", ...arguments);
}}
socket.onAny((eventName, ...args) => {
console.debug("receive", eventName, ...args)
});
socket.on("messages", setMessages);
socket.on("message", message => {
setMessages(messages => [...messages, message]);
});
socket.on("users", setUsers);
return () => socket.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function updateUser(partial_user) {
var updated_user = Object.assign(user, partial_user)
setUser(updated_user);
socket.emit("user", updated_user);
setUser({...user, ...partial_user});
};
function sendMessage(message) {
socket.emit("message", message);
}
return (
<div className="Chat h-full flex flex-col">
<MessageList messages={messages} setMessages={setMessages} />
<UserList users={users} updateUser={updateUser} user={user} socket={socket} />
<ChatInput sendMessage={sendMessage} user={user} updateUser={updateUser} setSettingsModalOpen={setSettingsModalOpen} socket={socket} />
<SettingsModal open={settingsModalOpen} setOpen={setSettingsModalOpen} user={user} updateUser={updateUser} />
</div>
)
}
var [theme, _setTheme] = useState(localStorage.theme || "");
var setTheme = theme => { _setTheme(theme); localStorage.theme = theme; }
function MessageList({messages, setMessages}) {
var [atBottom, setAtBottom] = useState(true);
var [atTop, setAtTop] = useState(false);
var endRef = useRef();
function onScroll(event) {
var { scrollTop, scrollHeight, clientHeight } = event.target;
setAtBottom(scrollHeight - (scrollTop + clientHeight) < 32);
setAtTop(scrollTop < 32);
};
var scrollToBottom = () => endRef.current?.scrollIntoView();
useEffect(() => {
if (atBottom) scrollToBottom();
});
var observer = new ResizeObserver(() => atBottom && scrollToBottom());
useEffect(()=>{
if (atTop) loadOlderMessages();
}, [atTop]);
async function loadOlderMessages() {
var olderMessages = await fetch(BASE_URL+"/messages?before="+messages[0].timestamp).then(res => res.json());
console.debug("loadOlderMessages", olderMessages);
setMessages(messages => [...olderMessages, ...messages]);
}
return <ul className="p-4 w-full flex-1 overflow-y-auto break-words" onScroll={onScroll} onLoad={e => observer.observe(e.target)}>
{messages.map(message => <Message message={message} key={message._id} />)}
<div ref={endRef}></div>
</ul>
}
function Message({message}) {
if (message.user) {
var prefix = <b>{message.user.website ? <a href={message.user.website} target="_blank" rel="noopener" className={message.user.website && "hover:underline"}>{message.user.name}</a> : message.user.name}: </b>
}
var content = <span className={(message.user ? '' : " font-bold")} style={{color: message.color || message.user?.color}} title={new Date(message.timestamp).toLocaleString()}>{processMessageContent(message.content)}</span>
if (message.file) {
let url = BASE_URL + `/file/${message._id}/${message.file.name}`;
var file =
message.file.type?.startsWith("image") ?
<a href={url} target="_blank" rel="noopener" style={{color:"revert",textDecoration:"revert"}}><img src={url} alt={message.file.name} className="max-h-32 inline-block align-top border" /></a>
: message.file.type?.startsWith("video") ?
<video className='max-h-64 inline-block align-top border' controls>
<source src={url} type={message.file.type} />
</video>
: message.file.type?.startsWith("audio") ?
<audio className='max-h-64 inline-block align-top' controls>
<source src={url} type={message.file.type} />
</audio>
: <a href={url} target="_blank" rel="noopener" style={{color:"revert",textDecoration:"revert"}}>{message.file.name}</a>
}
return <li>{prefix} {content} {file}</li>
}
function ChatInput({sendMessage, user, updateUser, setSettingsModalOpen, socket}) {
var [content, setContent] = useState("");
var [file, setFile] = useState();
var fileInput = useRef();
var [showEmojiPicker, setShowEmojiPicker] = useState(false);
function onSubmit(event) {
event.preventDefault();
content = content.trim();
if (!content && !file) return;
sendMessage({content, file});
setContent("");
setFile();
}
async function onPaste(event) {
var file = event.clipboardData.files[0];
if (file) {
event.preventDefault();
var binary = await file.arrayBuffer();
setFile({
data: binary,
type: file.type,
name: file.name
});
}
}
async function onFileInput(event) {
var input = event.target;
var file = input.files[0];
var binary = await file.arrayBuffer();
setFile({
data: binary,
type: file.type,
name: file.name
});
}
function onKeyDown(event) {
if (event.key === "Escape") {
setFile();
fileInput.current.value = null;
}
}
return <div className='ChatInput'>
<form className="flex flex-row" onSubmit={onSubmit}>
<input
className="p-4 flex-1 dark:bg-black"
style={{color: user.color}}
type="text"
onChange={event => {
setContent(event.target.value);
socket.emit("type");
}}
onPaste={onPaste}
onKeyDown={onKeyDown}
value={content}
placeholder={file ? "press ENTER to send file without text" : "type and press ENTER"}>
</input>
<input type="submit" className='w-14 h-14 cursor-pointer' value="➡️" />
<button type="button" className='w-14 h-14' onClick={()=>setShowEmojiPicker(!showEmojiPicker)}>🤔</button>
<label htmlFor="file" className="w-14 h-14 text-center leading-[56px] cursor-pointer" style={{backgroundColor: file ? "red" : undefined}}>📄</label>
<input type="file" id="file" className='hidden' onChange={onFileInput} ref={fileInput}></input>
<button type="button" className='w-14 h-14' onClick={e => setSettingsModalOpen(true)}></button>
</form>
<EmojiPicker open={showEmojiPicker} setOpen={setShowEmojiPicker} setChatInputContent={setContent} />
</div>
}
function EmojiPicker({open, setOpen, setChatInputContent}) {
var [emojis, setEmojis] = useState([]);
var getEmojis = () => fetch(BASE_URL + "/emojis").then(r => r.json());
useEffect(() => {open && getEmojis().then(setEmojis)}, [open]);
var onEmojiClick = event => {
//setOpen(false);
setChatInputContent(content => `${content} :${event.target.dataset.emoji}:`);
};
if (open)
return <div className='fixed top-0 left-0 w-full h-full' onClick={e=>setOpen(false)}>
<div className='w-[258px] h-64 rounded border fixed right-6 bottom-16 overflow-auto bg-white dark:bg-black p-2' onClick={e=>e.stopPropagation()}>
{emojis.map(emoji => <img src={BASE_URL+"/emoji/"+emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} data-emoji={emoji} className="w-10 h-10 inline-block p-0.5 cursor-pointer border border-transparent hover:border-gray-600" onClick={onEmojiClick} />)}
return <div className={"App"+((theme === "dark" || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) ? " dark" : "")}>
<div className="App h-full dark:bg-black dark:text-white">
{user.name ? <Chat user={user} updateUser={updateUser} theme={theme} setTheme={setTheme} /> : <InitPage updateUser={updateUser} />}
</div>
</div>
}
@@ -242,100 +35,3 @@ function EmojiPicker({open, setOpen, setChatInputContent}) {
function UserList({users, updateUser, user: myUser, socket}) {
function changeName() {
var name = prompt("Change name", myUser.name);
if (!name) return;
updateUser({name});
}
useEffect(() => {
socket?.on("type", socketid => {
var n = document.getElementById(socketid);
n.style.bottom = "2px";
setTimeout(() => n.style.bottom = "0px", 100);
});
}, [socket])
return <div className="fixed top-0 right-5 text-right max-w-xs">
<b>{users.length}</b> online: {users.map(user => <div style={{color: user.color}} key={user.socketid} id={user.socketid} className={"mr-1 inline-block relative" + (user.socketid === socket.id ? ' cursor-pointer ' : '')} onClick={user.socketid === socket.id ? changeName : undefined}>{user.name}</div>)}
</div>
}
function SettingsModal({open, setOpen, user, updateUser}) {
var [emojis, setEmojis] = useState([]);
async function getEmojis() {
var emojis = await (await fetch(BASE_URL + "/emojis")).json();
return emojis;
}
useEffect(function(){
if (open) {
getEmojis().then(setEmojis);
}
},[open]);
function EmojiUploader({setEmojis}) {
var [file, setFile] = useState(null);
var [name, setName] = useState("");
async function onSubmit(event){
event.preventDefault();
if (!name || !file) return;
var _file = file; setFile(null);
var _name = name; setName("");
var res = await fetch(BASE_URL+"/emoji/"+_name+"?type="+encodeURIComponent(_file.type), {
method: "PUT",
body: await _file.arrayBuffer()
});
if (res.status === 201) setEmojis(emojis => [...emojis, _name]);
else alert(await res.text());
}
return <form onSubmit={onSubmit}>
<input type="file" onChange={event => setFile(event.target.files[0])} />
<input type="text" placeholder='Emoji name' className="text-black" value={name} onChange={event => setName(event.target.value.replace(/[^a-z0-9-_]/gi, ''))} />
<input type="submit" className="border" />
</form>
}
if (open) return <div className="fixed top-0 left-0 w-full h-full bg-slate-500/50 flex justify-center items-center" onClick={e => setOpen(false)}>
<div className="bg-white dark:bg-black border rounded-lg w-8/12 p-8 h-4/6 overflow-auto" onClick={e => e.stopPropagation()}>
<div className="text-2xl border-b border-slate-500 mb-1">Settings</div>
<div className="m-3"><label>Name: <input type="text" className="dark:text-black" value={user.name} onChange={e => updateUser({name: e.target.value})} /></label></div>
<div className="m-3"><label>Color: <input type="color" value={user.color} onChange={e => updateUser({color: e.target.value})} /></label></div>
<div className="m-3"><label>Website: <input type="text" className="dark:text-black" placeholder='http://example.com' value={user.website} onChange={e => updateUser({website: e.target.value})} /></label></div>
<div className="text-2xl mt-8 border-b border-slate-500 mb-2">Emoji</div>
{emojis.map(emoji => <img src={BASE_URL+"/emoji/"+emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} className="w-8 h-8 inline-block m-1" />)}
<div className="text-xl mt-8">Upload Emoji</div>
<EmojiUploader setEmojis={setEmojis} />
</div>
</div>
}
function processMessageContent(content) {
if (!content) return;
// hyperlinks
content = reactStringReplace(content, /(https?:\/\/\S+)/gi, link =>
<a href={link} target="_blank" rel="noopener" style={{color: "revert", textDecoration: "revert"}}>{link}</a>
)
// emoji
content = reactStringReplace(content, /:([a-z0-9-_]+):/gi, emoji =>
<img src={BASE_URL + "/emoji/" + emoji} alt={`:${emoji}:`} title={emoji} className="inline-block w-8 h-8" />
)
return content;
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState, useCallback } from 'react';
import io from "socket.io-client";
import { SERVER_BASE_URL } from './App';
import { MousingLayer } from './MousingLayer';
import { SettingsModal } from './SettingsModal';
import { UserList } from './UserList';
import { ProgressBar } from './ProgressBar';
import { ChatInput } from './ChatInput';
import { MessageList } from './MessageList';
export function Chat({ user, updateUser, theme, setTheme }) {
var [messages, setMessages] = useState([]);
var [users, setUsers] = useState([]);
var [socket, setSocket] = useState();
var [settingsModalOpen, setSettingsModalOpen] = useState(false);
var [progress, setProgress] = useState(null);
var [mice, setMice] = useState({});
var [unread, setUnread] = useState(0);
useEffect(() => {
document.getElementById("favicon").href = SERVER_BASE_URL + `/favicon.svg?num=${unread}`;
}, [unread]);
useEffect(() => {
var fn = () => {
if (document.visibilityState === "visible") setUnread(0);
};
document.addEventListener("visibilitychange", fn);
return () => document.removeEventListener("visibilitychange", fn);
}, []);
var loadMessages = useCallback(function loadMessages() {
fetch(SERVER_BASE_URL + "/messages?secret=" + user.secret).then(res => res.json()).then(messages => {
console.debug("messages", messages);
setMessages(messages);
});
}, [setMessages, user.secret]);
useEffect(() => {
var socket = io(SERVER_BASE_URL, {
transports: ["websocket", "polling"]
});
setSocket(socket);
global.socket = socket;
socket.on("connect", () => {
console.debug("socket connect, id: " + socket.id);
socket.emit("user", user);
});
socket.on("disconnect", () => console.debug("socket disconnect"));
/*{let emit = socket.emit; socket.emit = function() {
emit.apply(socket, arguments);
console.debug("send", ...arguments);
}}
socket.onAny((eventName, ...args) => {
console.debug("receive", eventName, ...args)
});*/
socket.on("messages", setMessages);
socket.on("message", message => {
setMessages(messages => [...messages, message]);
if (document.visibilityState === "hidden") setUnread(unread => ++unread);
});
socket.on("users", setUsers);
window.onmousemove = event => {
socket.emit("mouse", event.pageX / window.innerWidth, event.pageY / window.innerHeight);
};
socket.on("mouse", (x, y, socketid) => {
setMice(mice => ({ ...mice, [socketid]: { x, y } }));
});
return () => socket.close();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
socket?.emit("user", user);
}, [socket, user]);
useEffect(() => {
var socketids = users.map(user => user.socketid);
setMice(mice => {
for (let mouseid in mice) if (!socketids.includes(mouseid)) delete mice[mouseid];
return mice;
});
}, [users]);
var sendMessage = useCallback(message => socket.emit("message", message), [socket]);
return (
<div className="Chat flex flex-col">
<MessageList messages={messages} setMessages={setMessages} userSecret={user.secret} />
<UserList users={users} updateUser={updateUser} user={user} socket={socket} />
<ProgressBar progress={progress} />
<ChatInput sendMessage={sendMessage} user={user} updateUser={updateUser} setSettingsModalOpen={setSettingsModalOpen} socket={socket} setProgress={setProgress} />
<SettingsModal open={settingsModalOpen} setOpen={setSettingsModalOpen} user={user} updateUser={updateUser} theme={theme} setTheme={setTheme} />
<MousingLayer mice={mice} users={users} />
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useState, useRef, memo } from 'react';
import axios from "axios";
import { SERVER_BASE_URL } from './App';
import { EmojiPicker } from './EmojiPicker';
export var ChatInput = memo(function ChatInput({ sendMessage, user, setSettingsModalOpen, socket, setProgress }) {
var [content, setContent] = useState("");
var textinputref = useRef();
function appendToTextInput(str) {
setContent(content => `${content} ${str} `);
textinputref.current?.select();
}
global.appendToTextInput = appendToTextInput;
var [showEmojiPicker, setShowEmojiPicker] = useState(false);
function onSubmit(event) {
event.preventDefault();
content = content.trim();
if (!content) return;
sendMessage({ content });
setContent("");
}
async function uploadFiles(files) {
console.debug("uploadFiles", files);
var formdata = new FormData();
for (let file of files) formdata.append(file.name, file);
setProgress(0);
var res = await axios.post(SERVER_BASE_URL + "/upload", formdata, {
headers: {
"Content-Type": "multipart/form-data"
},
onUploadProgress: progressEvent => {
console.debug("progressEvent", progressEvent);
var progress = progressEvent.loaded / progressEvent.total;
console.debug("upload progress", `${progress * 100}%`);
setProgress(progress);
}
});
setProgress(null);
console.debug("upload res", res.data);
var codes = res.data.map(({ name, code, type }) => {
name = name.replaceAll(' ', '_');
var t = type.toLowerCase().split('/')[0];
t = t === "image" ? "img" : t === "video" ? "video" : t === "audio" ? "audio" : "file";
return `[${t}:${code}/${name}]`;
}).join(" ");
appendToTextInput(codes);
}
return <div className='ChatInput'>
<form className="flex flex-row" onSubmit={onSubmit}>
<input
ref={textinputref}
className="p-4 flex-1 dark:bg-black"
style={{ color: user.color }}
type="text"
onChange={event => {
setContent(event.target.value);
socket.emit("type");
}}
onPaste={event => {
var files = event.clipboardData.files;
if (files.length) {
event.preventDefault();
uploadFiles(files);
}
}}
value={content}
placeholder="type or paste files and press ENTER">
</input>
<input type="submit" className='w-14 h-14 cursor-pointer' value="➡️" />
<button type="button" className='w-14 h-14' onClick={() => setShowEmojiPicker(!showEmojiPicker)}>🤔</button>
<label htmlFor="file" className="w-14 h-14 text-center leading-[56px] cursor-pointer">📄</label>
<input type="file" id="file" className='hidden' onChange={event => {
uploadFiles(event.target.files);
event.target.value = null;
}} multiple></input>
<button type="button" className='w-14 h-14' onClick={e => setSettingsModalOpen(true)}></button>
</form>
<EmojiPicker open={showEmojiPicker} setOpen={setShowEmojiPicker} appendToTextInput={appendToTextInput} />
</div>;
});
+8
View File
@@ -0,0 +1,8 @@
import { useState } from 'react';
import { SERVER_BASE_URL } from './App';
export function Emoji({ emoji }) {
var [failed, setFailed] = useState(null);
if (failed) return `:${emoji}:`;
else return <img src={SERVER_BASE_URL + "/emoji/" + emoji} alt={`:${emoji}:`} title={emoji} className="inline-block w-8 h-8" onError={() => setFailed(true)} />;
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { SERVER_BASE_URL } from './App';
export function EmojiPicker({ open, setOpen, appendToTextInput }) {
var [emojis, setEmojis] = useState([]);
var getEmojis = () => fetch(SERVER_BASE_URL + "/emojis").then(r => r.json());
useEffect(() => { open && getEmojis().then(setEmojis); }, [open]);
var onEmojiClick = event => {
//setOpen(false);
appendToTextInput(`:${event.target.dataset.emoji}:`);
};
if (open)
return <div className='fixed top-0 left-0 w-full h-full' onClick={e => setOpen(false)}>
<div className='w-[258px] h-64 rounded border fixed right-6 bottom-16 overflow-auto bg-white dark:bg-black p-2' onClick={e => e.stopPropagation()}>
{emojis.map(emoji => <img src={SERVER_BASE_URL + "/emoji/" + emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} data-emoji={emoji} className="w-10 h-10 inline-block p-0.5 cursor-pointer border border-transparent hover:border-gray-600" onClick={onEmojiClick} />)}
</div>
</div>;
}
+2 -2
View File
@@ -1,13 +1,13 @@
import {useState} from "react";
export default function InitPage({setUser}) {
export function InitPage({updateUser}) {
var [name, setName] = useState(null);
var [color, setColor] = useState(random_color());
function onSubmit(event) {
event.preventDefault();
setUser({name, color});
updateUser({name, color});
}
return <div className="InitPage p-3">
+25
View File
@@ -0,0 +1,25 @@
import { useState } from 'react';
import { processMessageContent } from './util';
export function Message({ message }) {
var [hover, setHover] = useState(false);
if (message.user) {
var prefix = <b>{message.user?.website ? <a href={message.user.website} target="_blank" rel="noopener" className={message.user.website && "hover:underline"}>{message.user.name}</a> : message.user.name}: </b>;
}
var content = <span className={"content" + (message.user ? '' : " font-bold")} style={{ color: message.user?.color }} title={new Date(message.timestamp).toLocaleString()}>{processMessageContent(message.content)}</span>;
if (hover) {
if (message.user?.you) var manageButtons = <><button>🗑</button><button></button></>;
var options = <span className="absolute top-0 right-0 mr-4 text-gray-500 text-xs italic">{manageButtons}<button onClick={() => global.appendToTextInput(`@${message.user?.name}:“${message.content}”>>`)}></button> - {new Date(message.timestamp).toLocaleString()}</span>;
}
return <li id={message._id} className="px-4 relative" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
backgroundColor: hover ? "rgb(127 127 127 / 15%)" : undefined
}}>{prefix} {content} {options}</li>;
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState, useCallback, memo } from 'react';
import { SERVER_BASE_URL } from './App';
import { Message } from './Message';
export var MessageList = memo(function MessageList({ messages, setMessages, userSecret }) {
var [atBottom, setAtBottom] = useState(true);
var [atTop, setAtTop] = useState(false);
var onScroll = useCallback(event => {
var { scrollTop, scrollHeight, clientHeight } = event.target;
setAtBottom(scrollHeight - (scrollTop + clientHeight) < 32);
setAtTop(scrollTop < 32);
}, [setAtBottom, setAtTop]);
var loadOlderMessages = useCallback(async () => {
var olderMessages = await fetch(SERVER_BASE_URL + "/messages?secret=" + userSecret + "&before=" + messages[0].timestamp).then(res => res.json());
console.debug("loadOlderMessages", olderMessages);
setMessages(messages => [...olderMessages, ...messages]);
// why don't either of these work on ios (webkit) ?
//document.getElementById(messages[0]._id).scrollIntoView();
setTimeout(() => document.getElementById(olderMessages.at(-1)._id).scrollIntoView(), 0);
}, [setMessages, messages, userSecret]);
var scrollToBottom = () => document.getElementById(messages.at(-1)?._id)?.scrollIntoView();
useEffect(() => {
if (atBottom) scrollToBottom();
});
var observer = new ResizeObserver(() => atBottom && scrollToBottom());
useEffect(() => {
console.debug("atTop", atTop);
if (atTop) loadOlderMessages();
}, [atTop, loadOlderMessages]);
useEffect(() => {
console.debug("atBottom", atBottom);
if (atBottom) setMessages(messages => messages.slice(-100));
}, [atBottom, setMessages]);
return <ul className="py-4 w-full flex-1 overflow-auto break-words" onScroll={onScroll} onLoad={e => observer.observe(e.target)}>
{messages.map(message => <Message message={message} key={message._id} />)}
</ul>;
});
+20
View File
@@ -0,0 +1,20 @@
export function MousingLayer({ mice: miceData, users }) {
var mice = [];
for (let socketid in miceData) {
let user = users.find(user => user.socketid === socketid);
let mouse = miceData[socketid];
mice.push(<div key={socketid} className="fixed" style={{
color: user?.color || "gray",
left: mouse.x * window.innerWidth,
top: (mouse.y * window.innerHeight) - 4,
}}><span style={{ fontFamily: "'Noto Sans Symbols 2'" }}>🮰</span><span className="relative top-2 text-xs">{user?.name || socketid}</span></div>);
}
return <div className="fixed top-0 left-0 w-full h-full pointer-events-none">
{mice}
</div>;
}
+10
View File
@@ -0,0 +1,10 @@
export function ProgressBar({ progress }) {
if (progress !== null) return <div className="w-full h-1" style={{ backgroundColor: "red" }}>
<div className="h-full" style={{ backgroundColor: "lime", width: `${progress * 100}%` }}></div>
</div>;
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState, memo } from 'react';
import { SERVER_BASE_URL } from './App';
export var SettingsModal = memo(function SettingsModal({ open, setOpen, user, updateUser, theme, setTheme }) {
var [emojis, setEmojis] = useState([]);
async function getEmojis() {
var emojis = await (await fetch(SERVER_BASE_URL + "/emojis")).json();
return emojis;
}
useEffect(function () {
if (open) {
getEmojis().then(setEmojis);
}
}, [open]);
function EmojiUploader({ setEmojis }) {
var [file, setFile] = useState(null);
var [name, setName] = useState("");
async function onSubmit(event) {
event.preventDefault();
if (!name || !file) return;
var _file = file; setFile(null);
var _name = name; setName("");
var res = await fetch(SERVER_BASE_URL + "/emoji/" + _name + "?type=" + encodeURIComponent(_file.type), {
method: "PUT",
body: await _file.arrayBuffer()
});
if (res.status === 201) setEmojis(emojis => [...emojis, _name]);
else alert(await res.text());
}
return <form onSubmit={onSubmit}>
<input type="file" onChange={event => setFile(event.target.files[0])} />
<input type="text" placeholder='Emoji name' className="dark:text-black border" value={name} onChange={event => setName(event.target.value.replace(/[^a-z0-9-_]/gi, ''))} />
<input type="submit" className="border" />
</form>;
}
if (open) return <div className="fixed top-0 left-0 w-full h-full bg-slate-500/50 flex justify-center items-center" onClick={e => setOpen(false)}>
<div className="bg-white dark:bg-black border rounded-lg w-8/12 p-8 h-4/6 overflow-auto" onClick={e => e.stopPropagation()}>
<div className="text-2xl border-b border-slate-500 mb-1">Settings</div>
<div className="m-3"><label>Name: <input type="text" className="dark:text-black border" value={user.name} onChange={e => updateUser({ name: e.target.value })} /></label></div>
<div className="m-3"><label>Color: <input type="color" value={user.color} onChange={e => updateUser({ color: e.target.value })} /></label></div>
<div className="m-3"><label>Website: <input type="text" className="dark:text-black border" placeholder='http://example.com' value={user.website} onChange={e => updateUser({ website: e.target.value })} /></label></div>
<div className="m-3"><label>Theme: <select className="dark:text-black border" value={theme} onChange={event => setTheme(event.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="">System</option>
</select></label></div>
<div className="text-2xl mt-8 border-b border-slate-500 mb-2">Emoji</div>
{emojis.map(emoji => <img src={SERVER_BASE_URL + "/emoji/" + emoji} title={`:${emoji}:`} alt={`:${emoji}:`} key={emoji} className="w-8 h-8 inline-block m-1" />)}
<div className="text-xl mt-8">Upload Emoji</div>
<EmojiUploader setEmojis={setEmojis} />
<div className="text-2xl mt-8 border-b border-slate-500 mb-2">Report bug</div>
<a style={{ color: "revert", textDecoration: "revert" }} href="https://gitea.moe/lamp/chat" target="_blank">https://gitea.moe/lamp/chat</a>
</div>
</div>;
});
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, memo } from 'react';
export var UserList = memo(function UserList({ users, updateUser, user: myUser, socket }) {
function changeName() {
var name = prompt("Change name", myUser.name);
if (!name) return;
updateUser({ name });
}
useEffect(() => {
socket?.on("type", socketid => {
var n = document.getElementById(socketid);
n.style.bottom = "2px";
setTimeout(() => n.style.bottom = "0px", 100);
});
}, [socket]);
return <div className="fixed top-0 right-5 text-right max-w-xs">
<b>{users.length}</b> online: {users.map(user => <div style={{ color: user.color }} key={user.socketid} id={user.socketid} className={"mr-1 inline-block relative" + (user.socketid === socket.id ? ' cursor-pointer ' : '')} onClick={user.socketid === socket.id ? changeName : undefined}>{user.name}</div>)}
</div>;
});
+7 -1
View File
@@ -2,7 +2,13 @@
@tailwind components;
@tailwind utilities;
html, body, #root, .App {
html, body, #root, .App, .Chat {
height: 100%;
min-height: 100%;
overflow: hidden;
}
.content a {
color: revert;
text-decoration: revert;
}
+4 -4
View File
@@ -1,11 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { App } from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
<React.StrictMode>
<App />
</React.StrictMode>
);
+65
View File
@@ -0,0 +1,65 @@
import reactStringReplace from 'react-string-replace';
import { Emoji } from './Emoji';
import { SERVER_BASE_URL } from './App';
/*function reactStringReplace(pcs, re, fn, limit = 10) {
if (!(pcs instanceof Array)) pcs = [pcs];
return pcs.map(str => {
if (typeof str != "string") return str;
var split = str.split(re);
for (var i = 1; i < split.length && ((i + 1) / 2) < limit; i += 2) {
split[i] = fn(split[i]);
}
return split;
}).flat();
}*/
export function processMessageContent(content) {
if (!content) return;
if (content.length > 5000) {
content = content.substring(0, 5000) + `[${content.length - 5000} chars truncated]`;
}
// youtube
content = reactStringReplace(content, /https?:\/\/(?:(?:www.)?youtube.com|youtu.be)\/(?:watch\?v=)?([a-zA-Z0-9-_]{11})/gi, id => <iframe className="inline-block align-top max-w-[560px] max-h-[315px] w-full h-full aspect-video" src={`https://www.youtube-nocookie.com/embed/${id}`} title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>);
// hyperlinks
content = reactStringReplace(content, /(https?:\/\/\S+)/gi, link => <a href={link} target="_blank" rel="noopener" style={{ color: "revert", textDecoration: "revert" }}>{link}</a>
);
// emoji
content = reactStringReplace(content, /:([a-z0-9-_]+):/gi, emoji => <Emoji emoji={emoji} />);
// files
content = reactStringReplace(content, /\[img:(\S+)\]/gi, x => {
var url = SERVER_BASE_URL + "/objects/" + x;
return <a href={url} target="_blank" rel="noopener">
<img src={url} alt="" className="max-h-32 inline-block align-top border" />
</a>;
});
content = reactStringReplace(content, /\[video:(\S+)\]/gi, x => {
var url = SERVER_BASE_URL + "/objects/" + x;
return <video className='max-h-64 inline-block align-top border' controls>
<source src={url} />
</video>;
});
content = reactStringReplace(content, /\[audio:(\S+)\]/gi, x => {
var url = SERVER_BASE_URL + "/objects/" + x;
return <audio className='max-h-64 inline-block align-top' controls>
<source src={url} />
</audio>;
});
content = reactStringReplace(content, /\[file:(\S+)\]/gi, x => {
var url = SERVER_BASE_URL + "/objects/" + x;
var filename = x.split('/').pop() || x;
return <a href={url} target="_blank" rel="noopener">{filename}</a>;
});
return content;
}
+1
View File
@@ -7,4 +7,5 @@ module.exports = {
extend: {},
},
plugins: [],
darkMode: "class"
}
+9
View File
@@ -0,0 +1,9 @@
unfinished/work in progress:
- message delete & edit
todo:
- better replies
- mousing not in react?
- fix scrollback issues
+2 -157
View File
@@ -1,157 +1,2 @@
require("dotenv").config({path: __dirname+"/.env"});
var express = require("express");
var {MongoClient, ObjectId} = require("mongodb");
var socketio = require("socket.io");
var http = require("http");
var morgan = require("morgan");
var app = express();
app.set("trust proxy", true);
var server = http.createServer(app);
var io = socketio(server, {
cors: {origin: "*"},
maxHttpBufferSize: 16e6
});
var dbclient = new MongoClient(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chatserver");
var db = dbclient.db();
var messages = db.collection("messages");
var emojis = db.collection("emojis");
app.use(morgan("dev"));
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "*");
res.header("Access-Control-Allow-Methods", "*");
next();
});
app.get("/messages", (req, res, next) => {
var query = req.query.before ? {timestamp: {$lt: new Date(req.query.before)}} : {};
messages.find(query).project({"file.data": 0, "user.ip": 0}).sort({timestamp: -1}).limit(100).toArray().then(messages => {
res.send(messages.reverse());
}).catch(e => next(e));
});
app.get("/file/:message_id/:filename", async (req, res, next) => {
try {
var doc = await messages.findOne({_id: new ObjectId(req.params.message_id)}, {file:1});
if (!doc) return res.sendStatus(404);
var file = doc.file;
if (!file) return res.status(404).send("message does not have file");
if (req.params.filename != file.name) return res.status(400).send("filename is not correct") //idk
res.type(file.type);
if (res.get("Content-Type").startsWith("text/html")) res.type("text/plain");
res.header("Cache-Control", "max-age=31536000");
res.send(file.data.buffer);
} catch (error) {
next(error);
}
});
app.get("/emoji/:emoji", (req, res, next) => {
emojis.findOne({name: req.params.emoji}, function(err, emoji){
if (err) return next(err);
if (!emoji) return res.sendStatus(404);
res.header("Cache-Control", "max-age=31536000");
res.type(emoji.type);
res.send(emoji.data.buffer);
});
});
app.get("/emojis", (req, res, next) => {
emojis.find({}, {name: 1}).toArray(function (err, emojis) {
if (err) return next(err);
emojis = emojis.map(e => e.name);
shuffleArray(emojis);
res.send(emojis);
});
});
app.put("/emoji/:name", express.raw({limit: "1mb", type: ()=>true}), async (req, res, next) => {
try {
var emoji = {
name: req.params.name,
type: req.query.type,
data: req.body
};
if (!emoji.name || !/^[a-z0-9_-]+$/i.test(emoji.name) || !["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"].includes(emoji.type)) return res.sendStatus(400);
var total = await emojis.countDocuments();
if (total >= 100) return res.status(507).send("too many emojis on the server");
var exist = await emojis.countDocuments({name: emoji.name});
if (exist > 0) return res.status(409).send("emoji already exists with that name");
await emojis.insertOne(emoji);
res.sendStatus(201);
} catch (error) {next(error)}
});
app.use(express.static(require("path").join(__dirname, "../app/build/")));
dbclient.connect().then(async () => {
io.on("connection", async socket => {
socket.ip = socket.handshake.headers["x-forwarded-for"]?.split(',')[0] || socket.handshake.address;
console.debug("connection from", socket.ip, "socket id", socket.id);
socket.on("user", user => {
user = {
name: user.name?.trim()?.substring(0,32) || "no name",
color: user.color?.trim()?.substring(0,32),
website: user.website?.trim()?.substring(0,1000),
uuid: user.uuid?.substring(0,128) || Math.random().toString(),
socketid: socket.id,
ip: socket.ip
};
console.debug("user", user);
socket.data.user = user;
broadcastUsers();
});
socket.once("user", async user => {
//await newMessage({color: "#00FF00", content:`${user.name} connected`});
socket.on("disconnect", () => {
//newMessage({color: "#FF0000", content: `${socket.data.user.name} disconnected`});
broadcastUsers();
});
socket.on("message", m => newMessage({
content: m.content?.substring(0,1024),
user: Object.assign({}, socket.data.user),
file: m.file ? {
name: m.file.name?.substring(0,128),
data: m.file.data,
type: m.file.type?.substring(0,64)
} : undefined
}));
var history = await messages.find().project({"file.data": 0, "user.ip": 0}).sort({timestamp: -1}).limit(100).toArray();
history = history.reverse();
socket.emit("messages", history);
socket.on("type", () => {
io.emit("type", socket.id);
});
});
});
server.listen(8535);
async function newMessage(message) {
message.timestamp = new Date();
var {insertedId} = await messages.insertOne(message);
message._id = insertedId.toString();
console.debug("message", message);
delete message.file?.data;
delete message.user.ip;
io.emit("message", message);
}
async function broadcastUsers() {
var users = await io.fetchSockets().then(sockets => sockets.filter(socket => socket.data.user).map(socket => {
var user = Object.assign({}, socket.data.user);
delete user.ip;
return user;
}));
io.emit("users", users);
}
});
function shuffleArray(array) {
for (var i = array.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
import 'dotenv/config';
import "./src/index.js";
+100
View File
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 135.46667 135.46667"
version="1.1"
id="svg12"
inkscape:export-filename="bitmap.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview14"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.71624579"
inkscape:cx="155.67282"
inkscape:cy="226.87742"
inkscape:window-width="1920"
inkscape:window-height="991"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs9">
<filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter1158"
x="-0.2668752"
y="-0.16879727"
width="1.5337504"
height="1.3375945">
<feGaussianBlur
inkscape:collect="always"
stdDeviation="5.2893724"
id="feGaussianBlur1160" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:#00ffff;stroke-width:0.216579"
id="path372"
cx="-67.73333"
cy="67.73333"
transform="scale(-1,1)"
r="67.73333" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:134.795px;line-height:1.25;font-family:sans-serif;fill:#008080;fill-opacity:1;stroke:none;stroke-width:3.36987"
x="0.060306985"
y="113.65733"
id="text378"><tspan
sodipodi:role="line"
id="tspan376"
style="fill:#008080;stroke-width:3.36987"
x="0.060306985"
y="113.65733"></tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:110.067px;line-height:1.25;font-family:sans-serif;direction:rtl;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:2.752;stroke-opacity:1;filter:url(#filter1158);stroke-dasharray:none"
x="140.86877"
y="131.62408"
id="text393-5"><tspan
sodipodi:role="line"
id="tspan391-2"
style="direction:rtl;fill:#000000;stroke-width:2.752;stroke:#000000;stroke-opacity:1;fill-opacity:1;stroke-dasharray:none"
x="140.86877"
y="131.62408">{num}</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:110.067px;line-height:1.25;font-family:sans-serif;direction:rtl;fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:2.75168"
x="138.9102"
y="130.72346"
id="text393"><tspan
sodipodi:role="line"
id="tspan391"
style="direction:rtl;fill:#ff0000;stroke-width:2.75168"
x="138.9102"
y="130.72346">{num}</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+2380 -1853
View File
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -1,9 +1,17 @@
{
"dependencies": {
"dotenv": "^16.0.2",
"express": "^4.18.1",
"mongodb": "^4.9.1",
"morgan": "^1.10.0",
"socket.io": "^4.5.2"
}
"dependencies": {
"1636": "^1.0.0",
"busboy": "^1.6.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"hash-through": "^0.1.16",
"mime": "^3.0.0",
"mongodb": "^6.3.0",
"mongoose": "^8.0.1",
"multer": "^1.4.5-lts.1",
"sanitize-filename": "^1.6.3",
"socket.io": "^4.7.2"
},
"type": "module"
}
+21
View File
@@ -0,0 +1,21 @@
import express from "express";
import {router} from "./router.js";
var app = express();
app.set("trust proxy", true);
app.use((req, res, next) => {
console.log(req.ip, req.method, req.url, req.headers["user-agent"]);
next();
});
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "*");
res.header("Access-Control-Allow-Methods", "*");
next();
});
app.use(router);
export default app;
+2
View File
@@ -0,0 +1,2 @@
export const DATA_DIR = process.env.DATA_DIR || "data";
export const MIN_FREE_GB = process.env.MIN_FREE_GB ? Number(process.env.MIN_FREE_GB) : 10;
+6
View File
@@ -0,0 +1,6 @@
import mongoose from "mongoose";
import server from "./server.js";
import "./socketio.js";
await mongoose.connect(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chatserver");
server.listen(process.env.PORT || 8535, process.env.ADDRESS);
+21
View File
@@ -0,0 +1,21 @@
import {Schema, model} from "mongoose";
var schema = new Schema({
name: {
type: String,
unique: true,
match: /^[a-z0-9_-]+$/i,
required: true
},
type: {
type: String,
enum: ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"],
required: true
},
data: {
type: Buffer,
required: true
}
});
export default model("Emoji", schema);
+36
View File
@@ -0,0 +1,36 @@
import { Schema, model } from "mongoose";
var schema = new Schema({
timestamp: Date,
content: String,
user: {
name: String,
color: String,
website: String,
uuid: String,
secret: {type: String, select: false},
ip: {type: String, select: false},
agent: String
}
}, {
strict: true,
strictQuery: true
});
schema.static("getMessages", async function({query = {}, secret}) {
var messages = await this.find(query).sort({timestamp: -1}).limit(100).select('+user.secret').lean().exec();
for (var message of messages) {
if (message.user) {
if (secret && secret === message.user.secret) {
message.user.you = true;
}
delete message.user.secret;
}
}
return messages.reverse();
});
export default model("Message", schema);
+130
View File
@@ -0,0 +1,130 @@
import express from "express";
import "express-async-errors";
import busboy from "busboy";
import HashThrough from "hash-through";
import { to36 } from "1636";
import { createHash } from "crypto";
import fs from "fs";
import path from "path";
import mime from "mime";
import Message from "./models/Message.js";
import Emoji from "./models/Emoji.js";
import { DATA_DIR, MIN_FREE_GB } from "./constants.js";
import { df } from "./util.js";
export var router = express.Router();
router.get("/messages", (req, res, next) => {
if (req.query.before) var beforeDate = new Date(req.query.before);
if (beforeDate == "Invalid Date") return res.status(400).send("`before` param is invalid date");
if (req.query.after) var afterDate = new Date(req.query.after);
if (afterDate == "Invalid Date") return res.status(400).send("`after` param is invalid date");
if (beforeDate || afterDate) {
var query = {timestamp:{}};
if (beforeDate) query.timestamp.$lt = beforeDate;
if (afterDate) query.timestamp.$gt = afterDate;
}
Message.getMessages({query, secret: req.query.secret}).then(messages => res.send(messages)).catch(next);
});
router.post("/upload", async (req, res, next) => {
var free = await df(DATA_DIR);
if (free < MIN_FREE_GB) {
res.sendStatus(507);
return;
}
var boi = busboy({headers: req.headers});
var codes = [];
boi.on("file", (name, file, info) => {
var resolve;
codes.push(new Promise(r => resolve = r));
var hash = HashThrough(() => createHash("sha1"));
var tmpfilepath = path.join(DATA_DIR, "tmp", Math.random().toString());
var write = fs.createWriteStream(tmpfilepath);
file.pipe(hash).pipe(write);
write.on("error", error => next(error));
write.on("close", () => {
var code = to36(hash.digest("hex"));
var j = {name, code, type: info.mimeType};
var targetfilepath = path.join(DATA_DIR, "objects", code);
fs.exists(targetfilepath, exists => {
if (exists) {
resolve(j);
fs.unlink(tmpfilepath, e=>e&&console.error(e.stack));
}
else fs.rename(tmpfilepath, targetfilepath, error => {
if (error) console.error(error.stack);
resolve(j);
});
});
});
});
boi.on("close", () => {
Promise.all(codes).then(codes => res.send(codes));
});
req.pipe(boi);
});
router.get("/objects/:code/:filename?", (req, res) => {
//var type = req.query.type?.trim().toLowerCase().replace(/[^a-z0-9\/; =-]/g,'');
//if (type?.startsWith("text/html")) type = type.replace("text/html", "text/plain");
if (req.params.filename) {
var type = mime.getType(req.params.filename);
}
res.header("Cache-Control", "max-age=31536000");
res.sendFile(req.params.code, {
root: path.join(DATA_DIR, "objects/"),
headers: type ? {"Content-Type": type} : undefined
});
});
router.get("/emoji/:emoji", (req, res, next) => {
Emoji.findOne({name: req.params.emoji}).then(emoji => {
if (!emoji) return res.sendStatus(404);
res.header("Cache-Control", "max-age=31536000");
res.type(emoji.type);
res.send(emoji.data);
}).catch(next);
});
router.get("/emojis", (req, res, next) => {
Emoji.find({}, "name").then(emojis => {
emojis = emojis.map(e => e.name);
res.send(emojis);
}).catch(next);
});
router.put("/emoji/:name", express.raw({limit: "1mb", type: ()=>true}), async (req, res) => {
var free = await df(DATA_DIR);
if (free < MIN_FREE_GB) {
res.sendStatus(507);
return;
}
var emoji = new Emoji({
name: req.params.name,
type: req.query.type,
data: req.body
});
await emoji.save();
res.sendStatus(201);
});
var favicon = fs.readFileSync(path.join(process.cwd(), "logo.svg"), "utf-8");
router.get("/favicon.svg", (req, res) => {
res.header("Cache-Control", "max-age=86400");
res.type("svg").send(favicon.replaceAll("{num}", Number(req.query.num || 0) || ""));
});
router.use(express.static(path.join(process.cwd(), "../app/build/")));
+4
View File
@@ -0,0 +1,4 @@
import { createServer } from "http";
import app from "./app.js";
export default createServer(app);
+94
View File
@@ -0,0 +1,94 @@
import { Server as SocketIOServer } from "socket.io";
import { randomUUID } from "crypto";
import server from "./server.js";
import Message from "./models/Message.js";
import { Quota } from "./util.js";
export var io = new SocketIOServer(server, {
cors: {origin: "*"},
maxHttpBufferSize: 100000,
perMessageDeflate: true
});
io.on("connection", socket => {
socket.ip = socket.handshake.headers["x-forwarded-for"]?.split(',')[0] || socket.handshake.address;
console.log("connect", socket.ip, socket.id);
socket.quotas = {
user: new Quota(1000, 1000*60*60),
message: new Quota(30, 60000)
};
socket.on("disconnect", reason => {
console.log("disconnect", socket.id, reason);
for (var quota in socket.quotas) {
socket.quotas[quota].destroy();
}
})
socket.on("user", user => {
if (!socket.quotas.user.spend()) return;
if (typeof user != "object") return;
user = {
name: user.name?.toString().trim().substring(0,32),
color: user.color?.toString().trim().substring(0,32),
website: user.website?.toString().trim().substring(0,1000),
uuid: user.uuid?.toString().substring(0,128) || "A"+randomUUID(),
secret: user.secret?.toString().substring(0,128),
socketid: socket.id,
ip: socket.ip,
agent: socket.handshake.headers["user-agent"]
};
console.debug("user", JSON.stringify(user));
socket.data.user = user;
broadcastUsers();
});
socket.once("user", async user => {
if (typeof user != "object") return;
//await newMessage({color: "#00FF00", content:`${user.name} connected`});
socket.on("disconnect", () => {
//newMessage({color: "#FF0000", content: `${socket.data.user.name} disconnected`});
broadcastUsers();
});
socket.on("message", message => {
if (!socket.quotas.message.spend()) return;
if (typeof message != "object") return;
newMessage({
content: message.content?.toString().substring(0,10000),
user: {...socket.data.user}
}).catch(error => {
console.error(socket.id, error.message);
});
});
socket.on("type", () => {
io.emit("type", socket.id);
});
socket.on("mouse", (x, y) => {
if (typeof x != "number" || typeof y != "number") return;
//socket.broadcast.emit("mouse", x, y, socket.id);
// see own cursor (test)
io.emit("mouse", x, y, socket.id);
});
Message.getMessages({secret: user.secret}).then(messages => {
socket.emit("messages", messages);
});
});
});
async function broadcastUsers() {
var users = await io.fetchSockets().then(sockets => sockets.filter(socket => socket.data.user).map(socket => {
var user = Object.assign({}, socket.data.user);
delete user.ip;
delete user.secret;
return user;
}));
io.emit("users", users);
}
async function newMessage(message) {
message = new Message(message);
message.timestamp = new Date();
message = await message.save();
message = message.toObject();
console.debug("message", message);
delete message.user.ip;
delete message.user.secret;
io.emit("message", message);
}
+36
View File
@@ -0,0 +1,36 @@
export function shuffleArray(array) {
for (var i = array.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
export class Quota {
constructor(limit, resetInterval) {
this.limit = limit;
this.count = limit;
this.interval = setInterval(() => {
this.count = this.limit;
}, resetInterval);
}
destroy () {
clearInterval(this.interval);
}
spend() {
return --this.count > 0;
}
}
import { execFile } from "child_process";
import { promisify } from "util";
var exec = promisify(execFile);
var free, ldft;
export async function df(path) {
if (Date.now() - ldft < 1000*60) return free;
ldft = Date.now();
var {stdout} = await exec("df", ["--output=avail", "--block-size=1G", path]);
return free = Number(stdout.toString().match(/\d+/)[0]);
}