1
0

Seeding with chat subsystem from peddlers of ketran

Signed-off-by: James Ketrenos <james@ketrenos.com>
This commit is contained in:
James Ketrenos 2023-03-30 11:24:27 -07:00
parent 3e5d4b0cbf
commit 84ef980c42
27 changed files with 3990 additions and 8 deletions

4
client/.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": [ "@babel/env", "@babel/preset-react" ],
"plugins": [ "@babel/plugin-proposal-class-properties" ]
}

View File

@ -1,13 +1,10 @@
import './App.css';
import { PlayerList } from './PlayerList';
function App() {
return (
<div className="App">
<header className="App-header">
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
</header>
<PlayerList/>
</div>
);
}

View File

@ -0,0 +1,92 @@
.MediaControlSpacer {
display: flex;
width: 5rem;
min-width: 5rem;
height: 3.75rem;
min-height: 3.75rem;
background-color: #444;
border-radius: 0.25rem;
}
.MediaControlSpacer.Medium {
width: 11.5em;
height: 8.625em;
min-width: 11.5em;
min-height: 8.625em;
}
.MediaControl {
display: flex;
position: fixed;
flex-direction: row;
justify-content: flex-end;
align-items: center;
width: 5rem;
height: 3.75rem;
min-width: 5rem;
min-height: 3.75rem;
z-index: 50000;
}
.MediaControl .Video {
position: relative;
width: 100%;
height: 100%;
background-color: #444;
border-radius: 0.25rem;
border: 1px solid black;
}
.MediaControl.Medium {
width: 11.5em;
height: 8.625em;
min-width: 11.5em;
min-height: 8.625em;
}
.MediaControl > div {
display: flex;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
margin-right: 0.25rem;
}
.MediaControl .Controls {
display: flex;
position: absolute;
left: 0.5em;
bottom: 0.5em;
justify-content: flex-end;
z-index: 1;
}
.MediaControl.Small .Controls {
left: 0;
bottom: unset;
justify-content: center;
}
.MediaControl .Controls > div {
display: flex;
border-radius: 0.25em;
cursor: pointer;
padding: 0.25em;
}
.MediaControl .Controls > div:hover {
background-color: #d0d0d0;
}
.moveable-control-box {
border: none;
--moveable-color: unset !important;
}
.moveable-control-box .moveable-direction {
border: none !important;
}

711
client/src/MediaControl.js Normal file
View File

@ -0,0 +1,711 @@
import React, { useState, useEffect, useRef, useCallback,
useContext } from "react";
import Moveable from "react-moveable";
import "./MediaControl.css";
import VolumeOff from '@mui/icons-material/VolumeOff';
import VolumeUp from '@mui/icons-material/VolumeUp';
import MicOff from '@mui/icons-material/MicOff';
import Mic from '@mui/icons-material/Mic';
import VideocamOff from '@mui/icons-material/VideocamOff';
import Videocam from '@mui/icons-material/Videocam';
import { GlobalContext } from "./GlobalContext.js";
const debug = true;
/* Proxy object so we can pass in srcObject to <audio> */
const Video = ({ srcObject, local, ...props }) => {
const refVideo = useRef(null);
useEffect(() => {
if (!refVideo.current) {
return;
}
const ref = refVideo.current;
if (debug) console.log('media-control - video <video> bind');
ref.srcObject = srcObject;
if (local) {
ref.muted = true;
}
return () => {
if (debug) console.log('media-control - <video> unbind');
if (ref) {
ref.srcObject = undefined;
}
};
}, [srcObject, local]);
return <video ref={refVideo} {...props} />;
}
const MediaAgent = ({setPeers}) => {
const { name, ws } = useContext(GlobalContext);
const [ peers ] = useState({});
const [stream, setStream] = useState(undefined);
const onTrack = useCallback((event) => {
const connection = event.target;
console.log("media-agent - ontrack", event);
for (let peer in peers) {
if (peers[peer].connection === connection) {
console.log(`media-agent - ontrack - remote ${peer} stream assigned.`);
Object.assign(peers[peer].attributes, {
srcObject: event.streams[0]
});
/* Trigger update of MediaControl now that a stream is available */
setPeers(Object.assign({}, peers));
}
}
}, [peers, setPeers]);
const refOnTrack = useRef(onTrack);
const sendMessage = useCallback((data) => {
ws.send(JSON.stringify(data));
}, [ws]);
const onWsMessage = useCallback((event) => {
const addPeer = (config) => {
console.log('media-agent - Signaling server said to add peer:', config);
if (!stream) {
console.log(`media-agent - No local media stream`);
return;
}
const peer_id = config.peer_id;
if (peer_id in peers) {
if (!peers[peer_id].dead) {
/* This is normal when peers are added by other connecting
* peers through the signaling server */
console.log(`media-agent - addPeer - ${peer_id} already in peers`);
return;
}
}
/* Even if reviving, allocate a new Object so <MediaControl> will
* have its peer state change and trigger an update from
* <PlayerList> */
const peer = {
name: peer_id,
hasAudio: config.hasAudio,
hasVideo: config.hasVideo,
attributes: {},
};
if (peer_id in peers) {
peer.muted = peers[peer_id].muted;
peer.videoOn = peers[peer_id].videoOn;
console.log(`media-agent - addPeer - reviving dead peer ${peer_id}`, peer);
} else {
peer.muted = false;
peer.videoOn = true;
}
peers[peer_id] = peer;
console.log(`media-agent - addPeer - remote`, peers);
setPeers(Object.assign({}, peers));
const connection = new RTCPeerConnection({
configuration: {
offerToReceiveAudio: true,
offerToReceiveVideo: true
},
iceServers: [{
urls: "turns:ketrenos.com:5349",
username: "ketra",
credential: "ketran"
},
/*
{
urls: "turn:numb.viagenie.ca",
username: "james_viagenie@ketrenos.com",
credential: "1!viagenie"
}
*/
]
});
peer.connection = connection;
connection.addEventListener('connectionstatechange', (event) => {
console.log(`media-agent - connectionstatechange - `,
connection.connectionState, event);
});
connection.addEventListener('negotiationneeded', (event) => {
console.log(`media-agent - negotiationneeded - `,
connection.connectionState, event);
});
connection.addEventListener('icecandidateerror', (event) => {
if (event.errorCode === 701) {
if (connection.iceGatheringState === 'gathering') {
console.log(`media-agent - Unable to reach host: ${event.url}`);
} else {
console.error(`media-agent - icecandidateerror - `, event.errorCode, event.hostcandidate, event.url, event.errorText);
}
}
});
connection.onicecandidate = (event) => {
if (!event.candidate) {
console.log(`media-agent - icecanditate - gathering is complete: ${connection.connectionState}`);
return;
}
/* If a srflx candidate was found, notify that the STUN server works! */
if (event.candidate.type === "srflx"){
console.log("media-agent - The STUN server is reachable!");
console.log(`media-agent - Your Public IP Address is: ${event.candidate.address}`);
}
/* If a relay candidate was found, notify that the TURN server works! */
if (event.candidate.type === "relay"){
console.log("media-agent - The TURN server is reachable !");
}
console.log(`media-agent - onicecandidate - `, event.candidate);
sendMessage({
type: 'relayICECandidate',
config: {
peer_id,
candidate: event.candidate
}
});
};
connection.ontrack = e => refOnTrack.current(e);
/* Add our local stream */
connection.addStream(stream.media);
/* Only one side of the peer connection should create the
* offer, the signaling server picks one to be the offerer.
* The other user will get a 'sessionDescription' event and will
* create an offer, then send back an answer 'sessionDescription'
* to us
*/
if (config.should_create_offer) {
if (debug) console.log(`media-agent - Creating RTC offer to ` +
`${peer_id}`);
return connection.createOffer()
.then((local_description) => {
if (debug) console.log(`media-agent - Local offer ` +
`description is: `, local_description);
return connection.setLocalDescription(local_description)
.then(() => {
sendMessage({
type: 'relaySessionDescription',
config: {
peer_id,
'session_description': local_description
}
});
if (debug) console.log(`media-agent - Offer ` +
`setLocalDescription succeeded`);
})
.catch((error) => {
console.error(`media-agent - Offer setLocalDescription failed!`);
});
})
.catch((error) => {
console.log(`media-agente - Error sending offer: `, error);
});
}
}
const sessionDescription = ({ peer_id, session_description }) => {
const peer = peers[peer_id];
if (!peer) {
console.error(`media-agent - sessionDescription - ` +
`No peer for ${peer_id}`);
return;
}
const { connection } = peer;
const desc = new RTCSessionDescription(session_description);
return connection.setRemoteDescription(desc, () => {
if (debug) console.log(`media-agent - sessionDescription - ` +
`setRemoteDescription succeeded`);
if (session_description.type === "offer") {
if (debug) console.log(`media-agent - sessionDescription - ` +
`Creating answer`);
connection.createAnswer((local_description) => {
if (debug) console.log(`media-agent - sessionDescription - ` +
`Answer description is: `, local_description);
connection.setLocalDescription(local_description, () => {
sendMessage({
type: 'relaySessionDescription',
config: {
peer_id,
session_description: local_description
}
});
if (debug) console.log(`media-agent - sessionDescription ` +
`- Answer setLocalDescription succeeded`);
}, () => {
console.error(`media-agent - sessionDescription - ` +
`Answer setLocalDescription failed!`);
});
}, (error) => {
console.error(error);
});
}
}, (error) => {
console.log(`media-agent - sessionDescription - ` +
`setRemoteDescription error: `, error);
});
};
const removePeer = ({peer_id}) => {
console.log(`media-agent - removePeer - Signaling server said to ` +
`remove peer ${peer_id}`);
if (peer_id in peers) {
if (peers[peer_id].connection) {
peers[peer_id].connection.close();
peers[peer_id].connection = undefined;
}
}
/* To maintain mute/videoOn states, we don't remove the peer but
* instead mark it as dead */
peers[peer_id].dead = true;
if (debug) console.log(`media-agent - removePeer`, peers);
setPeers(Object.assign({}, peers));
};
const iceCandidate = ({ peer_id, candidate }) => {
/**
* The offerer will send a number of ICE Candidate blobs to the
* answerer so they can begin trying to find the best path to one
* another on the net.
*/
const peer = peers[peer_id];
if (!peer) {
console.error(`media-agent - iceCandidate - No peer for ` +
`${peer_id}`, peers);
return;
}
peer.connection.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => {
if (debug) console.log(`media-agent - iceCandidate - ` +
`Successfully added Ice Candidate for ${peer_id}`);
})
.catch((error) => {
console.error(error, peer, candidate);
});
};
const data = JSON.parse(event.data);
if (data.type in [ 'addPeer', 'removePeer',
'iceCandidate', 'sessionDescription' ]) {
console.log(`media-agent - message - ${data.type}`, peers);
}
switch (data.type) {
case 'addPeer': addPeer(data.data); break;
case 'removePeer': removePeer(data.data); break;
case 'iceCandidate': iceCandidate(data.data); break;
case 'sessionDescription': sessionDescription(data.data); break;
default: break;
}
}, [ peers, setPeers, stream, refOnTrack, sendMessage ]);
const refWsMessage = useRef(onWsMessage);
const onWsClose = (event) => {
console.log(`media-agent - ${name} Disconnected from signaling server`);
/* Tear down all of our peer connections and remove all the
* media divs when we disconnect */
for (let peer_id in peers) {
if (peers[peer_id].local) {
continue;
}
peers[peer_id].connection.close();
peers[peer_id].connection = undefined;
}
for (let id in peers) {
peers[id].dead = true;
peers[id].connection = undefined;
}
if (debug) console.log(`media-agent - close`, peers);
setPeers(Object.assign({}, peers));
}
const refWsClose = useRef(onWsClose);
useEffect(() => {
refWsMessage.current = onWsMessage;
refWsClose.current = onWsClose;
refOnTrack.current = onTrack;
});
useEffect(() => {
if (!ws) {
return;
}
console.log(`media-control - Binding to WebSocket`);
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
const cbClose = e => refWsClose.current(e);
ws.addEventListener('close', cbClose);
return () => {
ws.removeEventListener('message', cbMessage);
ws.removeEventListener('close', cbClose);
}
}, [ws, refWsMessage, refWsClose ]);
useEffect(() => {
console.log(`media-control - WebSocket or Stream changed`);
const join = () => {
sendMessage({
type: 'join',
config: {
hasAudio: stream.audio,
hasVideo: stream.video
}
});
}
if (ws && stream) {
console.log(`media-conterol - issuing join request`);
for (let peer in peers) {
if (peers[peer].local && peers[peer].dead) {
/* Allocate a new Object so <MediaControl> will trigger */
peers[peer] = Object.assign({}, peers[peer]);
delete peers[peer].dead;
setPeers(Object.assign({}, peers));
}
}
join();
}
}, [ws, stream, peers, setPeers, sendMessage]);
useEffect(() => {
if (!name) {
return;
}
let update = false;
if (stream) {
if (!(name in peers)) {
update = true;
peers[name] = {
name: name,
local: true,
muted: true,
videoOn: false,
hasVideo: stream.video,
hasAudio: stream.audio,
attributes: {
local: true,
srcObject: stream.media
}
};
}
}
/* Renaming the local connection requires the peer to be deleted
* and re-established with the signaling server */
for (let key in peers) {
if (peers[key].local && key !== name) {
delete peers[key];
update = true;
}
}
if (update) {
if (debug) console.log(`media-agent - Setting global peers`, peers);
setPeers(Object.assign({}, peers));
}
}, [peers, name, setPeers, stream]);
useEffect(() => {
if (!ws || !name) {
return;
}
const setup_local_media = () => {
/* Ask user for permission to use the computers microphone and/or camera,
* attach it to an <audio> or <video> tag if they give us access. */
console.log(`media-agent - Requesting access to local ` +
`audio / video inputs`);
/* See Dummy Tracks for more ideas...
* https://blog.mozilla.org/webrtc/warm-up-with-replacetrack/
*/
navigator.getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia);
return navigator.mediaDevices
.getUserMedia({audio: true, video: true})
.then((media) => {
return {
media: media,
audio: true,
video: true
};
})
.catch((error) => {
console.log(`media-agent - Access to audio and video ` +
`failed. Trying just audio.`);
return navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((media) => {
return {
media: media,
audio: true,
video: false
};
})
.catch((error) => {
console.log(`media-agent - Access to audio ` +
`failed.`);
return {
media: undefined,
audio: false,
video: false
};
});
})
.then((context) => { /* user accepted access to a/v */
sendMessage({type: 'media-status',
video: context.video,
audio: context.audio
});
if (context.video) {
console.log("media-agent - Access granted to audio/video");
context.media.getVideoTracks().forEach((track) => {
track.applyConstraints({
"video": {
"width": {
"min": 160,
"max": 320
},
"height": {
"min": 120,
"max": 240
}
}
});
});
return context;
}
const black = ({ width = 640, height = 480 } = {}) => {
const canvas = Object.assign(document.createElement("canvas"), {
width, height
});
canvas.getContext('2d').fillRect(0, 0, width, height);
const stream = canvas.captureStream();
return Object.assign(stream.getVideoTracks()[0], {
enabled: true
});
}
const silence = () => {
const ctx = new AudioContext(), oscillator = ctx.createOscillator();
const dst = oscillator.connect(ctx.createMediaStreamDestination());
oscillator.start();
return Object.assign(dst.stream.getAudioTracks()[0], {
enabled: true
});
}
if (context.audio) {
console.log("media-agent - Access granted to audio");
let black = ({ width = 640, height = 480 } = {}) => {
let canvas = Object.assign(document.createElement("canvas"), {
width, height
});
canvas.getContext('2d').fillRect(0, 0, width, height);
let stream = canvas.captureStream();
return Object.assign(stream.getVideoTracks()[0], {
enabled: true
});
}
context.media = new MediaStream([context.media, black()]);
return context;
}
context.media = new MediaStream([black(), silence()]);
return context;
});
};
let abort = false;
if (!stream) {
if (debug) console.log(`media-agent - WebSocket open request. ` +
`Attempting to create local media.`);
setup_local_media().then((context) => {
/* once the user has given us access to their
* microphone/camcorder, join the channel and start peering up */
if (abort) {
console.log(`media-agent - aborting setting local media`);
} else {
setStream(context);
}
}).catch((error) => { /* user denied access to a/v */
console.error(error);
console.log("media-agent - Access denied for audio/video");
});
}
return () => {
abort = true;
if (!stream) {
console.log(`media-agent - abort media setup!`);
}
};
}, [ws, setStream, stream, name, sendMessage]);
return <></>;
}
const MediaControl = ({isSelf, peer, className}) => {
const [media, setMedia] = useState(undefined);
const [muted, setMuted] = useState(undefined);
const [videoOn, setVideoOn] = useState(undefined);
const [target, setTarget] = useState();
const [frame, setFrame] = useState({
translate: [0, 0],
});
useEffect(() => {
if (peer && peer.name) {
setTarget(document.querySelector(
`.MediaControl[data-peer="${peer.name}"]`));
}
}, [setTarget, peer]);
/* local state is used to trigger re-renders, and the global
* state is kept up to date in the peers object so re-assignment
* of sessions doesn't kill the peer or change the mute/video states */
useEffect(() => {
if (!peer) {
setMedia(undefined);
return;
}
setMuted(peer.muted);
setVideoOn(peer.videoOn);
setMedia(peer);
}, [peer, setMedia, setMuted, setVideoOn]);
console.log(`media-control - render`);
const toggleMute = (event) => {
if (debug) console.log(`media-control - toggleMute - ${peer.name}`,
!muted);
peer.muted = !muted;
setMuted(peer.muted);
event.stopPropagation();
}
const toggleVideo = (event) => {
if (debug) console.log(`media-control - toggleVideo - ${peer.name}`,
!videoOn);
peer.videoOn = !videoOn;
if (peer.videoOn) {
const video = document.querySelector(`video[data-id="${media.name}"`);
if (video) {
video.play();
}
}
setVideoOn(peer.videoOn);
event.stopPropagation();
}
useEffect(() => {
if (!media || media.dead || !peer) {
return;
}
if (media.attributes.srcObject) {
console.log(`media-control - audio enable - ${peer.name}:${!muted}`);
media.attributes.srcObject.getAudioTracks().forEach((track) => {
track.enabled = media.hasAudio && !muted;
});
}
}); /* run after every render to hit when ontrack has received and set
* the stream //, [media, muted]); */
useEffect(() => {
if (!media || media.dead || !peer) {
return;
}
if (media.attributes.srcObject) {
console.log(`media-control - video enable - ${peer.name}:${videoOn}`);
media.attributes.srcObject.getVideoTracks().forEach((track) => {
track.enabled = media.hasVideo && videoOn;
});
}
}); /* run after every render to hit when ontrack has received and set
* the stream //, [media, videoOn]); */
const isValid = media && !media.dead,
colorAudio = (isValid && media.hasAudio) ? 'primary' : 'disabled',
colorVideo = (isValid && media.hasVideo) ? 'primary' : 'disabled';
if (!peer) {
return <></>;
}
return <>
<div className={`MediaControlSpacer ${className}`}/>
<div className={`MediaControl ${className}`} data-peer={peer.name}>
<div className="Controls" >
{ isSelf && <div onTouchStart={toggleMute} onClick={toggleMute}>
{ muted && <MicOff color={colorAudio}/> }
{!muted && <Mic color={colorAudio}/> }
</div> }
{ !isSelf && <div onTouchStart={toggleMute} onClick={toggleMute}>
{muted && <VolumeOff color={colorAudio}/> }
{!muted && <VolumeUp color={colorAudio}/> }
</div> }
<div onTouchStart={toggleVideo} onClick={toggleVideo}>
{ !videoOn && <VideocamOff color={colorVideo}/> }
{videoOn && <Videocam color={colorVideo}/> }
</div>
</div>
{ isValid && <>
<Moveable
pinchable={true}
draggable={true}
target={target}
resizable={true}
keepRatio={true}
throttleResize={0}
hideDefaultLines={false}
edge={true}
zoom={1}
origin={false}
onDragStart={e => {
e.set(frame.translate);
}}
onDrag={e => {
frame.translate = e.beforeTranslate;
}}
onResizeStart={e => {
e.setOrigin(["%", "%"]);
e.dragStart && e.dragStart.set(frame.translate);
}}
onResize={e => {
const { translate, rotate, transformOrigin } = frame;
e.target.style.width = `${e.width}px`;
e.target.style.height = `${e.height}px`;
e.target.style.transform = `translate(${translate[0]}px, ${translate[1]}px)`;
}}
onRender={e => {
const { translate, rotate, transformOrigin } = frame;
//e.target.style.transformOrigin = transformOrigin;
e.target.style.transform = `translate(${translate[0]}px, ${translate[1]}px)`;
}}
/><Video className="Video"
data-id={media.name}
autoPlay='autoplay'
{...media.attributes}/>
</> }
{ !isValid && <video className="Video"></video> }
</div>
</>;
};
export { MediaControl, MediaAgent };

View File

@ -0,0 +1,20 @@
.PlayerColor {
display: inline-flex;
justify-content: center;
align-items: center;
width: 1em;
height: 1em;
padding: 0.125em;
margin: 0 0.25em;
border-radius: 0.625em;
border-width: 1px;
border-style: solid;
text-align: center;
}
.PlayerColor > div {
font-weight: bold;
overflow: hidden;
font-size: 0.75rem;
}

17
client/src/PlayerColor.js Normal file
View File

@ -0,0 +1,17 @@
import React from "react";
import Avatar from '@material-ui/core/Avatar';
import { useStyles } from './Styles.js';
import "./PlayerColor.css";
const PlayerColor = ({ color }) => {
const classes = useStyles();
return (
<Avatar className={['PlayerColor', classes[color]].join(' ')}/>
);
};
export { PlayerColor };

137
client/src/PlayerList.css Normal file
View File

@ -0,0 +1,137 @@
.PlayerList {
display: flex;
position: relative;
padding: 0.5em;
user-select: none;
flex-direction: column;
margin: 0.25rem 0.25rem 0.25rem 0;
}
.PlayerList .Name {
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.PlayerList .NoNetwork {
display: flex;
justify-self: flex-end;
width: 1em;
height: 1em;
background-image: url("./assets/no-network.png");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}
.PlayerList .Unselected {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
}
/* Player name in the Unselected list... */
.PlayerList .Unselected > div:nth-child(2) > div > div:first-child {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
}
.PlayerList .Unselected > div:nth-child(2) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
}
.PlayerList .Unselected > div:nth-child(2) > div {
justify-content: flex-end;
display: flex;
flex-direction: column;
align-items: center;
margin: 0.25rem;
padding: 0.25rem;
max-width: 8rem;
background-color: #eee;
border-radius: 0.25rem;
}
.PlayerList .Unselected .Self {
border: 1px solid black;
}
.PlayerList .PlayerSelector .PlayerColor {
width: 1em;
height: 1em;
}
.PlayerList .PlayerSelector {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
}
.PlayerList .PlayerSelector.MuiList-padding {
padding: 0;
}
.PlayerList .PlayerSelector .MuiTypography-body1 {
font-size: 0.8rem;
/* white-space: nowrap;*/
}
.PlayerList .PlayerSelector .MuiTypography-body2 {
font-size: 0.7rem;
white-space: nowrap;
}
.PlayerList .PlayerSelector .PlayerEntry {
display: flex;
flex-direction: column;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex: 1 1 0px;
align-items: flex-start;
border: 1px solid rgba(0,0,0,0);
border-radius: 0.25em;
min-width: 11em;
padding: 0 1px;
justify-content: flex-end;
}
.PlayerList .PlayerSelector .PlayerEntry > div:first-child {
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
margin-bottom: 0.25em;
}
.PlayerList .PlayerEntry[data-selectable=true]:hover {
border-color: rgba(0,0,0,0.5);
cursor: pointer;
}
.PlayerList .Players .PlayerToggle {
min-width: 5em;
display: inline-flex;
align-items: flex-end;
flex-direction: column;
}
.PlayerList .PlayerName {
padding: 0.5em;
}
.PlayerList .Players > * {
width: 100%;
}
.PlayerList .Players .nameInput {
flex-grow: 1;
}

177
client/src/PlayerList.js Normal file
View File

@ -0,0 +1,177 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import Paper from '@material-ui/core/Paper';
import List from '@material-ui/core/List';
import "./PlayerList.css";
import { PlayerColor } from './PlayerColor.js';
import { MediaAgent, MediaControl } from "./MediaControl.js";
import { GlobalContext } from "./GlobalContext.js";
const PlayerList = () => {
const { ws, name } = useContext(GlobalContext);
const [players, setPlayers] = useState({});
const [unselected, setUneslected] = useState([]);
const [state, setState] = useState('lobby');
const [color, setColor] = useState(undefined);
const [peers, setPeers] = useState({});
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
console.log(`player-list - game update`, data.update);
if ('unselected' in data.update) {
setUneslected(data.update.unselected);
}
if ('players' in data.update) {
let found = false;
for (let key in data.update.players) {
if (data.update.players[key].name === name) {
found = true;
setColor(key);
break;
}
}
if (!found) {
setColor(undefined);
}
setPlayers(data.update.players);
}
if ('state' in data.update && data.update.state !== state) {
setState(data.update.state);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
return () => {
ws.removeEventListener('message', cbMessage);
}
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(JSON.stringify({
type: 'get',
fields: [ 'state', 'players', 'unselected' ]
}));
}, [ws]);
const toggleSelected = (key) => {
ws.send(JSON.stringify({
type: 'set',
field: 'color',
value: color === key ? "" : key
}));
}
const playerElements = [];
const inLobby = state === 'lobby';
const sortedPlayers = [];
for (let key in players) {
sortedPlayers.push(players[key]);
}
const sortPlayers = (A, B) => {
/* active player first */
if (A.name === name) {
return -1;
}
if (B.name === name) {
return +1;
}
/* Sort active players first */
if (A.name && !B.name) {
return -1;
}
if (B.name && !A.name) {
return +1;
}
/* Ohterwise, sort by color */
return A.color.localeCompare(B.color);
};
sortedPlayers.sort(sortPlayers);
/* Array of just names... */
unselected.sort((A, B) => {
/* active player first */
if (A === name) {
return -1;
}
if (B === name) {
return +1;
}
/* Then sort alphabetically */
return A.localeCompare(B);
});
const videoClass = sortedPlayers.length <= 2 ? 'Medium' : 'Small';
sortedPlayers.forEach(player => {
const name = player.name;
const selectable = inLobby && (player.status === 'Not active' || color === player.color);
playerElements.push(
<div
data-selectable={selectable}
data-selected={player.color === color}
className="PlayerEntry"
onClick={() => { inLobby && selectable && toggleSelected(player.color) }}
key={`player-${player.color}`}>
<div>
<PlayerColor color={player.color}/>
<div className="Name">{name ? name : 'Available' }</div>
{ name && !player.live && <div className="NoNetwork"></div> }
</div>
{ name && player.live && <MediaControl className={videoClass} peer={peers[name]}
isSelf={player.color === color}/> }
{ !name && <div></div> }
</div>
);
});
const waiting = unselected.map((player) => {
return <div className={player === name ? 'Self' : ''} key={player}>
<div>{ player }</div>
<MediaControl className={'Small'} peer={peers[player]} isSelf={name === player}/>
</div>
});
return (
<Paper className={`PlayerList ${videoClass}`}>
<MediaAgent setPeers={setPeers}/>
<List className="PlayerSelector">
{ playerElements }
</List>
{ unselected && unselected.length !== 0 && <div className="Unselected">
<div>In lobby</div>
<div>
{ waiting }
</div>
</div> }
</Paper>
);
}
export { PlayerList };

17
client/src/PlayerName.css Normal file
View File

@ -0,0 +1,17 @@
.PlayerName {
padding: 0.5em;
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
}
.PlayerName > .nameInput {
margin-right: 1em;
flex: 1;
max-width: 30em;
}
.PlayerName > Button {
background: lightblue;
}

37
client/src/PlayerName.js Normal file
View File

@ -0,0 +1,37 @@
import React, { useState } from "react";
import "./PlayerName.css";
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
const PlayerName = ({ name, setName }) => {
const [edit, setEdit] = useState(name);
const sendName = () => {
setName(edit);
}
const nameChange = (event) => {
setEdit(event.target.value);
}
const nameKeyPress = (event) => {
if (event.key === "Enter") {
setName(edit ? edit : name);
}
}
return (
<div className="PlayerName">
<TextField className="nameInput"
onChange={nameChange}
onKeyPress={nameKeyPress}
label="Enter your name"
variant="outlined"
value={edit}
/>
<Button onClick={sendName}>Set</Button>
</div>
);
};
export { PlayerName };

View File

@ -0,0 +1,204 @@
.PlayersStatus {
display: flex;
flex-direction: column;
position: absolute;
color: #d0d0d0;
pointer-events: none;
align-items: flex-end;
right: 0;
width: 16rem;
padding: 0.25rem;
}
.PlayerStatus * {
display: inline-flex;
}
.PlayersStatus.ActivePlayer {
align-items: flex-start;
pointer-events: all;
right: auto;
bottom: 8rem; /* 1rem over top of Resource cards in hand */
}
.PlayersStatus .Player:not(:last-child) {
margin-bottom: 0.5rem;
border-bottom: 1px solid black;
}
.PlayersStatus.ActivePlayer .Player {
border-bottom: none;
}
.PlayersStatus .Player .Who {
color: white;
display: inline-flex;
align-items: center;
justify-content: flex-end;
padding-left: 0;
width: 100%;
}
.PlayersStatus.ActivePlayer .Who {
justify-content: flex-start;
padding-right: 0;
}
.PlayerStatus.ActivePlayer .Resource {
margin: 0.5rem 0 0 0.5rem;
}
.PlayersStatus .What {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.PlayersStatus .What > div {
margin-top: 0.25rem;
}
.PlayersStatus.ActivePlayer .What {
align-items: flex-start;
}
.PlayersStatus .PlayerColor {
width: 1rem;
height: 1rem;
}
.PlayersStatus .Shrunken {
position: relative;
height: 4.75rem;
display: flex;
margin-left: 0.5rem;
margin-top: 0.25rem;
}
.PlayersStatus .Normal {
position: relative;
height: 7rem;
display: flex;
margin-right: 0.5rem;
}
.PlayersStatus .BoardPieces {
align-items: flex-end;
bottom: 0;
right: 0;
border-bottom: none;
margin-bottom: 0;
}
.PlayersStatus.ActivePlayer .BoardPieces {
align-items: flex-start;
left: 0;
}
.PlayersStatus .Shrunken .BoardPieces {
align-items: flex-end;
right: 0;
transform-origin: 100% 100%;
transform: scale(0.75);
}
.PlayersStatus .Resource {
display: inline-flex;
align-items: center;
justify-content: space-around;
height: 3rem;
width: 2.1rem;
background-size: contain;
pointer-events: none;
margin: 0.75rem 0.5rem 0 0;
border-radius: 2px;
filter: brightness(150%);
}
.PlayersStatus .Has {
display: flex;
align-items: flex-end;
}
.PlayersStatus .Placard {
/*
width: 9.4em;
height: 11.44em;
*/
width: 3rem;
height: 3.64rem;
background-position: center;
background-size: 108%;
box-sizing: border-box;
border-radius: 2px;
border: 1px solid #fff;
margin: 0 0 0 0.75rem;
}
.PlayersStatus.ActivePlayer .Placard {
margin-right: 0.75rem;
}
.PlayersStatus .Placard > div.Right {
position: absolute;
display: flex;
align-items: center;
justify-content: space-around;
top: -0.5rem;
right: -1.25rem;
border-radius: 50%;
border: 1px solid white;
background-color: rgb(36, 148, 46);
font-size: 0.75rem;
width: 1.25rem;
height: 1.25rem;
text-align: center;
line-height: 1rem;
filter: brightness(150%);
}
.PlayersStatus .Points {
display: flex;
margin-top: 0.25rem;
}
.PlayersStatus .Points .Resource {
display: inline-flex;
align-items: center;
justify-content: space-around;
height: 1rem;
width: 1rem;
pointer-events: none;
border-radius: 50%;
border: 2px solid #444;
background-size: 130%;
margin: 0 0 0 -0.625rem;
}
.PlayersStatus .Points .Resource:first-child {
margin-left: 0;
}
.PlayersStatus .Stack:not(:first-child) {
margin-left: 0;
}
.PlayersStatus .Resource > div {
position: absolute;
top: -0.5rem;
right: -0.5rem;
border-radius: 50%;
border: 1px solid white;
background-color: rgb(36, 148, 46);
font-size: 0.75rem;
width: 1rem;
height: 1rem;
text-align: center;
line-height: 1rem;
}
.PlayersStatus .Points b {
margin-right: 0.25rem;
margin-left: 0.25rem;
}

211
client/src/PlayersStatus.js Normal file
View File

@ -0,0 +1,211 @@
import React, { useContext, useState, useMemo, useRef, useEffect } from "react";
import equal from "fast-deep-equal";
import "./PlayersStatus.css";
import { BoardPieces } from './BoardPieces.js';
import { Resource } from './Resource.js';
import { PlayerColor } from './PlayerColor.js';
import { Placard } from './Placard.js';
import { GlobalContext } from './GlobalContext.js';
const Player = ({ player, onClick, reverse, color,
largestArmy, isSelf, longestRoad, mostPorts, mostDeveloped }) => {
if (!player) {
return <>You are an observer.</>;
}
const developmentCards = player.unplayed
? <Resource label={true} type={'progress-back'}
count={player.unplayed} disabled/>
: undefined;
const resourceCards = player.resources
? <Resource label={true} type={'resource-back'}
count={player.resources} disabled/>
: undefined;
const armyCards = player.army
? <Resource label={true} type={'army-1'} count={player.army} disabled/>
: undefined;
let points = <></>;
if (player.points && reverse) {
points = <><b>{player.points}</b><Resource type={'progress-back'}
count={player.points} disabled/></>;
} else if (player.points) {
points = <><Resource type={'progress-back'} count={player.points}
disabled/><b>{player.points}</b></>;
}
const mostPortsPlacard = mostPorts && mostPorts === color ?
<Placard
disabled
active={false}
type='port-of-call'
count={player.ports}
/> : undefined;
const mostDevelopedPlacard = mostDeveloped && mostDeveloped === color ?
<Placard
disabled
active={false}
type='most-developed'
count={player.developmentCards}
/> : undefined;
const longestRoadPlacard = longestRoad && longestRoad === color ?
<Placard
disabled
active={false}
type='longest-road'
count={player.longestRoad}
/> : undefined;
const largestArmyPlacard = largestArmy && largestArmy === color ?
<Placard
disabled
active={false}
type='largest-army'
count={player.army}
/> : undefined;
return <div className="Player">
<div className="Who">
<PlayerColor color={color}/>{player.name}
</div>
<div className="What">
{ isSelf &&
<div className="LongestRoad">
Longest road: {player.longestRoad ? player.longestRoad : 0}
</div>
}
<div className="Points">{points}</div>
{ (largestArmy || longestRoad || armyCards || resourceCards || developmentCards || mostPorts || mostDeveloped) && <>
<div className="Has">
{ !reverse && <>
{ mostDevelopedPlacard }
{ mostPortsPlacard }
{ largestArmyPlacard }
{ longestRoadPlacard }
{ !largestArmyPlacard && armyCards }
{ developmentCards }
{ resourceCards }
</> }
{ reverse && <>
{ resourceCards }
{ developmentCards }
{ !largestArmyPlacard && armyCards }
{ longestRoadPlacard }
{ largestArmyPlacard }
{ mostPortsPlacard }
{ mostDevelopedPlacard }
</> }
</div>
</> }
</div>
<div className={`${onClick ? 'Normal' : 'Shrunken'}`}>
<BoardPieces onClick={onClick} player={player}/>
</div>
</div>
};
const PlayersStatus = ({ active }) => {
const { ws } = useContext(GlobalContext);
const [players, setPlayers] = useState(undefined);
const [color, setColor] = useState(undefined);
const [largestArmy, setLargestArmy] = useState(undefined);
const [longestRoad, setLongestRoad] = useState(undefined);
const [mostPorts, setMostPorts] = useState(undefined);
const [mostDeveloped, setMostDeveloped] = useState(undefined);
const fields = useMemo(() => [
'players', 'color', 'longestRoad', 'largestArmy', 'mostPorts', 'mostDeveloped'
], []);
const onWsMessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'game-update':
console.log(`players-status - game-update: `, data.update);
if ('players' in data.update && !equal(players, data.update.players)) {
setPlayers(data.update.players);
}
if ('color' in data.update && data.update.color !== color) {
setColor(data.update.color);
}
if ('longestRoad' in data.update
&& data.update.longestRoad !== longestRoad) {
setLongestRoad(data.update.longestRoad);
}
if ('largestArmy' in data.update
&& data.update.largestArmy !== largestArmy) {
setLargestArmy(data.update.largestArmy);
}
if ('mostDeveloped' in data.update
&& data.update.mostDeveloped !== mostDeveloped) {
setMostDeveloped(data.update.mostDeveloped);
}
if ('mostPorts' in data.update
&& data.update.mostPorts !== mostPorts) {
setMostPorts(data.update.mostPorts);
}
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!ws) { return; }
const cbMessage = e => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
return () => { ws.removeEventListener('message', cbMessage); }
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) { return; }
ws.send(JSON.stringify({
type: 'get',
fields
}));
}, [ws, fields]);
if (!players) {
return <></>;
}
const buildItem = () => {
console.log(`player-status - build-item`);
}
let elements;
if (active) {
elements = <Player
player={players[color]}
onClick={buildItem}
reverse
largestArmy={largestArmy}
longestRoad={longestRoad}
mostPorts={mostPorts}
mostDeveloped={mostDeveloped}
isSelf={active}
key={`PlayerStatus-${color}`}
color={color}/>;
} else {
elements = Object.getOwnPropertyNames(players)
.filter(key => color !== key)
.map(key => {
return <Player
player={players[key]}
largestArmy={largestArmy}
longestRoad={longestRoad}
mostPorts={mostPorts}
mostDeveloped={mostDeveloped}
key={`PlayerStatus-${key}}`}
color={key}/>;
});
}
return (
<div className={`PlayersStatus ${active ? 'ActivePlayer' : ''}`}>
{ elements }
</div>
);
}
export { PlayersStatus };

12
client/src/setupProxy.js Normal file
View File

@ -0,0 +1,12 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
const base = process.env.PUBLIC_URL;
console.log(`http-proxy-middleware ${base}`);
app.use(createProxyMiddleware(
`${base}/api/v1/games/ws`, {
ws: true,
target: 'http://localhost:8930',
changeOrigin: true,
}));
};

2
server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
games/*
!games/.keep

120
server/app.js Normal file → Executable file
View File

@ -0,0 +1,120 @@
"use strict";
process.env.TZ = "Etc/GMT";
console.log("Loading ketr.ketran");
const express = require("express"),
bodyParser = require("body-parser"),
config = require("config"),
session = require('express-session'),
basePath = require("./lib/basepath"),
cookieParser = require("cookie-parser"),
app = express(),
fs = require('fs');
const server = require("http").createServer(app);
app.use(cookieParser());
const ws = require('express-ws')(app, server);
require("./lib/console-line.js"); /* Monkey-patch console.log with line numbers */
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
serverConfig = config.get("server");
console.log("Hosting server from: " + basePath);
let userDB, chatDB;
app.use(bodyParser.json());
/* App is behind an nginx proxy which we trust, so use the remote address
* set in the headers */
app.set("trust proxy", true);
app.set("basePath", basePath);
app.use(basePath, require("./routes/basepath.js"));
/* Handle static files first so excessive logging doesn't occur */
app.use(basePath, express.static(frontendPath, { index: false }));
const index = require("./routes/index");
if (config.has("admin")) {
const admin = config.get("admin");
app.set("admin", admin);
}
/* Allow loading of the app w/out being logged in */
app.use(basePath, index);
/* /chat loads the default index */
app.use(basePath + "chat", index);
/* Allow access to the 'users' API w/out being logged in */
/*
const users = require("./routes/users");
app.use(basePath + "api/v1/users", users.router);
*/
app.use(function(err, req, res, next) {
console.error(err.message);
res.status(err.status || 500).json({
message: err.message,
error: {}
});
});
app.use(`${basePath}api/v1/chat`, require("./routes/chat"));
/* Declare the "catch all" index route last; the final route is a 404 dynamic router */
app.use(basePath, index);
/**
* Create HTTP server and listen for new connections
*/
app.set("port", serverConfig.port);
process.on('SIGINT', () => {
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
setTimeout(() => process.exit(-1), 2000);
server.close(() => process.exit(1));
});
require("./db/chat").then(function(db) {
chatDB = db;
}).then(function() {
return require("./db/users").then(function(db) {
userDB = db;
});
}).then(function() {
console.log("DB connected. Opening server.");
server.listen(serverConfig.port, () => {
console.log(`http/ws server listening on ${serverConfig.port}`);
});
}).catch(function(error) {
console.error(error);
process.exit(-1);
});
server.on("error", function(error) {
if (error.syscall !== "listen") {
throw error;
}
// handle specific listen errors with friendly messages
switch (error.code) {
case "EACCES":
console.error(serverConfig.port + " requires elevated privileges");
process.exit(1);
break;
case "EADDRINUSE":
console.error(serverConfig.port + " is already in use");
process.exit(1);
break;
default:
throw error;
}
});

1
server/config/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
local.json

19
server/config/default.json Executable file
View File

@ -0,0 +1,19 @@
{
"db": {
"users": {
"dialect": "sqlite",
"storage": "../db/users.db",
"logging" : false,
"timezone": "+00:00"
}
},
"server": {
"port": 8930
},
"frontendPath": "./",
"basePath": "/",
"sessions": {
"db": "../db/sessions.db",
"store-secret": "m@g1kc00ki3z!"
}
}

View File

@ -0,0 +1 @@
{}

44
server/db/chat.js Executable file
View File

@ -0,0 +1,44 @@
"use strict";
const fs = require('fs'),
path = require('path'),
Sequelize = require('sequelize'),
config = require('config');
function init() {
const db = {
sequelize: new Sequelize(config.get("db.chats")),
Sequelize: Sequelize
};
return db.sequelize.authenticate().then(function () {
const Chat = db.sequelize.define('chat', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
path: Sequelize.STRING,
name: Sequelize.STRING,
}, {
timestamps: false,
classMethods: {
associate: function() {
}
}
});
return db.sequelize.sync({
force: false
}).then(function () {
return db;
});
}).catch(function (error) {
console.log("ERROR: Failed to authenticate with CHATS DB");
console.log("ERROR: " + JSON.stringify(config.get("db"), null, 2));
console.log(error);
throw error;
});
}
module.exports = init();

70
server/db/users.js Executable file
View File

@ -0,0 +1,70 @@
"use strict";
const Sequelize = require('sequelize'),
config = require('config');
function init() {
const db = {
sequelize: new Sequelize(config.get("db.users")),
Sequelize: Sequelize
};
return db.sequelize.authenticate().then(function () {
const User = db.sequelize.define('users', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
displayName: Sequelize.STRING,
notes: Sequelize.STRING,
uid: Sequelize.STRING,
authToken: Sequelize.STRING,
authDate: Sequelize.DATE,
authenticated: Sequelize.BOOLEAN,
mailVerified: Sequelize.BOOLEAN,
mail: Sequelize.STRING,
memberSince: Sequelize.DATE,
password: Sequelize.STRING, /* SHA hash of user supplied password */
passwordExpires: Sequelize.DATE
}, {
timestamps: false
});
const Authentication = db.sequelize.define('authentication', {
key: {
type: Sequelize.STRING,
primaryKey: true,
allowNull: false
},
issued: Sequelize.DATE,
type: {
type: Sequelize.ENUM,
values: [ 'account-setup', 'password-reset' ]
},
userId: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: User,
key: 'id',
}
}
}, {
timestamps: false
});
return db.sequelize.sync({
force: false
}).then(function () {
return db;
});
}).catch(function (error) {
console.log("ERROR: Failed to authenticate with USER DB");
console.log("ERROR: " + JSON.stringify(config.get("db"), null, 2));
console.log(error);
throw error;
});
}
module.exports = init();

9
server/lib/basepath.js Executable file
View File

@ -0,0 +1,9 @@
let basePath = process.env.REACT_APP_basePath;
basePath = "/" + basePath.replace(/^\/+/, "").replace(/\/+$/, "") + "/";
if (basePath == "//") {
basePath = "/";
}
console.log(`Using basepath ${basePath}`);
module.exports = basePath;

30
server/lib/console-line.js Executable file
View File

@ -0,0 +1,30 @@
/* monkey-patch console.log to prefix with file/line-number */
if (process.env.LOG_LINE) {
let cwd = process.cwd(),
cwdRe = new RegExp("^[^/]*" + cwd.replace("/", "\\/") + "\/([^:]*:[0-9]*).*$");
[ "log", "warn", "error" ].forEach(function(method) {
console[method] = (function () {
let orig = console[method];
return function () {
function getErrorObject() {
try {
throw Error('');
} catch (err) {
return err;
}
}
let err = getErrorObject(),
caller_line = err.stack.split("\n")[3],
args = [caller_line.replace(cwdRe, "$1 -")];
/* arguments.unshift() doesn't exist... */
for (var i = 0; i < arguments.length; i++) {
args.push(arguments[i]);
}
orig.apply(this, args);
};
})();
});
}

View File

@ -6,7 +6,7 @@
"start": "export $(cat ../.env | xargs) && node app.js"
},
"author": "James Ketrenos <james_ketr_chat@ketrenos.com>",
"license": "LICENSE FILE LICENSE",
"license": "MIT",
"private": true,
"dependencies": {
"bluebird": "^3.5.5",
@ -17,13 +17,22 @@
"core-js": "^3.21.1",
"express": "^4.17.3",
"express-session": "^1.17.1",
"express-ws": "^5.0.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^5.0.1",
"handlebars": "^4.7.6",
"moment": "^2.24.0",
"morgan": "^1.9.1",
"node-fetch": "^2.6.0",
"node-gzip": "^1.1.2",
"nodemailer": "^6.3.0",
"typeface-roboto": "0.0.75"
"random-words": "^1.1.2",
"sequelize": "^5.21.6",
"sqlite3": "^4.1.1",
"typeface-roboto": "0.0.75",
"ws": "^8.5.0"
},
"repository": {
"type": "git",
"url": "git@git.ketrenos.com:jketreno/ketr.chat"
}
}

33
server/routes/basepath.js Normal file
View File

@ -0,0 +1,33 @@
"use strict";
const express = require("express"),
fs = require("fs"),
url = require("url");
const router = express.Router();
/* This router only handles HTML files and is used
* to replace BASEPATH */
router.get("/*", (req, res, next) => {
const parts = url.parse(req.url),
basePath = req.app.get("basePath");
if (!/^\/[^/]+\.html$/.exec(parts.pathname)) {
return next();
}
console.log("Attempting to parse 'frontend" + parts.pathname + "'");
/* Replace <script>'<base href="/BASEPATH/">';</script> in index.html with
* the basePath */
fs.readFile("frontend" + parts.pathname, "utf8", function(error, content) {
if (error) {
return next();
}
res.send(content.replace(
/<script>'<base href="BASEPATH">';<\/script>/,
"<base href='" + basePath + "'>"));
});
});
module.exports = router;

1659
server/routes/chat.js Executable file

File diff suppressed because it is too large Load Diff

66
server/routes/index.js Executable file
View File

@ -0,0 +1,66 @@
"use strict";
const express = require("express"),
fs = require("fs"),
url = require("url"),
config = require("config"),
basePath = require("../basepath");
const router = express.Router();
/* List of filename extensions we know are "potential" file extensions for
* assets we don"t want to return "index.html" for */
const extensions = [
"html", "js", "css", "eot", "gif", "ico", "jpeg", "jpg", "mp4",
"md", "ttf", "txt", "woff", "woff2", "yml", "svg"
];
/* Build the extension match RegExp from the list of extensions */
const extensionMatch = new RegExp("^.*?(" + extensions.join("|") + ")$", "i");
/* To handle dynamic routes, we return index.html to every request that
* gets this far -- so this needs to be the last route.
*
* However, that introduces site development problems when assets are
* referenced which don't yet exist (due to bugs, or sequence of adds) --
* the server would return HTML content instead of the 404.
*
* So, check to see if the requested path is for an asset with a recognized
* file extension.
*
* If so, 404 because the asset isn't there. otherwise assume it is a
* dynamic client side route and *then* return index.html.
*/
router.get("/*", function(req, res, next) {
const parts = url.parse(req.url);
/* If req.user isn't set yet (authentication hasn't happened) then
* only allow / to be loaded--everything else chains to the next
* handler */
if (!req.user &&
req.url != "/" &&
req.url.indexOf("/games") != 0) {
return next();
}
if (req.url == "/" || req.url.indexOf("/games") == 0 || !extensionMatch.exec(parts.pathname)) {
console.log("Returning index for " + req.url);
/* Replace <script>'<base href="BASEPATH">';</script> in index.html with
* the basePath */
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
index = fs.readFileSync(frontendPath + "index.html", "utf8");
res.send(index.replace(
/<script>'<base href="BASEPATH">';<\/script>/,
"<base href='" + basePath + "'>"));
return;
}
console.log("Page not found: " + req.url);
return res.status(404).json({
message: "Page not found",
status: 404
});
});
module.exports = router;

283
server/routes/users.js Executable file
View File

@ -0,0 +1,283 @@
"use strict";
const express = require("express"),
config = require("config"),
{ sendVerifyMail, sendPasswordChangedMail } = require("../lib/mail"),
crypto = require("crypto");
const router = express.Router();
let userDB;
require("../db/users.js").then(function(db) {
userDB = db;
});
router.get("/", function(req, res/*, next*/) {
console.log("/users/");
return getSessionUser(req).then((user) => {
return res.status(200).send(user);
}).catch((error) => {
console.log("User not logged in: " + error);
return res.status(200).send({});
});
});
router.put("/password", function(req, res) {
console.log("/users/password");
const changes = {
currentPassword: req.query.c || req.body.c,
newPassword: req.query.n || req.body.n
};
if (!changes.currentPassword || !changes.newPassword) {
return res.status(400).send("Missing current password and/or new password.");
}
if (changes.currentPassword == changes.newPassword) {
return res.status(400).send("Attempt to set new password to current password.");
}
return getSessionUser(req).then(function(user) {
return userDB.sequelize.query("SELECT id FROM users " +
"WHERE uid=:username AND password=:password", {
replacements: {
username: user.username,
password: crypto.createHash('sha256').update(changes.currentPassword).digest('base64')
},
type: userDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(users) {
if (users.length != 1) {
return null;
}
return user;
});
}).then(function(user) {
if (!user) {
console.log("Invalid password");
/* Invalid password */
res.status(401).send("Invalid password");
return null;
}
return userDB.sequelize.query("UPDATE users SET password=:password WHERE uid=:username", {
replacements: {
username: user.username,
password: crypto.createHash('sha256').update(changes.newPassword).digest('base64')
}
}).then(function() {
console.log("Password changed for user " + user.username + " to '" + changes.newPassword + "'.");
res.status(200).send(user);
user.id = req.session.userId;
return sendPasswordChangedMail(userDB, req, user);
});
});
});
router.post("/create", function(req, res) {
console.log("/users/create");
const user = {
uid: req.query.m || req.body.m,
displayName: req.query.n || req.body.n || "",
password: req.query.p || req.body.p || "",
mail: req.query.m || req.body.m,
notes: req.query.w || req.body.w || ""
};
if (!user.uid || !user.password || !user.displayName || !user.notes) {
return res.status(400).send("Missing email address, password, name, and/or who you know.");
}
user.password = crypto.createHash('sha256').update(user.password).digest('base64');
return userDB.sequelize.query("SELECT * FROM users WHERE uid=:uid", {
replacements: user,
type: userDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(results) {
if (results.length != 0) {
return res.status(400).send("Email address already used.");
}
let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (!re.exec(user.mail)) {
console.log("Invalid email address: " + user.mail);
throw "Invalid email address.";
}
}).then(function() {
return userDB.sequelize.query("INSERT INTO users " +
"(uid,displayName,password,mail,memberSince,authenticated,notes) " +
"VALUES(:uid,:displayName,:password,:mail,CURRENT_TIMESTAMP,0,:notes)", {
replacements: user
}).spread(function(results, metadata) {
req.session.userId = metadata.lastID;
}).then(function() {
return getSessionUser(req).then(function(user) {
res.status(200).send(user);
user.id = req.session.userId;
return sendVerifyMail(userDB, req, user);
});
}).catch(function(error) {
console.log("Error creating account: ", error);
return res.status(401).send(error);
});
});
});
const getSessionUser = function(req) {
return Promise.resolve().then(function() {
if (!req.session || !req.session.userId) {
throw "Unauthorized. You must be logged in.";
}
let query = "SELECT " +
"uid AS username,displayName,mailVerified,authenticated,memberSince AS name,mail " +
"FROM users WHERE id=:id";
return userDB.sequelize.query(query, {
replacements: {
id: req.session.userId
},
type: userDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(results) {
if (results.length != 1) {
throw "Invalid account.";
}
let user = results[0];
if (!user.mailVerified) {
user.restriction = user.restriction || "Email address not verified.";
return user;
}
if (!user.authenticated) {
user.restriction = user.restriction || "Accout not authorized.";
return user;
}
return user;
});
}).then(function(user) {
req.user = user;
/* If the user already has a restriction, or there are no album user restrictions,
* return the user to the next promise */
if (user.restriction || !config.has("restrictions")) {
return user;
}
let allowed = config.get("restrictions");
if (!Array.isArray(allowed)) {
allowed = [ allowed ];
}
for (let i = 0; i < allowed.length; i++) {
if (allowed[i] == user.username) {
return user;
}
}
console.log("Unauthorized (logged in) access by user: " + user.username);
user.restriction = "Unauthorized access attempt to restricted album.";
return user;
}).then(function(user) {
/* If there are maintainers on this album, check if this user is a maintainer */
if (config.has("maintainers")) {
let maintainers = config.get("maintainers");
if (maintainers.indexOf(user.username) != -1) {
user.maintainer = true;
if (user.restriction) {
console.warn("User " + user.username + " is a maintainer AND has a restriction which will be ignored: " + user.restriction);
delete user.restriction;
}
}
}
return user;
}).then(function(user) {
/* Strip out any fields that shouldn't be there. The allowed fields are: */
let allowed = [
"maintainer", "username", "displayName", "mailVerified", "authenticated", "name", "mail", "restriction"
];
for (let field in user) {
if (allowed.indexOf(field) == -1) {
delete user[field];
}
}
return user;
});
}
router.post("/login", function(req, res) {
console.log("/users/login");
let username = req.query.u || req.body.u || "",
password = req.query.p || req.body.p || "";
console.log("Login attempt");
if (!username || !password) {
return res.status(400).send("Missing username and/or password");
}
return new Promise((reject, resolve) => {
console.log("Looking up user in DB.");
let query = "SELECT " +
"id,mailVerified,authenticated,uid AS username,displayName AS name,mail " +
"FROM users WHERE uid=:username AND password=:password";
return userDB.sequelize.query(query, {
replacements: {
username: username,
password: crypto.createHash('sha256').update(password).digest('base64')
},
type: userDB.Sequelize.QueryTypes.SELECT
}).then(function(users) {
if (users.length != 1) {
return resolve(null);
}
let user = users[0];
req.session.userId = user.id;
return resolve(user);
});
}).then(function(user) {
if (!user) {
console.log(username + " not found (or invalid password.)");
req.session.userId = null;
return res.status(401).send("Invalid login credentials");
}
let message = "Logged in as " + user.username + " (" + user.id + ")";
if (!user.mailVerified) {
console.log(message + ", who is not verified email.");
} else if (!user.authenticated) {
console.log(message + ", who is not authenticated.");
} else {
console.log(message);
}
return getSessionUser(req).then(function(user) {
return res.status(200).send(user);
});
}).catch(function(error) {
console.log(error);
return res.status(403).send(error);
});
});
router.get("/logout", function(req, res) {
console.log("/users/logout");
if (req.session && req.session.userId) {
req.session.userId = null;
}
res.status(200).send({});
});
module.exports = {
router,
getSessionUser
};