aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorvellichora <fangrui_tong@brown.edu>2020-01-07 10:49:04 -0500
committervellichora <fangrui_tong@brown.edu>2020-01-07 10:49:04 -0500
commit3633971bd9c1f739c0d6facd74754b99a7f26db6 (patch)
tree62aab716a9a027b3b63673e3134fa651135635e5 /src
parent9614ba541c30dc7d2b5183d5450864354d911643 (diff)
parentccd39c9a53ebf9aea84fcdcba6050145add4526f (diff)
Merge branch 'master' of https://github.com/browngraphicslab/Dash-Web into textbox_fawn_fix
Diffstat (limited to 'src')
-rw-r--r--src/client/util/DocumentManager.ts30
-rw-r--r--src/client/util/RichTextRules.ts4
-rw-r--r--src/client/util/RichTextSchema.tsx4
-rw-r--r--src/client/util/TooltipTextMenu.tsx4
-rw-r--r--src/client/views/DocumentButtonBar.tsx3
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx11
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx14
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx22
-rw-r--r--src/client/views/nodes/DocuLinkBox.tsx25
-rw-r--r--src/client/views/nodes/DocumentView.scss1
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx4
-rw-r--r--src/client/views/nodes/FormattedTextBoxComment.tsx2
-rw-r--r--src/client/views/nodes/PDFBox.tsx3
-rw-r--r--src/client/views/pdf/PDFViewer.tsx6
-rw-r--r--src/server/ActionUtilities.ts36
-rw-r--r--src/server/ApiManagers/SearchManager.ts3
-rw-r--r--src/server/Session/session.ts338
-rw-r--r--src/server/Session/session_config_schema.ts35
-rw-r--r--src/server/Websocket/Websocket.ts7
-rw-r--r--src/server/index.ts82
-rw-r--r--src/server/repl.ts (renamed from src/server/session_manager/input_manager.ts)36
-rw-r--r--src/server/server_Initialization.ts (renamed from src/server/Initialization.ts)25
-rw-r--r--src/server/session.ts142
-rw-r--r--src/server/session_manager/config.ts33
-rw-r--r--src/server/session_manager/email.ts26
-rw-r--r--src/server/session_manager/logs/current_daemon_pid.log1
-rw-r--r--src/server/session_manager/logs/current_server_pid.log1
-rw-r--r--src/server/session_manager/logs/current_session_manager_pid.log1
-rw-r--r--src/server/session_manager/session_manager.ts206
-rw-r--r--src/server/session_manager/session_manager_cluster.ts36
30 files changed, 601 insertions, 540 deletions
diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts
index a318dede8..fb4c2155a 100644
--- a/src/client/util/DocumentManager.ts
+++ b/src/client/util/DocumentManager.ts
@@ -102,27 +102,27 @@ export class DocumentManager {
@computed
public get LinkedDocumentViews() {
- const pairs = DocumentManager.Instance.DocumentViews.filter(dv =>
- (dv.isSelected() || Doc.IsBrushed(dv.props.Document)) // draw links from DocumentViews that are selected or brushed OR
+ const pairs = DocumentManager.Instance.DocumentViews
+ //.filter(dv => (dv.isSelected() || Doc.IsBrushed(dv.props.Document))) // draw links from DocumentViews that are selected or brushed OR
// || DocumentManager.Instance.DocumentViews.some(dv2 => { // Documentviews which
// const rest = DocListCast(dv2.props.Document.links).some(l => Doc.AreProtosEqual(l, dv.props.Document));// are link doc anchors
// const init = (dv2.isSelected() || Doc.IsBrushed(dv2.props.Document)) && dv2.Document.type !== DocumentType.AUDIO; // on a view that is selected or brushed
// return init && rest;
// }
// )
- ).reduce((pairs, dv) => {
- const linksList = LinkManager.Instance.getAllRelatedLinks(dv.props.Document);
- pairs.push(...linksList.reduce((pairs, link) => {
- const linkToDoc = link && LinkManager.Instance.getOppositeAnchor(link, dv.props.Document);
- linkToDoc && DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => {
- if (dv.props.Document.type !== DocumentType.LINK || dv.props.layoutKey !== docView1.props.layoutKey) {
- pairs.push({ a: dv, b: docView1, l: link });
- }
- });
+ .reduce((pairs, dv) => {
+ const linksList = LinkManager.Instance.getAllRelatedLinks(dv.props.Document);
+ pairs.push(...linksList.reduce((pairs, link) => {
+ const linkToDoc = link && LinkManager.Instance.getOppositeAnchor(link, dv.props.Document);
+ linkToDoc && DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => {
+ if (dv.props.Document.type !== DocumentType.LINK || dv.props.layoutKey !== docView1.props.layoutKey) {
+ pairs.push({ a: dv, b: docView1, l: link });
+ }
+ });
+ return pairs;
+ }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]));
return pairs;
- }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]));
- return pairs;
- }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]);
+ }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]);
return pairs;
}
@@ -136,7 +136,7 @@ export class DocumentManager {
const docView = DocumentManager.Instance.getFirstDocumentView(targetDoc);
let annotatedDoc = await Cast(docView?.props.Document.annotationOn, Doc);
if (annotatedDoc) {
- let first = DocumentManager.Instance.getFirstDocumentView(annotatedDoc);
+ const first = DocumentManager.Instance.getFirstDocumentView(annotatedDoc);
if (first) annotatedDoc = first.props.Document;
}
if (docView) { // we have a docView already and aren't forced to create a new one ... just focus on the document. TODO move into view if necessary otherwise just highlight?
diff --git a/src/client/util/RichTextRules.ts b/src/client/util/RichTextRules.ts
index 5148af889..c980a8003 100644
--- a/src/client/util/RichTextRules.ts
+++ b/src/client/util/RichTextRules.ts
@@ -85,11 +85,11 @@ export const inpRules = {
const value = state.doc.textBetween(start, end);
if (value) {
DocServer.GetRefField(value).then(docx => {
- const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: value, width: 500, height: 500 }, value);
+ const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: value, width: 500, height: 500, }, value);
DocUtils.Publish(target, value, returnFalse, returnFalse);
DocUtils.MakeLink({ doc: (schema as any).Document }, { doc: target }, "portal link", "");
});
- const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + value), location: "onRight", title: value });
+ const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + value), location: "onRight", title: value, targetId: value });
return state.tr.addMark(start, end, link);
}
return state.tr;
diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx
index 622e71812..2a5b348d2 100644
--- a/src/client/util/RichTextSchema.tsx
+++ b/src/client/util/RichTextSchema.tsx
@@ -274,6 +274,8 @@ export const marks: { [index: string]: MarkSpec } = {
link: {
attrs: {
href: {},
+ targetId: { default: "" },
+ showPreview: { default: true },
location: { default: null },
title: { default: null },
docref: { default: false } // flags whether the linked text comes from a document within Dash. If so, an attribution label is appended after the text
@@ -287,7 +289,7 @@ export const marks: { [index: string]: MarkSpec } = {
toDOM(node: any) {
return node.attrs.docref && node.attrs.title ?
["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, class: "prosemirror-attribution", title: `${node.attrs.title}` }, node.attrs.title], ["br"]] :
- ["a", { ...node.attrs, title: `${node.attrs.title}` }, 0];
+ ["a", { ...node.attrs, id: node.attrs.targetId, title: `${node.attrs.title}` }, 0];
}
},
diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx
index fbdb9e377..33257b658 100644
--- a/src/client/util/TooltipTextMenu.tsx
+++ b/src/client/util/TooltipTextMenu.tsx
@@ -449,8 +449,8 @@ export class TooltipTextMenu {
// let link = state.schema.mark(state.schema.marks.link, { href: target, location: location });
// }
- makeLink = (targetDoc: Doc, title: string, location: string): string => {
- const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + targetDoc[Id]), title: title, location: location });
+ makeLink = (linkDocId: string, title: string, location: string, targetDocId: string): string => {
+ const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, targetId: targetDocId });
this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link).
addMark(this.view.state.selection.from, this.view.state.selection.to, link));
const node = this.view.state.selection.$from.nodeAfter;
diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx
index 37b5ef3ec..0ef842275 100644
--- a/src/client/views/DocumentButtonBar.tsx
+++ b/src/client/views/DocumentButtonBar.tsx
@@ -20,6 +20,7 @@ import React = require("react");
import { DocumentView } from './nodes/DocumentView';
import { ParentDocSelector } from './collections/ParentDocumentSelector';
import { CollectionDockingView } from './collections/CollectionDockingView';
+import { Id } from '../../new_fields/FieldSymbols';
const higflyout = require("@hig/flyout");
export const { anchorPoints } = higflyout;
export const Flyout = higflyout.default;
@@ -117,8 +118,8 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView |
proto.sourceContext = this.view0.props.ContainingCollectionDoc;
const anchor2Title = linkDoc.anchor2 instanceof Doc ? StrCast(linkDoc.anchor2.title) : "-untitled-";
- const text = FormattedTextBox.ToolTipTextMenu.makeLink(linkDoc, anchor2Title, e.ctrlKey ? "onRight" : "inTab");
if (linkDoc.anchor2 instanceof Doc) {
+ const text = FormattedTextBox.ToolTipTextMenu.makeLink(linkDoc[Id], anchor2Title, e.ctrlKey ? "onRight" : "inTab", linkDoc.anchor2[Id]);
proto.title = text === "" ? proto.title : text + " to " + linkDoc.anchor2.title; // TODO open to more descriptive descriptions of following in text link
}
}
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
index 5e4b4fd27..059393142 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
@@ -66,9 +66,12 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo
apt.point.x, apt.point.y);
const pt1 = [apt.point.x, apt.point.y];
const pt2 = [bpt.point.x, bpt.point.y];
- return (<line key="linkLine" className="collectionfreeformlinkview-linkLine"
- style={{ opacity: this._opacity }}
- x1={`${pt1[0]}`} y1={`${pt1[1]}`}
- x2={`${pt2[0]}`} y2={`${pt2[1]}`} />);
+ let aActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document);
+ let bActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document);
+ return !aActive && !bActive ? (null) :
+ <line key="linkLine" className="collectionfreeformlinkview-linkLine"
+ style={{ opacity: this._opacity }}
+ x1={`${pt1[0]}`} y1={`${pt1[1]}`}
+ x2={`${pt2[0]}`} y2={`${pt2[1]}`} />;
}
} \ No newline at end of file
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx
index 218012fe1..044d35eca 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx
@@ -91,13 +91,11 @@ export class CollectionFreeFormLinksView extends React.Component {
}
render() {
- return (
- <div className="collectionfreeformlinksview-container">
- <svg className="collectionfreeformlinksview-svgCanvas">
- {SelectionManager.GetIsDragging() ? (null) : this.uniqueConnections}
- </svg>
- {this.props.children}
- </div>
- );
+ return <div className="collectionfreeformlinksview-container">
+ <svg className="collectionfreeformlinksview-svgCanvas">
+ {SelectionManager.GetIsDragging() ? (null) : this.uniqueConnections}
+ </svg>
+ {this.props.children}
+ </div>;
}
} \ No newline at end of file
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 6fd353b41..6af29171e 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -805,7 +805,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
layoutItems.push({ description: "Arrange contents in grid", event: this.layoutDocsInGrid, icon: "table" });
layoutItems.push({ description: "Analyze Strokes", event: this.analyzeStrokes, icon: "paint-brush" });
layoutItems.push({ description: "Jitter Rotation", event: action(() => this.props.Document.jitterRotation = 10), icon: "paint-brush" });
- layoutItems.push({ description: "Import document", icon: "upload", event: ({ x, y }) => {
+ layoutItems.push({
+ description: "Import document", icon: "upload", event: ({ x, y }) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".zip";
@@ -907,16 +908,15 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
// this.Document.fitH = this.contentBounds && (this.contentBounds.b - this.contentBounds.y);
// if isAnnotationOverlay is set, then children will be stored in the extension document for the fieldKey.
// otherwise, they are stored in fieldKey. All annotations to this document are stored in the extension document
- return !this.extensionDoc ? (null) :
- <div className={"collectionfreeformview-container"} ref={this.createDropTarget} onWheel={this.onPointerWheel}//pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined,
- style={{ pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, height: this.isAnnotationOverlay ? (this.props.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() }}
- onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} onTouchStart={this.onTouchStart}>
- {!BoolCast(this.Document.LODdisable) && !this.props.isAnnotationOverlay && this.props.renderDepth > 0 && this.props.CollectionView &&
- this.Document[WidthSym]() * this.Document[HeightSym]() / this.props.ScreenToLocalTransform().Scale / this.props.ScreenToLocalTransform().Scale <
- NumCast(this.Document.LODarea, 100000) ?
- this.placeholder : this.marqueeView}
- <CollectionFreeFormOverlayView elements={this.elementFunc} />
- </div>;
+ if (!this.extensionDoc) return (null);
+ // let lodarea = this.Document[WidthSym]() * this.Document[HeightSym]() / this.props.ScreenToLocalTransform().Scale / this.props.ScreenToLocalTransform().Scale;
+ return <div className={"collectionfreeformview-container"} ref={this.createDropTarget} onWheel={this.onPointerWheel}//pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined,
+ style={{ pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, height: this.isAnnotationOverlay ? (this.props.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() }}
+ onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} onTouchStart={this.onTouchStart}>
+ {!this.Document.LODdisable && !this.props.active() && !this.props.isAnnotationOverlay && !this.props.annotationsKey && this.props.renderDepth > 0 ? // && this.props.CollectionView && lodarea < NumCast(this.Document.LODarea, 100000) ?
+ this.placeholder : this.marqueeView}
+ <CollectionFreeFormOverlayView elements={this.elementFunc} />
+ </div>;
}
}
diff --git a/src/client/views/nodes/DocuLinkBox.tsx b/src/client/views/nodes/DocuLinkBox.tsx
index 3e2e74c67..d17b2e498 100644
--- a/src/client/views/nodes/DocuLinkBox.tsx
+++ b/src/client/views/nodes/DocuLinkBox.tsx
@@ -1,6 +1,6 @@
import { action, observable } from "mobx";
import { observer } from "mobx-react";
-import { Doc } from "../../../new_fields/Doc";
+import { Doc, WidthSym, HeightSym } from "../../../new_fields/Doc";
import { makeInterface } from "../../../new_fields/Schema";
import { NumCast, StrCast, Cast } from "../../../new_fields/Types";
import { Utils } from '../../../Utils';
@@ -12,6 +12,7 @@ import { FieldView, FieldViewProps } from "./FieldView";
import React = require("react");
import { DocumentType } from "../../documents/DocumentTypes";
import { documentSchema } from "../../../new_fields/documentSchemas";
+import { Id } from "../../../new_fields/FieldSymbols";
type DocLinkSchema = makeInterface<[typeof documentSchema]>;
const DocLinkDocument = makeInterface(documentSchema);
@@ -68,17 +69,31 @@ export class DocuLinkBox extends DocComponent<FieldViewProps, DocLinkSchema>(Doc
render() {
const anchorDoc = Cast(this.props.Document[this.props.fieldKey], Doc);
- const hasAnchor = anchorDoc instanceof Doc && anchorDoc.type === DocumentType.PDFANNO;
- const y = NumCast(this.props.Document[this.props.fieldKey + "_y"], 100);
- const x = NumCast(this.props.Document[this.props.fieldKey + "_x"], 100);
+ let anchorScale = anchorDoc instanceof Doc && anchorDoc.type === DocumentType.PDFANNO ? 0.33 : 1;
+ let y = NumCast(this.props.Document[this.props.fieldKey + "_y"], 100);
+ let x = NumCast(this.props.Document[this.props.fieldKey + "_x"], 100);
const c = StrCast(this.props.Document.backgroundColor, "lightblue");
const anchor = this.props.fieldKey === "anchor1" ? "anchor2" : "anchor1";
+
+ // really hacky stuff to make the link box display at the top right of hypertext link in a formatted text box. somehow, this should get moved into the hyperlink itself...
+ const other = window.document.getElementById((this.props.Document[anchor] as Doc)[Id]);
+ if (other) {
+ (this.props.Document[this.props.fieldKey] as Doc)?.data; // ugh .. assumes that 'data' is the field used to store the text
+ setTimeout(() => {
+ let m = other.getBoundingClientRect();
+ let mp = this.props.ScreenToLocalTransform().transformPoint(m.right - 5, m.top + 5);
+ this.props.Document[this.props.fieldKey + "_x"] = mp[0] / this.props.PanelWidth() * 100;
+ this.props.Document[this.props.fieldKey + "_y"] = mp[1] / this.props.PanelHeight() * 100;
+ }, 0);
+ anchorScale = 0.15;
+ }
+
const timecode = this.props.Document[anchor + "Timecode"];
const targetTitle = StrCast((this.props.Document[anchor]! as Doc).title) + (timecode !== undefined ? ":" + timecode : "");
return <div className="docuLinkBox-cont" onPointerDown={this.onPointerDown} onClick={this.onClick} title={targetTitle}
ref={this._ref} style={{
background: c, left: `calc(${x}% - 12.5px)`, top: `calc(${y}% - 12.5px)`,
- transform: `scale(${hasAnchor ? 0.333 : 1 / this.props.ContentScaling()})`
+ transform: `scale(${anchorScale / this.props.ContentScaling()})`
}} />;
}
}
diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss
index dfb84ed5c..f44c6dd3b 100644
--- a/src/client/views/nodes/DocumentView.scss
+++ b/src/client/views/nodes/DocumentView.scss
@@ -39,6 +39,7 @@
transform-origin: top left;
width: 100%;
height: 100%;
+ z-index: 1;
}
.documentView-styleWrapper {
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 7e81876a2..f00a24d41 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -218,7 +218,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
}
public highlightSearchTerms = (terms: string[]) => {
- if (this._editorView && (this._editorView as any).docView) {
+ if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) {
const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight);
const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true });
const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term));
@@ -1063,7 +1063,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps &
e.preventDefault();
return;
}
- let state = this._editorView!.state;
+ const state = this._editorView!.state;
if (!state.selection.empty && e.key === "%") {
state.schema.EnteringStyle = true;
e.preventDefault();
diff --git a/src/client/views/nodes/FormattedTextBoxComment.tsx b/src/client/views/nodes/FormattedTextBoxComment.tsx
index 1755cb99c..5fd5d4ce1 100644
--- a/src/client/views/nodes/FormattedTextBoxComment.tsx
+++ b/src/client/views/nodes/FormattedTextBoxComment.tsx
@@ -154,7 +154,7 @@ export class FormattedTextBoxComment {
let child: any = null;
state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node));
const mark = child && findLinkMark(child.marks);
- if (mark && child && nbef && naft) {
+ if (mark && child && nbef && naft && mark.attrs.showPreview) {
FormattedTextBoxComment.tooltipText.textContent = "external => " + mark.attrs.href;
(FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href;
if (mark.attrs.href.startsWith("https://en.wikipedia.org/wiki/")) {
diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx
index 2f1e1832e..8370df6ba 100644
--- a/src/client/views/nodes/PDFBox.tsx
+++ b/src/client/views/nodes/PDFBox.tsx
@@ -21,6 +21,7 @@ import { pageSchema } from "./ImageBox";
import "./PDFBox.scss";
import React = require("react");
import { documentSchema } from '../../../new_fields/documentSchemas';
+import { url } from 'inspector';
type PdfDocument = makeInterface<[typeof documentSchema, typeof panZoomSchema, typeof pageSchema]>;
const PdfDocument = makeInterface(documentSchema, panZoomSchema, pageSchema);
@@ -61,7 +62,7 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument>
console.log("\nHere's the { url } being fed into the outer regex:");
console.log(href);
console.log("And here's the 'properPath' build from the captured filename:\n");
- if (matches !== null) {
+ if (matches !== null && href.startsWith(window.location.origin)) {
const properPath = Utils.prepend(`/files/pdfs/${matches[0]}`);
console.log(properPath);
if (!properPath.includes(href)) {
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 096226d05..62467ce4d 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -126,8 +126,10 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument
!this.props.Document.lockedTransform && (this.props.Document.lockedTransform = true);
// change the address to be the file address of the PNG version of each page
// file address of the pdf
- const path = Utils.prepend(`/thumbnail${this.props.url.substring("files/pdfs/".length, this.props.url.length - ".pdf".length)}-${(this.Document.curPage || 1)}.png`);
- this._coverPath = JSON.parse(await rp.get(path));
+ const { url: { href } } = Cast(this.props.Document[this.props.fieldKey], PdfField)!;
+ this._coverPath = href.startsWith(window.location.origin) ?
+ JSON.parse(await rp.get(Utils.prepend(`/thumbnail${this.props.url.substring("files/pdfs/".length, this.props.url.length - ".pdf".length)}-${(this.Document.curPage || 1)}.png`))) :
+ { width: 100, height: 100, path: "" };
runInAction(() => this._showWaiting = this._showCover = true);
this.props.startupLive && this.setupPdfJsViewer();
this._searchReactionDisposer = reaction(() => this.Document.searchMatch, search => {
diff --git a/src/server/ActionUtilities.ts b/src/server/ActionUtilities.ts
index 053576a92..30aed32e6 100644
--- a/src/server/ActionUtilities.ts
+++ b/src/server/ActionUtilities.ts
@@ -4,6 +4,8 @@ import { exec } from 'child_process';
import * as path from 'path';
import * as rimraf from "rimraf";
import { yellow, Color } from 'colors';
+import * as nodemailer from "nodemailer";
+import { MailOptions } from "nodemailer/lib/json-transport";
const projectRoot = path.resolve(__dirname, "../../");
export function pathFromRoot(relative?: string) {
@@ -105,3 +107,37 @@ export async function Prune(rootDirectory: string): Promise<boolean> {
}
export const Destroy = (mediaPath: string) => new Promise<boolean>(resolve => unlink(mediaPath, error => resolve(error === null)));
+
+export namespace Email {
+
+ const smtpTransport = nodemailer.createTransport({
+ service: 'Gmail',
+ auth: {
+ user: 'brownptcdash@gmail.com',
+ pass: 'browngfx1'
+ }
+ });
+
+ export async function dispatchAll(recipients: string[], subject: string, content: string) {
+ const failures: string[] = [];
+ await Promise.all(recipients.map(async (recipient: string) => {
+ if (!await Email.dispatch(recipient, subject, content)) {
+ failures.push(recipient);
+ }
+ }));
+ return failures;
+ }
+
+ export async function dispatch(recipient: string, subject: string, content: string): Promise<boolean> {
+ const mailOptions = {
+ to: recipient,
+ from: 'brownptcdash@gmail.com',
+ subject,
+ text: `Hello ${recipient.split("@")[0]},\n\n${content}`
+ } as MailOptions;
+ return new Promise<boolean>(resolve => {
+ smtpTransport.sendMail(mailOptions, (dispatchError: Error | null) => resolve(dispatchError === null));
+ });
+ }
+
+} \ No newline at end of file
diff --git a/src/server/ApiManagers/SearchManager.ts b/src/server/ApiManagers/SearchManager.ts
index 75ccfe2a8..c1c908088 100644
--- a/src/server/ApiManagers/SearchManager.ts
+++ b/src/server/ApiManagers/SearchManager.ts
@@ -8,6 +8,7 @@ import { command_line } from "../ActionUtilities";
import request = require('request-promise');
import { red } from "colors";
import RouteSubscriber from "../RouteSubscriber";
+import { execSync } from "child_process";
export class SearchManager extends ApiManager {
@@ -72,7 +73,7 @@ export namespace SolrManager {
const args = status ? "start" : "stop -p 8983";
try {
console.log(`Solr management: trying to ${args}`);
- console.log(await command_line(`./solr.cmd ${args}`, "./solr-8.3.1/bin"));
+ console.log(execSync(`./solr.cmd ${args}`, { cwd: "./solr-8.3.1/bin" }));
return true;
} catch (e) {
console.log(red(`Solr management error: unable to ${args}`));
diff --git a/src/server/Session/session.ts b/src/server/Session/session.ts
new file mode 100644
index 000000000..cf2231b1f
--- /dev/null
+++ b/src/server/Session/session.ts
@@ -0,0 +1,338 @@
+import { red, cyan, green, yellow, magenta, blue } from "colors";
+import { on, fork, setupMaster, Worker } from "cluster";
+import { execSync } from "child_process";
+import { get } from "request-promise";
+import { Utils } from "../../Utils";
+import Repl, { ReplAction } from "../repl";
+import { readFileSync } from "fs";
+import { validate, ValidationError } from "jsonschema";
+import { configurationSchema } from "./session_config_schema";
+
+const onWindows = process.platform === "win32";
+
+/**
+ * This namespace relies on NodeJS's cluster module, which allows a parent (master) process to share
+ * code with its children (workers). A simple `isMaster` flag indicates who is trying to access
+ * the code, and thus determines the functionality that actually gets invoked (checked by the caller, not internally).
+ *
+ * Think of the master thread as a factory, and the workers as the helpers that actually run the server.
+ *
+ * So, when we run `npm start`, given the appropriate check, initializeMaster() is called in the parent process
+ * This will spawn off its own child process (by default, mirrors the execution path of its parent),
+ * in which initializeWorker() is invoked.
+ */
+export namespace Session {
+
+ interface Configuration {
+ showServerOutput: boolean;
+ masterIdentifier: string;
+ workerIdentifier: string;
+ ports: { [description: string]: number };
+ pollingRoute: string;
+ pollingIntervalSeconds: number;
+ [key: string]: any;
+ }
+
+ const defaultConfiguration: Configuration = {
+ showServerOutput: false,
+ masterIdentifier: yellow("__monitor__:"),
+ workerIdentifier: magenta("__server__:"),
+ ports: { server: 3000 },
+ pollingRoute: "/",
+ pollingIntervalSeconds: 30
+ };
+
+ interface MasterExtensions {
+ addReplCommand: (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => void;
+ addChildMessageHandler: (message: string, handler: ActionHandler) => void;
+ }
+
+ export interface NotifierHooks {
+ key?: (key: string) => boolean | Promise<boolean>;
+ crash?: (error: Error) => boolean | Promise<boolean>;
+ }
+
+ export interface SessionAction {
+ message: string;
+ args: any;
+ }
+
+ export type ExitHandler = (error: Error) => void | Promise<void>;
+ export type ActionHandler = (action: SessionAction) => void | Promise<void>;
+ export interface EmailTemplate {
+ subject: string;
+ body: string;
+ }
+
+ function loadAndValidateConfiguration(): any {
+ try {
+ const configuration: Configuration = JSON.parse(readFileSync('./session.config.json', 'utf8'));
+ const options = {
+ throwError: true,
+ allowUnknownAttributes: false
+ };
+ // ensure all necessary and no excess information is specified by the configuration file
+ validate(configuration, configurationSchema, options);
+ let formatMaster = true;
+ let formatWorker = true;
+ Object.keys(defaultConfiguration).forEach(property => {
+ if (!configuration[property]) {
+ if (property === "masterIdentifier") {
+ formatMaster = false;
+ } else if (property === "workerIdentifier") {
+ formatWorker = false;
+ }
+ configuration[property] = defaultConfiguration[property];
+ }
+ });
+ if (formatMaster) {
+ configuration.masterIdentifier = yellow(configuration.masterIdentifier + ":");
+ }
+ if (formatWorker) {
+ configuration.workerIdentifier = magenta(configuration.workerIdentifier + ":");
+ }
+ return configuration;
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ console.log(red("\nSession configuration failed."));
+ console.log("The given session.config.json configuration file is invalid.");
+ console.log(`${error.instance}: ${error.stack}`);
+ process.exit(0);
+ } else if (error.code === "ENOENT" && error.path === "./session.config.json") {
+ console.log(cyan("Loading default session parameters..."));
+ console.log("Consider including a session.config.json configuration file in your project root for customization.");
+ return defaultConfiguration;
+ } else {
+ console.log(red("\nSession configuration failed."));
+ console.log("The following unknown error occurred during configuration.");
+ console.log(error.stack);
+ process.exit(0);
+ }
+ }
+ }
+
+ function timestamp() {
+ return blue(`[${new Date().toUTCString()}]`);
+ }
+
+ /**
+ * Validates and reads the configuration file, accordingly builds a child process factory
+ * and spawns off an initial process that will respawn as predecessors die.
+ */
+ export async function initializeMonitorThread(notifiers?: NotifierHooks): Promise<MasterExtensions> {
+ let activeWorker: Worker;
+ const childMessageHandlers: { [message: string]: (action: SessionAction, args: any) => void } = {};
+
+ // read in configuration .json file only once, in the master thread
+ // pass down any variables the pertinent to the child processes as environment variables
+ const {
+ masterIdentifier,
+ workerIdentifier,
+ ports,
+ pollingRoute,
+ showServerOutput,
+ pollingIntervalSeconds
+ } = loadAndValidateConfiguration();
+
+ const masterLog = (...optionalParams: any[]) => console.log(timestamp(), masterIdentifier, ...optionalParams);
+
+ // this sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
+ // to kill the server via the /kill/:key route
+ let key: string | undefined;
+ if (notifiers && notifiers.key) {
+ key = Utils.GenerateGuid();
+ const success = await notifiers.key(key);
+ const statement = success ? green("distributed session key to recipients") : red("distribution of session key failed");
+ masterLog(statement);
+ }
+
+ // handle exceptions in the master thread - there shouldn't be many of these
+ // the IPC (inter process communication) channel closed exception can't seem
+ // to be caught in a try catch, and is inconsequential, so it is ignored
+ process.on("uncaughtException", ({ message, stack }) => {
+ if (message !== "Channel closed") {
+ masterLog(red(message));
+ if (stack) {
+ masterLog(`uncaught exception\n${red(stack)}`);
+ }
+ }
+ });
+
+ // determines whether or not we see the compilation / initialization / runtime output of each child server process
+ setupMaster({ silent: !showServerOutput });
+
+ // attempts to kills the active worker ungracefully
+ const tryKillActiveWorker = (graceful = false): boolean => {
+ if (activeWorker && !activeWorker.isDead()) {
+ if (graceful) {
+ activeWorker.kill();
+ } else {
+ activeWorker.process.kill();
+ }
+ return true;
+ }
+ return false;
+ };
+
+ const restart = () => {
+ // indicate to the worker that we are 'expecting' this restart
+ activeWorker.send({ setResponsiveness: false });
+ tryKillActiveWorker();
+ };
+
+ const setPort = (port: string, value: number, immediateRestart: boolean) => {
+ if (value > 1023 && value < 65536) {
+ ports[port] = value;
+ if (immediateRestart) {
+ restart();
+ }
+ } else {
+ masterLog(red(`${port} is an invalid port number`));
+ }
+ };
+
+ // kills the current active worker and proceeds to spawn a new worker,
+ // feeding in configuration information as environment variables
+ const spawn = (): void => {
+ tryKillActiveWorker();
+ activeWorker = fork({
+ pollingRoute,
+ serverPort: ports.server,
+ socketPort: ports.socket,
+ pollingIntervalSeconds,
+ session_key: key
+ });
+ masterLog(`spawned new server worker with process id ${activeWorker.process.pid}`);
+ // an IPC message handler that executes actions on the master thread when prompted by the active worker
+ activeWorker.on("message", async ({ lifecycle, action }) => {
+ if (action) {
+ const { message, args } = action as SessionAction;
+ console.log(timestamp(), `${workerIdentifier} action requested (${cyan(message)})`);
+ switch (message) {
+ case "kill":
+ masterLog(red("an authorized user has manually ended the server session"));
+ tryKillActiveWorker(true);
+ process.exit(0);
+ case "notify_crash":
+ if (notifiers && notifiers.crash) {
+ const { error } = args;
+ const success = await notifiers.crash(error);
+ const statement = success ? green("distributed crash notification to recipients") : red("distribution of crash notification failed");
+ masterLog(statement);
+ }
+ case "set_port":
+ const { port, value, immediateRestart } = args;
+ setPort(port, value, immediateRestart);
+ default:
+ const handler = childMessageHandlers[message];
+ if (handler) {
+ handler(action, args);
+ }
+ }
+ } else if (lifecycle) {
+ console.log(timestamp(), `${workerIdentifier} lifecycle phase (${lifecycle})`);
+ }
+ });
+ };
+
+ // a helpful cluster event called on the master thread each time a child process exits
+ on("exit", ({ process: { pid } }, code, signal) => {
+ const prompt = `server worker with process id ${pid} has exited with code ${code}${signal === null ? "" : `, having encountered signal ${signal}`}.`;
+ masterLog(cyan(prompt));
+ // to make this a robust, continuous session, every time a child process dies, we immediately spawn a new one
+ spawn();
+ });
+
+ // builds the repl that allows the following commands to be typed into stdin of the master thread
+ const repl = new Repl({ identifier: () => `${timestamp()} ${masterIdentifier}` });
+ repl.registerCommand("exit", [], () => execSync(onWindows ? "taskkill /f /im node.exe" : "killall -9 node"));
+ repl.registerCommand("restart", [], restart);
+ repl.registerCommand("set", [/[a-zA-Z]+/, "port", /\d+/, /true|false/], args => setPort(args[0], Number(args[2]), args[3] === "true"));
+ // finally, set things in motion by spawning off the first child (server) process
+ spawn();
+
+ // returned to allow the caller to add custom commands
+ return {
+ addReplCommand: repl.registerCommand,
+ addChildMessageHandler: (message: string, handler: ActionHandler) => { childMessageHandlers[message] = handler; }
+ };
+ }
+
+ /**
+ * Effectively, each worker repairs the connection to the server by reintroducing a consistent state
+ * if its predecessor has died. It itself also polls the server heartbeat, and exits with a notification
+ * email if the server encounters an uncaught exception or if the server cannot be reached.
+ * @param work the function specifying the work to be done by each worker thread
+ */
+ export async function initializeWorkerThread(work: Function): Promise<(handler: ExitHandler) => void> {
+ let shouldServerBeResponsive = false;
+ const exitHandlers: ExitHandler[] = [];
+
+ // notify master thread (which will log update in the console) of initialization via IPC
+ process.send?.({ lifecycle: green("compiling and initializing...") });
+
+ // updates the local value of listening to the value sent from master
+ process.on("message", ({ setResponsiveness }) => shouldServerBeResponsive = setResponsiveness);
+
+ // called whenever the process has a reason to terminate, either through an uncaught exception
+ // in the process (potentially inconsistent state) or the server cannot be reached
+ const activeExit = async (error: Error): Promise<void> => {
+ if (!shouldServerBeResponsive) {
+ return;
+ }
+ shouldServerBeResponsive = false;
+ // communicates via IPC to the master thread that it should dispatch a crash notification email
+ process.send?.({
+ action: {
+ message: "notify_crash",
+ args: { error }
+ }
+ });
+ await Promise.all(exitHandlers.map(handler => handler(error)));
+ // notify master thread (which will log update in the console) of crash event via IPC
+ process.send?.({ lifecycle: red(`crash event detected @ ${new Date().toUTCString()}`) });
+ process.send?.({ lifecycle: red(error.message) });
+ process.exit(1);
+ };
+
+ // one reason to exit, as the process might be in an inconsistent state after such an exception
+ process.on('uncaughtException', activeExit);
+
+ const {
+ pollingIntervalSeconds,
+ pollingRoute,
+ serverPort
+ } = process.env;
+ // this monitors the health of the server by submitting a get request to whatever port / route specified
+ // by the configuration every n seconds, where n is also given by the configuration.
+ const pollTarget = `http://localhost:${serverPort}${pollingRoute}`;
+ const pollServer = async (): Promise<void> => {
+ await new Promise<void>(resolve => {
+ setTimeout(async () => {
+ try {
+ await get(pollTarget);
+ if (!shouldServerBeResponsive) {
+ // notify master thread (which will log update in the console) via IPC that the server is up and running
+ process.send?.({ lifecycle: green(`listening on ${serverPort}...`) });
+ }
+ shouldServerBeResponsive = true;
+ resolve();
+ } catch (error) {
+ // if we expect the server to be unavailable, i.e. during compilation,
+ // the listening variable is false, activeExit will return early and the child
+ // process will continue
+ activeExit(error);
+ }
+ }, 1000 * Number(pollingIntervalSeconds));
+ });
+ // controlled, asynchronous infinite recursion achieves a persistent poll that does not submit a new request until the previous has completed
+ pollServer();
+ };
+
+ work();
+ pollServer(); // begin polling
+
+ return (handler: ExitHandler) => exitHandlers.push(handler);
+ }
+
+} \ No newline at end of file
diff --git a/src/server/Session/session_config_schema.ts b/src/server/Session/session_config_schema.ts
new file mode 100644
index 000000000..76af04b9f
--- /dev/null
+++ b/src/server/Session/session_config_schema.ts
@@ -0,0 +1,35 @@
+import { Schema } from "jsonschema";
+
+export const configurationSchema: Schema = {
+ id: "/configuration",
+ type: "object",
+ properties: {
+ ports: {
+ type: "object",
+ properties: {
+ server: { type: "number", minimum: 1024, maximum: 65535 },
+ socket: { type: "number", minimum: 1024, maximum: 65535 }
+ },
+ required: ["server"],
+ additionalProperties: true
+ },
+ pollingRoute: {
+ type: "string",
+ pattern: /\/[a-zA-Z]*/g
+ },
+ masterIdentifier: {
+ type: "string",
+ minLength: 1
+ },
+ workerIdentifier: {
+ type: "string",
+ minLength: 1
+ },
+ showServerOutput: { type: "boolean" },
+ pollingIntervalSeconds: {
+ type: "number",
+ minimum: 1,
+ maximum: 86400
+ }
+ }
+}; \ No newline at end of file
diff --git a/src/server/Websocket/Websocket.ts b/src/server/Websocket/Websocket.ts
index 0b58ca344..578147d60 100644
--- a/src/server/Websocket/Websocket.ts
+++ b/src/server/Websocket/Websocket.ts
@@ -18,15 +18,15 @@ export namespace WebSocket {
export const socketMap = new Map<SocketIO.Socket, string>();
export let disconnect: Function;
- export async function start(serverPort: number, isRelease: boolean) {
+ export async function start(isRelease: boolean) {
await preliminaryFunctions();
- initialize(serverPort, isRelease);
+ initialize(isRelease);
}
async function preliminaryFunctions() {
}
- export function initialize(socketPort: number, isRelease: boolean) {
+ function initialize(isRelease: boolean) {
const endpoint = io();
endpoint.on("connection", function (socket: Socket) {
_socket = socket;
@@ -63,6 +63,7 @@ export namespace WebSocket {
};
});
+ const socketPort = isRelease ? Number(process.env.socketPort) : 4321;
endpoint.listen(socketPort);
logPort("websocket", socketPort);
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 8706c2d84..5e411aa3a 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -6,11 +6,11 @@ import { Database } from './database';
const serverPort = 4321;
import { DashUploadUtils } from './DashUploadUtils';
import RouteSubscriber from './RouteSubscriber';
-import initializeServer from './Initialization';
+import initializeServer from './server_Initialization';
import RouteManager, { Method, _success, _permission_denied, _error, _invalid, PublicHandler } from './RouteManager';
import * as qs from 'query-string';
import UtilManager from './ApiManagers/UtilManager';
-import { SearchManager } from './ApiManagers/SearchManager';
+import { SearchManager, SolrManager } from './ApiManagers/SearchManager';
import UserManager from './ApiManagers/UserManager';
import { WebSocket } from './Websocket/Websocket';
import DownloadManager from './ApiManagers/DownloadManager';
@@ -18,13 +18,16 @@ import { GoogleCredentialsLoader } from './credentials/CredentialsLoader';
import DeleteManager from "./ApiManagers/DeleteManager";
import PDFManager from "./ApiManagers/PDFManager";
import UploadManager from "./ApiManagers/UploadManager";
-import { log_execution } from "./ActionUtilities";
+import { log_execution, Email } from "./ActionUtilities";
import GeneralGoogleManager from "./ApiManagers/GeneralGoogleManager";
import GooglePhotosManager from "./ApiManagers/GooglePhotosManager";
import { Logger } from "./ProcessFactory";
import { yellow } from "colors";
-import { Session } from "./session";
+import { Session } from "./Session/session";
+import { isMaster } from "cluster";
+import { execSync } from "child_process";
import { Utils } from "../Utils";
+import { MessageStore } from "./Message";
export const publicDirectory = path.resolve(__dirname, "public");
export const filesDirectory = path.resolve(publicDirectory, "files");
@@ -35,7 +38,6 @@ export const filesDirectory = path.resolve(publicDirectory, "files");
* before clients can access the server should be run or awaited here.
*/
async function preliminaryFunctions() {
- await Session.distributeKey();
await Logger.initialize();
await GoogleCredentialsLoader.loadCredentials();
GoogleApiServerUtils.processProjectCredentials();
@@ -90,11 +92,11 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }:
addSupervisedRoute({
method: Method.GET,
- subscription: new RouteSubscriber("kill").add("password"),
+ subscription: new RouteSubscriber("kill").add("key"),
secureHandler: ({ req, res }) => {
- if (req.params.password === Session.key) {
- process.send!({ action: yellow("kill") });
- res.send("Server successfully killed.");
+ if (req.params.key === process.env.session_key) {
+ res.send("<img src='https://media.giphy.com/media/NGIfqtcS81qi4/giphy.gif' style='width:100%;height:100%;'/>");
+ process.send!({ action: { message: "kill" } });
} else {
res.redirect("/home");
}
@@ -125,16 +127,70 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }:
// initialize the web socket (bidirectional communication: if a user changes
// a field on one client, that change must be broadcast to all other clients)
- WebSocket.initialize(serverPort, isRelease);
+ WebSocket.start(isRelease);
}
-async function launch() {
+/**
+ * This function can be used in two different ways. If not in release mode,
+ * this is simply the logic that is invoked to start the server. In release mode,
+ * however, this becomes the logic invoked by a single worker thread spawned by
+ * the main monitor (master) thread.
+ */
+async function launchServer() {
await log_execution({
startMessage: "\nstarting execution of preliminary functions",
endMessage: "completed preliminary functions\n",
action: preliminaryFunctions
});
- await initializeServer({ serverPort: 1050, routeSetter });
+ await initializeServer(routeSetter);
}
-Session.initialize(launch); \ No newline at end of file
+/**
+ * If we're the monitor (master) thread, we should launch the monitor logic for the session.
+ * Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus
+ * our job should be to run the server.
+ */
+async function launchMonitoredSession() {
+ if (isMaster) {
+ const recipients = ["samuel_wilkins@brown.edu"];
+ const signature = "-Dash Server Session Manager";
+ const customizer = await Session.initializeMonitorThread({
+ key: async (key: string) => {
+ const content = `The key for this session (started @ ${new Date().toUTCString()}) is ${key}.\n\n${signature}`;
+ const failures = await Email.dispatchAll(recipients, "Server Termination Key", content);
+ return failures.length === 0;
+ },
+ crash: async (error: Error) => {
+ const subject = "Dash Web Server Crash";
+ const { name, message, stack } = error;
+ const body = [
+ "You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:",
+ `name:\n${name}`,
+ `message:\n${message}`,
+ `stack:\n${stack}`,
+ "The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.",
+ ].join("\n\n");
+ const content = `${body}\n\n${signature}`;
+ const failures = await Email.dispatchAll(recipients, subject, content);
+ return failures.length === 0;
+ }
+ });
+ customizer.addReplCommand("pull", [], () => execSync("git pull", { stdio: ["ignore", "inherit", "inherit"] }));
+ customizer.addReplCommand("solr", [/start|stop/g], args => SolrManager.SetRunning(args[0] === "start"));
+ } else {
+ const addExitHandler = await Session.initializeWorkerThread(launchServer); // server initialization delegated to worker
+ addExitHandler(() => Utils.Emit(WebSocket._socket, MessageStore.ConnectionTerminated, "Manual"));
+ }
+}
+
+/**
+ * If you're in development mode, you won't need to run a session.
+ * The session spawns off new server processes each time an error is encountered, and doesn't
+ * log the output of the server process, so it's not ideal for development.
+ * So, the 'else' clause is exactly what we've always run when executing npm start.
+ */
+if (process.env.RELEASE) {
+ launchMonitoredSession();
+} else {
+ launchServer();
+} \ No newline at end of file
diff --git a/src/server/session_manager/input_manager.ts b/src/server/repl.ts
index 133b7144a..bd00e48cd 100644
--- a/src/server/session_manager/input_manager.ts
+++ b/src/server/repl.ts
@@ -1,34 +1,39 @@
import { createInterface, Interface } from "readline";
-import { red } from "colors";
+import { red, green, white } from "colors";
export interface Configuration {
- identifier: string;
+ identifier: () => string | string;
onInvalid?: (culprit?: string) => string | string;
+ onValid?: (success?: string) => string | string;
isCaseSensitive?: boolean;
}
-type Action = (parsedArgs: IterableIterator<string>) => any | Promise<any>;
+export type ReplAction = (parsedArgs: Array<string>) => any | Promise<any>;
export interface Registration {
argPatterns: RegExp[];
- action: Action;
+ action: ReplAction;
}
-export default class InputManager {
- private identifier: string;
- private onInvalid: ((culprit?: string) => string) | string;
+export default class Repl {
+ private identifier: () => string | string;
+ private onInvalid: (culprit?: string) => string | string;
+ private onValid: (success: string) => string | string;
private isCaseSensitive: boolean;
private commandMap = new Map<string, Registration[]>();
public interface: Interface;
private busy = false;
private keys: string | undefined;
- constructor({ identifier: prompt, onInvalid, isCaseSensitive }: Configuration) {
+ constructor({ identifier: prompt, onInvalid, onValid, isCaseSensitive }: Configuration) {
this.identifier = prompt;
this.onInvalid = onInvalid || this.usage;
+ this.onValid = onValid || this.success;
this.isCaseSensitive = isCaseSensitive ?? true;
this.interface = createInterface(process.stdin, process.stdout).on('line', this.considerInput);
}
+ private resolvedIdentifier = () => typeof this.identifier === "string" ? this.identifier : this.identifier();
+
private usage = () => {
const resolved = this.keys;
if (resolved) {
@@ -40,10 +45,12 @@ export default class InputManager {
while (!(next = keys.next()).done) {
members.push(next.value);
}
- return `${this.identifier} commands: { ${members.sort().join(", ")} }`;
+ return `${this.resolvedIdentifier()} commands: { ${members.sort().join(", ")} }`;
}
- public registerCommand = (basename: string, argPatterns: (RegExp | string)[], action: Action) => {
+ private success = (command: string) => `${this.resolvedIdentifier()} completed execution of ${white(command)}`;
+
+ public registerCommand = (basename: string, argPatterns: (RegExp | string)[], action: ReplAction) => {
const existing = this.commandMap.get(basename);
const converted = argPatterns.map(input => input instanceof RegExp ? input : new RegExp(input));
const registration = { argPatterns: converted, action };
@@ -59,6 +66,11 @@ export default class InputManager {
this.busy = false;
}
+ private valid = (command: string) => {
+ console.log(green(typeof this.onValid === "string" ? this.onValid : this.onValid(command)));
+ this.busy = false;
+ }
+
private considerInput = async (line: string) => {
if (this.busy) {
console.log(red("Busy"));
@@ -91,8 +103,8 @@ export default class InputManager {
matched = true;
}
if (!length || matched) {
- await action(parsed[Symbol.iterator]());
- this.busy = false;
+ await action(parsed);
+ this.valid(`${command} ${parsed.join(" ")}`);
return;
}
}
diff --git a/src/server/Initialization.ts b/src/server/server_Initialization.ts
index 465e7ea63..0f502e8fb 100644
--- a/src/server/Initialization.ts
+++ b/src/server/server_Initialization.ts
@@ -22,24 +22,28 @@ import { publicDirectory } from '.';
import { logPort, } from './ActionUtilities';
import { timeMap } from './ApiManagers/UserManager';
import { blue, yellow } from 'colors';
+var cors = require('cors');
/* RouteSetter is a wrapper around the server that prevents the server
from being exposed. */
export type RouteSetter = (server: RouteManager) => void;
-export interface InitializationOptions {
- serverPort: number;
- routeSetter: RouteSetter;
-}
-
export let disconnect: Function;
-export default async function InitializeServer(options: InitializationOptions) {
- const { serverPort, routeSetter } = options;
+export default async function InitializeServer(routeSetter: RouteSetter) {
const app = buildWithMiddleware(express());
- app.use(express.static(publicDirectory));
+ app.use(express.static(publicDirectory, {
+ setHeaders: (res, path) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ }
+ }));
app.use("/images", express.static(publicDirectory));
-
+ const corsOptions = {
+ origin: function (origin: any, callback: any) {
+ callback(null, true);
+ }
+ };
+ app.use(cors(corsOptions));
app.use("*", ({ user, originalUrl }, res, next) => {
if (user && !originalUrl.includes("Heartbeat")) {
const userEmail = (user as any).email;
@@ -63,8 +67,9 @@ export default async function InitializeServer(options: InitializationOptions) {
routeSetter(new RouteManager(app, isRelease));
+ const serverPort = isRelease ? Number(process.env.serverPort) : 1050;
const server = app.listen(serverPort, () => {
- logPort("server", serverPort);
+ logPort("server", Number(serverPort));
console.log();
});
disconnect = async () => new Promise<Error>(resolve => server.close(resolve));
diff --git a/src/server/session.ts b/src/server/session.ts
deleted file mode 100644
index ec51b6e18..000000000
--- a/src/server/session.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { yellow, red, cyan, magenta, green } from "colors";
-import { isMaster, on, fork, setupMaster, Worker } from "cluster";
-import InputManager from "./session_manager/input_manager";
-import { execSync } from "child_process";
-import { Email } from "./session_manager/email";
-import { get } from "request-promise";
-import { WebSocket } from "./Websocket/Websocket";
-import { Utils } from "../Utils";
-import { MessageStore } from "./Message";
-
-const onWindows = process.platform === "win32";
-const heartbeat = `http://localhost:1050/serverHeartbeat`;
-const admin = ["samuel_wilkins@brown.edu"];
-
-export namespace Session {
-
- export let key: string;
- export const signature = "Best,\nServer Session Manager";
- let activeWorker: Worker;
- let listening = false;
- const masterIdentifier = `${yellow("__master__")}:`;
- const workerIdentifier = `${magenta("__worker__")}:`;
-
- function log(message?: any, ...optionalParams: any[]) {
- const identifier = isMaster ? masterIdentifier : workerIdentifier;
- console.log(identifier, message, ...optionalParams);
- }
-
- export async function distributeKey() {
- key = Utils.GenerateGuid();
- const timestamp = new Date().toUTCString();
- const content = `The key for this session (started @ ${timestamp}) is ${key}.\n\n${signature}`;
- return Promise.all(admin.map(recipient => Email.dispatch(recipient, "Server Termination Key", content)));
- }
-
- function tryKillActiveWorker() {
- if (activeWorker && !activeWorker.isDead()) {
- activeWorker.process.kill();
- return true;
- }
- return false;
- }
-
- function logLifecycleEvent(lifecycle: string) {
- process.send?.({ lifecycle });
- }
-
- function messageHandler({ lifecycle, action }: any) {
- if (action) {
- console.log(`${workerIdentifier} action requested (${action})`);
- switch (action) {
- case "kill":
- log(red("An authorized user has ended the server from the /kill route"));
- tryKillActiveWorker();
- process.exit(0);
- }
- } else if (lifecycle) {
- console.log(`${workerIdentifier} lifecycle phase (${lifecycle})`);
- }
- }
-
- async function activeExit(error: Error) {
- if (!listening) {
- return;
- }
- listening = false;
- await Promise.all(admin.map(recipient => Email.dispatch(recipient, "Dash Web Server Crash", crashReport(error))));
- const { _socket } = WebSocket;
- if (_socket) {
- Utils.Emit(_socket, MessageStore.ConnectionTerminated, "Manual");
- }
- logLifecycleEvent(red(`Crash event detected @ ${new Date().toUTCString()}`));
- logLifecycleEvent(red(error.message));
- process.exit(1);
- }
-
- function crashReport({ name, message, stack }: Error) {
- return [
- "You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:",
- `name:\n${name}`,
- `message:\n${message}`,
- `stack:\n${stack}`,
- "The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress.",
- signature
- ].join("\n\n");
- }
-
- export async function initialize(work: Function) {
- if (isMaster) {
- process.on("uncaughtException", error => {
- if (error.message !== "Channel closed") {
- log(red(error.message));
- if (error.stack) {
- log(`\n${red(error.stack)}`);
- }
- }
- });
- setupMaster({ silent: true });
- const spawn = () => {
- tryKillActiveWorker();
- activeWorker = fork();
- activeWorker.on("message", messageHandler);
- };
- spawn();
- on("exit", ({ process: { pid } }, code, signal) => {
- const prompt = `Server worker with process id ${pid} has exited with code ${code}${signal === null ? "" : `, having encountered signal ${signal}`}.`;
- log(cyan(prompt));
- spawn();
- });
- const { registerCommand } = new InputManager({ identifier: masterIdentifier });
- registerCommand("exit", [], () => execSync(onWindows ? "taskkill /f /im node.exe" : "killall -9 node"));
- registerCommand("pull", [], () => execSync("git pull", { stdio: ["ignore", "inherit", "inherit"] }));
- registerCommand("restart", [], () => {
- listening = false;
- tryKillActiveWorker();
- });
- } else {
- logLifecycleEvent(green("initializing..."));
- process.on('uncaughtException', activeExit);
- const checkHeartbeat = async () => {
- await new Promise<void>(resolve => {
- setTimeout(async () => {
- try {
- await get(heartbeat);
- if (!listening) {
- logLifecycleEvent(green("listening..."));
- }
- listening = true;
- resolve();
- } catch (error) {
- await activeExit(error);
- }
- }, 1000 * 15);
- });
- checkHeartbeat();
- };
- work();
- checkHeartbeat();
- }
- }
-
-} \ No newline at end of file
diff --git a/src/server/session_manager/config.ts b/src/server/session_manager/config.ts
deleted file mode 100644
index ebbd999c6..000000000
--- a/src/server/session_manager/config.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { resolve } from 'path';
-import { yellow } from "colors";
-
-export const latency = 10;
-export const ports = [1050, 4321];
-export const onWindows = process.platform === "win32";
-export const heartbeat = `http://localhost:1050/serverHeartbeat`;
-export const recipient = "samuel_wilkins@brown.edu";
-export const { pid, platform } = process;
-
-/**
- * Logging
- */
-export const identifier = yellow("__session_manager__:");
-
-/**
- * Paths
- */
-export const logPath = resolve(__dirname, "./logs");
-export const crashPath = resolve(logPath, "./crashes");
-
-/**
- * State
- */
-export enum SessionState {
- STARTING = "STARTING",
- INITIALIZED = "INITIALIZED",
- LISTENING = "LISTENING",
- AUTOMATICALLY_RESTARTING = "CRASH_RESTARTING",
- MANUALLY_RESTARTING = "MANUALLY_RESTARTING",
- EXITING = "EXITING",
- UPDATING = "UPDATING"
-} \ No newline at end of file
diff --git a/src/server/session_manager/email.ts b/src/server/session_manager/email.ts
deleted file mode 100644
index a638644db..000000000
--- a/src/server/session_manager/email.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as nodemailer from "nodemailer";
-import { MailOptions } from "nodemailer/lib/json-transport";
-
-export namespace Email {
-
- const smtpTransport = nodemailer.createTransport({
- service: 'Gmail',
- auth: {
- user: 'brownptcdash@gmail.com',
- pass: 'browngfx1'
- }
- });
-
- export async function dispatch(recipient: string, subject: string, content: string): Promise<boolean> {
- const mailOptions = {
- to: recipient,
- from: 'brownptcdash@gmail.com',
- subject,
- text: `Hello ${recipient.split("@")[0]},\n\n${content}`
- } as MailOptions;
- return new Promise<boolean>(resolve => {
- smtpTransport.sendMail(mailOptions, (dispatchError: Error | null) => resolve(dispatchError === null));
- });
- }
-
-} \ No newline at end of file
diff --git a/src/server/session_manager/logs/current_daemon_pid.log b/src/server/session_manager/logs/current_daemon_pid.log
deleted file mode 100644
index 557e3d7c3..000000000
--- a/src/server/session_manager/logs/current_daemon_pid.log
+++ /dev/null
@@ -1 +0,0 @@
-26860
diff --git a/src/server/session_manager/logs/current_server_pid.log b/src/server/session_manager/logs/current_server_pid.log
deleted file mode 100644
index 85fdb7ae0..000000000
--- a/src/server/session_manager/logs/current_server_pid.log
+++ /dev/null
@@ -1 +0,0 @@
-54649 created @ 2019-12-14T08:04:42.391Z
diff --git a/src/server/session_manager/logs/current_session_manager_pid.log b/src/server/session_manager/logs/current_session_manager_pid.log
deleted file mode 100644
index 75c23b35a..000000000
--- a/src/server/session_manager/logs/current_session_manager_pid.log
+++ /dev/null
@@ -1 +0,0 @@
-54643
diff --git a/src/server/session_manager/session_manager.ts b/src/server/session_manager/session_manager.ts
deleted file mode 100644
index 97c2ab214..000000000
--- a/src/server/session_manager/session_manager.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import * as request from "request-promise";
-import { log_execution, pathFromRoot } from "../ActionUtilities";
-import { red, yellow, cyan, green, Color } from "colors";
-import * as nodemailer from "nodemailer";
-import { MailOptions } from "nodemailer/lib/json-transport";
-import { writeFileSync, existsSync, mkdirSync } from "fs";
-import { resolve } from 'path';
-import { ChildProcess, exec, execSync } from "child_process";
-import InputManager from "./input_manager";
-import { identifier, logPath, crashPath, onWindows, pid, ports, heartbeat, recipient, latency, SessionState } from "./config";
-const killport = require("kill-port");
-import * as io from "socket.io";
-
-process.on('SIGINT', endPrevious);
-let state: SessionState = SessionState.STARTING;
-const is = (...reference: SessionState[]) => reference.includes(state);
-const set = (reference: SessionState) => state = reference;
-
-const endpoint = io();
-endpoint.on("connection", socket => {
-
-});
-endpoint.listen(process.env.PORT);
-
-const { registerCommand } = new InputManager({ identifier });
-
-registerCommand("restart", [], async () => {
- set(SessionState.MANUALLY_RESTARTING);
- identifiedLog(cyan("Initializing manual restart..."));
- await endPrevious();
-});
-
-registerCommand("exit", [], exit);
-
-async function exit() {
- set(SessionState.EXITING);
- identifiedLog(cyan("Initializing session end"));
- await endPrevious();
- identifiedLog("Cleanup complete. Exiting session...\n");
- execSync(killAllCommand());
-}
-
-registerCommand("update", [], async () => {
- set(SessionState.UPDATING);
- identifiedLog(cyan("Initializing server update from version control..."));
- await endPrevious();
- await new Promise<void>(resolve => {
- exec(updateCommand(), error => {
- if (error) {
- identifiedLog(red(error.message));
- }
- resolve();
- });
- });
- await exit();
-});
-
-registerCommand("state", [], () => identifiedLog(state));
-
-if (!existsSync(logPath)) {
- mkdirSync(logPath);
-}
-if (!existsSync(crashPath)) {
- mkdirSync(crashPath);
-}
-
-function addLogEntry(message: string, color: Color) {
- const formatted = color(`${message} ${timestamp()}.`);
- identifiedLog(formatted);
- // appendFileSync(resolve(crashPath, `./session_crashes_${new Date().toISOString()}.log`), `${formatted}\n`);
-}
-
-function identifiedLog(message?: any, ...optionalParams: any[]) {
- console.log(identifier, message, ...optionalParams);
-}
-
-if (!["win32", "darwin"].includes(process.platform)) {
- identifiedLog(red("Invalid operating system: this script is supported only on Mac and Windows."));
- process.exit(1);
-}
-
-const windowsPrepend = (command: string) => `"C:\\Program Files\\Git\\git-bash.exe" -c "${command}"`;
-const macPrepend = (command: string) => `osascript -e 'tell app "Terminal"\ndo script "cd ${pathFromRoot()} && ${command}"\nend tell'`;
-
-function updateCommand() {
- const command = "git pull && npm install";
- if (onWindows) {
- return windowsPrepend(command);
- }
- return macPrepend(command);
-}
-
-function startServerCommand() {
- const command = "npm run start-release";
- if (onWindows) {
- return windowsPrepend(command);
- }
- return macPrepend(command);
-}
-
-function killAllCommand() {
- if (onWindows) {
- return "taskkill /f /im node.exe";
- }
- return "killall -9 node";
-}
-
-identifiedLog("Initializing session...");
-
-writeLocalPidLog("session_manager", pid);
-
-function writeLocalPidLog(filename: string, contents: any) {
- const path = `./logs/current_${filename}_pid.log`;
- identifiedLog(cyan(`${contents} written to ${path}`));
- writeFileSync(resolve(__dirname, path), `${contents}\n`);
-}
-
-function timestamp() {
- return `@ ${new Date().toISOString()}`;
-}
-
-async function endPrevious() {
- identifiedLog(yellow("Cleaning up previous connections..."));
- current_backup?.kill();
- await Promise.all(ports.map(port => {
- const task = killport(port, 'tcp');
- return task.catch((error: any) => identifiedLog(red(error)));
- }));
- identifiedLog(yellow("Done. Any failures will be printed in red immediately above."));
-}
-
-let current_backup: ChildProcess | undefined = undefined;
-
-async function checkHeartbeat() {
- const listening = is(SessionState.LISTENING);
- let error: any;
- try {
- listening && process.stdout.write(`${identifier} 👂 `);
- await request.get(heartbeat);
- listening && console.log('⇠ 💚');
- if (!listening) {
- addLogEntry(is(SessionState.INITIALIZED) ? "Server successfully started" : "Backup server successfully restarted", green);
- set(SessionState.LISTENING);
- }
- } catch (e) {
- listening && console.log("⇠ 💔\n");
- error = e;
- } finally {
- if (error && !is(SessionState.AUTOMATICALLY_RESTARTING, SessionState.INITIALIZED, SessionState.UPDATING)) {
- if (is(SessionState.STARTING)) {
- set(SessionState.INITIALIZED);
- } else if (is(SessionState.MANUALLY_RESTARTING)) {
- set(SessionState.AUTOMATICALLY_RESTARTING);
- } else {
- set(SessionState.AUTOMATICALLY_RESTARTING);
- addLogEntry("Detected a server crash", red);
- identifiedLog(red(error.message));
- await endPrevious();
- await log_execution({
- startMessage: identifier + " Sending crash notification email",
- endMessage: ({ error, result }) => {
- const success = error === null && result === true;
- return identifier + ` ${(success ? `Notification successfully sent to` : `Failed to notify`)} ${recipient} ${timestamp()}`;
- },
- action: async () => notify(error || "Hmm, no error to report..."),
- color: cyan
- });
- identifiedLog(green("Initiating server restart..."));
- }
- current_backup = exec(startServerCommand(), err => identifiedLog(err?.message || is(SessionState.INITIALIZED) ? "Spawned initial server." : "Previous server process exited."));
- writeLocalPidLog("server", `${(current_backup?.pid ?? -2) + 1} created ${timestamp()}`);
- }
- setTimeout(checkHeartbeat, 1000 * latency);
- }
-}
-
-function emailText(error: any) {
- return [
- `Hey ${recipient.split("@")[0]},`,
- "You, as a Dash Administrator, are being notified of a server crash event. Here's what we know:",
- `Location: ${heartbeat}\nError: ${error}`,
- "The server should already be restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress."
- ].join("\n\n");
-}
-
-async function notify(error: any) {
- const smtpTransport = nodemailer.createTransport({
- service: 'Gmail',
- auth: {
- user: 'brownptcdash@gmail.com',
- pass: 'browngfx1'
- }
- });
- const mailOptions = {
- to: recipient,
- from: 'brownptcdash@gmail.com',
- subject: 'Dash Server Crash',
- text: emailText(error)
- } as MailOptions;
- return new Promise<boolean>(resolve => {
- smtpTransport.sendMail(mailOptions, (dispatchError: Error | null) => resolve(dispatchError === null));
- });
-}
-
-identifiedLog(yellow(`After initialization, will poll server heartbeat repeatedly...\n`));
-checkHeartbeat(); \ No newline at end of file
diff --git a/src/server/session_manager/session_manager_cluster.ts b/src/server/session_manager/session_manager_cluster.ts
deleted file mode 100644
index 546465c03..000000000
--- a/src/server/session_manager/session_manager_cluster.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { isMaster, fork, on } from "cluster";
-import { cpus } from "os";
-import { createServer } from "http";
-
-const capacity = cpus().length;
-
-let thrown = false;
-
-if (isMaster) {
- console.log(capacity);
- for (let i = 0; i < capacity; i++) {
- fork();
- }
- on("exit", (worker, code, signal) => {
- console.log(`worker ${worker.process.pid} died with code ${code} and signal ${signal}`);
- fork();
- });
-} else {
- const port = 1234;
- createServer().listen(port, () => {
- console.log('process id local', process.pid);
- console.log(`http server started at port ${port}`);
- if (!thrown) {
- thrown = true;
- setTimeout(() => {
- throw new Error("Hey I'm a fake error!");
- }, 1000);
- }
- });
-}
-
-process.on('uncaughtException', function (err) {
- console.error((new Date).toUTCString() + ' uncaughtException:', err.message);
- console.error(err.stack);
- process.exit(1);
-}); \ No newline at end of file