initial commit (working MVP)

This commit is contained in:
Nadav Ivgi 2017-12-17 13:15:55 +02:00
commit c7a3b2897b
29 changed files with 3705 additions and 0 deletions

4
.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["env"]
, "plugins": ["transform-object-rest-spread"]
}

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
.env

17
LICENSE Normal file
View File

@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

130
README.md Normal file
View File

@ -0,0 +1,130 @@
# FileBazaar
Sell digital files with Lightning!
- Simple setup and minimal configuration, just put a bunch of files in a directory and run the server.
- Lightweight web browsing interface, works without JavaScript.
- Generates previews for images, videos, audio, pdf and text documents.
## Quickstart
Setup [Lightning Kite](https://github.com/ElementsProject/lightning-strike), then:
```bash
# Install dependencies for EXIF extraction and preview generation
$ apt-get install exiftool ffmpeg graphicsmagick unoconv
# node-canvas dependencies, see https://github.com/Automattic/node-canvas#installation
$ apt-get install libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++
# Install filebazaar
$ npm install -g filebazaar
# Prepare a directory with the files you wish to sell and cd to it
$ mkdir ~/ForSale && cd ~/ForSale
# Initialize the `_filebazaar.yaml` config file
$ filebazaar init
# Start filebazaar!
$ filebazaar
```
## Configuration
FileBazaar's configuration options can be managed using the `_filebazaar.yaml` file or via envirnoment variables. All config options are optional and have sane defaults, except for `token_secret` which is required. See [`lib/config.js`](#) for more details.
Below is an example `_filebazaar.yaml` file:
```yaml
---
### Server settings
port: 9678
host: 127.0.0.1
env: production
url: http://my-public-url.com/
### Lightning Kite
kite_url: http://localhost:9112
kite_token: API_TOKEN_CONFIGURED_IN_KITE
### FileBazaar settings
# The directory containing the files for sale
# defaults to the directory containing the _filebazaar.yaml file
directory: /home/shesek/ForSale
# The default file price, can be overridden for individual files (see below)
default_price: 0.25 USD
# Expiry times
invoice_expiry: 3600 # lock-in exchange rate for 1 hour
download_expiry: 172800 # make download available for 2 days after payment
# Secret for generating HMAC access tokens (required)
token_secret: SOME_LONG_RANDOM_STRING
# Directory to keep cached preview files
# defaults to `{directory}/_filebazaar_cache`
cache_path: /path/to/filebazaar_cache
### Looks & feel
# See available themes on https://bootswatch.com
theme: yeti
# Add custom CSS
css: |
body { background: blue }
a { color: orange }
# Set custom views directory
views_dir: /path/to/custom/views
# Set custom static files directory
static_dir: /path/to/custom/static
### Files settings
files:
Books/Mastering-Bitcoin.pdf:
price: 5 USD
button: Buy this book
desc: >
Mastering Bitcoin is essential reading for everyone interested in learning about bitcoin.
This field **supports markdown** and will show up on the file's page.
# if you're only interested in setting the price, you can use:
Books/Mastering-Bitcoin.pdf: 5 USD
# if you want to configure multiple files inside the same directory, you can nest them:
Media/: # (note the trailing slash)
Books/Andreas/:
Mastering-Bitcoin.pdf: 5 USD # /Media/Books/Andreas/Mastering-Bitcoin.pdf
The-Internet-of-Money.pdf: 4 USD # /Media/Books/Andreas/The-Internet-of-Money.pdf
```
## CLI
You can use `$ filebazaar init [directory]` to initialize a new `_filebazaar.yaml` config file. A random `token_secret` will be added for you. If no `[directory]` is specified, the file will be created in the working directory.
To start FileBazaar, run `$ filebazaar [path]`. You can either specify the path to the files directory or to the `_filebazaar.yaml` file. If none is specified, defaults to the working directory.
## File Preview
FileBazaar can currently generate previews for the following file types:
- Images: a preview image will be generated by pixelating the left-half and adding watermark text using [node-canvas](https://github.com/Automattic/node-canvas) and [graphicsmagick](http://www.graphicsmagick.org) (see [example image](https://i.imgur.com/OmrUysL.png)).
- Videos & audio: a preview will be generated by slicing off the first 30 seconds using [ffmpeg](http://ffmpeg.org).
- Documents: a preview image of the first page of the document will be generated using [unoconv](https://github.com/dagwieers/unoconv) (supports pdf, doc, docx, odt, and many others).
In addition, EXIF metadata will be extracted using [exiftool](https://www.sno.phy.queensu.ca/~phil/exiftool/) and displayed for all file types.

59
_filebazaar.yaml.example Normal file
View File

@ -0,0 +1,59 @@
---
### Server settings
env: production
#port: 9678
#host: 127.0.0.1
#url: http://my-public-url.com/
### Lightning Kite
#kite_url: http://localhost:9112
#kite_token: TOKEN_GOES_HERE
### FileBazaar settings
# The directory containing the files for sale
# defaults to the directory containing this file
# directory: /path/to/files
# The default file price, can be overridden for individual files (see below)
#default_price: 0.25 USD
# Expiry times
#invoice_expiry: 3600 # lock-in exchange rate for 1 hour
#download_expiry: 172800 # make download available for 2 days after payment
# Secret for generating HMAC access tokens (required)
token_secret: $TOKEN_SECRET
# Directory to keep cached preview files
# defaults to `{directory}/_filebazaar_cache`
#cache_path: /path/to/filebazaar_cache
### Looks & feel
# See available themes on https://bootswatch.com
#theme: yeti
# Add custom CSS
#css: |
# body { background: blue }
# a { color: orange }
# Set custom views directory
#views_dir: /path/to/custom/views
# Set custom static files directory
#static_dir: /path/to/custom/static
### Files settings
#files:
# Books/
# Mastering-Bitcoin.pdf:
# price: 5 USD
# button: Buy this book

96
app.js Normal file
View File

@ -0,0 +1,96 @@
import { pwrap, pick, fcurrency, fmsat, pngPixel } from './lib/util'
// Setup
const app = require('express')()
, conf = require('./lib/config')(process.argv[2] || process.cwd())
, kite = require('lightning-strike-client')(conf.kite_url, conf.kite_token)
, files = require('./lib/files')(conf.directory, conf.default_price, conf.invoice_expiry, conf.files)
, tokenr = require('./lib/token')(conf.token_secret)
, preview = require('./lib/preview')(files, conf.cache_path)
// Express settings
app.set('trust proxy', conf.proxied)
app.set('env', conf.env)
app.set('view engine', 'pug')
app.set('views', conf.views_dir)
app.enable('json escape')
app.enable('strict routing')
app.enable('case sensitive routing')
// View locals
Object.assign(app.locals, {
conf, fmsat, fcurrency
, prettybytes: require('pretty-bytes')
, markdown: require('markdown-it')()
, qruri: require('qruri')
, version: require('./package').version
, pretty: (conf.env === 'development')
})
// Middlewares
app.get('/favicon.ico', (req, res) => res.sendStatus(204)) // to prevent logging
app.use(require('morgan')('dev'))
app.use(require('body-parser').json())
app.use(require('body-parser').urlencoded({ extended: false }))
// Static assets
app.use('/_assets', require('stylus').middleware({ src: conf.static_dir, serve: true }))
app.use('/_assets', require('express').static(conf.static_dir))
// Create invoice
app.post('/_invoice', pwrap(async (req, res) => {
const file = await files.load(req.body.file)
if (file.type !== 'file') return res.sendStatus(405)
const invoice = await kite.invoice(files.invoice(file))
res.status(201).format({
html: _ => res.redirect(file.urlpath + '?invoice=' + invoice.id)
, json: _ => res.send(pick(invoice, 'id', 'msatoshi', 'quoted_currency', 'quoted_amount', 'payreq'))
})
}))
// Payment updates long-polling via <img> hack
app.get('/_invoice/:invoice/longpoll.png', pwrap(async (req, res) => {
const paid = await kite.wait(req.params.invoice, 100)
if (paid) res.set('Content-Type', 'image/png').send(pngPixel)
else res.sendStatus(402)
// @TODO close kite request on client disconnect
}))
// File browser
app.get('/:rpath(*)', pwrap(async (req, res) => {
const file = await files.load(req.params.rpath)
if (file.type == 'dir') return res.render('dir', file)
const invoice = req.query.invoice && await kite.fetch(req.query.invoice)
, access = req.query.token && tokenr.parse(file.path, req.query.token)
if (access) {
if ('download' in req.query) res.set('Content-Type', file.mime).download(file.fullpath)
else if ('view' in req.query) res.set('Content-Type', file.mime).sendFile(file.fullpath)
else res.render('file', { ...file, access })
}
else if (invoice) {
if (invoice.completed) res.redirect(escape(file.name) + '?token=' + tokenr.make(invoice, conf.access_expiry))
else res.render('file', { ...file, invoice })
}
else if ('preview' in req.query) await preview.handler(file, res)
else res.render('file', { ...file, preview: await preview.metadata(file) })
}))
// Normalize errors to HTTP status codes
app.use((err, req, res, next) =>
err.syscall === 'stat' && err.code == 'ENOENT' ? res.sendStatus(404)
: err.message === 'forbidden' ? res.sendStatus(403)
: next(err))
// Go!
app.listen(conf.port, conf.host, _ => console.log('FileBazaar serving %s on port %d, browse at %s', conf.directory, conf.port, conf.url))
// strict handling for uncaught promise rejections
process.on('unhandledRejection', err => { throw err })

23
cli.js Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env node
const path = require('path')
, crypto = require('crypto')
, fs = require('fs')
require('babel-polyfill')
require('babel-register')
const templatePath = path.join(__dirname, '_filebazaar.yaml.example')
if (process.argv[2] === 'init') {
const directory = process.argv[3] || process.cwd()
, configPath = path.join(directory, '_filebazaar.yaml')
if (fs.existsSync(configPath)) throw new Error(`${configPath} already exists`)
fs.writeFileSync(configPath, fs.readFileSync(templatePath).toString()
.replace('$TOKEN_SECRET', crypto.randomBytes(32).toString('hex')))
console.log('FileBazaar config written to %s', configPath)
} else {
require('./app')
}

60
lib/config.js Normal file
View File

@ -0,0 +1,60 @@
import path from 'path'
import assert from 'assert'
import fs from 'fs-extra'
import yaml from 'js-yaml'
// Initialize config from `configPath`,
// can be either the base directory or the full path to _filebazaar.yaml
module.exports = basePath => {
const configPath = fs.statSync(basePath).isFile() ? basePath : path.join(basePath, '_filebazaar.yaml')
, config = fs.existsSync(configPath) ? yaml.safeLoad(fs.readFileSync(configPath)) : {}
config.env = config.env || process.env.NODE_ENV || 'development'
config.host = config.host || process.env.HOST || 'localhost'
config.port = config.port || process.env.PORT || 9678
config.url = config.url || process.env.URL || `http://${config.host}:${config.port}/`
config.proxied = config.proxied || process.env.PROXIED || false
config.directory = config.directory || process.env.BASE_DIR || path.dirname(configPath)
config.token_secret = config.token_secret || process.env.TOKEN_SECRET || assert(false, 'token_secret is required')
config.cache_path = config.cache_path || process.env.CACHE_PATH || path.join(config.directory, '_filebazaar_cache')
config.kite_url = config.kite_url || process.env.KITE_URL || 'http://localhost:9112'
config.kite_token = config.kite_token || process.env.KITE_TOKEN
config.invoice_expiry = config.invoice_expiry || +process.env.INVOICE_EXP || 3600 // 1 hour
config.access_expiry = config.access_expiry || +process.env.ACCESS_EXP || 172800 // 2 days
config.views_dir = config.views_dir || process.env.VIEWS_DIR || path.join(__dirname, '..', 'views')
config.static_dir = config.static_dir || process.env.STATIC_DIR || path.join(__dirname, '..', 'static')
config.theme = config.theme || process.env.theme || 'yeti'
config.css = config.css || process.env.CSS
config.files = parseFiles(config.files || (process.env.FILES_JSON ? JSON.parse(process.env.FILES_JSON) : {}))
config.default_price = parsePrice(config.default_price || process.env.DEFAULT_PRICE || '0.25 USD')
fs.ensureDirSync(config.cache_path)
return config
}
const parsePrice = str => {
const m = str.match(/^([\d.]+) ([a-z]+)$/i)
if (!m) throw new Error(`invalid price: ${ str }`)
return { amount: m[1], currency: m[2] }
}
const parseFiles = (files, prefix='') =>
Object.keys(files)
.reduce((o, name) =>
(name[name.length-1] === '/'
? files[name] && Object.assign(o, parseFiles(files[name], prefix + name))
: o[prefix+name] = parseFile(files[name])
, o)
, {})
const parseFile = file =>
typeof file === 'string'
? { price: parsePrice(file) }
: { ...file, price: file.price && parsePrice(file.price) }

16
lib/exif.js Normal file
View File

@ -0,0 +1,16 @@
import memoize from 'lru-memoize'
import { exec } from './util'
const importantExif = [ 'Title', 'Artist', 'Band', 'Album', 'Year', 'Genre', 'Track'
, 'Megapixels', 'ImageSize', 'Duration', 'VideoFrameRate', 'AudioBitrate'
, 'PageCount' ]
module.exports = memoize(path =>
exec('exiftool', '-j', path)
.then(r => JSON.parse(r.stdout)[0])
.then(exif => Object.keys(exif)
.filter(k => !/^(File|SourceFile|Directory|ExifTool|MIMEType|Picture$)/.test(k) && exif[k])
.sort((a, b) => importantExif.includes(a) ? -1 : 1)
.reduce((o, k) => (o[k]=exif[k], o), {}))
.catch(_ => null) // drop errors, just return null
, 100)

68
lib/files.js Normal file
View File

@ -0,0 +1,68 @@
import crypto from 'crypto'
import upath from 'path'
import fs from 'fs-extra'
import memoize from 'lru-memoize'
import prettyb from 'pretty-bytes'
import fileExt from 'file-extension'
import fileType from 'file-type'
import mimeTypes from 'mime-types'
import readChunk from 'read-chunk'
import getExif from './exif'
const reIgnore = /^[._]/
, forbiddenErr = new Error('forbidden')
module.exports = (base, default_price, invoice_expiry, files_attr) => {
const load = async (_path, basic=false) => {
const fullpath = upath.resolve(base, _path)
, relpath = upath.relative(base, fullpath)
, dirname = upath.dirname(relpath)
, name = upath.basename(relpath)
, ext = fileExt(name)
, attr = files_attr[relpath] || {}
if (/^\.?\//.test(relpath) || reIgnore.test(name)) throw new Error('forbidden')
const file = { fullpath, path: relpath, urlpath: escape(relpath), name, ext, dirname, attr }
, stat = await fs.stat(fullpath)
return stat.isDirectory() ? { ...file, type: 'dir', files: !basic && await listFiles(fullpath) }
: stat.isFile() ? { ...file, type: 'file', size: stat.size, price: attr.price || default_price
, mime: await getMime(file), exif: !basic && await getExif(fullpath) }
: null
}
const listFiles = async path =>
(await Promise.all((await fs.readdir(path))
.filter(name => !reIgnore.test(name))
.map(name => load(upath.join(path, name), true))))
.filter(file => !!file)
.sort((a, b) => a.type == 'dir' ? -1 : 1)
const getMime = async ({ fullpath, ext }) =>
ext && mimeTypes.lookup(ext) || await detectType(fullpath)
const detectType = memoize(async path => {
const type = fileType(await readChunk(path, 0, 4100))
return type && type.mime
}, 100)
const getHash = memoize(path => new Promise((resolve, reject) =>
fs.createReadStream(path).pipe(crypto.createHash('sha256').setEncoding('hex'))
.on('finish', function() { resolve(this.read()) })
.on('error', reject)
), 100)
const invoice = file => ({
description: `Pay to access ${ file.path }, size=${ prettyb(file.size) }, mime=${ file.mime||'-' }` //, sha256=${ await getHash(file.full.path) }`
, currency: file.price.currency
, amount: file.price.amount
, expiry: invoice_expiry
, metadata: { path: file.path }
})
return { load, invoice }
}

32
lib/preview.js Normal file
View File

@ -0,0 +1,32 @@
import crypto from 'crypto'
import path from 'path'
import fs from 'fs-extra'
const engines = [ require('./preview/image'), require('./preview/ffmpeg'), require('./preview/unoconv') ]
const cache_name = file => crypto.createHash('sha1').update(file.fullpath).digest('hex')
module.exports = (files, cache_path) => {
const metadata = async file =>
Object.assign({}, ...await Promise.all(
engines.map(p => p.metadata && p.detect(file) && p.metadata(file) || {})))
const preview = async file => {
const p = engines.find(p => p.preview && p.detect(file))
if (p) {
const dest = path.join(cache_path, cache_name(file)) + '.' + p.ext(file)
if (!await fs.pathExists(dest)) await p.preview(file.fullpath, dest)
return dest
}
}
const handler = async (file, res) => {
const { fullpath } = file, preview_path = await (_gen[fullpath] = _gen[fullpath] || preview(file))
delete _gen[fullpath] // @TODO in finally
preview_path ? res.sendFile(preview_path) : res.sendStatus(405)
}
const _gen = {}
return { handler, metadata }
}

10
lib/preview/ffmpeg.js Normal file
View File

@ -0,0 +1,10 @@
import { exec } from '../util'
exports.detect = ({ mime }) => /^(video|audio)\//.test(mime)
exports.ext = file => ~file.mime.indexOf('video/') ? 'mp4' : 'mp3'
exports.metadata = file => ({ media_type: file.mime.split('/')[0] })
exports.preview = (src, dest) =>
exec('ffmpeg', '-t', 30, '-i', src, '-acodec', 'copy', '-vcodec', 'copy', dest)

53
lib/preview/image.js Normal file
View File

@ -0,0 +1,53 @@
import fs from 'fs-extra'
import gm from 'gm'
import Canvas from 'canvas'
exports.detect = ({ mime }) => /^image\//.test(mime)
exports.metadata = file => ({ media_type: 'image' })
exports.ext = file => 'png'
exports.preview = async (src, dest) => fs.writeFile(dest, await pixelate(src))
const pixelate = async path => {
// load original image
const img = new Canvas.Image
, buff = img.src = await fs.readFile(path)
// make canvas for preview image
const canvas = new Canvas(img.width, img.height)
, ctx = canvas.getContext('2d')
ctx.imageSmoothingEnabled = false
ctx.patternQuality = 'fast'
// resize left-half to 0.1x, then re-enlarge
const scaledImg = new Canvas.Image
scaledImg.src = await toBuff(gm(buff).crop(img.width/2, img.height, 0, 0).resize(Math.min(img.width*0.1, 80)), 'PNG')
ctx.drawImage(img, 0, 0, img.width, img.height)
ctx.drawImage(scaledImg, 0, 0, img.width/2, img.height)
// add red separator line
ctx.beginPath()
ctx.lineWidth = (img.height*0.002)
ctx.strokeStyle = 'red'
ctx.moveTo(img.width/2-ctx.lineWidth/2, 0)
ctx.lineTo(img.width/2-ctx.lineWidth/2, img.height)
ctx.stroke()
// add PREVIEW text
ctx.font = '' + (img.width*0.1) + 'px arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.strokeStyle = 'black'
ctx.fillStyle = 'white'
ctx.fillText('PREVIEW', img.width/2, img.height/2)
ctx.strokeText('PREVIEW', img.width/2, img.height/2)
return toBuff(canvas)
}
const toBuff = (obj, ...a) => new Promise((resolve, reject) => {
obj.toBuffer(...a, (err, res) => err ? reject(err) : resolve(res))
})

11
lib/preview/unoconv.js Normal file
View File

@ -0,0 +1,11 @@
import { exec } from '../util'
const supported = [ 'pdf', 'doc', 'docx', 'odt', 'odt', 'bib', 'rtf', 'latex', 'csv', 'xls', 'xlsx', 'ods' ]
exports.detect = ({ ext }) => supported.includes(ext)
exports.ext = file => 'png'
exports.metadata = file => ({ media_type: 'doc' })
exports.preview = (src, dest) => exec('unoconv', '-o', dest, src)

29
lib/token.js Normal file
View File

@ -0,0 +1,29 @@
import { createHmac } from 'crypto'
import assert from 'assert'
module.exports = tokenSecret => {
assert(tokenSecret, 'TOKEN_SECRET is required')
const hmac = (invoice_id, path, expiry) =>
createHmac('sha256', tokenSecret)
.update([ invoice_id, path, expiry ].join('.'))
.digest().toString('base64').replace(/\W+/g, '')
const make = (invoice, ttl) => {
const expiry = invoice.completed_at + ttl
, hash = hmac(invoice.id, invoice.metadata.path, expiry)
return [ invoice.id, expiry.toString(36), hash ].join('.')
}
const parse = (path, token) => {
const [ invoice_id, expiry_, hash ] = token.split('.')
, expiry = parseInt(expiry_, 36)
return hmac(invoice_id, path, expiry) === hash
&& expiry > Date.now()/1000
&& { token, path, invoice_id, expiry }
}
return { make, parse }
}

26
lib/util.js Normal file
View File

@ -0,0 +1,26 @@
import { execFile } from 'child_process'
import moveDec from 'move-decimal-point'
import CurrencyF from 'currency-formatter'
// Promise wrapper for express handler functions
const pwrap = fn => (req, res, next, ...a) =>
fn(req, res, next, ...a).catch(next)
// Promise wrapper for execFile
const exec = (cmd, ...args) => new Promise((resolve, reject) =>
execFile(cmd, args, (err, stdout, stderr) =>
err ? reject(err) : resolve({ stderr, stdout })))
// Pick specified object properties
const pick = (O, ...K) => K.reduce((o, k) => (o[k]=O[k], o), {})
// Format milli-satoshis as milli-bitcoins
const fmsat = msat => moveDec(msat, -8)
// Format price with currency symbol
const fcurrency = p => CurrencyF.format(p.amount, { code: p.currency.toUpperCase() })
// Empty 1x1 PNG pixel
const pngPixel = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64')
module.exports = { pwrap, exec, pick, fmsat, fcurrency, pngPixel }

2781
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "filebazaar",
"version": "0.1.0",
"description": "",
"scripts": {
"start": "./start.sh"
},
"bin": "cli.js",
"author": "Nadav Ivgi",
"license": "MIT",
"dependencies": {
"babel-polyfill": "^6.26.0",
"bootswatch": "^4.0.0-beta.2",
"commander": "^2.12.2",
"currency-formatter": "^1.3.1",
"express": "^4.16.2",
"file-extension": "^4.0.1",
"file-type": "^7.4.0",
"fs-extra": "^5.0.0",
"gm": "^1.23.0",
"js-yaml": "^3.10.0",
"jstransformer-markdown-it": "^2.0.0",
"lightning-strike-client": "github:ElementsProject/lightning-strike-client-js",
"lru-memoize": "github:neilk/lru-memoize",
"markdown-it": "^8.4.0",
"mime-types": "^2.1.17",
"morgan": "^1.9.0",
"move-decimal-point": "0.0.4",
"pretty-bytes": "^4.0.2",
"pug": "^2.0.0-rc.4",
"qruri": "0.0.3",
"read-chunk": "^2.1.0",
"stylus": "^0.54.5"
},
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-plugin-syntax-object-rest-spread": "^6.13.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-register": "^6.26.0"
}
}

3
start.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
[ -f .env ] && source .env
babel-node app.js

1
static/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
style.css

1
static/bootswatch Symbolic link
View File

@ -0,0 +1 @@
../node_modules/bootswatch/dist

BIN
static/loader.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

70
static/style.styl Normal file
View File

@ -0,0 +1,70 @@
body
padding-top 50px
header
h3
color #ccc
table
font-size 16px
thead th
border-top 0 !important
footer
padding-bottom 16px
text-align right
pre
display block
padding 0.5em
margin 1em 0
font-size 0.9em
word-break break-all
white-space normal
background #f5f5f5
color #999
border 1px solid #ccc
.pay-btn
font-weight 400
cursor pointer
margin-bottom 1em
.exif
word-break break-word
.checkout
max-width 750px
font-size 15px
@media (max-width:575px)
text-align center
h4
font-size 18px
color #777
margin-bottom 0
pre
display block
padding 0.5em
margin 1em 0
font-size 0.9em
word-break break-all
white-space normal
background #f5f5f5
color #999
border 1px solid #ccc
@media (min-width:576px) and (max-width:767px)
font-size 0.7em
.qr
img
max-width 100%
@media (max-width:575px)
margin-top 1rem
.btn-primary
margin-bottom 1rem

13
views/_access.pug Normal file
View File

@ -0,0 +1,13 @@
- token_url = urlpath + "?token=" + access.token
- down_url = token_url + '&download'
- view_url = token_url + '&view'
p Thank you for paying. You can access the purchased file below:
p
a.btn.btn-primary(href=down_url) Download
= ' '
a.btn.btn-secondary(href=view_url) View
p.text-muted Your access will expire #{ new Date(access.expiry*1000).toUTCString() }.
p.text-muted Until then, you can return to this page to access the file #[a(href=token_url) using this link].

21
views/_checkout.pug Normal file
View File

@ -0,0 +1,21 @@
.checkout
h3 Pay with Lightning
h4 #[strong= invoice.quoted_amount] #{invoice.quoted_currency } = #[strong= fmsat(invoice.msatoshi)] mBTC
.row
.pay.col-sm-8
pre= invoice.payreq
p
a.btn.btn-lg.btn-primary(href=`lightning:${invoice.payreq}`) Pay now
.qr.col-sm-4: img(src=qruri('lightning:'+invoice.payreq, { mode: 'alphanumeric', margin: 0 }))
noscript
p Your browser has JavaScript turned off. Please refresh the page manually after making the payment.
.yesscript
p #[img(src='_assets/loader.gif', alt='loading')] Awaiting payment... #[span.text-muted (the page will be updated automatically)]
//- long-pull payment updates via <img> reloading hack. not the prettiest, but extremely terse, and it works
img.d-none(src='_invoice/'+invoice.id+'/longpoll.png', onerror='this.src=this.src', onload='location.reload()')
p.expiry.text-muted Invoice valid until #{ new Date(invoice.expires_at*1000).toUTCString() }.

41
views/_preview.pug Normal file
View File

@ -0,0 +1,41 @@
.row
.col-md-8.mb-3
if attr.desc
!= markdown.render(attr.desc, { linkify: true, typographer: true })
hr
h3 Purchase
//-p This file is available for #{ price.amount } #{ price.currency }.
form(method='post', action='_invoice')
input(type='hidden', name='file', value=path)
button.btn.btn-primary.btn-lg.pay-btn(type='submit')= attr.buy_button || `Pay ${fcurrency(price)} to access`
h3 File preview
if preview && preview.media_type
- preview_url = urlpath + '?preview'
if preview.media_type == 'image'
p A half-pixelated preview image is available below:
a(href=preview_url): img.mw-100(src=preview_url)
if preview.media_type == 'video'
p A preview video of the first 30 seconds is available below:
video.mw-100(src=preview_url, controls)
if preview.media_type == 'audio'
p A preview audio of the first 30 seconds is available below:
audio.mw-100(src=preview_url, controls)
if preview.media_type == 'doc'
p The first page from the document is available as a preview below:
a(href=preview_url): img.mw-100(src=preview_url)
else
p.text-muted No preview available.
.col-md-4
if exif && Object.keys(exif).length
h3 Exif metadata
p.exif: each val, key in exif
| #{key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')}: #[span.text-muted= val]
= ' '

30
views/dir.pug Normal file
View File

@ -0,0 +1,30 @@
extend layout
append vars
if path != ''
- title += ': ' + path
block header
- url_ancs = urlpath.split('/')
h3
a(href='.') ~
for d, i in path.split('/')
= ' / '
a(href=url_ancs.slice(0, i+1).join('/'))= d
block content
if files.length
table.table.table-striped
thead: tr
th Name
th Type
th Size
th Price
tbody: for file in files
tr
td: a(href=file.urlpath)= file.name
td= file.type == 'dir' ? 'directory' : file.mime || '-'
td= file.size ? prettybytes(file.size) : '-'
td= file.price ? fcurrency(file.price) : '-'
else
p.text-muted This directory is empty.

39
views/file.pug Normal file
View File

@ -0,0 +1,39 @@
extend layout
append vars
- title += ': ' + path
append head
if attr.css
style!= attr.css
block header
h3
a(href='.') ~
= ' / '
- url_ancs = urlpath.split('/').slice(0, -1)
for d, i in path.split('/').slice(0, -1)
a(href=url_ancs.slice(0, i+1).join('/'))= d
= ' / '
h1= name
h4
span.badge.badge-light= mime
= ' '
span.badge.badge-info= prettybytes(size)
= ' '
if exif && (exif.Duration || exif.ImageSize || exif.PageCount)
span.badge.badge-warning= (exif.Duration || exif.ImageSize || exif.PageCount+ ' pages').replace(/\(.*/, '')
= ' '
span.badge.badge-success= fcurrency(price)
block content
if access
include _access
else if invoice
include _checkout
else
include _preview

27
views/layout.pug Normal file
View File

@ -0,0 +1,27 @@
doctype html
block vars
- title = conf.sitename || 'filebazaar'
block head
meta(chartset='utf-8')
meta(name='viewport', content='width=device-width, initial-scale=1')
base(href=conf.url)
title= title
link(rel='stylesheet', href='_assets/bootswatch/'+conf.theme+'/bootstrap.min.css')
link(rel='stylesheet', href='_assets/style.css')
noscript: style .yesscript{display:none}
if conf.css
style= conf.css
.container
header: block header
hr
block content
hr
footer filebazaar@#{version}