initial commit

This commit is contained in:
JJ Kasper
2018-05-17 14:31:05 -05:00
commit 2c90d2e7dd
79 changed files with 10684 additions and 0 deletions

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

29
.eslintrc.json Normal file
View File

@@ -0,0 +1,29 @@
{
"env": {
"es6": true,
"node": true,
"mocha": true
},
"parserOptions": {
"ecmaVersion": 2017
},
"extends": "eslint:recommended",
"rules": {
"indent": [
"error",
2
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

43
.eslintrc.react.json Normal file
View File

@@ -0,0 +1,43 @@
{
"parser": "babel-eslint",
"plugins": [
"react"
],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"env": {
"es6": true,
"browser": true,
"node": true,
"mocha": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"rules": {
"react/prop-types": 0,
"react/react-in-jsx-scope": 0,
"indent": [
"error",
2
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

119
.gitignore vendored Normal file
View File

@@ -0,0 +1,119 @@
# Logs
logs
*.log
# Next build data
.next
# cloudflare ips cache file
config/cfIPs.json
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# Commenting this out is preferred by some people, see
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
node_modules
# Users Environment Variables
.lock-wscript
# IDEs and editors (shamelessly copied from @angular/cli's .gitignore)
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### OSX ###
*.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Windows ###
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
# Others
lib/
db/
kb/

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
The MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

98
README.md Normal file
View File

@@ -0,0 +1,98 @@
# MYKB
> A file system/markdown based knowledge base editor/viewer
## Demo
You can try the demo at: https://mykb.jjsweb.site
\- Login
email: notadmin
password: secretpass
P.S. the demo is reset every 10 minutes
## About
MYKB is a simple file system/markdown based knowledge base editor/viewer built with [Feathers](http://feathersjs.com) and [Next.js](https://github.com/zeit/next.js)
Current features:
- live preview when editing a doc
- live file system watching
- caching of docs to speed up searching/viewing of docs
- automatic git versioning
- automatic trusting of cloudflare reverse proxies
## Installing
Getting up and running is as easy as 1, 2, 3
1. Clone repo
```
git clone https://github.com/ijjk/mykb
```
2. Install dependencies (omit `--prod` if developing)
```
cd path/to/mykb; npm i --prod
```
3. Start it
```
npm start
```
## Options
host.json
| Name | Description |
| ---- | ----------- |
| host | The host to listen on |
| port | The port to listen on |
| basePath | Used to prefix all urls for reverse proxies |
production.json (overrides default.json with production NODE_ENV var)
| Name | Description |
| ---- | ----------- |
| useGit | Whether or not to use a git repo to automatically version changes to docs (requires git to be installed) |
| docsDir | The directory where the markdown docs are located |
| cacheSize | Max size of docs to store in memory for faster searching (default 7.5MB) |
| trustCloudflare | Whether to trust X-Forwarded-For header from cloudflare IPs (used for rate limiting) |
If using git the `user.email` and `user.name` configs need to be set either globally or on the docs repo
trustIPs.json - An array of [proxy-addr](https://www.npmjs.com/package/proxy-addr) compatible addresses to trust the X-Forwarded-For header from (Only needed if behind reverse proxy)
## Testing
Simply run `npm test` and all your tests in the `test/` directory will be run
## Linting
Lint just react stuff
```
npm lint:react
```
Lint just server stuff
```
npm lint:node
```
Lint both
```
npm lint
```
## Changelog
__0.1.0__
- Initial release
## License
Copyright (c) 2017
Licensed under the [MIT license](LICENSE).

49
comps/AddDoc.js Normal file
View File

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

65
comps/CodeMirror.js Normal file
View File

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

20
comps/DocItem.js Normal file
View File

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

10
comps/Footer.js Normal file
View File

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

75
comps/Header.js Normal file
View File

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

68
comps/KeyShortcuts.js Normal file
View File

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

67
comps/Login.js Normal file
View File

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

13
comps/Markdown.js Normal file
View File

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

150
comps/MngDoc.js Normal file
View File

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

18
comps/PaddedRow.js Normal file
View File

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

25
comps/Page.js Normal file
View File

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

97
comps/Setup.js Normal file
View File

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

4
comps/Spinner.js Normal file
View File

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

33
config/default.json Normal file
View File

@@ -0,0 +1,33 @@
{
"docsDir": "../kb",
"trustCloudflare": false,
"cacheSize": 7500000,
"paginate": {
"default": 10,
"max": 50
},
"authentication": {
"secret": "3e1e7c92b1ba7fd917d8a8abfe42349982242d4673464b43f57f72cdf2c153ed0c1cff46299ee633009e0b7e9d9c60b1dc3fa05e0a5541be5f3659999498987bf1e76524535b60c13780dc0e12322593ddad755c833d8423d4641cf006866604a0f06c2acc7741289cdabf55e61fd164e02bfc26d0cd1031e40c695ba32a9a89fca000ee2cdb705f5d2e46fec98308e7db47a2ed95f51488c4b89cb549b495fc841f4b48f35c5ece9af31dfaf9befa4e25b746269becc93fa9a8484c55a93869afb5377d2a38b98a432a4bb4aef09fe89f2de1c1d73d4476d222958a3401ab3cfb5ad86f883c847806618e44c198ea4baec426d488cb6e323a89bfce896a86d6",
"strategies": [
"jwt",
"local"
],
"path": "/auth",
"service": "users",
"jwt": {
"header": {
"typ": "access"
},
"subject": "anonymous",
"issuer": "feathers",
"algorithm": "HS256",
"expiresIn": "1d"
},
"local": {
"entity": "user",
"usernameField": "email",
"passwordField": "password"
}
},
"nedb": "../db"
}

6
config/host.json Normal file
View File

@@ -0,0 +1,6 @@
{
"host": "localhost",
"port": 3030,
"protocol": "http",
"basePath": "/"
}

1
config/production.json Normal file
View File

@@ -0,0 +1 @@
{}

1
config/trustIPs.json Normal file
View File

@@ -0,0 +1 @@
[]

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

24
genSecret.js Normal file
View File

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

12
kb/hello world.md Normal file
View File

@@ -0,0 +1,12 @@
## Welcome to MYKB!
MYKB is a simple file system/markdown based knowledge base editor/viewer.
Current features:
- live preview when editing a doc
- live file system watching
- caching of docs to speed up searching/viewing of docs
- automatic git versioning
- automatic trusting of cloudflare proxies

20
next.config.js Normal file
View File

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

88
package.json Normal file
View File

@@ -0,0 +1,88 @@
{
"name": "mykb",
"description": "A file system/markdown based knowledge base editor/viewer",
"version": "0.1.0",
"main": "src",
"keywords": [
"feathers",
"react",
"next.js",
"markdown"
],
"author": {
"name": "JJ Kasper",
"email": "jj@jjsweb.site"
},
"contributors": [],
"bugs": {},
"directories": {
"lib": "src",
"test": "test/"
},
"engines": {
"node": ">= 8.0.0",
"yarn": ">= 0.18.0"
},
"scripts": {
"lint:node": "eslint ./src ./test --config .eslintrc.json",
"lint:react": "eslint ./pages ./redux ./util ./comps --config .eslintrc.react.json",
"lint": "npm run lint:node && npm run lint:react",
"mocha": "cross-env NODE_ENV=production mocha test/ --recursive --exit",
"build": "next build",
"analyze": "cross-env ANALYZE=true next build",
"dev": "nodemon -w ./src -w ./config -w ./util -w next.config.js --exec 'npm run start:dev'",
"start:dev": "node src/",
"start": "cross-env NODE_ENV=production node src/",
"test": "npm run lint && npm run build && npm run mocha",
"check:build": "if-env NODE_ENV=production && npm run build || echo ''",
"postinstall": "node ./genSecret.js && npm run check:build"
},
"dependencies": {
"@feathersjs/authentication": "^2.1.5",
"@feathersjs/authentication-jwt": "^2.0.0",
"@feathersjs/authentication-local": "^1.1.2",
"@feathersjs/configuration": "^1.0.2",
"@feathersjs/errors": "^3.3.0",
"@feathersjs/express": "^1.2.2",
"@feathersjs/feathers": "^3.1.4",
"@zeit/next-sass": "^0.1.2",
"chokidar": "^2.0.3",
"codemirror": "^5.37.0",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
"cors": "^2.8.4",
"cross-env": "^5.1.4",
"express-rate-limit": "^2.11.0",
"feathers-nedb": "^3.0.0",
"fs-extra": "^5.0.0",
"glob": "^7.1.2",
"helmet": "^3.12.0",
"if-env": "^1.0.4",
"isomorphic-unfetch": "^2.0.0",
"milligram": "^1.3.0",
"nedb": "^1.8.0",
"next": "^6.0.0",
"node-sass": "^4.8.3",
"react": "^16.3.2",
"react-dom": "^16.3.2",
"react-markdown": "^3.3.0",
"react-paginate": "^5.2.3",
"react-redux": "^5.0.7",
"redux": "^4.0.0",
"serve-favicon": "^2.5.0",
"simple-git": "^1.92.0",
"url-join": "^4.0.0",
"winston": "^2.4.1"
},
"devDependencies": {
"babel-eslint": "^8.2.3",
"eslint": "^4.19.1",
"eslint-plugin-react": "^7.8.2",
"mocha": "^5.1.1",
"nodemon": "^1.17.3",
"redux-logger": "^3.0.6",
"request": "^2.85.0",
"request-promise": "^4.2.2",
"webpack-bundle-analyzer": "^2.11.1"
}
}

42
pages/_app.js Normal file
View File

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

25
pages/_document.js Normal file
View File

@@ -0,0 +1,25 @@
import Document, { Head, Main, NextScript } from 'next/document';
import getUrl from '../util/getUrl';
export default class MyDocument extends Document {
render() {
const favicon = getUrl('favicon.ico');
return (
<html>
<Head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no'/>
<link rel='shortcut icon' href={favicon} type='image/x-icon'/>
<link rel='icon' href={favicon} type='image/x-icon'/>
<link rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic'/>
<link rel='stylesheet' href={getUrl('/_next/static/style.css')} />
<title>My Knowledge Base</title>
</Head>
<body>
<Main />
<NextScript />
</body>
</html>
);
}
}

17
pages/edit.js Normal file
View File

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

145
pages/index.js Normal file
View File

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

53
pages/k.js Normal file
View File

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

2
pages/new.js Normal file
View File

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

99
pages/settings.js Normal file
View File

@@ -0,0 +1,99 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import fetch from 'isomorphic-unfetch';
import Page from '../comps/Page';
import PaddedRow from '../comps/PaddedRow';
import Spinner from '../comps/Spinner';
import updStateFromId from '../util/updStateFromId';
import mapUser from '../util/mapUser';
import getUrl from '../util/getUrl';
import getJwt from '../util/getJwt';
class Settings extends Component {
state = {
pending: false,
passErr: null,
curPass: '',
newPass: '',
confPass: ''
}
updVal = updStateFromId.bind(this);
submit = async e => {
e.preventDefault();
const { pending, curPass, newPass, confPass } = this.state;
const { email, _id } = this.props.user;
if(pending) return;
const doErr = passErr => this.setState({ pending: false, passErr });
const vals = {
'Current password': curPass,
'New password': newPass,
'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');
this.setState({ passErr: null, pending: true });
const updRes = await fetch(getUrl('users/' + _id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: getJwt() },
body: JSON.stringify({ email, password: curPass, newPassword: newPass })
}).catch(doErr);
if(updRes.ok) {
this.setState({
curPass: '', newPass: '', confPass: '',
passErr: 'Password updated successfully',
pending: false
});
} else {
let message = 'failed to update password';
try {
const data = await updRes.json();
message = data.message || message;
} catch (err) { doErr(err.message); }
doErr(message);
}
}
render() {
const {
pending, passErr, curPass,
newPass, confPass
} = this.state;
return (
<Page>
<PaddedRow amount={25}>
<h3>Account settings</h3>
<hr />
<form noValidate style={{ padding: '0 0 45px' }}>
<h4>Change password</h4>
<fieldset>
<label htmlFor='curPass'>Current Password</label>
<input type='password' id='curPass' onChange={this.updVal}
placeholder='Current super secret password...' value={curPass}
/>
<label htmlFor='newPass'>New Password</label>
<input type='password' id='newPass' onChange={this.updVal}
placeholder='New super secret password...' value={newPass}
/>
<label htmlFor='confPass'>Confirm New Password</label>
<input type='password' id='confPass' onChange={this.updVal}
placeholder='Confirm new super secret password...' value={confPass}
/>
</fieldset>
<button onClick={this.submit}
className={'float-right' + (pending ? ' disabled' : '')}
>
{pending ? <Spinner /> : 'Submit'}
</button>
{!passErr ? null : <p>{passErr}</p>}
</form>
</PaddedRow>
</Page>
);
}
}
export default connect(mapUser)(Settings);

74
redux/actions/userAct.js Normal file
View File

@@ -0,0 +1,74 @@
import fetch from 'isomorphic-unfetch';
import store from '../store';
import getUrl from '../../util/getUrl';
// define action types
export const SET_USER = 'SET_USER';
export const LOGIN_PENDING = 'LOGIN_PENDING';
export const LOGIN_FAILED = 'LOGIN_FAILED';
export const LOGOUT = 'LOGOUT';
export const setUser = user => {
store.dispatch({
type: SET_USER,
data: user
});
}; // setUser
export const doLogout = () => {
if(typeof window !== 'undefined') {
window.localStorage.removeItem('jwt');
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/;';
}
store.dispatch({ type: LOGOUT });
}; // doLogout
export async function doLogin (creds, jwt, noPend) {
!noPend && store.dispatch({ type: LOGIN_PENDING });
const authReqOpts = { method: 'POST', credentials: 'include' };
const authReqHead = { headers: jwt ? { Authorization: jwt } : {
'Content-Type': 'application/json' }
};
const authReqBody = jwt ? null : {
body: JSON.stringify({...creds, strategy: 'local'})
};
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 payload = accessToken.split('.')[1];
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

45
redux/reducers/userRed.js Normal file
View File

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

23
redux/store.js Normal file
View File

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

34
src/app.hooks.js Normal file
View File

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

117
src/app.js Normal file
View File

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

42
src/authentication.js Normal file
View File

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

61
src/channels.js Normal file
View File

@@ -0,0 +1,61 @@
module.exports = function(app) {
if(typeof app.channel !== 'function') {
// If no real-time functionality has been configured just return
return;
}
app.on('connection', connection => {
// On a new real-time connection, add it to the anonymous channel
app.channel('anonymous').join(connection);
});
app.on('login', (authResult, { connection }) => {
// connection can be undefined if there is no
// real-time connection, e.g. when logging in via REST
if(connection) {
// Obtain the logged in user from the connection
// const user = connection.user;
// The connection is no longer anonymous, remove it
app.channel('anonymous').leave(connection);
// Add it to the authenticated user channel
app.channel('authenticated').join(connection);
// Channels can be named anything and joined on any condition
// E.g. to send real-time events only to admins use
// if(user.isAdmin) { app.channel('admins').join(connection); }
// 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));
// Easily organize users by email and userid for things like messaging
// app.channel(`emails/${user.email}`).join(channel);
// app.channel(`userIds/$(user.id}`).join(channel);
}
});
// eslint-disable-next-line no-unused-vars
app.publish((data, hook) => {
// Here you can add event publishers to channels set up in `channels.js`
// 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
// e.g. to publish all service events to all authenticated users use
return app.channel('authenticated');
});
// Here you can also add service specific event publishers
// e..g the publish the `users` service `created` event to the `admins` channel
// app.service('users').publish('created', () => app.channel('admins'));
// With the userid and email organization from above you can easily select involved users
// app.service('messages').publish(() => {
// return [
// app.channel(`userIds/${data.createdBy}`),
// app.channel(`emails/${data.recipientEmail}`)
// ];
// });
};

23
src/hooks/logger.js Normal file
View File

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

14
src/index.js Normal file
View File

@@ -0,0 +1,14 @@
const logger = require('winston');
const app = require('./app');
const port = app.get('port');
const server = app.listen(port);
app.startNext();
server.on('listening', () =>
logger.info('MYKB listening at http://%s:%d', app.get('host'), port)
);
process.on('unhandledRejection', (reason, p) =>
logger.error('Unhandled Rejection at: Promise ', p, reason)
);

11
src/middleware/index.js Normal file
View File

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

14
src/models/users.model.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

23
src/services/hooksUtil.js Normal file
View File

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

6
src/services/index.js Normal file
View File

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

View File

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

View File

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

50
src/trustIPs.js Normal file
View File

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

7
styles/_theme.sass Normal file
View File

@@ -0,0 +1,7 @@
$primary: #202225;
$primaryAlt: lighten($primary, 5);
$danger: #d44848;
$text: #dcddde;
$link: #00d1b2;
$linkAct: darken($link, 10);
$fontFamily: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;

5
styles/comps/_all.sass Normal file
View File

@@ -0,0 +1,5 @@
// import all components from this folder
@import 'footer.sass';
@import 'navbar.sass';
@import 'spinner.sass';
@import 'paginate.sass';

6
styles/comps/footer.sass Normal file
View File

@@ -0,0 +1,6 @@
.footer
text-align: center;
padding: 10px 10px 15px;
background: $primaryAlt;
p
margin-bottom: 0;

126
styles/comps/navbar.sass Normal file
View File

@@ -0,0 +1,126 @@
.navbar
background: $primaryAlt;
display: flex;
flex-direction: row;
align-items: center;
height: 55px;
.navbar-brand
margin-left: 0.75em;
margin-right: auto;
h3
margin-bottom: 0;
.navbar-burger
display: none;
width: 32px;
margin-right: 10px;
&.active div
&:nth-child(1)
transform-origin: center;
transform: translateY(8px) rotate(45deg);
&:nth-child(2)
opacity: 0;
&:nth-child(3)
transform-origin: left -6px;
transform: translateY(8px) rotate(-45deg);
div
transition: all ease-in-out 150ms;
width: 100%;
height: 2px;
margin: 5px 0;
border-radius: 1px;
background: $text;
.navbar-items
display: inline-flex;
flex-direction: row;
.active .item, .item:hover
background: $primary;
.item
margin: 0;
cursor: pointer;
padding: 15px 20px;
// .dropdown
// position: relative;
// &:hover
// .dropdown-items
// height: auto;
// .dropdown-root:after
// content: ' \25BE';
// .dropdown-items
// height: 0;
// width: 125px;
// overflow: hidden;
// top: 100%;
// left: 0px;
// z-index: 6;
// position: absolute;
// background: $primaryAlt;
// .item
// padding: 0;
// margin: 0 !important;
// padding: 8px 10px;
// &.active, &:hover,
// background: none;
// color: $linkAct;
@media screen and (max-width: 840px)
.navbar
.navbar-burger
display: inline-block;
.navbar-items
display: block;
overflow: hidden;
position: fixed;
top: 55px;
left: 0;
z-index: 5;
background: $primaryAlt;
width: 100%;
transform: scaleY(0);
transform-origin: top;
transition: all ease-in-out 125ms;
&.active
transform: scaleY(1);
overflow: auto;
.item
text-align: center;
width: 100%;
padding: 5px 0;
// .dropdown
// padding: 0 !important;
// .dropdown-root
// display: none;
// .dropdown-items
// position: relative !important;
// overflow: hidden;
// padding: 0 !important;
// height: auto !important;
// width: 100% !important;
// .item
// width: 100% !important;
// padding: 5px 0 !important;
// text-align: center !important;
// &:last-child
// padding: 5px 0 0 !important;

View File

@@ -0,0 +1,18 @@
.paginate
list-style: none;
text-align: center;
user-select: none;
margin: 0;
li
display: inline-block;
&.active a
border-color: $link;
a
outline: 0;
border-radius: 50%;
border: 1px solid;
border-color: transparent;
padding: 3px 8px;

15
styles/comps/spinner.sass Normal file
View File

@@ -0,0 +1,15 @@
.spinner
height: 24px;
width: 24px;
border-radius: 100%;
border: 2px solid $text;
$noBorder: none;
border-right: $noBorder;
border-bottom: $noBorder;
animation: spinner 500ms linear infinite;
@keyframes spinner
from
transform: rotate(0deg);
to
transform: rotate(360deg);

2
styles/mixins/_all.sass Normal file
View File

@@ -0,0 +1,2 @@
// import all mixins from this folder
@import '_placeholder.sass';

View File

@@ -0,0 +1,11 @@
@mixin placeholder()
&::-webkit-input-placeholder
@content
&::-moz-placeholder
@content
&:-ms-input-placeholder
@content
&:-moz-placeholder
@content
&::placeholder
@content

117
styles/style.sass Normal file
View File

@@ -0,0 +1,117 @@
// import milligram
@import '../node_modules/milligram/src/milligram.sass';
// import codemirror
@import '../node_modules/codemirror/lib/codemirror.css';
@import '../node_modules/codemirror/theme/monokai.css';
// import other stuff
@import '_theme.sass';
@import 'mixins/_all.sass';
@import 'comps/_all.sass';
body, code, pre
background: $primary;
color: $text;
margin: 0;
pre, code
font-size: 1.5rem;
word-break: break-all;
input, textarea, select, button,
.button, .cm-s-monokai.CodeMirror
color: $text
border: none !important;
border-radius: .4rem;
background-color: $primaryAlt !important;
button
&[disabled], &.disabled
cursor: default;
input, textarea
font-size: 1.6rem;
font-family: $fontFamily;
font-weight: 300;
resize: none;
&[disabled]
opacity: 0.8;
cursor: not-allowed;
@include placeholder()
opacity: 0.85;
color: $text;
select
-webkit-appearance: none;
-moz-appearance: none;
text-overflow: '';
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%23d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;
&:focus
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="%239b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>')
.button.disabled
cursor: default;
a
color: $link
cursor: pointer;
&:visited, &:focus
color: $link;
&:hover
color: $linkAct;
.danger
color: $danger;
.noMargin
margin: 0 !important;
.float-right
margin-left: auto;
.float-left
margin-right: auto;
.container
display: flex;
flex-direction: column;
.CodeMirror
width: 100%;
.cm-s-monokai span.cm-comment
color: #ccc9ba;
.content
min-height: calc(100vh - 55px - 50px);
padding: 10px;
p, pre
word-wrap: break-word;
word-break: break-all;
.v-center
min-height: calc(100vh - 55px - 50px - 20px);
flex-direction: row;
align-items: center;
.no-mob
display: none !important;
.inline
display: inline-flex !important;
align-items: middle;
select, input
width: auto;
height: 28px;
flex-grow: 1;
margin-left: 5px;
margin-bottom: 0;
padding: 6px;
border: none;
@media screen and (min-width: 640px)
.no-mob
display: block !important;

56
test/app.test.js Normal file
View File

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

View File

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

View File

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

8
util/basePath.js Normal file
View File

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

48
util/checkDirParts.js Normal file
View File

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

21
util/freezeSSR.js Normal file
View File

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

36
util/getDocs.js Normal file
View File

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

6
util/getJwt.js Normal file
View File

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

15
util/getUrl.js Normal file
View File

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

4
util/keys.js Normal file
View File

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

3
util/mapUser.js Normal file
View File

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

18
util/parseSort.js Normal file
View File

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

8
util/stripBase.js Normal file
View File

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

4
util/updStateFromId.js Normal file
View File

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

7529
yarn.lock Normal file

File diff suppressed because it is too large Load Diff