initial commit
This commit is contained in:
13
.editorconfig
Normal file
13
.editorconfig
Normal 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
29
.eslintrc.json
Normal 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
43
.eslintrc.react.json
Normal 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
119
.gitignore
vendored
Normal 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
19
LICENSE
Normal 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
98
README.md
Normal 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
49
comps/AddDoc.js
Normal 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
65
comps/CodeMirror.js
Normal 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
20
comps/DocItem.js
Normal 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
10
comps/Footer.js
Normal 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
75
comps/Header.js
Normal 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
68
comps/KeyShortcuts.js
Normal 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
67
comps/Login.js
Normal 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
13
comps/Markdown.js
Normal 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
150
comps/MngDoc.js
Normal 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
18
comps/PaddedRow.js
Normal 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
25
comps/Page.js
Normal 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
97
comps/Setup.js
Normal 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
4
comps/Spinner.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const Spinner = (props) => (
|
||||
<div className='spinner' {...props}></div>
|
||||
);
|
||||
export default Spinner;
|
||||
33
config/default.json
Normal file
33
config/default.json
Normal 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
6
config/host.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"host": "localhost",
|
||||
"port": 3030,
|
||||
"protocol": "http",
|
||||
"basePath": "/"
|
||||
}
|
||||
1
config/production.json
Normal file
1
config/production.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
config/trustIPs.json
Normal file
1
config/trustIPs.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
24
genSecret.js
Normal file
24
genSecret.js
Normal 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
12
kb/hello world.md
Normal 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
20
next.config.js
Normal 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
88
package.json
Normal 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
42
pages/_app.js
Normal 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
25
pages/_document.js
Normal 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
17
pages/edit.js
Normal 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
145
pages/index.js
Normal 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
53
pages/k.js
Normal 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
2
pages/new.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import MngDoc from '../comps/MngDoc';
|
||||
export default MngDoc;
|
||||
99
pages/settings.js
Normal file
99
pages/settings.js
Normal 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
74
redux/actions/userAct.js
Normal 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
45
redux/reducers/userRed.js
Normal 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
23
redux/store.js
Normal 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
34
src/app.hooks.js
Normal 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
117
src/app.js
Normal 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
42
src/authentication.js
Normal 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
61
src/channels.js
Normal 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
23
src/hooks/logger.js
Normal 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
14
src/index.js
Normal 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
11
src/middleware/index.js
Normal 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
14
src/models/users.model.js
Normal 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;
|
||||
};
|
||||
225
src/services/docs/docs.class.js
Normal file
225
src/services/docs/docs.class.js
Normal 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;
|
||||
91
src/services/docs/docs.hooks.js
Normal file
91
src/services/docs/docs.hooks.js
Normal 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: []
|
||||
}
|
||||
};
|
||||
29
src/services/docs/docs.service.js
Normal file
29
src/services/docs/docs.service.js
Normal 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);
|
||||
};
|
||||
64
src/services/docs/loadDocs.js
Normal file
64
src/services/docs/loadDocs.js
Normal 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
23
src/services/hooksUtil.js
Normal 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
6
src/services/index.js
Normal 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);
|
||||
};
|
||||
80
src/services/users/users.hooks.js
Normal file
80
src/services/users/users.hooks.js
Normal 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: []
|
||||
}
|
||||
};
|
||||
25
src/services/users/users.service.js
Normal file
25
src/services/users/users.service.js
Normal 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
50
src/trustIPs.js
Normal 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
7
styles/_theme.sass
Normal 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
5
styles/comps/_all.sass
Normal 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
6
styles/comps/footer.sass
Normal 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
126
styles/comps/navbar.sass
Normal 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;
|
||||
18
styles/comps/paginate.sass
Normal file
18
styles/comps/paginate.sass
Normal 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
15
styles/comps/spinner.sass
Normal 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
2
styles/mixins/_all.sass
Normal file
@@ -0,0 +1,2 @@
|
||||
// import all mixins from this folder
|
||||
@import '_placeholder.sass';
|
||||
11
styles/mixins/_placeholder.sass
Normal file
11
styles/mixins/_placeholder.sass
Normal 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
117
styles/style.sass
Normal 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
56
test/app.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
10
test/services/docs.test.js
Normal file
10
test/services/docs.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
10
test/services/users.test.js
Normal file
10
test/services/users.test.js
Normal 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
8
util/basePath.js
Normal 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
48
util/checkDirParts.js
Normal 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
21
util/freezeSSR.js
Normal 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
36
util/getDocs.js
Normal 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
6
util/getJwt.js
Normal 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
15
util/getUrl.js
Normal 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
4
util/keys.js
Normal 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
3
util/mapUser.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default ({ user }) => {
|
||||
return { user };
|
||||
};
|
||||
18
util/parseSort.js
Normal file
18
util/parseSort.js
Normal 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
8
util/stripBase.js
Normal 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
4
util/updStateFromId.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function updateStateFromId(e){
|
||||
const el = e.target;
|
||||
this.setState({ [el.id]: el.value });
|
||||
}
|
||||
Reference in New Issue
Block a user