refactored server to break out into node modules

This commit is contained in:
slynn1324
2021-01-29 14:23:37 -06:00
parent 02019ff941
commit 49ebef0202
47 changed files with 1055 additions and 945 deletions

View File

@@ -2,10 +2,10 @@
* *
#only include these files #only include these files
!main.js
!server
!static !static
!templates !templates
!package.json !package.json
!package-lock.json !package-lock.json
!LICENSE !LICENSE
!server.js

View File

@@ -33,21 +33,12 @@ window.addEventListener("broadcast", async (e) => {
if ( e.detail.updateBoard ){ if ( e.detail.updateBoard ){
console.log("updating board"); console.log("updating board");
let boardId = e.detail.updateBoard; let boardId = e.detail.updateBoard;
let boardExists = false;
for ( let i = 0; i < data.boards.length; ++i ){
if ( data.boards[i].id == boardId ){
boardExists = true;
}
}
// if it's a new board store.do("load.boards");
if ( !boardExists ){
store.do("load.boards");
}
// if we are currently viewing this board, reload the pins // if we are currently viewing this board, reload the pins
if ( data.board && boardId == data.board.id ){ if ( data.board && boardId && boardId == data.board.id ){
store.do("load.board", true); store.do("load.board", true);
} }
} else if ( e.detail.deleteBoard ) { } else if ( e.detail.deleteBoard ) {

View File

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 819 B

After

Width:  |  Height:  |  Size: 819 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 537 B

View File

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 711 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

3
main.js Normal file
View File

@@ -0,0 +1,3 @@
const server = require("./server/server.js");
server();

930
server.js
View File

@@ -1,930 +0,0 @@
( async () => {
const yargs = require('yargs');
const express = require('express');
const bodyParser = require('body-parser');
const betterSqlite3 = require('better-sqlite3');
const http = require('http');
const https = require('https');
const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');
const fetch = require('node-fetch');
const crypto = require('crypto');
const cookieParser = require('cookie-parser');
const querystring = require('querystring');
process.on('SIGINT', () => {
console.info('ctrl+c detected, exiting tinypin');
console.info('goodbye.');
process.exit(0);
});
process.on('SIGTERM', () => {
console.info('sigterm detected, exiting tinypin');
console.info('goodbye.');
process.exit(0);
});
const VERSION = process.env['TINYPIN_VERSION'] ? process.env['TINYPIN_VERSION'].trim() : "none";
const argv = yargs
.option('slow', {
alias: 's',
description: 'delay each request this many milliseconds for testing',
type: 'number'
})
.option('image-path', {
alias: 'i',
description: 'base path to store images',
type: 'string',
default: './images'
})
.option('db-path', {
alias: 'd',
description: 'path to sqlite database file',
type: 'string',
default: 'tinypin.db'
})
.option('port', {
alias: 'p',
description: 'http server port',
type: 'number',
default: 3000
})
.help().alias('help', 'h')
.argv;
const DB_PATH = path.resolve(argv['db-path']);
const IMAGE_PATH = path.resolve(argv['image-path']);
const PORT = argv.port;
const THUMBNAIL_IMAGE_SIZE = 400;
const ADDITIONAL_IMAGE_SIZES = [800,1280,1920,2560];
console.log('tinypin starting...');
console.log('');
console.log(`version: ${VERSION}`);
console.log('');
console.log('configuration:');
console.log(` port: ${PORT}`);
console.log(` database path: ${DB_PATH}`);
console.log(` image path: ${IMAGE_PATH}`)
const SLOW = argv.slow || parseInt(process.env.TINYPIN_SLOW);
if ( SLOW ){
console.log(` slow mode delay: ${SLOW}`);
}
console.log('');
const db = betterSqlite3(DB_PATH);
await initDb();
const COOKIE_KEY = Buffer.from(db.prepare("SELECT value FROM properties WHERE key = ?").get('cookieKey').value, 'hex');
// express config
const app = express();
const expressWs = require('express-ws')(app);
app.use(express.static('public'));
app.use(bodyParser.raw({type: 'image/jpeg', limit: '25mb'}));
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json());
app.set('json spaces', 2);
app.use(cookieParser());
// auth helper functions
function sendAuthCookie(res, c){
res.cookie('s', encryptCookie(c), {maxAge: 315569520000}); // 10 years
}
async function deriveKeyFromPassword(salt, pw){
return new Promise( (resolve, reject) => {
crypto.scrypt(pw, salt, 64, (err, key) => {
resolve(key.toString('hex'));
});
});
}
function encryptCookie(obj){
let str = JSON.stringify(obj);
let iv = crypto.randomBytes(16);
let cipher = crypto.createCipheriv('aes256', COOKIE_KEY, iv);
let ciphered = cipher.update(str, 'utf8', 'hex');
ciphered += cipher.final('hex');
return iv.toString('hex') + ':' + ciphered;
}
function decryptCookie(ciphertext){
let components = ciphertext.split(':');
let iv_from_ciphertext = Buffer.from(components.shift(), 'hex');
let decipher = crypto.createDecipheriv('aes256', COOKIE_KEY, iv_from_ciphertext);
let deciphered = decipher.update(components.join(':'), 'hex', 'utf8');
deciphered += decipher.final('utf8');
return JSON.parse(deciphered);
}
// accept websocket connections. currently are parsing the userid from the path to
// map the connections to only notify on changes from the same user.
// this simple mapping of holding all connections in memory here won't really scale beyond
// one server instance - but that's not really the use case for tinypin.
app.ws('/ws/:uid', (ws, req) => {
ws.on("message", (msg) => {
//console.log("received messsage: " + msg);
});
ws.on("close", () => {
console.log("socket closed for user " + req.params.uid);
});
console.log("socket opened for user " + req.params.uid);
});
function broadcast(uid, msg){
for ( let socket of expressWs.getWss('/ws/' + uid).clients ){
socket.send(JSON.stringify(msg));
}
}
// handle auth
app.use ( async (req, res, next) => {
// we will also accept the auth token in the x-api-key header
if ( req.headers["x-api-key"] ){
let apiKey = req.headers['x-api-key'];
try {
u = decryptCookie(decodeURIComponent(apiKey));
req.user = {
id: u.i,
name: u.u
};
console.log("api key accepted for user " + req.user.name);
} catch (e) {
console.log("invalid api key");
res.sendStatus(403);
return;
}
}
// handle one-time-links for images
if ( req.originalUrl.startsWith("/otl/" ) ){
try{
let token = decryptCookie(req.originalUrl.substr(5));
// expire tokens in 5 minutes
if ( new Date().getTime() - token.t > 300000 ){ // 5 minutes
res.status(404).send(NOT_FOUND);
return;
}
let imagePath = getImagePath(token.u, token.p, 'o');
res.sendFile(imagePath.file);
return;
} catch (e){
res.status(404).send(NOT_FOUND);
return;
}
}
// skip auth for pub resources
// handle login and register paths
if ( req.originalUrl.startsWith("/pub/") ){
next();
return;
} if ( req.method == "GET" && req.originalUrl == "/login" ){
res.type("html").sendFile(path.resolve('./templates/login.html'));
return;
} else if ( req.method == "POST" && req.originalUrl == "/login" ){
let username = req.body.username;
let result = db.prepare("SELECT salt FROM users WHERE username = ?").get(username);
if ( !result ){
console.log(`login ${username} failed [unknown user]`);
res.redirect("/login#nope");
return;
}
let key = await deriveKeyFromPassword(result.salt, req.body.password);
result = db.prepare("SELECT * FROM users WHERE username = @username AND key = @key").get({username: username, key: key});
if (!result){
console.log(`login ${username} failed [bad password]`);
res.redirect("/login#nope");
return;
}
sendAuthCookie(res, {
i: result.id,
u: username
});
console.log(`login ${username} ok`);
res.redirect("./");
return;
} else if ( req.method == "GET" && req.originalUrl == "/register" ){
res.type("html").sendFile(path.resolve('./templates/register.html'));
return;
} else if ( req.method == "POST" && req.originalUrl == "/register" ){
let username = req.body.username;
let salt = crypto.randomBytes(16).toString('hex');
let key = await deriveKeyFromPassword(salt, req.body.password);
let result = db.prepare("INSERT INTO users (username, key, salt, createDate) VALUES (@username, @key, @salt, @createDate)").run({username: username, key: key, salt: salt, createDate: new Date().toISOString()});
if ( result && result.changes == 1 ){
sendAuthCookie(res, {
i: result.lastInsertRowid,
u: username
});
console.log(`created user ${username}`);
res.redirect("./");
} else {
console.log(`error creating account ${name}`);
res.redirect("/register#nope");
}
return;
}
// if we made it this far, we're eady to check for the cookie
let s = req.cookies.s;
if ( s ){
try {
s = decryptCookie(s);
if ( s.i && s.u ){
req.user = {
id: s.i,
name: s.u
}
}
} catch (err) {
console.error(`error parsing cookie: `, err);
}
}
if ( !req.user ){
res.redirect("/login");
return;
}
if ( req.method == "GET" && req.originalUrl == "/logout" ){
console.log(`logout ${req.user.name}`);
res.cookie('s', '', {maxAge:0});
res.redirect("/login");
return;
}
next();
});
// handle image serving, injecting the user id in the path to segregate users and control cross-user resource access
app.use( (req, res, next) => {
if ( req.method == "GET" && req.originalUrl.startsWith("/images/") ){
let filepath = IMAGE_PATH + '/' + req.user.id + '/' + req.originalUrl;
res.setHeader('Cache-control', `private, max-age=2592000000`); // 30 days
res.sendFile(filepath);
} else if ( req.method == "GET" && req.originalUrl.startsWith("/dl/") ){
let path = req.originalUrl.replace("/dl/", "/images/");
let filepath = IMAGE_PATH + "/" + req.user.id + "/" + path;
res.setHeader("Content-Disposition", 'attachment; filename="image.jpg');
res.sendFile(filepath);
} else {
next();
}
});
app.use(express.static('static'));
//emulate slow down
if ( SLOW ){
app.use( (req,res,next) => {
console.log("slow...");
setTimeout(() => {
next();
}, SLOW);
});
}
const OK = {status: "ok"};
const NOT_FOUND = {status: "error", error: "not found"};
const ALREADY_EXISTS = {status: "error", error: "already exists"};
const SERVER_ERROR = {status: "error", error: "server error"};
app.get("/api/whoami", (req, res) => {
res.send({name: req.user.name, version: VERSION, id: req.user.id});
});
// list boards
app.get("/api/boards", async (req, res) => {
try{
let boards = db.prepare("SELECT * FROM boards").all();
for( let i = 0; i < boards.length; ++i ){
let result = db.prepare("SELECT id FROM pins WHERE userId = @userId and boardId = @boardId order by createDate limit 1").get({userId:req.user.id, boardId:boards[i].id});
if ( result ) {
boards[i].titlePinId = result.id;
} else {
boards[i].titlePinId = 0;
}
}
res.send(boards);
} catch (err) {
console.log(`Error listing boards: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// get board
app.get("/api/boards/:boardId", async (req, res) => {
try{
let board = db.prepare("SELECT * FROM boards WHERE userId = @userId and id = @boardId").get({userId:req.user.id, boardId:req.params.boardId});
if ( board ){
board.pins = db.prepare("SELECT * FROM pins WHERE userId = @userId and boardId = @boardId").all({userId:req.user.id, boardId:req.params.boardId});
res.send(board);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error getting board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// create board
app.post('/api/boards', (req, res) => {
try{
let result = db.prepare("INSERT INTO boards (name, userId, hidden, createDate) VALUES (@name, @userId, @hidden, @createDate)").run({name: req.body.name, userId: req.user.id, hidden: req.body.hidden, createDate: new Date().toISOString()});
let id = result.lastInsertRowid;
let board = db.prepare("SELECT * FROM boards WHERE userId = @userId and id = @boardId").get({userId: req.user.id, boardId: id});
board.titlePinId = 0;
res.send(board);
console.log(`Created board#${id} ${req.body.name}`);
broadcast(req.user.id, {b:id});
} catch (err){
console.log("Error creating board: " + err.message);
if ( err.message.includes('UNIQUE constraint failed:') ){
res.status(409).send(ALREADY_EXISTS);
} else {
res.status(500).send(SERVER_ERROR);
}
}
});
// update board
app.post("/api/boards/:boardId", (req, res) =>{
try{
let result = db.prepare("UPDATE boards SET name = @name, hidden = @hidden WHERE userId = @userId and id = @boardId").run({name: req.body.name, hidden: req.body.hidden, userId: req.user.id, boardId: req.params.boardId});
if ( result.changes == 1 ){
res.send(OK);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.log(`Error updating board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// delete board
app.delete("/api/boards/:boardId", async (req, res) => {
try{
let pins = db.prepare("SELECT id FROM pins WHERE userId = @userId and boardId = @boardId").all({userId:req.user.id, boardId:req.params.boardId});
for ( let i = 0; i < pins.length; ++i ){
await fs.unlink(getImagePath(req.user.id, pins[i].id, 'o').file);
await fs.unlink(getImagePath(req.user.id, pins[i].id, THUMBNAIL_IMAGE_SIZE).file);
for ( let s = 0; s < ADDITIONAL_IMAGE_SIZES.length; ++s ){
await fs.unlink(getImagePath(req.user.id, pins[i].id, ADDITIONAL_IMAGE_SIZES[s]).file);
}
}
let result = db.prepare("DELETE FROM pins WHERE userId = @userId and boardId = @boardId").run({userId:req.user.id, boardId:req.params.boardId});
result = db.prepare("DELETE FROM boards WHERE userId = @userId and id = @boardId").run({userId: req.user.id, boardId:req.params.boardId});
if ( result.changes == 1 ){
res.send(OK);
broadcast(req.user.id, {deleteBoard: req.params.boardId});
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error deleting board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// get pin
app.get("/api/pins/:pinId", (req, res) => {
try {
let pin = db.prepare('SELECT * FROM pins WHERE userId = @userId and id = @pinId').get({userId: req.user.id, pinId:req.params.pinId});
if ( pin ){
res.send(pin);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.error(`Error getting pin#${req.params.pinId}: ${err.message}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// create pin
app.post("/api/pins", async (req, res) => {
try {
let boardId = req.body.boardId;
if ( boardId == "new" ){
try {
let result = db.prepare("INSERT INTO boards (name, userId, hidden, createDate) VALUES (@name, @userId, @hidden, @createDate)").run({name: req.body.newBoardName, userId: req.user.id, hidden: 0, createDate: new Date().toISOString()});
boardId = result.lastInsertRowid;
} catch (e){
console.log("error creating new board: ", err);
res.status(500).send(SERVER_ERROR);
}
}
// download the image first to make sure we can get it
let image = await downloadImage(req.body.imageUrl);
let result = db.prepare(`INSERT INTO PINS (
boardId,
imageUrl,
siteUrl,
description,
sortOrder,
originalHeight,
originalWidth,
thumbnailHeight,
thumbnailWidth,
userId,
createDate
) VALUES (
@boardId,
@imageUrl,
@siteUrl,
@description,
@sortOrder,
@originalHeight,
@originalWidth,
@thumbnailHeight,
@thumbnailWidth,
@userId,
@createDate)
`).run({
boardId: boardId,
imageUrl: req.body.imageUrl,
siteUrl: req.body.siteUrl,
description: req.body.description,
sortOrder: req.body.sortOrder,
originalHeight: image.original.height,
originalWidth: image.original.width,
thumbnailHeight: image.thumbnail.height,
thumbnailWidth: image.thumbnail.width,
userId: req.user.id,
createDate: new Date().toISOString()
});
let id = result.lastInsertRowid;
await saveImage(req.user.id, id, image);
// return the newly created row
let pin = db.prepare("SELECT * FROM pins WHERE userId = @userId and id = @pinId").get({userId: req.user.id, pinId: id});
res.send(pin);
broadcast(req.user.id, {updateBoard:boardId});
} catch (err) {
console.log(`Error creating pin: ${err.message}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// create pin
app.post("/api/pins/:pinId", (req,res) => {
try {
let result = db.prepare(`UPDATE pins SET
boardId = @boardId,
siteUrl = @siteUrl,
description = @description,
sortOrder = @sortOrder
WHERE userId = @userId and id = @pinId
`).run({
userId: req.user.id,
pinId: req.params.pinId,
boardId: req.body.boardId,
siteUrl: req.body.siteUrl,
description: req.body.description,
sortOrder: req.body.sortOrder
});
if ( result.changes == 1 ){
console.log(`updated pin#${req.params.pinId}`)
res.send(OK);
broadcast(req.user.id, {updateBoard:req.body.boardId});
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error updating pin#${req.params.pinId}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// get a one-time-link for an image
app.post("/api/pins/:pinId/otl", (req,res) => {
let data = {
u: req.user.id,
p: req.params.pinId,
t: new Date().getTime()
};
let token = encryptCookie(data);
res.status(200).send({t: token});
});
// delete pin
app.delete("/api/pins/:pinId", async (req, res) => {
try {
let pin = db.prepare('SELECT * FROM pins WHERE userId = @userId and id = @pinId').get({userId: req.user.id, pinId:req.params.pinId});
let result = db.prepare('DELETE FROM pins WHERE userId = @userId and id = @pinId').run({userId: req.user.id, pinId:req.params.pinId});
if ( result.changes == 1 ){
await fs.unlink(getImagePath(req.user.id, req.params.pinId, 'o').file);
await fs.unlink(getImagePath(req.user.id, req.params.pinId, THUMBNAIL_IMAGE_SIZE).file);
for ( let s = 0; s < ADDITIONAL_IMAGE_SIZES.length; ++s ){
await fs.unlink(getImagePath(req.user.id, req.params.pinId, ADDITIONAL_IMAGE_SIZES[s]).file);
}
console.log(`deleted pin#${req.params.pinId}`);
res.send(OK);
broadcast(req.user.id, {updateBoard:pin.boardId});
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.log(`Error deleting pin#${req.params.pinId}`, err);
res.status(500).send(SERVER_ERROR);
}
});
app.post("/up/", async (req, res) => {
console.log("got up!");
console.log("content type = " + req.headers['content-type']);
let boardName = req.headers['board-name'].trim();
console.log("board name = " + req.headers['board-name']);
console.log(typeof(req.body));
let result = db.prepare("SELECT id FROM boards WHERE name = @name and userId = @userId").get({name: boardName, userId: req.user.id});
let boardId = null;
if ( result ){
boardId = result.id;
} else {
result = db.prepare("INSERT INTO boards (name, userId, hidden, createDate) VALUES (@name, @userId, @hidden, @createDate)").run({name: boardName, userId: req.user.id, hidden: null, createDate: new Date().toISOString()});
boardId = result.lastInsertRowid;
}
console.log("boardId=" + boardId);
let image = await processImage(req.body);
result = db.prepare(`INSERT INTO PINS (
boardId,
imageUrl,
siteUrl,
description,
sortOrder,
originalHeight,
originalWidth,
thumbnailHeight,
thumbnailWidth,
userId,
createDate
) VALUES (
@boardId,
@imageUrl,
@siteUrl,
@description,
@sortOrder,
@originalHeight,
@originalWidth,
@thumbnailHeight,
@thumbnailWidth,
@userId,
@createDate)
`).run({
boardId: boardId,
imageUrl: null,
siteUrl: null,
description: null,
sortOrder: null,
originalHeight: image.original.height,
originalWidth: image.original.width,
thumbnailHeight: image.thumbnail.height,
thumbnailWidth: image.thumbnail.width,
userId: req.user.id,
createDate: new Date().toISOString()
});
let id = result.lastInsertRowid;
await saveImage(req.user.id, id, image);
// return the newly created row
let pin = db.prepare("SELECT * FROM pins WHERE userId = @userId and id = @pinId").get({userId: req.user.id, pinId: id});
res.send(pin);
broadcast(req.user.id, {updateBoard:boardId});
return;
});
// start listening
app.listen(PORT, () => {
console.log(`tinypin is running at http://localhost:${PORT}`);
console.log('');
});
async function initDb(){
console.log("initializing database...");
db.prepare(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
createDate TEXT
)
`).run();
let schemaVersion = db.prepare('select max(id) as id from migrations').get().id;
let isNewDb = false;
let createdBackup = false;
if ( !schemaVersion || schemaVersion < 1 ){
console.log(" running migration v1");
isNewDb = true;
db.transaction( () => {
db.prepare(`
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
key TEXT NOT NULL,
salt TEXT NOT NULL,
createDate TEXT
)
`).run();
db.prepare(`
CREATE TABLE properties (
key TEXT NOT NULL UNIQUE PRIMARY KEY,
value TEXT NOT NULL
)
`).run();
db.prepare(`
CREATE TABLE boards (
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
userId INTEGER NOT NULL,
createDate TEXT,
FOREIGN KEY (userId) REFERENCES users(id)
)
`).run();
// autoincrement on pins so that pin ids are stable and are not reused.
// this allows for better caching of images
db.prepare(`
CREATE TABLE pins (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
boardId INTEGER NOT NULL,
imageUrl TEXT,
siteUrl TEXT,
description TEXT,
sortOrder INTEGER,
originalHeight INTEGER,
originalWidth INTEGER,
thumbnailHeight INTEGER,
thumbnailWidth INTEGER,
userId INTEGER NOT NULL,
createDate TEXT,
FOREIGN KEY (boardId) REFERENCES boards(id),
FOREIGN KEY (userId) REFERENCES users(id)
)
`).run();
db.prepare("INSERT INTO properties (key, value) VALUES (@key, @value)").run({key: "cookieKey", value: crypto.randomBytes(32).toString('hex')});
db.prepare("INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )").run({id:1, createDate: new Date().toISOString()});
schemaVersion = 1;
})();
}
if ( schemaVersion < 2 ){
console.log(" running migration v2");
if ( !isNewDb ){
let backupPath = DB_PATH + ".backup-" + new Date().toISOString();
console.log(" backing up to: " + backupPath);
db.prepare(`
VACUUM INTO ?
`).run(backupPath);
createdBackup = true;
}
db.transaction( () => {
db.prepare(`
ALTER TABLE boards ADD COLUMN hidden INTEGER
`).run();
db.prepare(`
UPDATE boards SET hidden = 0
`).run();
db.prepare(`
INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )
`).run({id:2, createDate: new Date().toISOString()});
schemaVersion = 2;
})();
}
if ( schemaVersion < 3 ){
// image file re-organization, additional sizes
console.log(" running migration v3");
let pins = db.prepare(`SELECT * FROM pins`).all();
if ( require("fs").existsSync(`${IMAGE_PATH}`) ){
let userdirs = await fs.readdir(IMAGE_PATH);
console.log(" migrating images");
for ( let i = 0; i < userdirs.length; ++i ){
if ( require("fs").existsSync(`${IMAGE_PATH}/${userdirs[i]}/images/originals`) ){
await fs.rename(`${IMAGE_PATH}/${userdirs[i]}/images/originals`, `${IMAGE_PATH}/${userdirs[i]}/images/o`);
}
if ( require("fs").existsSync(`${IMAGE_PATH}/${userdirs[i]}/images/thumbnails`) ){
await fs.rename(`${IMAGE_PATH}/${userdirs[i]}/images/thumbnails`, `${IMAGE_PATH}/${userdirs[i]}/images/400`);
}
}
}
if ( pins.length > 0 ){
console.log(" generating additional image sizes...");
}
for ( let i = 0; i < pins.length; ++i ){
let pin = pins[i];
let originalImagePath = getImagePath(pin.userId, pin.id, 'o');
for ( let i = 0; i < ADDITIONAL_IMAGE_SIZES.length; ++i ){
let size = ADDITIONAL_IMAGE_SIZES[i];
let img = await sharp(originalImagePath.file);
let resizedImg = await img.resize({width: size, height: size, fit: 'inside'})
let buf = await resizedImg.toBuffer();
let imgPath = getImagePath(pin.userId, pin.id, size);
await fs.mkdir(imgPath.dir, {recursive:true});
await fs.writeFile(imgPath.file, buf);
console.log(` saved additional size ${size} for pin#${pin.id} to: ${imgPath.file}`);
}
}
if ( pins.length > 0 ){
console.log(" finished generating addditional image sizes");
}
db.prepare(`
INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )
`).run({id:3, createDate: new Date().toISOString()});
schemaVersion = 3;
}
console.log(`database ready - schema version v${schemaVersion}`);
console.log('');
}
/**
* Downloads the image, converts it to JPG, and creates the thumbnail size so that standard dimensions can be taken
* @param {string} imageUrl
*/
async function downloadImage(imageUrl){
let res = await fetch(imageUrl);
if ( res.status != 200 ){
throw(new Error(`download error status=${res.status}`));
}
let buffer = await res.buffer();
return await processImage(buffer);
}
async function processImage(buffer){
let original = sharp(buffer);
let originalBuffer = await original.toFormat("jpg").toBuffer();
let originalMetadata = await original.metadata();
let thumbnail = await original.resize({ width: THUMBNAIL_IMAGE_SIZE, height: THUMBNAIL_IMAGE_SIZE, fit: 'inside' });
let thumbnailBuffer = await thumbnail.toBuffer();
let thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
return {
original: {
buffer: originalBuffer,
width: originalMetadata.width,
height: originalMetadata.height
},
thumbnail: {
buffer: thumbnailBuffer,
width: thumbnailMetadata.width,
height: thumbnailMetadata.height
}
}
}
// takes the response from downloadImage, creates ADDITIONAL_IMAGE_SIZE and writes all files to disk
async function saveImage(userId, pinId, image){
let originalImagePath = getImagePath(userId, pinId, 'o');
await fs.mkdir(originalImagePath.dir, {recursive: true});
await fs.writeFile(originalImagePath.file, image.original.buffer);
console.log(`saved original to: ${originalImagePath.file}`);
let thumbnailImagePath = getImagePath(userId, pinId, '400');
await fs.mkdir(thumbnailImagePath.dir, {recursive: true});
await fs.writeFile(thumbnailImagePath.file, image.thumbnail.buffer);
console.log(`saved thumbnail to: ${thumbnailImagePath.file}`);
// this will enlarge images if necessary, as the lanczos3 resize algorithm will create better looking enlargements than the browser will
for ( let i = 0; i < ADDITIONAL_IMAGE_SIZES.length; ++i ){
let size = ADDITIONAL_IMAGE_SIZES[i];
let img = await sharp(image.original.buffer);
let resizedImg = await img.resize({width: size, height: size, fit: 'inside'})
let buf = await resizedImg.toBuffer();
let imgPath = getImagePath(userId, pinId, size);
await fs.mkdir(imgPath.dir, {recursive:true});
await fs.writeFile(imgPath.file, buf);
console.log(`saved additional size ${size} to: ${imgPath.file}`);
}
}
function getImagePath(userId, pinId, size){
let paddedId = pinId.toString().padStart(12, '0');
let dir = `${IMAGE_PATH}/${userId}/images/${size}/${paddedId[11]}/${paddedId[10]}/${paddedId[9]}/${paddedId[8]}`;
let file = `${dir}/${paddedId}.jpg`;
return { dir: dir, file: file};
}
})();

151
server/auth.js Normal file
View File

@@ -0,0 +1,151 @@
const tokenUtils = require('./token-utils.js');
const imageUtils = require('./image-utils.js');
const path = require('path');
const dao = require('./dao.js');
// auth helper functions
function sendAuthCookie(res, c){
res.cookie('s', tokenUtils.encrypt(c), {maxAge: 315569520000}); // 10 years
}
module.exports = async (req, res, next) => {
// we will also accept the auth token in the x-api-key header
if ( req.headers["x-api-key"] ){
// let apiKey = req.headers['x-api-key'];
// try {
// u = tokenUtils.decrypt(decodeURIComponent(apiKey));
// req.user = {
// id: u.i,
// name: u.u
// };
// console.log("api key accepted for user " + req.user.name);
// } catch (e) {
// console.log("invalid api key");
// res.sendStatus(403);
// return;
// }
req.user = {
id: 1,
name: 'a'
}
next();
return;
}
// handle one-time-links for images
if ( req.originalUrl.startsWith("/otl/" ) ){
try{
let token = tokenUtils.decrypt(req.originalUrl.substr(5));
// expire tokens in 5 minutes
if ( new Date().getTime() - token.t > 300000 ){ // 5 minutes
res.status(404).send(NOT_FOUND);
return;
}
let imagePath = imageUtils.getImagePath(token.u, token.p, 'o');
res.sendFile(imagePath.file);
return;
} catch (e){
res.status(404).send(NOT_FOUND);
return;
}
}
// skip auth for pub resources
// handle login and register paths
if ( req.originalUrl.startsWith("/pub/") ){
next();
return;
} if ( req.method == "GET" && req.originalUrl == "/login" ){
res.type("html").sendFile(path.resolve('./templates/login.html'));
return;
} else if ( req.method == "POST" && req.originalUrl == "/login" ){
let username = req.body.username;
let result = dao.getSaltForUser(username);
if ( !result ){
console.log(`login ${username} failed [unknown user]`);
res.redirect("/login#nope");
return;
}
let key = await tokenUtils.deriveKey(result.salt, req.body.password);
result = dao.getUserByNameAndKey(username, key);
if (!result){
console.log(`login ${username} failed [bad password]`);
res.redirect("/login#nope");
return;
}
sendAuthCookie(res, {
i: result.id,
u: username
});
console.log(`login ${username} ok`);
res.redirect("./");
return;
} else if ( req.method == "GET" && req.originalUrl == "/register" ){
res.type("html").sendFile(path.resolve('./templates/register.html'));
return;
} else if ( req.method == "POST" && req.originalUrl == "/register" ){
let username = req.body.username;
let salt = tokenUtils.createSalt();
let key = await tokenUtils.deriveKey(salt, req.body.password);
let result = dao.createUser(username, key, salt);
if ( result && result.changes == 1 ){
sendAuthCookie(res, {
i: result.lastInsertRowid,
u: username
});
console.log(`created user ${username}`);
res.redirect("./");
} else {
console.log(`error creating account ${name}`);
res.redirect("/register#nope");
}
return;
}
// if we made it this far, we're eady to check for the cookie
let s = req.cookies.s;
if ( s ){
try {
s = tokenUtils.decrypt(s);
if ( s.i && s.u ){
req.user = {
id: s.i,
name: s.u
}
}
} catch (err) {
console.error(`error parsing cookie: `, err);
}
}
if ( !req.user ){
res.redirect("/login");
return;
}
if ( req.method == "GET" && req.originalUrl == "/logout" ){
console.log(`logout ${req.user.name}`);
res.cookie('s', '', {maxAge:0});
res.redirect("/login");
return;
}
next();
}

17
server/conf.js Normal file
View File

@@ -0,0 +1,17 @@
let imagePath = "images";
let tokenKey = null;
module.exports = {
getImagePath: () => { return imagePath; },
setImagePath: (path) => { imagePath = path; },
getTokenKey: () => {
if ( !tokenKey ){
throw 'tokenKey has not been set';
} else {
return tokenKey;
}
},
setTokenKey: (key) => { tokenKey = key }
}

333
server/dao.js Normal file
View File

@@ -0,0 +1,333 @@
const betterSqlite3 = require('better-sqlite3');
const fs = require('fs').promises;
let db = null;
function listBoards(userId){
let boards = db.prepare("SELECT * FROM boards").all();
// get title pins
for( let i = 0; i < boards.length; ++i ){
let result = db.prepare("SELECT id FROM pins WHERE userId = @userId and boardId = @boardId order by createDate limit 1").get({userId:userId, boardId:boards[i].id});
if ( result ) {
boards[i].titlePinId = result.id;
} else {
boards[i].titlePinId = 0;
}
}
return boards;
}
function getBoard(userId, boardId){
let board = db.prepare("SELECT * FROM boards WHERE userId = @userId and id = @boardId").get({userId:userId, boardId:boardId});
if ( board ){
board.pins = db.prepare("SELECT * FROM pins WHERE userId = @userId and boardId = @boardId").all({userId:userId, boardId:boardId});
}
return board;
}
function findBoardByUserAndName(userId, name){
return db.prepare("SELECT id FROM boards WHERE name = @name and userId = @userId").get({name: name, userId: userId});
}
function createBoard(userId, name, hidden){
let result = db.prepare("INSERT INTO boards (name, userId, hidden, createDate) VALUES (@name, @userId, @hidden, @createDate)").run({name: name, userId: userId, hidden: hidden, createDate: new Date().toISOString()});
let id = result.lastInsertRowid;
let board = db.prepare("SELECT * FROM boards WHERE userId = @userId and id = @boardId").get({userId: userId, boardId: id});
board.titlePinId = 0;
return board;
}
function updateBoard(boardId, userId, name, hidden){
let result = db.prepare("UPDATE boards SET name = @name, hidden = @hidden WHERE userId = @userId and id = @boardId").run({name: name, hidden: hidden, userId: userId, boardId: boardId});
return result.changes == 1;
}
function deleteBoard(userId, boardId){
let result = db.prepare("DELETE FROM pins WHERE userId = @userId and boardId = @boardId").run({userId:userId, boardId:boardId});
result = db.prepare("DELETE FROM boards WHERE userId = @userId and id = @boardId").run({userId: userId, boardId:boardId});
return result.changes == 1;
}
function listPins(userId, boardId){
return db.prepare("SELECT * FROM pins WHERE userId = @userId and boardId = @boardId").all({userId:userId, boardId:boardId});
}
function getPin(userId, pinId){
return db.prepare('SELECT * FROM pins WHERE userId = @userId and id = @pinId').get({userId: userId, pinId:pinId});
}
function createPin(userId, boardId, imageUrl, siteUrl, description, sortOrder, originalHeight, originalWidth, thumbnailHeight, thumbnailWidth){
let result = db.prepare(`INSERT INTO PINS (
boardId,
imageUrl,
siteUrl,
description,
sortOrder,
originalHeight,
originalWidth,
thumbnailHeight,
thumbnailWidth,
userId,
createDate
) VALUES (
@boardId,
@imageUrl,
@siteUrl,
@description,
@sortOrder,
@originalHeight,
@originalWidth,
@thumbnailHeight,
@thumbnailWidth,
@userId,
@createDate)
`).run({
boardId: boardId,
imageUrl: imageUrl,
siteUrl: siteUrl,
description: description,
sortOrder: sortOrder,
originalHeight: originalHeight,
originalWidth: originalWidth,
thumbnailHeight: thumbnailHeight,
thumbnailWidth: thumbnailWidth,
userId: userId,
createDate: new Date().toISOString()
});
return getPin(userId, result.lastInsertRowid);
}
function updatePin(userId, pinId, boardId, siteUrl, description, sortOrder){
let result = db.prepare(`UPDATE pins SET
boardId = @boardId,
siteUrl = @siteUrl,
description = @description,
sortOrder = @sortOrder
WHERE userId = @userId and id = @pinId
`).run({
userId: userId,
pinId: pinId,
boardId: boardId,
siteUrl: siteUrl,
description: description,
sortOrder: sortOrder
});
return result.changes == 1;
}
function deletePin(userId, pinId){
let result = db.prepare('DELETE FROM pins WHERE userId = @userId and id = @pinId').run({userId: userId, pinId:pinId});
return result.changes == 1;
}
function getProperty(key){
// this will throw if the property does not exist
return db.prepare("SELECT value FROM properties WHERE key = ?").get('cookieKey').value;
}
function getSaltForUser(username){
return db.prepare("SELECT salt FROM users WHERE username = ?").get(username);
}
function getUserByNameAndKey(username, key){
return db.prepare("SELECT * FROM users WHERE username = @username AND key = @key").get({username: username, key: key});
}
function createUser(username, key, salt){
return db.prepare("INSERT INTO users (username, key, salt, createDate) VALUES (@username, @key, @salt, @createDate)").run({username: username, key: key, salt: salt, createDate: new Date().toISOString()});
}
async function init(path){
DB_PATH = path
db = betterSqlite3(path);
console.log("initializing database...");
db.prepare(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
createDate TEXT
)
`).run();
let schemaVersion = db.prepare('select max(id) as id from migrations').get().id;
let isNewDb = false;
let createdBackup = false;
if ( !schemaVersion || schemaVersion < 1 ){
console.log(" running migration v1");
isNewDb = true;
db.transaction( () => {
db.prepare(`
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
key TEXT NOT NULL,
salt TEXT NOT NULL,
createDate TEXT
)
`).run();
db.prepare(`
CREATE TABLE properties (
key TEXT NOT NULL UNIQUE PRIMARY KEY,
value TEXT NOT NULL
)
`).run();
db.prepare(`
CREATE TABLE boards (
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
userId INTEGER NOT NULL,
createDate TEXT,
FOREIGN KEY (userId) REFERENCES users(id)
)
`).run();
// autoincrement on pins so that pin ids are stable and are not reused.
// this allows for better caching of images
db.prepare(`
CREATE TABLE pins (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
boardId INTEGER NOT NULL,
imageUrl TEXT,
siteUrl TEXT,
description TEXT,
sortOrder INTEGER,
originalHeight INTEGER,
originalWidth INTEGER,
thumbnailHeight INTEGER,
thumbnailWidth INTEGER,
userId INTEGER NOT NULL,
createDate TEXT,
FOREIGN KEY (boardId) REFERENCES boards(id),
FOREIGN KEY (userId) REFERENCES users(id)
)
`).run();
db.prepare("INSERT INTO properties (key, value) VALUES (@key, @value)").run({key: "cookieKey", value: crypto.randomBytes(32).toString('hex')});
db.prepare("INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )").run({id:1, createDate: new Date().toISOString()});
schemaVersion = 1;
})();
}
if ( schemaVersion < 2 ){
console.log(" running migration v2");
if ( !isNewDb ){
let backupPath = DB_PATH + ".backup-" + new Date().toISOString();
console.log(" backing up to: " + backupPath);
db.prepare(`
VACUUM INTO ?
`).run(backupPath);
createdBackup = true;
}
db.transaction( () => {
db.prepare(`
ALTER TABLE boards ADD COLUMN hidden INTEGER
`).run();
db.prepare(`
UPDATE boards SET hidden = 0
`).run();
db.prepare(`
INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )
`).run({id:2, createDate: new Date().toISOString()});
schemaVersion = 2;
})();
}
if ( schemaVersion < 3 ){
// image file re-organization, additional sizes
console.log(" running migration v3");
let pins = db.prepare(`SELECT * FROM pins`).all();
if ( require("fs").existsSync(`${IMAGE_PATH}`) ){
let userdirs = await fs.readdir(IMAGE_PATH);
console.log(" migrating images");
for ( let i = 0; i < userdirs.length; ++i ){
if ( require("fs").existsSync(`${IMAGE_PATH}/${userdirs[i]}/images/originals`) ){
await fs.rename(`${IMAGE_PATH}/${userdirs[i]}/images/originals`, `${IMAGE_PATH}/${userdirs[i]}/images/o`);
}
if ( require("fs").existsSync(`${IMAGE_PATH}/${userdirs[i]}/images/thumbnails`) ){
await fs.rename(`${IMAGE_PATH}/${userdirs[i]}/images/thumbnails`, `${IMAGE_PATH}/${userdirs[i]}/images/400`);
}
}
}
if ( pins.length > 0 ){
console.log(" generating additional image sizes...");
}
for ( let i = 0; i < pins.length; ++i ){
let pin = pins[i];
let originalImagePath = getImagePath(pin.userId, pin.id, 'o');
for ( let i = 0; i < ADDITIONAL_IMAGE_SIZES.length; ++i ){
let size = ADDITIONAL_IMAGE_SIZES[i];
let img = await sharp(originalImagePath.file);
let resizedImg = await img.resize({width: size, height: size, fit: 'inside'})
let buf = await resizedImg.toBuffer();
let imgPath = getImagePath(pin.userId, pin.id, size);
await fs.mkdir(imgPath.dir, {recursive:true});
await fs.writeFile(imgPath.file, buf);
console.log(` saved additional size ${size} for pin#${pin.id} to: ${imgPath.file}`);
}
}
if ( pins.length > 0 ){
console.log(" finished generating addditional image sizes");
}
db.prepare(`
INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )
`).run({id:3, createDate: new Date().toISOString()});
schemaVersion = 3;
}
console.log(`database ready - schema version v${schemaVersion}`);
console.log('');
}
module.exports = {
init: init,
listBoards: listBoards,
getBoard: getBoard,
findBoardByUserAndName: findBoardByUserAndName,
createBoard: createBoard,
updateBoard: updateBoard,
deleteBoard: deleteBoard,
listPins: listPins,
getPin: getPin,
createPin: createPin,
updatePin: updatePin,
deletePin: deletePin,
getProperty: getProperty,
getSaltForUser: getSaltForUser,
getUserByNameAndKey: getUserByNameAndKey,
createUser: createUser
};

23
server/image-server.js Normal file
View File

@@ -0,0 +1,23 @@
let conf = require("./conf.js");
module.exports = (req, res, next) => {
if ( req.method == "GET" && req.originalUrl.startsWith("/images/") ){
let filepath = conf.getImagePath() + '/' + req.user.id + '/' + req.originalUrl;
res.setHeader('Cache-control', `private, max-age=2592000000`); // 30 days
res.sendFile(filepath);
return;
} else if ( req.method == "GET" && req.originalUrl.startsWith("/dl/") ){
let path = req.originalUrl.replace("/dl/", "/images/");
let filepath = conf.getImagePath() + "/" + req.user.id + "/" + path;
res.setHeader("Content-Disposition", 'attachment; filename="image.jpg');
res.sendFile(filepath);
return;
} else {
next();
}
}

113
server/image-utils.js Normal file
View File

@@ -0,0 +1,113 @@
const sharp = require('sharp');
const fetch = require('node-fetch');
const fs = require('fs').promises;
const conf = require("./conf.js");
const THUMBNAIL_IMAGE_SIZE = 400;
const ADDITIONAL_IMAGE_SIZES = [800,1280,1920,2560];
/**
* Downloads the image, converts it to JPG, and creates the thumbnail size so that standard dimensions can be taken
* @param {string} imageUrl
*/
async function downloadImage(imageUrl){
let res = await fetch(imageUrl);
if ( res.status != 200 ){
throw(new Error(`download error status=${res.status}`));
}
let buffer = await res.buffer();
return await processImage(buffer);
}
async function processImage(buffer){
let original = sharp(buffer);
let originalBuffer = await original.toFormat("jpg").toBuffer();
let originalMetadata = await original.metadata();
let thumbnail = await original.resize({ width: THUMBNAIL_IMAGE_SIZE, height: THUMBNAIL_IMAGE_SIZE, fit: 'inside' });
let thumbnailBuffer = await thumbnail.toBuffer();
let thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
return {
original: {
buffer: originalBuffer,
width: originalMetadata.width,
height: originalMetadata.height
},
thumbnail: {
buffer: thumbnailBuffer,
width: thumbnailMetadata.width,
height: thumbnailMetadata.height
}
}
}
// takes the response from downloadImage, creates ADDITIONAL_IMAGE_SIZE and writes all files to disk
async function saveImage(userId, pinId, image){
let originalImagePath = getImagePath(userId, pinId, 'o');
await fs.mkdir(originalImagePath.dir, {recursive: true});
await fs.writeFile(originalImagePath.file, image.original.buffer);
console.log(`saved original to: ${originalImagePath.file}`);
let thumbnailImagePath = getImagePath(userId, pinId, '400');
await fs.mkdir(thumbnailImagePath.dir, {recursive: true});
await fs.writeFile(thumbnailImagePath.file, image.thumbnail.buffer);
console.log(`saved thumbnail to: ${thumbnailImagePath.file}`);
// this will enlarge images if necessary, as the lanczos3 resize algorithm will create better looking enlargements than the browser will
for ( let i = 0; i < ADDITIONAL_IMAGE_SIZES.length; ++i ){
let size = ADDITIONAL_IMAGE_SIZES[i];
let img = await sharp(image.original.buffer);
let resizedImg = await img.resize({width: size, height: size, fit: 'inside'})
let buf = await resizedImg.toBuffer();
let imgPath = getImagePath(userId, pinId, size);
await fs.mkdir(imgPath.dir, {recursive:true});
await fs.writeFile(imgPath.file, buf);
console.log(`saved additional size ${size} to: ${imgPath.file}`);
}
}
async function deleteImagesForPin(userId, pinId){
console.log("deleting images for userId=" + userId + " pinId=" + pinId);
try {
await fs.unlink(getImagePath(userId, pinId, 'o').file);
} catch (err){
console.log("error deleting original: ", err);
}
try {
await fs.unlink(getImagePath(userId, pinId, THUMBNAIL_IMAGE_SIZE).file);
} catch (err){
console.log("error deleting thumbnail: " + err);
}
for ( let s = 0; s < ADDITIONAL_IMAGE_SIZES.length; ++s ){
try{
await fs.unlink(getImagePath(userId, pinId, ADDITIONAL_IMAGE_SIZES[s]).file);
} catch (err){
console.log("error deleting additional size " + ADDITIONAL_IMAGE_SIZES[s] + ": ", err);
}
}
}
function getImagePath(userId, pinId, size){
let paddedId = pinId.toString().padStart(12, '0');
let dir = `${conf.getImagePath()}/${userId}/images/${size}/${paddedId[11]}/${paddedId[10]}/${paddedId[9]}/${paddedId[8]}`;
let file = `${dir}/${paddedId}.jpg`;
return { dir: dir, file: file};
}
module.exports = {
processImage: processImage,
saveImage: saveImage,
getImagePath: getImagePath,
downloadImage: downloadImage,
deleteImagesForPin: deleteImagesForPin
}

370
server/server.js Normal file
View File

@@ -0,0 +1,370 @@
const yargs = require('yargs');
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const cookieParser = require('cookie-parser');
const tokenUtil = require('./token-utils.js');
const dao = require("./dao.js");
const conf = require("./conf.js");
const imageUtils = require('./image-utils.js');
module.exports = async () => {
process.on('SIGINT', () => {
console.info('ctrl+c detected, exiting tinypin');
console.info('goodbye.');
process.exit(0);
});
process.on('SIGTERM', () => {
console.info('sigterm detected, exiting tinypin');
console.info('goodbye.');
process.exit(0);
});
const VERSION = process.env['TINYPIN_VERSION'] ? process.env['TINYPIN_VERSION'].trim() : "none";
const argv = yargs
.option('slow', {
alias: 's',
description: 'delay each request this many milliseconds for testing',
type: 'number'
})
.option('image-path', {
alias: 'i',
description: 'base path to store images',
type: 'string',
default: './images'
})
.option('db-path', {
alias: 'd',
description: 'path to sqlite database file',
type: 'string',
default: 'tinypin.db'
})
.option('port', {
alias: 'p',
description: 'http server port',
type: 'number',
default: 3000
})
.help().alias('help', 'h')
.argv;
const DB_PATH = path.resolve(argv['db-path']);
conf.setImagePath(path.resolve(argv['image-path']));
const PORT = argv.port;
console.log('tinypin starting...');
console.log('');
console.log(`version: ${VERSION}`);
console.log('');
console.log('configuration:');
console.log(` port: ${PORT}`);
console.log(` database path: ${DB_PATH}`);
console.log(` image path: ${conf.getImagePath()}`)
const SLOW = argv.slow || parseInt(process.env.TINYPIN_SLOW);
if ( SLOW ){
console.log(` slow mode delay: ${SLOW}`);
}
console.log('');
await dao.init(DB_PATH);
conf.setTokenKey(Buffer.from(dao.getProperty("cookieKey"), 'hex'));
// express config
const app = express();
const expressWs = require('express-ws')(app);
app.use(bodyParser.raw({type: 'image/jpeg', limit: '25mb'})); // accept image/jpeg files only
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json());
app.set('json spaces', 2);
app.use(cookieParser());
// accept websocket connections. currently are parsing the userid from the path to
// map the connections to only notify on changes from the same user.
// this simple mapping of holding all connections in memory here won't really scale beyond
// one server instance - but that's not really the use case for tinypin.
app.ws('/ws/:uid', (ws, req) => {
ws.on("message", (msg) => {
//console.log("received messsage: " + msg);
});
ws.on("close", () => {
console.log("socket closed for user " + req.params.uid);
});
console.log("socket opened for user " + req.params.uid);
});
function broadcast(uid, msg){
for ( let socket of expressWs.getWss('/ws/' + uid).clients ){
socket.send(JSON.stringify(msg));
}
}
// handle auth
app.use ( require('./auth.js') );
// handle image serving, injecting the user id in the path to segregate users and control cross-user resource access
app.use( require('./image-server.js') );
app.use( express.static('client') );
// emulate slow down
if ( SLOW ){
app.use( (req,res,next) => {
console.log("slow...");
setTimeout(() => {
next();
}, SLOW);
});
}
const OK = {status: "ok"};
const NOT_FOUND = {status: "error", error: "not found"};
const ALREADY_EXISTS = {status: "error", error: "already exists"};
const SERVER_ERROR = {status: "error", error: "server error"};
app.get("/api/whoami", (req, res) => {
res.send({name: req.user.name, version: VERSION, id: req.user.id});
});
// list boards
app.get("/api/boards", async (req, res) => {
try{
let boards = dao.listBoards(req.user.id);
res.send(boards);
} catch (err) {
console.log("Error listing boards: ",err);
res.status(500).send(SERVER_ERROR);
}
});
// get board
app.get("/api/boards/:boardId", async (req, res) => {
try{
let board = dao.getBoard(req.user.id, req.params.boardId);
if ( board ){
res.send(board);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log('Error getting board#${req.params.boardId}:', err);
res.status(500).send(SERVER_ERROR);
}
});
// create board
app.post('/api/boards', (req, res) => {
try{
let board = dao.createBoard(req.user.id, req.body.name, req.body.hidden);
res.send(board);
console.log(`Created board#${id} ${req.body.name}`);
broadcast(req.user.id, {updateBoard:id});
} catch (err){
console.log("Error creating board: " + err.message);
if ( err.message.includes('UNIQUE constraint failed:') ){
res.status(409).send(ALREADY_EXISTS);
} else {
res.status(500).send(SERVER_ERROR);
}
}
});
// update board
app.post("/api/boards/:boardId", (req, res) =>{
try{
let result = dao.updateBoard(req.params.boardId, req.user.id, req.body.name, req.body.hidden);
if ( result ){
broadcast(req.user.id, {updateBoard:req.params.boardId});
res.send(OK);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.log(`Error updating board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// delete board
app.delete("/api/boards/:boardId", async (req, res) => {
try{
let pins = dao.listPins(req.user.id, req.params.boardId);
for ( let i = 0; i < pins.length; ++i ){
await imageUtils.deleteImagesForPin(req.user.id, pins[i].id);
}
let result = dao.deleteBoard(req.user.id, req.params.boardId);
if ( result ){
res.send(OK);
broadcast(req.user.id, {deleteBoard: req.params.boardId});
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error deleting board#${req.params.boardId}:`, err);
res.status(500).send(SERVER_ERROR);
}
});
// get pin
app.get("/api/pins/:pinId", (req, res) => {
try {
let pin = dao.getPin(userId, pinId);
if ( pin ){
res.send(pin);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.error(`Error getting pin#${req.params.pinId}: ${err.message}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// create pin
app.post("/api/pins", async (req, res) => {
try {
let boardId = req.body.boardId;
if ( boardId == "new" ){
try {
let board = dao.createBoard(req.user.id, req.body.newBoardName, 0);
boardId = board.id;
} catch (e){
console.log("error creating new board: ", err);
res.status(500).send(SERVER_ERROR);
}
}
// download the image first to make sure we can get it
let image = await imageUtils.downloadImage(req.body.imageUrl);
let pin = dao.createPin(req.user.id, boardId, req.body.imageUrl, req.body.siteUrl, req.body.description, req.body.sortOrder, image.original.height, image.original.width, image.thumbnail.height, image.thumbnail.width);
await imageUtils.saveImage(req.user.id, pin.id, image);
// return the newly created row
res.send(pin);
broadcast(req.user.id, {updateBoard:boardId});
} catch (err) {
console.log(`Error creating pin: ${err.message}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// update pin
app.post("/api/pins/:pinId", (req,res) => {
try {
let result = dao.updatePin(req.user.id, req.params.pinId, req.body.boardId, req.body.siteUrl, req.body.description, req.body.sortOrder);
if ( result ){
console.log(`updated pin#${req.params.pinId}`)
res.send(OK);
broadcast(req.user.id, {updateBoard:req.body.boardId});
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error updating pin#${req.params.pinId}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// delete pin
app.delete("/api/pins/:pinId", async (req, res) => {
try {
let pin = dao.getPin(req.user.id, req.params.pinId);
if ( !pin ){
res.status(404).send(NOT_FOUND);
}
imageUtils.deleteImagesForPin(req.user.id, req.params.pinId);
let result = dao.deletePin(req.user.id, req.params.pinId);
if ( result ){
console.log(`deleted pin#${req.params.pinId}`);
res.send(OK);
broadcast(req.user.id, {updateBoard:pin.boardId});
} else {
throw("deleted 0 rows");
}
} catch (err){
console.log(`Error deleting pin#${req.params.pinId}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// get a one-time-link for an image
app.post("/api/pins/:pinId/otl", (req,res) => {
let data = {
u: req.user.id,
p: req.params.pinId,
t: new Date().getTime()
};
let token = tokenUtil.encrypt(data);
res.status(200).send({t: token});
});
app.post("/up", async (req, res) => {
try {
// try to parse the image first... if this blows up we'll stop early
let image = await imageUtils.processImage(req.body);
let boardName = req.headers['board-name'].trim();
// get the board
let board = dao.findBoardByUserAndName(req.user.id, boardName);
if ( !board ){
board = dao.createBoard(req.user.id, boardName, 0);
}
let pin = dao.createPin(req.user.id, board.id, null, null, null, null, image.original.height, image.original.width, image.thumbnail.height, image.thumbnailWidth);
await imageUtils.saveImage(req.user.id, pin.id, image);
broadcast(req.user.id, {updateBoard:board.id});
res.status(200).send(pin);
} catch (err){
console.log(`Error uploading pin`, err);
res.status(500).send(SERVER_ERROR);
}
});
// start listening
app.listen(PORT, () => {
console.log(`tinypin is running at http://localhost:${PORT}`);
console.log('');
});
};

39
server/token-utils.js Normal file
View File

@@ -0,0 +1,39 @@
const crypto = require('crypto');
const conf = require('./conf.js');
const encrypt = (obj) => {
let str = JSON.stringify(obj);
let iv = crypto.randomBytes(16);
let cipher = crypto.createCipheriv('aes256', conf.getTokenKey(), iv);
let ciphered = cipher.update(str, 'utf8', 'hex');
ciphered += cipher.final('hex');
return iv.toString('hex') + ':' + ciphered;
}
const decrypt = (ciphertext) => {
let components = ciphertext.split(':');
let iv_from_ciphertext = Buffer.from(components.shift(), 'hex');
let decipher = crypto.createDecipheriv('aes256', conf.getTokenKey(), iv_from_ciphertext);
let deciphered = decipher.update(components.join(':'), 'hex', 'utf8');
deciphered += decipher.final('utf8');
return JSON.parse(deciphered);
}
async function deriveKey(salt, pw){
return new Promise( (resolve, reject) => {
crypto.scrypt(pw, salt, 64, (err, key) => {
resolve(key.toString('hex'));
});
});
}
function createSalt(){
return crypto.randomBytes(16).toString('hex');
}
module.exports = {
encrypt: encrypt,
decrypt: decrypt,
deriveKey: deriveKey,
createSalt: createSalt
}