@@ -1,4 +1,5 @@ | |||
# ignore data dir, except .gitkeep | |||
data/* | |||
!data/.gitkeep | |||
data/pastes/* | |||
!data/pastes/.gitkeep | |||
*/node_modules | |||
@@ -1,5 +1,13 @@ | |||
# TODO | |||
- Create script to create authentication token and add to data/tokens.json | |||
- Create bash script to paste text or image | |||
# DONE | |||
- POST to / should add a new text paste | |||
- DELETE to /:paste should delete a paste | |||
- GET /:paste should display a raw version of the paste | |||
- Check metadata before displaying file | |||
- If text, display raw file | |||
- If image, display image | |||
- Require basic authentication for POST and DELETE | |||
- Allow pasting of images (jpg, png) |
@@ -0,0 +1,7 @@ | |||
{ | |||
"admin_token": "mvrQXMdPOlPNThlm6vyv1WcAuuDbApISM1IDVp2bQGpSu5SQUnwVLAFqVPyZGQa", | |||
"uri_base": "http://localhost:3000", | |||
"filename_length": 16, | |||
"path": "../data/pastes/", | |||
"max_age": 0 | |||
} |
@@ -0,0 +1,5 @@ | |||
{ | |||
"uri_base": "https://paste.grytoyr.io", | |||
"filename_length": 16, | |||
"path": "../data/pastes/" | |||
} |
@@ -0,0 +1,3 @@ | |||
{ | |||
"kim": "abcd" | |||
} |
@@ -0,0 +1,82 @@ | |||
// vim: tabstop=2 shiftwidth=2 expandtab | |||
const express = require('express'); | |||
const path = require('path'); | |||
const favicon = require('serve-favicon'); | |||
const logger = require('morgan'); | |||
const cookieParser = require('cookie-parser'); | |||
const bodyParser = require('body-parser'); | |||
const fs = require('fs'); | |||
const crontab = require('node-crontab'); | |||
const index = require('./routes/index'); | |||
const app = express(); | |||
// TODO: Better config parsing? | |||
const env = process.env.NODE_ENV || 'dev'; | |||
const getConfig = () => { | |||
if (fs.existsSync('../data/config.' + env + '.json')) { | |||
const c = JSON.parse(fs.readFileSync('../data/config.' + env + '.json')); | |||
return c; | |||
} else { | |||
throw Error('Unable to read config..'); | |||
} | |||
} | |||
app.set('_config', getConfig()); | |||
const jobId = crontab.scheduleJob("* * * * *", () => { | |||
// Delete pastes older than X minutes | |||
const config = getConfig(); | |||
if (config.max_age <= 0) return; | |||
const pastes = fs.readdirSync(config.path); | |||
const now = new Date().getTime(); | |||
for (let i = 0; i < pastes.length; i++) { | |||
const file = pastes[i]; | |||
if (file.indexOf('.meta') !== -1) { | |||
const metadata = JSON.parse(fs.readFileSync(config.path + file)); | |||
const diff = (now - (config.max_age * 1000 * 60)); | |||
if (diff > metadata.timestamp) { | |||
// Delete file.. | |||
fs.unlinkSync(config.path + metadata.id + '.meta'); | |||
fs.unlinkSync(config.path + metadata.id + '.' + metadata.extension); | |||
} | |||
} | |||
} | |||
}); | |||
// view engine setup | |||
app.set('views', path.join(__dirname, 'views')); | |||
app.set('view engine', 'jade'); | |||
// uncomment after placing your favicon in /public | |||
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); | |||
app.use(logger('dev')); | |||
app.use(bodyParser.json()); | |||
app.use(bodyParser.urlencoded({ extended: false, limit: '5mb' })); | |||
app.use(cookieParser()); | |||
app.use(express.static(path.join(__dirname, 'public'))); | |||
app.use('/', index); | |||
// catch 404 and forward to error handler | |||
app.use((req, res, next) => { | |||
var err = new Error('Not Found'); | |||
err.status = 404; | |||
next(err); | |||
}); | |||
// error handler | |||
app.use((err, req, res, next) => { | |||
// set locals, only providing error in development | |||
res.locals.message = err.message; | |||
res.locals.error = req.app.get('env') === 'development' ? err : {}; | |||
// render the error page | |||
res.status(err.status || 500); | |||
res.render('error'); | |||
}); | |||
module.exports = app; |
@@ -0,0 +1,90 @@ | |||
#!/usr/bin/env node | |||
/** | |||
* Module dependencies. | |||
*/ | |||
var app = require('../app'); | |||
var debug = require('debug')('npaste:server'); | |||
var http = require('http'); | |||
/** | |||
* Get port from environment and store in Express. | |||
*/ | |||
var port = normalizePort(process.env.PORT || '3000'); | |||
app.set('port', port); | |||
/** | |||
* Create HTTP server. | |||
*/ | |||
var server = http.createServer(app); | |||
/** | |||
* Listen on provided port, on all network interfaces. | |||
*/ | |||
server.listen(port); | |||
server.on('error', onError); | |||
server.on('listening', onListening); | |||
/** | |||
* Normalize a port into a number, string, or false. | |||
*/ | |||
function normalizePort(val) { | |||
var port = parseInt(val, 10); | |||
if (isNaN(port)) { | |||
// named pipe | |||
return val; | |||
} | |||
if (port >= 0) { | |||
// port number | |||
return port; | |||
} | |||
return false; | |||
} | |||
/** | |||
* Event listener for HTTP server "error" event. | |||
*/ | |||
function onError(error) { | |||
if (error.syscall !== 'listen') { | |||
throw error; | |||
} | |||
var bind = typeof port === 'string' | |||
? 'Pipe ' + port | |||
: 'Port ' + port; | |||
// handle specific listen errors with friendly messages | |||
switch (error.code) { | |||
case 'EACCES': | |||
console.error(bind + ' requires elevated privileges'); | |||
process.exit(1); | |||
break; | |||
case 'EADDRINUSE': | |||
console.error(bind + ' is already in use'); | |||
process.exit(1); | |||
break; | |||
default: | |||
throw error; | |||
} | |||
} | |||
/** | |||
* Event listener for HTTP server "listening" event. | |||
*/ | |||
function onListening() { | |||
var addr = server.address(); | |||
var bind = typeof addr === 'string' | |||
? 'pipe ' + addr | |||
: 'port ' + addr.port; | |||
debug('Listening on ' + bind); | |||
} |
@@ -0,0 +1,27 @@ | |||
{ | |||
"name": "npaste", | |||
"version": "0.0.0", | |||
"private": true, | |||
"scripts": { | |||
"start": "node ./bin/www", | |||
"test": "node test/routes.js" | |||
}, | |||
"dependencies": { | |||
"basic-auth": "^2.0.0", | |||
"body-parser": "~1.18.2", | |||
"cookie-parser": "~1.4.3", | |||
"debug": "~2.6.9", | |||
"express": "~4.15.5", | |||
"jade": "~1.11.0", | |||
"mmmagic": "^0.4.6", | |||
"morgan": "~1.9.0", | |||
"multer": "^1.3.0", | |||
"node-crontab": "0.0.8", | |||
"serve-favicon": "~2.4.5" | |||
}, | |||
"devDependencies": { | |||
"express-reload": "^1.1.0", | |||
"supertest": "^3.0.0", | |||
"tape": "^4.8.0" | |||
} | |||
} |
@@ -0,0 +1,8 @@ | |||
body { | |||
padding: 50px; | |||
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; | |||
} | |||
a { | |||
color: #00B7FF; | |||
} |
@@ -0,0 +1,149 @@ | |||
// vim: tabstop=2 shiftwidth=2 expandtab | |||
const express = require('express'); | |||
const multer = require('multer') | |||
// TODO: Get path from config? | |||
const upload = multer({ dest: '../data/pastes' }) | |||
const router = express.Router(); | |||
const crypto = require('crypto'); | |||
const fs = require('fs'); | |||
const basicAuth = require('basic-auth'); | |||
const mmm = require('mmmagic'), | |||
Magic = mmm.Magic; | |||
const getMetadata = (id, path) => { | |||
// TODO: Error handling | |||
return JSON.parse(fs.readFileSync(path + id + '.meta')); | |||
} | |||
const authenticate = (req, res, next) => { | |||
const config = req.app.get('_config'); | |||
const unauthorized = (res) => { | |||
res.setHeader('WWW-Authenticate', 'Basic realm="npaste"'); | |||
return res.sendStatus(401); | |||
}; | |||
const user = basicAuth(req); | |||
if (!user || !user.name || !user.pass) { | |||
return unauthorized(res); | |||
} | |||
// TODO: Better way to find tokens and error handling | |||
const tokens = JSON.parse(fs.readFileSync('../data/tokens.json')); | |||
if (tokens[user.name] && tokens[user.name] == user.pass) { | |||
return next(); | |||
} | |||
return unauthorized(res); | |||
} | |||
/* GET home page. */ | |||
router.get('/', (req, res, next) => { | |||
res.render('index', { title: 'npaste' }); | |||
}); | |||
/* GET paste */ | |||
router.get('/:paste', (req, res, next) => { | |||
const config = req.app.get('_config'); | |||
if (!fs.existsSync(config.path + req.params.paste + '.meta')) { | |||
return res.status(400).send('Paste not found'); | |||
} | |||
const metadata = getMetadata(req.params.paste, config.path); | |||
const data = fs.readFileSync(config.path + req.params.paste + '.' + metadata.extension); | |||
if (typeof metadata.contentType === 'undefined') { | |||
return res.status(500).send('Invalid type'); | |||
} | |||
res.setHeader("Content-Type", metadata.contentType); | |||
return res.end(data, 'binary'); | |||
}); | |||
/* GET paste metadata */ | |||
router.get('/:paste/meta', (req, res, next) => { | |||
const config = req.app.get('_config'); | |||
if (!fs.existsSync(config.path + req.params.paste + '.meta')) { | |||
return res.status(400).send('Paste not found'); | |||
} | |||
const metadata = getMetadata(req.params.paste, config.path); | |||
res.setHeader("Content-Type", "application/json"); | |||
return res.end(JSON.stringify(metadata)); | |||
}); | |||
/* POST paste */ | |||
router.route('/') | |||
.post(authenticate, upload.single('paste'), (req, res, next) => { | |||
const config = req.app.get('_config'); | |||
const user = basicAuth(req); | |||
if (!req.file) | |||
return res.status(400).send('No paste uploaded'); | |||
// Find an unused and unique filename | |||
var filename; | |||
while (true) { | |||
// TODO: Get filename length from config | |||
filename = crypto.randomBytes(parseInt(config.filename_length)).toString('hex'); | |||
if (!fs.existsSync(config.path + filename + '.meta')) break; | |||
} | |||
// Check file type | |||
// TODO: Review, a bit messy | |||
let extension = null; | |||
const magic = new Magic(mmm.MAGIC_MIME_TYPE | mmm.MAGIC_MIME_ENCODING); | |||
magic.detectFile(req.file.path, (err, result) => { | |||
if (err) throw err; | |||
const type = result.split(';')[0]; | |||
if (type == 'text/plain') { | |||
extension = 'txt'; | |||
} else if (type == 'image/jpg') { | |||
extension = 'jpg'; | |||
} else if (type == 'image/png') { | |||
extension = 'png'; | |||
} | |||
const contentType = type; | |||
if (extension == null) { | |||
return res.status(400).send('Wrong file type'); | |||
} | |||
// Create .meta file | |||
// TODO: Move this to function or module | |||
fs.writeFileSync(config.path + filename + '.meta', JSON.stringify({ | |||
id: filename, | |||
timestamp: new Date().getTime(), | |||
contentType: contentType, | |||
extension: extension, | |||
submitter: user.name | |||
})); | |||
// Move uploaded file to its final destination | |||
fs.renameSync(req.file.path, config.path + filename + '.' + extension); | |||
return res.status(200).send(config.uri_base + '/' + filename); | |||
}); | |||
}); | |||
/* DELETE paste */ | |||
router.delete('/:paste', authenticate, (req, res, next) => { | |||
const config = req.app.get('_config'); | |||
if (!fs.existsSync(config.path + req.params.paste + '.meta')) { | |||
return res.status(400).send('Paste not found'); | |||
} | |||
const metadata = getMetadata(req.params.paste, config.path); | |||
fs.unlinkSync(config.path + req.params.paste + '.meta'); | |||
fs.unlinkSync(config.path + req.params.paste + '.' + metadata.extension); | |||
return res.status(200).send('OK'); | |||
}); | |||
module.exports = router; |
@@ -0,0 +1,15 @@ | |||
// vim: tabstop=2 shiftwidth=2 expandtab | |||
var express = require('express'); | |||
var router = express.Router(); | |||
/* GET version. */ | |||
router.get('/:paste', function(req, res, next) { | |||
res.render('version', { | |||
paste: req.params.paste, | |||
}); | |||
}); | |||
module.exports = router; | |||
@@ -0,0 +1,20 @@ | |||
// vim: tabstop=2 shiftwidth=2 expandtab | |||
const supertest = require('supertest'); | |||
const app = require('../app'); | |||
const request = supertest(app); | |||
const test = require('tape'); | |||
test('GET /', (assert) => { | |||
request | |||
.get('/') | |||
.expect(200) | |||
.end((err, res) => { | |||
if (err) { | |||
assert.fail(err) | |||
assert.end() | |||
} | |||
assert.ok(res.body, 'Response body is present') | |||
assert.end() | |||
}) | |||
}) |
@@ -0,0 +1,6 @@ | |||
extends layout | |||
block content | |||
h1= message | |||
h2= error.status | |||
pre #{error.stack} |
@@ -0,0 +1,6 @@ | |||
extends layout | |||
block content | |||
h1= title | |||
p Simple personal pastbin server. | |||
a(href="https://git.grytoyr.io/npaste/") https://git.grytoyr.io/npaste/ |
@@ -0,0 +1,7 @@ | |||
doctype html | |||
html | |||
head | |||
title= title | |||
link(rel='stylesheet', href='/stylesheets/style.css') | |||
body | |||
block content |
@@ -0,0 +1,2 @@ | |||
| #{paste} | |||