Browse Source

First working version

tags/v0.1
Kim Grytøyr 2 years ago
parent
commit
7795121353
18 changed files with 3047 additions and 3 deletions
  1. +3
    -2
      .gitignore
  2. +9
    -1
      TODO.md
  3. +7
    -0
      data/config.dev.json
  4. +5
    -0
      data/config.prod.json
  5. +0
    -0
      data/pastes/.gitkeep
  6. +3
    -0
      data/tokens.json
  7. +82
    -0
      src/app.js
  8. +90
    -0
      src/bin/www
  9. +2608
    -0
      src/package-lock.json
  10. +27
    -0
      src/package.json
  11. +8
    -0
      src/public/stylesheets/style.css
  12. +149
    -0
      src/routes/index.js
  13. +15
    -0
      src/routes/paste.js
  14. +20
    -0
      src/test/routes.js
  15. +6
    -0
      src/views/error.jade
  16. +6
    -0
      src/views/index.jade
  17. +7
    -0
      src/views/layout.jade
  18. +2
    -0
      src/views/paste.jade

+ 3
- 2
.gitignore View File

@@ -1,4 +1,5 @@
# ignore data dir, except .gitkeep
data/*
!data/.gitkeep
data/pastes/*
!data/pastes/.gitkeep
*/node_modules


+ 9
- 1
TODO.md View File

@@ -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)

+ 7
- 0
data/config.dev.json View File

@@ -0,0 +1,7 @@
{
"admin_token": "mvrQXMdPOlPNThlm6vyv1WcAuuDbApISM1IDVp2bQGpSu5SQUnwVLAFqVPyZGQa",
"uri_base": "http://localhost:3000",
"filename_length": 16,
"path": "../data/pastes/",
"max_age": 0
}

+ 5
- 0
data/config.prod.json View File

@@ -0,0 +1,5 @@
{
"uri_base": "https://paste.grytoyr.io",
"filename_length": 16,
"path": "../data/pastes/"
}

+ 0
- 0
data/pastes/.gitkeep View File


+ 3
- 0
data/tokens.json View File

@@ -0,0 +1,3 @@
{
"kim": "abcd"
}

+ 82
- 0
src/app.js View File

@@ -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;

+ 90
- 0
src/bin/www View File

@@ -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);
}

+ 2608
- 0
src/package-lock.json
File diff suppressed because it is too large
View File


+ 27
- 0
src/package.json View File

@@ -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"
}
}

+ 8
- 0
src/public/stylesheets/style.css View File

@@ -0,0 +1,8 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}

a {
color: #00B7FF;
}

+ 149
- 0
src/routes/index.js View File

@@ -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;

+ 15
- 0
src/routes/paste.js View File

@@ -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;



+ 20
- 0
src/test/routes.js View File

@@ -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()
})
})

+ 6
- 0
src/views/error.jade View File

@@ -0,0 +1,6 @@
extends layout

block content
h1= message
h2= error.status
pre #{error.stack}

+ 6
- 0
src/views/index.jade View File

@@ -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/

+ 7
- 0
src/views/layout.jade View File

@@ -0,0 +1,7 @@
doctype html
html
head
title= title
link(rel='stylesheet', href='/stylesheets/style.css')
body
block content

+ 2
- 0
src/views/paste.jade View File

@@ -0,0 +1,2 @@
| #{paste}


Loading…
Cancel
Save