Browse Source

simple paste

isundil 2 years ago
parent
commit
529c232614

+ 1 - 0
.gitignore

@@ -4,6 +4,7 @@ npm-debug.log*
 
 /config.json
 /memory.sqlite
+/data
 
 # Runtime data
 pids

+ 15 - 3
main.js

@@ -6,23 +6,35 @@ const Router = require('node-simple-router');
 const http = require('http');
 const CONFIG = require('./src/config.js');
 const RouterUtils = require('./src/routerUtils.js').RouterUtils;
+const ClockWatch = require('./src/clockwatch.js').ClockWatch;
 
 function App() {
     this.router = new Router({ static_route: __dirname+"/static/" });
     const _app = this;
     this.routerUtils = new RouterUtils(this);
     this.databaseHelper = require('./src/databaseHelper.js').DatabaseHelper;
+
+    this.dataDir = __dirname+'/data/';
+}
+
+App.prototype.getData = function(privId) {
+    return path.join(this.dataDir, privId);
 }
 
 App.prototype.init = async function() {
-    const _app = this;
+    if (!fs.existsSync(this.dataDir))
+        fs.mkdirSync(this.dataDir);
+
     require('./router/mdi.js').register(this);
 
+    require('./router/input.js').register(this);
+
     await this.databaseHelper.init();
 }
 
-App.prototype.clock = function() {
-    setTimeout(() => this.clock(), 10 *60 *1000); // 10 minutes
+App.prototype.clock = async function() {
+    await ClockWatch.tick(this);
+    setTimeout(async () => await this.clock(), 10 *60 *1000); // 10 minutes
 }
 
 App.prototype.run = function() {

+ 9 - 0
models/DatabaseModel.js

@@ -0,0 +1,9 @@
+
+function DatabaseModel() {
+}
+
+DatabaseModel.prototype.getTableName = function() { return ""; }
+DatabaseModel.prototype.createOrUpdateBase = function(dbHelper) {}
+
+module.exports.DatabaseModel = DatabaseModel;
+

+ 68 - 0
models/pasteContent.js

@@ -0,0 +1,68 @@
+
+const DatabaseModel = require("./DatabaseModel.js").DatabaseModel;
+const mCrypto = require('../src/crypto.js');
+
+function PasteContent(privId, type) {
+    this.created = new Date();
+    this.apiKey = null;
+    this.privId = privId;
+    this.publicId = privId ? mCrypto.publicKey(privId) : null;
+    this.type = type;
+    this.expire = this.created;
+    this.expired = false;
+    this.data = null;
+    this.renew();
+}
+
+Object.setPrototypeOf(PasteContent.prototype, DatabaseModel.prototype);
+
+PasteContent.prototype.renew = function() {
+    if (this.expired)
+        return;
+    this.expire = new Date();
+    this.expire.setMonth(this.expire.getMonth() + 6);
+}
+
+PasteContent.prototype.getTableName = function() {
+    return "pasteContent";
+}
+
+PasteContent.prototype.createOrUpdateBase = async function(dbHelper) {
+    await dbHelper.runSql(`CREATE TABLE IF NOT EXISTS 'pasteContent' (
+        privId string not null PRIMARY KEY,
+        publicId string not null,
+        created datetime not null,
+        expire datetime,
+        type string not null,
+        expired boolean not null,
+        apiKey string,
+        data string
+        )`);
+}
+
+PasteContent.prototype.describe = function() {
+    return {
+        "privId": this.privId,
+        "publicId": this.publicId,
+        "created": this.created.getTime(),
+        "expire": this.expire?.getTime(),
+        "type": this.type,
+        "apiKey": this.apiKey,
+        "expired": this.expired,
+        "data": this.data
+    };
+}
+
+PasteContent.prototype.fromDb = function(dbObj) {
+    this.privId = dbObj['privId'];
+    this.publicId = dbObj['publicId'];
+    this.created = new Date(dbObj['created']);
+    this.expire = dbObj['expire'] ? new Date(dbObj['expire']) : null;
+    this.apiKey = dbObj['apiKey'];
+    this.type = dbObj['type'];
+    this.expired = dbObj['expired'];
+    this.data = dbObj['data'];
+}
+
+module.exports.PasteContent = PasteContent;
+

+ 4 - 1
package.json

@@ -1,7 +1,10 @@
 {
   "dependencies": {
+    "@mdi/font": "^7.1.96",
     "mime-types": "^2.1.35",
     "node-simple-router": "^0.10.2",
-    "sqlite3": "^5.1.4"
+    "request": "^2.88.2",
+    "sqlite3": "^5.1.4",
+    "whiskers": "^0.4.0"
   }
 }

+ 73 - 0
router/input.js

@@ -0,0 +1,73 @@
+
+const whiskers = require('whiskers');
+const fs = require('fs');
+
+const PasteContent = require('../models/pasteContent.js').PasteContent;
+const mCrypto = require('../src/crypto.js');
+const Security = require('../src/security.js');
+
+async function renderRawPage(app, res, entity) {
+    if (entity.type === 'paste')
+        return await app.routerUtils.staticServe(res, app.getData(entity.privId));
+    app.routerUtils.onInternalError(res, "Unknown type: " +entity.type);
+}
+
+async function renderPublicPage(app, res, entity) {
+    if (entity.type === 'paste')
+        return await app.routerUtils.staticServe(res, app.getData(entity.privId));
+    app.routerUtils.onInternalError(res, "Unknown type: " +entity.type);
+}
+
+function renderPrivatePage(app, res, entity) {
+    let stat;
+    try { stat = fs.statSync(app.dataDir+entity.privId); } catch (e) { stat = { error: e }; }
+    app.routerUtils.jsonResponse(res, { ...entity.describe(), ...stat, ...{ path: app.getData(entity.privId) } });
+}
+
+module.exports = { register: app => {
+    app.router.get("/", (req, res) => {
+        app.routerUtils.redirect(res, '/pastit');
+    });
+    app.router.get("/pastit", (req, res) => {
+        let context = app.routerUtils.commonRenderInfos();
+        context.page_title += " - Pastit";
+        res.end(whiskers.render(require('../templates/pastit.js'), context));
+    });
+    app.router.post("/pastit", async (req, res) => {
+        const content = req.body.content;
+        const privId = mCrypto.string(content);
+        const captchaOk = await Security.captchaCheck(req.body['g-recaptcha-response'], req.headers['x-forwarded-for'] || req.socket.remoteAddress);
+        let  entity = await app.databaseHelper.findOne(PasteContent, { privId: privId });
+
+        if (!captchaOk)
+            return app.routerUtils.jsonResponse(res, { err: "Invalid captcha input", id: null });
+        if (!content || !content.length)
+            return app.routerUtils.jsonResponse(res, { err: "Empty input", id: null });
+        if (entity && !entity.expired) {
+            entity.renew();
+            await app.databaseHelper.update({privId: privId}, entity);
+        } else {
+            entity = entity || new PasteContent(privId, "paste");
+            entity.expired = false;
+            entity.renew();
+            fs.writeFileSync(app.getData(privId), content);
+            await app.databaseHelper.upsertOne(entity);
+        }
+        app.routerUtils.jsonResponse(res, { err: null, id: entity.publicId });
+    });
+    app.router.get("/pastit/:id", async (req, res) => {
+        let entity = await app.databaseHelper.findOne(PasteContent, { privId: req.params.id, publicId: req.params.id }, " or ");
+        if (entity && entity.privId === req.params.id)
+            return renderPrivatePage(app, res, entity);
+        if (entity && !entity.expired)
+            return renderPublicPage(app, res, entity);
+        app.routerUtils.onPageNotFound(res);
+    });
+    app.router.get("/pastit/raw/:id", async (req, res) => {
+        let entity = await app.databaseHelper.findOne(PasteContent, { privId: req.params.id, publicId: req.params.id }, " or ");
+        if (entity && !entity.expired)
+            return renderRawPage(app, res, entity);
+        app.routerUtils.onPageNotFound(res);
+    });
+}};
+

+ 22 - 0
src/clockwatch.js

@@ -0,0 +1,22 @@
+
+const fs = require('fs');
+
+const PasteContent = require('../models/pasteContent.js').PasteContent;
+
+function ClockWatch() {}
+
+ClockWatch.tick = async function(app) {
+    const now = Date.now();
+    let prom = [];
+    for (let i of await app.databaseHelper.rawFetch(PasteContent, `expire < ${now} and expired=false`)) {
+        if (i.type === 'paste') {
+            try { fs.unlinkSync(app.getData(i.privId)); } catch (e) { console.error("ClockWatch::tick error on parse ", i.privId, e); continue; }
+        }
+        i.expired = true;
+        prom.push(app.databaseHelper.update({privId: i.privId}, i));
+    }
+    await Promise.all(prom);
+}
+
+module.exports.ClockWatch = ClockWatch;
+

+ 3 - 1
src/config.js

@@ -35,7 +35,9 @@ let configEntries = {};
     let defaultConfig = {
         port: { value: 80, valid: validNumber },
         database: { value: "", valid: validNotEmptyString },
-        sitename: { value: "Archives", valid: validNotEmptyString }
+        sitename: { value: "Archives", valid: validNotEmptyString },
+        reCaptchaPublic: { value: "", valid: validNotEmptyString },
+        reCaptchaSecret: { value: "", valid: validNotEmptyString }
     };
 
     configEntries = pickConfig(defaultConfig, configContent);

+ 25 - 0
src/crypto.js

@@ -0,0 +1,25 @@
+
+const crypto = require('crypto');
+const fs = require('fs');
+
+const HASH = 'sha256';
+const DIGEST = 'base64url';
+
+function cryptoFile(path) {
+    return crypto.createHash(HASH).update(fs.readFileSync(path)).digest(DIGEST);
+}
+
+function cryptoString(input) {
+    return crypto.createHash(HASH).update(input).digest(DIGEST);
+}
+
+function publicKey(priv) {
+    return cryptoString(priv).substr(0, 24);
+}
+
+module.exports = {
+    file: cryptoFile,
+    string: cryptoString,
+    publicKey: publicKey
+}
+

+ 19 - 3
src/databaseHelper.js

@@ -2,6 +2,8 @@
 const sqlite3 = require('sqlite3');
 const CONFIG = require('./config.js');
 
+const PasteContent = require('../models/pasteContent.js').PasteContent;
+
 function DatabaseHelper() {
     this.db = null;
 }
@@ -13,7 +15,7 @@ DatabaseHelper.prototype.init = function() {
                 ko(err);
                 return;
             }
-            let types = [];
+            let types = [ PasteContent ];
             for (let i =0; i < types.length; ++i) {
                 let instance = new types[i]();
                 await instance.createOrUpdateBase(this);
@@ -137,9 +139,23 @@ DatabaseHelper.prototype.fetch = async function(objectPrototype, where, orderBy)
     return resultArr;
 }
 
-DatabaseHelper.prototype.findOne = async function(objectPrototype, where) {
+DatabaseHelper.prototype.rawFetch = async function(objectPrototype, where) {
+    let query = "select * from `" +objectPrototype.prototype.getTableName.call(null) +"` where " +where;
+    let result = await this.runSql(query);
+    let resultArr = [];
+    for (let i of result)
+    {
+        let resultObj = new objectPrototype();
+        resultObj.fromDb(i);
+        resultArr.push(resultObj);
+    }
+    return resultArr;
+}
+
+DatabaseHelper.prototype.findOne = async function(objectPrototype, where, whereSeparator) {
     let whereArgs = this.buildWhere(where);
-    let result = await this.runSql("select * from `" +objectPrototype.prototype.getTableName.call(null) +"` where " +whereArgs.columns.join(" and "), whereArgs.args);
+    whereSeparator = whereSeparator || " and ";
+    let result = await this.runSql("select * from `" +objectPrototype.prototype.getTableName.call(null) +"` where " +whereArgs.columns.join(whereSeparator), whereArgs.args);
     if (result && result.length)
     {
         let resultObj = new objectPrototype();

+ 9 - 12
src/routerUtils.js

@@ -19,6 +19,11 @@ RouterUtils.prototype.redirect = function(res, url) {
     res.end();
 }
 
+RouterUtils.prototype.onInternalError = function(res, error) {
+    res.writeHead(400);
+    res.end(error);
+}
+
 RouterUtils.prototype.apiError = function(res) {
     res.writeHead(400, { "Content-Type": "application/json"});
     res.end();
@@ -40,9 +45,9 @@ RouterUtils.prototype.staticServe = async function(res, filePath) {
         const stream = fs.createReadStream(filePath);
         const fileSize = fs.statSync(filePath)?.size || undefined;
         if (!stream || !fileSize) {
-            console.error("RouterUtils::staticGet", url, err);
+            console.error("RouterUtils::staticGet", filePath);
             this.httpResponse(res, 500, "Internal Server Error");
-            return ko(err);
+            return;
         }
         res.writeHead(200, {
             "Content-Type": mime.contentType(path.basename(filePath)),
@@ -59,19 +64,11 @@ RouterUtils.prototype.staticGet = function(app, url, staticResources) {
     });
 }
 
-RouterUtils.prototype.commonRenderInfos = function(endpointName) {
+RouterUtils.prototype.commonRenderInfos = function() {
     let context = {
         page_title: CONFIG.sitename,
-        endpoints: [],
-        currentEndpoint: endpointName
+        reCaptcha_public: CONFIG.reCaptchaPublic
     };
-    for (let endpoint in CONFIG.endpoints)
-        context.endpoints.push({
-            name: endpoint,
-            address: '/dashboard/'+this.encodeUrlComponent(endpoint),
-            selected: endpoint === endpointName,
-            icon: CONFIG.endpoints[endpoint].icon
-        });
     return context;
 }
 

+ 26 - 0
src/security.js

@@ -0,0 +1,26 @@
+
+const request = require('request');
+
+const CONFIG = require('./config.js');
+
+module.exports.captchaCheck = function(captcha, remoteIp) {
+    return new Promise((ok, ko) => {
+        request(`https://www.google.com/recaptcha/api/siteverify?secret=${CONFIG.reCaptchaSecret}&response=${captcha}&remoteip=${remoteIp}`,
+        (err, response, body) => {
+            if (err) {
+                console.error("Security::captchaCheck", err);
+                ok(false);
+            }
+            try {
+                body = JSON.parse(body);
+            } catch (e) {
+                return ko();
+            }
+            console.log(body);
+            if (body.success)
+                return ok(true);
+            ok(false);
+        });
+    });
+}
+

+ 70 - 0
static/public/css/style.css

@@ -0,0 +1,70 @@
+
+/**
+ * menu bg: #dedede
+**/
+
+body {
+    margin: 0;
+    padding: 0;
+}
+
+body > menu {
+    display: block;
+    background: #dedede;
+    margin: 0;
+}
+
+body > menu ul {
+    list-style-type: none;
+    margin: 0;
+}
+
+body > menu li {
+    display: inline-block;
+    margin: 0.5em 0.75em;
+}
+
+body > menu a {
+    display: inline-block;
+    border: 1px solid black;
+    margin: 0;
+    padding: 0.5em 0.75em;
+    color: black;
+}
+
+body > menu a:hover {
+    border-color: grey;
+    color: grey;
+}
+
+body > .page {
+    width: calc(100vw - 200px);
+    margin: auto;
+    padding-top: 5em;
+}
+
+@media screen and (max-width: 800px) {
+    body > .page {
+        width: 100%;
+        margin: 0;
+    }
+}
+
+.page textarea {
+    width: 100%;
+    max-width: 100%;
+    min-height: 35em;
+    display: block;
+}
+
+#error {
+    background: #ff4646;
+    border: 2px solid #810000;
+    margin: 1em 0;
+    padding: 0.33em;
+}
+
+#error.hidden {
+    display: none;
+}
+

File diff suppressed because it is too large
+ 1 - 0
static/public/js/jquery-3.6.3.min.js


+ 2 - 0
templates/footer.js

@@ -0,0 +1,2 @@
+
+module.exports = `</div></body></html>`;

+ 14 - 0
templates/header.js

@@ -0,0 +1,14 @@
+
+module.exports = `
+<!DOCTYPE html5>
+<html>
+<head>
+<link type="text/css" rel="stylesheet" href="/public/mdi/materialdesignicons.min.css"/><link type="text/css" rel="stylesheet" href="/public/css/style.css"/>
+<title>{page_title}</title>
+<script src="https://www.google.com/recaptcha/api.js"></script>
+<script src="/public/js/jquery-3.6.3.min.js"></script>
+</head>
+<body>
+<menu><ul><li><a href="/pastit">Paste bin</a></li><li><a href="/files">File Upload</a></li><li><a href="/api">Paste API</a></li><li><a href="/short">URL Shortener</a></li></ul></menu>
+<div class='page'>
+`;

+ 20 - 0
templates/pastit.js

@@ -0,0 +1,20 @@
+
+module.exports = require('./header.js') +`
+<form id="form" action="#" method="POST">
+<textarea id="pasteContent" name="content" required></textarea>
+<div id="error" class="hidden"></div>
+<button class="g-recaptcha" data-sitekey="{reCaptcha_public}" data-callback="onSubmit" data-action="submit">Submit</button>
+</form>
+<script>
+function onSubmit(token) {
+    $.post(document.location.href, $("#form").serialize(), data => {
+        if (data.err) {
+            $("#error").text("Error: " +data.err).removeClass("hidden");
+        } else if (data.id) {
+            $("#error").text("").addClass("hidden");
+            document.location.href = "/pastit/" +data.id;
+        }
+    });
+}
+</script>
+` + require('./footer.js');

Some files were not shown because too many files changed in this diff