Seeding with chat subsystem from peddlers of ketran
Signed-off-by: James Ketrenos <james@ketrenos.com>
This commit is contained in:
parent
3e5d4b0cbf
commit
84ef980c42
4
client/.babelrc
Normal file
4
client/.babelrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": [ "@babel/env", "@babel/preset-react" ],
|
||||
"plugins": [ "@babel/plugin-proposal-class-properties" ]
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
92
client/src/MediaControl.css
Normal file
92
client/src/MediaControl.css
Normal 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
711
client/src/MediaControl.js
Normal 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 };
|
20
client/src/PlayerColor.css
Normal file
20
client/src/PlayerColor.css
Normal 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
17
client/src/PlayerColor.js
Normal 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
137
client/src/PlayerList.css
Normal 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
177
client/src/PlayerList.js
Normal 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
17
client/src/PlayerName.css
Normal 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
37
client/src/PlayerName.js
Normal 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 };
|
204
client/src/PlayersStatus.css
Normal file
204
client/src/PlayersStatus.css
Normal 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
211
client/src/PlayersStatus.js
Normal 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
12
client/src/setupProxy.js
Normal 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
2
server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
games/*
|
||||
!games/.keep
|
120
server/app.js
Normal file → Executable file
120
server/app.js
Normal file → Executable 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
1
server/config/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
local.json
|
19
server/config/default.json
Executable file
19
server/config/default.json
Executable 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!"
|
||||
}
|
||||
}
|
1
server/config/production.json
Normal file
1
server/config/production.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
44
server/db/chat.js
Executable file
44
server/db/chat.js
Executable 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
70
server/db/users.js
Executable 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
9
server/lib/basepath.js
Executable 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
30
server/lib/console-line.js
Executable 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);
|
||||
};
|
||||
})();
|
||||
});
|
||||
}
|
@ -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
33
server/routes/basepath.js
Normal 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
1659
server/routes/chat.js
Executable file
File diff suppressed because it is too large
Load Diff
66
server/routes/index.js
Executable file
66
server/routes/index.js
Executable 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
283
server/routes/users.js
Executable 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
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user