diff options
author | vellichora <fangrui_tong@brown.edu> | 2020-01-07 10:49:04 -0500 |
---|---|---|
committer | vellichora <fangrui_tong@brown.edu> | 2020-01-07 10:49:04 -0500 |
commit | 3633971bd9c1f739c0d6facd74754b99a7f26db6 (patch) | |
tree | 62aab716a9a027b3b63673e3134fa651135635e5 /src | |
parent | 9614ba541c30dc7d2b5183d5450864354d911643 (diff) | |
parent | ccd39c9a53ebf9aea84fcdcba6050145add4526f (diff) |
Merge branch 'master' of https://github.com/browngraphicslab/Dash-Web into textbox_fawn_fix
Diffstat (limited to 'src')
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 |