refactored server to break out into node modules
@@ -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
|
|
||||||
|
|
||||||
@@ -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 ) {
|
||||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 819 B After Width: | Height: | Size: 819 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 537 B After Width: | Height: | Size: 537 B |
|
Before Width: | Height: | Size: 711 B After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
0
static/reef.min.js → client/reef.min.js
vendored
3
main.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const server = require("./server/server.js");
|
||||||
|
|
||||||
|
server();
|
||||||
930
server.js
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||