initial commit (working MVP)
This commit is contained in:
commit
c7a3b2897b
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["env"]
|
||||
, "plugins": ["transform-object-rest-spread"]
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
.env
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 })
|
|
@ -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')
|
||||
}
|
|
@ -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) }
|
||||
|
|
@ -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)
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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)
|
|
@ -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))
|
||||
})
|
|
@ -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)
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
[ -f .env ] && source .env
|
||||
babel-node app.js
|
|
@ -0,0 +1 @@
|
|||
style.css
|
|
@ -0,0 +1 @@
|
|||
../node_modules/bootswatch/dist
|
Binary file not shown.
After Width: | Height: | Size: 673 B |
|
@ -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
|
|
@ -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].
|
|
@ -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() }.
|
|
@ -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]
|
||||
= ' '
|
|
@ -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.
|
|
@ -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
|
|
@ -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}
|
Loading…
Reference in New Issue