updated format and lint scripts and applied them

This commit is contained in:
JJ Kasper
2018-06-01 16:52:12 -05:00
parent 53ac8a6793
commit 017a9993ee
58 changed files with 1711 additions and 1568 deletions

3
.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
.next
styles
config

View File

@@ -1,49 +1,49 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import { connect } from 'react-redux'; import { connect } from 'react-redux'
import fetch from 'isomorphic-unfetch'; import fetch from 'isomorphic-unfetch'
import mapUser from '../util/mapUser'; import mapUser from '../util/mapUser'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
import getJwt from '../util/getJwt'; import getJwt from '../util/getJwt'
const getDoc = async (id, req) => { const getDoc = async (id, req) => {
let found, doc; let found, doc
const jwt = getJwt(req); const jwt = getJwt(req)
if(!jwt) return { found, doc, id }; if (!jwt) return { found, doc, id }
const docRes = await fetch(getUrl('docs/' + id, Boolean(req)), { const docRes = await fetch(getUrl('docs/' + id, Boolean(req)), {
method: 'GET', method: 'GET',
headers: { Authorization: jwt } headers: { Authorization: jwt },
}); })
if(docRes.ok) { if (docRes.ok) {
doc = await docRes.json(); doc = await docRes.json()
found = true; found = true
} }
return { found, doc, id }; return { found, doc, id }
}; }
export default ComposedComponent => { export default ComposedComponent => {
class DocComp extends Component { class DocComp extends Component {
state = { state = {
found: false, found: false,
id: null, id: null,
doc: {} doc: {},
} }
static async getInitialProps({ query, req }) { static async getInitialProps({ query, req }) {
return await getDoc(query.id, req); return await getDoc(query.id, req)
} }
static getDerivedStateFromProps(nextProps, state) { static getDerivedStateFromProps(nextProps, state) {
const { found, id, doc } = nextProps; const { found, id, doc } = nextProps
if(state.found !== found) return { found, id, doc }; if (state.found !== found) return { found, id, doc }
return null; return null
} }
async componentDidUpdate(prevProps) { async componentDidUpdate(prevProps) {
const { user, found, id } = this.props; const { user, found, id } = this.props
if(prevProps.user.email === user.email || found) return; if (prevProps.user.email === user.email || found) return
if(!user.email) return; if (!user.email) return
this.setState(await getDoc(id)); this.setState(await getDoc(id))
} }
render() { render() {
return <ComposedComponent {...this.state} />; return <ComposedComponent {...this.state} />
} }
} }
return connect(mapUser)(DocComp); return connect(mapUser)(DocComp)
}; }

View File

@@ -1,65 +1,65 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import cm from 'codemirror'; import cm from 'codemirror'
import { getKey, isCtrlKey } from '../util/keys'; import { getKey, isCtrlKey } from '../util/keys'
if(typeof window !== 'undefined') { if (typeof window !== 'undefined') {
require('codemirror/mode/markdown/markdown'); require('codemirror/mode/markdown/markdown')
} }
export default class CodeMirror extends Component { export default class CodeMirror extends Component {
handleChange = () => { handleChange = () => {
if(!this.editor) return; if (!this.editor) return
const value = this.editor.getValue(); const value = this.editor.getValue()
if(value !== this.props.value) { if (value !== this.props.value) {
this.props.onChange && this.props.onChange(value); this.props.onChange && this.props.onChange(value)
if(this.editor.getValue() !== this.props.value) { if (this.editor.getValue() !== this.props.value) {
if(this.state.isControlled) { if (this.state.isControlled) {
this.editor.setValue(this.props.value); this.editor.setValue(this.props.value)
} else { } else {
this.props.value = value; this.props.value = value
} }
} }
} }
} }
checkSubmit = (cm, e) => { checkSubmit = (cm, e) => {
const key = getKey(e); const key = getKey(e)
if(isCtrlKey(key)) { if (isCtrlKey(key)) {
this.ctrlKey = true; this.ctrlKey = true
} else if(key === 13 && this.ctrlKey) { } else if (key === 13 && this.ctrlKey) {
this.props.onSubmit(); this.props.onSubmit()
} }
} }
handleKeyUp = (cm, e) => { handleKeyUp = (cm, e) => {
if(isCtrlKey(getKey(e))) this.ctrlKey = false; if (isCtrlKey(getKey(e))) this.ctrlKey = false
} }
componentDidMount() { componentDidMount() {
if(typeof window === 'undefined') return; if (typeof window === 'undefined') return
this.editor = cm.fromTextArea(this.textarea, this.props.options); this.editor = cm.fromTextArea(this.textarea, this.props.options)
this.editor.on('change', this.handleChange); this.editor.on('change', this.handleChange)
if(typeof this.props.onSubmit === 'function') { if (typeof this.props.onSubmit === 'function') {
this.editor.on('keydown', this.checkSubmit); this.editor.on('keydown', this.checkSubmit)
this.editor.on('keyup', this.handleKeyUp); this.editor.on('keyup', this.handleKeyUp)
this.setupSubmitKey = true; this.setupSubmitKey = true
} }
} }
componentWillUnmount() { componentWillUnmount() {
if(this.setupSubmitKey) { if (this.setupSubmitKey) {
this.editor.off('keydown', this.checkSubmit); this.editor.off('keydown', this.checkSubmit)
this.editor.off('keyup', this.handleKeyUp); this.editor.off('keyup', this.handleKeyUp)
this.setupSubmitKey = false; this.setupSubmitKey = false
} }
} }
componentDidUpdate() { componentDidUpdate() {
if(!this.editor || !this.props.value) return; if (!this.editor || !this.props.value) return
if(this.editor.getValue() !== this.props.value) { if (this.editor.getValue() !== this.props.value) {
this.editor.setValue(this.props.value); this.editor.setValue(this.props.value)
} }
} }
render() { render() {
const { value, className, onChange } = this.props; const { value, className, onChange } = this.props
return ( return (
<div {...{className}}> <div {...{ className }}>
<textarea {...{value, onChange}} ref={el => this.textarea = el} /> <textarea {...{ value, onChange }} ref={el => (this.textarea = el)} />
</div> </div>
); )
} }
} }

View File

@@ -1,20 +1,26 @@
import Link from 'next/link'; import Link from 'next/link'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
const DocItem = ({ id, name, dir, updated }) => { const DocItem = ({ id, name, dir, updated }) => {
name = dir + (dir.length > 0 ? '/' : '') + name; name = dir + (dir.length > 0 ? '/' : '') + name
const as = getUrl('k/' + id); const as = getUrl('k/' + id)
const href = { pathname: '/k', query: { id } }; const href = { pathname: '/k', query: { id } }
return (<tr> return (
<td><Link {...{href, as}}> <tr>
<a><p className='noMargin'> <td>
{ name } <Link {...{ href, as }}>
<span className='float-right'> <a>
{new Date(updated).toLocaleDateString('en-US')} <p className="noMargin">
</span> {name}
</p></a> <span className="float-right">
</Link></td> {new Date(updated).toLocaleDateString('en-US')}
</tr>); </span>
}; </p>
</a>
</Link>
</td>
</tr>
)
}
export default DocItem; export default DocItem

View File

@@ -1,10 +1,16 @@
const Footer = () => ( const Footer = () => (
<footer className='footer'> <footer className="footer">
<p> <p>
Powered by{' '} Powered by{' '}
<a href='//github.com/ijjk/mykb' target='_blank' rel='noopener noreferrer'>MYKB</a> <a
href="//github.com/ijjk/mykb"
target="_blank"
rel="noopener noreferrer"
>
MYKB
</a>
</p> </p>
</footer> </footer>
); )
export default Footer; export default Footer

View File

@@ -1,75 +1,79 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import { withRouter } from 'next/router'; import { withRouter } from 'next/router'
import { connect } from 'react-redux'; import { connect } from 'react-redux'
import { doLogout } from '../redux/actions/userAct'; import { doLogout } from '../redux/actions/userAct'
import Link from 'next/link'; import Link from 'next/link'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
import mapUser from '../util/mapUser'; import mapUser from '../util/mapUser'
const NavLink = ({ children, href, active }) => { const NavLink = ({ children, href, active }) => {
const activeClass = active ? ' active' : ''; const activeClass = active ? ' active' : ''
return ( return (
<Link href={href} as={getUrl(href)}> <Link href={href} as={getUrl(href)}>
<a className={activeClass}>{children}</a> <a className={activeClass}>{children}</a>
</Link> </Link>
); )
}; }
const navItems = [ const navItems = [['/', 'Home'], ['/new', 'New Doc'], ['/settings', 'Settings']]
['/', 'Home'],
['/new', 'New Doc'],
['/settings', 'Settings']
];
class Header extends Component { class Header extends Component {
state = { state = {
open: false open: false,
} }
hideNav = () => this.setState({ open: false }); hideNav = () => this.setState({ open: false })
toggleNav = () => this.setState({ toggleNav = () =>
open: !this.state.open this.setState({
}); open: !this.state.open,
isActive = url => ( })
getUrl(this.props.router.pathname) === getUrl(url) isActive = url => getUrl(this.props.router.pathname) === getUrl(url)
);
logout = e => { logout = e => {
e.preventDefault(); e.preventDefault()
this.hideNav(); this.hideNav()
doLogout(); doLogout()
}; }
render() { render() {
const expandClass = this.state.open ? ' active' : ''; const expandClass = this.state.open ? ' active' : ''
const { user } = this.props; const { user } = this.props
return( return (
<nav className='navbar' role='navigation' aria-label='main navigation'> <nav className="navbar" role="navigation" aria-label="main navigation">
<div className='navbar-brand'> <div className="navbar-brand">
<NavLink href='/'> <NavLink href="/">
<h3 onClick={this.hideNav}>MYKB</h3> <h3 onClick={this.hideNav}>MYKB</h3>
</NavLink> </NavLink>
</div> </div>
{!user.email ? null {!user.email
? null
: [ : [
<div className={'navbar-burger ' + expandClass} <div
onClick={this.toggleNav} key='burger' className={'navbar-burger ' + expandClass}
> onClick={this.toggleNav}
<div /><div /><div /> key="burger"
</div>, >
<div className={'navbar-items' + expandClass} key='items'> <div />
{navItems.map(item => ( <div />
<NavLink key={item[0]} href={item[0]} <div />
active={this.isActive(item[0])} </div>,
> <div className={'navbar-items' + expandClass} key="items">
<p className='item' onClick={this.hideNav}>{item[1]}</p> {navItems.map(item => (
</NavLink> <NavLink
))} key={item[0]}
<a href='/logout' onClick={this.logout}> href={item[0]}
<p className='item'>Logout</p> active={this.isActive(item[0])}
</a> >
</div> <p className="item" onClick={this.hideNav}>
]} {item[1]}
</p>
</NavLink>
))}
<a href="/logout" onClick={this.logout}>
<p className="item">Logout</p>
</a>
</div>,
]}
</nav> </nav>
); )
} }
} }
export default withRouter(connect(mapUser)(Header)); export default withRouter(connect(mapUser)(Header))

View File

@@ -1,8 +1,8 @@
import { Component } from 'react'; import { Component } from 'react'
import Router from 'next/router'; import Router from 'next/router'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
import { getKey } from '../util/keys'; import { getKey } from '../util/keys'
import { doLogout } from '../redux/actions/userAct'; import { doLogout } from '../redux/actions/userAct'
/* - keyboard shortcuts /* - keyboard shortcuts
g then h -> navigate home g then h -> navigate home
@@ -16,53 +16,56 @@ import { doLogout } from '../redux/actions/userAct';
const keyToUrl = { const keyToUrl = {
72: '/', 72: '/',
78: '/new', 78: '/new',
83: '/settings' 83: '/settings',
}; }
const keyToEl = { const keyToEl = {
69: { sel: '#edit', func: 'click' }, 69: { sel: '#edit', func: 'click' },
191: { sel: '.search', func: 'focus' } 191: { sel: '.search', func: 'focus' },
}; }
class KeyShortcuts extends Component { class KeyShortcuts extends Component {
handleDown = e => { handleDown = e => {
const tag = e.target.tagName; const tag = e.target.tagName
if(tag === 'INPUT' || tag === 'TEXTAREA') return; if (tag === 'INPUT' || tag === 'TEXTAREA') return
const key = getKey(e); const key = getKey(e)
if(this.prevKey === 71) { // prev key was g if (this.prevKey === 71) {
// prev key was g
switch (key) { switch (key) {
case 72: case 72:
case 78: case 78:
case 83: { case 83: {
const url = keyToUrl[key]; const url = keyToUrl[key]
Router.push(url, getUrl(url)); Router.push(url, getUrl(url))
break; break
} }
case 76: { case 76: {
setTimeout(doLogout, 1); setTimeout(doLogout, 1)
break; break
} }
default: break; default:
break
} }
} }
switch (key) { switch (key) {
case 69: case 69:
case 191: { case 191: {
const { sel, func } = keyToEl[key]; const { sel, func } = keyToEl[key]
const el = document.querySelector(sel); const el = document.querySelector(sel)
if(el) setTimeout(() => el[func](), 1); if (el) setTimeout(() => el[func](), 1)
break; break
}
default:
break
} }
default: break; this.prevKey = key
} }
this.prevKey = key;
};
componentDidMount() { componentDidMount() {
window.addEventListener('keydown', this.handleDown); window.addEventListener('keydown', this.handleDown)
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('keydown', this.handleDown); window.removeEventListener('keydown', this.handleDown)
} }
render = () => null render = () => null
} }
export default KeyShortcuts; export default KeyShortcuts

View File

@@ -1,57 +1,66 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import { connect } from 'react-redux'; import { connect } from 'react-redux'
import { doLogin } from '../redux/actions/userAct'; import { doLogin } from '../redux/actions/userAct'
import Spinner from './Spinner'; import Spinner from './Spinner'
import PaddedRow from './PaddedRow'; import PaddedRow from './PaddedRow'
import mapUser from '../util/mapUser'; import mapUser from '../util/mapUser'
class Login extends Component { class Login extends Component {
state = { state = {
email: '', email: '',
pass: '' pass: '',
}; }
updVal = e => { updVal = e => {
const el = e.target; const el = e.target
const val = el.value; const val = el.value
if(el.getAttribute('type') === 'email') { if (el.getAttribute('type') === 'email') {
return this.setState({ email: val }); return this.setState({ email: val })
} }
this.setState({ pass: val }); this.setState({ pass: val })
} }
submit = e => { submit = e => {
const { pending } = this.props.user; const { pending } = this.props.user
let { email, pass } = this.state; let { email, pass } = this.state
email = email.trim(); email = email.trim()
pass = pass.trim(); pass = pass.trim()
e.preventDefault(); e.preventDefault()
if(pending || email.length === 0 || pass.length == 0) { if (pending || email.length === 0 || pass.length == 0) {
return; return
} }
doLogin({ email, password: pass }); doLogin({ email, password: pass })
} }
render() { render() {
const { pending, error } = this.props.user; const { pending, error } = this.props.user
return( return (
<div className='container content'> <div className="container content">
<PaddedRow amount={25} vCenter> <PaddedRow amount={25} vCenter>
<h4>Please login to continue</h4> <h4>Please login to continue</h4>
<form noValidate> <form noValidate>
<fieldset> <fieldset>
<label htmlFor='email'>Email:</label> <label htmlFor="email">Email:</label>
<input type='email' id='email' name='email' <input
autoFocus placeholder='John@deux.com' type="email"
onChange={this.updVal} id="email"
name="email"
autoFocus
placeholder="John@deux.com"
onChange={this.updVal}
/> />
<label htmlFor='pass'>Pass:</label> <label htmlFor="pass">Pass:</label>
<input type='password' id='pass' name='password' <input
placeholder='Super secret password...' onChange={this.updVal} type="password"
id="pass"
name="password"
placeholder="Super secret password..."
onChange={this.updVal}
/> />
</fieldset> </fieldset>
<button className={'float-right' + (pending ? ' disabled' : '')} <button
className={'float-right' + (pending ? ' disabled' : '')}
onClick={this.submit} onClick={this.submit}
> >
{pending ? <Spinner /> : 'Submit'} {pending ? <Spinner /> : 'Submit'}
@@ -61,7 +70,7 @@ class Login extends Component {
</form> </form>
</PaddedRow> </PaddedRow>
</div> </div>
); )
} }
} }
export default connect(mapUser)(Login); export default connect(mapUser)(Login)

View File

@@ -1,16 +1,11 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic'
import freezeSSR from '../util/freezeSSR'; import freezeSSR from '../util/freezeSSR'
const Markdown = dynamic(import('react-markdown'), freezeSSR('.Markdown')); const Markdown = dynamic(import('react-markdown'), freezeSSR('.Markdown'))
const link = props => ( const link = props => <a {...props} target="_blank" rel="noopener noreferrer" />
<a {...props} const renderers = { link }
target='_blank'
rel='noopener noreferrer'
/>
)
const renderers = { link };
const AddRenderers = ({ className, source }) => ( const AddRenderers = ({ className, source }) => (
<Markdown {...{className, source, renderers}} /> <Markdown {...{ className, source, renderers }} />
); )
export default AddRenderers; export default AddRenderers

View File

@@ -1,150 +1,170 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import Router from 'next/router'; import Router from 'next/router'
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic'
import Page from '../comps/Page'; import Page from '../comps/Page'
import Markdown from '../comps/Markdown'; import Markdown from '../comps/Markdown'
import { checkDir, checkName } from '../util/checkDirParts'; import { checkDir, checkName } from '../util/checkDirParts'
import updStateFromId from '../util/updStateFromId'; import updStateFromId from '../util/updStateFromId'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
import getJwt from '../util/getJwt'; import getJwt from '../util/getJwt'
const CodeMirrorSkel = () => ( const CodeMirrorSkel = () => (
<div className='column'> <div className="column">
<textarea style={{ height: 'calc(300px - 1.2rem)', margin: 0 }} /> <textarea style={{ height: 'calc(300px - 1.2rem)', margin: 0 }} />
</div> </div>
); )
const CodeMirror = dynamic(import('../comps/CodeMirror'), { const CodeMirror = dynamic(import('../comps/CodeMirror'), {
loading: CodeMirrorSkel, ssr: false loading: CodeMirrorSkel,
}); ssr: false,
})
const initState = { const initState = {
name: '', name: '',
dir: '', dir: '',
md: '## New Document!!', md: '## New Document!!',
editMode: false, editMode: false,
error: null, error: null,
pending: false pending: false,
}; }
export default class MngDoc extends Component { export default class MngDoc extends Component {
state = initState; state = initState
updVal = updStateFromId.bind(this); updVal = updStateFromId.bind(this)
updMd = md => this.setState({ md }); updMd = md => this.setState({ md })
submit = async () => { submit = async () => {
let { name, md, dir, editMode } = this.state; let { name, md, dir, editMode } = this.state
let data = { let data = {
name: checkName(name), name: checkName(name),
dir: checkDir(dir), dir: checkDir(dir),
md md,
};
const doErr = error => this.setState({ pending: false, error });
const dirErr = 'can only contain A-Z, a-z, 0-9, -, or . and not start or end with .';
if(!data.name) return doErr(
'Document name ' + (data.name === 0 ? 'can not be empty' : dirErr)
);
if(!data.dir && data.dir !== 0) {
return doErr('Directory ' + dirErr);
} else if(data.dir === 0) {
data.dir = '';
} }
if(data.md.trim().length === 0) { const doErr = error => this.setState({ pending: false, error })
return doErr('Content can not be empty'); const dirErr =
'can only contain A-Z, a-z, 0-9, -, or . and not start or end with .'
if (!data.name)
return doErr(
'Document name ' + (data.name === 0 ? 'can not be empty' : dirErr)
)
if (!data.dir && data.dir !== 0) {
return doErr('Directory ' + dirErr)
} else if (data.dir === 0) {
data.dir = ''
}
if (data.md.trim().length === 0) {
return doErr('Content can not be empty')
} }
let url = getUrl('docs'), let url = getUrl('docs'),
method = 'POST', method = 'POST',
headers = { Authorization: getJwt(), headers = {
'Content-Type': 'application/json' Authorization: getJwt(),
}; 'Content-Type': 'application/json',
if(editMode) { }
let numRemoved = 0; if (editMode) {
const dataKeys = Object.keys(data); let numRemoved = 0
const dataKeys = Object.keys(data)
dataKeys.forEach(k => { dataKeys.forEach(k => {
if(data[k] === this.props.doc[k]) { if (data[k] === this.props.doc[k]) {
delete data[k]; delete data[k]
numRemoved++; numRemoved++
} }
}); })
if(dataKeys.length === numRemoved) return; if (dataKeys.length === numRemoved) return
url = getUrl('docs/' + this.props.doc.id); url = getUrl('docs/' + this.props.doc.id)
method = 'PATCH'; method = 'PATCH'
} }
this.setState({ error: null, pending: true }); this.setState({ error: null, pending: true })
const res = await fetch(url, { const res = await fetch(url, {
headers, method, headers,
body: JSON.stringify(data) method,
}).catch(doErr); body: JSON.stringify(data),
try { }).catch(doErr)
data = await res.json(); try {
} catch (err) { data = await res.json()
data = { message: 'An error occurred submitting doc' }; } catch (err) {
data = { message: 'An error occurred submitting doc' }
} }
if(res.ok) { if (res.ok) {
const { id } = data; const { id } = data
return Router.push({ return Router.push(
pathname: '/k', {
query: { id } pathname: '/k',
}, getUrl(`k/${id}`)); query: { id },
},
getUrl(`k/${id}`)
)
} }
doErr(data.message); doErr(data.message)
} }
static getDerivedStateFromProps(nextProps, prevState) { static getDerivedStateFromProps(nextProps, prevState) {
const { doc } = nextProps; const { doc } = nextProps
if(doc) { if (doc) {
const { name, dir, md } = doc; const { name, dir, md } = doc
return { name, md, dir, editMode: true }; return { name, md, dir, editMode: true }
} else if(prevState.id) { } else if (prevState.id) {
return initState; return initState
} }
return null; return null
} }
render() { render() {
const { md, dir, name, error, pending } = this.state; const { md, dir, name, error, pending } = this.state
const rowStyle = { paddingTop: 10 }; const rowStyle = { paddingTop: 10 }
return( return (
<Page> <Page>
<div className='row fill' style={rowStyle}> <div className="row fill" style={rowStyle}>
<div className='column column-50'> <div className="column column-50">
<Markdown className='fill Markdown' source={md} /> <Markdown className="fill Markdown" source={md} />
</div> </div>
<div className='column column-50'> <div className="column column-50">
<div className='row'> <div className="row">
<div className='column column-60'> <div className="column column-60">
<input type='text' maxLength={250} <input
placeholder='New document name' id='name' type="text"
value={name} onChange={this.updVal} maxLength={250}
/> placeholder="New document name"
id="name"
value={name}
onChange={this.updVal}
/>
</div> </div>
<div className='column'> <div className="column">
<input type='text' maxLength={1024} <input
placeholder='Subdirectory (optional)' id='dir' type="text"
value={dir} onChange={this.updVal} maxLength={1024}
placeholder="Subdirectory (optional)"
id="dir"
value={dir}
onChange={this.updVal}
/> />
</div> </div>
</div> </div>
<div className='row'> <div className="row">
<CodeMirror value={md} <CodeMirror
className='column WrapCodeMirror' value={md}
className="column WrapCodeMirror"
onChange={this.updMd} onChange={this.updMd}
onSubmit={this.submit} onSubmit={this.submit}
options={{ options={{
theme: 'monokai', theme: 'monokai',
mode: 'markdown', mode: 'markdown',
lineWrapping: true lineWrapping: true,
}} }}
/> />
</div> </div>
<div className='row' style={{ marginTop: 5 }}> <div className="row" style={{ marginTop: 5 }}>
<div className='column'> <div className="column">
<span>{error}</span> <span>{error}</span>
<button className='float-right' <button
className="float-right"
style={{ marginTop: 5 }} style={{ marginTop: 5 }}
onClick={pending ? null : this.submit} onClick={pending ? null : this.submit}
>Submit</button> >
Submit
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</Page> </Page>
); )
} }
} }

View File

@@ -1,18 +1,16 @@
const PaddedRow = ({ children, amount, vCenter }) => { const PaddedRow = ({ children, amount, vCenter }) => {
amount = amount || 20; amount = amount || 20
const PadItem = () => ( const PadItem = () => <div className={'column column-' + amount + ' nomob'} />
<div className={'column column-' + amount + ' nomob'} /> let rowProps = { className: 'row' }
); if (vCenter) rowProps = { className: 'row v-center' }
let rowProps = {className: 'row'}; else rowProps = { ...rowProps, style: { paddingTop: amount } }
if(vCenter) rowProps = {className: 'row v-center'};
else rowProps = {...rowProps, style: {paddingTop: amount}};
return ( return (
<div {...rowProps}> <div {...rowProps}>
<PadItem /> <PadItem />
<div className='column'>{ children }</div> <div className="column">{children}</div>
<PadItem /> <PadItem />
</div> </div>
); )
}; }
export default PaddedRow; export default PaddedRow

View File

@@ -1,25 +1,25 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux'
import Header from './Header'; import Header from './Header'
import KeyShortcuts from './KeyShortcuts'; import KeyShortcuts from './KeyShortcuts'
import Footer from './Footer'; import Footer from './Footer'
import Login from './Login'; import Login from './Login'
import Setup from './Setup'; import Setup from './Setup'
import mapUser from '../util/mapUser'; import mapUser from '../util/mapUser'
const Page = ({ user, children }) => { const Page = ({ user, children }) => {
return ( return (
<div> <div>
<Header /> <Header />
<KeyShortcuts /> <KeyShortcuts />
{user.email {user.email ? (
? <div className='container content'> <div className="container content">{children}</div>
{ children } ) : user.setup ? (
</div> <Setup />
: user.setup ) : (
? <Setup /> : <Login {...{user}} /> <Login {...{ user }} />
} )}
<Footer /> <Footer />
</div> </div>
); )
}; }
export default connect(mapUser)(Page); export default connect(mapUser)(Page)

View File

@@ -1,97 +1,105 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import { doLogin } from '../redux/actions/userAct'; import { doLogin } from '../redux/actions/userAct'
import PaddedRow from './PaddedRow'; import PaddedRow from './PaddedRow'
import Spinner from './Spinner'; import Spinner from './Spinner'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
export default class Setup extends Component { export default class Setup extends Component {
state = { state = {
email: '', email: '',
password: '', password: '',
confirmPass: '', confirmPass: '',
pending: false, pending: false,
error: null error: null,
} }
updVal = e => { updVal = e => {
const el = e.target; const el = e.target
let key = 'email'; let key = 'email'
if(el.id === 'pass') key = 'password'; if (el.id === 'pass') key = 'password'
else if(el.id === 'pass2') key = 'confirmPass'; else if (el.id === 'pass2') key = 'confirmPass'
const obj = {}; const obj = {}
obj[key] = el.value; obj[key] = el.value
this.setState(obj); this.setState(obj)
} }
submit = e => { submit = e => {
e.preventDefault(); e.preventDefault()
let { email, password, confirmPass, pending } = this.state; let { email, password, confirmPass, pending } = this.state
if(pending) return; if (pending) return
email = email.trim(); email = email.trim()
password = password.trim(); password = password.trim()
confirmPass = confirmPass.trim(); confirmPass = confirmPass.trim()
const hasEmpty = [ email, password, confirmPass ] const hasEmpty = [email, password, confirmPass].some(
.some(val => val.length === 0); val => val.length === 0
)
if(hasEmpty) return; if (hasEmpty) return
if(password.toLowerCase() !== confirmPass.toLowerCase()) { if (password.toLowerCase() !== confirmPass.toLowerCase()) {
return this.setState({ error: 'Passwords don\'t match' }); return this.setState({ error: "Passwords don't match" })
} }
this.setState({ pending: true, error: null }); this.setState({ pending: true, error: null })
const defaultErr = 'Could not create account'; const defaultErr = 'Could not create account'
fetch(getUrl('users'), { fetch(getUrl('users'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, admin: true }) body: JSON.stringify({ email, password, admin: true }),
}) })
.then(res => { .then(res => {
if(res.ok) { if (res.ok) {
return doLogin({ email, password }, null, true); return doLogin({ email, password }, null, true)
} }
res.json().then(({ message }) => { res.json().then(({ message }) => {
const error = message || defaultErr; const error = message || defaultErr
this.setState({ pending: false, error }); this.setState({ pending: false, error })
}); })
}) })
.catch(err => { .catch(err => {
const error = err.message || defaultErr; const error = err.message || defaultErr
this.setState({ pending: false, error }); this.setState({ pending: false, error })
}); })
} }
render() { render() {
const { pending, error } = this.state; const { pending, error } = this.state
return( return (
<div className='container content'> <div className="container content">
<PaddedRow amount={25} vCenter> <PaddedRow amount={25} vCenter>
<div className='column'> <div className="column">
<h3>Setup account</h3> <h3>Setup account</h3>
<form noValidate> <form noValidate>
<fieldset> <fieldset>
<label htmlFor='email'>Email:</label> <label htmlFor="email">Email:</label>
<input type='email' autoFocus id='email' <input
placeholder={'Your email (does\'t have to be actual email)'} type="email"
autoFocus
id="email"
placeholder={"Your email (does't have to be actual email)"}
onChange={this.updVal} onChange={this.updVal}
/> />
<label htmlFor='pass'>Password:</label> <label htmlFor="pass">Password:</label>
<input type='password' id='pass' maxLength={512} <input
placeholder='A super secret password' type="password"
onChange={this.updVal} id="pass"
/> maxLength={512}
<label htmlFor='pass2'>Confirm Password:</label> placeholder="A super secret password"
<input type='password' id='pass2' maxLength={512}
placeholder='Confirm your super secret password'
onChange={this.updVal} onChange={this.updVal}
/> />
<button className='float-right' <label htmlFor="pass2">Confirm Password:</label>
onClick={this.submit} <input
> type="password"
id="pass2"
maxLength={512}
placeholder="Confirm your super secret password"
onChange={this.updVal}
/>
<button className="float-right" onClick={this.submit}>
{pending ? <Spinner /> : 'Submit'} {pending ? <Spinner /> : 'Submit'}
</button> </button>
{!error ? null : <p className='danger'>{error}</p>} {!error ? null : <p className="danger">{error}</p>}
</fieldset> </fieldset>
</form> </form>
</div> </div>
</PaddedRow> </PaddedRow>
</div> </div>
); )
} }
} }

View File

@@ -1,4 +1,2 @@
const Spinner = (props) => ( const Spinner = props => <div className="spinner" {...props} />
<div className='spinner' {...props}></div> export default Spinner
);
export default Spinner;

View File

@@ -1,24 +1,23 @@
const fs = require('fs'); /* eslint-disable no-console */
const path = require('path'); const fs = require('fs')
const crypto = require('crypto'); const path = require('path')
const secret = crypto.randomBytes(256).toString('hex'); const crypto = require('crypto')
const { NODE_ENV } = process.env; const secret = crypto.randomBytes(256).toString('hex')
let confFile = 'default.json'; const { NODE_ENV } = process.env
let confFile = 'default.json'
if(NODE_ENV && NODE_ENV.toLowerCase() === 'production') { if (NODE_ENV && NODE_ENV.toLowerCase() === 'production') {
confFile = 'production.json'; confFile = 'production.json'
} }
let config = require('./config/' + confFile); let config = require('./config/' + confFile)
let configPath = path.join(__dirname, 'config', confFile); let configPath = path.join(__dirname, 'config', confFile)
if(!config.authentication) { if (!config.authentication) {
config.authentication = { secret }; config.authentication = { secret }
} else { } else {
config.authentication.secret = secret; config.authentication.secret = secret
} }
fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', err => {
err => { if (err) return console.error(err)
if(err) return console.error(err); console.log('\nAuthentication secret successfully updated in', confFile)
console.log('\nAuthentication secret successfully updated in', confFile); })
}
);

View File

@@ -1,20 +1,22 @@
const withSass = require('@zeit/next-sass'); const withSass = require('@zeit/next-sass')
const { ANALYZE } = process.env const { ANALYZE } = process.env
let AnalyzerPlugin; let AnalyzerPlugin
if(ANALYZE) { if (ANALYZE) {
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
AnalyzerPlugin = BundleAnalyzerPlugin; AnalyzerPlugin = BundleAnalyzerPlugin
} }
module.exports = withSass({ module.exports = withSass({
poweredByHeader: false, poweredByHeader: false,
webpack: function (config, { isServer }) { webpack: function(config, { isServer }) {
if (ANALYZE) { if (ANALYZE) {
config.plugins.push(new AnalyzerPlugin({ config.plugins.push(
analyzerMode: 'server', new AnalyzerPlugin({
analyzerPort: isServer ? 8888 : 8889, analyzerMode: 'server',
openAnalyzer: true analyzerPort: isServer ? 8888 : 8889,
})) openAnalyzer: true,
})
)
} }
return config return config
} },
}); })

View File

@@ -24,8 +24,8 @@
"yarn": ">= 0.18.0" "yarn": ">= 0.18.0"
}, },
"scripts": { "scripts": {
"format": "prettier --write '**/*.js'", "format": "prettier --ignore-path .eslintignore --write '**/*.js'",
"lint": "eslint ./src ./test ./pages ./redux ./util ./comps --config .eslintrc.json", "lint": "eslint . --config .eslintrc.json",
"mocha": "cross-env NODE_ENV=production mocha test/ --recursive --exit", "mocha": "cross-env NODE_ENV=production mocha test/ --recursive --exit",
"build": "next build", "build": "next build",
"analyze": "cross-env ANALYZE=true next build", "analyze": "cross-env ANALYZE=true next build",

View File

@@ -1,42 +1,42 @@
import App, { Container } from 'next/app'; import App, { Container } from 'next/app'
import store from '../redux/store'; import store from '../redux/store'
import { Provider } from 'react-redux'; import { Provider } from 'react-redux'
import { setUser, doLogin } from '../redux/actions/userAct'; import { setUser, doLogin } from '../redux/actions/userAct'
import '../styles/style.sass'; import '../styles/style.sass'
const ssr = typeof window === 'undefined'; const ssr = typeof window === 'undefined'
export default class MyApp extends App { export default class MyApp extends App {
static async getInitialProps ({ Component, ctx }) { static async getInitialProps({ Component, ctx }) {
let user = {}; let user = {}
let setup = false; let setup = false
if(ssr) { if (ssr) {
user = ctx.req.user || {}; user = ctx.req.user || {}
setup = ctx.req.doSetup || false; setup = ctx.req.doSetup || false
} }
let pageProps = {}; let pageProps = {}
if (Component.getInitialProps) { if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx); pageProps = await Component.getInitialProps(ctx)
} }
return { Component, pageProps, user, setup }; return { Component, pageProps, user, setup }
} }
componentWillMount() { componentWillMount() {
const { user, setup } = this.props; const { user, setup } = this.props
setUser({...user, setup}); setUser({ ...user, setup })
if(!ssr && !user.email) { if (!ssr && !user.email) {
const { jwt } = window.localStorage; const { jwt } = window.localStorage
if(jwt) doLogin(null, jwt, true); if (jwt) doLogin(null, jwt, true)
} }
} }
render () { render() {
let { Component, pageProps } = this.props; let { Component, pageProps } = this.props
return ( return (
<Provider store={store}> <Provider store={store}>
<Container> <Container>
<Component {...pageProps} /> <Component {...pageProps} />
</Container> </Container>
</Provider> </Provider>
); )
} }
} }

View File

@@ -1,17 +1,20 @@
import Document, { Head, Main, NextScript } from 'next/document'; import Document, { Head, Main, NextScript } from 'next/document'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
export default class MyDocument extends Document { export default class MyDocument extends Document {
render() { render() {
const favicon = getUrl('favicon.ico'); const favicon = getUrl('favicon.ico')
return ( return (
<html> <html>
<Head> <Head>
<meta charSet='utf-8'/> <meta charSet="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no'/> <meta
<link rel='shortcut icon' href={favicon} type='image/x-icon'/> name="viewport"
<link rel='icon' href={favicon} type='image/x-icon'/> content="width=device-width, initial-scale=1, shrink-to-fit=no"
<link rel='stylesheet' href={getUrl('/_next/static/style.css')} /> />
<link rel="shortcut icon" href={favicon} type="image/x-icon" />
<link rel="icon" href={favicon} type="image/x-icon" />
<link rel="stylesheet" href={getUrl('/_next/static/style.css')} />
<title>My Knowledge Base</title> <title>My Knowledge Base</title>
</Head> </Head>
<body> <body>
@@ -19,6 +22,6 @@ export default class MyDocument extends Document {
<NextScript /> <NextScript />
</body> </body>
</html> </html>
); )
} }
} }

View File

@@ -1,17 +1,18 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import Page from '../comps/Page'; import Page from '../comps/Page'
import MngDoc from '../comps/MngDoc'; import MngDoc from '../comps/MngDoc'
import AddDoc from '../comps/AddDoc'; import AddDoc from '../comps/AddDoc'
class Edit extends Component { class Edit extends Component {
render() { render() {
const { found, doc } = this.props; const { found, doc } = this.props
if(!found) return ( if (!found)
<Page> return (
<h3>Doc not found...</h3> <Page>
</Page> <h3>Doc not found...</h3>
); </Page>
return <MngDoc {...{doc}} />; )
return <MngDoc {...{ doc }} />
} }
} }
export default AddDoc(Edit); export default AddDoc(Edit)

View File

@@ -1,14 +1,14 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import { connect } from 'react-redux'; import { connect } from 'react-redux'
import Router from 'next/router'; import Router from 'next/router'
import Paginate from 'react-paginate'; import Paginate from 'react-paginate'
import Page from '../comps/Page'; import Page from '../comps/Page'
import PaddedRow from '../comps/PaddedRow'; import PaddedRow from '../comps/PaddedRow'
import Spinner from '../comps/Spinner'; import Spinner from '../comps/Spinner'
import DocItem from '../comps/DocItem'; import DocItem from '../comps/DocItem'
import { $limit, getDocs, buildQ } from '../util/getDocs'; import { $limit, getDocs, buildQ } from '../util/getDocs'
import getJwt from '../util/getJwt'; import getJwt from '../util/getJwt'
import mapUser from '../util/mapUser'; import mapUser from '../util/mapUser'
class Index extends Component { class Index extends Component {
state = { state = {
@@ -18,95 +18,104 @@ class Index extends Component {
pending: false, pending: false,
error: null, error: null,
total: 0, total: 0,
docs: [] docs: [],
} }
static async getInitialProps({ req, query }) { static async getInitialProps({ req, query }) {
let page = 1, $search = ''; let page = 1,
if(query) { $search = ''
page = query.page || page; if (query) {
$search = query.search || $search; page = query.page || page
$search = query.search || $search
} }
const jwt = getJwt(req); const jwt = getJwt(req)
if(!jwt) return { total: 0, docs: [] }; if (!jwt) return { total: 0, docs: [] }
const q = buildQ({ $search, $skip: page }); const q = buildQ({ $search, $skip: page })
const data = await getDocs(q, req ? jwt : false); const data = await getDocs(q, req ? jwt : false)
return { ...data, page, $search }; return { ...data, page, $search }
} }
updDocs = (time, doSearch) => { updDocs = (time, doSearch) => {
clearTimeout(this.docsTime); clearTimeout(this.docsTime)
this.docsTime = setTimeout(async () => { this.docsTime = setTimeout(async () => {
let { $sort, $search, page } = this.state; let { $sort, $search, page } = this.state
if(doSearch) { if (doSearch) {
const query = { search: $search }; const query = { search: $search }
if(!$search) delete query.search; if (!$search) delete query.search
Router.push({ pathname: '/', query }); Router.push({ pathname: '/', query })
} }
this.setState({ error: null }); this.setState({ error: null })
this.docsTime = setTimeout(() => { this.docsTime = setTimeout(() => {
this.setState({ pending: true }); this.setState({ pending: true })
}, 125); }, 125)
const q = buildQ({ $search, $sort, $skip: page }); const q = buildQ({ $search, $sort, $skip: page })
const data = await getDocs(q); const data = await getDocs(q)
clearTimeout(this.docsTime); clearTimeout(this.docsTime)
this.setState({ ...data, pending: false }); this.setState({ ...data, pending: false })
}, time || 275); }, time || 275)
} }
updQuery = e => { updQuery = e => {
this.setState({ [e.target.id]: e.target.value }); this.setState({ [e.target.id]: e.target.value })
this.updDocs(0, e.target.id === '$search'); this.updDocs(0, e.target.id === '$search')
} }
handlePage = ({ selected }) => { handlePage = ({ selected }) => {
const { $search } = this.state; const { $search } = this.state
const page = selected + 1; const page = selected + 1
const query = {}; const query = {}
this.setState({ page }); this.setState({ page })
if(page > 1) query.page = page; if (page > 1) query.page = page
if($search) query.search = $search; if ($search) query.search = $search
Router.push({ pathname: '/', query }); Router.push({ pathname: '/', query })
this.updDocs(1); this.updDocs(1)
} }
static getDerivedStateFromProps(nextProps, prevState) { static getDerivedStateFromProps(nextProps, prevState) {
let { docs, total, page, $search } = nextProps; let { docs, total, page, $search } = nextProps
if(docs.length !== prevState.docs.length || if (
page !== prevState.page || $search !== prevState.$search) { docs.length !== prevState.docs.length ||
return { total, docs, page, $search, pending: false }; page !== prevState.page ||
$search !== prevState.$search
) {
return { total, docs, page, $search, pending: false }
} }
return null; return null
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { user, docs } = this.props; const { user, docs } = this.props
if(prevProps.user.email === user.email) return; if (prevProps.user.email === user.email) return
if(user.email && docs.length === 0) this.updDocs(1); if (user.email && docs.length === 0) this.updDocs(1)
} }
render() { render() {
const { const { $sort, $search, pending, error, docs, total, page } = this.state
$sort, $search, pending, const pages = Math.ceil(total / $limit)
error, docs, total, page
} = this.state;
const pages = Math.ceil(total / $limit);
return ( return (
<Page> <Page>
<PaddedRow> <PaddedRow>
<input type='text' placeholder='Search knowledge base...' <input
maxLength={128} value={$search} className='search' type="text"
id='$search' onChange={this.updQuery} placeholder="Search knowledge base..."
maxLength={128}
value={$search}
className="search"
id="$search"
onChange={this.updQuery}
/> />
</PaddedRow> </PaddedRow>
<PaddedRow> <PaddedRow>
<div className='inline' style={{ width: '100%' }}> <div className="inline" style={{ width: '100%' }}>
<h4 className='noMargin'>Docs</h4> <h4 className="noMargin">Docs</h4>
<div className='float-right inline'> <div className="float-right inline">
<label htmlFor='sort'>Sort: </label> <label htmlFor="sort">Sort: </label>
<select id='$sort' value={$sort} <select
onChange={this.updQuery} style={{ width: 150 }} id="$sort"
value={$sort}
onChange={this.updQuery}
style={{ width: 150 }}
> >
<option value='updated:-1'>{'Updated (new -> old)'}</option> <option value="updated:-1">{'Updated (new -> old)'}</option>
<option value='updated:1'>{'Updated (old -> new)'}</option> <option value="updated:1">{'Updated (old -> new)'}</option>
<option value='created:-1'>{'Created (new -> old)'}</option> <option value="created:-1">{'Created (new -> old)'}</option>
<option value='created:1'>{'Created (old -> new)'}</option> <option value="created:1">{'Created (old -> new)'}</option>
<option value='dirName:1'>{'Name (A -> Z)'}</option> <option value="dirName:1">{'Name (A -> Z)'}</option>
<option value='dirName:-1'>{'Name (Z -> A)'}</option> <option value="dirName:-1">{'Name (Z -> A)'}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -114,32 +123,37 @@ class Index extends Component {
<PaddedRow> <PaddedRow>
{docs.length > 0 || error || pending ? null : <p>No docs found...</p>} {docs.length > 0 || error || pending ? null : <p>No docs found...</p>}
{!error ? null : <p>{error}</p>} {!error ? null : <p>{error}</p>}
{!pending || error ? null : <Spinner style={{ margin: '25px auto 0' }} />} {!pending || error ? null : (
{docs.length < 1 || pending || error ? null <Spinner style={{ margin: '25px auto 0' }} />
: <div> )}
{docs.length < 1 || pending || error ? null : (
<div>
<table> <table>
<thead> <thead>
<tr><th> <tr>
Doc <span className='float-right'>Modified</span> <th>
</th></tr> Doc <span className="float-right">Modified</span>
</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{docs.map(doc => <DocItem {...doc} key={doc.id} />)} {docs.map(doc => <DocItem {...doc} key={doc.id} />)}
</tbody> </tbody>
</table> </table>
{pages < 2 ? null {pages < 2 ? null : (
: <Paginate pageCount={pages} <Paginate
containerClassName='paginate' pageCount={pages}
activeClassName='active' containerClassName="paginate"
activeClassName="active"
onPageChange={this.handlePage} onPageChange={this.handlePage}
forcePage={page - 1} forcePage={page - 1}
/> />
} )}
</div> </div>
} )}
</PaddedRow> </PaddedRow>
</Page> </Page>
); )
} }
} }
export default connect(mapUser)(Index); export default connect(mapUser)(Index)

View File

@@ -1,53 +1,64 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import Link from 'next/link'; import Link from 'next/link'
import Router from 'next/router'; import Router from 'next/router'
import fetch from 'isomorphic-unfetch'; import fetch from 'isomorphic-unfetch'
import Page from '../comps/Page'; import Page from '../comps/Page'
import Markdown from '../comps/Markdown'; import Markdown from '../comps/Markdown'
import AddDoc from '../comps/AddDoc'; import AddDoc from '../comps/AddDoc'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
import getJwt from '../util/getJwt'; import getJwt from '../util/getJwt'
class k extends Component { class k extends Component {
delete = async () => { delete = async () => {
const sure = window.confirm('Are you sure you want to delete this doc? This can not be undone.'); const sure = window.confirm(
if(!sure) return; 'Are you sure you want to delete this doc? This can not be undone.'
)
if (!sure) return
const del = await fetch(getUrl('docs/' + this.props.id), { const del = await fetch(getUrl('docs/' + this.props.id), {
headers: { Authorization: getJwt() }, headers: { Authorization: getJwt() },
method: 'DELETE' method: 'DELETE',
}).catch(({ message }) => ({ ok: false, message })); }).catch(({ message }) => ({ ok: false, message }))
if(del.ok) Router.push('/'); if (del.ok) Router.push('/')
else { else {
if(!del.message) { if (!del.message) {
const data = await del.json(); const data = await del.json()
del.message = data.message; del.message = data.message
} }
window.alert(`Could not delete doc, ${del.message}`); window.alert(`Could not delete doc, ${del.message}`)
} }
} }
render() { render() {
const { found, id, doc } = this.props; const { found, id, doc } = this.props
if(!found) return ( if (!found)
<Page> return (
<h3>Doc not found...</h3> <Page>
</Page> <h3>Doc not found...</h3>
); </Page>
)
return ( return (
<Page> <Page>
<h5 style={{ marginBottom: '1rem' }}> <h5 style={{ marginBottom: '1rem' }}>
{doc.dir}{doc.dir.length > 0 ? '/' : ''}{doc.name}{' - '} {doc.dir}
<Link as={getUrl('edit/' + id)} {doc.dir.length > 0 ? '/' : ''}
href={{ pathname: '/edit', query: { id }}} {doc.name}
{' - '}
<Link
as={getUrl('edit/' + id)}
href={{ pathname: '/edit', query: { id } }}
> >
<a id='edit'>edit</a> <a id="edit">edit</a>
</Link> </Link>
<button className='float-right' onClick={this.delete} <button
className="float-right"
onClick={this.delete}
style={{ margin: '5px 0 0' }} style={{ margin: '5px 0 0' }}
>Delete</button> >
Delete
</button>
</h5> </h5>
<Markdown source={doc.md} className='Markdown' /> <Markdown source={doc.md} className="Markdown" />
</Page> </Page>
); )
} }
} }
export default AddDoc(k); export default AddDoc(k)

View File

@@ -1,2 +1,2 @@
import MngDoc from '../comps/MngDoc'; import MngDoc from '../comps/MngDoc'
export default MngDoc; export default MngDoc

View File

@@ -1,13 +1,13 @@
import React, { Component } from 'react'; import React, { Component } from 'react'
import { connect } from 'react-redux'; import { connect } from 'react-redux'
import fetch from 'isomorphic-unfetch'; import fetch from 'isomorphic-unfetch'
import Page from '../comps/Page'; import Page from '../comps/Page'
import PaddedRow from '../comps/PaddedRow'; import PaddedRow from '../comps/PaddedRow'
import Spinner from '../comps/Spinner'; import Spinner from '../comps/Spinner'
import updStateFromId from '../util/updStateFromId'; import updStateFromId from '../util/updStateFromId'
import mapUser from '../util/mapUser'; import mapUser from '../util/mapUser'
import getUrl from '../util/getUrl'; import getUrl from '../util/getUrl'
import getJwt from '../util/getJwt'; import getJwt from '../util/getJwt'
class Settings extends Component { class Settings extends Component {
state = { state = {
@@ -15,54 +15,56 @@ class Settings extends Component {
passErr: null, passErr: null,
curPass: '', curPass: '',
newPass: '', newPass: '',
confPass: '' confPass: '',
} }
updVal = updStateFromId.bind(this); updVal = updStateFromId.bind(this)
submit = async e => { submit = async e => {
e.preventDefault(); e.preventDefault()
const { pending, curPass, newPass, confPass } = this.state; const { pending, curPass, newPass, confPass } = this.state
const { email, _id } = this.props.user; const { email, _id } = this.props.user
if(pending) return; if (pending) return
const doErr = passErr => this.setState({ pending: false, passErr }); const doErr = passErr => this.setState({ pending: false, passErr })
const vals = { const vals = {
'Current password': curPass, 'Current password': curPass,
'New password': newPass, 'New password': newPass,
'Confirm new password': confPass 'Confirm new password': confPass,
};
const keys = Object.keys(vals);
for(let i = 0; i < keys.length; i++) {
let key = keys[i], val = vals[key];
if(val.length === 0) return doErr(`${key} is required`);
} }
if(newPass !== confPass) return doErr('New passwords don\'t match'); const keys = Object.keys(vals)
for (let i = 0; i < keys.length; i++) {
this.setState({ passErr: null, pending: true }); let key = keys[i],
val = vals[key]
if (val.length === 0) return doErr(`${key} is required`)
}
if (newPass !== confPass) return doErr("New passwords don't match")
this.setState({ passErr: null, pending: true })
const updRes = await fetch(getUrl('users/' + _id), { const updRes = await fetch(getUrl('users/' + _id), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: getJwt() }, headers: { 'Content-Type': 'application/json', Authorization: getJwt() },
body: JSON.stringify({ email, password: curPass, newPassword: newPass }) body: JSON.stringify({ email, password: curPass, newPassword: newPass }),
}).catch(doErr); }).catch(doErr)
if(updRes.ok) { if (updRes.ok) {
this.setState({ this.setState({
curPass: '', newPass: '', confPass: '', curPass: '',
passErr: 'Password updated successfully', newPass: '',
pending: false confPass: '',
}); passErr: 'Password updated successfully',
pending: false,
})
} else { } else {
let message = 'failed to update password'; let message = 'failed to update password'
try { try {
const data = await updRes.json(); const data = await updRes.json()
message = data.message || message; message = data.message || message
} catch (err) { doErr(err.message); } } catch (err) {
doErr(message); doErr(err.message)
}
doErr(message)
} }
} }
render() { render() {
const { const { pending, passErr, curPass, newPass, confPass } = this.state
pending, passErr, curPass,
newPass, confPass
} = this.state;
return ( return (
<Page> <Page>
<PaddedRow amount={25}> <PaddedRow amount={25}>
@@ -71,20 +73,33 @@ class Settings extends Component {
<form noValidate style={{ padding: '0 0 45px' }}> <form noValidate style={{ padding: '0 0 45px' }}>
<h4>Change password</h4> <h4>Change password</h4>
<fieldset> <fieldset>
<label htmlFor='curPass'>Current Password</label> <label htmlFor="curPass">Current Password</label>
<input type='password' id='curPass' onChange={this.updVal} <input
placeholder='Current super secret password...' value={curPass} type="password"
id="curPass"
onChange={this.updVal}
placeholder="Current super secret password..."
value={curPass}
/> />
<label htmlFor='newPass'>New Password</label> <label htmlFor="newPass">New Password</label>
<input type='password' id='newPass' onChange={this.updVal} <input
placeholder='New super secret password...' value={newPass} type="password"
id="newPass"
onChange={this.updVal}
placeholder="New super secret password..."
value={newPass}
/> />
<label htmlFor='confPass'>Confirm New Password</label> <label htmlFor="confPass">Confirm New Password</label>
<input type='password' id='confPass' onChange={this.updVal} <input
placeholder='Confirm new super secret password...' value={confPass} type="password"
id="confPass"
onChange={this.updVal}
placeholder="Confirm new super secret password..."
value={confPass}
/> />
</fieldset> </fieldset>
<button onClick={this.submit} <button
onClick={this.submit}
className={'float-right' + (pending ? ' disabled' : '')} className={'float-right' + (pending ? ' disabled' : '')}
> >
{pending ? <Spinner /> : 'Submit'} {pending ? <Spinner /> : 'Submit'}
@@ -93,7 +108,7 @@ class Settings extends Component {
</form> </form>
</PaddedRow> </PaddedRow>
</Page> </Page>
); )
} }
} }
export default connect(mapUser)(Settings); export default connect(mapUser)(Settings)

View File

@@ -1,74 +1,82 @@
import fetch from 'isomorphic-unfetch'; import fetch from 'isomorphic-unfetch'
import store from '../store'; import store from '../store'
import getUrl from '../../util/getUrl'; import getUrl from '../../util/getUrl'
// define action types // define action types
export const SET_USER = 'SET_USER'; export const SET_USER = 'SET_USER'
export const LOGIN_PENDING = 'LOGIN_PENDING'; export const LOGIN_PENDING = 'LOGIN_PENDING'
export const LOGIN_FAILED = 'LOGIN_FAILED'; export const LOGIN_FAILED = 'LOGIN_FAILED'
export const LOGOUT = 'LOGOUT'; export const LOGOUT = 'LOGOUT'
export const setUser = user => { export const setUser = user => {
store.dispatch({ store.dispatch({
type: SET_USER, type: SET_USER,
data: user data: user,
}); })
}; // setUser } // setUser
export const doLogout = () => { export const doLogout = () => {
if(typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.removeItem('jwt'); window.localStorage.removeItem('jwt')
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/;'; document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/;'
} }
store.dispatch({ type: LOGOUT }); store.dispatch({ type: LOGOUT })
}; // doLogout } // doLogout
export async function doLogin (creds, jwt, noPend) { export async function doLogin(creds, jwt, noPend) {
!noPend && store.dispatch({ type: LOGIN_PENDING }); !noPend && store.dispatch({ type: LOGIN_PENDING })
const authReqOpts = { method: 'POST', credentials: 'include' }; const authReqOpts = { method: 'POST', credentials: 'include' }
const authReqHead = { headers: jwt ? { Authorization: jwt } : { const authReqHead = {
'Content-Type': 'application/json' } headers: jwt
}; ? { Authorization: jwt }
const authReqBody = jwt ? null : { : {
body: JSON.stringify({...creds, strategy: 'local'}) 'Content-Type': 'application/json',
}; },
const authReq = new Request(getUrl('auth'), {
...authReqOpts, ...authReqHead, ...authReqBody
});
const authRes = await fetch(authReq).catch(err => {
store.dispatch({ type: LOGIN_FAILED, data: err.message });
});
if(!authRes.ok) {
let error;
try {
error = await authRes.json();
error = error.message;
}
catch (err) {
error = authRes.status === 429
? 'Max login attempts reached'
: 'An error occurred during login';
}
return store.dispatch({
type: LOGIN_FAILED,
data: error
});
} }
const { accessToken } = await authRes.json(); const authReqBody = jwt
const payload = accessToken.split('.')[1]; ? null
const { userId } = JSON.parse(atob(payload)); : {
const userReq = new Request(getUrl(`/users/${userId}`), { body: JSON.stringify({ ...creds, strategy: 'local' }),
headers: { }
Authorization: accessToken const authReq = new Request(getUrl('auth'), {
...authReqOpts,
...authReqHead,
...authReqBody,
})
const authRes = await fetch(authReq).catch(err => {
store.dispatch({ type: LOGIN_FAILED, data: err.message })
})
if (!authRes.ok) {
let error
try {
error = await authRes.json()
error = error.message
} catch (err) {
error =
authRes.status === 429
? 'Max login attempts reached'
: 'An error occurred during login'
} }
});
const userRes = await fetch(userReq);
if(!userRes.ok) {
return store.dispatch({ return store.dispatch({
type: LOGIN_FAILED, type: LOGIN_FAILED,
data: 'failed to get user' data: error,
}); })
} }
window.localStorage.setItem('jwt', accessToken); const { accessToken } = await authRes.json()
setUser(await userRes.json()); const payload = accessToken.split('.')[1]
} // doLogin const { userId } = JSON.parse(atob(payload))
const userReq = new Request(getUrl(`/users/${userId}`), {
headers: {
Authorization: accessToken,
},
})
const userRes = await fetch(userReq)
if (!userRes.ok) {
return store.dispatch({
type: LOGIN_FAILED,
data: 'failed to get user',
})
}
window.localStorage.setItem('jwt', accessToken)
setUser(await userRes.json())
} // doLogin

View File

@@ -1,9 +1,9 @@
import { import {
SET_USER, SET_USER,
LOGIN_PENDING, LOGIN_PENDING,
LOGIN_FAILED, LOGIN_FAILED,
LOGOUT LOGOUT,
} from '../actions/userAct'; } from '../actions/userAct'
const initState = { const initState = {
setup: false, setup: false,
@@ -11,35 +11,36 @@ const initState = {
email: null, email: null,
admin: null, admin: null,
pending: false, pending: false,
error: null error: null,
}; }
function user(state=initState, action) { function user(state = initState, action) {
switch(action.type) { switch (action.type) {
case SET_USER: { case SET_USER: {
return { return {
...initState, ...initState,
...action.data ...action.data,
}; }
} }
case LOGIN_PENDING: { case LOGIN_PENDING: {
return { return {
...initState, ...initState,
pending: true pending: true,
}; }
} }
case LOGIN_FAILED: { case LOGIN_FAILED: {
return { return {
...state, ...state,
pending: false, pending: false,
error: action.data error: action.data,
}; }
} }
case LOGOUT: { case LOGOUT: {
return initState; return initState
} }
default: return state; default:
return state
} }
} }
export default user; export default user

View File

@@ -1,23 +1,19 @@
import { import { applyMiddleware, combineReducers, createStore } from 'redux'
applyMiddleware,
combineReducers,
createStore
} from 'redux';
import user from './reducers/userRed'; import user from './reducers/userRed'
let middleware; let middleware
if(process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
const logger = require('redux-logger').default; const logger = require('redux-logger').default
if(typeof window !== 'undefined') { if (typeof window !== 'undefined') {
middleware = applyMiddleware(logger); middleware = applyMiddleware(logger)
} }
} }
const reducers = combineReducers({ const reducers = combineReducers({
user user,
}); })
export default middleware export default (middleware
? createStore(reducers, middleware) ? createStore(reducers, middleware)
: createStore(reducers); : createStore(reducers))

View File

@@ -1,34 +1,34 @@
// Application hooks that run for every service // Application hooks that run for every service
const logger = require('./hooks/logger'); const logger = require('./hooks/logger')
module.exports = { module.exports = {
before: { before: {
all: [ logger() ], all: [logger()],
find: [], find: [],
get: [], get: [],
create: [], create: [],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
}, },
after: { after: {
all: [ logger() ], all: [logger()],
find: [], find: [],
get: [], get: [],
create: [], create: [],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
}, },
error: { error: {
all: [ logger() ], all: [logger()],
find: [], find: [],
get: [], get: [],
create: [], create: [],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
} },
}; }

View File

@@ -1,131 +1,126 @@
const favicon = require('serve-favicon'); const favicon = require('serve-favicon')
const fs = require('fs'); const fs = require('fs')
const path = require('path'); const path = require('path')
const compress = require('compression'); const compress = require('compression')
const cors = require('cors'); const cors = require('cors')
const helmet = require('helmet'); const helmet = require('helmet')
const cookieParser = require('cookie-parser')(); const cookieParser = require('cookie-parser')()
const RateLimit = require('express-rate-limit'); const RateLimit = require('express-rate-limit')
const trustIPs = require('./trustIPs'); const trustIPs = require('./trustIPs')
const logger = require('winston'); const logger = require('winston')
const feathers = require('@feathersjs/feathers'); const feathers = require('@feathersjs/feathers')
const express = require('@feathersjs/express'); const express = require('@feathersjs/express')
const configuration = require('@feathersjs/configuration'); const configuration = require('@feathersjs/configuration')
const hostConfig = require('../config/host.json'); const hostConfig = require('../config/host.json')
const middleware = require('./middleware'); const middleware = require('./middleware')
const services = require('./services'); const services = require('./services')
const appHooks = require('./app.hooks'); const appHooks = require('./app.hooks')
const channels = require('./channels'); const channels = require('./channels')
const authentication = require('./authentication'); const authentication = require('./authentication')
const dev = process.env.NODE_ENV !== 'production'; const dev = process.env.NODE_ENV !== 'production'
const basePath = require('../util/basePath'); const basePath = require('../util/basePath')
const stripBase = require('../util/stripBase'); const stripBase = require('../util/stripBase')
const getUrl = require('../util/getUrl'); const getUrl = require('../util/getUrl')
const { parse } = require('url'); const { parse } = require('url')
const nxt = require('next')({ dev, quiet: true }); const nxt = require('next')({ dev, quiet: true })
const nxtHandler = nxt.getRequestHandler(); const nxtHandler = nxt.getRequestHandler()
const app = express(feathers()); const app = express(feathers())
app.run = async port => { app.run = async port => {
const server = app.listen(port); const server = app.listen(port)
await nxt.prepare(); await nxt.prepare()
if(dev) { if (dev) {
server.on('upgrade', (req, socket) => { server.on('upgrade', (req, socket) => {
nxtHandler(req, socket, parse(stripBase(req.url), true)); nxtHandler(req, socket, parse(stripBase(req.url), true))
}); })
} }
return server; return server
}; }
// Load app configuration // Load app configuration
app.configure(configuration()); app.configure(configuration())
// load host config // load host config
Object.keys(hostConfig).forEach(key => ( Object.keys(hostConfig).forEach(key => app.set(key, hostConfig[key]))
app.set(key, hostConfig[key]) app.set('didSetup', false)
));
app.set('didSetup', false);
try { try {
fs.statSync(path.join(__dirname, '..', 'db', '.didSetup')); fs.statSync(path.join(__dirname, '..', 'db', '.didSetup'))
app.set('didSetup', true); app.set('didSetup', true)
} } catch (err) {
catch(err) {
app.use((req, res, next) => { app.use((req, res, next) => {
req.doSetup = !app.get('didSetup'); req.doSetup = !app.get('didSetup')
next(); next()
}); })
} }
const authLimit = new RateLimit({ const authLimit = new RateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes windowMs: 5 * 60 * 1000, // 5 minutes
max: 5, // 5 attempts then block max: 5, // 5 attempts then block
delayAfter: 3, // slow down after 3 fails delayAfter: 3, // slow down after 3 fails
delayMs: 2 * 1000 delayMs: 2 * 1000,
}); })
app.authLimit = authLimit; app.authLimit = authLimit
app.use(getUrl('auth'), authLimit); app.use(getUrl('auth'), authLimit)
app.patch(getUrl('users/*'), authLimit); app.patch(getUrl('users/*'), authLimit)
// Enable CORS, security, compression, favicon and body parsing // Enable CORS, security, compression, favicon and body parsing
trustIPs(app); trustIPs(app)
app.use(cors()); app.use(cors())
app.use(helmet({ app.use(
hidePoweredBy: { setTo: 'hamsters' } helmet({
})); hidePoweredBy: { setTo: 'hamsters' },
})
)
if(!dev) app.use(compress()); if (!dev) app.use(compress())
app.use(express.json()); // use { limit } option to increase max post size app.use(express.json()) // use { limit } option to increase max post size
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }))
app.use(getUrl('/'), favicon('favicon.ico')); app.use(getUrl('/'), favicon('favicon.ico'))
app.configure(express.rest()); // Set up Plugins and providers app.configure(express.rest()) // Set up Plugins and providers
app.configure(middleware); // middleware/index.js app.configure(middleware) // middleware/index.js
app.configure(authentication); // Set up authentication app.configure(authentication) // Set up authentication
app.configure(services); // Set up our services (see `services/index.js`) app.configure(services) // Set up our services (see `services/index.js`)
app.configure(channels); // Set up event channels (see channels.js) app.configure(channels) // Set up event channels (see channels.js)
const checkJWT = async (req, res, next) => { const checkJWT = async (req, res, next) => {
const result = await req.app.authenticate('jwt', {})(req); const result = await req.app.authenticate('jwt', {})(req)
if(result.success) { if (result.success) {
req.jwt = req.cookies.jwt; req.jwt = req.cookies.jwt
delete result.data.user.password; delete result.data.user.password
req.user = result.data.user; req.user = result.data.user
} }
next(); next()
}; }
nxt.setAssetPrefix(basePath); // setup next.js routes nxt.setAssetPrefix(basePath) // setup next.js routes
;['/', '/logout', '/new', '/settings'].forEach(route => {
app.get(getUrl(route), cookieParser, checkJWT, (req, res) => {
const { query } = parse(req.url, true)
nxt.render(req, res, route, query)
})
})
;['/k', '/edit'].forEach(route => {
app.get(getUrl(route + '/:id'), cookieParser, checkJWT, (req, res) => {
nxt.render(req, res, route, { id: req.params.id })
})
})
[ '/', '/logout', '/new', '/settings' ] const notFound = express.notFound()
.forEach(route => {
app.get(getUrl(route), cookieParser, checkJWT, (req, res) => {
const { query } = parse(req.url, true);
nxt.render(req, res, route, query);
});
});
[ '/k', '/edit' ]
.forEach(route => {
app.get(getUrl(route + '/:id'), cookieParser, checkJWT, (req, res) => {
nxt.render(req, res, route, { id: req.params.id });
});
});
const notFound = express.notFound();
app.use((req, res, next) => { app.use((req, res, next) => {
let accept = req.get('accept'); let accept = req.get('accept')
if(accept && accept.toLowerCase() === 'application/json') if (accept && accept.toLowerCase() === 'application/json')
return notFound(req, res, next); return notFound(req, res, next)
if(req.url.substr(0, basePath.length) !== basePath) if (req.url.substr(0, basePath.length) !== basePath)
return nxt.render404(req, res); return nxt.render404(req, res)
nxtHandler(req, res, parse(stripBase(req.url), true)); nxtHandler(req, res, parse(stripBase(req.url), true))
}); })
app.use(express.errorHandler({ logger })); app.use(express.errorHandler({ logger }))
app.hooks(appHooks); app.hooks(appHooks)
module.exports = app; module.exports = app

View File

@@ -1,26 +1,28 @@
const authentication = require('@feathersjs/authentication'); const authentication = require('@feathersjs/authentication')
const jwt = require('@feathersjs/authentication-jwt'); const jwt = require('@feathersjs/authentication-jwt')
const local = require('@feathersjs/authentication-local'); const local = require('@feathersjs/authentication-local')
const getUrl = require('../util/getUrl'); const getUrl = require('../util/getUrl')
module.exports = function (app) { module.exports = function(app) {
const config = app.get('authentication'); const config = app.get('authentication')
config.path = getUrl(config.path); config.path = getUrl(config.path)
config.service = getUrl('users'); config.service = getUrl('users')
// Set up authentication with the secret // Set up authentication with the secret
app.configure(authentication( app.configure(
Object.assign({}, config, { authentication(
cookie: { Object.assign({}, config, {
enabled: true, cookie: {
httpOnly: false, enabled: true,
secure: false, httpOnly: false,
name: 'jwt', secure: false,
} name: 'jwt',
}) },
)); })
app.configure(jwt()); )
app.configure(local()); )
app.configure(jwt())
app.configure(local())
// The `authentication` service is used to create a JWT. // The `authentication` service is used to create a JWT.
// The before `create` hook registers strategies that can be used // The before `create` hook registers strategies that can be used
@@ -30,13 +32,11 @@ module.exports = function (app) {
create: [ create: [
authentication.hooks.authenticate(config.strategies), authentication.hooks.authenticate(config.strategies),
ctx => { ctx => {
ctx.app.authLimit.resetKey(ctx.params.ip); ctx.app.authLimit.resetKey(ctx.params.ip)
return ctx; return ctx
} },
], ],
remove: [ remove: [authentication.hooks.authenticate('jwt')],
authentication.hooks.authenticate('jwt') },
] })
} }
});
};

View File

@@ -1,56 +1,59 @@
module.exports = function(app) { module.exports = function(app) {
if(typeof app.channel !== 'function') { if (typeof app.channel !== 'function') {
// If no real-time functionality has been configured just return // If no real-time functionality has been configured just return
return; return
} }
app.on('connection', connection => { app.on('connection', connection => {
// On a new real-time connection, add it to the anonymous channel // On a new real-time connection, add it to the anonymous channel
app.channel('anonymous').join(connection); app.channel('anonymous').join(connection)
}); })
app.on('login', (authResult, { connection }) => { app.on('login', (authResult, { connection }) => {
// connection can be undefined if there is no // connection can be undefined if there is no
// real-time connection, e.g. when logging in via REST // real-time connection, e.g. when logging in via REST
if(connection) { if (connection) {
// Obtain the logged in user from the connection // Obtain the logged in user from the connection
// const user = connection.user; // const user = connection.user;
// The connection is no longer anonymous, remove it // The connection is no longer anonymous, remove it
app.channel('anonymous').leave(connection); app.channel('anonymous').leave(connection)
// Add it to the authenticated user channel // Add it to the authenticated user channel
app.channel('authenticated').join(connection); app.channel('authenticated').join(connection)
// Channels can be named anything and joined on any condition
// Channels can be named anything and joined on any condition
// E.g. to send real-time events only to admins use // E.g. to send real-time events only to admins use
// if(user.isAdmin) { app.channel('admins').join(connection); } // if(user.isAdmin) { app.channel('admins').join(connection); }
// If the user has joined e.g. chat rooms // If the user has joined e.g. chat rooms
// if(Array.isArray(user.rooms)) user.rooms.forEach(room => app.channel(`rooms/${room.id}`).join(channel)); // if(Array.isArray(user.rooms)) user.rooms.forEach(room => app.channel(`rooms/${room.id}`).join(channel));
// Easily organize users by email and userid for things like messaging // Easily organize users by email and userid for things like messaging
// app.channel(`emails/${user.email}`).join(channel); // app.channel(`emails/${user.email}`).join(channel);
// app.channel(`userIds/$(user.id}`).join(channel); // app.channel(`userIds/$(user.id}`).join(channel);
} }
}); })
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
app.publish((data, hook) => { app.publish((data, hook) => {
// Here you can add event publishers to channels set up in `channels.js` // Here you can add event publishers to channels set up in `channels.js`
// To publish only for a specific event use `app.publish(eventname, () => {})` // To publish only for a specific event use `app.publish(eventname, () => {})`
console.log('Publishing all events to all authenticated users. See `channels.js` and https://docs.feathersjs.com/api/channels.html for more information.'); // eslint-disable-line // eslint-disable-next-line
console.log(
'Publishing all events to all authenticated users. See `channels.js` and https://docs.feathersjs.com/api/channels.html for more information.'
)
// e.g. to publish all service events to all authenticated users use // e.g. to publish all service events to all authenticated users use
return app.channel('authenticated'); return app.channel('authenticated')
}); })
// Here you can also add service specific event publishers // Here you can also add service specific event publishers
// e..g the publish the `users` service `created` event to the `admins` channel // e..g the publish the `users` service `created` event to the `admins` channel
// app.service('users').publish('created', () => app.channel('admins')); // app.service('users').publish('created', () => app.channel('admins'));
// With the userid and email organization from above you can easily select involved users // With the userid and email organization from above you can easily select involved users
// app.service('messages').publish(() => { // app.service('messages').publish(() => {
// return [ // return [
@@ -58,4 +61,4 @@ module.exports = function(app) {
// app.channel(`emails/${data.recipientEmail}`) // app.channel(`emails/${data.recipientEmail}`)
// ]; // ];
// }); // });
}; }

View File

@@ -1,23 +1,25 @@
// A hook that logs service method before, after and error // A hook that logs service method before, after and error
// See https://github.com/winstonjs/winston for documentation // See https://github.com/winstonjs/winston for documentation
// about the logger. // about the logger.
const logger = require('winston'); const logger = require('winston')
// To see more detailed messages, uncomment the following line // To see more detailed messages, uncomment the following line
// logger.level = 'debug'; // logger.level = 'debug';
module.exports = function () { module.exports = function() {
return context => { return context => {
// This debugs the service call and a stringified version of the hook context // This debugs the service call and a stringified version of the hook context
// You can customize the mssage (and logger) to your needs // You can customize the mssage (and logger) to your needs
logger.debug(`${context.type} app.service('${context.path}').${context.method}()`); logger.debug(
`${context.type} app.service('${context.path}').${context.method}()`
if(typeof context.toJSON === 'function') { )
logger.debug('Hook Context', JSON.stringify(context, null, ' '));
if (typeof context.toJSON === 'function') {
logger.debug('Hook Context', JSON.stringify(context, null, ' '))
} }
if (context.error) { if (context.error) {
logger.error(context.error); logger.error(context.error)
} }
}; }
}; }

View File

@@ -1,6 +1,6 @@
const logger = require('winston'); const logger = require('winston')
const app = require('./app'); const app = require('./app')
const port = app.get('port'); const port = app.get('port')
app.run(port).then(() => { app.run(port).then(() => {
logger.info('MYKB listening at http://%s:%d', app.get('host'), port) logger.info('MYKB listening at http://%s:%d', app.get('host'), port)
@@ -8,4 +8,4 @@ app.run(port).then(() => {
process.on('unhandledRejection', (reason, p) => process.on('unhandledRejection', (reason, p) =>
logger.error('Unhandled Rejection at: Promise ', p, reason) logger.error('Unhandled Rejection at: Promise ', p, reason)
); )

View File

@@ -1,11 +1,11 @@
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
module.exports = function (app) { module.exports = function(app) {
// Add your custom middleware here. Remember, that // Add your custom middleware here. Remember, that
// in Express the order matters // in Express the order matters
// add req.ip to feathers // add req.ip to feathers
app.use((req, res, next) => { app.use((req, res, next) => {
req.feathers.ip = req.ip; req.feathers.ip = req.ip
next(); next()
}); })
}; }

View File

@@ -1,14 +1,14 @@
const NeDB = require('nedb'); const NeDB = require('nedb')
const path = require('path'); const path = require('path')
module.exports = function (app) { module.exports = function(app) {
const dbPath = app.get('nedb'); const dbPath = app.get('nedb')
const Model = new NeDB({ const Model = new NeDB({
filename: path.join(dbPath, 'users.db'), filename: path.join(dbPath, 'users.db'),
autoload: true autoload: true,
}); })
Model.ensureIndex({ fieldName: 'email', unique: true }); Model.ensureIndex({ fieldName: 'email', unique: true })
return Model; return Model
}; }

View File

@@ -1,225 +1,233 @@
const errors = require('@feathersjs/errors'); const errors = require('@feathersjs/errors')
const gitP = require('simple-git/promise'); const gitP = require('simple-git/promise')
const fs = require('fs-extra'); const fs = require('fs-extra')
const path = require('path'); const path = require('path')
const loadDocs = require('./loadDocs'); const loadDocs = require('./loadDocs')
const comparableFields = [ 'name', 'dir' ]; const comparableFields = ['name', 'dir']
let git; let git
class Service { class Service {
constructor (options) { constructor(options) {
this.options = options || {}; this.options = options || {}
this.docsDir = this.options.docsDir; this.docsDir = this.options.docsDir
this.$limit = this.options.paginate.default; this.$limit = this.options.paginate.default
this.useGit = this.options.useGit; this.useGit = this.options.useGit
this.maxDocSize = 1024 * 100; // 100 kb (don't try sending if bigger) this.maxDocSize = 1024 * 100 // 100 kb (don't try reading if bigger)
this.docs = {}; this.docs = {}
this.updTimeouts = {}; this.updTimeouts = {}
loadDocs.bind(this)(); loadDocs.bind(this)()
if(this.useGit) { if (this.useGit) {
git = gitP(this.docsDir); git = gitP(this.docsDir)
fs.stat(path.join(this.docsDir, '.git'), async err => { fs.stat(path.join(this.docsDir, '.git'), async err => {
if(err && err.code === 'ENOENT') { if (err && err.code === 'ENOENT') {
git.init().then(() => { git.init().then(() => {
if(this.numInitDocs === 0) return; if (this.numInitDocs === 0) return
git.add('./*').then(() => git.commit('initial commit')); git.add('./*').then(() => git.commit('initial commit'))
}); })
} }
}); })
} }
} }
async getMd (id) { async getMd(id) {
const doc = this.docs[id]; const doc = this.docs[id]
if(doc.md) return doc.md; if (doc.md) return doc.md
const docPath = path.join(this.docsDir, doc.dir, doc.name); const docPath = path.join(this.docsDir, doc.dir, doc.name)
const { size } = await fs.stat(docPath); const { size } = await fs.stat(docPath)
if(size > this.maxDocSize) return 'Document is too big to display...'; if (size > this.maxDocSize) return 'Document is too big to display...'
const buff = await fs.readFile(docPath); const buff = await fs.readFile(docPath)
return buff.toString(); return buff.toString()
} }
async isMatch (id, $search) { async isMatch(id, $search) {
const doc = this.docs[id]; const doc = this.docs[id]
const name = doc.name.toLowerCase(); const name = doc.name.toLowerCase()
if(name.indexOf($search) > -1) return true; if (name.indexOf($search) > -1) return true
const dir = doc.dir.toLowerCase(); const dir = doc.dir.toLowerCase()
if(dir.indexOf($search) > -1) return true;
const relPath = dir + (dir.length > 0 ? '/' : '') + name;
if(relPath.toLowerCase().indexOf($search) > -1) return true;
let md = await this.getMd(id);
md = md.toLowerCase();
if(md.indexOf($search) > -1) return true; if (dir.indexOf($search) > -1) return true
return false; const relPath = dir + (dir.length > 0 ? '/' : '') + name
if (relPath.toLowerCase().indexOf($search) > -1) return true
let md = await this.getMd(id)
md = md.toLowerCase()
if (md.indexOf($search) > -1) return true
return false
} }
async checkRmDir (dir) { async checkRmDir(dir) {
if(dir === '') return; if (dir === '') return
const parts = dir.split('/'); const parts = dir.split('/')
const n = parts.length; const n = parts.length
for(let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const dir = parts.filter((p, pIdx) => pIdx < (n - i)).join('/'); const dir = parts.filter((p, pIdx) => pIdx < n - i).join('/')
const docsInDir = await this.find({ query: { dir } }); const docsInDir = await this.find({ query: { dir } })
if(docsInDir.total === 0) { if (docsInDir.total === 0) {
try { try {
await fs.rmdir(path.join(this.docsDir, dir)); await fs.rmdir(path.join(this.docsDir, dir))
} catch(err) { return; } } catch (err) {
} else return; return
}
} else return
} }
} }
async find (params) { async find(params) {
const { query } = params; const { query } = params
let { $limit, $skip, $search, $select, $sort } = query; let { $limit, $skip, $search, $select, $sort } = query
if($skip < 0) $skip = 0; if ($skip < 0) $skip = 0
$limit = $limit || this.$limit; $limit = $limit || this.$limit
if($search) { if ($search) {
$search = $search.toLowerCase().trim(); $search = $search.toLowerCase().trim()
} }
let ids = Object.keys(this.docs); let ids = Object.keys(this.docs)
const data = []; const data = []
const toComp = comparableFields.filter(f => typeof query[f] !== 'undefined'); const toComp = comparableFields.filter(f => typeof query[f] !== 'undefined')
if(toComp.length > 0) { if (toComp.length > 0) {
ids = ids.filter(id => { ids = ids.filter(id => {
const doc = this.docs[id]; const doc = this.docs[id]
return !toComp.some(f => doc[f] !== query[f]); return !toComp.some(f => doc[f] !== query[f])
}); })
} }
if($search) { if ($search) {
ids = await Promise.all( ids = await Promise.all(
ids.map(async id => await this.isMatch(id, $search) ? id : null) ids.map(async id => ((await this.isMatch(id, $search)) ? id : null))
); )
ids = ids.filter(Boolean); ids = ids.filter(Boolean)
} }
if(ids.length === 0) return { total: 0, data }; if (ids.length === 0) return { total: 0, data }
if($sort) { if ($sort) {
const sortKey = Object.keys($sort).pop(); const sortKey = Object.keys($sort).pop()
const ascDesc = $sort[sortKey]; const ascDesc = $sort[sortKey]
if(this.docs[ids[0]][sortKey] || sortKey === 'dirName') { // make sure valid key
if (this.docs[ids[0]][sortKey] || sortKey === 'dirName') {
ids.sort((a, b) => { ids.sort((a, b) => {
let val1, val2, parseVals; a = this.docs[a]
const docA = this.docs[a], b = this.docs[b]
docB = this.docs[b]; let parseVal
if(sortKey === 'dirName') { if (sortKey === 'dirName') {
parseVals = doc => path.join(doc.dir, doc.name); parseVal = doc => path.join(doc.dir, doc.name)
} else {
parseVal = doc => doc[sortKey]
} }
else { a = parseVal(a)
parseVals = doc => doc[sortKey]; b = parseVal(b)
} let c = 0
[val1, val2] = [docA, docB].map(parseVals); if (a < b) c = -1
let c = 0; else if (a > b) c = 1
if(val1 < val2) c = -1; return c * ascDesc
else if(val1 > val2) c = 1; })
return c * ascDesc;
});
} }
} }
for(let i = $skip || 0; i < ids.length && data.length < $limit; i++) { for (let i = $skip || 0; i < ids.length && data.length < $limit; i++) {
const id = ids[i]; const id = ids[i]
let doc = $select && !$select.md ? this.docs[id] : this.get(id); let doc = $select && !$select.md ? this.docs[id] : this.get(id)
if($select) { if ($select) {
let _doc = {}; let _doc = {}
$select.forEach(k => _doc[k] = doc[k]); $select.forEach(k => (_doc[k] = doc[k]))
doc = _doc; doc = _doc
} }
data.push(doc); data.push(doc)
} }
return { total: ids.length, data }; return { total: ids.length, data }
} }
async get (id, params) { // eslint-disable-line no-unused-vars // eslint-disable-next-line no-unused-vars
const doc = this.docs[id]; async get(id, params) {
if(!doc) throw new errors.NotFound(`No record found for id '${id}'`); const doc = this.docs[id]
if(!doc.md) doc.md = await this.getMd(id); if (!doc) throw new errors.NotFound(`No record found for id '${id}'`)
return this.docs[id]; if (!doc.md) doc.md = await this.getMd(id)
return this.docs[id]
} }
async create (data, params) { // eslint-disable-line no-unused-vars // eslint-disable-next-line no-unused-vars
if(Array.isArray(data)) { async create(data, params) {
return await Promise.all(data.map(current => this.create(current))); if (Array.isArray(data)) {
return await Promise.all(data.map(current => this.create(current)))
} }
const { name, dir, md } = data; const { name, dir, md } = data
try { try {
const rPath = path.join(dir, name); const rPath = path.join(dir, name)
const docPath = path.join(this.docsDir, rPath); const docPath = path.join(this.docsDir, rPath)
await fs.outputFile(docPath, md); await fs.outputFile(docPath, md)
if(this.useGit) { if (this.useGit) {
git.add(rPath).then(() => git.commit(`added doc ${rPath}`)); git.add(rPath).then(() => git.commit(`added doc ${rPath}`))
} }
const ts = new Date().toJSON(); const ts = new Date().toJSON()
return this.setDoc(path.join(dir, name), ts, md, ts); return this.setDoc(path.join(dir, name), ts, md, ts)
} } catch (err) {
catch (err) { throw new errors.GeneralError('could not create doc')
throw new errors.GeneralError('could not create doc');
} }
} }
async update (id, data, params) { // eslint-disable-line no-unused-vars // eslint-disable-next-line no-unused-vars
throw new errors.MethodNotAllowed('can not update on docs service'); async update(id, data, params) {
throw new errors.MethodNotAllowed('can not update on docs service')
} }
async patch (id, data, params) { // eslint-disable-line no-unused-vars // eslint-disable-next-line no-unused-vars
const doc = this.docs[id]; async patch(id, data, params) {
if(!doc) throw new errors.NotFound(`No record found for id '${id}'`); const doc = this.docs[id]
let { name, dir, md } = data; if (!doc) throw new errors.NotFound(`No record found for id '${id}'`)
let diffDir = Boolean(doc.dir !== dir); let { name, dir, md } = data
if(!name) name = doc.name; let diffDir = Boolean(doc.dir !== dir)
if(typeof dir !== 'string') { if (!name) name = doc.name
dir = doc.dir; if (typeof dir !== 'string') {
diffDir = false; dir = doc.dir
diffDir = false
} }
const rPath = path.join(dir, name); const rPath = path.join(dir, name)
const docPath = path.join(this.docsDir, rPath); const docPath = path.join(this.docsDir, rPath)
if(name !== doc.name || diffDir) { if (name !== doc.name || diffDir) {
const oldRPath = path.join(doc.dir, doc.name); const oldRPath = path.join(doc.dir, doc.name)
const oldPath = path.join(this.docsDir, oldRPath); const oldPath = path.join(this.docsDir, oldRPath)
await fs.ensureDir(path.join(this.docsDir, dir)); await fs.ensureDir(path.join(this.docsDir, dir))
await fs.move(oldPath, docPath); await fs.move(oldPath, docPath)
if(this.useGit) { if (this.useGit) {
git.rm(oldRPath) git
.rm(oldRPath)
.then(() => git.add(rPath)) .then(() => git.add(rPath))
.then(() => git.commit(`renamed doc ${oldRPath} ${rPath}`)); .then(() => git.commit(`renamed doc ${oldRPath} ${rPath}`))
} }
id = this.getId(path.join(dir, name)); id = this.getId(path.join(dir, name))
this.docs[id] = Object.assign({}, doc, { id, name, dir }); this.docs[id] = Object.assign({}, doc, { id, name, dir })
delete this.docs[doc.id]; delete this.docs[doc.id]
if(diffDir) this.checkRmDir(doc.dir); if (diffDir) this.checkRmDir(doc.dir)
} }
if(md) { if (md) {
await fs.writeFile(docPath, md); await fs.writeFile(docPath, md)
if(this.useGit) { if (this.useGit) {
git.add(rPath).then(() => git.commit(`updated doc ${rPath}`)); git.add(rPath).then(() => git.commit(`updated doc ${rPath}`))
} }
this.docs[id].md = md; this.docs[id].md = md
} }
return this.docs[id]; return this.docs[id]
} }
async remove (id, params) { // eslint-disable-line no-unused-vars // eslint-disable-next-line no-unused-vars
const doc = this.docs[id]; async remove(id, params) {
if(!id) throw new errors.NotFound(`No record found for id '${id}'`); const doc = this.docs[id]
const rPath = path.join(doc.dir, doc.name); if (!id) throw new errors.NotFound(`No record found for id '${id}'`)
const docPath = path.join(this.docsDir, rPath); const rPath = path.join(doc.dir, doc.name)
await fs.unlink(docPath); const docPath = path.join(this.docsDir, rPath)
await fs.unlink(docPath)
if(this.useGit) {
git.rm(rPath).then(() => git.commit(`removed doc ${rPath}`)); if (this.useGit) {
git.rm(rPath).then(() => git.commit(`removed doc ${rPath}`))
} }
delete this.docs[id]; delete this.docs[id]
this.checkRmDir(doc.dir); this.checkRmDir(doc.dir)
return doc; return doc
} }
} }
module.exports = function (options) { module.exports = function(options) {
return new Service(options); return new Service(options)
}; }
module.exports.Service = Service; module.exports.Service = Service

View File

@@ -1,72 +1,81 @@
const { authenticate } = require('@feathersjs/authentication').hooks; const { authenticate } = require('@feathersjs/authentication').hooks
const { checkDir, checkName } = require('../../../util/checkDirParts'); const { checkDir, checkName } = require('../../../util/checkDirParts')
const { disable, invalid, adminOnly } = require('../hooksUtil'); const { disable, invalid, adminOnly } = require('../hooksUtil')
const getUrl = require('../../../util/getUrl'); const getUrl = require('../../../util/getUrl')
const nameIsValid = name => { const nameIsValid = name => {
name = checkName(name); name = checkName(name)
if(!name) return invalid('name'); if (!name) return invalid('name')
if(name.substr(name.length - 3).toLowerCase() !== '.md') { if (name.substr(name.length - 3).toLowerCase() !== '.md') {
name += '.md'; name += '.md'
} }
return name; return name
}; }
const dirIsValid = dir => { const dirIsValid = dir => {
dir = checkDir(dir); dir = checkDir(dir)
if(!dir && dir !== 0) return invalid('dir'); if (!dir && dir !== 0) return invalid('dir')
else if(dir === 0) return ''; else if (dir === 0) return ''
return dir; return dir
}; }
const mdIsValid = md => { const mdIsValid = md => {
if(typeof md !== 'string' || md.trim().length === 0) { if (typeof md !== 'string' || md.trim().length === 0) {
return invalid('md'); return invalid('md')
} }
return md; return md
}; }
const pathTaken = async (name, dir, app) => { const pathTaken = async (name, dir, app) => {
const matches = await app.service(getUrl('docs')).find({ query: { name, dir } }); const matches = await app
if(matches.total > 0) { .service(getUrl('docs'))
return invalid(null, 'filename is taken'); .find({ query: { name, dir } })
if (matches.total > 0) {
return invalid(null, 'filename is taken')
} }
}; }
module.exports = { module.exports = {
before: { before: {
all: [ authenticate('jwt') ], all: [authenticate('jwt')],
find: [], find: [],
get: [], get: [],
create: [ async ctx => { create: [
const { app, data } = ctx; async ctx => {
let { name, dir, md } = data; const { app, data } = ctx
const k = {}; let { name, dir, md } = data
k.name = nameIsValid(name); const k = {}
k.dir = dirIsValid(dir); k.name = nameIsValid(name)
k.md = mdIsValid(md); k.dir = dirIsValid(dir)
await pathTaken(k.name, k.dir, app); k.md = mdIsValid(md)
ctx.data = k; await pathTaken(k.name, k.dir, app)
return ctx; ctx.data = k
}], return ctx
update: [ disable ], },
patch: [ async ctx => { ],
const { data, app } = ctx; update: [disable],
const { name, dir, md } = data; patch: [
const k = {}; async ctx => {
if(name) k.name = nameIsValid(name); const { data, app } = ctx
if(typeof dir === 'string') k.dir = dirIsValid(dir); // allow empty string const { name, dir, md } = data
if(name || typeof dir === 'string') { const k = {}
let checkName, checkDir; if (name) k.name = nameIsValid(name)
if(!name || typeof dir !== 'string') { if (typeof dir === 'string') k.dir = dirIsValid(dir) // allow empty string
const doc = await app.service(getUrl('docs')).get(ctx.id); if (name || typeof dir === 'string') {
if(!name) checkName = doc.name; let checkName, checkDir
if(typeof dir !== 'string') checkDir = doc.dir; if (!name || typeof dir !== 'string') {
const doc = await app.service(getUrl('docs')).get(ctx.id)
if (!name) checkName = doc.name
if (typeof dir !== 'string') checkDir = doc.dir
}
await pathTaken(
k.name || checkName,
typeof k.dir === 'string' ? k.dir : checkDir,
app
)
} }
await pathTaken(k.name || checkName, if (md) k.md = mdIsValid(md)
typeof k.dir === 'string' ? k.dir : checkDir, app); if (Object.keys(k).length === 0) invalid(null, 'nothing to update')
} ctx.data = k
if(md) k.md = mdIsValid(md); return ctx
if(Object.keys(k).length === 0) invalid(null, 'nothing to update'); },
ctx.data = k; ],
return ctx; remove: [adminOnly],
}],
remove: [ adminOnly ]
}, },
after: { after: {
@@ -76,7 +85,7 @@ module.exports = {
create: [], create: [],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
}, },
error: { error: {
@@ -86,6 +95,6 @@ module.exports = {
create: [], create: [],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
} },
}; }

View File

@@ -1,29 +1,28 @@
// Initializes the `docs` service on path `/docs` // Initializes the `docs` service on path `/docs`
const createService = require('./docs.class.js'); const createService = require('./docs.class.js')
const hooks = require('./docs.hooks'); const hooks = require('./docs.hooks')
const getUrl = require('../../../util/getUrl'); const getUrl = require('../../../util/getUrl')
module.exports = function (app) { module.exports = function(app) {
const paginate = app.get('paginate')
const paginate = app.get('paginate'); const docsDir = app.get('docsDir')
const docsDir = app.get('docsDir'); const cacheSize = app.get('cacheSize')
const cacheSize = app.get('cacheSize'); const useGit = app.get('useGit')
const useGit = app.get('useGit');
const options = { const options = {
name: 'docs', name: 'docs',
paginate, paginate,
docsDir, docsDir,
cacheSize, cacheSize,
useGit useGit,
}; }
const url = getUrl('docs'); const url = getUrl('docs')
// Initialize our service with any options it requires // Initialize our service with any options it requires
app.use(url, createService(options)); app.use(url, createService(options))
// Get our initialized service so that we can register hooks and filters // Get our initialized service so that we can register hooks and filters
const service = app.service(url); const service = app.service(url)
service.hooks(hooks); service.hooks(hooks)
}; }

View File

@@ -1,64 +1,78 @@
const chokidar = require('chokidar'); const chokidar = require('chokidar')
const fs = require('fs-extra'); const fs = require('fs-extra')
const path = require('path'); const path = require('path')
const glob = require('glob'); const glob = require('glob')
const crypto = require('crypto'); const crypto = require('crypto')
async function loadDocs() { async function loadDocs() {
const { docsDir, cacheSize } = this.options; const { docsDir, cacheSize } = this.options
this.numInitDocs = glob.sync(path.join(docsDir, '**/*.md')).length; this.numInitDocs = glob.sync(path.join(docsDir, '**/*.md')).length
this.cached = 0; this.cached = 0
this.loaded = false; this.loaded = false
const getId = relPath =>
crypto
.createHash('sha1')
.update(relPath)
.digest()
.toString('base64')
.substr(0, 16)
.split('/')
.join('_')
this.getId = getId
const getId = relPath => (crypto.createHash('sha1')
.update(relPath).digest().toString('base64')
.substr(0, 16).split('/').join('_')
);
this.getId = getId;
const setDoc = (relPath, created, md, updated) => { const setDoc = (relPath, created, md, updated) => {
const id = getId(relPath); const id = getId(relPath)
let dir = relPath.split('/'); let dir = relPath.split('/')
const name = dir.pop(); const name = dir.pop()
dir = dir.join('/'); dir = dir.join('/')
const doc = { const doc = {
id, name, dir, created, md, updated id,
}; name,
this.docs[id] = doc; dir,
return doc; created,
}; md,
this.setDoc = setDoc; updated,
}
this.docs[id] = doc
return doc
}
this.setDoc = setDoc
const watcher = chokidar.watch(path.join(docsDir, '/**/*.md'), { persistent: true }); const watcher = chokidar.watch(path.join(docsDir, '/**/*.md'), {
persistent: true,
})
const relPath = path => { const relPath = path => {
path = path.split(docsDir).pop(); path = path.split(docsDir).pop()
if(path.substr(0, 1) === '/') path = path.substr(1); if (path.substr(0, 1) === '/') path = path.substr(1)
return path; return path
}; }
const handleDoc = async (path, stats) => { const handleDoc = async (path, stats) => {
const rPath = relPath(path); const rPath = relPath(path)
if(!stats) stats = await fs.stat(path); if (!stats) stats = await fs.stat(path)
else { // stats was set so it's a change event else {
const changedDoc = this.docs[getId(rPath)]; // stats was set so it's a change event
if(changedDoc && changedDoc.md) this.cached -= changedDoc.md.length; const changedDoc = this.docs[getId(rPath)]
if (changedDoc && changedDoc.md) this.cached -= changedDoc.md.length
} }
const { birthtime, size, mtime } = stats; const { birthtime, size, mtime } = stats
let md = null; let md = null
if(size < this.maxDocSize && (this.cached + size) < cacheSize) { if (size < this.maxDocSize && this.cached + size < cacheSize) {
md = await fs.readFile(path); md = await fs.readFile(path)
md = md.toString(); md = md.toString()
this.cached += md.length; this.cached += md.length
} }
setDoc(rPath, birthtime, md, mtime); setDoc(rPath, birthtime, md, mtime)
}; }
watcher.on('add', handleDoc); // file added watcher.on('add', handleDoc) // file added
watcher.on('change', handleDoc); // file changed (rename triggers unlink then add) watcher.on('change', handleDoc) // file changed (rename triggers unlink then add)
watcher.on('unlink', path => { // file removed watcher.on('unlink', path => {
const id = getId(relPath(path)); // file removed
if(this.docs[id] && this.docs[id].md) { const id = getId(relPath(path))
this.cached -= this.docs[id].md.length; if (this.docs[id] && this.docs[id].md) {
this.cached -= this.docs[id].md.length
} }
delete this.docs[id]; delete this.docs[id]
}); })
} }
module.exports = loadDocs; module.exports = loadDocs

View File

@@ -1,23 +1,23 @@
const { BadRequest, Forbidden } = require('@feathersjs/errors'); const { BadRequest, Forbidden } = require('@feathersjs/errors')
const isAdmin = ctx => { const isAdmin = ctx => {
const { params } = ctx; const { params } = ctx
return Boolean(params.user && params.user.admin); return Boolean(params.user && params.user.admin)
}; }
module.exports = { module.exports = {
disable: () => { disable: () => {
throw new BadRequest('method not allowed'); throw new BadRequest('method not allowed')
}, },
invalid: (field, msg) => { invalid: (field, msg) => {
throw new BadRequest(msg || `invalid ${field} value`); throw new BadRequest(msg || `invalid ${field} value`)
}, },
isAdmin: isAdmin, isAdmin: isAdmin,
adminOnly: ctx => { adminOnly: ctx => {
if(!isAdmin(ctx)) throw new Forbidden('invalid permission'); if (!isAdmin(ctx)) throw new Forbidden('invalid permission')
return ctx; return ctx
} },
}; }

View File

@@ -1,6 +1,6 @@
const users = require('./users/users.service.js'); const users = require('./users/users.service.js')
const docs = require('./docs/docs.service.js'); const docs = require('./docs/docs.service.js')
module.exports = function (app) { module.exports = function(app) {
app.configure(users); app.configure(users)
app.configure(docs); app.configure(docs)
}; }

View File

@@ -1,71 +1,76 @@
const { authenticate } = require('@feathersjs/authentication').hooks; const { authenticate } = require('@feathersjs/authentication').hooks
const { Forbidden } = require('@feathersjs/errors'); const { Forbidden } = require('@feathersjs/errors')
const fs = require('fs'); const fs = require('fs')
const path = require('path'); const path = require('path')
const { disable, invalid, isAdmin, adminOnly } = require('../hooksUtil'); const { disable, invalid, isAdmin, adminOnly } = require('../hooksUtil')
const { const {
hashPassword, protect hashPassword,
} = require('@feathersjs/authentication-local').hooks; protect,
} = require('@feathersjs/authentication-local').hooks
const invalidStr = str => Boolean( const invalidStr = str =>
typeof str !== 'string' || str.trim().length === 0); Boolean(typeof str !== 'string' || str.trim().length === 0)
module.exports = { module.exports = {
before: { before: {
all: [], all: [],
find: [ authenticate('jwt') ], find: [authenticate('jwt')],
get: [ authenticate('jwt') ], get: [authenticate('jwt')],
create: [ create: [
async ctx => { async ctx => {
const { data, app } = ctx; const { data, app } = ctx
if(app.get('didSetup') && !isAdmin(ctx)) { if (app.get('didSetup') && !isAdmin(ctx)) {
throw new Forbidden('invalid permission'); throw new Forbidden('invalid permission')
} }
const { email, password, admin } = data; const { email, password, admin } = data
if(invalidStr(email)) invalid('email'); if (invalidStr(email)) invalid('email')
if(invalidStr(password)) invalid('password'); if (invalidStr(password)) invalid('password')
if(typeof admin !== 'boolean') invalid('admin'); if (typeof admin !== 'boolean') invalid('admin')
ctx.data = { email, password, admin }; ctx.data = { email, password, admin }
return ctx; return ctx
}, },
hashPassword() hashPassword(),
], ],
update: [ disable ], update: [disable],
patch: [ authenticate('local'), patch: [
authenticate('local'),
async ctx => { async ctx => {
const { newPassword } = ctx.data; const { newPassword } = ctx.data
if(invalidStr(newPassword)) invalid('newPassword'); if (invalidStr(newPassword)) invalid('newPassword')
ctx.data = { password: newPassword }; ctx.data = { password: newPassword }
await hashPassword()(ctx); await hashPassword()(ctx)
ctx.app.authLimit.resetKey(ctx.params.ip); ctx.app.authLimit.resetKey(ctx.params.ip)
return ctx; return ctx
} },
], ],
remove: [ authenticate('jwt'), adminOnly ] remove: [authenticate('jwt'), adminOnly],
}, },
after: { after: {
all: [ all: [
// Make sure the password field is never sent to the client // Make sure the password field is never sent to the client
// Always must be the last hook // Always must be the last hook
protect('password') protect('password'),
], ],
find: [], find: [],
get: [], get: [],
create: [ create: [
async ctx => { async ctx => {
const { app } = ctx; const { app } = ctx
if(app.get('didSetup')) return ctx; if (app.get('didSetup')) return ctx
app.set('didSetup', true); app.set('didSetup', true)
fs.writeFileSync( // create empty file so we cant stat it fs.writeFileSync(
path.join(__dirname, '..', '..', '..', 'db', '.didSetup'), ''); // create empty file so we cant stat it
return ctx; path.join(__dirname, '..', '..', '..', 'db', '.didSetup'),
} ''
)
return ctx
},
], ],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
}, },
error: { error: {
@@ -75,6 +80,6 @@ module.exports = {
create: [], create: [],
update: [], update: [],
patch: [], patch: [],
remove: [] remove: [],
} },
}; }

View File

@@ -1,25 +1,25 @@
// Initializes the `users` service on path `/users` // Initializes the `users` service on path `/users`
const createService = require('feathers-nedb'); const createService = require('feathers-nedb')
const createModel = require('../../models/users.model'); const createModel = require('../../models/users.model')
const hooks = require('./users.hooks'); const hooks = require('./users.hooks')
const getUrl = require('../../../util/getUrl'); const getUrl = require('../../../util/getUrl')
module.exports = function (app) { module.exports = function(app) {
const Model = createModel(app); const Model = createModel(app)
const paginate = app.get('paginate'); const paginate = app.get('paginate')
const options = { const options = {
name: 'users', name: 'users',
Model, Model,
paginate paginate,
}; }
const url = getUrl('users'); const url = getUrl('users')
// Initialize our service with any options it requires // Initialize our service with any options it requires
app.use(url, createService(options)); app.use(url, createService(options))
// Get our initialized service so that we can register hooks and filters // Get our initialized service so that we can register hooks and filters
const service = app.service(url); const service = app.service(url)
service.hooks(hooks); service.hooks(hooks)
}; }

View File

@@ -1,50 +1,49 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
const fs = require('fs'); const fs = require('fs')
const path = require('path'); const path = require('path')
const fetch = require('isomorphic-unfetch'); const fetch = require('isomorphic-unfetch')
const ips = require('../config/trustIPs.json'); const ips = require('../config/trustIPs.json')
ips.push('loopback'); ips.push('loopback')
const cfv4 = 'https://www.cloudflare.com/ips-v4'; const cfv4 = 'https://www.cloudflare.com/ips-v4'
const cfv6 = 'https://www.cloudflare.com/ips-v6'; const cfv6 = 'https://www.cloudflare.com/ips-v6'
const cfConf = path.join(__dirname, '../config/cfIPs.json'); const cfConf = path.join(__dirname, '../config/cfIPs.json')
const refreshInterval = 24 * 60 * 60 * 1000; const refreshInterval = 24 * 60 * 60 * 1000
const getIps = str => { const getIps = str => {
str = str.split('\n').map(ip => ip.trim()); str = str.split('\n').map(ip => ip.trim())
str = str.filter(ip => ip.length !== 0); str = str.filter(ip => ip.length !== 0)
return str; return str
}; }
const getCfIps = async app => { const getCfIps = async app => {
const cfIps = []; const cfIps = []
let res = await fetch(cfv4); let res = await fetch(cfv4)
if(res.ok) { if (res.ok) {
cfIps.push(...getIps(await res.text())); cfIps.push(...getIps(await res.text()))
} }
res = await fetch(cfv6); res = await fetch(cfv6)
if(res.ok) { if (res.ok) {
cfIps.push(...getIps(await res.text())); cfIps.push(...getIps(await res.text()))
} }
fs.writeFile(cfConf, JSON.stringify(cfIps, null, 2), err => { fs.writeFile(cfConf, JSON.stringify(cfIps, null, 2), err => {
if(err) console.error(err); if (err) console.error(err)
}); })
app.set('trust proxy', [...ips, ...cfIps]); app.set('trust proxy', [...ips, ...cfIps])
}; }
module.exports = app => { module.exports = app => {
if(!app.get('trustCloudflare')) { if (!app.get('trustCloudflare')) {
return app.set('trust proxy', ips); return app.set('trust proxy', ips)
} }
fs.readFile(cfConf, async (err, buff) => { fs.readFile(cfConf, async (err, buff) => {
if(err) { if (err) {
if(err.code === 'ENOENT') getCfIps(app); if (err.code === 'ENOENT') getCfIps(app)
else return console.error(err); else return console.error(err)
} else {
const cfIps = JSON.parse(buff.toString())
app.set('trust proxy', [...ips, ...cfIps])
} }
else { setInterval(() => getCfIps(app), refreshInterval)
const cfIps = JSON.parse(buff.toString()); })
app.set('trust proxy', [...ips, ...cfIps]); }
}
setInterval(() => getCfIps(app), refreshInterval);
});
};

View File

@@ -1,55 +1,56 @@
const assert = require('assert'); const assert = require('assert')
const rp = require('request-promise'); const rp = require('request-promise')
const url = require('url'); const url = require('url')
const app = require('../src/app'); const app = require('../src/app')
const getUrlPath = require('../util/getUrl'); const getUrlPath = require('../util/getUrl')
const port = app.get('port') || 3030; const port = app.get('port') || 3030
const getUrl = pathname => url.format({ const getUrl = pathname =>
hostname: app.get('host') || 'localhost', url.format({
protocol: 'http', hostname: app.get('host') || 'localhost',
port, protocol: 'http',
pathname: getUrlPath(pathname) port,
}); pathname: getUrlPath(pathname),
})
describe('Feathers application tests', () => { describe('Feathers application tests', () => {
before(async () => { before(async () => {
this.server = await app.run(port); this.server = await app.run(port)
}); })
after(done => { after(done => {
this.server.close(done); this.server.close(done)
}); })
it('starts and shows the index page', () => { it('starts and shows the index page', () => {
return rp(getUrl('/')).then(body => return rp(getUrl('/')).then(body =>
assert.ok(body.indexOf('<html>') !== -1) assert.ok(body.indexOf('<html>') !== -1)
); )
}); })
describe('404', function() { describe('404', function() {
it('shows a 404 HTML page', () => { it('shows a 404 HTML page', () => {
return rp({ return rp({
url: getUrl('path/to/nowhere'), url: getUrl('path/to/nowhere'),
headers: { headers: {
'Accept': 'text/html' Accept: 'text/html',
} },
}).catch(res => { }).catch(res => {
assert.equal(res.statusCode, 404); assert.equal(res.statusCode, 404)
assert.ok(res.error.indexOf('<html>') !== -1); assert.ok(res.error.indexOf('<html>') !== -1)
}); })
}); })
it('shows a 404 JSON error without stack trace', () => { it('shows a 404 JSON error without stack trace', () => {
return rp({ return rp({
url: getUrl('path/to/nowhere'), url: getUrl('path/to/nowhere'),
json: true json: true,
}).catch(res => { }).catch(res => {
assert.equal(res.statusCode, 404); assert.equal(res.statusCode, 404)
assert.equal(res.error.code, 404); assert.equal(res.error.code, 404)
assert.equal(res.error.message, 'Page not found'); assert.equal(res.error.message, 'Page not found')
assert.equal(res.error.name, 'NotFound'); assert.equal(res.error.name, 'NotFound')
}); })
}); })
}); })
}); })

View File

@@ -1,10 +1,10 @@
const assert = require('assert'); const assert = require('assert')
const app = require('../../src/app'); const app = require('../../src/app')
const getUrl = require('../../util/getUrl'); const getUrl = require('../../util/getUrl')
describe('\'docs\' service', () => { describe("'docs' service", () => {
it('registered the service', () => { it('registered the service', () => {
const service = app.service(getUrl('docs')); const service = app.service(getUrl('docs'))
assert.ok(service, 'Registered the service'); assert.ok(service, 'Registered the service')
}); })
}); })

View File

@@ -1,10 +1,10 @@
const assert = require('assert'); const assert = require('assert')
const app = require('../../src/app'); const app = require('../../src/app')
const getUrl = require('../../util/getUrl'); const getUrl = require('../../util/getUrl')
describe('\'users\' service', () => { describe("'users' service", () => {
it('registered the service', () => { it('registered the service', () => {
const service = app.service(getUrl('users')); const service = app.service(getUrl('users'))
assert.ok(service, 'Registered the service'); assert.ok(service, 'Registered the service')
}); })
}); })

View File

@@ -1,8 +1,8 @@
// make sure basePath doesn't end with / // make sure basePath doesn't end with /
let { basePath } = require('../config/host.json'); let { basePath } = require('../config/host.json')
const urlChars = basePath.split(''); const urlChars = basePath.split('')
if(basePath.length > 1 && urlChars.pop() === '/') { if (basePath.length > 1 && urlChars.pop() === '/') {
basePath = urlChars.join(''); basePath = urlChars.join('')
} }
module.exports = basePath; module.exports = basePath

View File

@@ -1,48 +1,52 @@
const isOkDirPart = str => { const isOkDirPart = str => {
if(str.length > 255 || str.length === 0) return false; if (str.length > 255 || str.length === 0) return false
const end = str.length - 1; const end = str.length - 1
for(let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i); const c = str.charCodeAt(i)
if(!(c > 47 && c < 58) && // 0-9 if (
!(c > 64 && c < 91) && // A-Z !(c > 47 && c < 58) && // 0-9
!(c > 96 && c < 123) && // a-z !(c > 64 && c < 91) && // A-Z
!(c === 95) && !(c === 45) && // _ and - !(c > 96 && c < 123) && // a-z
!((c === 46 || c === 32) && // period or space if not first or last !(c === 95) &&
i !== 0 && i !== end)) { !(c === 45) && // _ and -
return false; !(
(c === 46 || c === 32) && // period or space if not first or last
i !== 0 &&
i !== end
)
) {
return false
} }
} }
return true; return true
}; }
module.exports = { module.exports = {
checkDir: dir => { checkDir: dir => {
if(typeof dir !== 'string') return false; if (typeof dir !== 'string') return false
dir = dir.trim(); dir = dir.trim()
if(dir.length === 0) return 0; if (dir.length === 0) return 0
if(dir.indexOf('/') > -1) { if (dir.indexOf('/') > -1) {
dir = dir.split('/').filter(p => p.length !== 0); dir = dir.split('/').filter(p => p.length !== 0)
if(dir.length === 1) { if (dir.length === 1) {
if(!isOkDirPart(dir[0])) false; if (!isOkDirPart(dir[0])) false
dir = dir[0]; dir = dir[0]
} else if(dir.length === 0) { } else if (dir.length === 0) {
dir = ''; dir = ''
} else if(dir.some(part => !isOkDirPart(part))) { } else if (dir.some(part => !isOkDirPart(part))) {
return false; return false
} }
} else if(!isOkDirPart(dir)) { } else if (!isOkDirPart(dir)) {
return false; return false
} }
return Array.isArray(dir) ? dir.join('/') : dir; return Array.isArray(dir) ? dir.join('/') : dir
}, },
checkName: name => { checkName: name => {
if(typeof name !== 'string') return false; if (typeof name !== 'string') return false
name = name.trim(); name = name.trim()
if(name.length === 0) return 0; if (name.length === 0) return 0
if(!isOkDirPart(name)) return false; if (!isOkDirPart(name)) return false
return name; return name
}, },
}
};

View File

@@ -1,21 +1,21 @@
const freezeSSR = selector => { const freezeSSR = selector => {
const FrozenSSR = () => { const FrozenSSR = () => {
let __html = ''; let __html = ''
let props = {}; let props = {}
if(typeof document !== 'undefined') { if (typeof document !== 'undefined') {
let el = document.querySelector(selector); let el = document.querySelector(selector)
if(el) { if (el) {
__html = el.innerHTML; __html = el.innerHTML
el.getAttributeNames().forEach(attr => { el.getAttributeNames().forEach(attr => {
const attrKey = attr === 'class' ? 'className' : attr; const attrKey = attr === 'class' ? 'className' : attr
props[attrKey] = el.getAttribute(attr); props[attrKey] = el.getAttribute(attr)
}); })
} }
} }
return <div {...props} dangerouslySetInnerHTML={{ __html }} />; return <div {...props} dangerouslySetInnerHTML={{ __html }} />
}; }
return { loading: FrozenSSR }; return { loading: FrozenSSR }
}; }
export default freezeSSR; export default freezeSSR

View File

@@ -1,36 +1,39 @@
import fetch from 'isomorphic-unfetch'; import fetch from 'isomorphic-unfetch'
import parseSort from './parseSort'; import parseSort from './parseSort'
import getUrl from './getUrl'; import getUrl from './getUrl'
import getJwt from './getJwt'; import getJwt from './getJwt'
export const $limit = 12; // number of docs per page export const $limit = 12 // number of docs per page
export const select = ['id', 'name', 'updated', 'dir'] export const select = ['id', 'name', 'updated', 'dir'].map((f, i) => ({
.map((f, i) => ({ [`$select[${i}]`]: f })); [`$select[${i}]`]: f,
}))
export const getDocs = async (q, jwt) => { export const getDocs = async (q, jwt) => {
const docsRes = await fetch(getUrl('docs', Boolean(jwt)) + q, { const docsRes = await fetch(getUrl('docs', Boolean(jwt)) + q, {
headers: { Authorization: jwt || getJwt() } headers: { Authorization: jwt || getJwt() },
}).catch(({ message }) => ({ ok: false, error: message })); }).catch(({ message }) => ({ ok: false, error: message }))
if(docsRes.ok) { if (docsRes.ok) {
const res = await docsRes.json(); const res = await docsRes.json()
const total = res.total || 0; const total = res.total || 0
const docs = res.data || []; const docs = res.data || []
return { docs, total }; return { docs, total }
} }
return { total: 0, docs: [], error: docsRes.message }; return { total: 0, docs: [], error: docsRes.message }
}; }
export const buildQ = q => { export const buildQ = q => {
if(!q.$search) delete q.$search; if (!q.$search) delete q.$search
if(!q.$skip) delete q.$skip; if (!q.$skip) delete q.$skip
else { else {
q.$skip = (q.$skip - 1) * $limit; q.$skip = (q.$skip - 1) * $limit
} }
const $sort = parseSort(q.$sort ? q.$sort : 'updated:-1'); const $sort = parseSort(q.$sort ? q.$sort : 'updated:-1')
delete q.$sort; delete q.$sort
select.forEach(sel => q = {...q, ...sel}); select.forEach(sel => (q = { ...q, ...sel }))
q = { $limit, ...q }; q = { $limit, ...q }
let url = Object.keys(q).map(k => `${k}=${encodeURIComponent(q[k])}`).join('&'); let url = Object.keys(q)
url = `?${url}&${$sort}`; .map(k => `${k}=${encodeURIComponent(q[k])}`)
return url; .join('&')
}; url = `?${url}&${$sort}`
return url
}

View File

@@ -1,6 +1,6 @@
export default req => { export default req => {
if(req) return req.jwt; if (req) return req.jwt
if(typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return window.localStorage.getItem('jwt'); return window.localStorage.getItem('jwt')
} }
}; }

View File

@@ -1,15 +1,15 @@
const url = require('url'); const url = require('url')
const urljoin = require('url-join'); const urljoin = require('url-join')
const basePath = require('./basePath'); const basePath = require('./basePath')
const { host, port, protocol } = require('../config/host.json'); const { host, port, protocol } = require('../config/host.json')
module.exports = (path, absolute) => { module.exports = (path, absolute) => {
path = urljoin(basePath, path); path = urljoin(basePath, path)
if(!absolute) return path; if (!absolute) return path
return url.format({ return url.format({
hostname: host, hostname: host,
port, port,
protocol, protocol,
pathname: path, pathname: path,
}); })
}; }

View File

@@ -1,4 +1,4 @@
module.exports = { module.exports = {
getKey: e => e.which || e.keyCode, getKey: e => e.which || e.keyCode,
isCtrlKey: key => key === 91 || key === 93 || key === 17 isCtrlKey: key => key === 91 || key === 93 || key === 17,
}; }

View File

@@ -1,3 +1,3 @@
export default ({ user }) => { export default ({ user }) => {
return { user }; return { user }
}; }

View File

@@ -1,18 +1,19 @@
export default sort => { export default sort => {
let key, ascDesc; let key, ascDesc
switch(typeof sort) { switch (typeof sort) {
case 'object': { case 'object': {
key = Object.keys(sort).pop(); key = Object.keys(sort).pop()
ascDesc = sort[key]; ascDesc = sort[key]
break; break
}
case 'string': {
const parts = sort.split(':')
key = parts[0]
ascDesc = parts[1]
break
}
default:
break
} }
case 'string': { return `$sort[${key}]=${ascDesc}`
const parts = sort.split(':'); }
key = parts[0];
ascDesc = parts[1];
break;
}
default: break;
}
return `$sort[${key}]=${ascDesc}`;
};

View File

@@ -1,8 +1,8 @@
const basePath = require('./basePath'); const basePath = require('./basePath')
module.exports = url => { module.exports = url => {
if(basePath !== '/') { if (basePath !== '/') {
url = url.split(basePath).join(''); url = url.split(basePath).join('')
} }
return url; return url
}; }

View File

@@ -1,4 +1,4 @@
export default function updateStateFromId(e){ export default function updateStateFromId(e) {
const el = e.target; const el = e.target
this.setState({ [el.id]: el.value }); this.setState({ [el.id]: el.value })
} }