Merge branch 'develop' into feature/lutim

This commit is contained in:
Max Wu 2019-08-01 00:22:23 +08:00 committed by GitHub
commit 62918134c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 8794 additions and 6327 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.git/
node_modules/
docs/
test/
.sequelizerc.example
config.json.example
public/build

View File

@ -1,3 +0,0 @@
lib/ot
public/vendor
public/build

View File

@ -1,21 +0,0 @@
module.exports = {
"root": true,
"extends": "standard",
"env": {
"node": true
},
"rules": {
// at some point all of these should return to their default "error" state
// but right now, this is not a good choice, because too many places are
// wrong.
"import/first": ["warn"],
"indent": ["warn"],
"no-multiple-empty-lines": ["warn"],
"no-multi-spaces": ["warn"],
"object-curly-spacing": ["warn"],
"one-var": ["warn"],
"quotes": ["warn"],
"semi": ["warn"],
"space-infix-ops": ["warn"]
}
};

2
.gitignore vendored
View File

@ -27,3 +27,5 @@ public/views/build
public/uploads/* public/uploads/*
!public/uploads/.gitkeep !public/uploads/.gitkeep
/.nyc_output
/coverage/

View File

@ -1,47 +1,34 @@
language: node_js language: node_js
dist: trusty
node_js:
- "lts/carbon"
- "lts/dubnium"
- "11"
- "12"
dist: xenial
cache: yarn cache: yarn
env:
global: matrix:
- CXX=g++-4.8 fast_finish: true
- YARN_VERSION=1.3.2 include:
- node_js: lts/carbon
- node_js: lts/dubnium
allow_failures:
- node_js: "11"
- node_js: "12"
script:
- yarn test:ci
- yarn build
jobs: jobs:
include: include:
- env: task=npm-test - stage: doctoc-check
node_js: install: npm install -g doctoc
- 6 if: type = pull_request OR branch = master
before_install:
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
- export PATH="$HOME/.yarn/bin:$PATH"
- env: task=npm-test
node_js:
- 8
before_install:
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
- export PATH="$HOME/.yarn/bin:$PATH"
- env: task=npm-test
node_js:
- 10
before_install:
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
- export PATH="$HOME/.yarn/bin:$PATH"
- env: task=ShellCheck
script:
- shellcheck bin/heroku bin/setup
language: generic
- env: task=doctoc
install: npm install doctoc
script: script:
- cp README.md README.md.orig - cp README.md README.md.orig
- npm run doctoc - npm run doctoc
- diff -q README.md README.md.orig - diff -q README.md README.md.orig
language: generic node_js: lts/carbon
- env: task=json-lint
addons:
apt:
packages:
- jq
script:
- npm run jsonlint
language: generic

423
README.md
View File

@ -1,382 +1,105 @@
CodiMD CodiMD
=== ===
[![#CodiMD on matrix.org][matrix.org-image]][matrix.org-url] [![CodiMD on Gitter][gitter-image]][gitter-url]
[![build status][travis-image]][travis-url] [![build status][travis-image]][travis-url]
[![version][github-version-badge]][github-release-page] [![version][github-version-badge]][github-release-page]
[![Gitter][gitter-image]][gitter-url]
[![POEditor][poeditor-image]][poeditor-url] [![POEditor][poeditor-image]][poeditor-url]
CodiMD lets you create real-time collaborative markdown notes on all platforms. CodiMD lets you collaborate in real-time with markdown.
Inspired by Hackpad, with more focus on speed and flexibility, and build from [HackMD](https://hackmd.io) source code. Built on [HackMD](https://hackmd.io) source code, CodiMD lets you host and control your team's content with speed and ease.
Feel free to contribute.
Thanks for using! :smile:
<!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
# Table of Contents # Table of Contents
- [HackMD CE became CodiMD](#hackmd-ce-became-codimd) - [CodiMD - The Open Source HackMD](#codimd---the-open-source-hackmd)
- [Browsers Requirement](#browsers-requirement) - [Documentation](#documentation)
- [Installation](#installation) - [Deployment](#deployment)
- [Getting started (Native install)](#getting-started-native-install) - [Configuration](#configuration)
- [Prerequisite](#prerequisite) - [Upgrading and Migration](#upgrading-and-migration)
- [Instructions](#instructions) - [Developer](#developer)
- [Heroku Deployment](#heroku-deployment) - [Contribution and Discussion](#contribution-and-discussion)
- [Kubernetes](#kubernetes) - [Browser Support](#browser-support)
- [CodiMD by docker container](#codimd-by-docker-container)
- [Cloudron](#cloudron)
- [Upgrade](#upgrade)
- [Native setup](#native-setup)
- [Configuration](#configuration)
- [Environment variables (will overwrite other server configs)](#environment-variables-will-overwrite-other-server-configs)
- [Application settings `config.json`](#application-settings-configjson)
- [Third-party integration API key settings](#third-party-integration-api-key-settings)
- [Third-party integration OAuth callback URLs](#third-party-integration-oauth-callback-urls)
- [Developer Notes](#developer-notes)
- [Structure](#structure)
- [Operational Transformation](#operational-transformation)
- [License](#license) - [License](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update --> <!-- END doctoc generated TOC please keep comment here to allow auto update -->
# HackMD CE became CodiMD ## CodiMD - The Open Source HackMD
[HackMD](https://hackmd.io) helps developers write better documents and build active communities with open collaboration.
HackMD is built with one promise - **You own and control all your content**:
- You should be able to easily [download all your online content at once](https://hackmd.io/c/news/%2Fs%2Fr1cx3a3SE).
- Your content formatting should be portable as well. (That's why we choose [markdown](https://hackmd.io/features#Typography).)
- You should be able to control your content's presentation with HTML, [slide mode](https://hackmd.io/p/slide-example), or [book mode](https://hackmd.io/c/book-example/).
CodiMD was recently renamed from its former name was HackMD. CodiMD is the free software version of HackMD. It was the original Version of HackMD. The HackMD team initiated CodiMD and provided a solid code base. Due to the need of paying bills, A fork was created and called HackMD EE, which is a SaaS (Software as a Service) product available at [hackmd.io](https://hackmd.io). With the same promise of you owning your content, CodiMD is the free software version of [HackMD](https://hackmd.io), developed and opened source by the HackMD team with reduced features, so you can use CodiMD for your community and own your data. *(See the [origin of the name CodiMD](https://github.com/hackmdio/hackmd/issues/720).)*
We decided to change the name to break the confusion between HackMD and CodiMD, formally known as HackMD CE, as it never was an open core project. CodiMD is perfect for open communities, while HackMD emphasizes on permission and access controls for commercial use cases.
Just to more confusion: We are still friends with HackMD :heart: HackMD team is committed to keep CodiMD open source. All contributions are welcome!
*For the whole renaming story, see the [related issue](https://github.com/hackmdio/hackmd/issues/720)* ## Documentation
You would find all documentation here: [CodiMD Documentation](https://hackmd.io/c/codimd-documentation)
# Browsers Requirement ### Deployment
If you want to spin up an instance and start using immediately, see [Docker deployment](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-documentation#Deployment).
If you want to contribute to the project, start with [manual deployment](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-manual-deployment).
- ![Chrome](http://browserbadge.com/chrome/47/18px) Chrome >= 47, Chrome for Android >= 47 ### Configuration
- ![Safari](http://browserbadge.com/safari/9/18px) Safari >= 9, iOS Safari >= 8.4 CodiMD is highly customizable, learn about all configuration options of networking, security, performance, resources, privilege, privacy, image storage, and authentication in [CodiMD Configuration](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-configuration).
- ![Firefox](http://browserbadge.com/firefox/44/18px) Firefox >= 44
- ![IE](http://browserbadge.com/ie/9/18px) IE >= 9, Edge >= 12 ### Upgrading and Migration
- ![Opera](http://browserbadge.com/opera/34/18px) Opera >= 34, Opera Mini not supported Upgrade CodiMD from previous version? See [this guide](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-upgrade)
Migrating from Etherpad? Follow [this guide](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-migration-etherpad)
### Developer
Join our contributor community! Start from deploying [CodiMD manually](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-manual-deployment), [connecting to your own database](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-db-connection), [learn about the project structure](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-project-structure), to [build your changes](https://hackmd.io/c/codimd-documentation/%2Fs%2Fcodimd-webpack) with the help of webpack.
## Contribution and Discussion
All contributions are welcome! Even asking a question helps.
| Project | Contribution Types | Contribution Venue |
| ------- | ------------------ | ------------------ |
|**CodiMD**|:couple: Community chat|[Gitter][gitter-url]|
||:bug: Issues, bugs, and feature requests|[Issue tracker](https://github.com/hackmdio/codimd/issues)|
||:books: Improve documentation|[Documentations](https://hackmd.io/c/codimd-documentation)|
||:pencil: Translation|[POEditor][poeditor-url]|
||:coffee: Donation|[Buy us coffee](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=KDGS4PREHX6QQ&lc=US&item_name=HackMD&currency_code=USD&bn=PP%2dDonationsBF%3abtn_donate_LG%2egif%3aNonHosted)|
|**HackMD**|:question: Issues related to [HackMD](https://hackmd.io/)|[Issue tracker](https://github.com/hackmdio/hackmd-io-issues/issues)|
||:pencil2: Translation|[hackmd-locales](https://github.com/hackmdio/hackmd-locales/tree/master/locales)|
## Browser Support
CodiMD is a service that runs on Node.js, while users use the service through browsers. We support your users using the following browsers:
- ![Chrome](http://browserbadge.com/chrome/47/18px)
- Chrome >= 47
- Chrome for Android >= 47
- ![Safari](http://browserbadge.com/safari/9/18px)
- Safari >= 9
- iOS Safari >= 8.4
- ![Firefox](http://browserbadge.com/firefox/44/18px)
- Firefox >= 44
- ![IE](http://browserbadge.com/ie/9/18px)
- IE >= 9
- Edge >= 12
- ![Opera](http://browserbadge.com/opera/34/18px)
- Opera >= 34
- Opera Mini not supported
- Android Browser >= 4.4 - Android Browser >= 4.4
# Installation To stay up to date with your installation it's recommended to subscribe the [release feed][github-release-feed].
## Getting started (Native install) ## License
### Prerequisite
- Node.js 6.x or up (test up to 7.5.0) and <10.x
- Database (PostgreSQL, MySQL, MariaDB, SQLite, MSSQL) use charset `utf8`
- npm (and its dependencies, especially [uWebSockets](https://github.com/uWebSockets/uWebSockets#nodejs-developers), [node-gyp](https://github.com/nodejs/node-gyp#installation))
- For **building** CodiMD we recommend to use a machine with at least **2GB** RAM
### Instructions
1. Download a release and unzip or clone into a directory
2. Enter the directory and type `bin/setup`, which will install npm dependencies and create configs. The setup script is written in Bash, you would need bash as a prerequisite.
3. Setup the configs, see more below
4. Setup environment variables which will overwrite the configs
5. Build front-end bundle by `npm run build` (use `npm run dev` if you are in development)
6. Modify the file named `.sequelizerc`, change the value of the variable `url` with your db connection string
For example: `postgres://username:password@localhost:5432/codimd`
7. Run `node_modules/.bin/sequelize db:migrate`, this step will migrate your db to the latest schema
8. Run the server as you like (node, forever, pm2)
To stay up to date with your installation it's recommended to join our [Matrix channel][matrix.org-url] or subscribe to the [release feed][github-release-feed].
## Heroku Deployment
You can quickly setup a sample Heroku CodiMD application by clicking the button below.
[![Deploy on Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/hackmdio/codimd/tree/master)
If you deploy it without the button, keep in mind to use the right buildpacks. For details check `app.json`.
## Kubernetes
To install use `helm install stable/hackmd`.
For all further details, please check out the offical CodiMD [K8s helm chart](https://github.com/kubernetes/charts/tree/master/stable/hackmd).
## CodiMD by docker container
[![Try in PWD](https://cdn.rawgit.com/play-with-docker/stacks/cff22438/assets/images/button.png)](http://play-with-docker.com?stack=https://github.com/hackmdio/codimd-container/raw/master/docker-compose.yml&stack_name=codimd)
**Debian-based version:**
[![latest](https://images.microbadger.com/badges/version/hackmdio/hackmd:latest.svg)](https://microbadger.com/images/hackmdio/hackmd "Get your own version badge on microbadger.com") [![](https://images.microbadger.com/badges/image/hackmdio/hackmd:latest.svg)](https://microbadger.com/images/hackmdio/hackmd "Get your own image badge on microbadger.com")
**Alpine-based version:**
[![alpine](https://images.microbadger.com/badges/version/hackmdio/hackmd:alpine.svg)](https://microbadger.com/images/hackmdio/hackmd:alpine "Get your own version badge on microbadger.com") [![](https://images.microbadger.com/badges/image/hackmdio/hackmd:alpine.svg)](https://microbadger.com/images/hackmdio/hackmd:alpine "Get your own image badge on microbadger.com")
The easiest way to setup CodiMD using docker are using the following three commands:
```console
git clone https://github.com/hackmdio/codimd-container.git
cd codimd-container
docker-compose up
```
Read more about it in the [container repository…](https://github.com/hackmdio/codimd-container)
## Cloudron
Install CodiMD on [Cloudron](https://cloudron.io):
[![Install](https://cloudron.io/img/button.svg)](https://cloudron.io/button.html?app=io.hackmd.cloudronapp)
# Upgrade
## Native setup
If you are upgrading CodiMD from an older version, follow these steps:
1. Fully stop your old server first (important)
2. `git pull` or do whatever that updates the files
3. `npm install` to update dependencies
4. Build front-end bundle by `npm run build` (use `npm run dev` if you are in development)
5. Modify the file named `.sequelizerc`, change the value of the variable `url` with your db connection string
For example: `postgres://username:password@localhost:5432/codimd`
6. Run `node_modules/.bin/sequelize db:migrate`, this step will migrate your db to the latest schema
7. Start your whole new server!
To stay up to date with your installation it's recommended to join our [Matrix channel][matrix.org-url] or subscribe to the [release feed][github-release-feed].
* **migrate-to-1.1.0**
We deprecated the older lower case config style and moved on to camel case style. Please have a look at the current `config.json.example` and check the warnings on startup.
*Notice: This is not a breaking change right now but in the future*
* [**migration-to-0.5.0**](https://github.com/hackmdio/migration-to-0.5.0)
We don't use LZString to compress socket.io data and DB data after version 0.5.0.
Please run the migration tool if you're upgrading from the old version.
* [**migration-to-0.4.0**](https://github.com/hackmdio/migration-to-0.4.0)
We've dropped MongoDB after version 0.4.0.
So here is the migration tool for you to transfer the old DB data to the new DB.
This tool is also used for official service.
# Configuration
There are some config settings you need to change in the files below.
```
./config.json ----application settings
```
## Environment variables (will overwrite other server configs)
| variables | example values | description |
| --------- | ------ | ----------- |
| `NODE_ENV` | `production` or `development` | set current environment (will apply corresponding settings in the `config.json`) |
| `DEBUG` | `true` or `false` | set debug mode; show more logs |
| `CMD_CONFIG_FILE` | `/path/to/config.json` | optional override for the path to CodiMD's config file |
| `CMD_DOMAIN` | `codimd.org` | domain name |
| `CMD_URL_PATH` | `codimd` | sub URL path, like `www.example.com/<URL_PATH>` |
| `CMD_HOST` | `localhost` | host to listen on |
| `CMD_PORT` | `80` | web app port |
| `CMD_PATH` | `/var/run/codimd.sock` | path to UNIX domain socket to listen on (if specified, `CMD_HOST` and `CMD_PORT` are ignored) |
| `CMD_LOGLEVEL` | `info` | Defines what kind of logs are provided to stdout. |
| `CMD_ALLOW_ORIGIN` | `localhost, codimd.org` | domain name whitelist (use comma to separate) |
| `CMD_PROTOCOL_USESSL` | `true` or `false` | set to use SSL protocol for resources path (only applied when domain is set) |
| `CMD_URL_ADDPORT` | `true` or `false` | set to add port on callback URL (ports `80` or `443` won't be applied) (only applied when domain is set) |
| `CMD_USECDN` | `true` or `false` | set to use CDN resources or not (default is `true`) |
| `CMD_ALLOW_ANONYMOUS` | `true` or `false` | set to allow anonymous usage (default is `true`) |
| `CMD_ALLOW_ANONYMOUS_EDITS` | `true` or `false` | if `allowAnonymous` is `true`, allow users to select `freely` permission, allowing guests to edit existing notes (default is `false`) |
| `CMD_ALLOW_FREEURL` | `true` or `false` | set to allow new note creation by accessing a nonexistent note URL |
| `CMD_FORBIDDEN_NODE_IDS` | `'robots.txt'` | disallow creation of notes, even if `CMD_ALLOW_FREEURL` is `true` |
| `CMD_DEFAULT_PERMISSION` | `freely`, `editable`, `limited`, `locked` or `private` | set notes default permission (only applied on signed users) |
| `CMD_DB_URL` | `mysql://localhost:3306/database` | set the database URL |
| `CMD_SESSION_SECRET` | no example | Secret used to sign the session cookie. If non is set, one will randomly generated on startup |
| `CMD_SESSION_LIFE` | `1209600000` | Session life time. (milliseconds) |
| `CMD_FACEBOOK_CLIENTID` | no example | Facebook API client id |
| `CMD_FACEBOOK_CLIENTSECRET` | no example | Facebook API client secret |
| `CMD_TWITTER_CONSUMERKEY` | no example | Twitter API consumer key |
| `CMD_TWITTER_CONSUMERSECRET` | no example | Twitter API consumer secret |
| `CMD_GITHUB_CLIENTID` | no example | GitHub API client id |
| `CMD_GITHUB_CLIENTSECRET` | no example | GitHub API client secret |
| `CMD_GITLAB_SCOPE` | `read_user` or `api` | GitLab API requested scope (default is `api`) (GitLab snippet import/export need `api` scope) |
| `CMD_GITLAB_BASEURL` | no example | GitLab authentication endpoint, set to use other endpoint than GitLab.com (optional) |
| `CMD_GITLAB_CLIENTID` | no example | GitLab API client id |
| `CMD_GITLAB_CLIENTSECRET` | no example | GitLab API client secret |
| `CMD_GITLAB_VERSION` | no example | GitLab API version (v3 or v4) |
| `CMD_MATTERMOST_BASEURL` | no example | Mattermost authentication endpoint for versions below 5.0. For Mattermost version 5.0 and above, see [guide](docs/guides/auth/mattermost-self-hosted.md). |
| `CMD_MATTERMOST_CLIENTID` | no example | Mattermost API client id |
| `CMD_MATTERMOST_CLIENTSECRET` | no example | Mattermost API client secret |
| `CMD_DROPBOX_CLIENTID` | no example | Dropbox API client id |
| `CMD_DROPBOX_CLIENTSECRET` | no example | Dropbox API client secret |
| `CMD_GOOGLE_CLIENTID` | no example | Google API client id |
| `CMD_GOOGLE_CLIENTSECRET` | no example | Google API client secret |
| `CMD_LDAP_URL` | `ldap://example.com` | URL of LDAP server |
| `CMD_LDAP_BINDDN` | no example | bindDn for LDAP access |
| `CMD_LDAP_BINDCREDENTIALS` | no example | bindCredentials for LDAP access |
| `CMD_LDAP_SEARCHBASE` | `o=users,dc=example,dc=com` | LDAP directory to begin search from |
| `CMD_LDAP_SEARCHFILTER` | `(uid={{username}})` | LDAP filter to search with |
| `CMD_LDAP_SEARCHATTRIBUTES` | `displayName, mail` | LDAP attributes to search with (use comma to separate) |
| `CMD_LDAP_USERIDFIELD` | `uidNumber` or `uid` or `sAMAccountName` | The LDAP field which is used uniquely identify a user on CodiMD |
| `CMD_LDAP_USERNAMEFIELD` | Fallback to userid | The LDAP field which is used as the username on CodiMD |
| `CMD_LDAP_TLS_CA` | `server-cert.pem, root.pem` | Root CA for LDAP TLS in PEM format (use comma to separate) |
| `CMD_LDAP_PROVIDERNAME` | `My institution` | Optional name to be displayed at login form indicating the LDAP provider |
| `CMD_SAML_IDPSSOURL` | `https://idp.example.com/sso` | authentication endpoint of IdP. for details, see [guide](docs/guides/auth/saml-onelogin.md). |
| `CMD_SAML_IDPCERT` | `/path/to/cert.pem` | certificate file path of IdP in PEM format |
| `CMD_SAML_ISSUER` | no example | identity of the service provider (optional, default: serverurl)" |
| `CMD_SAML_IDENTIFIERFORMAT` | no example | name identifier format (optional, default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`) |
| `CMD_SAML_GROUPATTRIBUTE` | `memberOf` | attribute name for group list (optional) |
| `CMD_SAML_REQUIREDGROUPS` | `Hackmd-users` | group names that allowed (use vertical bar to separate) (optional) |
| `CMD_SAML_EXTERNALGROUPS` | `Temporary-staff` | group names that not allowed (use vertical bar to separate) (optional) |
| `CMD_SAML_ATTRIBUTE_ID` | `sAMAccountName` | attribute map for `id` (optional, default: NameID of SAML response) |
| `CMD_SAML_ATTRIBUTE_USERNAME` | `mailNickname` | attribute map for `username` (optional, default: NameID of SAML response) |
| `CMD_SAML_ATTRIBUTE_EMAIL` | `mail` | attribute map for `email` (optional, default: NameID of SAML response if `CMD_SAML_IDENTIFIERFORMAT` is default) |
| `CMD_OAUTH2_USER_PROFILE_URL` | `https://example.com` | where retrieve information about a user after succesful login. Needs to output JSON. (no default value) Refer to the [Mattermost](docs/guides/auth/mattermost-self-hosted.md) or [Nextcloud](docs/guides/auth/nextcloud.md) examples for more details on all of the `CMD_OAUTH2...` options. |
| `CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR` | `name` | where to find the username in the JSON from the user profile URL. (no default value)|
| `CMD_OAUTH2_USER_PROFILE_DISPLAY_NAME_ATTR` | `display-name` | where to find the display-name in the JSON from the user profile URL. (no default value) |
| `CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR` | `email` | where to find the email address in the JSON from the user profile URL. (no default value) |
| `CMD_OAUTH2_TOKEN_URL` | `https://example.com` | sometimes called token endpoint, please refer to the documentation of your OAuth2 provider (no default value) |
| `CMD_OAUTH2_AUTHORIZATION_URL` | `https://example.com` | authorization URL of your provider, please refer to the documentation of your OAuth2 provider (no default value) |
| `CMD_OAUTH2_CLIENT_ID` | `afae02fckafd...` | you will get this from your OAuth2 provider when you register CodiMD as OAuth2-client, (no default value) |
| `CMD_OAUTH2_CLIENT_SECRET` | `afae02fckafd...` | you will get this from your OAuth2 provider when you register CodiMD as OAuth2-client, (no default value) |
| `CMD_OAUTH2_PROVIDERNAME` | `My institution` | Optional name to be displayed at login form indicating the oAuth2 provider |
| `CMD_IMGUR_CLIENTID` | no example | Imgur API client id |
| `CMD_EMAIL` | `true` or `false` | set to allow email signin |
| `CMD_ALLOW_PDF_EXPORT` | `true` or `false` | Enable or disable PDF exports |
| `CMD_ALLOW_EMAIL_REGISTER` | `true` or `false` | set to allow email register (only applied when email is set, default is `true`. Note `bin/manage_users` might help you if registration is `false`.) |
| `CMD_ALLOW_GRAVATAR` | `true` or `false` | set to `false` to disable gravatar as profile picture source on your instance |
| `CMD_IMAGE_UPLOAD_TYPE` | `imgur`, `s3`, `minio` or `filesystem` | Where to upload images. For S3, see our Image Upload Guides for [S3](docs/guides/s3-image-upload.md) or [Minio](docs/guides/minio-image-upload.md) |
| `CMD_S3_ACCESS_KEY_ID` | no example | AWS access key id |
| `CMD_S3_SECRET_ACCESS_KEY` | no example | AWS secret key |
| `CMD_S3_REGION` | `ap-northeast-1` | AWS S3 region |
| `CMD_S3_BUCKET` | no example | AWS S3 bucket name |
| `CMD_MINIO_ACCESS_KEY` | no example | Minio access key |
| `CMD_MINIO_SECRET_KEY` | no example | Minio secret key |
| `CMD_MINIO_ENDPOINT` | `minio.example.org` | Address of your Minio endpoint/instance |
| `CMD_MINIO_PORT` | `9000` | Port that is used for your Minio instance |
| `CMD_MINIO_SECURE` | `true` | If set to `true` HTTPS is used for Minio |
| `CMD_AZURE_CONNECTION_STRING` | no example | Azure Blob Storage connection string |
| `CMD_AZURE_CONTAINER` | no example | Azure Blob Storage container name (automatically created if non existent) |
| `CMD_HSTS_ENABLE` | ` true` | set to enable [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) if HTTPS is also enabled (default is ` true`) |
| `CMD_HSTS_INCLUDE_SUBDOMAINS` | `true` | set to include subdomains in HSTS (default is `true`) |
| `CMD_HSTS_MAX_AGE` | `31536000` | max duration in seconds to tell clients to keep HSTS status (default is a year) |
| `CMD_HSTS_PRELOAD` | `true` | whether to allow preloading of the site's HSTS status (e.g. into browsers) |
| `CMD_CSP_ENABLE` | `true` | whether to enable Content Security Policy (directives cannot be configured with environment variables) |
| `CMD_CSP_REPORTURI` | `https://<someid>.report-uri.com/r/d/csp/enforce` | Allows to add a URL for CSP reports in case of violations |
| `CMD_SOURCE_URL` | `https://github.com/hackmdio/codimd/tree/<current commit>` | Provides the link to the source code of CodiMD on the entry page (Please, make sure you change this when you run a modified version) |
***Note:** Due to the rename process we renamed all `HMD_`-prefix variables to be `CMD_`-prefixed. The old ones continue to work.*
## Application settings `config.json`
| variables | example values | description |
| --------- | ------ | ----------- |
| `debug` | `true` or `false` | set debug mode, show more logs |
| `domain` | `localhost` | domain name |
| `urlPath` | `codimd` | sub URL path, like `www.example.com/<urlpath>` |
| `host` | `localhost` | host to listen on |
| `port` | `80` | web app port |
| `path` | `/var/run/codimd.sock` | path to UNIX domain socket to listen on (if specified, `host` and `port` are ignored) |
| `loglevel` | `info` | Defines what kind of logs are provided to stdout. |
| `allowOrigin` | `['localhost']` | domain name whitelist |
| `useSSL` | `true` or `false` | set to use SSL server (if `true`, will auto turn on `protocolUseSSL`) |
| `hsts` | `{"enable": true, "maxAgeSeconds": 31536000, "includeSubdomains": true, "preload": true}` | [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) options to use with HTTPS (default is the example value, max age is a year) |
| `csp` | `{"enable": true, "directives": {"scriptSrc": "trustworthy-scripts.example.com"}, "upgradeInsecureRequests": "auto", "addDefaults": true}` | Configures [Content Security Policy](https://helmetjs.github.io/docs/csp/). Directives are passed to Helmet - see [their documentation](https://helmetjs.github.io/docs/csp/) for more information on the format. Some defaults are added to the configured values so that the application doesn't break. To disable this behaviour, set `addDefaults` to `false`. Further, if `usecdn` is on, some CDN locations are allowed too. By default (`auto`), insecure (HTTP) requests are upgraded to HTTPS via CSP if `useSSL` is on. To change this behaviour, set `upgradeInsecureRequests` to either `true` or `false`. |
| `protocolUseSSL` | `true` or `false` | set to use SSL protocol for resources path (only applied when domain is set) |
| `urlAddPort` | `true` or `false` | set to add port on callback URL (ports `80` or `443` won't be applied) (only applied when domain is set) |
| `useCDN` | `true` or `false` | set to use CDN resources or not (default is `true`) |
| `allowAnonymous` | `true` or `false` | set to allow anonymous usage (default is `true`) |
| `allowAnonymousEdits` | `true` or `false` | if `allowAnonymous` is `true`: allow users to select `freely` permission, allowing guests to edit existing notes (default is `false`) |
| `allowFreeURL` | `true` or `false` | set to allow new note creation by accessing a nonexistent note URL |
| `forbiddenNoteIDs` | `['robots.txt']` | disallow creation of notes, even if `allowFreeUrl` is `true` |
| `defaultPermission` | `freely`, `editable`, `limited`, `locked`, `protected` or `private` | set notes default permission (only applied on signed users) |
| `dbURL` | `mysql://localhost:3306/database` | set the db URL; if set, then db config (below) won't be applied |
| `db` | `{ "dialect": "sqlite", "storage": "./db.codimd.sqlite" }` | set the db configs, [see more here](http://sequelize.readthedocs.org/en/latest/api/sequelize/) |
| `sslKeyPath` | `./cert/client.key` | SSL key path<sup>1</sup> (only need when you set `useSSL`) |
| `sslCertPath` | `./cert/codimd_io.crt` | SSL cert path<sup>1</sup> (only need when you set `useSSL`) |
| `sslCAPath` | `['./cert/COMODORSAAddTrustCA.crt']` | SSL ca chain<sup>1</sup> (only need when you set `useSSL`) |
| `dhParamPath` | `./cert/dhparam.pem` | SSL dhparam path<sup>1</sup> (only need when you set `useSSL`) |
| `tmpPath` | `./tmp/` | temp directory path<sup>1</sup> |
| `defaultNotePath` | `./public/default.md` | default note file path<sup>1</sup> |
| `docsPath` | `./public/docs` | docs directory path<sup>1</sup> |
| `viewPath` | `./public/views` | template directory path<sup>1</sup> |
| `uploadsPath` | `./public/uploads` | uploads directory<sup>1</sup> - needs to be persistent when you use imageUploadType `filesystem` |
| `sessionName` | `connect.sid` | cookie session name |
| `sessionSecret` | `secret` | cookie session secret |
| `sessionLife` | `14 * 24 * 60 * 60 * 1000` | cookie session life |
| `staticCacheTime` | `1 * 24 * 60 * 60 * 1000` | static file cache time |
| `heartbeatInterval` | `5000` | socket.io heartbeat interval |
| `heartbeatTimeout` | `10000` | socket.io heartbeat timeout |
| `documentMaxLength` | `100000` | note max length |
| `email` | `true` or `false` | set to allow email signin |
| `oauth2` | `{baseURL: ..., userProfileURL: ..., userProfileUsernameAttr: ..., userProfileDisplayNameAttr: ..., userProfileEmailAttr: ..., tokenURL: ..., authorizationURL: ..., clientID: ..., clientSecret: ...}` | An object detailing your OAuth2 provider. Refer to the [Mattermost](docs/guides/auth/mattermost-self-hosted.md) or [Nextcloud](docs/guides/auth/nextcloud.md) examples for more details!|
| `allowEmailRegister` | `true` or `false` | set to allow email register (only applied when email is set, default is `true`. Note `bin/manage_users` might help you if registration is `false`.) |
| `allowGravatar` | `true` or `false` | set to `false` to disable gravatar as profile picture source on your instance |
| `imageUploadType` | `imgur`, `s3`, `minio`, `azure`, `lutim` or `filesystem`(default) | Where to upload images. For S3, see our Image Upload Guides for [S3](docs/guides/s3-image-upload.md) or [Minio](docs/guides/minio-image-upload.md)|
|`lutim`| `{"url": "YOUR_LUTIM_URL"}`| When `imageUploadType` is set to `lutim`, you can setup the lutim url|
| `minio` | `{ "accessKey": "YOUR_MINIO_ACCESS_KEY", "secretKey": "YOUR_MINIO_SECRET_KEY", "endpoint": "YOUR_MINIO_HOST", port: 9000, secure: true }` | When `imageUploadType` is set to `minio`, you need to set this key. Also checkout our [Minio Image Upload Guide](docs/guides/minio-image-upload.md) |
| `s3` | `{ "accessKeyId": "YOUR_S3_ACCESS_KEY_ID", "secretAccessKey": "YOUR_S3_ACCESS_KEY", "region": "YOUR_S3_REGION" }` | When `imageuploadtype` be set to `s3`, you would also need to setup this key, check our [S3 Image Upload Guide](docs/guides/s3-image-upload.md) |
| `s3bucket` | `YOUR_S3_BUCKET_NAME` | bucket name when `imageUploadType` is set to `s3` or `minio` |
| `sourceURL` | `https://github.com/hackmdio/codimd/tree/<current commit>` | Provides the link to the source code of CodiMD on the entry page (Please, make sure you change this when you run a modified version) |
<sup>1</sup>: relative paths are based on CodiMD's base directory
## Third-party integration API key settings
| service | settings location | description |
| ------- | --------- | ----------- |
| facebook, twitter, github, gitlab, mattermost, dropbox, google, ldap, saml | environment variables or `config.json` | for signin |
| imgur, s3, minio, azure | environment variables or `config.json` | for image upload |
| dropbox(`dropbox/appKey`) | `config.json` | for export and import |
## Third-party integration OAuth callback URLs
| service | callback URL (after the server URL) |
| ------- | --------- |
| facebook | `/auth/facebook/callback` |
| twitter | `/auth/twitter/callback` |
| github | `/auth/github/callback` |
| gitlab | `/auth/gitlab/callback` |
| mattermost | `/auth/mattermost/callback` |
| dropbox | `/auth/dropbox/callback` |
| google | `/auth/google/callback` |
| saml | `/auth/saml/callback` |
# Developer Notes
## Structure
```text
codimd/
├── tmp/ --- temporary files
├── docs/ --- document files
├── lib/ --- server libraries
└── public/ --- client files
├── css/ --- css styles
├── js/ --- js scripts
├── vendor/ --- vendor includes
└── views/ --- view templates
```
## Operational Transformation
From 0.3.2, we started supporting operational transformation.
It makes concurrent editing safe and will not break up other users' operations.
Additionally, now can show other clients' selections.
See more at [http://operational-transformation.github.io/](http://operational-transformation.github.io/)
# License
**License under AGPL.** **License under AGPL.**
[matrix.org-image]: https://img.shields.io/badge/Matrix.org-%23CodiMD@matrix.org-green.svg [gitter-image]: https://img.shields.io/badge/gitter-hackmdio/codimd-blue.svg
[matrix.org-url]: https://riot.im/app/#/room/#codimd:matrix.org [gitter-url]: https://gitter.im/hackmdio/hackmd
[travis-image]: https://travis-ci.org/hackmdio/codimd.svg?branch=master [travis-image]: https://travis-ci.com/hackmdio/codimd.svg?branch=master
[travis-url]: https://travis-ci.org/hackmdio/codimd [travis-url]: https://travis-ci.com/hackmdio/codimd
[github-version-badge]: https://img.shields.io/github/release/hackmdio/codimd.svg [github-version-badge]: https://img.shields.io/github/release/hackmdio/codimd.svg
[github-release-page]: https://github.com/hackmdio/codimd/releases [github-release-page]: https://github.com/hackmdio/codimd/releases
[github-release-feed]: https://github.com/hackmdio/codimd/releases.atom [github-release-feed]: https://github.com/hackmdio/codimd/releases.atom
[poeditor-image]: https://img.shields.io/badge/POEditor-translate-blue.svg [poeditor-image]: https://img.shields.io/badge/POEditor-translate-blue.svg
[poeditor-url]: https://poeditor.com/join/project/1OpGjF2Jir [poeditor-url]: https://poeditor.com/join/project/q0nuPWyztp

57
app.js
View File

@ -7,7 +7,6 @@ var ejs = require('ejs')
var passport = require('passport') var passport = require('passport')
var methodOverride = require('method-override') var methodOverride = require('method-override')
var cookieParser = require('cookie-parser') var cookieParser = require('cookie-parser')
var compression = require('compression')
var session = require('express-session') var session = require('express-session')
var SequelizeStore = require('connect-session-sequelize')(session.Store) var SequelizeStore = require('connect-session-sequelize')(session.Store)
var fs = require('fs') var fs = require('fs')
@ -26,30 +25,33 @@ var response = require('./lib/response')
var models = require('./lib/models') var models = require('./lib/models')
var csp = require('./lib/csp') var csp = require('./lib/csp')
function createHttpServer () {
if (config.useSSL) {
const ca = (function () {
let i, len, results
results = []
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
}
return results
})()
const options = {
key: fs.readFileSync(config.sslKeyPath, 'utf8'),
cert: fs.readFileSync(config.sslCertPath, 'utf8'),
ca: ca,
dhparam: fs.readFileSync(config.dhParamPath, 'utf8'),
requestCert: false,
rejectUnauthorized: false
}
return require('https').createServer(options, app)
} else {
return require('http').createServer(app)
}
}
// server setup // server setup
var app = express() var app = express()
var server = null var server = createHttpServer()
if (config.useSSL) {
var ca = (function () {
var i, len, results
results = []
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
}
return results
})()
var options = {
key: fs.readFileSync(config.sslKeyPath, 'utf8'),
cert: fs.readFileSync(config.sslCertPath, 'utf8'),
ca: ca,
dhparam: fs.readFileSync(config.dhParamPath, 'utf8'),
requestCert: false,
rejectUnauthorized: false
}
server = require('https').createServer(options, app)
} else {
server = require('http').createServer(app)
}
// logger // logger
app.use(morgan('combined', { app.use(morgan('combined', {
@ -77,9 +79,6 @@ var sessionStore = new SequelizeStore({
db: models.sequelize db: models.sequelize
}) })
// compression
app.use(compression())
// use hsts to tell https users stick to this // use hsts to tell https users stick to this
if (config.hsts.enable) { if (config.hsts.enable) {
app.use(helmet.hsts({ app.use(helmet.hsts({
@ -113,7 +112,7 @@ if (config.csp.enable) {
} }
i18n.configure({ i18n.configure({
locales: ['en', 'zh-CN', 'zh-TW', 'fr', 'de', 'ja', 'es', 'ca', 'el', 'pt', 'it', 'tr', 'ru', 'nl', 'hr', 'pl', 'uk', 'hi', 'sv', 'eo', 'da', 'ko', 'id'], locales: ['en', 'zh-CN', 'zh-TW', 'fr', 'de', 'ja', 'es', 'ca', 'el', 'pt', 'it', 'tr', 'ru', 'nl', 'hr', 'pl', 'uk', 'hi', 'sv', 'eo', 'da', 'ko', 'id', 'sr'],
cookie: 'locale', cookie: 'locale',
directory: path.join(__dirname, '/locales'), directory: path.join(__dirname, '/locales'),
updateFiles: config.updateI18nFiles updateFiles: config.updateI18nFiles
@ -279,6 +278,7 @@ process.on('uncaughtException', function (err) {
function handleTermSignals () { function handleTermSignals () {
logger.info('CodiMD has been killed by signal, try to exit gracefully...') logger.info('CodiMD has been killed by signal, try to exit gracefully...')
realtime.maintenance = true realtime.maintenance = true
realtime.terminate()
// disconnect all socket.io clients // disconnect all socket.io clients
Object.keys(io.sockets.sockets).forEach(function (key) { Object.keys(io.sockets.sockets).forEach(function (key) {
var socket = io.sockets.sockets[key] var socket = io.sockets.sockets[key]
@ -299,6 +299,9 @@ function handleTermSignals () {
}) })
} }
}, 100) }, 100)
setTimeout(() => {
process.exit(1)
}, 5000)
} }
process.on('SIGINT', handleTermSignals) process.on('SIGINT', handleTermSignals)
process.on('SIGTERM', handleTermSignals) process.on('SIGTERM', handleTermSignals)

View File

@ -6,7 +6,7 @@
"Markdown", "Markdown",
"Notes" "Notes"
], ],
"website": "https://hackmd.io", "website": "https://github.com/hackmdio/codimd",
"repository": "https://github.com/hackmdio/codimd", "repository": "https://github.com/hackmdio/codimd",
"logo": "https://github.com/hackmdio/codimd/raw/master/public/codimd-icon-1024.png", "logo": "https://github.com/hackmdio/codimd/raw/master/public/codimd-icon-1024.png",
"success_url": "/", "success_url": "/",

View File

@ -1,119 +1,117 @@
#!/usr/bin/env node #!/usr/bin/env node
// First configure the logger so it does not spam the console // First configure the logger so it does not spam the console
const logger = require("../lib/logger"); const logger = require('../lib/logger')
logger.transports.forEach((transport) => transport.level = "warning") logger.transports.forEach((transport) => {
transport.level = 'warning'
})
const models = require("../lib/models/"); const models = require('../lib/models/')
const readline = require("readline-sync"); const readline = require('readline-sync')
const minimist = require("minimist"); const minimist = require('minimist')
function showUsage(tips) { function showUsage (tips) {
console.log(`${tips} console.log(`${tips}
Command-line utility to create users for email-signin. Command-line utility to create users for email-signin.
Usage: bin/manage_users [--pass password] (--add | --del) user-email Usage: bin/manage_users [--pass password] (--add | --del) user-email
Options: Options:
--add Add user with the specified user-email --add\tAdd user with the specified user-email
--del Delete user with specified user-email --del\tDelete user with specified user-email
--reset Reset user password with specified user-email --reset\tReset user password with specified user-email
--pass Use password from cmdline rather than prompting --pass\tUse password from cmdline rather than prompting
`); `)
process.exit(1); process.exit(1)
} }
function getPass(argv, action) { function getPass (argv, action) {
// Find whether we use cmdline or prompt password // Find whether we use cmdline or prompt password
if(typeof argv["pass"] !== 'string') { if (typeof argv['pass'] !== 'string') {
return readline.question(`Password for ${argv[action]}:`, {hideEchoBack: true}); return readline.question(`Password for ${argv[action]}:`, { hideEchoBack: true })
} }
console.log("Using password from commandline..."); console.log('Using password from commandline...')
return argv["pass"]; return argv['pass']
} }
// Using an async function to be able to use await inside // Using an async function to be able to use await inside
async function createUser(argv) { async function createUser (argv) {
const existing_user = await models.User.findOne({where: {email: argv["add"]}}); const existingUser = await models.User.findOne({ where: { email: argv['add'] } })
// Cannot create already-existing users // Cannot create already-existing users
if(existing_user != undefined) { if (existingUser !== undefined) {
console.log(`User with e-mail ${existing_user.email} already exists! Aborting ...`); console.log(`User with e-mail ${existingUser.email} already exists! Aborting ...`)
process.exit(1); process.exit(1)
} }
const pass = getPass(argv, "add"); const pass = getPass(argv, 'add')
// Lets try to create, and check success
// Lets try to create, and check success const ref = await models.User.create({ email: argv['add'], password: pass })
const ref = await models.User.create({email: argv["add"], password: pass}); if (ref === undefined) {
if(ref == undefined) { console.log(`Could not create user with email ${argv['add']}`)
console.log(`Could not create user with email ${argv["add"]}`); process.exit(1)
process.exit(1); } else { console.log(`Created user with email ${argv['add']}`) }
} else
console.log(`Created user with email ${argv["add"]}`);
} }
// Using an async function to be able to use await inside // Using an async function to be able to use await inside
async function deleteUser(argv) { async function deleteUser (argv) {
// Cannot delete non-existing users // Cannot delete non-existing users
const existing_user = await models.User.findOne({where: {email: argv["del"]}}); const existingUser = await models.User.findOne({ where: { email: argv['del'] } })
if(existing_user === undefined) { if (existingUser === undefined) {
console.log(`User with e-mail ${argv["del"]} does not exist, cannot delete`); console.log(`User with e-mail ${argv['del']} does not exist, cannot delete`)
process.exit(1); process.exit(1)
} }
// Sadly .destroy() does not return any success value with all // Sadly .destroy() does not return any success value with all
// backends. See sequelize #4124 // backends. See sequelize #4124
await existing_user.destroy(); await existingUser.destroy()
console.log(`Deleted user ${argv["del"]} ...`); console.log(`Deleted user ${argv['del']} ...`)
} }
// Using an async function to be able to use await inside // Using an async function to be able to use await inside
async function resetUser(argv) { async function resetUser (argv) {
const existing_user = await models.User.findOne({where: {email: argv["reset"]}}); const existingUser = await models.User.findOne({ where: { email: argv['reset'] } })
// Cannot reset non-existing users // Cannot reset non-existing users
if(existing_user == undefined) { if (existingUser === undefined) {
console.log(`User with e-mail ${argv["reset"]} does not exist, cannot reset`); console.log(`User with e-mail ${argv['reset']} does not exist, cannot reset`)
process.exit(1); process.exit(1)
} }
const pass = getPass(argv, "reset"); const pass = getPass(argv, 'reset')
// set password and save // set password and save
existing_user.password = pass; existingUser.password = pass
await existing_user.save(); await existingUser.save()
console.log(`User with email ${argv["reset"]} password has been reset`); console.log(`User with email ${argv['reset']} password has been reset`)
} }
const options = { const options = {
add: createUser, add: createUser,
del: deleteUser, del: deleteUser,
reset: resetUser, reset: resetUser
}; }
// Perform commandline-parsing // Perform commandline-parsing
const argv = minimist(process.argv.slice(2)); const argv = minimist(process.argv.slice(2))
const keys = Object.keys(options); const keys = Object.keys(options)
const opts = keys.filter((key) => argv[key] !== undefined); const opts = keys.filter((key) => argv[key] !== undefined)
const action = opts[0]; const action = opts[0]
// Check for options missing // Check for options missing
if (opts.length === 0) { if (opts.length === 0) {
showUsage(`You did not specify either ${keys.map((key) => `--${key}`).join(' or ')}!`); showUsage(`You did not specify either ${keys.map((key) => `--${key}`).join(' or ')}!`)
} }
// Check if both are specified // Check if both are specified
if (opts.length > 1) { if (opts.length > 1) {
showUsage(`You cannot ${action.join(' and ')} at the same time!`); showUsage(`You cannot ${action.join(' and ')} at the same time!`)
} }
// Check if not string // Check if not string
if (typeof argv[action] !== 'string') { if (typeof argv[action] !== 'string') {
showUsage(`You must follow an email after --${action}`); showUsage(`You must follow an email after --${action}`)
} }
// Call respective processing functions // Call respective processing functions
options[action](argv).then(function() { options[action](argv).then(function () {
process.exit(0); process.exit(0)
}); })

View File

@ -20,7 +20,7 @@
"loglevel": "info", "loglevel": "info",
"hsts": { "hsts": {
"enable": true, "enable": true,
"maxAgeSeconds": "31536000", "maxAgeSeconds": 31536000,
"includeSubdomains": true, "includeSubdomains": true,
"preload": true "preload": true
}, },

56
deployments/Dockerfile Normal file
View File

@ -0,0 +1,56 @@
FROM node:8.15.1-jessie AS BUILD
# use multi-stage build to build frontend javascript
WORKDIR /codimd
COPY . ./
RUN yarn install --non-interactive --pure-lockfile && \
yarn build
# ----------------------------------------------------
# Runtime Stage
FROM node:8.15.1 AS RUNTIME
# build for production
ENV NODE_ENV production
ENV PATH="/home/codimd/.npm-global/bin:${PATH}"
# setup isolated user for more security
ARG USER_NAME=codimd
ARG UID=1500
ARG GID=1500
RUN set +x -ue && \
wget https://github.com/hackmdio/portchecker/releases/download/v1.0.1/portchecker-linux-amd64.tar.gz && \
tar xvf portchecker-linux-amd64.tar.gz -C /usr/local/bin && \
mv /usr/local/bin/portchecker-linux-amd64 /usr/local/bin/pcheck && \
# Add user and groupd
groupadd --gid $GID $USER_NAME && \
useradd --uid $UID --gid $USER_NAME --no-log-init --create-home $USER_NAME && \
# setup local npm global directory
mkdir /home/codimd/.npm-global && \
echo "prefix=/home/codimd/.npm-global/" > /home/codimd/.npmrc && \
# setup app dir
mkdir /codimd && \
# adjust permission
chown -R $USER_NAME:$USER_NAME /home/codimd
# Copy build stage file to runtime
COPY --from=BUILD /codimd /codimd
RUN chown -R $USER_NAME:$USER_NAME /codimd
# change running user name
USER $USER_NAME
# build project
WORKDIR /codimd
RUN set +x -ue && \
cliVer=$(cat package.json | grep sequelize-cli | awk '{print substr($1, 2, length($1) - 3)"@"substr($2, 2, length($2) - 3)}') && \
npm -g install "$cliVer" && \
yarn install --production --non-interactive --pure-lockfile && \
yarn cache clean
VOLUME /codimd/public/uploads
EXPOSE 3000
ENTRYPOINT ["/codimd/docker-entrypoint.sh"]

View File

@ -0,0 +1,7 @@
FROM node:8.15.1-jessie
WORKDIR /codimd
EXPOSE 3000
VOLUME ['/codimd/node_modules']

View File

@ -0,0 +1,25 @@
version: '3'
services:
dev-database:
image: postgres:11.2
environment:
POSTGRES_USER: codimd
POSTGRES_PASSWORD: password
POSTGRES_DB: codimd
dev-codimd:
build:
dockerfile: ./deployments/dev-Dockerfile
context: ../
environment:
CMD_DB_URL: postgres://codimd:password@dev-database/codimd
volumes:
- ../:/codimd
- node_modules:/codimd/node_modules
- public_build:/codimd/public/build
- public_view_build:/codimd/public/views/build
ports:
- 3000:3000
volumes:
node_modules:
public_build:
public_view_build:

View File

@ -0,0 +1,16 @@
version: '3'
services:
database:
image: postgres:11.2
environment:
POSTGRES_USER: codimd
POSTGRES_PASSWORD: password
POSTGRES_DB: codimd
codimd:
build:
dockerfile: ./deployments/Dockerfile
context: ../
environment:
CMD_DB_URL: postgres://codimd:password@database/codimd
ports:
- 3000:3000

View File

@ -0,0 +1,9 @@
#!/bin/bash
set -euo pipefail
pcheck -constr "$CMD_DB_URL"
sequelize db:migrate
node app.js

View File

@ -5,9 +5,9 @@ Authentication guide - SAML
The basic procedure is the same as the case of OneLogin which is mentioned in [OneLogin-Guide](./saml-onelogin.md). If you want to match your IdP, you can use more configurations as below. The basic procedure is the same as the case of OneLogin which is mentioned in [OneLogin-Guide](./saml-onelogin.md). If you want to match your IdP, you can use more configurations as below.
* If your IdP accepts metadata XML of the service provider to ease configuraion, use this url to download metadata XML. * If your IdP accepts metadata XML of the service provider to ease configuration, use this url to download metadata XML.
* {{your-serverurl}}/auth/saml/metadata * {{your-serverurl}}/auth/saml/metadata
* _Note: If not accessable from IdP, download to local once and upload to IdP._ * _Note: If not accessible from IdP, download to local once and upload to IdP._
* Change the value of `issuer`, `identifierFormat` to match your IdP. * Change the value of `issuer`, `identifierFormat` to match your IdP.
* `issuer`: A unique id to identify the application to the IdP, which is the base URL of your HackMD as default * `issuer`: A unique id to identify the application to the IdP, which is the base URL of your HackMD as default
* `identifierFormat`: A format of unique id to identify the user of IdP, which is the format based on email address as default. It is recommend that you use as below. * `identifierFormat`: A format of unique id to identify the user of IdP, which is the format based on email address as default. It is recommend that you use as below.
@ -59,7 +59,7 @@ The basic procedure is the same as the case of OneLogin which is mentioned in [O
HMD_SAML_ATTRIBUTE_EMAIL=mail HMD_SAML_ATTRIBUTE_EMAIL=mail
```` ````
* If you want to controll permission by group membership, add group attribute name and required group (allowed) or external group (not allowed). * If you want to control permission by group membership, add group attribute name and required group (allowed) or external group (not allowed).
* `groupAttribute`: An attribute name of group membership * `groupAttribute`: An attribute name of group membership
* `requiredGroups`: Group names array for allowed access to HackMD. Use vertical bar to separate for environment variables. * `requiredGroups`: Group names array for allowed access to HackMD. Use vertical bar to separate for environment variables.
* `externalGroups`: Group names array for not allowed access to HackMD. Use vertical bar to separate for environment variables. * `externalGroups`: Group names array for not allowed access to HackMD. Use vertical bar to separate for environment variables.

View File

@ -56,6 +56,8 @@ module.exports = {
// socket.io // socket.io
heartbeatInterval: 5000, heartbeatInterval: 5000,
heartbeatTimeout: 10000, heartbeatTimeout: 10000,
// toobusy-js
responseMaxLag: 70,
// document // document
documentMaxLength: 100000, documentMaxLength: 100000,
// image upload setting, available options are imgur/s3/filesystem/azure/lutim // image upload setting, available options are imgur/s3/filesystem/azure/lutim
@ -69,7 +71,8 @@ module.exports = {
s3: { s3: {
accessKeyId: undefined, accessKeyId: undefined,
secretAccessKey: undefined, secretAccessKey: undefined,
region: undefined region: undefined,
endpoint: undefined
}, },
minio: { minio: {
accessKey: undefined, accessKey: undefined,

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
const {toBooleanConfig, toArrayConfig, toIntegerConfig} = require('./utils') const { toBooleanConfig, toArrayConfig, toIntegerConfig } = require('./utils')
module.exports = { module.exports = {
sourceURL: process.env.CMD_SOURCE_URL, sourceURL: process.env.CMD_SOURCE_URL,
@ -14,7 +14,7 @@ module.exports = {
useSSL: toBooleanConfig(process.env.CMD_USESSL), useSSL: toBooleanConfig(process.env.CMD_USESSL),
hsts: { hsts: {
enable: toBooleanConfig(process.env.CMD_HSTS_ENABLE), enable: toBooleanConfig(process.env.CMD_HSTS_ENABLE),
maxAgeSeconds: process.env.CMD_HSTS_MAX_AGE, maxAgeSeconds: toIntegerConfig(process.env.CMD_HSTS_MAX_AGE),
includeSubdomains: toBooleanConfig(process.env.CMD_HSTS_INCLUDE_SUBDOMAINS), includeSubdomains: toBooleanConfig(process.env.CMD_HSTS_INCLUDE_SUBDOMAINS),
preload: toBooleanConfig(process.env.CMD_HSTS_PRELOAD) preload: toBooleanConfig(process.env.CMD_HSTS_PRELOAD)
}, },
@ -33,6 +33,7 @@ module.exports = {
dbURL: process.env.CMD_DB_URL, dbURL: process.env.CMD_DB_URL,
sessionSecret: process.env.CMD_SESSION_SECRET, sessionSecret: process.env.CMD_SESSION_SECRET,
sessionLife: toIntegerConfig(process.env.CMD_SESSION_LIFE), sessionLife: toIntegerConfig(process.env.CMD_SESSION_LIFE),
responseMaxLag: toIntegerConfig(process.env.CMD_RESPONSE_MAX_LAG),
imageUploadType: process.env.CMD_IMAGE_UPLOAD_TYPE, imageUploadType: process.env.CMD_IMAGE_UPLOAD_TYPE,
imgur: { imgur: {
clientID: process.env.CMD_IMGUR_CLIENTID clientID: process.env.CMD_IMGUR_CLIENTID
@ -40,7 +41,8 @@ module.exports = {
s3: { s3: {
accessKeyId: process.env.CMD_S3_ACCESS_KEY_ID, accessKeyId: process.env.CMD_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.CMD_S3_SECRET_ACCESS_KEY, secretAccessKey: process.env.CMD_S3_SECRET_ACCESS_KEY,
region: process.env.CMD_S3_REGION region: process.env.CMD_S3_REGION,
endpoint: process.env.CMD_S3_ENDPOINT
}, },
minio: { minio: {
accessKey: process.env.CMD_MINIO_ACCESS_KEY, accessKey: process.env.CMD_MINIO_ACCESS_KEY,

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
const {toBooleanConfig, toArrayConfig, toIntegerConfig} = require('./utils') const { toBooleanConfig, toArrayConfig, toIntegerConfig } = require('./utils')
module.exports = { module.exports = {
domain: process.env.HMD_DOMAIN, domain: process.env.HMD_DOMAIN,
@ -10,7 +10,7 @@ module.exports = {
useSSL: toBooleanConfig(process.env.HMD_USESSL), useSSL: toBooleanConfig(process.env.HMD_USESSL),
hsts: { hsts: {
enable: toBooleanConfig(process.env.HMD_HSTS_ENABLE), enable: toBooleanConfig(process.env.HMD_HSTS_ENABLE),
maxAgeSeconds: process.env.HMD_HSTS_MAX_AGE, maxAgeSeconds: toIntegerConfig(process.env.HMD_HSTS_MAX_AGE),
includeSubdomains: toBooleanConfig(process.env.HMD_HSTS_INCLUDE_SUBDOMAINS), includeSubdomains: toBooleanConfig(process.env.HMD_HSTS_INCLUDE_SUBDOMAINS),
preload: toBooleanConfig(process.env.HMD_HSTS_PRELOAD) preload: toBooleanConfig(process.env.HMD_HSTS_PRELOAD)
}, },
@ -28,6 +28,7 @@ module.exports = {
dbURL: process.env.HMD_DB_URL, dbURL: process.env.HMD_DB_URL,
sessionSecret: process.env.HMD_SESSION_SECRET, sessionSecret: process.env.HMD_SESSION_SECRET,
sessionLife: toIntegerConfig(process.env.HMD_SESSION_LIFE), sessionLife: toIntegerConfig(process.env.HMD_SESSION_LIFE),
responseMaxLag: toIntegerConfig(process.env.HMD_RESPONSE_MAX_LAG),
imageUploadType: process.env.HMD_IMAGE_UPLOAD_TYPE, imageUploadType: process.env.HMD_IMAGE_UPLOAD_TYPE,
imgur: { imgur: {
clientID: process.env.HMD_IMGUR_CLIENTID clientID: process.env.HMD_IMGUR_CLIENTID

View File

@ -4,11 +4,11 @@
const crypto = require('crypto') const crypto = require('crypto')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const {merge} = require('lodash') const { merge } = require('lodash')
const deepFreeze = require('deep-freeze') const deepFreeze = require('deep-freeze')
const {Environment, Permission} = require('./enum') const { Environment, Permission } = require('./enum')
const logger = require('../logger') const logger = require('../logger')
const {getGitCommit, getGitHubURL} = require('./utils') const { getGitCommit, getGitHubURL } = require('./utils')
const appRootPath = path.resolve(__dirname, '../../') const appRootPath = path.resolve(__dirname, '../../')
const env = process.env.NODE_ENV || Environment.development const env = process.env.NODE_ENV || Environment.development
@ -17,7 +17,7 @@ const debugConfig = {
} }
// Get version string from package.json // Get version string from package.json
const {version, repository} = require(path.join(appRootPath, 'package.json')) const { version, repository } = require(path.join(appRootPath, 'package.json'))
const commitID = getGitCommit(appRootPath) const commitID = getGitCommit(appRootPath)
const sourceURL = getGitHubURL(repository.url, commitID || version) const sourceURL = getGitHubURL(repository.url, commitID || version)
@ -128,7 +128,7 @@ if (config.gitlab && config.gitlab.version !== 'v4' && config.gitlab.version !==
config.gitlab.version = 'v4' config.gitlab.version = 'v4'
} }
// If gitlab scope is api, enable snippets Export/import // If gitlab scope is api, enable snippets Export/import
config.isGitlabSnippetsEnable = (!config.gitlab.scope || config.gitlab.scope === 'api') config.isGitlabSnippetsEnable = (!config.gitlab.scope || config.gitlab.scope === 'api') && config.isGitLabEnable
// Only update i18n files in development setups // Only update i18n files in development setups
config.updateI18nFiles = (env === Environment.development) config.updateI18nFiles = (env === Environment.development)
@ -159,8 +159,8 @@ if (Object.keys(process.env).toString().indexOf('HMD_') !== -1) {
if (config.sessionSecret === 'secret') { if (config.sessionSecret === 'secret') {
logger.warn('Session secret not set. Using random generated one. Please set `sessionSecret` in your config.js file. All users will be logged out.') logger.warn('Session secret not set. Using random generated one. Please set `sessionSecret` in your config.js file. All users will be logged out.')
config.sessionSecret = crypto.randomBytes(Math.ceil(config.sessionSecretLen / 2)) // generate crypto graphic random number config.sessionSecret = crypto.randomBytes(Math.ceil(config.sessionSecretLen / 2)) // generate crypto graphic random number
.toString('hex') // convert to hexadecimal format .toString('hex') // convert to hexadecimal format
.slice(0, config.sessionSecretLen) // return required number of characters .slice(0, config.sessionSecretLen) // return required number of characters
} }
// Validate upload upload providers // Validate upload upload providers

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
const {toBooleanConfig} = require('./utils') const { toBooleanConfig } = require('./utils')
module.exports = { module.exports = {
debug: toBooleanConfig(process.env.DEBUG), debug: toBooleanConfig(process.env.DEBUG),

View File

@ -1,7 +1,7 @@
'use strict' 'use strict'
// history // history
// external modules // external modules
var LZString = require('lz-string') var LZString = require('@hackmd/lz-string')
// core // core
var config = require('./config') var config = require('./config')

View File

@ -30,13 +30,14 @@ exports.generateAvatarURL = function (name, email = '', big = true) {
if (typeof email !== 'string') { if (typeof email !== 'string') {
email = '' + name + '@example.com' email = '' + name + '@example.com'
} }
name = encodeURIComponent(name)
let hash = crypto.createHash('md5') let hash = crypto.createHash('md5')
hash.update(email.toLowerCase()) hash.update(email.toLowerCase())
let hexDigest = hash.digest('hex') let hexDigest = hash.digest('hex')
if (email !== '' && config.allowGravatar) { if (email !== '' && config.allowGravatar) {
photo = 'https://www.gravatar.com/avatar/' + hexDigest; photo = 'https://www.gravatar.com/avatar/' + hexDigest
if (big) { if (big) {
photo += '?s=400' photo += '?s=400'
} else { } else {

View File

@ -1,5 +1,5 @@
'use strict' 'use strict'
const {createLogger, format, transports} = require('winston') const { createLogger, format, transports } = require('winston')
const logger = createLogger({ const logger = createLogger({
level: 'debug', level: 'debug',

View File

@ -18,8 +18,8 @@ module.exports = {
down: function (queryInterface, Sequelize) { down: function (queryInterface, Sequelize) {
return queryInterface.removeColumn('Notes', 'lastchangeAt') return queryInterface.removeColumn('Notes', 'lastchangeAt')
.then(function () { .then(function () {
return queryInterface.removeColumn('Notes', 'lastchangeuserId') return queryInterface.removeColumn('Notes', 'lastchangeuserId')
}) })
} }
} }

View File

@ -1,16 +1,16 @@
'use strict' 'use strict'
module.exports = { module.exports = {
up: function (queryInterface, Sequelize) { up: async function (queryInterface, Sequelize) {
queryInterface.changeColumn('Notes', 'content', {type: Sequelize.TEXT('long')}) await queryInterface.changeColumn('Notes', 'content', { type: Sequelize.TEXT('long') })
queryInterface.changeColumn('Revisions', 'patch', {type: Sequelize.TEXT('long')}) await queryInterface.changeColumn('Revisions', 'patch', { type: Sequelize.TEXT('long') })
queryInterface.changeColumn('Revisions', 'content', {type: Sequelize.TEXT('long')}) await queryInterface.changeColumn('Revisions', 'content', { type: Sequelize.TEXT('long') })
queryInterface.changeColumn('Revisions', 'lastContent', {type: Sequelize.TEXT('long')}) await queryInterface.changeColumn('Revisions', 'lastContent', { type: Sequelize.TEXT('long') })
}, },
down: function (queryInterface, Sequelize) { down: async function (queryInterface, Sequelize) {
queryInterface.changeColumn('Notes', 'content', {type: Sequelize.TEXT}) await queryInterface.changeColumn('Notes', 'content', { type: Sequelize.TEXT })
queryInterface.changeColumn('Revisions', 'patch', {type: Sequelize.TEXT}) await queryInterface.changeColumn('Revisions', 'patch', { type: Sequelize.TEXT })
queryInterface.changeColumn('Revisions', 'content', {type: Sequelize.TEXT}) await queryInterface.changeColumn('Revisions', 'content', { type: Sequelize.TEXT })
queryInterface.changeColumn('Revisions', 'lastContent', {type: Sequelize.TEXT}) await queryInterface.changeColumn('Revisions', 'lastContent', { type: Sequelize.TEXT })
} }
} }

View File

@ -1,13 +1,13 @@
'use strict' 'use strict'
module.exports = { module.exports = {
up: function (queryInterface, Sequelize) { up: async function (queryInterface, Sequelize) {
queryInterface.changeColumn('Notes', 'authorship', {type: Sequelize.TEXT('long')}) await queryInterface.changeColumn('Notes', 'authorship', { type: Sequelize.TEXT('long') })
queryInterface.changeColumn('Revisions', 'authorship', {type: Sequelize.TEXT('long')}) await queryInterface.changeColumn('Revisions', 'authorship', { type: Sequelize.TEXT('long') })
}, },
down: function (queryInterface, Sequelize) { down: async function (queryInterface, Sequelize) {
queryInterface.changeColumn('Notes', 'authorship', {type: Sequelize.TEXT}) await queryInterface.changeColumn('Notes', 'authorship', { type: Sequelize.TEXT })
queryInterface.changeColumn('Revisions', 'authorship', {type: Sequelize.TEXT}) await queryInterface.changeColumn('Revisions', 'authorship', { type: Sequelize.TEXT })
} }
} }

View File

@ -1,11 +1,11 @@
'use strict' 'use strict'
module.exports = { module.exports = {
up: function (queryInterface, Sequelize) { up: async function (queryInterface, Sequelize) {
queryInterface.changeColumn('Notes', 'permission', {type: Sequelize.ENUM('freely', 'editable', 'limited', 'locked', 'protected', 'private')}) await queryInterface.changeColumn('Notes', 'permission', { type: Sequelize.ENUM('freely', 'editable', 'limited', 'locked', 'protected', 'private') })
}, },
down: function (queryInterface, Sequelize) { down: async function (queryInterface, Sequelize) {
queryInterface.changeColumn('Notes', 'permission', {type: Sequelize.ENUM('freely', 'editable', 'locked', 'private')}) await queryInterface.changeColumn('Notes', 'permission', { type: Sequelize.ENUM('freely', 'editable', 'locked', 'private') })
} }
} }

View File

@ -18,25 +18,25 @@ module.exports = function (sequelize, DataTypes) {
unique: true, unique: true,
fields: ['noteId', 'userId'] fields: ['noteId', 'userId']
} }
], ]
classMethods: {
associate: function (models) {
Author.belongsTo(models.Note, {
foreignKey: 'noteId',
as: 'note',
constraints: false,
onDelete: 'CASCADE',
hooks: true
})
Author.belongsTo(models.User, {
foreignKey: 'userId',
as: 'user',
constraints: false,
onDelete: 'CASCADE',
hooks: true
})
}
}
}) })
Author.associate = function (models) {
Author.belongsTo(models.Note, {
foreignKey: 'noteId',
as: 'note',
constraints: false,
onDelete: 'CASCADE',
hooks: true
})
Author.belongsTo(models.User, {
foreignKey: 'userId',
as: 'user',
constraints: false,
onDelete: 'CASCADE',
hooks: true
})
}
return Author return Author
} }

View File

@ -3,14 +3,16 @@
var fs = require('fs') var fs = require('fs')
var path = require('path') var path = require('path')
var Sequelize = require('sequelize') var Sequelize = require('sequelize')
const {cloneDeep} = require('lodash') const { cloneDeep } = require('lodash')
// core // core
var config = require('../config') var config = require('../config')
var logger = require('../logger') var logger = require('../logger')
var dbconfig = cloneDeep(config.db) var dbconfig = cloneDeep(config.db)
dbconfig.logging = config.debug ? logger.info : false dbconfig.logging = config.debug ? (data) => {
logger.info(data)
} : false
var sequelize = null var sequelize = null
@ -39,13 +41,13 @@ sequelize.processData = processData
var db = {} var db = {}
fs.readdirSync(__dirname) fs.readdirSync(__dirname)
.filter(function (file) { .filter(function (file) {
return (file.indexOf('.') !== 0) && (file !== 'index.js') return (file.indexOf('.') !== 0) && (file !== 'index.js')
}) })
.forEach(function (file) { .forEach(function (file) {
var model = sequelize.import(path.join(__dirname, file)) var model = sequelize.import(path.join(__dirname, file))
db[model.name] = model db[model.name] = model
}) })
Object.keys(db).forEach(function (modelName) { Object.keys(db).forEach(function (modelName) {
if ('associate' in db[modelName]) { if ('associate' in db[modelName]) {

View File

@ -2,18 +2,19 @@
// external modules // external modules
var fs = require('fs') var fs = require('fs')
var path = require('path') var path = require('path')
var LZString = require('lz-string') var LZString = require('@hackmd/lz-string')
var base64url = require('base64url') var base64url = require('base64url')
var md = require('markdown-it')() var md = require('markdown-it')()
var metaMarked = require('meta-marked') var metaMarked = require('@hackmd/meta-marked')
var cheerio = require('cheerio') var cheerio = require('cheerio')
var shortId = require('shortid') var shortId = require('shortid')
var Sequelize = require('sequelize') var Sequelize = require('sequelize')
var async = require('async') var async = require('async')
var moment = require('moment') var moment = require('moment')
var DiffMatchPatch = require('diff-match-patch') var DiffMatchPatch = require('@hackmd/diff-match-patch')
var dmp = new DiffMatchPatch() var dmp = new DiffMatchPatch()
var S = require('string')
const { stripTags } = require('../../utils/string')
// core // core
var config = require('../config') var config = require('../config')
@ -86,486 +87,492 @@ module.exports = function (sequelize, DataTypes) {
} }
}, { }, {
paranoid: false, paranoid: false,
classMethods: {
associate: function (models) {
Note.belongsTo(models.User, {
foreignKey: 'ownerId',
as: 'owner',
constraints: false,
onDelete: 'CASCADE',
hooks: true
})
Note.belongsTo(models.User, {
foreignKey: 'lastchangeuserId',
as: 'lastchangeuser',
constraints: false
})
Note.hasMany(models.Revision, {
foreignKey: 'noteId',
constraints: false
})
Note.hasMany(models.Author, {
foreignKey: 'noteId',
as: 'authors',
constraints: false
})
},
checkFileExist: function (filePath) {
try {
return fs.statSync(filePath).isFile()
} catch (err) {
return false
}
},
encodeNoteId: function (id) {
// remove dashes in UUID and encode in url-safe base64
let str = id.replace(/-/g, '')
let hexStr = Buffer.from(str, 'hex')
return base64url.encode(hexStr)
},
decodeNoteId: function (encodedId) {
// decode from url-safe base64
let id = base64url.toBuffer(encodedId).toString('hex')
// add dashes between the UUID string parts
let idParts = []
idParts.push(id.substr(0, 8))
idParts.push(id.substr(8, 4))
idParts.push(id.substr(12, 4))
idParts.push(id.substr(16, 4))
idParts.push(id.substr(20, 12))
return idParts.join('-')
},
checkNoteIdValid: function (id) {
var uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
var result = id.match(uuidRegex)
if (result && result.length === 1) { return true } else { return false }
},
parseNoteId: function (noteId, callback) {
async.series({
parseNoteIdByAlias: function (_callback) {
// try to parse note id by alias (e.g. doc)
Note.findOne({
where: {
alias: noteId
}
}).then(function (note) {
if (note) {
let filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) {
// if doc in filesystem have newer modified time than last change time
// then will update the doc in db
var fsModifiedTime = moment(fs.statSync(filePath).mtime)
var dbModifiedTime = moment(note.lastchangeAt || note.createdAt)
var body = fs.readFileSync(filePath, 'utf8')
var contentLength = body.length
var title = Note.parseNoteTitle(body)
if (fsModifiedTime.isAfter(dbModifiedTime) && note.content !== body) {
note.update({
title: title,
content: body,
lastchangeAt: fsModifiedTime
}).then(function (note) {
sequelize.models.Revision.saveNoteRevision(note, function (err, revision) {
if (err) return _callback(err, null)
// update authorship on after making revision of docs
var patch = dmp.patch_fromText(revision.patch)
var operations = Note.transformPatchToOperations(patch, contentLength)
var authorship = note.authorship
for (let i = 0; i < operations.length; i++) {
authorship = Note.updateAuthorshipByOperation(operations[i], null, authorship)
}
note.update({
authorship: authorship
}).then(function (note) {
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
})
}).catch(function (err) {
return _callback(err, null)
})
} else {
return callback(null, note.id)
}
} else {
return callback(null, note.id)
}
} else {
var filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) {
Note.create({
alias: noteId,
owner: null,
permission: 'locked'
}).then(function (note) {
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
} else {
return _callback(null, null)
}
}
}).catch(function (err) {
return _callback(err, null)
})
},
// parse note id by LZString is deprecated, here for compability
parseNoteIdByLZString: function (_callback) {
// Calculate minimal string length for an UUID that is encoded
// base64 encoded and optimize comparsion by using -1
// this should make a lot of LZ-String parsing errors obsolete
// as we can assume that a nodeId that is 48 chars or longer is a
// noteID.
const base64UuidLength = ((4 * 36) / 3) - 1
if (!(noteId.length > base64UuidLength)) {
return _callback(null, null)
}
// try to parse note id by LZString Base64
try {
var id = LZString.decompressFromBase64(noteId)
if (id && Note.checkNoteIdValid(id)) { return callback(null, id) } else { return _callback(null, null) }
} catch (err) {
if (err.message === 'Cannot read property \'charAt\' of undefined') {
logger.warning('Looks like we can not decode "' + noteId + '" with LZString. Can be ignored.')
} else {
logger.error(err)
}
return _callback(null, null)
}
},
parseNoteIdByBase64Url: function (_callback) {
// try to parse note id by base64url
try {
var id = Note.decodeNoteId(noteId)
if (id && Note.checkNoteIdValid(id)) { return callback(null, id) } else { return _callback(null, null) }
} catch (err) {
logger.error(err)
return _callback(null, null)
}
},
parseNoteIdByShortId: function (_callback) {
// try to parse note id by shortId
try {
if (shortId.isValid(noteId)) {
Note.findOne({
where: {
shortid: noteId
}
}).then(function (note) {
if (!note) return _callback(null, null)
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
} else {
return _callback(null, null)
}
} catch (err) {
return _callback(err, null)
}
}
}, function (err, result) {
if (err) {
logger.error(err)
return callback(err, null)
}
return callback(null, null)
})
},
parseNoteInfo: function (body) {
var parsed = Note.extractMeta(body)
var $ = cheerio.load(md.render(parsed.markdown))
return {
title: Note.extractNoteTitle(parsed.meta, $),
tags: Note.extractNoteTags(parsed.meta, $)
}
},
parseNoteTitle: function (body) {
var parsed = Note.extractMeta(body)
var $ = cheerio.load(md.render(parsed.markdown))
return Note.extractNoteTitle(parsed.meta, $)
},
extractNoteTitle: function (meta, $) {
var title = ''
if (meta.title && (typeof meta.title === 'string' || typeof meta.title === 'number')) {
title = meta.title
} else {
var h1s = $('h1')
if (h1s.length > 0 && h1s.first().text().split('\n').length === 1) { title = S(h1s.first().text()).stripTags().s }
}
if (!title) title = 'Untitled'
return title
},
generateDescription: function (markdown) {
return markdown.substr(0, 100).replace(/(?:\r\n|\r|\n)/g, ' ')
},
decodeTitle: function (title) {
return title || 'Untitled'
},
generateWebTitle: function (title) {
title = !title || title === 'Untitled' ? 'CodiMD - Collaborative markdown notes' : title + ' - CodiMD'
return title
},
extractNoteTags: function (meta, $) {
var tags = []
var rawtags = []
if (meta.tags && (typeof meta.tags === 'string' || typeof meta.tags === 'number')) {
var metaTags = ('' + meta.tags).split(',')
for (let i = 0; i < metaTags.length; i++) {
var text = metaTags[i].trim()
if (text) rawtags.push(text)
}
} else {
var h6s = $('h6')
h6s.each(function (key, value) {
if (/^tags/gmi.test($(value).text())) {
var codes = $(value).find('code')
for (let i = 0; i < codes.length; i++) {
var text = S($(codes[i]).text().trim()).stripTags().s
if (text) rawtags.push(text)
}
}
})
}
for (let i = 0; i < rawtags.length; i++) {
var found = false
for (let j = 0; j < tags.length; j++) {
if (tags[j] === rawtags[i]) {
found = true
break
}
}
if (!found) { tags.push(rawtags[i]) }
}
return tags
},
extractMeta: function (content) {
var obj = null
try {
obj = metaMarked(content)
if (!obj.markdown) obj.markdown = ''
if (!obj.meta) obj.meta = {}
} catch (err) {
obj = {
markdown: content,
meta: {}
}
}
return obj
},
parseMeta: function (meta) {
var _meta = {}
if (meta) {
if (meta.title && (typeof meta.title === 'string' || typeof meta.title === 'number')) { _meta.title = meta.title }
if (meta.description && (typeof meta.description === 'string' || typeof meta.description === 'number')) { _meta.description = meta.description }
if (meta.robots && (typeof meta.robots === 'string' || typeof meta.robots === 'number')) { _meta.robots = meta.robots }
if (meta.GA && (typeof meta.GA === 'string' || typeof meta.GA === 'number')) { _meta.GA = meta.GA }
if (meta.disqus && (typeof meta.disqus === 'string' || typeof meta.disqus === 'number')) { _meta.disqus = meta.disqus }
if (meta.slideOptions && (typeof meta.slideOptions === 'object')) { _meta.slideOptions = meta.slideOptions }
}
return _meta
},
updateAuthorshipByOperation: function (operation, userId, authorships) {
var index = 0
var timestamp = Date.now()
for (let i = 0; i < operation.length; i++) {
var op = operation[i]
if (ot.TextOperation.isRetain(op)) {
index += op
} else if (ot.TextOperation.isInsert(op)) {
let opStart = index
let opEnd = index + op.length
var inserted = false
// authorship format: [userId, startPos, endPos, createdAt, updatedAt]
if (authorships.length <= 0) authorships.push([userId, opStart, opEnd, timestamp, timestamp])
else {
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
if (!inserted) {
let nextAuthorship = authorships[j + 1] || -1
if ((nextAuthorship !== -1 && nextAuthorship[1] >= opEnd) || j >= authorships.length - 1) {
if (authorship[1] < opStart && authorship[2] > opStart) {
// divide
let postLength = authorship[2] - opStart
authorship[2] = opStart
authorship[4] = timestamp
authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp])
authorships.splice(j + 2, 0, [authorship[0], opEnd, opEnd + postLength, authorship[3], timestamp])
j += 2
inserted = true
} else if (authorship[1] >= opStart) {
authorships.splice(j, 0, [userId, opStart, opEnd, timestamp, timestamp])
j += 1
inserted = true
} else if (authorship[2] <= opStart) {
authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp])
j += 1
inserted = true
}
}
}
if (authorship[1] >= opStart) {
authorship[1] += op.length
authorship[2] += op.length
}
}
}
index += op.length
} else if (ot.TextOperation.isDelete(op)) {
let opStart = index
let opEnd = index - op
if (operation.length === 1) {
authorships = []
} else if (authorships.length > 0) {
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
if (authorship[1] >= opStart && authorship[1] <= opEnd && authorship[2] >= opStart && authorship[2] <= opEnd) {
authorships.splice(j, 1)
j -= 1
} else if (authorship[1] < opStart && authorship[1] < opEnd && authorship[2] > opStart && authorship[2] > opEnd) {
authorship[2] += op
authorship[4] = timestamp
} else if (authorship[2] >= opStart && authorship[2] <= opEnd) {
authorship[2] = opStart
authorship[4] = timestamp
} else if (authorship[1] >= opStart && authorship[1] <= opEnd) {
authorship[1] = opEnd
authorship[4] = timestamp
}
if (authorship[1] >= opEnd) {
authorship[1] += op
authorship[2] += op
}
}
}
index += op
}
}
// merge
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
for (let k = j + 1; k < authorships.length; k++) {
let nextAuthorship = authorships[k]
if (nextAuthorship && authorship[0] === nextAuthorship[0] && authorship[2] === nextAuthorship[1]) {
let minTimestamp = Math.min(authorship[3], nextAuthorship[3])
let maxTimestamp = Math.max(authorship[3], nextAuthorship[3])
authorships.splice(j, 1, [authorship[0], authorship[1], nextAuthorship[2], minTimestamp, maxTimestamp])
authorships.splice(k, 1)
j -= 1
break
}
}
}
// clear
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
if (!authorship[0]) {
authorships.splice(j, 1)
j -= 1
}
}
return authorships
},
transformPatchToOperations: function (patch, contentLength) {
var operations = []
if (patch.length > 0) {
// calculate original content length
for (let j = patch.length - 1; j >= 0; j--) {
var p = patch[j]
for (let i = 0; i < p.diffs.length; i++) {
var diff = p.diffs[i]
switch (diff[0]) {
case 1: // insert
contentLength -= diff[1].length
break
case -1: // delete
contentLength += diff[1].length
break
}
}
}
// generate operations
var bias = 0
var lengthBias = 0
for (let j = 0; j < patch.length; j++) {
var operation = []
let p = patch[j]
var currIndex = p.start1
var currLength = contentLength - bias
for (let i = 0; i < p.diffs.length; i++) {
let diff = p.diffs[i]
switch (diff[0]) {
case 0: // retain
if (i === 0) {
// first
operation.push(currIndex + diff[1].length)
} else if (i !== p.diffs.length - 1) {
// mid
operation.push(diff[1].length)
} else {
// last
operation.push(currLength + lengthBias - currIndex)
}
currIndex += diff[1].length
break
case 1: // insert
operation.push(diff[1])
lengthBias += diff[1].length
currIndex += diff[1].length
break
case -1: // delete
operation.push(-diff[1].length)
bias += diff[1].length
currIndex += diff[1].length
break
}
}
operations.push(operation)
}
}
return operations
}
},
hooks: { hooks: {
beforeCreate: function (note, options, callback) { beforeCreate: function (note, options) {
// if no content specified then use default note return new Promise(function (resolve, reject) {
if (!note.content) { // if no content specified then use default note
var body = null if (!note.content) {
let filePath = null var body = null
if (!note.alias) { let filePath = null
filePath = config.defaultNotePath if (!note.alias) {
} else { filePath = config.defaultNotePath
filePath = path.join(config.docsPath, note.alias + '.md') } else {
} filePath = path.join(config.docsPath, note.alias + '.md')
if (Note.checkFileExist(filePath)) { }
var fsCreatedTime = moment(fs.statSync(filePath).ctime) if (Note.checkFileExist(filePath)) {
body = fs.readFileSync(filePath, 'utf8') var fsCreatedTime = moment(fs.statSync(filePath).ctime)
note.title = Note.parseNoteTitle(body) body = fs.readFileSync(filePath, 'utf8')
note.content = body note.title = Note.parseNoteTitle(body)
if (filePath !== config.defaultNotePath) { note.content = body
note.createdAt = fsCreatedTime if (filePath !== config.defaultNotePath) {
note.createdAt = fsCreatedTime
}
} }
} }
} // if no permission specified and have owner then give default permission in config, else default permission is freely
// if no permission specified and have owner then give default permission in config, else default permission is freely if (!note.permission) {
if (!note.permission) { if (note.ownerId) {
if (note.ownerId) { note.permission = config.defaultPermission
note.permission = config.defaultPermission } else {
} else { note.permission = 'freely'
note.permission = 'freely' }
} }
} return resolve(note)
return callback(null, note) })
}, },
afterCreate: function (note, options, callback) { afterCreate: function (note, options, callback) {
sequelize.models.Revision.saveNoteRevision(note, function (err, revision) { return new Promise(function (resolve, reject) {
callback(err, note) sequelize.models.Revision.saveNoteRevision(note, function (err, revision) {
if (err) {
return reject(err)
}
return resolve(note)
})
}) })
} }
} }
}) })
Note.associate = function (models) {
Note.belongsTo(models.User, {
foreignKey: 'ownerId',
as: 'owner',
constraints: false,
onDelete: 'CASCADE',
hooks: true
})
Note.belongsTo(models.User, {
foreignKey: 'lastchangeuserId',
as: 'lastchangeuser',
constraints: false
})
Note.hasMany(models.Revision, {
foreignKey: 'noteId',
constraints: false
})
Note.hasMany(models.Author, {
foreignKey: 'noteId',
as: 'authors',
constraints: false
})
}
Note.checkFileExist = function (filePath) {
try {
return fs.statSync(filePath).isFile()
} catch (err) {
return false
}
}
Note.encodeNoteId = function (id) {
// remove dashes in UUID and encode in url-safe base64
let str = id.replace(/-/g, '')
let hexStr = Buffer.from(str, 'hex')
return base64url.encode(hexStr)
}
Note.decodeNoteId = function (encodedId) {
// decode from url-safe base64
let id = base64url.toBuffer(encodedId).toString('hex')
// add dashes between the UUID string parts
let idParts = []
idParts.push(id.substr(0, 8))
idParts.push(id.substr(8, 4))
idParts.push(id.substr(12, 4))
idParts.push(id.substr(16, 4))
idParts.push(id.substr(20, 12))
return idParts.join('-')
}
Note.checkNoteIdValid = function (id) {
var uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
var result = id.match(uuidRegex)
if (result && result.length === 1) { return true } else { return false }
}
Note.parseNoteId = function (noteId, callback) {
async.series({
parseNoteIdByAlias: function (_callback) {
// try to parse note id by alias (e.g. doc)
Note.findOne({
where: {
alias: noteId
}
}).then(function (note) {
if (note) {
let filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) {
// if doc in filesystem have newer modified time than last change time
// then will update the doc in db
var fsModifiedTime = moment(fs.statSync(filePath).mtime)
var dbModifiedTime = moment(note.lastchangeAt || note.createdAt)
var body = fs.readFileSync(filePath, 'utf8')
var contentLength = body.length
var title = Note.parseNoteTitle(body)
if (fsModifiedTime.isAfter(dbModifiedTime) && note.content !== body) {
note.update({
title: title,
content: body,
lastchangeAt: fsModifiedTime
}).then(function (note) {
sequelize.models.Revision.saveNoteRevision(note, function (err, revision) {
if (err) return _callback(err, null)
// update authorship on after making revision of docs
var patch = dmp.patch_fromText(revision.patch)
var operations = Note.transformPatchToOperations(patch, contentLength)
var authorship = note.authorship
for (let i = 0; i < operations.length; i++) {
authorship = Note.updateAuthorshipByOperation(operations[i], null, authorship)
}
note.update({
authorship: authorship
}).then(function (note) {
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
})
}).catch(function (err) {
return _callback(err, null)
})
} else {
return callback(null, note.id)
}
} else {
return callback(null, note.id)
}
} else {
var filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) {
Note.create({
alias: noteId,
owner: null,
permission: 'locked'
}).then(function (note) {
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
} else {
return _callback(null, null)
}
}
}).catch(function (err) {
return _callback(err, null)
})
},
// parse note id by LZString is deprecated, here for compability
parseNoteIdByLZString: function (_callback) {
// Calculate minimal string length for an UUID that is encoded
// base64 encoded and optimize comparsion by using -1
// this should make a lot of LZ-String parsing errors obsolete
// as we can assume that a nodeId that is 48 chars or longer is a
// noteID.
const base64UuidLength = ((4 * 36) / 3) - 1
if (!(noteId.length > base64UuidLength)) {
return _callback(null, null)
}
// try to parse note id by LZString Base64
try {
var id = LZString.decompressFromBase64(noteId)
if (id && Note.checkNoteIdValid(id)) { return callback(null, id) } else { return _callback(null, null) }
} catch (err) {
if (err.message === 'Cannot read property \'charAt\' of undefined') {
logger.warning('Looks like we can not decode "' + noteId + '" with LZString. Can be ignored.')
} else {
logger.error(err)
}
return _callback(null, null)
}
},
parseNoteIdByBase64Url: function (_callback) {
// try to parse note id by base64url
try {
var id = Note.decodeNoteId(noteId)
if (id && Note.checkNoteIdValid(id)) { return callback(null, id) } else { return _callback(null, null) }
} catch (err) {
logger.error(err)
return _callback(null, null)
}
},
parseNoteIdByShortId: function (_callback) {
// try to parse note id by shortId
try {
if (shortId.isValid(noteId)) {
Note.findOne({
where: {
shortid: noteId
}
}).then(function (note) {
if (!note) return _callback(null, null)
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
} else {
return _callback(null, null)
}
} catch (err) {
return _callback(err, null)
}
}
}, function (err, result) {
if (err) {
logger.error(err)
return callback(err, null)
}
return callback(null, null)
})
}
Note.parseNoteInfo = function (body) {
var parsed = Note.extractMeta(body)
var $ = cheerio.load(md.render(parsed.markdown))
return {
title: Note.extractNoteTitle(parsed.meta, $),
tags: Note.extractNoteTags(parsed.meta, $)
}
}
Note.parseNoteTitle = function (body) {
var parsed = Note.extractMeta(body)
var $ = cheerio.load(md.render(parsed.markdown))
return Note.extractNoteTitle(parsed.meta, $)
}
Note.extractNoteTitle = function (meta, $) {
var title = ''
if (meta.title && (typeof meta.title === 'string' || typeof meta.title === 'number')) {
title = meta.title
} else {
var h1s = $('h1')
if (h1s.length > 0 && h1s.first().text().split('\n').length === 1) { title = stripTags(h1s.first().text()) }
}
if (!title) title = 'Untitled'
return title
}
Note.generateDescription = function (markdown) {
return markdown.substr(0, 100).replace(/(?:\r\n|\r|\n)/g, ' ')
}
Note.decodeTitle = function (title) {
return title || 'Untitled'
}
Note.generateWebTitle = function (title) {
title = !title || title === 'Untitled' ? 'CodiMD - Collaborative markdown notes' : title + ' - CodiMD'
return title
}
Note.extractNoteTags = function (meta, $) {
var tags = []
var rawtags = []
if (meta.tags && (typeof meta.tags === 'string' || typeof meta.tags === 'number')) {
var metaTags = ('' + meta.tags).split(',')
for (let i = 0; i < metaTags.length; i++) {
var text = metaTags[i].trim()
if (text) rawtags.push(text)
}
} else {
var h6s = $('h6')
h6s.each(function (key, value) {
if (/^tags/gmi.test($(value).text())) {
var codes = $(value).find('code')
for (let i = 0; i < codes.length; i++) {
var text = stripTags($(codes[i]).text().trim())
if (text) rawtags.push(text)
}
}
})
}
for (let i = 0; i < rawtags.length; i++) {
var found = false
for (let j = 0; j < tags.length; j++) {
if (tags[j] === rawtags[i]) {
found = true
break
}
}
if (!found) { tags.push(rawtags[i]) }
}
return tags
}
Note.extractMeta = function (content) {
var obj = null
try {
obj = metaMarked(content)
if (!obj.markdown) obj.markdown = ''
if (!obj.meta) obj.meta = {}
} catch (err) {
obj = {
markdown: content,
meta: {}
}
}
return obj
}
Note.parseMeta = function (meta) {
var _meta = {}
if (meta) {
if (meta.title && (typeof meta.title === 'string' || typeof meta.title === 'number')) { _meta.title = meta.title }
if (meta.description && (typeof meta.description === 'string' || typeof meta.description === 'number')) { _meta.description = meta.description }
if (meta.robots && (typeof meta.robots === 'string' || typeof meta.robots === 'number')) { _meta.robots = meta.robots }
if (meta.GA && (typeof meta.GA === 'string' || typeof meta.GA === 'number')) { _meta.GA = meta.GA }
if (meta.disqus && (typeof meta.disqus === 'string' || typeof meta.disqus === 'number')) { _meta.disqus = meta.disqus }
if (meta.slideOptions && (typeof meta.slideOptions === 'object')) { _meta.slideOptions = meta.slideOptions }
}
return _meta
}
Note.updateAuthorshipByOperation = function (operation, userId, authorships) {
var index = 0
var timestamp = Date.now()
for (let i = 0; i < operation.length; i++) {
var op = operation[i]
if (ot.TextOperation.isRetain(op)) {
index += op
} else if (ot.TextOperation.isInsert(op)) {
let opStart = index
let opEnd = index + op.length
var inserted = false
// authorship format: [userId, startPos, endPos, createdAt, updatedAt]
if (authorships.length <= 0) authorships.push([userId, opStart, opEnd, timestamp, timestamp])
else {
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
if (!inserted) {
let nextAuthorship = authorships[j + 1] || -1
if ((nextAuthorship !== -1 && nextAuthorship[1] >= opEnd) || j >= authorships.length - 1) {
if (authorship[1] < opStart && authorship[2] > opStart) {
// divide
let postLength = authorship[2] - opStart
authorship[2] = opStart
authorship[4] = timestamp
authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp])
authorships.splice(j + 2, 0, [authorship[0], opEnd, opEnd + postLength, authorship[3], timestamp])
j += 2
inserted = true
} else if (authorship[1] >= opStart) {
authorships.splice(j, 0, [userId, opStart, opEnd, timestamp, timestamp])
j += 1
inserted = true
} else if (authorship[2] <= opStart) {
authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp])
j += 1
inserted = true
}
}
}
if (authorship[1] >= opStart) {
authorship[1] += op.length
authorship[2] += op.length
}
}
}
index += op.length
} else if (ot.TextOperation.isDelete(op)) {
let opStart = index
let opEnd = index - op
if (operation.length === 1) {
authorships = []
} else if (authorships.length > 0) {
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
if (authorship[1] >= opStart && authorship[1] <= opEnd && authorship[2] >= opStart && authorship[2] <= opEnd) {
authorships.splice(j, 1)
j -= 1
} else if (authorship[1] < opStart && authorship[1] < opEnd && authorship[2] > opStart && authorship[2] > opEnd) {
authorship[2] += op
authorship[4] = timestamp
} else if (authorship[2] >= opStart && authorship[2] <= opEnd) {
authorship[2] = opStart
authorship[4] = timestamp
} else if (authorship[1] >= opStart && authorship[1] <= opEnd) {
authorship[1] = opEnd
authorship[4] = timestamp
}
if (authorship[1] >= opEnd) {
authorship[1] += op
authorship[2] += op
}
}
}
index += op
}
}
// merge
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
for (let k = j + 1; k < authorships.length; k++) {
let nextAuthorship = authorships[k]
if (nextAuthorship && authorship[0] === nextAuthorship[0] && authorship[2] === nextAuthorship[1]) {
let minTimestamp = Math.min(authorship[3], nextAuthorship[3])
let maxTimestamp = Math.max(authorship[3], nextAuthorship[3])
authorships.splice(j, 1, [authorship[0], authorship[1], nextAuthorship[2], minTimestamp, maxTimestamp])
authorships.splice(k, 1)
j -= 1
break
}
}
}
// clear
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
if (!authorship[0]) {
authorships.splice(j, 1)
j -= 1
}
}
return authorships
}
Note.transformPatchToOperations = function (patch, contentLength) {
var operations = []
if (patch.length > 0) {
// calculate original content length
for (let j = patch.length - 1; j >= 0; j--) {
var p = patch[j]
for (let i = 0; i < p.diffs.length; i++) {
var diff = p.diffs[i]
switch (diff[0]) {
case 1: // insert
contentLength -= diff[1].length
break
case -1: // delete
contentLength += diff[1].length
break
}
}
}
// generate operations
var bias = 0
var lengthBias = 0
for (let j = 0; j < patch.length; j++) {
var operation = []
let p = patch[j]
var currIndex = p.start1
var currLength = contentLength - bias
for (let i = 0; i < p.diffs.length; i++) {
let diff = p.diffs[i]
switch (diff[0]) {
case 0: // retain
if (i === 0) {
// first
operation.push(currIndex + diff[1].length)
} else if (i !== p.diffs.length - 1) {
// mid
operation.push(diff[1].length)
} else {
// last
operation.push(currLength + lengthBias - currIndex)
}
currIndex += diff[1].length
break
case 1: // insert
operation.push(diff[1])
lengthBias += diff[1].length
currIndex += diff[1].length
break
case -1: // delete
operation.push(-diff[1].length)
bias += diff[1].length
currIndex += diff[1].length
break
}
}
operations.push(operation)
}
}
return operations
}
return Note return Note
} }

View File

@ -7,6 +7,8 @@ var childProcess = require('child_process')
var shortId = require('shortid') var shortId = require('shortid')
var path = require('path') var path = require('path')
var Op = Sequelize.Op
// core // core
var config = require('../config') var config = require('../config')
var logger = require('../logger') var logger = require('../logger')
@ -97,214 +99,212 @@ module.exports = function (sequelize, DataTypes) {
this.setDataValue('authorship', value ? JSON.stringify(value) : value) this.setDataValue('authorship', value ? JSON.stringify(value) : value)
} }
} }
}, { })
classMethods: {
associate: function (models) { Revision.associate = function (models) {
Revision.belongsTo(models.Note, { Revision.belongsTo(models.Note, {
foreignKey: 'noteId', foreignKey: 'noteId',
as: 'note', as: 'note',
constraints: false, constraints: false,
onDelete: 'CASCADE', onDelete: 'CASCADE',
hooks: true hooks: true
}) })
}
Revision.getNoteRevisions = function (note, callback) {
Revision.findAll({
where: {
noteId: note.id
}, },
getNoteRevisions: function (note, callback) { order: [['createdAt', 'DESC']]
Revision.findAll({ }).then(function (revisions) {
where: { var data = []
noteId: note.id for (var i = 0, l = revisions.length; i < l; i++) {
}, var revision = revisions[i]
order: [['createdAt', 'DESC']] data.push({
}).then(function (revisions) { time: moment(revision.createdAt).valueOf(),
var data = [] length: revision.length
for (var i = 0, l = revisions.length; i < l; i++) { })
var revision = revisions[i] }
data.push({ callback(null, data)
time: moment(revision.createdAt).valueOf(), }).catch(function (err) {
length: revision.length callback(err, null)
}) })
}
Revision.getPatchedNoteRevisionByTime = function (note, time, callback) {
// find all revisions to prepare for all possible calculation
Revision.findAll({
where: {
noteId: note.id
},
order: [['createdAt', 'DESC']]
}).then(function (revisions) {
if (revisions.length <= 0) return callback(null, null)
// measure target revision position
Revision.count({
where: {
noteId: note.id,
createdAt: {
[Op.gte]: time
} }
callback(null, data) },
}).catch(function (err) { order: [['createdAt', 'DESC']]
callback(err, null) }).then(function (count) {
}) if (count <= 0) return callback(null, null)
}, sendDmpWorker({
getPatchedNoteRevisionByTime: function (note, time, callback) { msg: 'get revision',
// find all revisions to prepare for all possible calculation revisions: revisions,
Revision.findAll({ count: count
where: { }, callback)
noteId: note.id }).catch(function (err) {
}, return callback(err, null)
order: [['createdAt', 'DESC']] })
}).then(function (revisions) { }).catch(function (err) {
if (revisions.length <= 0) return callback(null, null) return callback(err, null)
// measure target revision position })
Revision.count({ }
where: { Revision.checkAllNotesRevision = function (callback) {
noteId: note.id, Revision.saveAllNotesRevision(function (err, notes) {
createdAt: { if (err) return callback(err, null)
$gte: time if (!notes || notes.length <= 0) {
return callback(null, notes)
} else {
Revision.checkAllNotesRevision(callback)
}
})
}
Revision.saveAllNotesRevision = function (callback) {
sequelize.models.Note.findAll({
// query all notes that need to save for revision
where: {
[Op.and]: [
{
lastchangeAt: {
[Op.or]: {
[Op.eq]: null,
[Op.and]: {
[Op.ne]: null,
[Op.gt]: sequelize.col('createdAt')
}
} }
}, }
order: [['createdAt', 'DESC']] },
}).then(function (count) { {
if (count <= 0) return callback(null, null) savedAt: {
sendDmpWorker({ [Op.or]: {
msg: 'get revision', [Op.eq]: null,
revisions: revisions, [Op.lt]: sequelize.col('lastchangeAt')
count: count }
}, callback) }
}).catch(function (err) { }
return callback(err, null) ]
}) }
}).catch(function (err) { }).then(function (notes) {
return callback(err, null) if (notes.length <= 0) return callback(null, notes)
}) var savedNotes = []
}, async.each(notes, function (note, _callback) {
checkAllNotesRevision: function (callback) { // revision saving policy: note not been modified for 5 mins or not save for 10 mins
Revision.saveAllNotesRevision(function (err, notes) { if (note.lastchangeAt && note.savedAt) {
if (err) return callback(err, null) var lastchangeAt = moment(note.lastchangeAt)
if (!notes || notes.length <= 0) { var savedAt = moment(note.savedAt)
return callback(null, notes) if (moment().isAfter(lastchangeAt.add(5, 'minutes'))) {
savedNotes.push(note)
Revision.saveNoteRevision(note, _callback)
} else if (lastchangeAt.isAfter(savedAt.add(10, 'minutes'))) {
savedNotes.push(note)
Revision.saveNoteRevision(note, _callback)
} else { } else {
Revision.checkAllNotesRevision(callback) return _callback(null, null)
} }
}) } else {
savedNotes.push(note)
Revision.saveNoteRevision(note, _callback)
}
}, function (err) {
if (err) {
return callback(err, null)
}
// return null when no notes need saving at this moment but have delayed tasks to be done
var result = ((savedNotes.length === 0) && (notes.length > savedNotes.length)) ? null : savedNotes
return callback(null, result)
})
}).catch(function (err) {
return callback(err, null)
})
}
Revision.saveNoteRevision = function (note, callback) {
Revision.findAll({
where: {
noteId: note.id
}, },
saveAllNotesRevision: function (callback) { order: [['createdAt', 'DESC']]
sequelize.models.Note.findAll({ }).then(function (revisions) {
// query all notes that need to save for revision if (revisions.length <= 0) {
where: { // if no revision available
$and: [ Revision.create({
{ noteId: note.id,
lastchangeAt: { lastContent: note.content ? note.content : '',
$or: { length: note.content ? note.content.length : 0,
$eq: null, authorship: note.authorship
$and: { }).then(function (revision) {
$ne: null, Revision.finishSaveNoteRevision(note, revision, callback)
$gt: sequelize.col('createdAt')
}
}
}
},
{
savedAt: {
$or: {
$eq: null,
$lt: sequelize.col('lastchangeAt')
}
}
}
]
}
}).then(function (notes) {
if (notes.length <= 0) return callback(null, notes)
var savedNotes = []
async.each(notes, function (note, _callback) {
// revision saving policy: note not been modified for 5 mins or not save for 10 mins
if (note.lastchangeAt && note.savedAt) {
var lastchangeAt = moment(note.lastchangeAt)
var savedAt = moment(note.savedAt)
if (moment().isAfter(lastchangeAt.add(5, 'minutes'))) {
savedNotes.push(note)
Revision.saveNoteRevision(note, _callback)
} else if (lastchangeAt.isAfter(savedAt.add(10, 'minutes'))) {
savedNotes.push(note)
Revision.saveNoteRevision(note, _callback)
} else {
return _callback(null, null)
}
} else {
savedNotes.push(note)
Revision.saveNoteRevision(note, _callback)
}
}, function (err) {
if (err) {
return callback(err, null)
}
// return null when no notes need saving at this moment but have delayed tasks to be done
var result = ((savedNotes.length === 0) && (notes.length > savedNotes.length)) ? null : savedNotes
return callback(null, result)
})
}).catch(function (err) { }).catch(function (err) {
return callback(err, null) return callback(err, null)
}) })
}, } else {
saveNoteRevision: function (note, callback) { var latestRevision = revisions[0]
Revision.findAll({ var lastContent = latestRevision.content || latestRevision.lastContent
where: { var content = note.content
noteId: note.id sendDmpWorker({
}, msg: 'create patch',
order: [['createdAt', 'DESC']] lastDoc: lastContent,
}).then(function (revisions) { currDoc: content
if (revisions.length <= 0) { }, function (err, patch) {
// if no revision available if (err) logger.error('save note revision error', err)
Revision.create({ if (!patch) {
noteId: note.id, // if patch is empty (means no difference) then just update the latest revision updated time
lastContent: note.content ? note.content : '', latestRevision.changed('updatedAt', true)
length: note.content ? note.content.length : 0, latestRevision.update({
authorship: note.authorship updatedAt: Date.now()
}).then(function (revision) { }).then(function (revision) {
Revision.finishSaveNoteRevision(note, revision, callback) Revision.finishSaveNoteRevision(note, revision, callback)
}).catch(function (err) { }).catch(function (err) {
return callback(err, null) return callback(err, null)
}) })
} else { } else {
var latestRevision = revisions[0] Revision.create({
var lastContent = latestRevision.content || latestRevision.lastContent noteId: note.id,
var content = note.content patch: patch,
sendDmpWorker({ content: note.content,
msg: 'create patch', length: note.content.length,
lastDoc: lastContent, authorship: note.authorship
currDoc: content }).then(function (revision) {
}, function (err, patch) { // clear last revision content to reduce db size
if (err) logger.error('save note revision error', err) latestRevision.update({
if (!patch) { content: null
// if patch is empty (means no difference) then just update the latest revision updated time }).then(function () {
latestRevision.changed('updatedAt', true) Revision.finishSaveNoteRevision(note, revision, callback)
latestRevision.update({ }).catch(function (err) {
updatedAt: Date.now() return callback(err, null)
}).then(function (revision) { })
Revision.finishSaveNoteRevision(note, revision, callback) }).catch(function (err) {
}).catch(function (err) { return callback(err, null)
return callback(err, null)
})
} else {
Revision.create({
noteId: note.id,
patch: patch,
content: note.content,
length: note.content.length,
authorship: note.authorship
}).then(function (revision) {
// clear last revision content to reduce db size
latestRevision.update({
content: null
}).then(function () {
Revision.finishSaveNoteRevision(note, revision, callback)
}).catch(function (err) {
return callback(err, null)
})
}).catch(function (err) {
return callback(err, null)
})
}
}) })
} }
}).catch(function (err) {
return callback(err, null)
})
},
finishSaveNoteRevision: function (note, revision, callback) {
note.update({
savedAt: revision.updatedAt
}).then(function () {
return callback(null, revision)
}).catch(function (err) {
return callback(err, null)
}) })
} }
} }).catch(function (err) {
}) return callback(err, null)
})
}
Revision.finishSaveNoteRevision = function (note, revision, callback) {
note.update({
savedAt: revision.updatedAt
}).then(function () {
return callback(null, revision)
}).catch(function (err) {
return callback(err, null)
})
}
return Revision return Revision
} }

View File

@ -1,11 +1,11 @@
'use strict' 'use strict'
// external modules // external modules
var Sequelize = require('sequelize') var Sequelize = require('sequelize')
var scrypt = require('@mlink/scrypt') var scrypt = require('scrypt')
// core // core
var logger = require('../logger') var logger = require('../logger')
var {generateAvatarURL} = require('../letter-avatars') var { generateAvatarURL } = require('../letter-avatars')
module.exports = function (sequelize, DataTypes) { module.exports = function (sequelize, DataTypes) {
var User = sequelize.define('User', { var User = sequelize.define('User', {
@ -47,111 +47,108 @@ module.exports = function (sequelize, DataTypes) {
this.setDataValue('password', hash) this.setDataValue('password', hash)
} }
} }
}, { })
instanceMethods: {
verifyPassword: function (attempt) { User.prototype.verifyPassword = function (attempt) {
if (scrypt.verifyKdfSync(Buffer.from(this.password, 'hex'), attempt)) { if (scrypt.verifyKdfSync(Buffer.from(this.password, 'hex'), attempt)) {
return this return this
} else { } else {
return false return false
} }
} }
},
classMethods: { User.associate = function (models) {
associate: function (models) { User.hasMany(models.Note, {
User.hasMany(models.Note, { foreignKey: 'ownerId',
foreignKey: 'ownerId', constraints: false
constraints: false })
}) User.hasMany(models.Note, {
User.hasMany(models.Note, { foreignKey: 'lastchangeuserId',
foreignKey: 'lastchangeuserId', constraints: false
constraints: false })
}) }
}, User.getProfile = function (user) {
getProfile: function (user) { if (!user) {
if (!user) { return null
return null }
} return user.profile ? User.parseProfile(user.profile) : (user.email ? User.parseProfileByEmail(user.email) : null)
return user.profile ? User.parseProfile(user.profile) : (user.email ? User.parseProfileByEmail(user.email) : null) }
}, User.parseProfile = function (profile) {
parseProfile: function (profile) { try {
try { profile = JSON.parse(profile)
profile = JSON.parse(profile) } catch (err) {
} catch (err) { logger.error(err)
logger.error(err) profile = null
profile = null }
} if (profile) {
if (profile) { profile = {
profile = { name: profile.displayName || profile.username,
name: profile.displayName || profile.username, photo: User.parsePhotoByProfile(profile),
photo: User.parsePhotoByProfile(profile), biggerphoto: User.parsePhotoByProfile(profile, true)
biggerphoto: User.parsePhotoByProfile(profile, true)
}
}
return profile
},
parsePhotoByProfile: function (profile, bigger) {
var photo = null
switch (profile.provider) {
case 'facebook':
photo = 'https://graph.facebook.com/' + profile.id + '/picture'
if (bigger) photo += '?width=400'
else photo += '?width=96'
break
case 'twitter':
photo = 'https://twitter.com/' + profile.username + '/profile_image'
if (bigger) photo += '?size=original'
else photo += '?size=bigger'
break
case 'github':
photo = 'https://avatars.githubusercontent.com/u/' + profile.id
if (bigger) photo += '?s=400'
else photo += '?s=96'
break
case 'gitlab':
photo = profile.avatarUrl
if (photo) {
if (bigger) photo = photo.replace(/(\?s=)\d*$/i, '$1400')
else photo = photo.replace(/(\?s=)\d*$/i, '$196')
} else {
photo = generateAvatarURL(profile.username)
}
break
case 'mattermost':
photo = profile.avatarUrl
if (photo) {
if (bigger) photo = photo.replace(/(\?s=)\d*$/i, '$1400')
else photo = photo.replace(/(\?s=)\d*$/i, '$196')
} else {
photo = generateAvatarURL(profile.username)
}
break
case 'dropbox':
photo = generateAvatarURL('', profile.emails[0].value, bigger)
break
case 'google':
photo = profile.photos[0].value
if (bigger) photo = photo.replace(/(\?sz=)\d*$/i, '$1400')
else photo = photo.replace(/(\?sz=)\d*$/i, '$196')
break
case 'ldap':
photo = generateAvatarURL(profile.username, profile.emails[0], bigger)
break
case 'saml':
photo = generateAvatarURL(profile.username, profile.emails[0], bigger)
break
}
return photo
},
parseProfileByEmail: function (email) {
return {
name: email.substring(0, email.lastIndexOf('@')),
photo: generateAvatarURL('', email, false),
biggerphoto: generateAvatarURL('', email, true)
}
} }
} }
}) return profile
}
User.parsePhotoByProfile = function (profile, bigger) {
var photo = null
switch (profile.provider) {
case 'facebook':
photo = 'https://graph.facebook.com/' + profile.id + '/picture'
if (bigger) photo += '?width=400'
else photo += '?width=96'
break
case 'twitter':
photo = 'https://twitter.com/' + profile.username + '/profile_image'
if (bigger) photo += '?size=original'
else photo += '?size=bigger'
break
case 'github':
photo = 'https://avatars.githubusercontent.com/u/' + profile.id
if (bigger) photo += '?s=400'
else photo += '?s=96'
break
case 'gitlab':
photo = profile.avatarUrl
if (photo) {
if (bigger) photo = photo.replace(/(\?s=)\d*$/i, '$1400')
else photo = photo.replace(/(\?s=)\d*$/i, '$196')
} else {
photo = generateAvatarURL(profile.username)
}
break
case 'mattermost':
photo = profile.avatarUrl
if (photo) {
if (bigger) photo = photo.replace(/(\?s=)\d*$/i, '$1400')
else photo = photo.replace(/(\?s=)\d*$/i, '$196')
} else {
photo = generateAvatarURL(profile.username)
}
break
case 'dropbox':
photo = generateAvatarURL('', profile.emails[0].value, bigger)
break
case 'google':
photo = profile.photos[0].value
if (bigger) photo = photo.replace(/(\?sz=)\d*$/i, '$1400')
else photo = photo.replace(/(\?sz=)\d*$/i, '$196')
break
case 'ldap':
photo = generateAvatarURL(profile.username, profile.emails[0], bigger)
break
case 'saml':
photo = generateAvatarURL(profile.username, profile.emails[0], bigger)
break
}
return photo
}
User.parseProfileByEmail = function (email) {
return {
name: email.substring(0, email.lastIndexOf('@')),
photo: generateAvatarURL('', email, false),
biggerphoto: generateAvatarURL('', email, true)
}
}
return User return User
} }

106
lib/processQueue.js Normal file
View File

@ -0,0 +1,106 @@
'use strict'
const EventEmitter = require('events').EventEmitter
/**
* Queuing Class for connection queuing
*/
const QueueEvent = {
Tick: 'Tick',
Push: 'Push',
Finish: 'Finish'
}
class ProcessQueue extends EventEmitter {
constructor ({
maximumLength = 500,
triggerTimeInterval = 5000,
// execute on push
proactiveMode = true,
// execute next work on finish
continuousMode = true
}) {
super()
this.max = maximumLength
this.triggerTime = triggerTimeInterval
this.taskMap = new Map()
this.queue = []
this.lock = false
this.on(QueueEvent.Tick, this.onEventProcessFunc.bind(this))
if (proactiveMode) {
this.on(QueueEvent.Push, this.onEventProcessFunc.bind(this))
}
if (continuousMode) {
this.on(QueueEvent.Finish, this.onEventProcessFunc.bind(this))
}
}
onEventProcessFunc () {
if (this.lock) return
this.lock = true
setImmediate(() => {
this.process()
})
}
start () {
if (this.eventTrigger) return
this.eventTrigger = setInterval(() => {
this.emit(QueueEvent.Tick)
}, this.triggerTime)
}
stop () {
if (this.eventTrigger) {
clearInterval(this.eventTrigger)
this.eventTrigger = null
}
}
checkTaskIsInQueue (id) {
return this.taskMap.has(id)
}
/**
* pushWithKey a promisify-task to queue
* @param id {string}
* @param processingFunc {Function<Promise>}
* @returns {boolean} if success return true, otherwise false
*/
push (id, processingFunc) {
if (this.queue.length >= this.max) return false
if (this.checkTaskIsInQueue(id)) return false
const task = {
id: id,
processingFunc: processingFunc
}
this.taskMap.set(id, true)
this.queue.push(task)
this.start()
this.emit(QueueEvent.Push)
return true
}
process () {
if (this.queue.length <= 0) {
this.stop()
this.lock = false
return
}
const task = this.queue.shift()
this.taskMap.delete(task.id)
const finishTask = () => {
this.lock = false
setImmediate(() => {
this.emit(QueueEvent.Finish)
})
}
task.processingFunc().then(finishTask).catch(finishTask)
}
}
exports.ProcessQueue = ProcessQueue

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
'use strict'
const async = require('async')
const config = require('./config')
const logger = require('./logger')
/**
* clean when user not in any rooms or user not in connected list
*/
class CleanDanglingUserJob {
constructor (realtime) {
this.realtime = realtime
}
start () {
if (this.timer) return
this.timer = setInterval(this.cleanDanglingUser.bind(this), 60000)
}
stop () {
if (!this.timer) return
clearInterval(this.timer)
this.timer = undefined
}
cleanDanglingUser () {
const users = this.realtime.getUserPool()
async.each(Object.keys(users), (key, callback) => {
const socket = this.realtime.io.sockets.connected[key]
if ((!socket && users[key]) ||
(socket && (!socket.rooms || socket.rooms.length <= 0))) {
if (config.debug) {
logger.info('cleaner found redundant user: ' + key)
}
if (!socket) {
return callback(null, null)
}
if (!this.realtime.disconnectProcessQueue.checkTaskIsInQueue(socket.id)) {
this.realtime.queueForDisconnect(socket)
}
}
return callback(null, null)
}, function (err) {
if (err) return logger.error('cleaner error', err)
})
}
}
exports.CleanDanglingUserJob = CleanDanglingUserJob

View File

@ -0,0 +1,232 @@
'use strict'
const get = require('lodash/get')
const config = require('./config')
const models = require('./models')
const logger = require('./logger')
class RealtimeClientConnection {
constructor (socket) {
this.socket = socket
this.realtime = require('./realtime')
}
registerEventHandler () {
// received client refresh request
this.socket.on('refresh', this.refreshEventHandler.bind(this))
// received user status
this.socket.on('user status', this.userStatusEventHandler.bind(this))
// when a new client disconnect
this.socket.on('disconnect', this.disconnectEventHandler.bind(this))
// received cursor focus
this.socket.on('cursor focus', this.cursorFocusEventHandler.bind(this))
// received cursor activity
this.socket.on('cursor activity', this.cursorActivityEventHandler.bind(this))
// received cursor blur
this.socket.on('cursor blur', this.cursorBlurEventHandler.bind(this))
// check version
this.socket.on('version', this.checkVersionEventHandler.bind(this))
// received sync of online users request
this.socket.on('online users', this.onlineUsersEventHandler.bind(this))
// reveiced when user logout or changed
this.socket.on('user changed', this.userChangedEventHandler.bind(this))
// delete a note
this.socket.on('delete', this.deleteNoteEventHandler.bind(this))
// received note permission change request
this.socket.on('permission', this.permissionChangeEventHandler.bind(this))
}
isUserLoggedIn () {
return this.socket.request.user && this.socket.request.user.logged_in
}
isNoteAndUserExists () {
const note = this.realtime.getNoteFromNotePool(this.socket.noteId)
const user = this.realtime.getUserFromUserPool(this.socket.id)
return note && user
}
isNoteOwner () {
const note = this.getCurrentNote()
return get(note, 'owner') === this.getCurrentLoggedInUserId()
}
isAnonymousEnable () {
// TODO: move this method to config module
return config.allowAnonymous || config.allowAnonymousEdits
}
getCurrentUser () {
if (!this.socket.id) return
return this.realtime.getUserFromUserPool(this.socket.id)
}
getCurrentLoggedInUserId () {
return get(this.socket, 'request.user.id')
}
getCurrentNote () {
if (!this.socket.noteId) return
return this.realtime.getNoteFromNotePool(this.socket.noteId)
}
getNoteChannel () {
return this.socket.broadcast.to(this.socket.noteId)
}
async destroyNote (id) {
return models.Note.destroy({
where: { id: id }
})
}
async changeNotePermission (newPermission) {
const [changedRows] = await models.Note.update({
permission: newPermission
}, {
where: {
id: this.getCurrentNote().id
}
})
if (changedRows !== 1) {
throw new Error(`updated permission failed, cannot set permission ${newPermission} to note ${this.getCurrentNote().id}`)
}
}
notifyPermissionChanged () {
this.realtime.io.to(this.getCurrentNote().id).emit('permission', {
permission: this.getCurrentNote().permission
})
this.getCurrentNote().socks.forEach((sock) => {
if (sock) {
if (!this.realtime.checkViewPermission(sock.request, this.getCurrentNote())) {
sock.emit('info', {
code: 403
})
setTimeout(function () {
sock.disconnect(true)
}, 0)
}
}
})
}
refreshEventHandler () {
this.realtime.emitRefresh(this.socket)
}
checkVersionEventHandler () {
this.socket.emit('version', {
version: config.fullversion,
minimumCompatibleVersion: config.minimumCompatibleVersion
})
}
userStatusEventHandler (data) {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
if (config.debug) {
logger.info('SERVER received [' + this.socket.noteId + '] user status from [' + this.socket.id + ']: ' + JSON.stringify(data))
}
if (data) {
user.idle = data.idle
user.type = data.type
}
this.realtime.emitUserStatus(this.socket)
}
userChangedEventHandler () {
logger.info('user changed')
const note = this.getCurrentNote()
if (!note) return
const user = note.users[this.socket.id]
if (!user) return
this.realtime.updateUserData(this.socket, user)
this.realtime.emitOnlineUsers(this.socket)
}
onlineUsersEventHandler () {
if (!this.isNoteAndUserExists()) return
const currentNote = this.getCurrentNote()
const currentNoteOnlineUserList = Object.keys(currentNote.users)
.map(key => this.realtime.buildUserOutData(currentNote.users[key]))
this.socket.emit('online users', {
users: currentNoteOnlineUserList
})
}
cursorFocusEventHandler (data) {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
user.cursor = data
const out = this.realtime.buildUserOutData(user)
this.getNoteChannel().emit('cursor focus', out)
}
cursorActivityEventHandler (data) {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
user.cursor = data
const out = this.realtime.buildUserOutData(user)
this.getNoteChannel().emit('cursor activity', out)
}
cursorBlurEventHandler () {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
user.cursor = null
this.getNoteChannel().emit('cursor blur', {
id: this.socket.id
})
}
deleteNoteEventHandler () {
// need login to do more actions
if (this.isUserLoggedIn() && this.isNoteAndUserExists()) {
const note = this.getCurrentNote()
// Only owner can delete note
if (note.owner && note.owner === this.getCurrentLoggedInUserId()) {
this.destroyNote(note.id)
.then((successRows) => {
if (!successRows) return
this.realtime.disconnectSocketOnNote(note)
})
.catch(function (err) {
return logger.error('delete note failed: ' + err)
})
}
}
}
permissionChangeEventHandler (permission) {
if (!this.isUserLoggedIn()) return
if (!this.isNoteAndUserExists()) return
const note = this.getCurrentNote()
// Only owner can change permission
if (!this.isNoteOwner()) return
if (!this.isAnonymousEnable() && permission === 'freely') return
this.changeNotePermission(permission)
.then(() => {
note.permission = permission
this.notifyPermissionChanged()
})
.catch(err => logger.error('update note permission failed: ' + err))
}
disconnectEventHandler () {
if (this.realtime.disconnectProcessQueue.checkTaskIsInQueue(this.socket.id)) {
return
}
this.realtime.queueForDisconnect(this.socket)
}
}
exports.RealtimeClientConnection = RealtimeClientConnection

View File

@ -0,0 +1,45 @@
'use strict'
const models = require('./models')
const logger = require('./logger')
/**
* clean when user not in any rooms or user not in connected list
*/
class SaveRevisionJob {
constructor (realtime) {
this.realtime = realtime
this.saverSleep = false
}
start () {
if (this.timer) return
this.timer = setInterval(this.saveRevision.bind(this), 5 * 60 * 1000)
}
stop () {
if (!this.timer) return
clearInterval(this.timer)
this.timer = undefined
}
saveRevision () {
if (this.getSaverSleep()) return
models.Revision.saveAllNotesRevision((err, notes) => {
if (err) return logger.error('revision saver failed: ' + err)
if (notes && notes.length <= 0) {
this.setSaverSleep(true)
}
})
}
getSaverSleep () {
return this.saverSleep
}
setSaverSleep (val) {
this.saverSleep = val
}
}
exports.SaveRevisionJob = SaveRevisionJob

View File

@ -0,0 +1,78 @@
'use strict'
const config = require('./config')
const logger = require('./logger')
const moment = require('moment')
class UpdateDirtyNoteJob {
constructor (realtime) {
this.realtime = realtime
}
start () {
if (this.timer) return
this.timer = setInterval(this.updateDirtyNotes.bind(this), 1000)
}
stop () {
if (!this.timer) return
clearInterval(this.timer)
this.timer = undefined
}
updateDirtyNotes () {
const notes = this.realtime.getNotePool()
Object.keys(notes).forEach((key) => {
const note = notes[key]
this.updateDirtyNote(note)
.catch((err) => {
logger.error('updateDirtyNote: updater error', err)
})
})
}
async updateDirtyNote (note) {
const notes = this.realtime.getNotePool()
if (!note.server.isDirty) return
if (config.debug) logger.info('updateDirtyNote: updater found dirty note: ' + note.id)
note.server.isDirty = false
try {
const _note = await this.updateNoteAsync(note)
// handle when note already been clean up
if (!notes[note.id] || !notes[note.id].server) return
if (!_note) {
this.realtime.io.to(note.id).emit('info', {
code: 404
})
logger.error('updateDirtyNote: note not found: ', note.id)
this.realtime.disconnectSocketOnNote(note)
}
note.updatetime = moment(_note.lastchangeAt).valueOf()
this.realtime.emitCheck(note)
} catch (err) {
logger.error('updateDirtyNote: note not found: ', note.id)
this.realtime.io.to(note.id).emit('info', {
code: 404
})
this.realtime.disconnectSocketOnNote(note)
throw err
}
}
updateNoteAsync (note) {
return new Promise((resolve, reject) => {
this.realtime.updateNote(note, (err, _note) => {
if (err) {
return reject(err)
}
return resolve(_note)
})
})
}
}
exports.UpdateDirtyNoteJob = UpdateDirtyNoteJob

View File

@ -14,16 +14,17 @@ var config = require('./config')
var logger = require('./logger') var logger = require('./logger')
var models = require('./models') var models = require('./models')
var utils = require('./utils') var utils = require('./utils')
var history = require('./history')
// public // public
var response = { var response = {
errorForbidden: function (res) { errorForbidden: function (res) {
const {req} = res const { req } = res
if (req.user) { if (req.user) {
responseError(res, '403', 'Forbidden', 'oh no.') responseError(res, '403', 'Forbidden', 'oh no.')
} else { } else {
req.flash('error', 'You are not allowed to access this page. Maybe try logging in?') req.flash('error', 'You are not allowed to access this page. Maybe try logging in?')
res.redirect(config.serverURL) res.redirect(config.serverURL + '/')
} }
}, },
errorNotFound: function (res) { errorNotFound: function (res) {
@ -106,6 +107,12 @@ function responseCodiMD (res, note) {
}) })
} }
function updateHistory (userId, note, document, time) {
var noteId = note.alias ? note.alias : models.Note.encodeNoteId(note.id)
history.updateHistory(userId, noteId, document, time)
logger.info('history updated')
}
function newNote (req, res, next) { function newNote (req, res, next) {
var owner = null var owner = null
var body = '' var body = ''
@ -125,6 +132,10 @@ function newNote (req, res, next) {
alias: req.alias ? req.alias : null, alias: req.alias ? req.alias : null,
content: body content: body
}).then(function (note) { }).then(function (note) {
if (req.isAuthenticated()) {
updateHistory(owner, note, body)
}
return res.redirect(config.serverURL + '/' + models.Note.encodeNoteId(note.id)) return res.redirect(config.serverURL + '/' + models.Note.encodeNoteId(note.id))
}).catch(function (err) { }).catch(function (err) {
logger.error(err) logger.error(err)
@ -549,16 +560,16 @@ function gitlabActionProjects (req, res, note) {
ret.accesstoken = user.accessToken ret.accesstoken = user.accessToken
ret.profileid = user.profileid ret.profileid = user.profileid
request( request(
config.gitlab.baseURL + '/api/' + config.gitlab.version + '/projects?membership=yes&per_page=100&access_token=' + user.accessToken, config.gitlab.baseURL + '/api/' + config.gitlab.version + '/projects?membership=yes&per_page=100&access_token=' + user.accessToken,
function (error, httpResponse, body) { function (error, httpResponse, body) {
if (!error && httpResponse.statusCode === 200) { if (!error && httpResponse.statusCode === 200) {
ret.projects = JSON.parse(body) ret.projects = JSON.parse(body)
return res.send(ret) return res.send(ret)
} else { } else {
return res.send(ret) return res.send(ret)
} }
} }
) )
}).catch(function (err) { }).catch(function (err) {
logger.error('gitlab action projects failed: ' + err) logger.error('gitlab action projects failed: ' + err)
return response.errorInternalError(res) return response.errorInternalError(res)

View File

@ -4,7 +4,7 @@ const Router = require('express').Router
const passport = require('passport') const passport = require('passport')
const DropboxStrategy = require('passport-dropbox-oauth2').Strategy const DropboxStrategy = require('passport-dropbox-oauth2').Strategy
const config = require('../../../config') const config = require('../../../config')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let dropboxAuth = module.exports = Router() let dropboxAuth = module.exports = Router()

View File

@ -7,8 +7,8 @@ const LocalStrategy = require('passport-local').Strategy
const config = require('../../../config') const config = require('../../../config')
const models = require('../../../models') const models = require('../../../models')
const logger = require('../../../logger') const logger = require('../../../logger')
const {setReturnToFromReferer} = require('../utils') const { setReturnToFromReferer } = require('../utils')
const {urlencodedParser} = require('../../utils') const { urlencodedParser } = require('../../utils')
const response = require('../../../response') const response = require('../../../response')
let emailAuth = module.exports = Router() let emailAuth = module.exports = Router()

View File

@ -5,7 +5,7 @@ const passport = require('passport')
const FacebookStrategy = require('passport-facebook').Strategy const FacebookStrategy = require('passport-facebook').Strategy
const config = require('../../../config') const config = require('../../../config')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let facebookAuth = module.exports = Router() let facebookAuth = module.exports = Router()

View File

@ -5,7 +5,7 @@ const passport = require('passport')
const GithubStrategy = require('passport-github').Strategy const GithubStrategy = require('passport-github').Strategy
const config = require('../../../config') const config = require('../../../config')
const response = require('../../../response') const response = require('../../../response')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let githubAuth = module.exports = Router() let githubAuth = module.exports = Router()

View File

@ -5,7 +5,7 @@ const passport = require('passport')
const GitlabStrategy = require('passport-gitlab2').Strategy const GitlabStrategy = require('passport-gitlab2').Strategy
const config = require('../../../config') const config = require('../../../config')
const response = require('../../../response') const response = require('../../../response')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let gitlabAuth = module.exports = Router() let gitlabAuth = module.exports = Router()

View File

@ -4,21 +4,22 @@ const Router = require('express').Router
const passport = require('passport') const passport = require('passport')
var GoogleStrategy = require('passport-google-oauth20').Strategy var GoogleStrategy = require('passport-google-oauth20').Strategy
const config = require('../../../config') const config = require('../../../config')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let googleAuth = module.exports = Router() let googleAuth = module.exports = Router()
passport.use(new GoogleStrategy({ passport.use(new GoogleStrategy({
clientID: config.google.clientID, clientID: config.google.clientID,
clientSecret: config.google.clientSecret, clientSecret: config.google.clientSecret,
callbackURL: config.serverURL + '/auth/google/callback' callbackURL: config.serverURL + '/auth/google/callback',
userProfileURL: 'https://www.googleapis.com/oauth2/v3/userinfo'
}, passportGeneralCallback)) }, passportGeneralCallback))
googleAuth.get('/auth/google', function (req, res, next) { googleAuth.get('/auth/google', function (req, res, next) {
setReturnToFromReferer(req) setReturnToFromReferer(req)
passport.authenticate('google', { scope: ['profile'] })(req, res, next) passport.authenticate('google', { scope: ['profile'] })(req, res, next)
}) })
// google auth callback // google auth callback
googleAuth.get('/auth/google/callback', googleAuth.get('/auth/google/callback',
passport.authenticate('google', { passport.authenticate('google', {
successReturnToOrRedirect: config.serverURL + '/', successReturnToOrRedirect: config.serverURL + '/',

View File

@ -6,8 +6,8 @@ const LDAPStrategy = require('passport-ldapauth')
const config = require('../../../config') const config = require('../../../config')
const models = require('../../../models') const models = require('../../../models')
const logger = require('../../../logger') const logger = require('../../../logger')
const {setReturnToFromReferer} = require('../utils') const { setReturnToFromReferer } = require('../utils')
const {urlencodedParser} = require('../../utils') const { urlencodedParser } = require('../../utils')
const response = require('../../../response') const response = require('../../../response')
let ldapAuth = module.exports = Router() let ldapAuth = module.exports = Router()

View File

@ -1,16 +1,17 @@
'use strict' 'use strict'
require('babel-polyfill')
require('isomorphic-fetch')
const Router = require('express').Router const Router = require('express').Router
const passport = require('passport') const passport = require('passport')
const Mattermost = require('mattermost') const MattermostClient = require('mattermost-redux/client/client4').default
const OAuthStrategy = require('passport-oauth2').Strategy const OAuthStrategy = require('passport-oauth2').Strategy
const config = require('../../../config') const config = require('../../../config')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
const mattermost = new Mattermost.Client()
let mattermostAuth = module.exports = Router() let mattermostAuth = module.exports = Router()
const mattermostClient = new MattermostClient()
let mattermostStrategy = new OAuthStrategy({ let mattermostStrategy = new OAuthStrategy({
authorizationURL: config.mattermost.baseURL + '/oauth/authorize', authorizationURL: config.mattermost.baseURL + '/oauth/authorize',
tokenURL: config.mattermost.baseURL + '/oauth/access_token', tokenURL: config.mattermost.baseURL + '/oauth/access_token',
@ -20,17 +21,11 @@ let mattermostStrategy = new OAuthStrategy({
}, passportGeneralCallback) }, passportGeneralCallback)
mattermostStrategy.userProfile = (accessToken, done) => { mattermostStrategy.userProfile = (accessToken, done) => {
mattermost.setUrl(config.mattermost.baseURL) mattermostClient.setUrl(config.mattermost.baseURL)
mattermost.token = accessToken mattermostClient.setToken(accessToken)
mattermost.useHeaderToken() mattermostClient.getMe()
mattermost.getMe( .then((data) => done(null, data))
(data) => { .catch((err) => done(err))
done(null, data)
},
(err) => {
done(err)
}
)
} }
passport.use(mattermostStrategy) passport.use(mattermostStrategy)

View File

@ -4,7 +4,7 @@ const Router = require('express').Router
const passport = require('passport') const passport = require('passport')
const { Strategy, InternalOAuthError } = require('passport-oauth2') const { Strategy, InternalOAuthError } = require('passport-oauth2')
const config = require('../../../config') const config = require('../../../config')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let oauth2Auth = module.exports = Router() let oauth2Auth = module.exports = Router()

View File

@ -6,8 +6,8 @@ const OpenIDStrategy = require('@passport-next/passport-openid').Strategy
const config = require('../../../config') const config = require('../../../config')
const models = require('../../../models') const models = require('../../../models')
const logger = require('../../../logger') const logger = require('../../../logger')
const {urlencodedParser} = require('../../utils') const { urlencodedParser } = require('../../utils')
const {setReturnToFromReferer} = require('../utils') const { setReturnToFromReferer } = require('../utils')
let openIDAuth = module.exports = Router() let openIDAuth = module.exports = Router()

View File

@ -6,7 +6,7 @@ const SamlStrategy = require('passport-saml').Strategy
const config = require('../../../config') const config = require('../../../config')
const models = require('../../../models') const models = require('../../../models')
const logger = require('../../../logger') const logger = require('../../../logger')
const {urlencodedParser} = require('../../utils') const { urlencodedParser } = require('../../utils')
const fs = require('fs') const fs = require('fs')
const intersection = function (array1, array2) { return array1.filter((n) => array2.includes(n)) } const intersection = function (array1, array2) { return array1.filter((n) => array2.includes(n)) }

View File

@ -5,7 +5,7 @@ const passport = require('passport')
const TwitterStrategy = require('passport-twitter').Strategy const TwitterStrategy = require('passport-twitter').Strategy
const config = require('../../../config') const config = require('../../../config')
const {setReturnToFromReferer, passportGeneralCallback} = require('../utils') const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let twitterAuth = module.exports = Router() let twitterAuth = module.exports = Router()

View File

@ -2,7 +2,7 @@
const Router = require('express').Router const Router = require('express').Router
const {urlencodedParser} = require('./utils') const { urlencodedParser } = require('./utils')
const history = require('../history') const history = require('../history')
const historyRouter = module.exports = Router() const historyRouter = module.exports = Router()

View File

@ -16,5 +16,12 @@ exports.uploadImage = function (imagePath, callback) {
return return
} }
callback(null, (new URL(path.basename(imagePath), config.serverURL + '/uploads/')).href) let url
try {
url = (new URL(path.basename(imagePath), config.serverURL + '/uploads/')).href
} catch (e) {
url = config.serverURL + '/uploads/' + path.basename(imagePath)
}
callback(null, url)
} }

View File

@ -2,7 +2,7 @@
const config = require('../../config') const config = require('../../config')
const logger = require('../../logger') const logger = require('../../logger')
const imgur = require('imgur') const imgur = require('@hackmd/imgur')
exports.uploadImage = function (imagePath, callback) { exports.uploadImage = function (imagePath, callback) {
if (!imagePath || typeof imagePath !== 'string') { if (!imagePath || typeof imagePath !== 'string') {
@ -17,12 +17,12 @@ exports.uploadImage = function (imagePath, callback) {
imgur.setClientId(config.imgur.clientID) imgur.setClientId(config.imgur.clientID)
imgur.uploadFile(imagePath) imgur.uploadFile(imagePath)
.then(function (json) { .then(function (json) {
if (config.debug) { if (config.debug) {
logger.info('SERVER uploadimage success: ' + JSON.stringify(json)) logger.info('SERVER uploadimage success: ' + JSON.stringify(json))
} }
callback(null, json.data.link.replace(/^http:\/\//i, 'https://')) callback(null, json.data.link.replace(/^http:\/\//i, 'https://'))
}).catch(function (err) { }).catch(function (err) {
callback(new Error(err), null) callback(new Error(err), null)
}) })
} }

View File

@ -3,7 +3,7 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const config = require('../../config') const config = require('../../config')
const {getImageMimeType} = require('../../utils') const { getImageMimeType } = require('../../utils')
const logger = require('../../logger') const logger = require('../../logger')
const Minio = require('minio') const Minio = require('minio')
@ -40,7 +40,9 @@ exports.uploadImage = function (imagePath, callback) {
callback(new Error(err), null) callback(new Error(err), null)
return return
} }
callback(null, `${protocol}://${config.minio.endPoint}:${config.minio.port}/${config.s3bucket}/${key}`) let hidePort = [80, 443].includes(config.minio.port)
let urlPort = hidePort ? '' : `:${config.minio.port}`
callback(null, `${protocol}://${config.minio.endPoint}${urlPort}/${config.s3bucket}/${key}`)
}) })
}) })
} }

View File

@ -3,7 +3,7 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const config = require('../../config') const config = require('../../config')
const {getImageMimeType} = require('../../utils') const { getImageMimeType } = require('../../utils')
const logger = require('../../logger') const logger = require('../../logger')
const AWS = require('aws-sdk') const AWS = require('aws-sdk')
@ -42,7 +42,9 @@ exports.uploadImage = function (imagePath, callback) {
} }
let s3Endpoint = 's3.amazonaws.com' let s3Endpoint = 's3.amazonaws.com'
if (config.s3.region && config.s3.region !== 'us-east-1') { if (config.s3.endpoint) {
s3Endpoint = config.s3.endpoint
} else if (config.s3.region && config.s3.region !== 'us-east-1') {
s3Endpoint = `s3-${config.s3.region}.amazonaws.com` s3Endpoint = `s3-${config.s3.region}.amazonaws.com`
} }
callback(null, `https://${s3Endpoint}/${config.s3bucket}/${params.Key}`) callback(null, `https://${s3Endpoint}/${config.s3bucket}/${params.Key}`)

View File

@ -2,8 +2,11 @@
const toobusy = require('toobusy-js') const toobusy = require('toobusy-js')
const config = require('../../config')
const response = require('../../response') const response = require('../../response')
toobusy.maxLag(config.responseMaxLag)
module.exports = function (req, res, next) { module.exports = function (req, res, next) {
if (toobusy()) { if (toobusy()) {
response.errorServiceUnavailable(res) response.errorServiceUnavailable(res)

View File

@ -4,7 +4,7 @@ const Router = require('express').Router
const response = require('../response') const response = require('../response')
const {markdownParser} = require('./utils') const { markdownParser } = require('./utils')
const noteRouter = module.exports = Router() const noteRouter = module.exports = Router()

View File

@ -8,7 +8,7 @@ const config = require('../config')
const models = require('../models') const models = require('../models')
const logger = require('../logger') const logger = require('../logger')
const {urlencodedParser} = require('./utils') const { urlencodedParser } = require('./utils')
const statusRouter = module.exports = Router() const statusRouter = module.exports = Router()

View File

@ -8,7 +8,7 @@ const response = require('../response')
const config = require('../config') const config = require('../config')
const models = require('../models') const models = require('../models')
const logger = require('../logger') const logger = require('../logger')
const {generateAvatar} = require('../letter-avatars') const { generateAvatar } = require('../letter-avatars')
const UserRouter = module.exports = Router() const UserRouter = module.exports = Router()
@ -33,7 +33,7 @@ UserRouter.get('/me', function (req, res) {
return response.errorInternalError(res) return response.errorInternalError(res)
}) })
} else { } else {
res.send({ res.status(401).send({
status: 'forbidden' status: 'forbidden'
}) })
} }

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
// external modules // external modules
var DiffMatchPatch = require('diff-match-patch') var DiffMatchPatch = require('@hackmd/diff-match-patch')
var dmp = new DiffMatchPatch() var dmp = new DiffMatchPatch()
// core // core

View File

@ -113,5 +113,7 @@
"Delete user": "Delete user", "Delete user": "Delete user",
"Export user data": "Export user data", "Export user data": "Export user data",
"Help us translating on %s": "Help us translating on %s", "Help us translating on %s": "Help us translating on %s",
"Source Code": "Source Code" "Source Code": "Source Code",
"Powered by %s": "Powered by %s",
"Register": "Register"
} }

View File

@ -15,7 +15,7 @@
"or": "ou", "or": "ou",
"Sign Out": "Se déconnecter", "Sign Out": "Se déconnecter",
"Explore all features": "Explorer toutes les fonctionnalités", "Explore all features": "Explorer toutes les fonctionnalités",
"Select tags...": "Selectionner les tags...", "Select tags...": "Sélectionner les tags...",
"Search keyword...": "Chercher un mot-clef...", "Search keyword...": "Chercher un mot-clef...",
"Sort by title": "Trier par titre", "Sort by title": "Trier par titre",
"Title": "Titre", "Title": "Titre",
@ -29,7 +29,7 @@
"Import from browser": "Importer depuis le navigateur", "Import from browser": "Importer depuis le navigateur",
"Releases": "Versions", "Releases": "Versions",
"Are you sure?": "Ëtes-vous sûr ?", "Are you sure?": "Ëtes-vous sûr ?",
"Do you really want to delete this note?": "Voulez-vous vraiment supprimer cette note?", "Do you really want to delete this note?": "Voulez-vous vraiment supprimer cette note ?",
"All users will lose their connection.": "Tous les utilisateurs perdront leur connexion.", "All users will lose their connection.": "Tous les utilisateurs perdront leur connexion.",
"Cancel": "Annuler", "Cancel": "Annuler",
"Yes, do it!": "Oui, je suis sûr !", "Yes, do it!": "Oui, je suis sûr !",
@ -62,7 +62,7 @@
"Refresh": "Recharger", "Refresh": "Recharger",
"Contacts": "Contacts", "Contacts": "Contacts",
"Report an issue": "Signaler un problème", "Report an issue": "Signaler un problème",
"Meet us on %s": "Rencontrez-nous sur %s", "Meet us on %s": "Rencontrez-nous sur %s",
"Send us email": "Envoyez-nous un mail", "Send us email": "Envoyez-nous un mail",
"Documents": "Documents", "Documents": "Documents",
"Features": "Fonctionnalités", "Features": "Fonctionnalités",
@ -88,14 +88,14 @@
"This is a alert area.": "Ceci est un texte d'alerte.", "This is a alert area.": "Ceci est un texte d'alerte.",
"Revert": "Revenir en arrière", "Revert": "Revenir en arrière",
"Import from clipboard": "Importer depuis le presse-papier", "Import from clipboard": "Importer depuis le presse-papier",
"Paste your markdown or webpage here...": "Coller votre markdown ou votre page web ici...", "Paste your markdown or webpage here...": "Collez votre markdown ou votre page web ici...",
"Clear": "Vider", "Clear": "Vider",
"This note is locked": "Cette note est verrouillée", "This note is locked": "Cette note est verrouillée",
"Sorry, only owner can edit this note.": "Désolé, seulement le propriétaire peut éditer cette note.", "Sorry, only owner can edit this note.": "Désolé, seul le propriétaire peut éditer cette note.",
"OK": "OK", "OK": "OK",
"Reach the limit": "Atteindre la limite", "Reach the limit": "Atteindre la limite",
"Sorry, you've reached the max length this note can be.": "Désolé, vous avez atteint la longueur maximale que cette note peut avoir.", "Sorry, you've reached the max length this note can be.": "Désolé, vous avez atteint la longueur maximale que cette note peut avoir.",
"Please reduce the content or divide it to more notes, thank you!": "Merci de réduire le contenu ou de le diviser en plusieurs notes!", "Please reduce the content or divide it to more notes, thank you!": "Merci de réduire le contenu ou de le diviser en plusieurs notes !",
"Import from Gist": "Importer depuis Gist", "Import from Gist": "Importer depuis Gist",
"Paste your gist url here...": "Coller l'URL de votre Gist ici...", "Paste your gist url here...": "Coller l'URL de votre Gist ici...",
"Import from Snippet": "Importer depuis Snippet", "Import from Snippet": "Importer depuis Snippet",
@ -104,14 +104,16 @@
"OR": "OU", "OR": "OU",
"Export to Snippet": "Exporter vers Snippet", "Export to Snippet": "Exporter vers Snippet",
"Select Visibility Level": "Sélectionner le niveau de visibilité", "Select Visibility Level": "Sélectionner le niveau de visibilité",
"Night Theme": "Theme Nuit", "Night Theme": "Thème Nuit",
"Follow us on %s and %s.": "Suivez-nous sur %s, et %s.", "Follow us on %s and %s.": "Suivez-nous sur %s, et %s.",
"Privacy": "Confidentialité", "Privacy": "Confidentialité",
"Terms of Use": "Conditions d'utilisation", "Terms of Use": "Conditions d'utilisation",
"Do you really want to delete your user account?": "Voulez-vous vraiment supprimer votre compte utilisateur", "Do you really want to delete your user account?": "Voulez-vous vraiment supprimer votre compte utilisateur ?",
"This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "Cela supprimera votre compte, toutes les notes dont vous êtes propriétaire et supprimera toute référence à votre compte dans les autres notes.", "This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "Cela supprimera votre compte, toutes les notes dont vous êtes propriétaire et supprimera toute référence à votre compte dans les autres notes.",
"Delete user": "Suprrimez l'utilisteur", "Delete user": "Supprimer l'utilisateur",
"Export user data": "Exportez les données utilisateur", "Export user data": "Exporter les données utilisateur",
"Help us translating on %s": "Aidez nous à traduire sur %s", "Help us translating on %s": "Aidez nous à traduire sur %s",
"Source Code": "Code source" "Source Code": "Code source",
"Register": "S'enregistrer",
"Powered by %s": "Propulsé par %s"
} }

View File

@ -111,5 +111,9 @@
"Do you really want to delete your user account?": "Apakah anda yakin ingin menghapus akun anda?", "Do you really want to delete your user account?": "Apakah anda yakin ingin menghapus akun anda?",
"This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "Ini akan menghapus akun anda, semua catatan yang dimiliki oleh anda akan dihapus dan menghapus semua referensi ke akun anda dari catatan lain.", "This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "Ini akan menghapus akun anda, semua catatan yang dimiliki oleh anda akan dihapus dan menghapus semua referensi ke akun anda dari catatan lain.",
"Delete user": "Hapus pengguna", "Delete user": "Hapus pengguna",
"Export user data": "Ekspor data pengguna" "Export user data": "Ekspor data pengguna",
"Help us translating on %s": "Bantu kami menerjemahkan di %s",
"Source Code": "Sumber Kode",
"Register": "Daftar",
"Powered by %s": "Ditenagai oleh %s"
} }

View File

@ -113,5 +113,7 @@
"Delete user": "Elimina utente", "Delete user": "Elimina utente",
"Export user data": "Esporta dati utente", "Export user data": "Esporta dati utente",
"Help us translating on %s": "Aiutaci nella traduzione su %s", "Help us translating on %s": "Aiutaci nella traduzione su %s",
"Source Code": "Codice Sorgente" "Source Code": "Codice Sorgente",
"Register": "Registrati",
"Powered by %s": "Alimentato da %s"
} }

View File

@ -113,5 +113,7 @@
"Delete user": "ユーザーの削除", "Delete user": "ユーザーの削除",
"Export user data": "ユーザーデータをエクスポート", "Export user data": "ユーザーデータをエクスポート",
"Help us translating on %s": "%s の翻訳にご協力ください", "Help us translating on %s": "%s の翻訳にご協力ください",
"Source Code": "ソースコード" "Source Code": "ソースコード",
"Register": "登録",
"Powered by %s": "Powered by %s"
} }

View File

@ -6,8 +6,8 @@
"History": "Geschiedenis", "History": "Geschiedenis",
"New guest note": "Nieuwe gastnotitie", "New guest note": "Nieuwe gastnotitie",
"Collaborate with URL": "Samenwerken met URL", "Collaborate with URL": "Samenwerken met URL",
"Support charts and MathJax": "Ondersteun grafieken en MathJax", "Support charts and MathJax": "Ondersteunt grafieken en MathJax",
"Support slide mode": "Ondersteun presentatiemodus", "Support slide mode": "Ondersteunt presentatiemodus",
"Sign In": "Inloggen", "Sign In": "Inloggen",
"Below is the history from browser": "Hier onder staat de browser geschiedenis", "Below is the history from browser": "Hier onder staat de browser geschiedenis",
"Welcome!": "Welkom!", "Welcome!": "Welkom!",
@ -113,5 +113,7 @@
"Delete user": "Gebruiker verwijderen", "Delete user": "Gebruiker verwijderen",
"Export user data": "Gebruikersdata exporteren", "Export user data": "Gebruikersdata exporteren",
"Help us translating on %s": "Help ons vertalen op %s", "Help us translating on %s": "Help ons vertalen op %s",
"Source Code": "Broncode" "Source Code": "Broncode",
"Register": "Registreren",
"Powered by %s": "Powered by %s"
} }

117
locales/sr.json Normal file
View File

@ -0,0 +1,117 @@
{
"Collaborative markdown notes": "Дељене белешке у Markdown формату",
"Realtime collaborative markdown notes on all platforms.": "Заједнички рад на markdown тексту у реалном времену, на свим платформама",
"Best way to write and share your knowledge in markdown.": "Савршен начин за писање и дељење знања у markdown формату",
"Intro": "Увод",
"History": "Историја",
"New guest note": "Нова белешка госта",
"Collaborate with URL": "Сарадња уз помоћ URL-а",
"Support charts and MathJax": "Подршка за графиконе и MathJax",
"Support slide mode": "Подршка за слајдове и презентације",
"Sign In": "Пријави се",
"Below is the history from browser": "Ниже је историјат преузет из прегледача",
"Welcome!": "Добродошли!",
"New note": "Нова белешка",
"or": "или",
"Sign Out": "Одјави се",
"Explore all features": "Истражи све могућности",
"Select tags...": "Одабери тагове...",
"Search keyword...": "Претрага по кључној речи...",
"Sort by title": "Редослед по наслову",
"Title": "Наслов",
"Sort by time": "Редослед по времену",
"Time": "време",
"Export history": "Извези историјат",
"Import history": "Увези историјат",
"Clear history": "Очисти историју",
"Refresh history": "Освежи историју",
"No history": "Нема историје",
"Import from browser": "Увези из прегледача",
"Releases": "Издања",
"Are you sure?": "Јесте ли сигурни?",
"Do you really want to delete this note?": "Да ли заиста желите да обришете ову белешку?",
"All users will lose their connection.": "Сви корисници ће изгубити везу у реалном времену.",
"Cancel": "Одустани",
"Yes, do it!": "Да, уради!",
"Choose method": "Изаберите начин",
"Sign in via %s": "Пријавите се помоћу %s",
"New": "Ново",
"Publish": "Објави",
"Extra": "Додатно",
"Revision": "Ревизија",
"Slide Mode": "Презентациони мод",
"Export": "Извоз",
"Import": "Увоз",
"Clipboard": "Клипборд",
"Download": "Преузимање",
"Raw HTML": "Сирови HTML",
"Edit": "Измени",
"View": "Прегледај",
"Both": "Обоје",
"Help": "Помоћ",
"Upload Image": "Пошаљи слику",
"Menu": "Мени",
"This page need refresh": "Ову страну је неопходно освежити",
"You have an incompatible client version.": "Ова верзија клијента није компатибилна.",
"Refresh to update.": "Освежите за приказ измена.",
"New version available!": "Доступна је нова верзија!",
"See releases notes here": "Овде погледајте напомене о издањима",
"Refresh to enjoy new features.": "Освежите како бисте уживали у новим функцијама.",
"Your user state has changed.": "Ваше корисничко стање се променило.",
"Refresh to load new user state.": "Освежите за учитавање новог корисничког стања.",
"Refresh": "Освежи",
"Contacts": "Контакти",
"Report an issue": "Пријава проблема",
"Meet us on %s": "Пронађите нас на %s",
"Send us email": "Пошаљите нам имејл",
"Documents": "Документи",
"Features": "Могућности",
"YAML Metadata": "YAML Метаподаци",
"Slide Example": "Пример слајда",
"Cheatsheet": "Трикови и форе",
"Example": "Пример",
"Syntax": "Синтакса",
"Header": "Заглавље",
"Unordered List": "Неуређени списак",
"Ordered List": "Уређени списак",
"Todo List": "Списак обавеза",
"Blockquote": "Пасус са наводима",
"Bold font": "Масна слова",
"Italics font": "Закривљена слова",
"Strikethrough": "Прецртано",
"Inserted text": "Уметнут текст",
"Marked text": "Означени текст",
"Link": "Линк",
"Image": "Слика",
"Code": "Код",
"Externals": "Спољни",
"This is a alert area.": "Ово је пасус за упозорења.",
"Revert": "Врати",
"Import from clipboard": "Увези из клипборда",
"Paste your markdown or webpage here...": "Залепи свој markdown или веб страну овде...",
"Clear": "Очисти",
"This note is locked": "Ова белешка је закључана",
"Sorry, only owner can edit this note.": "Жао нам је, ову белешку може мењати само њен власник.",
"OK": "OK",
"Reach the limit": "Досегни лимит",
"Sorry, you've reached the max length this note can be.": "Нажалост, досегли сте максималну дужину ове белешке.",
"Please reduce the content or divide it to more notes, thank you!": "Молимо Вас да смањите количину текста или да га поделите на више белешки, хвала!",
"Import from Gist": "Увези из Github Gist-а",
"Paste your gist url here...": "Залепите Gist URL адресу овде...",
"Import from Snippet": "Увези из \"исечака\"",
"Select From Available Projects": "Изабери из доступних пројеката",
"Select From Available Snippets": "Изабери из доступних исечака",
"OR": "ИЛИ",
"Export to Snippet": "Извези у \"исечак\"",
"Select Visibility Level": "Изаберите ниво читкости",
"Night Theme": "Ноћна тема",
"Follow us on %s and %s.": "Пратите нас на %s и %s.",
"Privacy": "Приватност",
"Terms of Use": "Услови коришћења",
"Do you really want to delete your user account?": "Да ли заиста желите да трајно обришете свој налог?",
"This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "Ова операција ће избрисати ваш налог, све ваше белешке, а уклониће и све везе ка вашем налогу из других белешки.",
"Delete user": "Брисање корисника",
"Export user data": "Извоз свих корисничких података",
"Help us translating on %s": "Помозите нам да преведемо на %s",
"Source Code": "Изворни код"
}

View File

@ -1,42 +1,42 @@
{ {
"Collaborative markdown notes": "Markdown 協作筆記", "Collaborative markdown notes": "Markdown 協作筆記",
"Realtime collaborative markdown notes on all platforms.": "使用 Markdown 的跨平台即時協作筆記", "Realtime collaborative markdown notes on all platforms.": "使用 Markdown 的跨平台即時協作筆記",
"Best way to write and share your knowledge in markdown.": "使用 Markdown 寫作與分享知識的最佳方式", "Best way to write and share your knowledge in markdown.": "使用 Markdown 寫作與分享知識的最佳方式",
"Intro": "簡介", "Intro": "簡介",
"History": "錄", "History": "歷史記錄",
"New guest note": "建立訪客筆記", "New guest note": "建立訪客筆記",
"Collaborate with URL": "使用網址協作", "Collaborate with URL": "使用網址協作",
"Support charts and MathJax": "支援圖表與 MathJax", "Support charts and MathJax": "支援圖表與 MathJax",
"Support slide mode": "支援簡報模式", "Support slide mode": "支援簡報模式",
"Sign In": "登入", "Sign In": "登入",
"Below is the history from browser": "以下為來自瀏覽器的紀錄", "Below is the history from browser": "以下是從瀏覽器取得的歷史記錄",
"Welcome!": "歡迎!", "Welcome!": "歡迎!",
"New note": "建立筆記", "New note": "建立筆記",
"or": "或", "or": "或",
"Sign Out": "登出", "Sign Out": "登出",
"Explore all features": "探索所有功能", "Explore all features": "探索所有功能",
"Select tags...": "選擇標籤...", "Select tags...": "選擇標籤",
"Search keyword...": "搜尋關鍵字...", "Search keyword...": "搜尋關鍵字",
"Sort by title": "標題排序", "Sort by title": "標題排序",
"Title": "標題", "Title": "標題",
"Sort by time": "時間排序", "Sort by time": "時間排序",
"Time": "時間", "Time": "時間",
"Export history": "匯出錄", "Export history": "匯出錄",
"Import history": "匯入錄", "Import history": "匯入錄",
"Clear history": "清空錄", "Clear history": "清空錄",
"Refresh history": "更新紀錄", "Refresh history": "重新整理記錄",
"No history": "沒有錄", "No history": "沒有錄",
"Import from browser": "從瀏覽器匯入", "Import from browser": "從瀏覽器匯入",
"Releases": "版本", "Releases": "版本",
"Are you sure?": "確定嗎?", "Are you sure?": "確定嗎?",
"Do you really want to delete this note?": "確定要刪除這個文件嗎", "Do you really want to delete this note?": "確定刪除這則筆記",
"All users will lose their connection.": "所有使用者將會失去連線", "All users will lose their connection.": "所有使用者將會失去連線",
"Cancel": "取消", "Cancel": "取消",
"Yes, do it!": "沒錯,就這樣辦", "Yes, do it!": "沒錯,就這麼做",
"Choose method": "選擇方式", "Choose method": "選擇方式",
"Sign in via %s": "透過 %s 登入", "Sign in via %s": "透過 %s 登入",
"New": "新增", "New": "新增",
"Publish": "發", "Publish": "發",
"Extra": "增益", "Extra": "增益",
"Revision": "修訂版本", "Revision": "修訂版本",
"Slide Mode": "簡報模式", "Slide Mode": "簡報模式",
@ -55,7 +55,7 @@
"You have an incompatible client version.": "您使用的是不相容的客戶端", "You have an incompatible client version.": "您使用的是不相容的客戶端",
"Refresh to update.": "請重新整理來更新", "Refresh to update.": "請重新整理來更新",
"New version available!": "新版本來了!", "New version available!": "新版本來了!",
"See releases notes here": "請由此查閱更新錄", "See releases notes here": "請由此查閱更新錄",
"Refresh to enjoy new features.": "請重新整理來享受最新功能", "Refresh to enjoy new features.": "請重新整理來享受最新功能",
"Your user state has changed.": "您的使用者狀態已變更", "Your user state has changed.": "您的使用者狀態已變更",
"Refresh to load new user state.": "請重新整理來載入新的使用者狀態", "Refresh to load new user state.": "請重新整理來載入新的使用者狀態",
@ -66,7 +66,7 @@
"Send us email": "寄信給我們", "Send us email": "寄信給我們",
"Documents": "文件", "Documents": "文件",
"Features": "功能簡介", "Features": "功能簡介",
"YAML Metadata": "YAML Metadata", "YAML Metadata": "YAML 中繼資料",
"Slide Example": "簡報範例", "Slide Example": "簡報範例",
"Cheatsheet": "快速簡表", "Cheatsheet": "快速簡表",
"Example": "範例", "Example": "範例",
@ -88,7 +88,7 @@
"This is a alert area.": "這是警告區塊", "This is a alert area.": "這是警告區塊",
"Revert": "還原", "Revert": "還原",
"Import from clipboard": "從剪貼簿匯入", "Import from clipboard": "從剪貼簿匯入",
"Paste your markdown or webpage here...": "在這裡貼上 Markdown 或是網頁內容...", "Paste your markdown or webpage here...": "在這裡貼上 Markdown 或是網頁內容",
"Clear": "清除", "Clear": "清除",
"This note is locked": "此份筆記已被鎖定", "This note is locked": "此份筆記已被鎖定",
"Sorry, only owner can edit this note.": "抱歉,只有擁有者可以編輯此筆記", "Sorry, only owner can edit this note.": "抱歉,只有擁有者可以編輯此筆記",
@ -97,21 +97,23 @@
"Sorry, you've reached the max length this note can be.": "抱歉,您已使用到此份筆記可用的最大長度", "Sorry, you've reached the max length this note can be.": "抱歉,您已使用到此份筆記可用的最大長度",
"Please reduce the content or divide it to more notes, thank you!": "請減少內容或是將內容切成更多筆記,謝謝!", "Please reduce the content or divide it to more notes, thank you!": "請減少內容或是將內容切成更多筆記,謝謝!",
"Import from Gist": "從 Gist 匯入", "Import from Gist": "從 Gist 匯入",
"Paste your gist url here...": "在這裡貼上 gist 網址...", "Paste your gist url here...": "在此處貼上 gist 網址…",
"Import from Snippet": "從 Snippet 匯入", "Import from Snippet": "從 Snippet 匯入",
"Select From Available Projects": "從可用專案選擇", "Select From Available Projects": "從可用專案選擇",
"Select From Available Snippets": "從可用的 Snippets 選擇", "Select From Available Snippets": "從可用的 Snippets 選擇",
"OR": "或是", "OR": "或是",
"Export to Snippet": "匯出 Snippet", "Export to Snippet": "匯出 Snippet",
"Select Visibility Level": "選擇可見層級", "Select Visibility Level": "選擇可見層級",
"Night Theme": "夜間主題", "Night Theme": "夜間主題",
"Follow us on %s and %s.": "來 %s 或 %s 和我們互動吧!", "Follow us on %s and %s.": "來 %s 或 %s 和我們互動吧!",
"Privacy": "隱私權政策", "Privacy": "隱私權政策",
"Terms of Use": "使用條款", "Terms of Use": "使用條款",
"Do you really want to delete your user account?": "你確定真的想要刪除帳戶?", "Do you really want to delete your user account?": "你確定真的想要刪除帳戶?",
"This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "我們將會刪除你的帳戶、你所擁有的筆記、以及你在別人筆記裡的作者錄。", "This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "我們將會刪除你的帳戶、你所擁有的筆記、以及你在別人筆記裡的作者錄。",
"Delete user": "刪除使用者", "Delete user": "刪除使用者",
"Export user data": "匯出使用者資料", "Export user data": "匯出使用者資料",
"Help us translating on %s": "來 %s 幫我們翻譯", "Help us translating on %s": "來 %s 幫我們翻譯",
"Source Code": "原始碼" "Source Code": "原始碼",
} "Powered by %s": "技術支援:%s",
"Register": "註冊"
}

View File

@ -1,151 +1,197 @@
{ {
"name": "CodiMD", "name": "codimd",
"version": "1.2.1", "version": "1.3.1",
"description": "Realtime collaborative markdown notes on all platforms.", "description": "Realtime collaborative markdown notes on all platforms.",
"main": "app.js",
"license": "AGPL-3.0",
"scripts": {
"test": "npm run-script eslint && npm run-script jsonlint && mocha",
"eslint": "node_modules/.bin/eslint lib public test app.js",
"jsonlint": "find . -not -path './node_modules/*' -type f -name '*.json' -o -type f -name '*.json.example' | while read json; do echo $json ; jq . $json; done",
"standard": "echo 'standard is no longer being used, use `npm run eslint` instead!' && exit 1",
"dev": "webpack --config webpack.dev.js --progress --colors --watch",
"build": "webpack --config webpack.prod.js --progress --colors --bail",
"postinstall": "bin/heroku",
"start": "sequelize db:migrate && node app.js",
"doctoc": "doctoc --title='# Table of Contents' README.md"
},
"dependencies": {
"@mlink/scrypt": "^6.1.2",
"@passport-next/passport-openid": "^1.0.0",
"Idle.Js": "git+https://github.com/shawnmclean/Idle.js",
"archiver": "^2.1.1",
"async": "^2.1.4",
"aws-sdk": "^2.345.0",
"azure-storage": "^2.7.0",
"base64url": "^3.0.0",
"body-parser": "^1.15.2",
"bootstrap": "^3.4.0",
"bootstrap-validator": "^0.11.8",
"chance": "^1.0.4",
"cheerio": "^0.22.0",
"codemirror": "git+https://github.com/hackmdio/CodeMirror.git",
"compression": "^1.6.2",
"connect-flash": "^0.1.1",
"connect-session-sequelize": "^4.1.0",
"cookie": "0.3.1",
"cookie-parser": "1.4.3",
"deep-freeze": "^0.0.1",
"diff-match-patch": "git+https://github.com/hackmdio/diff-match-patch.git",
"ejs": "^2.5.5",
"emojify.js": "~1.1.0",
"express": ">=4.14",
"express-session": "^1.14.2",
"file-saver": "^1.3.3",
"flowchart.js": "^1.6.4",
"fork-awesome": "^1.1.3",
"formidable": "^1.0.17",
"gist-embed": "~2.6.0",
"graceful-fs": "^4.1.11",
"handlebars": "^4.0.6",
"helmet": "^3.13.0",
"highlight.js": "~9.12.0",
"i18n": "^0.8.3",
"imgur": "git+https://github.com/hackmdio/node-imgur.git",
"ionicons": "~2.0.1",
"jquery": "^3.1.1",
"jquery-mousewheel": "^3.1.13",
"jquery-ui": "^1.12.1",
"js-cookie": "^2.1.3",
"js-sequence-diagrams": "^1000000.0.6",
"js-url": "^2.3.0",
"js-yaml": "^3.7.0",
"jsdom-nogyp": "^0.8.3",
"keymaster": "^1.6.2",
"list.js": "^1.5.0",
"lodash": "^4.17.11",
"lutim": "^1.0.2",
"lz-string": "git+https://github.com/hackmdio/lz-string.git",
"markdown-it": "^8.2.2",
"markdown-it-abbr": "^1.0.4",
"markdown-it-container": "^2.0.0",
"markdown-it-deflist": "^2.0.1",
"markdown-it-emoji": "^1.3.0",
"markdown-it-footnote": "^3.0.1",
"markdown-it-imsize": "^2.0.1",
"markdown-it-ins": "^2.0.0",
"markdown-it-mark": "^2.0.0",
"markdown-it-mathjax": "^2.0.0",
"markdown-it-regexp": "^0.4.0",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"markdown-pdf": "^9.0.0",
"mathjax": "~2.7.0",
"mattermost": "^3.4.0",
"mermaid": "~7.1.0",
"meta-marked": "^0.4.2",
"method-override": "^2.3.7",
"minimist": "^1.2.0",
"minio": "^6.0.0",
"moment": "^2.17.1",
"morgan": "^1.7.0",
"mysql": "^2.12.0",
"passport": "^0.4.0",
"passport-dropbox-oauth2": "^1.1.0",
"passport-facebook": "^2.1.1",
"passport-github": "^1.1.0",
"passport-gitlab2": "^4.0.0",
"passport-google-oauth20": "^1.0.0",
"passport-ldapauth": "^2.0.0",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.4.0",
"passport-saml": "^1.0.0",
"passport-twitter": "^1.0.4",
"passport.socketio": "^3.7.0",
"pdfobject": "^2.0.201604172",
"pg": "^6.1.2",
"pg-hstore": "^2.3.2",
"prismjs": "^1.6.0",
"randomcolor": "^0.5.3",
"raphael": "git+https://github.com/dmitrybaranovskiy/raphael",
"readline-sync": "^1.4.7",
"request": "^2.88.0",
"reveal.js": "~3.7.0",
"select2": "^3.5.2-browserify",
"sequelize": "^3.28.0",
"sequelize-cli": "^2.5.1",
"shortid": "2.2.8",
"socket.io": "~2.1.1",
"socket.io-client": "~2.1.1",
"spin.js": "^2.3.2",
"sqlite3": "^4.0.1",
"store": "^2.0.12",
"string": "^3.3.3",
"tedious": "^1.14.0",
"toobusy-js": "^0.5.1",
"turndown": "^5.0.1",
"uuid": "^3.1.0",
"validator": "^10.4.0",
"velocity-animate": "^1.4.0",
"visibilityjs": "^1.2.4",
"viz.js": "^1.7.0",
"winston": "^3.1.0",
"ws": "^6.0.0",
"xss": "^1.0.3"
},
"resolutions": {
"**/tough-cookie": "~2.4.0"
},
"engines": {
"node": ">=6.x"
},
"bugs": "https://github.com/hackmdio/codimd/issues",
"keywords": [ "keywords": [
"Collaborative", "Collaborative",
"Markdown", "Markdown",
"Notes" "Notes"
], ],
"homepage": "https://codimd.org", "bugs": "https://github.com/hackmdio/codimd/issues",
"repository": {
"type": "git",
"url": "https://github.com/hackmdio/codimd.git"
},
"license": "AGPL-3.0",
"main": "app.js",
"scripts": {
"build": "webpack --config webpack.prod.js --progress --colors --bail",
"dev": "webpack --config webpack.dev.js --progress --colors --watch",
"doctoc": "doctoc --title='# Table of Contents' README.md",
"lint": "standard",
"jsonlint": "find . -type f -not -ipath \"./node_modules/*\" \\( -name \"*.json\" -o -name \"*.json.*\" \\) | xargs -n 1 -I{} -- bash -c 'echo {}; jq . {} > /dev/null;'",
"start": "sequelize db:migrate && node app.js",
"mocha": "mocha --require intelli-espower-loader --exit ./test --recursive",
"mocha:ci": "mocha --no-color -R dot --require intelli-espower-loader --exit ./test --recursive",
"coverage": "nyc mocha --require intelli-espower-loader --exit --recursive ./test",
"coverage:ci": "nyc mocha --no-color -R dot --require intelli-espower-loader --exit --recursive ./test",
"test": "npm run-script lint && npm run-script jsonlint && npm run-script coverage",
"test:ci": "npm run-script lint && npm run-script jsonlint && npm run-script coverage:ci"
},
"dependencies": {
"@hackmd/codemirror": "~5.46.2",
"@hackmd/diff-match-patch": "~1.1.1",
"@hackmd/idle-js": "~1.0.1",
"@hackmd/imgur": "~0.4.1",
"@hackmd/js-sequence-diagrams": "~0.0.1-alpha.3",
"@hackmd/lz-string": "~1.4.4",
"@hackmd/meta-marked": "~0.4.4",
"@passport-next/passport-openid": "~1.0.0",
"archiver": "~2.1.1",
"async": "~2.1.4",
"aws-sdk": "~2.345.0",
"azure-storage": "~2.10.2",
"babel-polyfill": "~6.26.0",
"base64url": "~3.0.0",
"body-parser": "~1.18.3",
"bootstrap": "~3.4.0",
"bootstrap-validator": "~0.11.8",
"chance": "~1.0.4",
"cheerio": "~0.22.0",
"compression": "~1.7.4",
"connect-flash": "~0.1.1",
"connect-session-sequelize": "~6.0.0",
"cookie": "~0.3.1",
"cookie-parser": "~1.4.3",
"deep-freeze": "~0.0.1",
"ejs": "~2.5.5",
"emojify.js": "~1.1.0",
"express": "~4.16.4",
"express-session": "~1.16.1",
"file-saver": "~1.3.3",
"flowchart.js": "~1.12.0",
"fork-awesome": "~1.1.3",
"formidable": "~1.2.1",
"gist-embed": "~2.6.0",
"graceful-fs": "~4.1.11",
"handlebars": "~4.0.13",
"helmet": "~3.13.0",
"highlight.js": "~9.12.0",
"i18n": "~0.8.3",
"ionicons": "~2.0.1",
"isomorphic-fetch": "~2.2.1",
"jquery": "~3.1.1",
"jquery-mousewheel": "~3.1.13",
"jquery-ui": "~1.12.1",
"js-cookie": "~2.1.3",
"js-yaml": "~3.13.1",
"jsdom-nogyp": "~0.8.3",
"keymaster": "~1.6.2",
"list.js": "~1.5.0",
"lodash": "~4.17.11",
"lutim": "~1.0.2",
"markdown-it": "~8.2.2",
"markdown-it-abbr": "~1.0.4",
"markdown-it-container": "~2.0.0",
"markdown-it-deflist": "~2.0.1",
"markdown-it-emoji": "~1.3.0",
"markdown-it-footnote": "~3.0.1",
"markdown-it-imsize": "~2.0.1",
"markdown-it-ins": "~2.0.0",
"markdown-it-mark": "~2.0.0",
"markdown-it-mathjax": "~2.0.0",
"markdown-it-regexp": "~0.4.0",
"markdown-it-sub": "~1.0.0",
"markdown-it-sup": "~1.0.0",
"markdown-pdf": "~9.0.0",
"mathjax": "~2.7.0",
"mattermost-redux": "~5.9.0",
"mermaid": "~8.2.3",
"method-override": "~2.3.7",
"minimist": "~1.2.0",
"minio": "~6.0.0",
"moment": "~2.24.0",
"morgan": "~1.9.1",
"mysql": "~2.16.0",
"passport": "~0.4.0",
"passport-dropbox-oauth2": "~1.1.0",
"passport-facebook": "~2.1.1",
"passport-github": "~1.1.0",
"passport-gitlab2": "~4.0.0",
"passport-google-oauth20": "~1.0.0",
"passport-ldapauth": "~2.1.3",
"passport-local": "~1.0.0",
"passport-oauth2": "~1.4.0",
"passport-saml": "~1.0.0",
"passport-twitter": "~1.0.4",
"passport.socketio": "~3.7.0",
"pdfobject": "~2.0.201604172",
"pg": "~6.1.2",
"pg-hstore": "~2.3.2",
"prismjs": "~1.6.0",
"randomcolor": "~0.5.3",
"raphael": "~2.2.8",
"readline-sync": "~1.4.7",
"request": "~2.88.0",
"reveal.js": "~3.7.0",
"scrypt": "~6.0.3",
"select2": "~3.5.2-browserify",
"sequelize": "5.3.5",
"shortid": "~2.2.8",
"socket.io": "~2.1.1",
"socket.io-client": "~2.1.1",
"spin.js": "~2.3.2",
"sqlite3": "~4.0.1",
"store": "~2.0.12",
"tedious": "~6.1.0",
"toobusy-js": "~0.5.1",
"turndown": "~5.0.1",
"uuid": "~3.1.0",
"validator": "~10.4.0",
"velocity-animate": "~1.4.0",
"visibilityjs": "~1.2.4",
"viz.js": "~1.7.0",
"winston": "~3.1.0",
"ws": "~6.0.0",
"wurl": "~2.5.3",
"xss": "~1.0.3"
},
"devDependencies": {
"acorn": "~6.1.1",
"babel-core": "~6.26.3",
"babel-loader": "~7.1.4",
"babel-plugin-transform-runtime": "~6.23.0",
"babel-preset-env": "~1.7.0",
"babel-runtime": "~6.26.0",
"copy-webpack-plugin": "~4.5.2",
"css-loader": "~1.0.0",
"doctoc": "~1.4.0",
"ejs-loader": "~0.3.1",
"exports-loader": "~0.7.0",
"expose-loader": "~0.7.5",
"file-loader": "~2.0.0",
"html-webpack-plugin": "~4.0.0-beta.2",
"imports-loader": "~0.8.0",
"intelli-espower-loader": "~1.0.1",
"jsonlint": "~1.6.2",
"less": "~3.9.0",
"less-loader": "~4.1.0",
"mini-css-extract-plugin": "~0.4.1",
"mocha": "~5.2.0",
"mock-require": "~3.0.3",
"nyc": "~14.0.0",
"optimize-css-assets-webpack-plugin": "~5.0.0",
"power-assert": "~1.6.1",
"script-loader": "~0.7.2",
"sequelize-cli": "~5.4.0",
"sinon": "~7.3.2",
"standard": "~12.0.1",
"string-loader": "~0.0.1",
"style-loader": "~0.21.0",
"uglifyjs-webpack-plugin": "~1.2.7",
"url-loader": "~1.0.1",
"webpack": "~4.30.0",
"webpack-cli": "~3.3.0",
"webpack-merge": "~4.1.4",
"webpack-parallel-uglify-plugin": "~1.1.0"
},
"optionalDependencies": {
"bufferutil": "~4.0.0",
"utf-8-validate": "~5.0.1"
},
"engines": {
"node": ">=8.0.0"
},
"maintainers": [ "maintainers": [
{ {
"name": "Max Wu", "name": "Max Wu",
@ -156,52 +202,19 @@
"email": "codimd@sheogorath.shivering-isles.com" "email": "codimd@sheogorath.shivering-isles.com"
} }
], ],
"repository": { "standard": {
"type": "git", "ignore": [
"url": "https://github.com/hackmdio/codimd.git" "/public/build",
"/public/vendor",
"/lib/ot"
]
}, },
"devDependencies": { "nyc": {
"babel-cli": "^6.26.0", "all": true,
"babel-core": "^6.26.3", "include": [
"babel-loader": "^7.1.4", "app.js",
"babel-plugin-transform-runtime": "^6.23.0", "lib/**/*.js"
"babel-polyfill": "^6.26.0", ],
"babel-preset-env": "^1.7.0", "reporter": "lcov"
"babel-runtime": "^6.26.0",
"copy-webpack-plugin": "^4.5.2",
"css-loader": "^1.0.0",
"doctoc": "^1.4.0",
"ejs-loader": "^0.3.1",
"eslint": "^5.9.0",
"eslint-config-standard": "^12.0.0",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-node": "^8.0.0",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-standard": "^4.0.0",
"exports-loader": "^0.7.0",
"expose-loader": "^0.7.5",
"file-loader": "^2.0.0",
"html-webpack-plugin": "4.0.0-beta.2",
"imports-loader": "^0.8.0",
"jsonlint": "^1.6.2",
"less": "^2.7.1",
"less-loader": "^4.1.0",
"mini-css-extract-plugin": "^0.4.1",
"mocha": "^5.2.0",
"mock-require": "^3.0.3",
"optimize-css-assets-webpack-plugin": "^5.0.0",
"script-loader": "^0.7.2",
"string-loader": "^0.0.1",
"style-loader": "^0.21.0",
"uglifyjs-webpack-plugin": "^1.2.7",
"url-loader": "^1.0.1",
"webpack": "^4.14.0",
"webpack-cli": "^3.1.0",
"webpack-merge": "^4.1.4",
"webpack-parallel-uglify-plugin": "^1.1.0"
},
"optionalDependencies": {
"bufferutil": "^4.0.0",
"utf-8-validate": "^5.0.1"
} }
} }

View File

@ -12,7 +12,6 @@ module.exports = {
"ui": false, "ui": false,
"Spinner": false, "Spinner": false,
"modeType": false, "modeType": false,
"Idle": false,
"serverurl": false, "serverurl": false,
"key": false, "key": false,
"gapi": false, "gapi": false,

View File

@ -0,0 +1,144 @@
var ElapsedTimeBar = {
// default value
barColor: 'rgb(200,0,0)',
pausedBarColor: 'rgba(200,0,0,.6)',
isPaused: false,
isFinished: false,
allottedTime: null,
timeProgressBar: null,
startTime: null,
pauseTime: null,
pauseTimeDuration: 0,
/**
* initialize elements
*/
handleReady() {
var config = Reveal.getConfig();
// activate this plugin if config.allottedTime exists.
if (!config.allottedTime) {
console.warn('Failed to start ElapsedTimeBar plugin. "allottedTime" property is required.');
return;
}
// set configurations
this.barColor = config.barColor || this.barColor;
this.pausedBarColor = config.pausedBarColor || this.pausedBarColor;
// calc barHeight from config.barHeight or page-progress container
var barHeight;
var pageProgressContainer = document.querySelector('.progress');
if (config.progressBarHeight) {
barHeight = parseInt(config.progressBarHeight, 10) + 'px';
// override height of page-progress container
pageProgressContainer && (pageProgressContainer.style.height = barHeight);
} else if (config.progress && pageProgressContainer) {
// get height from page-progress container
barHeight = pageProgressContainer.getBoundingClientRect().height + 'px';
} else {
// default
barHeight = '3px';
}
// create container of time-progress
var timeProgressContainer = document.createElement('div');
timeProgressContainer.classList.add('progress');
Object.entries({
display: 'block',
position: 'fixed',
bottom: config.progress ? barHeight : 0,
width: '100%',
height: barHeight
}).forEach(([k, v]) => {
timeProgressContainer.style[k] = v;
});
document.querySelector('.reveal').appendChild(timeProgressContainer);
// create content of time-progress
this.timeProgressBar = document.createElement('div');
Object.entries({
height: '100%',
willChange: 'width'
}).forEach(([k, v]) => {
this.timeProgressBar.style[k] = v;
});
timeProgressContainer.appendChild(this.timeProgressBar);
// start timer
this.start(config.allottedTime);
},
/**
* update repeatedly using requestAnimationFrame.
*/
loop() {
if (this.isPaused) return;
var now = +new Date();
var elapsedTime = now - this.startTime - this.pauseTimeDuration;
if (elapsedTime > this.allottedTime) {
this.timeProgressBar.style.width = '100%';
this.isFinished = true;
} else {
this.timeProgressBar.style.width = elapsedTime / this.allottedTime * 100 + '%';
requestAnimationFrame(this.loop.bind(this));
}
},
/**
* set color of progress bar
*/
setBarColor() {
if (this.isPaused) {
this.timeProgressBar.style.backgroundColor = this.pausedBarColor;
} else {
this.timeProgressBar.style.backgroundColor = this.barColor;
}
},
/**
* start(reset) timer with new allotted time.
* @param {number} allottedTime
* @param {number} [elapsedTime=0]
*/
start(allottedTime, elapsedTime = 0) {
this.isFinished = false;
this.isPaused = false;
this.allottedTime = allottedTime;
this.startTime = +new Date() - elapsedTime;
this.pauseTimeDuration = 0;
this.setBarColor();
this.loop();
},
reset() {
this.start(this.allottedTime);
},
pause() {
if (this.isPaused) return;
this.isPaused = true;
this.pauseTime = +new Date();
this.setBarColor();
},
resume() {
if (!this.isPaused) return;
// add paused time duration
this.isPaused = false;
this.pauseTimeDuration += new Date() - this.pauseTime;
this.pauseTime = null;
this.setBarColor();
this.loop();
}
};
if (Reveal.isReady()) {
ElapsedTimeBar.handleReady();
} else {
Reveal.addEventListener('ready', () => ElapsedTimeBar.handleReady());
}

View File

@ -0,0 +1,283 @@
var RevealSpotlight = window.RevealSpotlight || (function () {
//configs
var spotlightSize;
var toggleOnMouseDown;
var spotlightOnKeyPressAndHold;
var presentingCursor;
var spotlightCursor;
var initialPresentationMode;
var disablingUserSelect;
var fadeInAndOut;
var style;
var lockPointerInsideCanvas;
var getMousePos;
var drawBoard;
var isSpotlightOn = true;
var isCursorOn = true;
var lastMouseMoveEvent;
function onRevealJsReady(event) {
configure();
drawBoard = setupCanvas();
addWindowResizeListener();
addMouseMoveListener();
if (toggleOnMouseDown) {
addMouseToggleSpotlightListener();
}
if (spotlightOnKeyPressAndHold) {
addKeyPressAndHoldSpotlightListener(spotlightOnKeyPressAndHold);
}
setCursor(!initialPresentationMode);
setSpotlight(false);
}
function configure() {
var config = Reveal.getConfig().spotlight || {};
spotlightSize = config.size || 60;
presentingCursor = config.presentingCursor || "none";
spotlightCursor = config.spotlightCursor || "none";
var useAsPointer = config.useAsPointer || false;
var pointerColor = config.pointerColor || 'red';
lockPointerInsideCanvas = config.lockPointerInsideCanvas || false;
if(lockPointerInsideCanvas){
getMousePos = getMousePosByMovement;
} else {
getMousePos = getMousePosByBoundingClientRect;
}
// If using as pointer draw a transparent background and
// the mouse pointer in the specified color or default
var pointerStyle = {
backgroundFillStyle : "rgba(0, 0, 0, 0)",
mouseFillStyle : pointerColor
};
var spotlightStyle = {
backgroundFillStyle : "#000000A8",
mouseFillStyle : "#FFFFFFFF"
};
style = useAsPointer ? pointerStyle : spotlightStyle;
if (config.hasOwnProperty("toggleSpotlightOnMouseDown")) {
toggleOnMouseDown = config.toggleSpotlightOnMouseDown;
} else {
toggleOnMouseDown = true;
}
if (config.hasOwnProperty("initialPresentationMode")) {
initialPresentationMode = config.initialPresentationMode;
} else {
initialPresentationMode = toggleOnMouseDown;
}
if (config.hasOwnProperty("spotlightOnKeyPressAndHold")) {
spotlightOnKeyPressAndHold = config.spotlightOnKeyPressAndHold;
} else {
spotlightOnKeyPressAndHold = false;
}
if (config.hasOwnProperty("disablingUserSelect")) {
disablingUserSelect = config.disablingUserSelect;
} else {
disablingUserSelect = true;
}
if (config.hasOwnProperty("fadeInAndOut")) {
fadeInAndOut = config.fadeInAndOut;
} else {
fadeInAndOut = false;
}
}
function setupCanvas() {
var container = document.createElement('div');
container.id = "spotlight";
container.style.cssText = "position:absolute;top:0;left:0;bottom:0;right:0;z-index:99;";
if (fadeInAndOut) {
container.style.cssText += "transition: " + fadeInAndOut + "ms opacity;";
}
var canvas = document.createElement('canvas');
var context = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
container.appendChild(canvas);
document.body.appendChild(container);
container.style.opacity = 0;
container.style['pointer-events'] = 'none';
return {
container,
canvas,
context
}
}
function addWindowResizeListener() {
window.addEventListener('resize', function (e) {
var canvas = drawBoard.canvas;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}, false);
}
function addMouseMoveListener() {
document.body.addEventListener('mousemove', function (e) {
if(isSpotlightOn) {
showSpotlight(e);
}
lastMouseMoveEvent = e;
}, false);
}
function addMouseToggleSpotlightListener() {
window.addEventListener("mousedown", function (e) {
if (!isCursorOn) {
setSpotlight(true, e);
}
}, false);
window.addEventListener("mouseup", function (e) {
if (!isCursorOn) {
setSpotlight(false, e);
}
}, false);
}
function addKeyPressAndHoldSpotlightListener(keyCode) {
window.addEventListener("keydown", function (e) {
if (!isCursorOn && !isSpotlightOn && e.keyCode === keyCode) {
setSpotlight(true, lastMouseMoveEvent);
}
}, false);
window.addEventListener("keyup", function (e) {
if (!isCursorOn && e.keyCode === keyCode) {
setSpotlight(false);
}
}, false);
}
function toggleSpotlight() {
setSpotlight(!isSpotlightOn, lastMouseMoveEvent);
}
function setSpotlight(isOn, mouseEvt) {
isSpotlightOn = isOn;
var container = drawBoard.container;
if (isOn) {
if (lockPointerInsideCanvas && document.pointerLockElement != drawBoard.canvas) {
drawBoard.canvas.requestPointerLock();
}
container.style.opacity = 1;
container.style['pointer-events'] = null;
document.body.style.cursor = spotlightCursor;
if (mouseEvt) {
showSpotlight(mouseEvt);
}
} else {
container.style.opacity = 0;
container.style['pointer-events'] = 'none';
document.body.style.cursor = presentingCursor;
}
}
function togglePresentationMode() {
setCursor(!isCursorOn);
}
function setCursor(isOn) {
isCursorOn = isOn;
if (isOn) {
if (disablingUserSelect) {
document.body.style.userSelect = null;
}
document.body.style.cursor = null;
} else {
if (disablingUserSelect) {
document.body.style.userSelect = "none";
}
document.body.style.cursor = presentingCursor;
}
}
function showSpotlight(mouseEvt) {
var canvas = drawBoard.canvas;
var context = drawBoard.context;
var mousePos = getMousePos(canvas, mouseEvt);
context.clearRect(0, 0, canvas.width, canvas.height);
// Create a canvas mask
var maskCanvas = document.createElement('canvas');
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
var maskCtx = maskCanvas.getContext('2d');
maskCtx.fillStyle = style.backgroundFillStyle;
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.globalCompositeOperation = 'xor';
maskCtx.fillStyle = style.mouseFillStyle;
maskCtx.arc(mousePos.x, mousePos.y, spotlightSize, 0, 2 * Math.PI);
maskCtx.fill();
context.drawImage(maskCanvas, 0, 0);
}
var mX = 0;
var mY = 0;
function getMousePosByMovement(canvas, evt) {
var movementX = evt.movementX || 0;
var movementY = evt.movementY || 0;
mX += movementX;
mY += movementY;
if (mX > canvas.clientWidth) {
mX = canvas.clientWidth;
}
if (mY > canvas.clientHeight) {
mY = canvas.clientHeight;
}
if (mX < 0) {
mX = 0;
}
if (mY < 0) {
mY = 0;
}
return {
x: mX,
y: mY
};
}
function getMousePosByBoundingClientRect(canvas, evt) {
var rect = canvas.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
}
Reveal.addEventListener('ready', onRevealJsReady);
this.toggleSpotlight = toggleSpotlight;
this.togglePresentationMode = togglePresentationMode;
return this;
})();

View File

@ -371,4 +371,42 @@ select {
.ui-use-tags { .ui-use-tags {
max-width: 100%; max-width: 100%;
} }
} }
.btn-mattermost {
background-color: #2179ec;
border-color: rgba(0,0,0,0.2);
color: #fff;
}
.btn-gitlab {
background-color: #e35431;
border-color: rgba(0,0,0,0.2);
color: #fff;
}
.btn-mattermost:hover, .btn-mattermost:active {
background-color: #105fc6;
border-color: rgba(0,0,0,0.2);
color: #fff;
}
.btn-gitlab:hover, .btn-gitlab:active {
background-color: #c23b1a;
border-color: rgba(0,0,0,0.2);
color: #fff;
}
a.btn.btn-social > i.oauth-icon {
display: inline-flex;
height: 45px;
width: 45px;
line-height: inherit;
padding: 6px;
}
a.btn.btn-social > i.oauth-icon > img {
width: 100%;
height: 100%;
line-height: inherit;
}

View File

@ -8,7 +8,7 @@ This means that you can write notes with other people on your **desktop**, **tab
You can sign-in via multiple auth providers like **Facebook**, **Twitter**, **GitHub** and many more on the [_homepage_](/). You can sign-in via multiple auth providers like **Facebook**, **Twitter**, **GitHub** and many more on the [_homepage_](/).
If you experience any _issues_, feel free to report it on [**GitHub**](https://github.com/hackmdio/codimd/issues). If you experience any _issues_, feel free to report it on [**GitHub**](https://github.com/hackmdio/codimd/issues).
Or meet us on [**Matrix.org**](https://riot.im/app/#/room/#codimd:matrix.org) for dev-talk and interactive help. Or meet us on [**Gitter**](https://gitter.im/hackmdio/hackmd) for dev-talk and interactive help.
**Thank you very much!** **Thank you very much!**
Workspace Workspace
@ -222,9 +222,6 @@ When youre a carpenter making a beautiful chest of drawers, youre not goin
### SlideShare ### SlideShare
{%slideshare briansolis/26-disruptive-technology-trends-2016-2018-56796196 %} {%slideshare briansolis/26-disruptive-technology-trends-2016-2018-56796196 %}
### Speakerdeck
{%speakerdeck sugarenia/xxlcss-how-to-scale-css-and-keep-your-sanity %}
### PDF ### PDF
**Caution: this might be blocked by your browser if not using an `https` URL.** **Caution: this might be blocked by your browser if not using an `https` URL.**
{%pdf https://papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf %} {%pdf https://papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf %}

View File

@ -1,6 +1,126 @@
Release Notes Release Notes
=== ===
<i class="fa fa-tag"></i> 1.3.1 <i class="fa fa-clock-o"></i> 2019-03-23 00:00
---
### Enhancements
* Add some missing translations
* Add Serbian language
### Fixes
* Fix broken redirect for empty `serverURL`
* Fix wrong variable type for HSTS maxAge
* Fix GitLab snippets showing up without being configured
* Fix Google's API after disabling Google+
* Fix broken PDF export
### Contributors
* atachibana (translator)
* [Aurélien JANVIER](https://github.com/ajanvier) (translator)
* [Daan Sprenkels](https://github.com/dsprenkels) (translator)
* Farizrizaldy (translator)
* [Luclu7](https://github.com/Luclu7)
* Sylke Vicious (translator)
* [toshi0123](https://github.com/toshi0123) & okochi-toshiki
* [Turakar](https://github.com/Turakar)
* [Vladan](https://github.com/cvladan) (translator)
<i class="fa fa-tag"></i> 1.3.0 <i class="fa fa-clock-o"></i> 2019-03-03 00:00
---
### Enhancements
* Run db migrations on `npm start`
* Add documentation about integration with AD LDAP
* Add `rel="noopener"` to all links
* Add documentation about integration with Nextcloud for authentication
* Update URL on frontpage to point to codimd.org
* Replace Fontawesome with Forkawesome
* Add OpenID support
* Add print icon to slide view
* Add auto-complete for language names that are highlighted in codeblocks
* Improve translations for Chinese, Dutch, French, German, Italien, Korean, Polish, and Russian language
* Add Download action to published document API
* Add reset password feature to `manage_users` script
* Move from own `./tmp` directory to system temp directory
* Add Etherpad migration guide
* Move XSS library to a more native position
* Use full version string to determine changes from the backend
* Update winston (logging library)
* Use slide preview in slide example
* Improve migration handling
* Update reveal.js to version 3.7.0
* Replace scrypt library with its successor
* Replace `to-markdown` with `turndown` (successor library)
* Update socket.io
* Add warning on missing base URL
* Update bootstrap to version 3.4.0
* Update handlebar
### Fixes
* Fix paths in GitLab documentation
* Fix missing `data:` URL in CSP
* Fix oAuth2 name/label field
* Fix GitLab API integration
* Fix auto-completed but not rendered emojis
* Fix menu organization depending on enabled services
* Fix some logging in the OT module
* Fix some unhandled internalOAuthError exception
* Fix unwanted creation of robots.txt document in "freeurl-mode"
* Fix some links on index page to lead to the right sections on feature page
* Fix document breaking, empty headlines
* Fix wrong multiplication for HSTS header seconds
* Fix wrong subdirectories in exported user data
* Fix CSP for speaker notes
* Fix CSP for disqus
* Fix URL API usage
* Fix Gist embedding
* Fix upload provider error message
* Fix unescaped disqus user names
* Fix SAML vulnerability
* Fix link to SAML guide
* Fix deep dependency problem with node 6.x
* Fix broken PDF export by wrong unlink call
* Fix possible XSS attack in MathJax
### Refactors
* Refactor to use `ws` instead of the the no longer supported `uws`
* Refactor frontend build system to use webpack version 4
* Refactor file path configuration (views, uploads, …)
* Refactor `manage_users` script
* Refactor handling of template variables
* Refactor linting to use eslint
### Removes
* Remove no longer working Octicons
* Remove links to our old Gitter channel
* Remove unused library node-uuid
* Remove unneeded blueimp-md5 dependency
* Remove speakerdeck due to broken implementation
### Contributors
* Adam.emts (translator)
* [Alex Garcia](https://github.com/asg017)
* [Cédric Couralet (micedre)](https://github.com/micedre)
* [Claudius Coenen](https://github.com/ccoenen)
* [Daan Sprenkels](https://github.com/dsprenkels)
* [David Mehren](https://github.com/davidmehren)
* [Erona](https://github.com/Eronana)
* [Felix Yan](https://github.com/felixonmars)
* [Jonathan](https://github.com/phrix32)
* Jong-kai Yang (translator)
* [MartB](https://github.com/MartB)
* [Max Wu (jackycute)](https://github.com/jackycute)
* [mcnesium](https://github.com/mcnesium)
* Nullnine (translator)
* RanoIP (translator)
* [SuNbiT](https://github.com/sunbit)
* Sylke Vicious (translator)
* Timothee (translator)
* [WilliButz](https://github.com/WilliButz)
* [Xaver Maierhofer](https://github.com/xf-)
* [云屿](https://github.com/cloudyu)
<i class="fa fa-tag"></i> 1.2.1 <i class="fa fa-clock-o"></i> 2018-09-26 00:00 <i class="fa fa-tag"></i> 1.2.1 <i class="fa fa-clock-o"></i> 2018-09-26 00:00
--- ---

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg"
width="500" height="500" viewBox="0 0 500 500">
<path id="mattermost" fill="#ffffff" stroke="none"
d="M 250.05,34.00
C 251.95,34.04 253.85,34.11 255.65,34.20
255.65,34.20 225.86,69.71 225.86,69.71
225.79,69.72 225.71,69.74 225.63,69.75
149.26,84.10 98.22,146.50 98.22,222.97
98.22,264.53 121.29,313.47 157.97,342.07
186.58,364.39 222.26,378.97 259.18,378.97
352.58,378.97 419.33,310.36 419.33,222.97
419.33,188.06 403.34,150.20 377.57,122.21
377.57,122.21 375.94,74.82 375.94,74.82
430.39,113.97 465.89,177.84 466.00,249.99
466.00,250.00 466.00,250.00 466.00,250.00
466.00,369.29 369.30,466.00 250.00,466.00
130.71,466.00 34.00,369.29 34.00,250.00
34.00,130.71 130.71,34.00 250.00,34.00
250.00,34.00 250.05,34.00 250.05,34.00 Z
M 314.15,54.29
C 314.81,54.25 315.47,54.32 316.11,54.54
319.12,55.54 319.96,58.11 320.04,60.99
320.04,60.99 323.88,207.87 323.88,207.87
324.64,236.53 306.72,276.31 263.49,276.43
232.52,276.51 199.81,255.60 199.81,216.30
199.82,201.57 205.42,185.04 219.06,168.19
219.06,168.19 309.09,57.01 309.09,57.01
310.24,55.59 312.17,54.43 314.15,54.29
314.15,54.29 314.15,54.29 314.15,54.29 Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,37 +1,37 @@
/* eslint-env browser, jquery */ /* eslint-env browser, jquery */
/* global moment, serverurl */ /* global moment, serverurl */
import {
checkIfAuth,
clearLoginState,
getLoginState,
resetCheckAuth,
setloginStateChangeEvent
} from './lib/common/login'
import {
clearDuplicatedHistory,
deleteServerHistory,
getHistory,
getStorageHistory,
parseHistory,
parseServerToHistory,
parseStorageToHistory,
postHistoryToServer,
removeHistory,
saveHistory,
saveStorageHistoryToServer
} from './history'
import { saveAs } from 'file-saver'
import List from 'list.js'
import unescapeHTML from 'lodash/unescape'
require('./locale') require('./locale')
require('../css/cover.css') require('../css/cover.css')
require('../css/site.css') require('../css/site.css')
import {
checkIfAuth,
clearLoginState,
getLoginState,
resetCheckAuth,
setloginStateChangeEvent
} from './lib/common/login'
import {
clearDuplicatedHistory,
deleteServerHistory,
getHistory,
getStorageHistory,
parseHistory,
parseServerToHistory,
parseStorageToHistory,
postHistoryToServer,
removeHistory,
saveHistory,
saveStorageHistoryToServer
} from './history'
import { saveAs } from 'file-saver'
import List from 'list.js'
import S from 'string'
const options = { const options = {
valueNames: ['id', 'text', 'timestamp', 'fromNow', 'time', 'tags', 'pinned'], valueNames: ['id', 'text', 'timestamp', 'fromNow', 'time', 'tags', 'pinned'],
item: `<li class="col-xs-12 col-sm-6 col-md-6 col-lg-4"> item: `<li class="col-xs-12 col-sm-6 col-md-6 col-lg-4">
@ -67,27 +67,27 @@ pageInit()
function pageInit () { function pageInit () {
checkIfAuth( checkIfAuth(
data => { data => {
$('.ui-signin').hide() $('.ui-signin').hide()
$('.ui-or').hide() $('.ui-or').hide()
$('.ui-welcome').show() $('.ui-welcome').show()
if (data.photo) $('.ui-avatar').prop('src', data.photo).show() if (data.photo) $('.ui-avatar').prop('src', data.photo).show()
else $('.ui-avatar').prop('src', '').hide() else $('.ui-avatar').prop('src', '').hide()
$('.ui-name').html(data.name) $('.ui-name').html(data.name)
$('.ui-signout').show() $('.ui-signout').show()
$('.ui-history').click() $('.ui-history').click()
parseServerToHistory(historyList, parseHistoryCallback) parseServerToHistory(historyList, parseHistoryCallback)
}, },
() => { () => {
$('.ui-signin').show() $('.ui-signin').show()
$('.ui-or').show() $('.ui-or').show()
$('.ui-welcome').hide() $('.ui-welcome').hide()
$('.ui-avatar').prop('src', '').hide() $('.ui-avatar').prop('src', '').hide()
$('.ui-name').html('') $('.ui-name').html('')
$('.ui-signout').hide() $('.ui-signout').hide()
parseStorageToHistory(historyList, parseHistoryCallback) parseStorageToHistory(historyList, parseHistoryCallback)
} }
) )
} }
$('.masthead-nav li').click(function () { $('.masthead-nav li').click(function () {
@ -132,7 +132,7 @@ function checkHistoryList () {
function parseHistoryCallback (list, notehistory) { function parseHistoryCallback (list, notehistory) {
checkHistoryList() checkHistoryList()
// sort by pinned then timestamp // sort by pinned then timestamp
list.sort('', { list.sort('', {
sortFunction (a, b) { sortFunction (a, b) {
const notea = a.values() const notea = a.values()
@ -152,13 +152,13 @@ function parseHistoryCallback (list, notehistory) {
} }
} }
}) })
// parse filter tags // parse filter tags
const filtertags = [] const filtertags = []
for (let i = 0, l = list.items.length; i < l; i++) { for (let i = 0, l = list.items.length; i < l; i++) {
const tags = list.items[i]._values.tags const tags = list.items[i]._values.tags
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
for (let j = 0; j < tags.length; j++) { for (let j = 0; j < tags.length; j++) {
// push info filtertags if not found // push info filtertags if not found
let found = false let found = false
if (filtertags.includes(tags[j])) { found = true } if (filtertags.includes(tags[j])) { found = true }
if (!found) { filtertags.push(tags[j]) } if (!found) { filtertags.push(tags[j]) }
@ -178,20 +178,20 @@ historyList.on('updated', e => {
const a = itemEl.find('a') const a = itemEl.find('a')
const pin = itemEl.find('.ui-history-pin') const pin = itemEl.find('.ui-history-pin')
const tagsEl = itemEl.find('.tags') const tagsEl = itemEl.find('.tags')
// parse link to element a // parse link to element a
a.attr('href', `${serverurl}/${values.id}`) a.attr('href', `${serverurl}/${values.id}`)
// parse pinned // parse pinned
if (values.pinned) { if (values.pinned) {
pin.addClass('active') pin.addClass('active')
} else { } else {
pin.removeClass('active') pin.removeClass('active')
} }
// parse tags // parse tags
const tags = values.tags const tags = values.tags
if (tags && tags.length > 0 && tagsEl.children().length <= 0) { if (tags && tags.length > 0 && tagsEl.children().length <= 0) {
const labels = [] const labels = []
for (let j = 0; j < tags.length; j++) { for (let j = 0; j < tags.length; j++) {
// push into the item label // push into the item label
labels.push(`<span class='label label-default'>${tags[j]}</span>`) labels.push(`<span class='label label-default'>${tags[j]}</span>`)
} }
tagsEl.html(labels.join(' ')) tagsEl.html(labels.join(' '))
@ -328,7 +328,7 @@ $('.ui-open-history').bind('change', e => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
const notehistory = JSON.parse(reader.result) const notehistory = JSON.parse(reader.result)
// console.log(notehistory); // console.log(notehistory);
if (!reader.result) return if (!reader.result) return
getHistory(data => { getHistory(data => {
let mergedata = data.concat(notehistory) let mergedata = data.concat(notehistory)
@ -397,7 +397,7 @@ function buildTagsFilter (tags) {
for (let i = 0; i < tags.length; i++) { for (let i = 0; i < tags.length; i++) {
tags[i] = { tags[i] = {
id: i, id: i,
text: S(tags[i]).unescapeHTML().s text: unescapeHTML(tags[i])
} }
} }
filtertags = tags filtertags = tags

View File

@ -1,6 +1,24 @@
/* eslint-env browser, jquery */ /* eslint-env browser, jquery */
/* global moment, serverurl */ /* global moment, serverurl */
import Prism from 'prismjs'
import hljs from 'highlight.js'
import PDFObject from 'pdfobject'
import { saveAs } from 'file-saver'
import escapeHTML from 'lodash/escape'
import unescapeHTML from 'lodash/unescape'
import { stripTags } from '../../utils/string'
import getUIElements from './lib/editor/ui-elements'
import markdownit from 'markdown-it'
import markdownitContainer from 'markdown-it-container'
/* Defined regex markdown it plugins */
import Plugin from 'markdown-it-regexp'
require('prismjs/themes/prism.css') require('prismjs/themes/prism.css')
require('prismjs/components/prism-wiki') require('prismjs/components/prism-wiki')
require('prismjs/components/prism-haskell') require('prismjs/components/prism-haskell')
@ -10,17 +28,9 @@ require('prismjs/components/prism-jsx')
require('prismjs/components/prism-makefile') require('prismjs/components/prism-makefile')
require('prismjs/components/prism-gherkin') require('prismjs/components/prism-gherkin')
import Prism from 'prismjs'
import hljs from 'highlight.js'
import PDFObject from 'pdfobject'
import S from 'string'
import { saveAs } from 'file-saver'
require('./lib/common/login') require('./lib/common/login')
require('../vendor/md-toc') require('../vendor/md-toc')
var Viz = require('viz.js') var Viz = require('viz.js')
import getUIElements from './lib/editor/ui-elements'
const ui = getUIElements() const ui = getUIElements()
// auto update last change // auto update last change
@ -157,7 +167,7 @@ export function renderTags (view) {
function slugifyWithUTF8 (text) { function slugifyWithUTF8 (text) {
// remove html tags and trim spaces // remove html tags and trim spaces
let newText = S(text).trim().stripTags().s let newText = stripTags(text.toString().trim())
// replace all spaces in between to dashes // replace all spaces in between to dashes
newText = newText.replace(/\s+/g, '-') newText = newText.replace(/\s+/g, '-')
// slugify string to make it valid for attribute // slugify string to make it valid for attribute
@ -190,7 +200,7 @@ export function parseMeta (md, edit, view, toc, tocAffix) {
dir = meta.dir dir = meta.dir
breaks = meta.breaks breaks = meta.breaks
} }
// text language // text language
if (lang && typeof lang === 'string') { if (lang && typeof lang === 'string') {
view.attr('lang', lang) view.attr('lang', lang)
toc.attr('lang', lang) toc.attr('lang', lang)
@ -202,7 +212,7 @@ export function parseMeta (md, edit, view, toc, tocAffix) {
tocAffix.removeAttr('lang') tocAffix.removeAttr('lang')
if (edit) { edit.removeAttr('lang', lang) } if (edit) { edit.removeAttr('lang', lang) }
} }
// text direction // text direction
if (dir && typeof dir === 'string') { if (dir && typeof dir === 'string') {
view.attr('dir', dir) view.attr('dir', dir)
toc.attr('dir', dir) toc.attr('dir', dir)
@ -212,7 +222,7 @@ export function parseMeta (md, edit, view, toc, tocAffix) {
toc.removeAttr('dir') toc.removeAttr('dir')
tocAffix.removeAttr('dir') tocAffix.removeAttr('dir')
} }
// breaks // breaks
if (typeof breaks === 'boolean' && !breaks) { if (typeof breaks === 'boolean' && !breaks) {
md.options.breaks = false md.options.breaks = false
} else { } else {
@ -245,7 +255,7 @@ if (typeof window.mermaid !== 'undefined' && window.mermaid) window.mermaid.star
// dynamic event or object binding here // dynamic event or object binding here
export function finishView (view) { export function finishView (view) {
// todo list // todo list
const lis = view.find('li.raw').removeClass('raw').sortByDepth().toArray() const lis = view.find('li.raw').removeClass('raw').sortByDepth().toArray()
for (let li of lis) { for (let li of lis) {
@ -259,9 +269,9 @@ export function finishView (view) {
li.innerHTML = html li.innerHTML = html
let disabled = 'disabled' let disabled = 'disabled'
if (typeof editor !== 'undefined' && window.havePermission()) { disabled = '' } if (typeof editor !== 'undefined' && window.havePermission()) { disabled = '' }
if (/^\s*\[[x ]\]\s*/.test(html)) { if (/^\s*\[[x ]]\s*/.test(html)) {
li.innerHTML = html.replace(/^\s*\[ \]\s*/, `<input type="checkbox" class="task-list-item-checkbox "${disabled}><label></label>`) li.innerHTML = html.replace(/^\s*\[ ]\s*/, `<input type="checkbox" class="task-list-item-checkbox "${disabled}><label></label>`)
.replace(/^\s*\[x\]\s*/, `<input type="checkbox" class="task-list-item-checkbox" checked ${disabled}><label></label>`) .replace(/^\s*\[x]\s*/, `<input type="checkbox" class="task-list-item-checkbox" checked ${disabled}><label></label>`)
if (li.tagName.toLowerCase() !== 'li') { if (li.tagName.toLowerCase() !== 'li') {
li.parentElement.setAttribute('class', 'task-list-item') li.parentElement.setAttribute('class', 'task-list-item')
} else { } else {
@ -269,42 +279,42 @@ export function finishView (view) {
} }
} }
if (typeof editor !== 'undefined' && window.havePermission()) { $(li).find('input').change(toggleTodoEvent) } if (typeof editor !== 'undefined' && window.havePermission()) { $(li).find('input').change(toggleTodoEvent) }
// color tag in list will convert it to tag icon with color // color tag in list will convert it to tag icon with color
const tagColor = $(li).closest('ul').find('.color') const tagColor = $(li).closest('ul').find('.color')
tagColor.each((key, value) => { tagColor.each((key, value) => {
$(value).addClass('fa fa-tag').css('color', $(value).attr('data-color')) $(value).addClass('fa fa-tag').css('color', $(value).attr('data-color'))
}) })
} }
// youtube // youtube
view.find('div.youtube.raw').removeClass('raw') view.find('div.youtube.raw').removeClass('raw')
.click(function () { .click(function () {
imgPlayiframe(this, '//www.youtube.com/embed/') imgPlayiframe(this, '//www.youtube.com/embed/')
}) })
// vimeo // vimeo
view.find('div.vimeo.raw').removeClass('raw') view.find('div.vimeo.raw').removeClass('raw')
.click(function () { .click(function () {
imgPlayiframe(this, '//player.vimeo.com/video/') imgPlayiframe(this, '//player.vimeo.com/video/')
}) })
.each((key, value) => { .each((key, value) => {
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: `//vimeo.com/api/v2/video/${$(value).attr('data-videoid')}.json`, url: `//vimeo.com/api/v2/video/${$(value).attr('data-videoid')}.json`,
jsonp: 'callback', jsonp: 'callback',
dataType: 'jsonp', dataType: 'jsonp',
success (data) { success (data) {
const thumbnailSrc = data[0].thumbnail_large const thumbnailSrc = data[0].thumbnail_large
const image = `<img src="${thumbnailSrc}" />` const image = `<img src="${thumbnailSrc}" />`
$(value).prepend(image) $(value).prepend(image)
if (window.viewAjaxCallback) window.viewAjaxCallback() if (window.viewAjaxCallback) window.viewAjaxCallback()
} }
}) })
}) })
// gist // gist
view.find('code[data-gist-id]').each((key, value) => { view.find('code[data-gist-id]').each((key, value) => {
if ($(value).children().length === 0) { $(value).gist(window.viewAjaxCallback) } if ($(value).children().length === 0) { $(value).gist(window.viewAjaxCallback) }
}) })
// sequence diagram // sequence diagram
const sequences = view.find('div.sequence-diagram.raw').removeClass('raw') const sequences = view.find('div.sequence-diagram.raw').removeClass('raw')
sequences.each((key, value) => { sequences.each((key, value) => {
try { try {
@ -323,11 +333,11 @@ export function finishView (view) {
svg[0].setAttribute('preserveAspectRatio', 'xMidYMid meet') svg[0].setAttribute('preserveAspectRatio', 'xMidYMid meet')
} catch (err) { } catch (err) {
$value.unwrap() $value.unwrap()
$value.parent().append('<div class="alert alert-warning">' + err + '</div>') $value.parent().append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
console.warn(err) console.warn(err)
} }
}) })
// flowchart // flowchart
const flow = view.find('div.flow-chart.raw').removeClass('raw') const flow = view.find('div.flow-chart.raw').removeClass('raw')
flow.each((key, value) => { flow.each((key, value) => {
try { try {
@ -347,11 +357,11 @@ export function finishView (view) {
$value.children().unwrap().unwrap() $value.children().unwrap().unwrap()
} catch (err) { } catch (err) {
$value.unwrap() $value.unwrap()
$value.parent().append('<div class="alert alert-warning">' + err + '</div>') $value.parent().append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
console.warn(err) console.warn(err)
} }
}) })
// graphviz // graphviz
var graphvizs = view.find('div.graphviz.raw').removeClass('raw') var graphvizs = view.find('div.graphviz.raw').removeClass('raw')
graphvizs.each(function (key, value) { graphvizs.each(function (key, value) {
try { try {
@ -366,30 +376,25 @@ export function finishView (view) {
$value.children().unwrap().unwrap() $value.children().unwrap().unwrap()
} catch (err) { } catch (err) {
$value.unwrap() $value.unwrap()
$value.parent().append('<div class="alert alert-warning">' + err + '</div>') $value.parent().append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
console.warn(err) console.warn(err)
} }
}) })
// mermaid // mermaid
const mermaids = view.find('div.mermaid.raw').removeClass('raw') const mermaids = view.find('div.mermaid.raw').removeClass('raw')
mermaids.each((key, value) => { mermaids.each((key, value) => {
try { try {
var $value = $(value) var $value = $(value)
const $ele = $(value).closest('pre') const $ele = $(value).closest('pre')
window.mermaid.mermaidAPI.parse($value.text()) window.mermaid.parse($value.text())
$ele.addClass('mermaid') $ele.addClass('mermaid')
$ele.html($value.text()) $ele.html($value.text())
window.mermaid.init(undefined, $ele) window.mermaid.init(undefined, $ele)
} catch (err) { } catch (err) {
var errormessage = err
if (err.str) {
errormessage = err.str
}
$value.unwrap() $value.unwrap()
$value.parent().append('<div class="alert alert-warning">' + errormessage + '</div>') $value.parent().append(`<div class="alert alert-warning">${escapeHTML(err.str)}</div>`)
console.warn(errormessage) console.warn(err)
} }
}) })
// abc.js // abc.js
@ -408,20 +413,20 @@ export function finishView (view) {
svg[0].setAttribute('preserveAspectRatio', 'xMidYMid meet') svg[0].setAttribute('preserveAspectRatio', 'xMidYMid meet')
} catch (err) { } catch (err) {
$value.unwrap() $value.unwrap()
$value.parent().append('<div class="alert alert-warning">' + err + '</div>') $value.parent().append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
console.warn(err) console.warn(err)
} }
}) })
// image href new window(emoji not included) // image href new window(emoji not included)
const images = view.find('img.raw[src]').removeClass('raw') const images = view.find('img.raw[src]').removeClass('raw')
images.each((key, value) => { images.each((key, value) => {
// if it's already wrapped by link, then ignore // if it's already wrapped by link, then ignore
const $value = $(value) const $value = $(value)
$value[0].onload = e => { $value[0].onload = e => {
if (window.viewAjaxCallback) window.viewAjaxCallback() if (window.viewAjaxCallback) window.viewAjaxCallback()
} }
}) })
// blockquote // blockquote
const blockquote = view.find('blockquote.raw').removeClass('raw') const blockquote = view.find('blockquote.raw').removeClass('raw')
const blockquoteP = blockquote.find('p') const blockquoteP = blockquote.find('p')
blockquoteP.each((key, value) => { blockquoteP.each((key, value) => {
@ -429,117 +434,96 @@ export function finishView (view) {
html = replaceExtraTags(html) html = replaceExtraTags(html)
$(value).html(html) $(value).html(html)
}) })
// color tag in blockquote will change its left border color // color tag in blockquote will change its left border color
const blockquoteColor = blockquote.find('.color') const blockquoteColor = blockquote.find('.color')
blockquoteColor.each((key, value) => { blockquoteColor.each((key, value) => {
$(value).closest('blockquote').css('border-left-color', $(value).attr('data-color')) $(value).closest('blockquote').css('border-left-color', $(value).attr('data-color'))
}) })
// slideshare // slideshare
view.find('div.slideshare.raw').removeClass('raw') view.find('div.slideshare.raw').removeClass('raw')
.each((key, value) => { .each((key, value) => {
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: `//www.slideshare.net/api/oembed/2?url=http://www.slideshare.net/${$(value).attr('data-slideshareid')}&format=json`, url: `//www.slideshare.net/api/oembed/2?url=http://www.slideshare.net/${$(value).attr('data-slideshareid')}&format=json`,
jsonp: 'callback', jsonp: 'callback',
dataType: 'jsonp', dataType: 'jsonp',
success (data) { success (data) {
const $html = $(data.html) const $html = $(data.html)
const iframe = $html.closest('iframe') const iframe = $html.closest('iframe')
const caption = $html.closest('div') const caption = $html.closest('div')
const inner = $('<div class="inner"></div>').append(iframe) const inner = $('<div class="inner"></div>').append(iframe)
const height = iframe.attr('height') const height = iframe.attr('height')
const width = iframe.attr('width') const width = iframe.attr('width')
const ratio = (height / width) * 100 const ratio = (height / width) * 100
inner.css('padding-bottom', `${ratio}%`) inner.css('padding-bottom', `${ratio}%`)
$(value).html(inner).append(caption) $(value).html(inner).append(caption)
if (window.viewAjaxCallback) window.viewAjaxCallback() if (window.viewAjaxCallback) window.viewAjaxCallback()
} }
}) })
}) })
// speakerdeck // speakerdeck
view.find('div.speakerdeck.raw').removeClass('raw') view.find('div.speakerdeck.raw').removeClass('raw')
.each((key, value) => { .each((key, value) => {
const url = `https://speakerdeck.com/oembed.json?url=https%3A%2F%2Fspeakerdeck.com%2F${encodeURIComponent($(value).attr('data-speakerdeckid'))}` const url = `https://speakerdeck.com/${$(value).attr('data-speakerdeckid')}`
// use yql because speakerdeck not support jsonp const inner = $('<a>Speakerdeck</a>')
$.ajax({ inner.attr('href', url)
url: 'https://query.yahooapis.com/v1/public/yql', inner.attr('rel', 'noopener noreferrer')
data: { inner.attr('target', '_blank')
q: `select * from json where url ='${url}'`, $(value).append(inner)
format: 'json' })
},
dataType: 'jsonp',
success (data) {
if (!data.query || !data.query.results) return
const json = data.query.results.json
const html = json.html
var ratio = json.height / json.width
$(value).html(html)
const iframe = $(value).children('iframe')
const src = iframe.attr('src')
if (src.indexOf('//') === 0) { iframe.attr('src', `https:${src}`) }
const inner = $('<div class="inner"></div>').append(iframe)
const height = iframe.attr('height')
const width = iframe.attr('width')
ratio = (height / width) * 100
inner.css('padding-bottom', `${ratio}%`)
$(value).html(inner)
if (window.viewAjaxCallback) window.viewAjaxCallback()
}
})
})
// pdf // pdf
view.find('div.pdf.raw').removeClass('raw') view.find('div.pdf.raw').removeClass('raw')
.each(function (key, value) { .each(function (key, value) {
const url = $(value).attr('data-pdfurl') const url = $(value).attr('data-pdfurl')
const inner = $('<div></div>') const inner = $('<div></div>')
$(this).append(inner) $(this).append(inner)
PDFObject.embed(url, inner, { PDFObject.embed(url, inner, {
height: '400px' height: '400px'
}) })
}) })
// syntax highlighting // syntax highlighting
view.find('code.raw').removeClass('raw') view.find('code.raw').removeClass('raw')
.each((key, value) => { .each((key, value) => {
const langDiv = $(value) const langDiv = $(value)
if (langDiv.length > 0) { if (langDiv.length > 0) {
const reallang = langDiv[0].className.replace(/hljs|wrap/g, '').trim() const reallang = langDiv[0].className.replace(/hljs|wrap/g, '').trim()
const codeDiv = langDiv.find('.code') const codeDiv = langDiv.find('.code')
let code = '' let code = ''
if (codeDiv.length > 0) code = codeDiv.html() if (codeDiv.length > 0) code = codeDiv.html()
else code = langDiv.html() else code = langDiv.html()
var result var result
if (!reallang) { if (!reallang) {
result = { result = {
value: code value: code
}
} else if (reallang === 'haskell' || reallang === 'go' || reallang === 'typescript' || reallang === 'jsx' || reallang === 'gherkin') {
code = S(code).unescapeHTML().s
result = {
value: Prism.highlight(code, Prism.languages[reallang])
}
} else if (reallang === 'tiddlywiki' || reallang === 'mediawiki') {
code = S(code).unescapeHTML().s
result = {
value: Prism.highlight(code, Prism.languages.wiki)
}
} else if (reallang === 'cmake') {
code = S(code).unescapeHTML().s
result = {
value: Prism.highlight(code, Prism.languages.makefile)
}
} else {
code = S(code).unescapeHTML().s
const languages = hljs.listLanguages()
if (!languages.includes(reallang)) {
result = hljs.highlightAuto(code)
} else {
result = hljs.highlight(reallang, code)
}
}
if (codeDiv.length > 0) codeDiv.html(result.value)
else langDiv.html(result.value)
} }
}) } else if (reallang === 'haskell' || reallang === 'go' || reallang === 'typescript' || reallang === 'jsx' || reallang === 'gherkin') {
code = unescapeHTML(code)
result = {
value: Prism.highlight(code, Prism.languages[reallang])
}
} else if (reallang === 'tiddlywiki' || reallang === 'mediawiki') {
code = unescapeHTML(code)
result = {
value: Prism.highlight(code, Prism.languages.wiki)
}
} else if (reallang === 'cmake') {
code = unescapeHTML(code)
result = {
value: Prism.highlight(code, Prism.languages.makefile)
}
} else {
code = unescapeHTML(code)
const languages = hljs.listLanguages()
if (!languages.includes(reallang)) {
result = hljs.highlightAuto(code)
} else {
result = hljs.highlight(reallang, code)
}
}
if (codeDiv.length > 0) codeDiv.html(result.value)
else langDiv.html(result.value)
}
})
// mathjax // mathjax
const mathjaxdivs = view.find('span.mathjax.raw').removeClass('raw').toArray() const mathjaxdivs = view.find('span.mathjax.raw').removeClass('raw').toArray()
try { try {
@ -553,7 +537,7 @@ export function finishView (view) {
} catch (err) { } catch (err) {
console.warn(err) console.warn(err)
} }
// render title // render title
document.title = renderTitle(view) document.title = renderTitle(view)
} }
@ -589,7 +573,7 @@ export function postProcess (code) {
if (warning && warning.length > 0) { if (warning && warning.length > 0) {
warning.text(md.metaError) warning.text(md.metaError)
} else { } else {
warning = $('<div id="meta-error" class="alert alert-warning">' + md.metaError + '</div>') warning = $(`<div id="meta-error" class="alert alert-warning">${escapeHTML(md.metaError)}</div>`)
result.prepend(warning) result.prepend(warning)
} }
} }
@ -613,23 +597,23 @@ window.removeDOMEvents = removeDOMEvents
function generateCleanHTML (view) { function generateCleanHTML (view) {
const src = view.clone() const src = view.clone()
const eles = src.find('*') const eles = src.find('*')
// remove syncscroll parts // remove syncscroll parts
eles.removeClass('part') eles.removeClass('part')
src.find('*[class=""]').removeAttr('class') src.find('*[class=""]').removeAttr('class')
eles.removeAttr('data-startline data-endline') eles.removeAttr('data-startline data-endline')
src.find("a[href^='#'][smoothhashscroll]").removeAttr('smoothhashscroll') src.find("a[href^='#'][smoothhashscroll]").removeAttr('smoothhashscroll')
// remove gist content // remove gist content
src.find('code[data-gist-id]').children().remove() src.find('code[data-gist-id]').children().remove()
// disable todo list // disable todo list
src.find('input.task-list-item-checkbox').attr('disabled', '') src.find('input.task-list-item-checkbox').attr('disabled', '')
// replace emoji image path // replace emoji image path
src.find('img.emoji').each((key, value) => { src.find('img.emoji').each((key, value) => {
let name = $(value).attr('alt') let name = $(value).attr('alt')
name = name.substr(1) name = name.substr(1)
name = name.slice(0, name.length - 1) name = name.slice(0, name.length - 1)
$(value).attr('src', `https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/images/basic/${name}.png`) $(value).attr('src', `https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/images/basic/${name}.png`)
}) })
// replace video to iframe // replace video to iframe
src.find('div[data-videoid]').each((key, value) => { src.find('div[data-videoid]').each((key, value) => {
const id = $(value).attr('data-videoid') const id = $(value).attr('data-videoid')
const style = $(value).attr('style') const style = $(value).attr('style')
@ -665,12 +649,12 @@ export function exportToHTML (view) {
const title = renderTitle(ui.area.markdown) const title = renderTitle(ui.area.markdown)
const filename = `${renderFilename(ui.area.markdown)}.html` const filename = `${renderFilename(ui.area.markdown)}.html`
const src = generateCleanHTML(view) const src = generateCleanHTML(view)
// generate toc // generate toc
const toc = $('#ui-toc').clone() const toc = $('#ui-toc').clone()
toc.find('*').removeClass('active').find("a[href^='#'][smoothhashscroll]").removeAttr('smoothhashscroll') toc.find('*').removeClass('active').find("a[href^='#'][smoothhashscroll]").removeAttr('smoothhashscroll')
const tocAffix = $('#ui-toc-affix').clone() const tocAffix = $('#ui-toc-affix').clone()
tocAffix.find('*').removeClass('active').find("a[href^='#'][smoothhashscroll]").removeAttr('smoothhashscroll') tocAffix.find('*').removeClass('active').find("a[href^='#'][smoothhashscroll]").removeAttr('smoothhashscroll')
// generate html via template // generate html via template
$.get(`${serverurl}/build/html.min.css`, css => { $.get(`${serverurl}/build/html.min.css`, css => {
$.get(`${serverurl}/views/html.hbs`, data => { $.get(`${serverurl}/views/html.hbs`, data => {
const template = window.Handlebars.compile(data) const template = window.Handlebars.compile(data)
@ -685,7 +669,7 @@ export function exportToHTML (view) {
dir: (md && md.meta && md.meta.dir) ? `dir="${md.meta.dir}"` : null dir: (md && md.meta && md.meta.dir) ? `dir="${md.meta.dir}"` : null
} }
const html = template(context) const html = template(context)
// console.log(html); // console.log(html);
const blob = new Blob([html], { const blob = new Blob([html], {
type: 'text/html;charset=utf-8' type: 'text/html;charset=utf-8'
}) })
@ -800,20 +784,20 @@ export function smoothHashScroll () {
const hash = element.hash const hash = element.hash
if (hash) { if (hash) {
$element.on('click', function (e) { $element.on('click', function (e) {
// store hash // store hash
const hash = decodeURIComponent(this.hash) const hash = decodeURIComponent(this.hash)
// escape special characters in jquery selector // escape special characters in jquery selector
const $hash = $(hash.replace(/(:|\.|\[|\]|,)/g, '\\$1')) const $hash = $(hash.replace(/(:|\.|\[|\]|,)/g, '\\$1'))
// return if no element been selected // return if no element been selected
if ($hash.length <= 0) return if ($hash.length <= 0) return
// prevent default anchor click behavior // prevent default anchor click behavior
e.preventDefault() e.preventDefault()
// animate // animate
$('body, html').stop(true, true).animate({ $('body, html').stop(true, true).animate({
scrollTop: $hash.offset().top scrollTop: $hash.offset().top
}, 100, 'linear', () => { }, 100, 'linear', () => {
// when done, add hash to url // when done, add hash to url
// (default click behaviour) // (default click behaviour)
window.location.hash = hash window.location.hash = hash
}) })
}) })
@ -923,7 +907,7 @@ export function scrollToHash () {
function highlightRender (code, lang) { function highlightRender (code, lang) {
if (!lang || /no(-?)highlight|plain|text/.test(lang)) { return } if (!lang || /no(-?)highlight|plain|text/.test(lang)) { return }
code = S(code).escapeHTML().s code = escapeHTML(code)
if (lang === 'sequence') { if (lang === 'sequence') {
return `<div class="sequence-diagram raw">${code}</div>` return `<div class="sequence-diagram raw">${code}</div>`
} else if (lang === 'flow') { } else if (lang === 'flow') {
@ -955,9 +939,6 @@ function highlightRender (code, lang) {
return result.value return result.value
} }
import markdownit from 'markdown-it'
import markdownitContainer from 'markdown-it-container'
export let md = markdownit('default', { export let md = markdownit('default', {
html: true, html: true,
breaks: true, breaks: true,
@ -1055,109 +1036,106 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return `<pre><code${self.renderAttrs(token)}>${highlighted}</code></pre>\n` return `<pre><code${self.renderAttrs(token)}>${highlighted}</code></pre>\n`
} }
/* Defined regex markdown it plugins */
import Plugin from 'markdown-it-regexp'
// youtube // youtube
const youtubePlugin = new Plugin( const youtubePlugin = new Plugin(
// regexp to match // regexp to match
/{%youtube\s*([\d\D]*?)\s*%}/, /{%youtube\s*([\d\D]*?)\s*%}/,
(match, utils) => { (match, utils) => {
const videoid = match[1] const videoid = match[1]
if (!videoid) return if (!videoid) return
const div = $('<div class="youtube raw"></div>') const div = $('<div class="youtube raw"></div>')
div.attr('data-videoid', videoid) div.attr('data-videoid', videoid)
const thumbnailSrc = `//img.youtube.com/vi/${videoid}/hqdefault.jpg` const thumbnailSrc = `//img.youtube.com/vi/${videoid}/hqdefault.jpg`
const image = `<img src="${thumbnailSrc}" />` const image = `<img src="${thumbnailSrc}" />`
div.append(image) div.append(image)
const icon = '<i class="icon fa fa-youtube-play fa-5x"></i>' const icon = '<i class="icon fa fa-youtube-play fa-5x"></i>'
div.append(icon) div.append(icon)
return div[0].outerHTML return div[0].outerHTML
} }
) )
// vimeo // vimeo
const vimeoPlugin = new Plugin( const vimeoPlugin = new Plugin(
// regexp to match // regexp to match
/{%vimeo\s*([\d\D]*?)\s*%}/, /{%vimeo\s*([\d\D]*?)\s*%}/,
(match, utils) => { (match, utils) => {
const videoid = match[1] const videoid = match[1]
if (!videoid) return if (!videoid) return
const div = $('<div class="vimeo raw"></div>') const div = $('<div class="vimeo raw"></div>')
div.attr('data-videoid', videoid) div.attr('data-videoid', videoid)
const icon = '<i class="icon fa fa-vimeo-square fa-5x"></i>' const icon = '<i class="icon fa fa-vimeo-square fa-5x"></i>'
div.append(icon) div.append(icon)
return div[0].outerHTML return div[0].outerHTML
} }
) )
// gist // gist
const gistPlugin = new Plugin( const gistPlugin = new Plugin(
// regexp to match // regexp to match
/{%gist\s*([\d\D]*?)\s*%}/, /{%gist\s*([\d\D]*?)\s*%}/,
(match, utils) => { (match, utils) => {
const gistid = match[1] const gistid = match[1]
const code = `<code data-gist-id="${gistid}"></code>` const code = `<code data-gist-id="${gistid}"></code>`
return code return code
} }
) )
// TOC // TOC
const tocPlugin = new Plugin( const tocPlugin = new Plugin(
// regexp to match // regexp to match
/^\[TOC\]$/i, /^\[TOC\]$/i,
(match, utils) => '<div class="toc"></div>' (match, utils) => '<div class="toc"></div>'
) )
// slideshare // slideshare
const slidesharePlugin = new Plugin( const slidesharePlugin = new Plugin(
// regexp to match // regexp to match
/{%slideshare\s*([\d\D]*?)\s*%}/, /{%slideshare\s*([\d\D]*?)\s*%}/,
(match, utils) => { (match, utils) => {
const slideshareid = match[1] const slideshareid = match[1]
const div = $('<div class="slideshare raw"></div>') const div = $('<div class="slideshare raw"></div>')
div.attr('data-slideshareid', slideshareid) div.attr('data-slideshareid', slideshareid)
return div[0].outerHTML return div[0].outerHTML
} }
) )
// speakerdeck // speakerdeck
const speakerdeckPlugin = new Plugin( const speakerdeckPlugin = new Plugin(
// regexp to match // regexp to match
/{%speakerdeck\s*([\d\D]*?)\s*%}/, /{%speakerdeck\s*([\d\D]*?)\s*%}/,
(match, utils) => { (match, utils) => {
const speakerdeckid = match[1] const speakerdeckid = match[1]
const div = $('<div class="speakerdeck raw"></div>') const div = $('<div class="speakerdeck raw"></div>')
div.attr('data-speakerdeckid', speakerdeckid) div.attr('data-speakerdeckid', speakerdeckid)
return div[0].outerHTML return div[0].outerHTML
} }
) )
// pdf // pdf
const pdfPlugin = new Plugin( const pdfPlugin = new Plugin(
// regexp to match // regexp to match
/{%pdf\s*([\d\D]*?)\s*%}/, /{%pdf\s*([\d\D]*?)\s*%}/,
(match, utils) => { (match, utils) => {
const pdfurl = match[1] const pdfurl = match[1]
if (!isValidURL(pdfurl)) return match[0] if (!isValidURL(pdfurl)) return match[0]
const div = $('<div class="pdf raw"></div>') const div = $('<div class="pdf raw"></div>')
div.attr('data-pdfurl', pdfurl) div.attr('data-pdfurl', pdfurl)
return div[0].outerHTML return div[0].outerHTML
} }
) )
const emojijsPlugin = new Plugin( const emojijsPlugin = new Plugin(
// regexp to match emoji shortcodes :something: // regexp to match emoji shortcodes :something:
// We generate an universal regex that guaranteed only contains the // We generate an universal regex that guaranteed only contains the
// emojies we have available. This should prevent all false-positives // emojies we have available. This should prevent all false-positives
new RegExp(':(' + window.emojify.emojiNames.map((item) => { return RegExp.escape(item) }).join('|') + '):', 'i'), new RegExp(':(' + window.emojify.emojiNames.map((item) => { return RegExp.escape(item) }).join('|') + '):', 'i'),
(match, utils) => { (match, utils) => {
const emoji = match[1].toLowerCase() const emoji = match[1].toLowerCase()
const div = $(`<img class="emoji" src="${serverurl}/build/emojify.js/dist/images/basic/${emoji}.png"></img>`) const div = $(`<img class="emoji" alt=":${emoji}:" src="${serverurl}/build/emojify.js/dist/images/basic/${emoji}.png"></img>`)
return div[0].outerHTML return div[0].outerHTML
} }
) )
// yaml meta, from https://github.com/eugeneware/remarkable-meta // yaml meta, from https://github.com/eugeneware/remarkable-meta

View File

@ -2,65 +2,64 @@
/* global serverurl, moment */ /* global serverurl, moment */
import store from 'store' import store from 'store'
import S from 'string' import LZString from '@hackmd/lz-string'
import LZString from 'lz-string'
import escapeHTML from 'lodash/escape'
import wurl from 'wurl'
import { import {
checkNoteIdValid, checkNoteIdValid,
encodeNoteId encodeNoteId
} from './utils' } from './utils'
import { import { checkIfAuth } from './lib/common/login'
checkIfAuth
} from './lib/common/login'
import { import { urlpath } from './lib/config'
urlpath
} from './lib/config'
window.migrateHistoryFromTempCallback = null window.migrateHistoryFromTempCallback = null
migrateHistoryFromTemp() migrateHistoryFromTemp()
function migrateHistoryFromTemp () { function migrateHistoryFromTemp () {
if (window.url('#tempid')) { if (wurl('#tempid')) {
$.get(`${serverurl}/temp`, { $.get(`${serverurl}/temp`, {
tempid: window.url('#tempid') tempid: wurl('#tempid')
}) })
.done(data => { .done(data => {
if (data && data.temp) { if (data && data.temp) {
getStorageHistory(olddata => { getStorageHistory(olddata => {
if (!olddata || olddata.length === 0) { if (!olddata || olddata.length === 0) {
saveHistoryToStorage(JSON.parse(data.temp)) saveHistoryToStorage(JSON.parse(data.temp))
} }
}) })
}
})
.always(() => {
let hash = location.hash.split('#')[1]
hash = hash.split('&')
for (let i = 0; i < hash.length; i++) {
if (hash[i].indexOf('tempid') === 0) {
hash.splice(i, 1)
i--
} }
} })
hash = hash.join('&') .always(() => {
location.hash = hash let hash = location.hash.split('#')[1]
if (window.migrateHistoryFromTempCallback) { window.migrateHistoryFromTempCallback() } hash = hash.split('&')
}) for (let i = 0; i < hash.length; i++) {
if (hash[i].indexOf('tempid') === 0) {
hash.splice(i, 1)
i--
}
}
hash = hash.join('&')
location.hash = hash
if (window.migrateHistoryFromTempCallback) { window.migrateHistoryFromTempCallback() }
})
} }
} }
export function saveHistory (notehistory) { export function saveHistory (notehistory) {
checkIfAuth( checkIfAuth(
() => { () => {
saveHistoryToServer(notehistory) saveHistoryToServer(notehistory)
}, },
() => { () => {
saveHistoryToStorage(notehistory) saveHistoryToStorage(notehistory)
} }
) )
} }
function saveHistoryToStorage (notehistory) { function saveHistoryToStorage (notehistory) {
@ -79,9 +78,9 @@ export function saveStorageHistoryToServer (callback) {
$.post(`${serverurl}/history`, { $.post(`${serverurl}/history`, {
history: data history: data
}) })
.done(data => { .done(data => {
callback(data) callback(data)
}) })
} }
} }
@ -108,7 +107,7 @@ export function clearDuplicatedHistory (notehistory) {
} }
function addHistory (id, text, time, tags, pinned, notehistory) { function addHistory (id, text, time, tags, pinned, notehistory) {
// only add when note id exists // only add when note id exists
if (id) { if (id) {
notehistory.push({ notehistory.push({
id, id,
@ -134,14 +133,14 @@ export function removeHistory (id, notehistory) {
// used for inner // used for inner
export function writeHistory (title, tags) { export function writeHistory (title, tags) {
checkIfAuth( checkIfAuth(
() => { () => {
// no need to do this anymore, this will count from server-side // no need to do this anymore, this will count from server-side
// writeHistoryToServer(title, tags); // writeHistoryToServer(title, tags);
}, },
() => { () => {
writeHistoryToStorage(title, tags) writeHistoryToStorage(title, tags)
} }
) )
} }
function writeHistoryToStorage (title, tags) { function writeHistoryToStorage (title, tags) {
@ -162,7 +161,7 @@ if (!Array.isArray) {
} }
function renderHistory (title, tags) { function renderHistory (title, tags) {
// console.debug(tags); // console.debug(tags);
const id = urlpath ? location.pathname.slice(urlpath.length + 1, location.pathname.length).split('/')[1] : location.pathname.split('/')[1] const id = urlpath ? location.pathname.slice(urlpath.length + 1, location.pathname.length).split('/')[1] : location.pathname.split('/')[1]
return { return {
id, id,
@ -174,7 +173,7 @@ function renderHistory (title, tags) {
function generateHistory (title, tags, notehistory) { function generateHistory (title, tags, notehistory) {
const info = renderHistory(title, tags) const info = renderHistory(title, tags)
// keep any pinned data // keep any pinned data
let pinned = false let pinned = false
for (let i = 0; i < notehistory.length; i++) { for (let i = 0; i < notehistory.length; i++) {
if (notehistory[i].id === info.id && notehistory[i].pinned) { if (notehistory[i].id === info.id && notehistory[i].pinned) {
@ -191,25 +190,25 @@ function generateHistory (title, tags, notehistory) {
// used for outer // used for outer
export function getHistory (callback) { export function getHistory (callback) {
checkIfAuth( checkIfAuth(
() => { () => {
getServerHistory(callback) getServerHistory(callback)
}, },
() => { () => {
getStorageHistory(callback) getStorageHistory(callback)
} }
) )
} }
function getServerHistory (callback) { function getServerHistory (callback) {
$.get(`${serverurl}/history`) $.get(`${serverurl}/history`)
.done(data => { .done(data => {
if (data.history) { if (data.history) {
callback(data.history) callback(data.history)
} }
}) })
.fail((xhr, status, error) => { .fail((xhr, status, error) => {
console.error(xhr.responseText) console.error(xhr.responseText)
}) })
} }
export function getStorageHistory (callback) { export function getStorageHistory (callback) {
@ -224,25 +223,25 @@ export function getStorageHistory (callback) {
export function parseHistory (list, callback) { export function parseHistory (list, callback) {
checkIfAuth( checkIfAuth(
() => { () => {
parseServerToHistory(list, callback) parseServerToHistory(list, callback)
}, },
() => { () => {
parseStorageToHistory(list, callback) parseStorageToHistory(list, callback)
} }
) )
} }
export function parseServerToHistory (list, callback) { export function parseServerToHistory (list, callback) {
$.get(`${serverurl}/history`) $.get(`${serverurl}/history`)
.done(data => { .done(data => {
if (data.history) { if (data.history) {
parseToHistory(list, data.history, callback) parseToHistory(list, data.history, callback)
} }
}) })
.fail((xhr, status, error) => { .fail((xhr, status, error) => {
console.error(xhr.responseText) console.error(xhr.responseText)
}) })
} }
export function parseStorageToHistory (list, callback) { export function parseStorageToHistory (list, callback) {
@ -268,15 +267,15 @@ function parseToHistory (list, notehistory, callback) {
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
// parse time to timestamp and fromNow // parse time to timestamp and fromNow
const timestamp = (typeof notehistory[i].time === 'number' ? moment(notehistory[i].time) : moment(notehistory[i].time, 'MMMM Do YYYY, h:mm:ss a')) const timestamp = (typeof notehistory[i].time === 'number' ? moment(notehistory[i].time) : moment(notehistory[i].time, 'MMMM Do YYYY, h:mm:ss a'))
notehistory[i].timestamp = timestamp.valueOf() notehistory[i].timestamp = timestamp.valueOf()
notehistory[i].fromNow = timestamp.fromNow() notehistory[i].fromNow = timestamp.fromNow()
notehistory[i].time = timestamp.format('llll') notehistory[i].time = timestamp.format('llll')
// prevent XSS // prevent XSS
notehistory[i].text = S(notehistory[i].text).escapeHTML().s notehistory[i].text = escapeHTML(notehistory[i].text)
notehistory[i].tags = (notehistory[i].tags && notehistory[i].tags.length > 0) ? S(notehistory[i].tags).escapeHTML().s.split(',') : [] notehistory[i].tags = (notehistory[i].tags && notehistory[i].tags.length > 0) ? escapeHTML(notehistory[i].tags).split(',') : []
// add to list // add to list
if (notehistory[i].id && list.get('id', notehistory[i].id).length === 0) { list.add(notehistory[i]) } if (notehistory[i].id && list.get('id', notehistory[i].id).length === 0) { list.add(notehistory[i]) }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -60,22 +60,22 @@ export function checkIfAuth (yesCallback, noCallback) {
if (checkLoginStateChanged()) checkAuth = false if (checkLoginStateChanged()) checkAuth = false
if (!checkAuth || typeof cookieLoginState === 'undefined') { if (!checkAuth || typeof cookieLoginState === 'undefined') {
$.get(`${serverurl}/me`) $.get(`${serverurl}/me`)
.done(data => { .done(data => {
if (data && data.status === 'ok') { if (data && data.status === 'ok') {
profile = data profile = data
yesCallback(profile) yesCallback(profile)
setLoginState(true, data.id) setLoginState(true, data.id)
} else { } else {
noCallback() noCallback()
setLoginState(false) setLoginState(false)
} }
}) })
.fail(() => { .fail(() => {
noCallback() noCallback()
}) })
.always(() => { .always(() => {
checkAuth = true checkAuth = true
}) })
} else if (cookieLoginState) { } else if (cookieLoginState) {
yesCallback(profile) yesCallback(profile)
} else { } else {

View File

@ -1,3 +1,4 @@
/* global CodeMirror, $, editor, Cookies */
import * as utils from './utils' import * as utils from './utils'
import config from './config' import config from './config'
import statusBarTemplate from './statusbar.html' import statusBarTemplate from './statusbar.html'

View File

@ -1,6 +1,7 @@
/* /*
* Global UI elements references * Global UI elements references
*/ */
/* global $ */
export const getUIElements = () => ({ export const getUIElements = () => ({
spinner: $('.ui-spinner'), spinner: $('.ui-spinner'),

View File

@ -1,3 +1,4 @@
/* global CodeMirror, editor */
const wrapSymbols = ['*', '_', '~', '^', '+', '='] const wrapSymbols = ['*', '_', '~', '^', '+', '=']
export function wrapTextWith (editor, cm, symbol) { export function wrapTextWith (editor, cm, symbol) {
if (!cm.getSelection()) { if (!cm.getSelection()) {
@ -51,7 +52,7 @@ export function insertText (cm, text, cursorEnd = 0) {
let cursor = cm.getCursor() let cursor = cm.getCursor()
cm.replaceSelection(text, cursor, cursor) cm.replaceSelection(text, cursor, cursor)
cm.focus() cm.focus()
cm.setCursor({line: cursor.line, ch: cursor.ch + cursorEnd}) cm.setCursor({ line: cursor.line, ch: cursor.ch + cursorEnd })
} }
export function insertLink (cm, isImage) { export function insertLink (cm, isImage) {
@ -80,7 +81,7 @@ export function insertLink (cm, isImage) {
cm.setSelections(ranges) cm.setSelections(ranges)
} else { } else {
cm.replaceRange(symbol + linkEnd, cursor, cursor) cm.replaceRange(symbol + linkEnd, cursor, cursor)
cm.setCursor({line: cursor.line, ch: cursor.ch + symbol.length + linkEnd.length}) cm.setCursor({ line: cursor.line, ch: cursor.ch + symbol.length + linkEnd.length })
} }
} }
cm.focus() cm.focus()
@ -88,8 +89,8 @@ export function insertLink (cm, isImage) {
export function insertHeader (cm) { export function insertHeader (cm) {
let cursor = cm.getCursor() let cursor = cm.getCursor()
let startOfLine = {line: cursor.line, ch: 0} let startOfLine = { line: cursor.line, ch: 0 }
let startOfLineText = cm.getRange(startOfLine, {line: cursor.line, ch: 1}) let startOfLineText = cm.getRange(startOfLine, { line: cursor.line, ch: 1 })
// See if it is already a header // See if it is already a header
if (startOfLineText === '#') { if (startOfLineText === '#') {
cm.replaceRange('#', startOfLine, startOfLine) cm.replaceRange('#', startOfLine, startOfLine)
@ -108,14 +109,14 @@ export function insertOnStartOfLines (cm, symbol) {
if (!range.empty()) { if (!range.empty()) {
const from = range.from() const from = range.from()
const to = range.to() const to = range.to()
let selection = cm.getRange({line: from.line, ch: 0}, to) let selection = cm.getRange({ line: from.line, ch: 0 }, to)
selection = selection.replace(/\n/g, '\n' + symbol) selection = selection.replace(/\n/g, '\n' + symbol)
selection = symbol + selection selection = symbol + selection
cm.replaceRange(selection, from, to) cm.replaceRange(selection, from, to)
} else { } else {
cm.replaceRange(symbol, {line: cursor.line, ch: 0}, {line: cursor.line, ch: 0}) cm.replaceRange(symbol, { line: cursor.line, ch: 0 }, { line: cursor.line, ch: 0 })
} }
} }
cm.setCursor({line: cursor.line, ch: cursor.ch + symbol.length}) cm.setCursor({ line: cursor.line, ch: cursor.ch + symbol.length })
cm.focus() cm.focus()
} }

View File

@ -188,7 +188,7 @@ function buildMapInner (callback) {
} }
nonEmptyList.push(0) nonEmptyList.push(0)
// make the first line go top // make the first line go top
_scrollMap[0] = viewTop _scrollMap[0] = viewTop
const parts = markdownArea.find('.part').toArray() const parts = markdownArea.find('.part').toArray()
@ -336,7 +336,7 @@ export function syncScrollToView (event, preventAnimate) {
const scrollInfo = editor.getScrollInfo() const scrollInfo = editor.getScrollInfo()
const textHeight = editor.defaultTextHeight() const textHeight = editor.defaultTextHeight()
lineNo = Math.floor(scrollInfo.top / textHeight) lineNo = Math.floor(scrollInfo.top / textHeight)
// if reach the last line, will start lerp to the bottom // if reach the last line, will start lerp to the bottom
const diffToBottom = (scrollInfo.top + scrollInfo.clientHeight) - (scrollInfo.height - textHeight) const diffToBottom = (scrollInfo.top + scrollInfo.clientHeight) - (scrollInfo.height - textHeight)
if (scrollInfo.height > scrollInfo.clientHeight && diffToBottom > 0) { if (scrollInfo.height > scrollInfo.clientHeight && diffToBottom > 0) {
topDiffPercent = diffToBottom / textHeight topDiffPercent = diffToBottom / textHeight

View File

@ -1,29 +1,29 @@
/* eslint-env browser, jquery */ /* eslint-env browser, jquery */
/* global refreshView */ /* global refreshView */
import {
autoLinkify,
deduplicatedHeaderId,
removeDOMEvents,
finishView,
generateToc,
md,
parseMeta,
postProcess,
renderTOC,
scrollToHash,
smoothHashScroll,
updateLastChange
} from './extra'
import { preventXSS } from './render'
require('../css/extra.css') require('../css/extra.css')
require('../css/slide-preview.css') require('../css/slide-preview.css')
require('../css/site.css') require('../css/site.css')
require('highlight.js/styles/github-gist.css') require('highlight.js/styles/github-gist.css')
import {
autoLinkify,
deduplicatedHeaderId,
removeDOMEvents,
finishView,
generateToc,
md,
parseMeta,
postProcess,
renderTOC,
scrollToHash,
smoothHashScroll,
updateLastChange
} from './extra'
import { preventXSS } from './render'
const markdown = $('#doc.markdown-body') const markdown = $('#doc.markdown-body')
const text = markdown.text() const text = markdown.text()
const lastMeta = md.meta const lastMeta = md.meta
@ -38,7 +38,7 @@ if (md.meta.type && md.meta.type === 'slide') {
const slides = window.RevealMarkdown.slidify(text, slideOptions) const slides = window.RevealMarkdown.slidify(text, slideOptions)
markdown.html(slides) markdown.html(slides)
window.RevealMarkdown.initialize() window.RevealMarkdown.initialize()
// prevent XSS // prevent XSS
markdown.html(preventXSS(markdown.html())) markdown.html(preventXSS(markdown.html()))
markdown.addClass('slides') markdown.addClass('slides')
} else { } else {
@ -46,12 +46,12 @@ if (md.meta.type && md.meta.type === 'slide') {
refreshView() refreshView()
markdown.removeClass('slides') markdown.removeClass('slides')
} }
// only render again when meta changed // only render again when meta changed
if (JSON.stringify(md.meta) !== JSON.stringify(lastMeta)) { if (JSON.stringify(md.meta) !== JSON.stringify(lastMeta)) {
parseMeta(md, null, markdown, $('#ui-toc'), $('#ui-toc-affix')) parseMeta(md, null, markdown, $('#ui-toc'), $('#ui-toc-affix'))
rendered = md.render(text) rendered = md.render(text)
} }
// prevent XSS // prevent XSS
rendered = preventXSS(rendered) rendered = preventXSS(rendered)
const result = postProcess(rendered) const result = postProcess(rendered)
markdown.html(result.html()) markdown.html(result.html())
@ -98,14 +98,14 @@ function generateScrollspy () {
} }
function windowResize () { function windowResize () {
// toc right // toc right
const paddingRight = parseFloat(markdown.css('padding-right')) const paddingRight = parseFloat(markdown.css('padding-right'))
const right = ($(window).width() - (markdown.offset().left + markdown.outerWidth() - paddingRight)) const right = ($(window).width() - (markdown.offset().left + markdown.outerWidth() - paddingRight))
toc.css('right', `${right}px`) toc.css('right', `${right}px`)
// affix toc left // affix toc left
let newbool let newbool
const rightMargin = (markdown.parent().outerWidth() - markdown.outerWidth()) / 2 const rightMargin = (markdown.parent().outerWidth() - markdown.outerWidth()) / 2
// for ipad or wider device // for ipad or wider device
if (rightMargin >= 133) { if (rightMargin >= 133) {
newbool = true newbool = true
const affixLeftMargin = (tocAffix.outerWidth() - tocAffix.width()) / 2 const affixLeftMargin = (tocAffix.outerWidth() - tocAffix.width()) / 2
@ -126,7 +126,7 @@ $(document).ready(() => {
windowResize() windowResize()
generateScrollspy() generateScrollspy()
setTimeout(scrollToHash, 0) setTimeout(scrollToHash, 0)
// tooltip // tooltip
$('[data-toggle="tooltip"]').tooltip() $('[data-toggle="tooltip"]').tooltip()
}) })

View File

@ -44,7 +44,7 @@ var filterXSSOptions = {
onIgnoreTag: function (tag, html, options) { onIgnoreTag: function (tag, html, options) {
// allow comment tag // allow comment tag
if (tag === '!--') { if (tag === '!--') {
// do not filter its attributes // do not filter its attributes
return html.replace(/<(?!!--)/g, '&lt;').replace(/-->/g, '__HTML_COMMENT_END__').replace(/>/g, '&gt;').replace(/__HTML_COMMENT_END__/g, '-->') return html.replace(/<(?!!--)/g, '&lt;').replace(/-->/g, '__HTML_COMMENT_END__').replace(/>/g, '&gt;').replace(/__HTML_COMMENT_END__/g, '-->')
} }
}, },

View File

@ -1,12 +1,12 @@
/* eslint-env browser, jquery */ /* eslint-env browser, jquery */
/* global serverurl, Reveal, RevealMarkdown */ /* global serverurl, Reveal, RevealMarkdown */
require('../css/extra.css')
require('../css/site.css')
import { preventXSS } from './render' import { preventXSS } from './render'
import { md, updateLastChange, removeDOMEvents, finishView } from './extra' import { md, updateLastChange, removeDOMEvents, finishView } from './extra'
require('../css/extra.css')
require('../css/site.css')
const body = preventXSS($('.slides').text()) const body = preventXSS($('.slides').text())
window.createtime = window.lastchangeui.time.attr('data-createtime') window.createtime = window.lastchangeui.time.attr('data-createtime')
@ -17,7 +17,7 @@ $('.ui-edit').attr('href', `${url}/edit`)
$('.ui-print').attr('href', `${url}?print-pdf`) $('.ui-print').attr('href', `${url}?print-pdf`)
$(document).ready(() => { $(document).ready(() => {
// tooltip // tooltip
$('[data-toggle="tooltip"]').tooltip() $('[data-toggle="tooltip"]').tooltip()
}) })
@ -74,6 +74,21 @@ const defaultOptions = {
const meta = JSON.parse($('#meta').text()) const meta = JSON.parse($('#meta').text())
var options = meta.slideOptions || {} var options = meta.slideOptions || {}
if (options.hasOwnProperty('spotlight')) {
defaultOptions.dependencies.push({
src: `${serverurl}/build/reveal.js/plugin/spotlight/spotlight.js`
})
}
if (options.hasOwnProperty('allottedTime') || options.hasOwnProperty('allottedMinutes')) {
defaultOptions.dependencies.push({
src: `${serverurl}/build/reveal.js/plugin/elapsed-time-bar/elapsed-time-bar.js`
})
if (options.hasOwnProperty('allottedMinutes')) {
options.allottedTime = options.allottedMinutes * 60 * 1000
}
}
const view = $('.reveal') const view = $('.reveal')
// text language // text language
@ -127,7 +142,7 @@ function renderSlide (event) {
Reveal.addEventListener('ready', event => { Reveal.addEventListener('ready', event => {
renderSlide(event) renderSlide(event)
const markdown = $(event.currentSlide) const markdown = $(event.currentSlide)
// force browser redraw // force browser redraw
setTimeout(() => { setTimeout(() => {
markdown.hide().show(0) markdown.hide().show(0)
}, 0) }, 0)

View File

@ -8,8 +8,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/Safe.js" integrity="sha256-0ygBUDksNDXZS4vm5HMNH1a33KUu6QT1cdNTN+ZLF+4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.0/mermaid.min.js" integrity="sha256-M3OC0Q6g4/+Q4j73OvnsnA+lMkdAE5KgupRHqTiPbnI=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.2.3/mermaid.min.js" integrity="sha256-4s3fF5e1iWRLtiV7mRev7n17oALqqDHbWrNqF3/r7jU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.2/lodash.min.js" integrity="sha256-Cv5v4i4SuYvwRYzIONifZjoc99CkwfncROMSWat1cVA=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.2/lodash.min.js" integrity="sha256-Cv5v4i4SuYvwRYzIONifZjoc99CkwfncROMSWat1cVA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js" integrity="sha256-ji09tECORKvr8xB9iCl8DJ8iNMLriDchC1+p+yt1hSs=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js" integrity="sha256-ji09tECORKvr8xB9iCl8DJ8iNMLriDchC1+p+yt1hSs=" crossorigin="anonymous"></script>
@ -23,5 +24,6 @@
<% } else { %> <% } else { %>
<script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script> <script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script>
<script src="<%- serverURL %>/build/MathJax/config/TeX-AMS-MML_HTMLorMML.js" defer></script> <script src="<%- serverURL %>/build/MathJax/config/TeX-AMS-MML_HTMLorMML.js" defer></script>
<script src="<%- serverURL %>/build/MathJax/config/Safe.js" defer></script>
<%- include ../build/index-pack-scripts %> <%- include ../build/index-pack-scripts %>
<% } %> <% } %>

View File

@ -148,12 +148,13 @@
<option value="da">dansk</option> <option value="da">dansk</option>
<option value="ko">한국어</option> <option value="ko">한국어</option>
<option value="id">Bahasa Indonesia</option> <option value="id">Bahasa Indonesia</option>
<option value="sr">српски</option>
</select> </select>
<p> <p>
Powered by <a href="https://codimd.org">CodiMD</a> | <a href="<%- serverURL %>/s/release-notes" target="_blank" rel="noopener"><%= __('Releases') %></a>| <a href="<%- sourceURL %>" target="_blank" rel="noopener"><%= __('Source Code') %></a><% if(privacyStatement) { %> | <a href="<%- serverURL %>/s/privacy" target="_blank" rel="noopener"><%= __('Privacy') %></a><% } %><% if(termsOfUse) { %> | <a href="<%- serverURL %>/s/terms-of-use" target="_blank" rel="noopener"><%= __('Terms of Use') %></a><% } %> <%- __('Powered by %s', '<a href="https://github.com/hackmdio/codimd">CodiMD</a>') %> | <a href="<%- serverURL %>/s/release-notes" target="_blank" rel="noopener"><%= __('Releases') %></a>| <a href="<%- sourceURL %>" target="_blank" rel="noopener"><%= __('Source Code') %></a><% if(privacyStatement) { %> | <a href="<%- serverURL %>/s/privacy" target="_blank" rel="noopener"><%= __('Privacy') %></a><% } %><% if(termsOfUse) { %> | <a href="<%- serverURL %>/s/terms-of-use" target="_blank" rel="noopener"><%= __('Terms of Use') %></a><% } %>
</p> </p>
<h6 class="social-foot"> <h6 class="social-foot">
<%- __('Follow us on %s and %s.', '<a href="https://github.com/hackmdio/CodiMD" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub</a>, <a href="https://riot.im/app/#/room/#codimd:matrix.org" target="_blank" rel="noopener"><i class="fa fa-comments"></i> Riot</a>', '<a href="https://translate.codimd.org" target="_blank" rel="noopener"><i class="fa fa-globe"></i> POEditor</a>') %> <%- __('Follow us on %s and %s.', '<a href="https://github.com/hackmdio/CodiMD" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub</a>, <a href="https://gitter.im/hackmdio/hackmd" target="_blank" rel="noopener"><i class="fa fa-comments"></i> Gitter</a>', '<a href="https://poeditor.com/join/project/q0nuPWyztp" target="_blank" rel="noopener"><i class="fa fa-globe"></i> POEditor</a>') %>
</h6> </h6>
</div> </div>
</div> </div>

View File

@ -80,8 +80,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/Safe.js" integrity="sha256-0ygBUDksNDXZS4vm5HMNH1a33KUu6QT1cdNTN+ZLF+4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.0/mermaid.min.js" integrity="sha256-M3OC0Q6g4/+Q4j73OvnsnA+lMkdAE5KgupRHqTiPbnI=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.2.3/mermaid.min.js" integrity="sha256-4s3fF5e1iWRLtiV7mRev7n17oALqqDHbWrNqF3/r7jU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script>
@ -92,6 +93,7 @@
<% } else { %> <% } else { %>
<script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script> <script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script>
<script src="<%- serverURL %>/build/MathJax/config/TeX-AMS-MML_HTMLorMML.js" defer></script> <script src="<%- serverURL %>/build/MathJax/config/TeX-AMS-MML_HTMLorMML.js" defer></script>
<script src="<%- serverURL %>/build/MathJax/config/Safe.js" defer></script>
<%- include build/pretty-pack-scripts %> <%- include build/pretty-pack-scripts %>
<% } %> <% } %>
<%- include shared/ga %> <%- include shared/ga %>

View File

@ -17,9 +17,9 @@
<div class="panel-body"> <div class="panel-body">
<a href="https://github.com/hackmdio/codimd/issues" target="_blank"><i class="fa fa-tag fa-fw"></i> <%= __('Report an issue') %></a> <a href="https://github.com/hackmdio/codimd/issues" target="_blank"><i class="fa fa-tag fa-fw"></i> <%= __('Report an issue') %></a>
<br> <br>
<a href="https://riot.im/app/#/room/#codimd:matrix.org" target="_blank"><i class="fa fa-hashtag fa-fw"></i> <%= __('Meet us on %s', 'Matrix') %></a> <a href="https://gitter.im/hackmdio/hackmd" target="_blank"><i class="fa fa-hashtag fa-fw"></i> <%= __('Meet us on %s', 'Gitter') %></a>
<br> <br>
<a href="https://translate.codimd.org" target="_blank"><i class="fa fa-language fa-fw"></i> <%= __('Help us translating on %s', 'POEditor') %></a> <a href="https://poeditor.com/join/project/q0nuPWyztp" target="_blank"><i class="fa fa-language fa-fw"></i> <%= __('Help us translating on %s', 'POEditor') %></a>
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -24,13 +24,13 @@
</a> </a>
<% } %> <% } %>
<% if (authProviders.gitlab) { %> <% if (authProviders.gitlab) { %>
<a href="<%- serverURL %>/auth/gitlab" class="btn btn-lg btn-block btn-social btn-soundcloud"> <a href="<%- serverURL %>/auth/gitlab" class="btn btn-lg btn-block btn-social btn-gitlab">
<i class="fa fa-gitlab"></i> <%= __('Sign in via %s', 'GitLab') %> <i class="fa fa-gitlab"></i> <%= __('Sign in via %s', 'GitLab') %>
</a> </a>
<% } %> <% } %>
<% if (authProviders.mattermost) { %> <% if (authProviders.mattermost) { %>
<a href="<%- serverURL %>/auth/mattermost" class="btn btn-lg btn-block btn-social btn-soundcloud"> <a href="<%- serverURL %>/auth/mattermost" class="btn btn-lg btn-block btn-social btn-mattermost">
<i class="fa fa-mattermost"></i> <%= __('Sign in via %s', 'Mattermost') %> <i class="oauth-icon"><img alt="mattermost-logo" src="<%- serverURL %>/images/mattermost-logo.svg" /></i> <%= __('Sign in via %s', 'Mattermost') %>
</a> </a>
<% } %> <% } %>
<% if (authProviders.dropbox) { %> <% if (authProviders.dropbox) { %>
@ -57,7 +57,7 @@
<hr> <hr>
<% }%> <% }%>
<% if (authProviders.ldap) { %> <% if (authProviders.ldap) { %>
<h4>Via <% if (authProviders.ldapProviderName) { %> <%= authProviders.ldapProviderName %> (LDAP) <% } else { %> LDAP <% } %></h4> <h4><%= __('Sign in via %s', authProviders.ldapProviderName ? authProviders.ldapProviderName + ' (LDAP)' : 'LDAP') %></h4>
<form data-toggle="validator" role="form" class="form-horizontal" method="post" enctype="application/x-www-form-urlencoded"> <form data-toggle="validator" role="form" class="form-horizontal" method="post" enctype="application/x-www-form-urlencoded">
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
@ -73,7 +73,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="submit" class="btn btn-primary" formaction="<%- serverURL %>/auth/ldap">Sign in</button> <button type="submit" class="btn btn-primary" formaction="<%- serverURL %>/auth/ldap"><%= __('Sign In') %></button>
</div> </div>
</div> </div>
</form> </form>
@ -82,7 +82,7 @@
<hr> <hr>
<% }%> <% }%>
<% if (authProviders.openID) { %> <% if (authProviders.openID) { %>
<h4>OpenID</h4> <h4><%= __('Sign in via %s', 'OpenID') %></h4>
<form data-toggle="validator" role="form" class="form-horizontal" method="post" enctype="application/x-www-form-urlencoded"> <form data-toggle="validator" role="form" class="form-horizontal" method="post" enctype="application/x-www-form-urlencoded">
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
@ -92,7 +92,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="submit" class="btn btn-primary" formaction="<%- serverURL %>/auth/openid">Sign in</button> <button type="submit" class="btn btn-primary" formaction="<%- serverURL %>/auth/openid"><%= __('Sign In') %></button>
</div> </div>
</div> </div>
</form> </form>
@ -101,11 +101,11 @@
<hr> <hr>
<% }%> <% }%>
<% if (authProviders.email) { %> <% if (authProviders.email) { %>
<h4>Via Email</h4> <h4><%= __('Sign in via %s', 'E-Mail') %></h4>
<form data-toggle="validator" role="form" class="form-horizontal" method="post" enctype="application/x-www-form-urlencoded"> <form data-toggle="validator" role="form" class="form-horizontal" method="post" enctype="application/x-www-form-urlencoded">
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<input type="email" class="form-control" name="email" placeholder="Email" required> <input type="email" class="form-control" name="email" placeholder="E-Mail" required>
<span class="help-block control-label with-errors" style="display: inline;"></span> <span class="help-block control-label with-errors" style="display: inline;"></span>
</div> </div>
</div> </div>
@ -117,8 +117,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button type="submit" class="btn btn-primary" formaction="<%- serverURL %>/login">Sign in</button> <button type="submit" class="btn btn-primary" formaction="<%- serverURL %>/login"><%= __('Sign In') %></button>
<% if (authProviders.allowEmailRegister) { %><button type="submit" class="btn btn-default" formaction="<%- serverURL %>/register">Register</button><% }%> <% if (authProviders.allowEmailRegister) { %><button type="submit" class="btn btn-default" formaction="<%- serverURL %>/register"><%= __('Register') %></button><% }%>
</div> </div>
</div> </div>
</form> </form>

View File

@ -96,8 +96,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/Safe.js" integrity="sha256-0ygBUDksNDXZS4vm5HMNH1a33KUu6QT1cdNTN+ZLF+4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.0/mermaid.min.js" integrity="sha256-M3OC0Q6g4/+Q4j73OvnsnA+lMkdAE5KgupRHqTiPbnI=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.2.3/mermaid.min.js" integrity="sha256-4s3fF5e1iWRLtiV7mRev7n17oALqqDHbWrNqF3/r7jU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script>
@ -108,6 +109,7 @@
<% } else { %> <% } else { %>
<script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script> <script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script>
<script src="<%- serverURL %>/build/MathJax/config/TeX-AMS-MML_HTMLorMML.js" defer></script> <script src="<%- serverURL %>/build/MathJax/config/TeX-AMS-MML_HTMLorMML.js" defer></script>
<script src="<%- serverURL %>/build/MathJax/config/Safe.js" defer></script>
<%- include build/slide-pack-scripts %> <%- include build/slide-pack-scripts %>
<% } %> <% } %>
</body> </body>

View File

@ -0,0 +1,129 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const sinon = require('sinon')
const { ProcessQueue } = require('../lib/processQueue')
describe('ProcessQueue', function () {
let clock
const waitTimeForCheckResult = 50
beforeEach(() => {
clock = sinon.useFakeTimers({
toFake: ['setInterval']
})
})
afterEach(() => {
clock.restore()
sinon.restore()
})
it('should not accept more than maximum task', () => {
const queue = new ProcessQueue({ maximumLength: 2 })
queue.start()
assert(queue.push(1, () => (Promise.resolve())))
assert(queue.push(1, () => (Promise.resolve())) === false)
})
it('should run task every interval', (done) => {
const runningClock = []
const queue = new ProcessQueue({ maximumLength: 2 })
const task = async () => {
runningClock.push(clock.now)
}
queue.start()
assert(queue.push(1, task))
assert(queue.push(2, task))
clock.tick(5)
setTimeout(() => {
clock.tick(5)
}, 1)
setTimeout(() => {
clock.tick(5)
}, 2)
setTimeout(() => {
clock.tick(5)
}, 3)
setTimeout(() => {
queue.stop()
assert(runningClock.length === 2)
done()
}, waitTimeForCheckResult)
})
it('should not crash when repeat stop queue', () => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
try {
queue.stop()
queue.stop()
queue.stop()
assert.ok(true)
} catch (e) {
assert.fail(e)
}
})
it('should run process when queue is empty', (done) => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
const processSpy = sinon.spy(queue, 'process')
queue.start()
clock.tick(100)
setTimeout(() => {
assert(processSpy.called)
done()
}, waitTimeForCheckResult)
})
it('should run process although error occurred', (done) => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
const failedTask = sinon.spy(async () => {
throw new Error('error')
})
const normalTask = sinon.spy(async () => {
})
queue.start()
assert(queue.push(1, failedTask))
assert(queue.push(2, normalTask))
clock.tick(100)
setTimeout(() => {
clock.tick(100)
}, 1)
setTimeout(() => {
// assert(queue.queue.length === 0)
assert(failedTask.called)
assert(normalTask.called)
done()
}, waitTimeForCheckResult)
})
it('should ignore trigger when event not complete', (done) => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
const processSpy = sinon.spy(queue, 'process')
const longTask = async () => {
return new Promise((resolve) => {
setInterval(() => {
resolve()
}, 50)
})
}
queue.start()
queue.push(1, longTask)
clock.tick(10)
setTimeout(() => {
clock.tick(10)
}, 0)
setTimeout(() => {
clock.tick(10)
}, 1)
setTimeout(() => {
assert(processSpy.callCount === 1)
assert(processSpy.calledOnce)
done()
}, waitTimeForCheckResult)
})
})

View File

@ -3,11 +3,50 @@
'use strict' 'use strict'
const assert = require('assert') const assert = require('assert')
const avatars = require('../lib/letter-avatars') const mock = require('mock-require')
describe('generateAvatarURL() gravatar enabled', function () {
let avatars
beforeEach(function () {
// Reset config to make sure we don't influence other tests
let testconfig = {
allowGravatar: true,
serverURL: 'http://localhost:3000',
port: 3000
}
mock('../lib/config', testconfig)
avatars = mock.reRequire('../lib/letter-avatars')
})
describe('generateAvatarURL()', function () {
it('should return correct urls', function () { it('should return correct urls', function () {
assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels', 'hello@dsprenkels.com', true), 'https://www.gravatar.com/avatar/d41b5f3508cc3f31865566a47dd0336b?s=400') assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels', 'hello@dsprenkels.com', true), 'https://www.gravatar.com/avatar/d41b5f3508cc3f31865566a47dd0336b?s=400')
assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels', 'hello@dsprenkels.com', false), 'https://www.gravatar.com/avatar/d41b5f3508cc3f31865566a47dd0336b?s=96') assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels', 'hello@dsprenkels.com', false), 'https://www.gravatar.com/avatar/d41b5f3508cc3f31865566a47dd0336b?s=96')
}) })
it('should return correct urls for names with spaces', function () {
assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels'), 'http://localhost:3000/user/Daan%20Sprenkels/avatar.svg')
})
})
describe('generateAvatarURL() gravatar disabled', function () {
let avatars
beforeEach(function () {
// Reset config to make sure we don't influence other tests
let testconfig = {
allowGravatar: false,
serverURL: 'http://localhost:3000',
port: 3000
}
mock('../lib/config', testconfig)
avatars = mock.reRequire('../lib/letter-avatars')
})
it('should return correct urls', function () {
assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels', 'hello@dsprenkels.com', true), 'http://localhost:3000/user/Daan%20Sprenkels/avatar.svg')
assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels', 'hello@dsprenkels.com', false), 'http://localhost:3000/user/Daan%20Sprenkels/avatar.svg')
})
it('should return correct urls for names with spaces', function () {
assert.strictEqual(avatars.generateAvatarURL('Daan Sprenkels'), 'http://localhost:3000/user/Daan%20Sprenkels/avatar.svg')
})
}) })

View File

@ -0,0 +1,69 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { removeModuleFromRequireCache, makeMockSocket } = require('./utils')
describe('cleanDanglingUser', function () {
let clock
beforeEach(() => {
clock = sinon.useFakeTimers()
mock('../../lib/processQueue', require('../testDoubles/ProcessQueueFake'))
mock('../../lib/logger', {
error: () => {},
info: () => {}
})
mock('../../lib/history', {})
mock('../../lib/models', {
Revision: {
saveAllNotesRevision: () => {
}
}
})
mock('../../lib/config', {
debug: true
})
mock('../../lib/realtimeUpdateDirtyNoteJob', require('../testDoubles/realtimeJobStub'))
mock('../../lib/realtimeSaveRevisionJob', require('../testDoubles/realtimeJobStub'))
})
afterEach(() => {
clock.restore()
removeModuleFromRequireCache('../../lib/realtime')
mock.stopAll()
sinon.restore()
})
it('should call queueForDisconnectSpy when user is dangling', (done) => {
const realtime = require('../../lib/realtime')
const queueForDisconnectSpy = sinon.spy(realtime, 'queueForDisconnect')
realtime.io = {
to: sinon.stub().callsFake(function () {
return {
emit: sinon.fake()
}
}),
sockets: {
connected: {}
}
}
let user1Socket = makeMockSocket()
let user2Socket = makeMockSocket()
user1Socket.rooms.push('room1')
realtime.io.sockets.connected[user1Socket.id] = user1Socket
realtime.io.sockets.connected[user2Socket.id] = user2Socket
realtime.users[user1Socket.id] = user1Socket
realtime.users[user2Socket.id] = user2Socket
clock.tick(60000)
clock.restore()
setTimeout(() => {
assert(queueForDisconnectSpy.called)
done()
}, 50)
})
})

View File

@ -0,0 +1,193 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { createFakeLogger } = require('../testDoubles/loggerFake')
const { removeLibModuleCache, makeMockSocket } = require('./utils')
const realtimeJobStub = require('../testDoubles/realtimeJobStub')
describe('realtime#connection', function () {
describe('connection', function () {
let realtime
let modelStub
beforeEach(() => {
removeLibModuleCache()
modelStub = {
Note: {
findOne: sinon.stub()
},
User: {},
Author: {}
}
mock('../../lib/logger', createFakeLogger())
mock('../../lib/history', {})
mock('../../lib/models', modelStub)
mock('../../lib/config', {})
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
mock('../../lib/ot', require('../testDoubles/otFake'))
realtime = require('../../lib/realtime')
})
afterEach(() => {
mock.stopAll()
sinon.restore()
})
describe('fail', function () {
it('should fast return when server not start', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = true
const spy = sinon.spy(realtime, 'parseNoteIdFromSocketAsync')
realtime.connection(mockSocket)
assert(!spy.called)
})
it('should failed when parse noteId occur error', (done) => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
/* eslint-disable-next-line */
throw 'error'
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
setTimeout(() => {
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [500, 'error', mockSocket])
done()
}, 50)
})
it('should failed when noteId not exists', (done) => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return null
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
setTimeout(() => {
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [404, 'note id not found', mockSocket])
done()
}, 50)
})
})
it('should success connect', function (done) {
const mockSocket = makeMockSocket()
const noteId = 'note123'
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return noteId
})
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
realtime.connection(mockSocket)
setTimeout(() => {
assert.ok(parseNoteIdFromSocketSpy.calledOnce)
assert(updateUserDataStub.calledOnce)
done()
}, 50)
})
describe('flow', function () {
it('should establish connection', function (done) {
const noteId = 'note123'
const mockSocket = makeMockSocket(null, {
noteId: noteId
})
mockSocket.request.user.logged_in = true
mockSocket.request.user.id = 'user1'
mockSocket.noteId = noteId
realtime.maintenance = false
sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return noteId
})
const updateHistoryStub = sinon.stub(realtime, 'updateHistory')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
const emitRefreshStub = sinon.stub(realtime, 'emitRefresh')
const failConnectionSpy = sinon.spy(realtime, 'failConnection')
let note = {
id: noteId,
authors: [
{
userId: 'user1',
color: 'red',
user: {
id: 'user1',
name: 'Alice'
}
},
{
userId: 'user2',
color: 'blue',
user: {
id: 'user2',
name: 'Bob'
}
}
]
}
modelStub.Note.findOne.returns(Promise.resolve(note))
modelStub.User.getProfile = sinon.stub().callsFake((user) => {
return user
})
sinon.stub(realtime, 'checkViewPermission').returns(true)
realtime.connection(mockSocket)
setTimeout(() => {
assert(modelStub.Note.findOne.calledOnce)
assert.deepStrictEqual(modelStub.Note.findOne.lastCall.args[0].include, [
{
model: modelStub.User,
as: 'owner'
}, {
model: modelStub.User,
as: 'lastchangeuser'
}, {
model: modelStub.Author,
as: 'authors',
include: [{
model: modelStub.User,
as: 'user'
}]
}
])
assert(modelStub.Note.findOne.lastCall.args[0].where.id === noteId)
assert(updateHistoryStub.calledOnce)
assert(emitOnlineUsersStub.calledOnce)
assert(emitRefreshStub.calledOnce)
assert(failConnectionSpy.callCount === 0)
assert(realtime.getNotePool()[noteId].id === noteId)
assert(realtime.getNotePool()[noteId].socks.length === 1)
assert.deepStrictEqual(realtime.getNotePool()[noteId].authors, {
user1: {
userid: 'user1', color: 'red', photo: undefined, name: 'Alice'
},
user2: {
userid: 'user2', color: 'blue', photo: undefined, name: 'Bob'
}
})
assert(Object.keys(realtime.getNotePool()[noteId].users).length === 1)
done()
}, 50)
})
})
})
})

Some files were not shown because too many files have changed in this diff Show More