mirror of
https://github.com/seejohnrun/haste-server
synced 2025-08-22 07:13:11 -07:00
hastepad 0.5
This commit is contained in:
parent
5d2965ffc5
commit
4618680737
14 changed files with 422 additions and 166 deletions
54
Dockerfile
54
Dockerfile
|
@ -3,56 +3,24 @@ FROM node:14.8.0-stretch
|
||||||
RUN mkdir -p /usr/src/app && \
|
RUN mkdir -p /usr/src/app && \
|
||||||
chown node:node /usr/src/app
|
chown node:node /usr/src/app
|
||||||
|
|
||||||
USER node:node
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY --chown=node:node . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm install && \
|
RUN mv config.json base.config.json && touch config.json && chown node:node config.json ./static/application.min.js && mkdir data && chown node:node data
|
||||||
npm install redis@0.8.1 && \
|
|
||||||
npm install pg@4.1.1 && \
|
|
||||||
npm install memcached@2.2.2 && \
|
|
||||||
npm install aws-sdk@2.738.0 && \
|
|
||||||
npm install rethinkdbdash@2.3.31
|
|
||||||
|
|
||||||
ENV STORAGE_TYPE=memcached \
|
RUN npm install
|
||||||
STORAGE_HOST=127.0.0.1 \
|
# && \
|
||||||
STORAGE_PORT=11211\
|
# npm install redis@0.8.1 && \
|
||||||
STORAGE_EXPIRE_SECONDS=2592000\
|
# npm install pg@4.1.1 && \
|
||||||
STORAGE_DB=2 \
|
# npm install memcached@2.2.2 && \
|
||||||
STORAGE_AWS_BUCKET= \
|
# npm install aws-sdk@2.738.0 && \
|
||||||
STORAGE_AWS_REGION= \
|
# npm install rethinkdbdash@2.3.31
|
||||||
STORAGE_USENAMER= \
|
|
||||||
STORAGE_PASSWORD= \
|
|
||||||
STORAGE_FILEPATH=
|
|
||||||
|
|
||||||
ENV LOGGING_LEVEL=verbose \
|
|
||||||
LOGGING_TYPE=Console \
|
|
||||||
LOGGING_COLORIZE=true
|
|
||||||
|
|
||||||
ENV HOST=0.0.0.0\
|
ENV HOST=0.0.0.0\
|
||||||
PORT=7777\
|
PORT=7777
|
||||||
KEY_LENGTH=10\
|
|
||||||
MAX_LENGTH=400000\
|
|
||||||
STATIC_MAX_AGE=86400\
|
|
||||||
RECOMPRESS_STATIC_ASSETS=true
|
|
||||||
|
|
||||||
ENV KEYGENERATOR_TYPE=phonetic \
|
USER node:node
|
||||||
KEYGENERATOR_KEYSPACE=
|
|
||||||
|
|
||||||
ENV RATELIMITS_NORMAL_TOTAL_REQUESTS=500\
|
|
||||||
RATELIMITS_NORMAL_EVERY_MILLISECONDS=60000 \
|
|
||||||
RATELIMITS_WHITELIST_TOTAL_REQUESTS= \
|
|
||||||
RATELIMITS_WHITELIST_EVERY_MILLISECONDS= \
|
|
||||||
# comma separated list for the whitelisted \
|
|
||||||
RATELIMITS_WHITELIST=example1.whitelist,example2.whitelist \
|
|
||||||
\
|
|
||||||
RATELIMITS_BLACKLIST_TOTAL_REQUESTS= \
|
|
||||||
RATELIMITS_BLACKLIST_EVERY_MILLISECONDS= \
|
|
||||||
# comma separated list for the blacklisted \
|
|
||||||
RATELIMITS_BLACKLIST=example1.blacklist,example2.blacklist
|
|
||||||
ENV DOCUMENTS=about=./about.md
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
STOPSIGNAL SIGINT
|
STOPSIGNAL SIGINT
|
||||||
|
|
30
README.md
30
README.md
|
@ -1,3 +1,13 @@
|
||||||
|
# TestNow
|
||||||
|
|
||||||
|
Test it in seconds :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -d -p 7777:7777 hastepad:0.5
|
||||||
|
```
|
||||||
|
Open your browser and type url : http://127.0.0.1:7777/
|
||||||
|
|
||||||
|
|
||||||
# Haste
|
# Haste
|
||||||
|
|
||||||
Haste is an open-source pastebin software written in node.js, which is easily
|
Haste is an open-source pastebin software written in node.js, which is easily
|
||||||
|
@ -20,6 +30,26 @@ to do things like:
|
||||||
which will output a URL to share containing the contents of `cat something`'s
|
which will output a URL to share containing the contents of `cat something`'s
|
||||||
STDOUT. Check the README there for more details and usages.
|
STDOUT. Check the README there for more details and usages.
|
||||||
|
|
||||||
|
# Customized version (HastePad)
|
||||||
|
|
||||||
|
This is not the original version, you'll find the original version at : https://github.com/seejohnrun/haste-server
|
||||||
|
|
||||||
|
This version is from here : https://github.com/mtudury/haste-server
|
||||||
|
|
||||||
|
This version is customized in order to add/change some features :
|
||||||
|
|
||||||
|
- Live saving : Do not loose your work when closing your browser
|
||||||
|
- List documents
|
||||||
|
- Delete document
|
||||||
|
|
||||||
|
It currently only works with storage type : file.
|
||||||
|
|
||||||
|
It main usage would be like a notepad online (mono user)
|
||||||
|
|
||||||
|
It does not have exactly the same purpose of original haste (no edit/no delete needed nor wanted in original version) so choose accordingly to your needs
|
||||||
|
|
||||||
|
If you need security, i would recommend : add a reverse-proxy in front (Caddy/Nginx), HTTPS and authentication using reverse proxy features, only expose the reverse proxy.
|
||||||
|
|
||||||
## Tested Browsers
|
## Tested Browsers
|
||||||
|
|
||||||
* Firefox 8
|
* Firefox 8
|
||||||
|
|
14
about.md
14
about.md
|
@ -6,6 +6,20 @@ use pastebins.
|
||||||
|
|
||||||
Haste is the prettiest, easiest to use pastebin ever made.
|
Haste is the prettiest, easiest to use pastebin ever made.
|
||||||
|
|
||||||
|
## Customized version (HastePad)
|
||||||
|
|
||||||
|
This is not the original version, you'll find the original version at : https://github.com/seejohnrun/haste-server
|
||||||
|
|
||||||
|
This version is from here : https://github.com/mtudury/haste-server
|
||||||
|
|
||||||
|
This version is customized in order to add/change some features :
|
||||||
|
|
||||||
|
- Live saving : Do not loose your work when closing your browser
|
||||||
|
- List document
|
||||||
|
- Delete document
|
||||||
|
|
||||||
|
It main usage would be like a notepad online (mono user)
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
Type what you want me to see, click "Save", and then copy the URL. Send that
|
Type what you want me to see, click "Save", and then copy the URL. Send that
|
||||||
|
|
|
@ -33,11 +33,13 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"storage": {
|
"storage": {
|
||||||
"type": "file"
|
"type": "file",
|
||||||
|
"allowList": true,
|
||||||
|
"allowDelete": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"documents": {
|
"documents": {
|
||||||
"about": "./about.md"
|
"about.md": "./about.md"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,19 +1,20 @@
|
||||||
version: '3.0'
|
version: '3.6'
|
||||||
services:
|
services:
|
||||||
haste-server:
|
haste-server:
|
||||||
build: .
|
build: .
|
||||||
networks:
|
image: hastepad:0.5
|
||||||
- db-network
|
|
||||||
environment:
|
|
||||||
- STORAGE_TYPE=memcached
|
|
||||||
- STORAGE_HOST=memcached
|
|
||||||
- STORAGE_PORT=11211
|
|
||||||
ports:
|
ports:
|
||||||
- 7777:7777
|
- 7777:7777
|
||||||
memcached:
|
volumes:
|
||||||
image: memcached:latest
|
- type: tmpfs
|
||||||
networks:
|
target: /usr/src/app/data
|
||||||
- db-network
|
tmpfs:
|
||||||
|
size: 128M
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "1M"
|
||||||
|
max-file: "10"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
db-network:
|
db-network:
|
||||||
|
|
|
@ -29,80 +29,58 @@ const {
|
||||||
RATE_LIMITS_BLACKLIST_EVERY_MILLISECONDS,
|
RATE_LIMITS_BLACKLIST_EVERY_MILLISECONDS,
|
||||||
RATE_LIMITS_BLACKLIST,
|
RATE_LIMITS_BLACKLIST,
|
||||||
DOCUMENTS,
|
DOCUMENTS,
|
||||||
|
STORAGE_ALLOWLIST,
|
||||||
|
STORAGE_ALLOWDELETE
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
const config = {
|
const config = require("./base.config.json");
|
||||||
host: HOST,
|
|
||||||
port: PORT,
|
|
||||||
|
|
||||||
keyLength: KEY_LENGTH,
|
if (HOST) config.host = HOST;
|
||||||
|
if (PORT) config.port = PORT;
|
||||||
|
|
||||||
maxLength: MAX_LENGTH,
|
if (KEY_LENGTH) config.keyLength = KEY_LENGTH;
|
||||||
|
|
||||||
staticMaxAge: STATIC_MAX_AGE,
|
if (MAX_LENGTH) config.maxLength = MAX_LENGTH;
|
||||||
|
|
||||||
recompressStaticAssets: RECOMPRESS_STATIC_ASSETS,
|
if (PORT) config.staticMaxAge = STATIC_MAX_AGE;
|
||||||
|
|
||||||
logging: [
|
if (PORT) config.ecompressStaticAssets = RECOMPRESS_STATIC_ASSETS;
|
||||||
{
|
|
||||||
level: LOGGING_LEVEL,
|
|
||||||
type: LOGGING_TYPE,
|
|
||||||
colorize: LOGGING_COLORIZE,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
keyGenerator: {
|
if (LOGGING_LEVEL) config.logging[0].level = LOGGING_LEVEL;
|
||||||
type: KEYGENERATOR_TYPE,
|
if (LOGGING_TYPE) config.logging[0].type = LOGGING_TYPE;
|
||||||
keyspace: KEY_GENERATOR_KEYSPACE,
|
if (LOGGING_COLORIZE) config.logging[0].colorize = LOGGING_COLORIZE;
|
||||||
},
|
|
||||||
|
|
||||||
rateLimits: {
|
if (KEYGENERATOR_TYPE) config.keyGenerator.type = KEYGENERATOR_TYPE;
|
||||||
whitelist: RATE_LIMITS_WHITELIST ? RATE_LIMITS_WHITELIST.split(",") : [],
|
if (KEY_GENERATOR_KEYSPACE) config.keyGenerator.keyspace = KEY_GENERATOR_KEYSPACE;
|
||||||
blacklist: RATE_LIMITS_BLACKLIST ? RATE_LIMITS_BLACKLIST.split(",") : [],
|
|
||||||
categories: {
|
|
||||||
normal: {
|
|
||||||
totalRequests: RATE_LIMITS_NORMAL_TOTAL_REQUESTS,
|
|
||||||
every: RATE_LIMITS_NORMAL_EVERY_MILLISECONDS,
|
|
||||||
},
|
|
||||||
whitelist:
|
|
||||||
RATE_LIMITS_WHITELIST_EVERY_MILLISECONDS ||
|
|
||||||
RATE_LIMITS_WHITELIST_TOTAL_REQUESTS
|
|
||||||
? {
|
|
||||||
totalRequests: RATE_LIMITS_WHITELIST_TOTAL_REQUESTS,
|
|
||||||
every: RATE_LIMITS_WHITELIST_EVERY_MILLISECONDS,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
blacklist:
|
|
||||||
RATE_LIMITS_BLACKLIST_EVERY_MILLISECONDS ||
|
|
||||||
RATE_LIMITS_BLACKLIST_TOTAL_REQUESTS
|
|
||||||
? {
|
|
||||||
totalRequests: RATE_LIMITS_WHITELIST_TOTAL_REQUESTS,
|
|
||||||
every: RATE_LIMITS_BLACKLIST_EVERY_MILLISECONDS,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
storage: {
|
if (RATE_LIMITS_WHITELIST) config.rateLimits.whitelist = RATE_LIMITS_WHITELIST.split(",");
|
||||||
type: STORAGE_TYPE,
|
if (RATE_LIMITS_BLACKLIST) config.rateLimits.blacklist = RATE_LIMITS_BLACKLIST.split(",");
|
||||||
host: STORAGE_HOST,
|
|
||||||
port: STORAGE_PORT,
|
|
||||||
expire: STORAGE_EXPIRE_SECONDS,
|
|
||||||
bucket: STORAGE_AWS_BUCKET,
|
|
||||||
region: STORAGE_AWS_REGION,
|
|
||||||
connectionUrl: `postgres://${STORAGE_USERNAME}:${STORAGE_PASSWORD}@${STORAGE_HOST}:${STORAGE_PORT}/${STORAGE_DB}`,
|
|
||||||
db: STORAGE_DB,
|
|
||||||
user: STORAGE_USERNAME,
|
|
||||||
password: STORAGE_PASSWORD,
|
|
||||||
path: STORAGE_FILEPATH,
|
|
||||||
},
|
|
||||||
|
|
||||||
documents: DOCUMENTS
|
if (RATE_LIMITS_NORMAL_TOTAL_REQUESTS) config.rateLimits.categories.normal.totalRequests = RATE_LIMITS_NORMAL_TOTAL_REQUESTS;
|
||||||
? DOCUMENTS.split(",").reduce((acc, item) => {
|
if (RATE_LIMITS_NORMAL_EVERY_MILLISECONDS) config.rateLimits.categories.normal.every = RATE_LIMITS_NORMAL_EVERY_MILLISECONDS;
|
||||||
const keyAndValueArray = item.replace(/\s/g, "").split("=");
|
|
||||||
return { ...acc, [keyAndValueArray[0]]: keyAndValueArray[1] };
|
if (RATE_LIMITS_WHITELIST_EVERY_MILLISECONDS || RATE_LIMITS_WHITELIST_TOTAL_REQUESTS) config.rateLimits.categories.whitelist = { totalRequests: RATE_LIMITS_WHITELIST_TOTAL_REQUESTS, every: RATE_LIMITS_WHITELIST_EVERY_MILLISECONDS };
|
||||||
}, {})
|
if (RATE_LIMITS_BLACKLIST_EVERY_MILLISECONDS || RATE_LIMITS_BLACKLIST_TOTAL_REQUESTS) config.rateLimits.categories.blacklist = { totalRequests: RATE_LIMITS_BLACKLIST_TOTAL_REQUESTS, every: RATE_LIMITS_BLACKLIST_EVERY_MILLISECONDS };
|
||||||
: null,
|
|
||||||
};
|
if (STORAGE_TYPE) config.storage.type = STORAGE_TYPE;
|
||||||
|
if (STORAGE_HOST) config.storage.host = STORAGE_HOST;
|
||||||
|
if (STORAGE_PORT) config.storage.port = STORAGE_PORT;
|
||||||
|
if (STORAGE_EXPIRE_SECONDS) config.storage.expire = STORAGE_EXPIRE_SECONDS;
|
||||||
|
if (STORAGE_AWS_BUCKET) config.storage.bucket = STORAGE_AWS_BUCKET;
|
||||||
|
if (STORAGE_AWS_REGION) config.storage.region = STORAGE_AWS_REGION;
|
||||||
|
if (STORAGE_DB) config.storage.connectionUrl = `postgres://${STORAGE_USERNAME}:${STORAGE_PASSWORD}@${STORAGE_HOST}:${STORAGE_PORT}/${STORAGE_DB}`;
|
||||||
|
if (STORAGE_DB) config.storage.db = STORAGE_DB;
|
||||||
|
if (STORAGE_USERNAME) config.storage.user = STORAGE_USERNAME;
|
||||||
|
if (STORAGE_PASSWORD) config.storage.password = STORAGE_PASSWORD;
|
||||||
|
if (STORAGE_FILEPATH) config.storage.path = STORAGE_FILEPATH;
|
||||||
|
if (STORAGE_ALLOWLIST) config.storage.allowList = STORAGE_ALLOWLIST == "true";
|
||||||
|
if (STORAGE_ALLOWDELETE) config.storage.allowDelete = STORAGE_ALLOWDELETE == "true";
|
||||||
|
|
||||||
|
if (DOCUMENTS) {
|
||||||
|
config.documents = DOCUMENTS.split(",").reduce((acc, item) => {
|
||||||
|
const keyAndValueArray = item.replace(/\s/g, "").split("=");
|
||||||
|
return { ...acc, [keyAndValueArray[0]]: keyAndValueArray[1] };
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(config));
|
console.log(JSON.stringify(config));
|
||||||
|
|
|
@ -4,6 +4,6 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
node ./docker-entrypoint.js > ./config.js
|
node ./docker-entrypoint.js > ./config.json
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
|
@ -3,6 +3,8 @@ var Busboy = require('busboy');
|
||||||
|
|
||||||
// For handling serving stored documents
|
// For handling serving stored documents
|
||||||
|
|
||||||
|
const checkkeyregex = /^[a-zA-Z0-9-_.]+$/;
|
||||||
|
|
||||||
var DocumentHandler = function(options) {
|
var DocumentHandler = function(options) {
|
||||||
if (!options) {
|
if (!options) {
|
||||||
options = {};
|
options = {};
|
||||||
|
@ -17,9 +19,19 @@ DocumentHandler.defaultKeyLength = 10;
|
||||||
|
|
||||||
// Handle retrieving a document
|
// Handle retrieving a document
|
||||||
DocumentHandler.prototype.handleGet = function(request, response, config) {
|
DocumentHandler.prototype.handleGet = function(request, response, config) {
|
||||||
const key = request.params.id.split('.')[0];
|
const key = request.params.id;
|
||||||
const skipExpire = !!config.documents[key];
|
const skipExpire = !!config.documents[key];
|
||||||
|
|
||||||
|
if ((key)&&(!checkkeyregex.test(key))) {
|
||||||
|
winston.warn('invalid key', { key: key });
|
||||||
|
response.writeHead(400, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ message: 'Invalid key', key: key })
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.get(key, function(ret) {
|
this.store.get(key, function(ret) {
|
||||||
if (ret) {
|
if (ret) {
|
||||||
winston.verbose('retrieved document', { key: key });
|
winston.verbose('retrieved document', { key: key });
|
||||||
|
@ -44,9 +56,19 @@ DocumentHandler.prototype.handleGet = function(request, response, config) {
|
||||||
|
|
||||||
// Handle retrieving the raw version of a document
|
// Handle retrieving the raw version of a document
|
||||||
DocumentHandler.prototype.handleRawGet = function(request, response, config) {
|
DocumentHandler.prototype.handleRawGet = function(request, response, config) {
|
||||||
const key = request.params.id.split('.')[0];
|
const key = request.params.id;
|
||||||
const skipExpire = !!config.documents[key];
|
const skipExpire = !!config.documents[key];
|
||||||
|
|
||||||
|
if ((key)&&(!checkkeyregex.test(key))) {
|
||||||
|
winston.warn('invalid key', { key: key });
|
||||||
|
response.writeHead(400, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ message: 'Invalid key', key: key })
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.get(key, function(ret) {
|
this.store.get(key, function(ret) {
|
||||||
if (ret) {
|
if (ret) {
|
||||||
winston.verbose('retrieved raw document', { key: key });
|
winston.verbose('retrieved raw document', { key: key });
|
||||||
|
@ -75,6 +97,17 @@ DocumentHandler.prototype.handlePost = function (request, response) {
|
||||||
var buffer = '';
|
var buffer = '';
|
||||||
var cancelled = false;
|
var cancelled = false;
|
||||||
|
|
||||||
|
const key = request.params.id ? request.params.id : null;
|
||||||
|
if ((key)&&(!checkkeyregex.test(key))) {
|
||||||
|
winston.warn('invalid key', { key: key });
|
||||||
|
response.writeHead(400, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ message: 'Invalid key', key: key })
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// What to do when done
|
// What to do when done
|
||||||
var onSuccess = function () {
|
var onSuccess = function () {
|
||||||
// Check length
|
// Check length
|
||||||
|
@ -87,9 +120,9 @@ DocumentHandler.prototype.handlePost = function (request, response) {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// And then save if we should
|
|
||||||
_this.chooseKey(function (key) {
|
const store = function (key) {
|
||||||
_this.store.set(key, buffer, function (res) {
|
_this.store.set(key, buffer, function (res) {
|
||||||
if (res) {
|
if (res) {
|
||||||
winston.verbose('added document', { key: key });
|
winston.verbose('added document', { key: key });
|
||||||
response.writeHead(200, { 'content-type': 'application/json' });
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
@ -101,7 +134,16 @@ DocumentHandler.prototype.handlePost = function (request, response) {
|
||||||
response.end(JSON.stringify({ message: 'Error adding document.' }));
|
response.end(JSON.stringify({ message: 'Error adding document.' }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// And then save if we should
|
||||||
|
if (!key) {
|
||||||
|
_this.chooseKey(function (key) {
|
||||||
|
store(key);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
store(key);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If we should, parse a form to grab the data
|
// If we should, parse a form to grab the data
|
||||||
|
@ -135,6 +177,69 @@ DocumentHandler.prototype.handlePost = function (request, response) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Handle deleting a document
|
||||||
|
DocumentHandler.prototype.handleDelete = function(request, response, config) {
|
||||||
|
const key = request.params.id;
|
||||||
|
const allowdelete = config.storage.allowDelete;
|
||||||
|
|
||||||
|
if ((key)&&(!checkkeyregex.test(key))) {
|
||||||
|
winston.warn('invalid key', { key: key });
|
||||||
|
response.writeHead(400, { 'content-type': 'application/json' });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ message: 'Invalid key', key: key })
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.store.delete||!allowdelete) {
|
||||||
|
winston.warn('document provider does not support delete', { key: key });
|
||||||
|
response.writeHead(405, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'Delete not supported.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.delete(key, function(ret) {
|
||||||
|
if (ret) {
|
||||||
|
winston.verbose('deleted document', { key: key });
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'Document deleted', key: key }));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
winston.warn('raw document not found', { key: key });
|
||||||
|
response.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'Document not found.' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle listing documents
|
||||||
|
DocumentHandler.prototype.handleList = function(request, response, config) {
|
||||||
|
const allowlist = config.storage.allowList;
|
||||||
|
|
||||||
|
if (!this.store.list||!allowlist) {
|
||||||
|
winston.warn('document provider does not support list');
|
||||||
|
response.writeHead(405, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ message: 'Delete not supported.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.list(function(ret) {
|
||||||
|
winston.verbose('list documents');
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(ret));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle listing documents
|
||||||
|
DocumentHandler.prototype.handleGetKey = function(request, response, config) {
|
||||||
|
this.chooseKey(function(ret) {
|
||||||
|
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||||
|
response.end(ret);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Keep choosing keys until one isn't taken
|
// Keep choosing keys until one isn't taken
|
||||||
DocumentHandler.prototype.chooseKey = function(callback) {
|
DocumentHandler.prototype.chooseKey = function(callback) {
|
||||||
var key = this.acceptableKey();
|
var key = this.acceptableKey();
|
||||||
|
|
|
@ -25,7 +25,7 @@ FileDocumentStore.prototype.set = function(key, data, callback, skipExpire) {
|
||||||
try {
|
try {
|
||||||
var _this = this;
|
var _this = this;
|
||||||
fs.mkdir(this.basePath, '700', function() {
|
fs.mkdir(this.basePath, '700', function() {
|
||||||
var fn = _this.basePath + '/' + FileDocumentStore.md5(key);
|
var fn = _this.basePath + '/' + key;
|
||||||
fs.writeFile(fn, data, 'utf8', function(err) {
|
fs.writeFile(fn, data, 'utf8', function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
callback(false);
|
callback(false);
|
||||||
|
@ -46,7 +46,7 @@ FileDocumentStore.prototype.set = function(key, data, callback, skipExpire) {
|
||||||
// Get data from a file from key
|
// Get data from a file from key
|
||||||
FileDocumentStore.prototype.get = function(key, callback, skipExpire) {
|
FileDocumentStore.prototype.get = function(key, callback, skipExpire) {
|
||||||
var _this = this;
|
var _this = this;
|
||||||
var fn = this.basePath + '/' + FileDocumentStore.md5(key);
|
var fn = this.basePath + '/' + key;
|
||||||
fs.readFile(fn, 'utf8', function(err, data) {
|
fs.readFile(fn, 'utf8', function(err, data) {
|
||||||
if (err) {
|
if (err) {
|
||||||
callback(false);
|
callback(false);
|
||||||
|
@ -60,4 +60,32 @@ FileDocumentStore.prototype.get = function(key, callback, skipExpire) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// delete a file from key
|
||||||
|
FileDocumentStore.prototype.delete = function(key, callback) {
|
||||||
|
var _this = this;
|
||||||
|
var fn = this.basePath + '/' + key;
|
||||||
|
fs.unlink(fn, function(err) {
|
||||||
|
if (err) {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
callback(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// list files
|
||||||
|
FileDocumentStore.prototype.list = function(callback) {
|
||||||
|
var _this = this;
|
||||||
|
var fn = this.basePath + '/';
|
||||||
|
fs.readdir(fn, function(err, listfiles) {
|
||||||
|
if (err) {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
callback(listfiles);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = FileDocumentStore;
|
module.exports = FileDocumentStore;
|
||||||
|
|
11
package.json
11
package.json
|
@ -1,18 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "haste",
|
"name": "hastepad",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Private Pastebin Server",
|
"description": "Private OnlineNotepad Server",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"paste",
|
"Notepad"
|
||||||
"pastebin"
|
|
||||||
],
|
],
|
||||||
"author": {
|
"author": {
|
||||||
"name": "John Crepezzi",
|
"name": "John Crepezzi",
|
||||||
"email": "john.crepezzi@gmail.com",
|
"email": "john.crepezzi@gmail.com",
|
||||||
"url": "http://seejohncode.com/"
|
"url": "http://seejohncode.com/"
|
||||||
},
|
},
|
||||||
"main": "haste",
|
"main": "hastepad",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"busboy": "0.2.4",
|
"busboy": "0.2.4",
|
||||||
"connect": "^3.7.0",
|
"connect": "^3.7.0",
|
||||||
|
|
23
server.js
23
server.js
|
@ -11,7 +11,7 @@ var connect_rate_limit = require('connect-ratelimit');
|
||||||
var DocumentHandler = require('./lib/document_handler');
|
var DocumentHandler = require('./lib/document_handler');
|
||||||
|
|
||||||
// Load the configuration and set some defaults
|
// Load the configuration and set some defaults
|
||||||
const configPath = process.argv.length <= 2 ? 'config.js' : process.argv[2];
|
const configPath = process.argv.length <= 2 ? 'config.json' : process.argv[2];
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
config.port = process.env.PORT || config.port || 7777;
|
config.port = process.env.PORT || config.port || 7777;
|
||||||
config.host = process.env.HOST || config.host || 'localhost';
|
config.host = process.env.HOST || config.host || 'localhost';
|
||||||
|
@ -125,14 +125,35 @@ app.use(route(function(router) {
|
||||||
return documentHandler.handlePost(request, response);
|
return documentHandler.handlePost(request, response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// update documents
|
||||||
|
|
||||||
|
router.post('/documents/:id', function(request, response) {
|
||||||
|
return documentHandler.handlePost(request, response);
|
||||||
|
});
|
||||||
|
|
||||||
// get documents
|
// get documents
|
||||||
router.get('/documents/:id', function(request, response) {
|
router.get('/documents/:id', function(request, response) {
|
||||||
return documentHandler.handleGet(request, response, config);
|
return documentHandler.handleGet(request, response, config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// get documents
|
||||||
|
router.delete('/documents/:id', function(request, response) {
|
||||||
|
return documentHandler.handleDelete(request, response, config);
|
||||||
|
});
|
||||||
|
|
||||||
router.head('/documents/:id', function(request, response) {
|
router.head('/documents/:id', function(request, response) {
|
||||||
return documentHandler.handleGet(request, response, config);
|
return documentHandler.handleGet(request, response, config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// list documents
|
||||||
|
router.get('/documents', function(request, response) {
|
||||||
|
return documentHandler.handleList(request, response, config);
|
||||||
|
});
|
||||||
|
|
||||||
|
// get key
|
||||||
|
router.get('/key', function(request, response) {
|
||||||
|
return documentHandler.handleGetKey(request, response, config);
|
||||||
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Otherwise, try to match static files
|
// Otherwise, try to match static files
|
||||||
|
|
|
@ -54,19 +54,18 @@ haste_document.prototype.load = function(key, callback, lang) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save this document to the server and lock it here
|
// Save this document to the server and lock it here
|
||||||
haste_document.prototype.save = function(data, callback) {
|
haste_document.prototype.save = function(key, data, callback) {
|
||||||
if (this.locked) {
|
if (this.locked) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.data = data;
|
this.data = data;
|
||||||
var _this = this;
|
var _this = this;
|
||||||
$.ajax('/documents', {
|
$.ajax('/documents/' + key, {
|
||||||
type: 'post',
|
type: 'post',
|
||||||
data: data,
|
data: data,
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
contentType: 'text/plain; charset=utf-8',
|
contentType: 'text/plain; charset=utf-8',
|
||||||
success: function(res) {
|
success: function(res) {
|
||||||
_this.locked = true;
|
|
||||||
_this.key = res.key;
|
_this.key = res.key;
|
||||||
var high = hljs.highlightAuto(data);
|
var high = hljs.highlightAuto(data);
|
||||||
callback(null, {
|
callback(null, {
|
||||||
|
@ -87,6 +86,24 @@ haste_document.prototype.save = function(data, callback) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// get a valid key from server
|
||||||
|
haste_document.prototype.getkey = function(callback) {
|
||||||
|
$.ajax('/key/', {
|
||||||
|
type: 'get',
|
||||||
|
success: function(res) {
|
||||||
|
callback(null, res);
|
||||||
|
},
|
||||||
|
error: function(res) {
|
||||||
|
try {
|
||||||
|
callback($.parseJSON(res.responseText));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
callback({message: 'Something went wrong!'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
///// represents the paste application
|
///// represents the paste application
|
||||||
|
|
||||||
var haste = function(appName, options) {
|
var haste = function(appName, options) {
|
||||||
|
@ -129,6 +146,11 @@ haste.prototype.fullKey = function() {
|
||||||
this.configureKey(['new', 'duplicate', 'twitter', 'raw']);
|
this.configureKey(['new', 'duplicate', 'twitter', 'raw']);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show all the keys
|
||||||
|
haste.prototype.allKey = function() {
|
||||||
|
this.configureKey(['new', 'save', 'duplicate', 'twitter', 'raw']);
|
||||||
|
};
|
||||||
|
|
||||||
// Set the key up for certain things to be enabled
|
// Set the key up for certain things to be enabled
|
||||||
haste.prototype.configureKey = function(enable) {
|
haste.prototype.configureKey = function(enable) {
|
||||||
var $this, i = 0;
|
var $this, i = 0;
|
||||||
|
@ -144,20 +166,60 @@ haste.prototype.configureKey = function(enable) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
haste.prototype.getCurrentKey = function () {
|
||||||
|
var key = window.location.pathname;
|
||||||
|
if (key == "/")
|
||||||
|
key = null;
|
||||||
|
else
|
||||||
|
key = key.substr(1);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get this document from the server and lock it here
|
||||||
|
haste.prototype.getList = function(callback) {
|
||||||
|
var _this = this;
|
||||||
|
$.ajax('/documents/', {
|
||||||
|
type: 'get',
|
||||||
|
dataType: 'json',
|
||||||
|
success: function(res) {
|
||||||
|
callback(res);
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Remove the current document (if there is one)
|
// Remove the current document (if there is one)
|
||||||
// and set up for a new one
|
// and set up for a new one
|
||||||
haste.prototype.newDocument = function(hideHistory) {
|
haste.prototype.newDocument = function(forcenewkey, callback) {
|
||||||
this.$box.hide();
|
var _this = this;
|
||||||
this.doc = new haste_document();
|
|
||||||
if (!hideHistory) {
|
var key = this.getCurrentKey();
|
||||||
window.history.pushState(null, this.appName, '/');
|
var newdoc = function(key) {
|
||||||
|
window.history.pushState(null, _this.appName, '/'+key);
|
||||||
|
_this.$box.hide();
|
||||||
|
_this.doc = new haste_document();
|
||||||
|
_this.setTitle();
|
||||||
|
_this.lightKey();
|
||||||
|
_this.$textarea.val('').show('fast', function() {
|
||||||
|
this.focus();
|
||||||
|
});
|
||||||
|
_this.removeLineNumbers();
|
||||||
|
if (callback) {
|
||||||
|
callback(_this.doc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.setTitle();
|
if (key&&!forcenewkey) {
|
||||||
this.lightKey();
|
newdoc(key);
|
||||||
this.$textarea.val('').show('fast', function() {
|
} else {
|
||||||
this.focus();
|
haste_document.prototype.getkey(function (err, key) {
|
||||||
});
|
newdoc(key);
|
||||||
this.removeLineNumbers();
|
});
|
||||||
|
}
|
||||||
|
_this.updateList();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map of common extensions
|
// Map of common extensions
|
||||||
|
@ -210,7 +272,7 @@ haste.prototype.loadDocument = function(key) {
|
||||||
// Ask for what we want
|
// Ask for what we want
|
||||||
var _this = this;
|
var _this = this;
|
||||||
_this.doc = new haste_document();
|
_this.doc = new haste_document();
|
||||||
_this.doc.load(parts[0], function(ret) {
|
_this.doc.load(key, function(ret) {
|
||||||
if (ret) {
|
if (ret) {
|
||||||
_this.$code.html(ret.value);
|
_this.$code.html(ret.value);
|
||||||
_this.setTitle(ret.key);
|
_this.setTitle(ret.key);
|
||||||
|
@ -223,32 +285,31 @@ haste.prototype.loadDocument = function(key) {
|
||||||
_this.newDocument();
|
_this.newDocument();
|
||||||
}
|
}
|
||||||
}, this.lookupTypeByExtension(parts[1]));
|
}, this.lookupTypeByExtension(parts[1]));
|
||||||
|
this.updateList();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Duplicate the current document - only if locked
|
// Duplicate the current document - only if locked
|
||||||
haste.prototype.duplicateDocument = function() {
|
haste.prototype.duplicateDocument = function() {
|
||||||
if (this.doc.locked) {
|
var _this = this;
|
||||||
var currentData = this.doc.data;
|
if (_this.doc.locked) {
|
||||||
this.newDocument();
|
var currentData = _this.doc.data;
|
||||||
this.$textarea.val(currentData);
|
_this.newDocument(true, function () {
|
||||||
|
_this.$textarea.val(currentData);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lock the current document
|
// Lock the current document
|
||||||
haste.prototype.lockDocument = function() {
|
haste.prototype.lockDocument = function() {
|
||||||
var _this = this;
|
var _this = this;
|
||||||
this.doc.save(this.$textarea.val(), function(err, ret) {
|
this.doc.save(this.getCurrentKey(), this.$textarea.val(), function(err, ret) {
|
||||||
if (err) {
|
if (err) {
|
||||||
_this.showMessage(err.message, 'error');
|
_this.showMessage(err.message, 'error');
|
||||||
}
|
}
|
||||||
else if (ret) {
|
else if (ret) {
|
||||||
|
_this.doc.locked = true;
|
||||||
_this.$code.html(ret.value);
|
_this.$code.html(ret.value);
|
||||||
_this.setTitle(ret.key);
|
_this.setTitle(ret.key);
|
||||||
var file = '/' + ret.key;
|
|
||||||
if (ret.language) {
|
|
||||||
file += '.' + _this.lookupExtensionByType(ret.language);
|
|
||||||
}
|
|
||||||
window.history.pushState(null, _this.appName + '-' + ret.key, file);
|
|
||||||
_this.fullKey();
|
_this.fullKey();
|
||||||
_this.$textarea.val('').hide();
|
_this.$textarea.val('').hide();
|
||||||
_this.$box.show().focus();
|
_this.$box.show().focus();
|
||||||
|
@ -257,6 +318,16 @@ haste.prototype.lockDocument = function() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// UnLock the current document
|
||||||
|
haste.prototype.unlockDocument = function() {
|
||||||
|
var _this = this;
|
||||||
|
_this.$textarea.val(_this.$code.text()).show().focus();
|
||||||
|
_this.$box.hide();
|
||||||
|
_this.allKey();
|
||||||
|
_this.removeLineNumbers();
|
||||||
|
_this.doc.locked = false;
|
||||||
|
};
|
||||||
|
|
||||||
haste.prototype.configureButtons = function() {
|
haste.prototype.configureButtons = function() {
|
||||||
var _this = this;
|
var _this = this;
|
||||||
this.buttons = [
|
this.buttons = [
|
||||||
|
@ -281,7 +352,7 @@ haste.prototype.configureButtons = function() {
|
||||||
},
|
},
|
||||||
shortcutDescription: 'control + n',
|
shortcutDescription: 'control + n',
|
||||||
action: function() {
|
action: function() {
|
||||||
_this.newDocument(!_this.doc.key);
|
_this.newDocument(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -308,13 +379,14 @@ haste.prototype.configureButtons = function() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$where: $('#box2 .twitter'),
|
$where: $('#box2 .twitter'),
|
||||||
label: 'Twitter',
|
label: 'Edit',
|
||||||
shortcut: function(evt) {
|
shortcut: function(evt) {
|
||||||
return _this.options.twitter && _this.doc.locked && evt.shiftKey && evt.ctrlKey && evt.keyCode == 84;
|
return _this.options.twitter && _this.doc.locked && evt.shiftKey && evt.ctrlKey && evt.keyCode == 84;
|
||||||
},
|
},
|
||||||
shortcutDescription: 'control + shift + t',
|
shortcutDescription: 'control + shift + t',
|
||||||
action: function() {
|
action: function() {
|
||||||
window.open('https://twitter.com/share?url=' + encodeURI(window.location.href));
|
//window.open('https://twitter.com/share?url=' + encodeURI(window.location.href));
|
||||||
|
_this.unlockDocument();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@ -345,6 +417,19 @@ haste.prototype.configureButton = function(options) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
haste.prototype.updateList = function () {
|
||||||
|
var _this = this;
|
||||||
|
_this.getList(function (lst) {
|
||||||
|
if (lst) {
|
||||||
|
var lis = "";
|
||||||
|
lst.forEach(function (file) {
|
||||||
|
lis+= '<li><a href="'+file+'">'+file+'</a></li>';
|
||||||
|
});
|
||||||
|
$('#list').html(lis);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Configure keyboard shortcuts for the textarea
|
// Configure keyboard shortcuts for the textarea
|
||||||
haste.prototype.configureShortcuts = function() {
|
haste.prototype.configureShortcuts = function() {
|
||||||
var _this = this;
|
var _this = this;
|
||||||
|
@ -361,6 +446,27 @@ haste.prototype.configureShortcuts = function() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
haste.prototype.autosave = function() {
|
||||||
|
var _this = this;
|
||||||
|
_this.$textarea.on('keydown', function () {
|
||||||
|
_this.doc.changed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var cycle = function () {
|
||||||
|
if ((_this.doc)&&(!_this.doc.locked)&&(_this.doc.changed)) {
|
||||||
|
_this.doc.save(_this.getCurrentKey(), _this.$textarea.val(), function (err, data) {
|
||||||
|
if (err) {
|
||||||
|
_this.showMessage("Error "+err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_this.doc.changed = false;
|
||||||
|
}
|
||||||
|
window.setTimeout(cycle, 15000);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.setTimeout(cycle, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
///// Tab behavior in the textarea - 2 spaces per tab
|
///// Tab behavior in the textarea - 2 spaces per tab
|
||||||
$(function() {
|
$(function() {
|
||||||
|
|
||||||
|
|
2
static/application.min.js
vendored
2
static/application.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -32,6 +32,7 @@
|
||||||
// Construct app and load initial path
|
// Construct app and load initial path
|
||||||
$(function() {
|
$(function() {
|
||||||
app = new haste('hastebin', { twitter: true });
|
app = new haste('hastebin', { twitter: true });
|
||||||
|
app.autosave();
|
||||||
handlePop({ target: window });
|
handlePop({ target: window });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -51,12 +52,15 @@
|
||||||
<button class="new function button-picture">New</button>
|
<button class="new function button-picture">New</button>
|
||||||
<button class="duplicate function button-picture">Duplicate & Edit</button>
|
<button class="duplicate function button-picture">Duplicate & Edit</button>
|
||||||
<button class="raw function button-picture">Just Text</button>
|
<button class="raw function button-picture">Just Text</button>
|
||||||
<button class="twitter function button-picture">Twitter</button>
|
<button class="twitter function button-picture">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="box3" style="display:none;">
|
<div id="box3" style="display:none;">
|
||||||
<div class="label"></div>
|
<div class="label"></div>
|
||||||
<div class="shortcut"></div>
|
<div class="shortcut"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="box4">
|
||||||
|
<ul id="list"></ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="linenos"></div>
|
<div id="linenos"></div>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue