454 changed files with 95782 additions and 1 deletions
@ -1,3 +1,12 @@ |
|||
# https://git-scm.com/docs/gitignore |
|||
# https://help.github.com/articles/ignoring-files |
|||
# Example .gitignore files: https://github.com/github/gitignore |
|||
# Example .gitignore files: https://github.com/github/gitignore |
|||
/storage/ |
|||
/node_modules/ |
|||
/config.json |
|||
/persist/ |
|||
/errors/ |
|||
/TODO |
|||
/thumbnails/ |
|||
/logs/ |
|||
.sass-cache |
|||
|
@ -0,0 +1,263 @@ |
|||
express = require('express') |
|||
uuid = require "uuid" |
|||
fs = require "fs" |
|||
domain = require "domain" |
|||
|
|||
app = express() |
|||
|
|||
reportError = (err, type = "error", sync = false) -> |
|||
errorPayload = {} |
|||
|
|||
Object.getOwnPropertyNames(err).forEach (key) -> |
|||
errorPayload[key] = err[key] |
|||
|
|||
filename = "errors/#{type}-#{Date.now()}-#{uuid.v4()}.json" |
|||
|
|||
if sync |
|||
fs.writeFileSync filename, JSON.stringify(errorPayload) |
|||
else |
|||
fs.writeFile filename, JSON.stringify(errorPayload) |
|||
|
|||
# To make absolutely sure that we can log and exit on all uncaught errors. |
|||
if app.get("env") == "production" |
|||
logAndExit = (err) -> |
|||
reportError(err, "error", true) |
|||
process.exit(1) |
|||
|
|||
rootDomain = domain.create() |
|||
rootDomain.on "error", logAndExit |
|||
|
|||
runWrapper = (func) -> |
|||
rootDomain.run -> |
|||
try |
|||
func() |
|||
catch err |
|||
logAndExit(err) |
|||
else |
|||
# In development, we don't have to catch uncaught errors. Just run the specified function directly. |
|||
runWrapper = (func) -> func() |
|||
|
|||
runWrapper -> |
|||
path = require('path') |
|||
|
|||
favicon = require('serve-favicon') |
|||
logger = require('morgan') |
|||
cookieParser = require('cookie-parser') |
|||
bodyParser = require('body-parser') |
|||
fancyshelf = require "fancyshelf" |
|||
session = require "express-session" |
|||
csurf = require "csurf" |
|||
fileStreamRotator = require "file-stream-rotator" |
|||
domainMiddleware = require("express-domain-middleware") |
|||
|
|||
errors = require "errors" |
|||
PrettyError = require "pretty-error" |
|||
Convert = require "ansi-to-html" |
|||
marked = require "marked" |
|||
moment = require "moment" |
|||
|
|||
persist = require "./lib/persist" |
|||
useCsrf = require "./lib/use-csrf" |
|||
templateUtil = require("./lib/template-util") |
|||
sessionStore = require("./lib/persist-session")(session) |
|||
|
|||
config = require "./config.json" |
|||
knexfile = require("./knexfile").development |
|||
|
|||
ansiHTML = new Convert(escapeXML: true, newline: true) |
|||
|
|||
# Error handling |
|||
pe = PrettyError.start() |
|||
pe.skipPackage "bluebird", "coffee-script", "express", "express-promise-router", "jade" |
|||
pe.skipNodeFiles() |
|||
|
|||
errors.create |
|||
name: "UploadError" |
|||
|
|||
errors.create |
|||
name: "UploadTooLarge" |
|||
parents: errors.UploadError |
|||
status: 413 |
|||
|
|||
errors.create |
|||
name: "InvalidFiletype" |
|||
parents: errors.UploadError |
|||
status: 415 |
|||
|
|||
errors.create |
|||
name: "NotAuthenticated" |
|||
status: 403 |
|||
|
|||
errors.create |
|||
name: "InvalidInput" |
|||
status: 422 |
|||
|
|||
# Database setup |
|||
shelf = fancyshelf |
|||
engine: knexfile.client |
|||
host: knexfile.connection.host |
|||
username: knexfile.connection.user |
|||
password: knexfile.connection.password |
|||
database: knexfile.connection.database |
|||
debug: (app.get("env") == "development") |
|||
|
|||
# Task runner |
|||
TaskRunner = require("./lib/task-runner") |
|||
runner = new TaskRunner(app: app, db: shelf, config: config, thumbnailPath: path.join(__dirname, 'thumbnails')) |
|||
runner.addTask "mirror", require("./tasks/mirror"), maxConcurrency: 5 |
|||
runner.addTask "thumbnail", require("./tasks/thumbnail") |
|||
runner.run() |
|||
|
|||
runner.on "taskQueued", (taskType, task) -> |
|||
persist.increment "task:#{taskType}:queued" |
|||
|
|||
runner.on "taskStarted", (taskType, task) -> |
|||
persist.decrement "task:#{taskType}:queued" |
|||
persist.increment "task:#{taskType}:running" |
|||
|
|||
runner.on "taskFailed", (taskType, task) -> |
|||
persist.decrement "task:#{taskType}:running" |
|||
persist.increment "task:#{taskType}:failed" |
|||
|
|||
runner.on "taskCompleted", (taskType, task) -> |
|||
persist.decrement "task:#{taskType}:running" |
|||
persist.increment "task:#{taskType}:completed" |
|||
|
|||
if app.get("env") == "development" |
|||
runner.on "taskFailed", (taskType, task, err) -> |
|||
console.log err.stack |
|||
else |
|||
runner.on "taskFailed", (taskType, task, err) -> |
|||
reportError err, "taskFailed" |
|||
|
|||
# Configure Express |
|||
app.set('views', path.join(__dirname, 'views')) |
|||
app.set('view engine', 'jade') |
|||
|
|||
# Middleware |
|||
if app.get("env") == "development" |
|||
app.use(logger('dev')) |
|||
else |
|||
accessLogStream = fileStreamRotator.getStream frequency: (config.accessLog.frequency ? "24h"), filename: config.accessLog.filename |
|||
app.use logger (config.accessLog.format ? "combined"), stream: accessLogStream |
|||
|
|||
# Using /static/ paths to maintain backwards compatibility. |
|||
app.use("/static/thumbs", express.static(path.join(__dirname, 'thumbnails'))) |
|||
app.use("/static/pdfjs", express.static(path.join(__dirname, 'public/pdfjs'))) |
|||
app.use(express.static(path.join(__dirname, 'public'))) |
|||
#app.use(favicon(__dirname + '/public/favicon.ico')) |
|||
|
|||
app.use domainMiddleware |
|||
app.use templateUtil |
|||
app.use shelf.express |
|||
|
|||
app.use session |
|||
store: new sessionStore |
|||
persist: persist |
|||
secret: config.session.signingKey |
|||
resave: true # TODO: Implement `touch` for the session store, and/or switch to a different store. |
|||
saveUninitialized: false |
|||
|
|||
# Load models |
|||
require("./models")(shelf) |
|||
|
|||
app.use(bodyParser.json()) |
|||
app.use(bodyParser.urlencoded({ extended: false })) |
|||
app.use(cookieParser()) |
|||
|
|||
app.use (req, res, next) -> |
|||
res.locals.md = marked |
|||
|
|||
req.appConfig = config |
|||
req.persist = persist |
|||
|
|||
jadeExports = [ |
|||
"announcementVisible" |
|||
"announcementText" |
|||
"announcementLinkText" |
|||
"announcementLink" |
|||
"maintenanceMode" |
|||
"maintenanceModeText" |
|||
"donationGoal" |
|||
"donationTotal" |
|||
"showNotice" |
|||
] |
|||
|
|||
for key in jadeExports |
|||
res.locals[key] = persist.getItem "var:#{key}" |
|||
|
|||
res.locals.bitcoinAddress = config.donations.bitcoinAddress |
|||
res.locals.isAdmin = req.session.isAdmin |
|||
|
|||
# This is for logging/reporting errors that should not occur, but that are known to not cause any adverse side-effects. |
|||
# This should NOT be used for errors that could leave the application in an undefined state! |
|||
req.reportError = reportError |
|||
|
|||
req.taskRunner = runner |
|||
|
|||
next() |
|||
|
|||
app.use "/", require("./routes/index") |
|||
app.use "/donate", require("./routes/donate") |
|||
app.use "/d", require("./routes/document") |
|||
app.use "/upload", require("./routes/upload") |
|||
app.use "/gallery", require("./routes/gallery") |
|||
app.use "/blog", require("./routes/blog") |
|||
app.use "/admin", csurf(), useCsrf, require("./routes/admin") |
|||
|
|||
# If no routes match, cause a 404 |
|||
app.use (req, res, next) -> |
|||
next new errors.Http404Error("The requested page was not found.") |
|||
|
|||
# Error handling middleware |
|||
app.use "/static/thumbs", (err, req, res, next) -> |
|||
# TODO: For some reason, Chrome doesn't always display images that are sent with a 404 status code. Let's stick with 200 for now... |
|||
#res.status 404 |
|||
if err.status == '404' |
|||
fs.createReadStream path.join(__dirname, "public/images/no-thumbnail.png") |
|||
.pipe res |
|||
else |
|||
next(err) |
|||
|
|||
app.use (err, req, res, next) -> |
|||
statusCode = err.status || 500 |
|||
res.status(statusCode) |
|||
|
|||
# Dump the error to disk if it's a 500, so that the error reporter can deal with it. |
|||
if app.get("env") != "development" and statusCode == 500 |
|||
reportError(err) |
|||
|
|||
# Display the error to the user - amount of details depending on whether the application is running in production mode or not. |
|||
if (app.get('env') == 'development') |
|||
stack = err |
|||
else |
|||
if statusCode == 500 |
|||
errorMessage = "An unknown error occurred." |
|||
stack = {stack: "An administrator has been notified, and the error will be resolved as soon as possible. Apologies for the inconvenience."} |
|||
else |
|||
errorMessage = err.message |
|||
stack = {stack: err.explanation, subMessage: err.subMessage} |
|||
|
|||
if req.headers["X-Requested-With"] == "XMLHttpRequest" |
|||
res.send { |
|||
message: errorMessage, |
|||
error: stack |
|||
} |
|||
else |
|||
if err instanceof errors.NotAuthenticated |
|||
res.redirect "/admin/login" |
|||
else |
|||
if app.get("env") == "development" |
|||
htmlStack = ansiHTML.toHtml(stack.stack) |
|||
.replace /#555/g, "#b5b5b5" |
|||
|
|||
# TODO: It seems aborted responses will result in an attempt to send an error page/header - of course this can't succeed, as the headers have already been sent by that time, and an error is thrown. |
|||
res.render('error', { |
|||
message: errorMessage, |
|||
error: stack, |
|||
statusCode: statusCode, |
|||
htmlStack: htmlStack |
|||
}) |
|||
|
|||
|
|||
module.exports = app |
@ -0,0 +1,56 @@ |
|||
#!/usr/bin/env coffee |
|||
|
|||
app = require('../app') |
|||
debug = require('debug')('pdfy:server') |
|||
http = require('http') |
|||
|
|||
normalizePort = (val) -> |
|||
port = parseInt val, 10 |
|||
|
|||
if isNaN port |
|||
return val |
|||
|
|||
if port >= 0 |
|||
return port |
|||
|
|||
return false |
|||
|
|||
onError = (error) -> |
|||
if error.syscall != "listen" |
|||
throw error |
|||
|
|||
bind = if typeof port == "string" |
|||
"Pipe #{port}" |
|||
else |
|||
"Port #{port}" |
|||
|
|||
switch error.code |
|||
when "EACCES" |
|||
console.error "#{bind} requires elevated privileges" |
|||
process.exit 1 |
|||
when "EADDRINUSE" |
|||
console.error "#{bind} is already in use" |
|||
process.exit 1 |
|||
else |
|||
throw error |
|||
|
|||
|
|||
onListening = -> |
|||
addr = server.address() |
|||
|
|||
bind = if typeof port == "string" |
|||
"pipe #{port}" |
|||
else |
|||
"port #{port}" |
|||
|
|||
debug("Listening on #{bind}") |
|||
|
|||
|
|||
port = normalizePort(process.env.PORT || '3000') |
|||
app.set('port', port) |
|||
|
|||
server = http.createServer(app) |
|||
|
|||
server.listen(port) |
|||
server.on('error', onError) |
|||
server.on('listening', onListening) |
@ -0,0 +1,51 @@ |
|||
chokidar = require "chokidar" |
|||
nodemailer = require "nodemailer" |
|||
path = require "path" |
|||
fs = require "fs" |
|||
|
|||
watcher = chokidar.watch "./errors", depth: 0, ignoreInitial: true |
|||
mailer = nodemailer.createTransport() |
|||
|
|||
processFile = (filePath) -> |
|||
fs.readFile filePath, (err, data) -> |
|||
try |
|||
parsedData = JSON.parse(data) |
|||
catch error |
|||
console.log "Error report not complete yet, retrying #{filePath} in 1 second..." |
|||
setTimeout (-> |
|||
processFile(filePath) |
|||
), 1000 |
|||
return |
|||
|
|||
errorMessage = parsedData?.message ? "UNKNOWN ERROR" |
|||
textStack = parsedData?.stack?.replace(/\u001b(?:\[\??(?:\d\;*)*[A-HJKSTfminsulh])?/g, "") ? "" |
|||
|
|||
message = """ |
|||
A failure occurred. #{filePath} is attached. |
|||
|
|||
#{textStack} |
|||
""" |
|||
|
|||
htmlMessage = """ |
|||
A failure occurred. #{filePath} is attached. |
|||
|
|||
<pre>#{textStack}</pre> |
|||
""".replace(/\n/g, "<br>") |
|||
|
|||
mailer.sendMail |
|||
from: "ops@pdf.yt" |
|||
to: "admin@cryto.net" |
|||
subject: "Automatic failure report: #{errorMessage}" |
|||
text: message |
|||
html: htmlMessage |
|||
attachments: [ |
|||
filename: path.basename(filePath) |
|||
path: filePath |
|||
contentType: "application/json" |
|||
] |
|||
|
|||
watcher.on "add", (filePath) -> |
|||
console.log "PANIC! Sending report:", filePath |
|||
processFile(filePath) |
|||
|
|||
console.log "Running..." |
@ -0,0 +1,35 @@ |
|||
require "./lib/upload" |
|||
require "./lib/embed" |
|||
require "./lib/donate" |
|||
require "./lib/form-popup" |
|||
|
|||
$ = require "jquery" |
|||
autosize = require "autosize" |
|||
marked = require "marked" |
|||
scrollFloat = require "./lib/scroll-float" |
|||
|
|||
$ -> |
|||
$(".checkAll").on "change", (event) -> |
|||
newValue = $(this).prop("checked") |
|||
|
|||
$(this) |
|||
.closest "table" |
|||
.find "input[type='checkbox']" |
|||
.filter -> |
|||
return !$(this).hasClass "checkAll" |
|||
.prop "checked", newValue |
|||
|
|||
scrollFloat($(".floating")) |
|||
|
|||
autosize($(".md-editor")) |
|||
|
|||
updatePreview = -> |
|||
$(".md-preview").html(marked($(this).val())) |
|||
|
|||
$(".md-editor") |
|||
.on "change input propertychange", updatePreview |
|||
.each updatePreview |
|||
|
|||
|
|||
|
|||
|
@ -0,0 +1,199 @@ |
|||
$ = require "jquery" |
|||
require "Base64" |
|||
|
|||
$ -> |
|||
selectedMethod = undefined |
|||
selectedAmount = undefined |
|||
selectedHasPrice = undefined |
|||
pulsateRemove = undefined |
|||
|
|||
paypalHandler = (block) -> |
|||
block.find "input.amount" |
|||
.val (Math.round(selectedAmount * 100) / 100) |
|||
|
|||
paymentMethodHandlers = |
|||
paypal: paypalHandler |
|||
"paypal-weekly": paypalHandler |
|||
"paypal-monthly": paypalHandler |
|||
bitcoin: (block) -> |
|||
block.find(".loading-message").show() |
|||
block.find(".loaded-content").hide() |
|||
|
|||
$.get "/donate/convert/btc?amount=#{selectedAmount}", (btcAmount) -> |
|||
$.get "/donate/bip21?amount=#{btcAmount}", (uri) -> |
|||
$(".bip21").attr("href", uri) |
|||
|
|||
block.find(".loading-message").hide() |
|||
block.find(".loaded-content").show() |
|||
|
|||
$(".bip21-qr").attr("src", "/donate/bip21/qr?amount=#{btcAmount}") |
|||
$(".btc-amount").text(Math.round(btcAmount * 100000000) / 100000000) |
|||
|
|||
setCustomValue = (value) -> |
|||
value = parseFloat(value) |
|||
|
|||
if isNaN value |
|||
# TODO: Validation error! |
|||
value = 0 |
|||
|
|||
selectedAmount = value |
|||
|
|||
$ "#custom_amount_input" |
|||
.on "keyup", (event) -> |
|||
setCustomValue $(this).val() |
|||
showInstructions() |
|||
.on "change", (event) -> |
|||
setCustomValue $(this).val() |
|||
showInstructions() |
|||
.on "click", (event) -> |
|||
#$ this |
|||
# .closest ".option" |
|||
# .find "input[type='radio']" |
|||
# .click() |
|||
|
|||
#event.stopPropagation() |
|||
|
|||
|
|||
$ "#amount_custom" |
|||
.on "click", (event) -> |
|||
setCustomValue $("#custom_amount_input").val() |
|||
|
|||
$ ".donation-page section.types .option input[type='radio']" |
|||
.on "click", (event) -> |
|||
# Hide any instructions that were already visible. |
|||
# TODO: Automatically show instructions again when switching back...? |
|||
$ ".donation-page section.instructions div" |
|||
.hide() |
|||
|
|||
$ ".donation-page section.instructions .placeholder" |
|||
.show() |
|||
|
|||
type = $ this |
|||
.closest ".option" |
|||
.data "type" |
|||
|
|||
$ ".donation-page section.methods .option" |
|||
.hide() |
|||
|
|||
$ ".donation-page section.methods .option[data-type='#{type}']" |
|||
.show() |
|||
|
|||
$ ".donation-page section.methods .option input[type='radio']" |
|||
.on "click", (event) -> |
|||
optionElement = $ this |
|||
.closest ".option" |
|||
|
|||
selectedMethod = method = optionElement.data "name" |
|||
selectedHasPrice = setPrice = !!(optionElement.data "set-price") |
|||
|
|||
if setPrice |
|||
$ ".donation-page section.amount" |
|||
.slideDown(400) |
|||
|
|||
instructionSection = $ ".donation-page section.instructions" |
|||
|
|||
instructionSection |
|||
.children "h3.set-amount" |
|||
.show() |
|||
|
|||
instructionSection |
|||
.children "h3.no-set-amount" |
|||
.hide() |
|||
else |
|||
$ ".donation-page section.amount" |
|||
.slideUp(400) |
|||
|
|||
instructionSection = $ ".donation-page section.instructions" |
|||
|
|||
instructionSection |
|||
.children "h3.set-amount" |
|||
.hide() |
|||
|
|||
instructionSection |
|||
.children "h3.no-set-amount" |
|||
.show() |
|||
|
|||
showInstructions() |
|||
|
|||
$ ".donation-page section.amount .option input[type='radio']" |
|||
.on "click", (event) -> |
|||
if not ($(this).attr("id") == "amount_custom") |
|||
selectedAmount = $(this).val() |
|||
|
|||
showInstructions() |
|||
|
|||
showInstructions = -> |
|||
$ ".donation-page section.instructions .method" |
|||
.hide() |
|||
|
|||
if selectedMethod? and (selectedAmount? or !selectedHasPrice) |
|||
$ ".donation-page section.instructions .placeholder" |
|||
.hide() |
|||
|
|||
$ ".donation-page section.instructions .method-#{selectedMethod}" |
|||
.show() |
|||
|
|||
#$ "html, body" |
|||
# .animate scrollTop: "#{$('.donation-page section.instructions').offset().top}px", 500 |
|||
|
|||
$ ".donation-page section.instructions" |
|||
.addClass "pulsate" |
|||
|
|||
if pulsateRemove? |
|||
clearTimeout pulsateRemove |
|||
|
|||
pulsateRemove = setTimeout (-> |
|||
$ ".donation-page section.instructions" |
|||
.removeClass "pulsate" |
|||
), 3000 |
|||
|
|||
if selectedMethod of paymentMethodHandlers |
|||
paymentMethodHandlers[selectedMethod]($(".donation-page section.instructions .method-#{selectedMethod}")) |
|||
else |
|||
$ ".donation-page section.instructions .placeholder" |
|||
.show() |
|||
|
|||
|
|||
$ ".donation-page .option input[type='radio']" |
|||
.on "click", (event) -> |
|||
$ this |
|||
.closest "section" |
|||
.find ".option" |
|||
.removeClass "selected" |
|||
|
|||
$ this |
|||
.closest ".option" |
|||
.addClass "selected" |
|||
|
|||
$ this |
|||
.closest ".option" |
|||
.find "input[type='number']" |
|||
.focus() |
|||
|
|||
event.stopPropagation() |
|||
|
|||
$ ".donation-page .option" |
|||
.on "click", (event) -> |
|||
$ this |
|||
.find "input[type='radio']" |
|||
.click() |
|||
|
|||
$ ".donation-page section.types .option[data-type='once']" |
|||
.click() |
|||
|
|||
$ ".donation-page #amount_10" |
|||
.click() |
|||
|
|||
$ ".donation-page section.instructions .method, .donation-page section.instructions h3.no-set-amount" |
|||
.hide() |
|||
|
|||
$ ".js-unavailable" |
|||
.hide() |
|||
|
|||
$ ".js-available" |
|||
.show() |
|||
|
|||
$ ".decodable" |
|||
.each -> |
|||
$(this).html atob($(this).html()) |
|||
|
@ -0,0 +1,33 @@ |
|||
$ = require "jquery" |
|||
|
|||
castBoolean = (value) -> |
|||
if value == true |
|||
return 1 |
|||
else |
|||
return 0 |
|||
|
|||
updateEmbedCode = -> |
|||
showToolbar = $("#show_toolbar").prop("checked") |
|||
showDonationLink = $("#show_donation").prop("checked") |
|||
|
|||
embedCode = embed_template |
|||
.replace "{SPARSE}", castBoolean(not showToolbar) |
|||
.replace "{DONATION}", castBoolean(showDonationLink) |
|||
|
|||
$(".embed_code").val embedCode |
|||
|
|||
$ -> |
|||
$ ".autoselect" |
|||
.on "click", (event) -> |
|||
$ this |
|||
.focus() |
|||
.select() |
|||
|
|||
$ "#show_toolbar, #show_donation" |
|||
.on "change", (event) -> |
|||
updateEmbedCode() |
|||
|
|||
# Linkify has a tendency of breaking our embed codes, so we re-set the embed code here to make sure that that doesn't happen. |
|||
if embed_template? |
|||
updateEmbedCode() |
|||
# do things and stuff |
@ -0,0 +1,16 @@ |
|||
$ = require "jquery" |
|||
|
|||
$ -> |
|||
$(".popup").hide() |
|||
|
|||
$(".form-popup").each -> |
|||
elem = $(this) |
|||
target = ".popup-#{elem.data('popup')}" |
|||
|
|||
elem.on "click", -> |
|||
$(target).show() |
|||
|
|||
$(".popup .close").on "click", -> |
|||
$(this) |
|||
.closest ".popup" |
|||
.hide() |
@ -0,0 +1,49 @@ |
|||
$ = require "jquery" |
|||
|
|||
module.exports = (jqueryObj) -> |
|||
jqueryObj.each -> |
|||
elem = $(this) |
|||
minTop = threshold = basePos = undefined |
|||
currentlyFloating = false |
|||
|
|||
originalPositioning = elem.css "position" |
|||
originalLeft = elem.css "left" |
|||
originalTop = elem.css "top" |
|||
originalLeft = elem.css "right" |
|||
|
|||
updateMetrics = -> |
|||
needRestore = currentlyFloating |
|||
|
|||
if needRestore |
|||
makeNotFloating() |
|||
|
|||
basePos = elem.offset() |
|||
minTop = elem.data("min-top") ? 0 |
|||
threshold = basePos.top - minTop |
|||
|
|||
if needRestore |
|||
makeFloating() |
|||
|
|||
makeFloating = -> |
|||
currentlyFloating = true |
|||
elem.css |
|||
position: "fixed" |
|||
top: "#{minTop}px" |
|||
right: "auto" |
|||
left: "#{basePos.left}px" |
|||
|
|||
makeNotFloating = -> |
|||
currentlyFloating = false |
|||
elem.attr "style", "" |
|||
#elem.css |
|||
# position: originalPositioning |
|||
# top: originalTop |
|||
|
|||
updateMetrics() |
|||
$(window).on "resize", updateMetrics |
|||
|
|||
$(document).on "scroll", (event) -> |
|||
if $(document).scrollTop() >= threshold |
|||
makeFloating() |
|||
else |
|||
makeNotFloating() |
@ -0,0 +1,176 @@ |
|||
$ = require "jquery" |
|||
prettyUnits = require "pretty-units" |
|||
|
|||
# The AMD loader for this package doesn't work for some reason - so we explicitly disable it. This will force it to fall back to the CommonJS API. |
|||
require "imports?define=>false!blueimp-file-upload" |
|||
|
|||
data_object = null |
|||
|
|||
uploadDone = (response) -> |
|||
switch response.status |
|||
when 415 |
|||
errorHeader = "Oops! That's not a PDF file." |
|||
errorMessage = "The file you tried to upload is not a valid PDF file. Currently, only PDF files are accepted." |
|||
when 413 |
|||
errorHeader = "Oops! That file is too big." |
|||
errorMessage = "The file you tried to upload is too big. Currently, you can only upload PDF files up to 150MB in size." |
|||
when 200 # Nothing, success! |
|||
else |
|||
errorHeader = "Oops! Something went wrong." |
|||
errorMessage = "An unknown error occurred. Please reload the page and try again. If the error keeps occurring, <a href='mailto:pdfy@cryto.net'>send us an e-mail</a>!" |
|||
|
|||
if errorMessage? |
|||
triggerError errorHeader, errorMessage |
|||
reinitializeUploader() |
|||
else |
|||
if response.responseJSON.redirect? |
|||
window.location = response.responseJSON.redirect |
|||
else |
|||
# TODO: Wat do? |
|||
|
|||
triggerError = (header, message) -> |
|||
$(".upload-form .privacySettings, .upload-form .progress, .upload-form .button-submit").hide() |
|||
$(".upload").removeClass("faded") |
|||
|
|||
errorBox = $("#uploadError") |
|||
.show() |
|||
|
|||
errorBox.find "h3" |
|||
.html header |
|||
|
|||
errorBox.find ".message" |
|||
.html message |
|||
|
|||
data_object = null |
|||
|
|||
reinitializeUploader = -> |
|||
$("#upload_element").replaceWith($("#upload_element").clone(true)) |
|||
|
|||
filePicked = (data) -> |
|||
$(".upload-form .privacySettings, .upload-form .button-submit").show() |
|||
$("#uploadError").hide() |
|||
$(".upload-form .fileinfo").removeClass("faded") |
|||
|
|||
fileinfo = $(".fileinfo") |
|||
filesize = data.files[0].size |
|||
|
|||
# TODO: Use filesize limit from configuration file! |
|||
if filesize > (150 * 1024 * 1024) |
|||
reinitializeUploader() |
|||
triggerError("Oops! That file is too big.", "The file you tried to upload is too big. Currently, you can only upload PDF files up to 150MB in size.") |
|||
return |
|||
|
|||
filesize_text = prettyUnits(filesize) + "B" |
|||
|
|||
fileinfo.find ".filename" |
|||
.text data.files[0].name |
|||
|
|||
fileinfo.find ".filesize" |
|||
.text filesize_text |
|||
|
|||
$ ".info" |
|||
.hide() |
|||
|
|||
fileinfo |
|||
.show() |
|||
|
|||
$ ".upload" |
|||
.addClass "faded" |
|||
|
|||
updateUploadProgress = (event) -> |
|||
if event.lengthComputable |
|||
percentage = event.loaded / event.total * 100 |
|||
|
|||
done_text = prettyUnits(event.loaded) + "B" |
|||
total_text = prettyUnits(event.total) + "B" |
|||
|
|||
progress = $ ".progress" |
|||
|
|||
progress.find ".done" |
|||
.text done_text |
|||
|
|||
progress.find ".total" |
|||
.text total_text |
|||
|
|||
progress.find ".percentage" |
|||
.text (Math.ceil(percentage * 100) / 100) |
|||
|
|||
progress.find ".bar-inner" |
|||
.css width: "#{percentage}%" |
|||
|
|||
if event.loaded >= event.total |
|||
# Completed! |
|||
progress.find ".numbers" |
|||
.hide() |
|||
|
|||
progress.find ".wait" |
|||
.show() |
|||
|
|||
$ -> |
|||
if $().fileupload? |
|||
# Only run this if the fileupload plugin is loaded; we don't need all this on eg. the 'view' page. |
|||
|
|||
$ "#upload_form" |
|||
.fileupload |
|||
fileInput: null |
|||
type: "POST" |
|||
url: "/upload" |
|||
paramName: "file" |
|||
autoUpload: false |
|||
maxNumberOfFiles: 1 |
|||
formData: (form) -> |
|||
form = $ "#upload_form" |
|||
form.serializeArray() |
|||
progressall: (e, data) -> |
|||
updateUploadProgress |
|||
lengthComputable: true |
|||
loaded: data.loaded |
|||
total: data.total |
|||
add: (e, data) -> |
|||
data_object = data |
|||
filePicked(data) |
|||
always: (e, data) -> |
|||
uploadDone(data.jqXHR) |
|||
|
|||
$ "#upload_activator" |
|||
.on "click", (event) -> |
|||
$("#upload_element").click() |
|||
|
|||
$ "#upload_element" |
|||
.on "change", (event) -> |
|||
filePicked(this) |
|||
|
|||
$ "#upload_form" |
|||
.on "submit", (event) -> |
|||
event.stopPropagation() |
|||
event.preventDefault() |
|||
|
|||
$ ".fileinfo" |
|||
.addClass "faded" |
|||
|
|||
$ ".progress" |
|||
.show() |
|||
|
|||
if data_object == null |
|||
# Only do this if the drag-and-drop dropzone hasn't been used. |
|||
formData = new FormData(this) |
|||
|
|||
$.ajax |
|||
method: "POST" |
|||
url: "/upload" |
|||
data: formData |
|||
cache: false |
|||
contentType: false |
|||
processData: false |
|||
xhr: -> |
|||
customHandler = $.ajaxSettings.xhr() |
|||
|
|||
if customHandler.upload? |
|||
customHandler.upload.addEventListener "progress", updateUploadProgress, false |
|||
|
|||
return customHandler |
|||
complete: (result) -> |
|||
uploadDone(result) |
|||
else |
|||
# If the dropzone was used... |
|||
data_object.submit() |
@ -0,0 +1,17 @@ |
|||
#!/usr/bin/env coffee |
|||
|
|||
scrypt = require "scrypt-for-humans" |
|||
Promise = require "bluebird" |
|||
read = Promise.promisify(require "read") |
|||
|
|||
Promise.try -> |
|||
read(prompt: "Enter a password:", silent: true) |
|||
.spread (password, isDefault) -> |
|||
if password.trim().length == 0 |
|||
console.log "You didn't enter a password!" |
|||
process.exit(1) |
|||
|
|||
scrypt.hash(password) |
|||
.then (hash) -> |
|||
console.log "Hash:", hash |
|||
console.log "Set this hash in your config.json to use it." |
After Width: | Height: | Size: 6.1 KiB |
After Width: | Height: | Size: 3.3 KiB |
@ -0,0 +1,85 @@ |
|||
var gulp = require('gulp'); |
|||
|
|||
/* CoffeeScript compile deps */ |
|||
var path = require('path'); |
|||
var gutil = require('gulp-util'); |
|||
var concat = require('gulp-concat'); |
|||
var rename = require('gulp-rename'); |
|||
var coffee = require('gulp-coffee'); |
|||
var cache = require('gulp-cached'); |
|||
var remember = require('gulp-remember'); |
|||
var plumber = require('gulp-plumber'); |
|||
var livereload = require('gulp-livereload'); |
|||
var nodemon = require("gulp-nodemon"); |
|||
var net = require("net"); |
|||
var webpack = require("gulp-webpack"); |
|||
var sass = require("gulp-sass"); |
|||
|
|||
task = { |
|||
"source": ["public/**/*.coffee", "routes/**/*.coffee", "models/**/*.coffee", "tasks/**/*.coffee", "app.coffee", "util.coffee", "migrate.coffee", "db.coffee"] |
|||
} |
|||
|
|||
/* |
|||
gulp.task('coffee', function() { |
|||
return gulp.src(task.source, {base: "."}) |
|||
.pipe(plumber()) |
|||
.pipe(cache("coffee")) |
|||
.pipe(coffee({bare: true}).on('error', gutil.log)).on('data', gutil.log) |
|||
.pipe(remember("coffee")) |
|||
.pipe(gulp.dest(".")); |
|||
});*/ |
|||
|
|||
gulp.task('webpack', function(){ |
|||
return gulp.src("frontend/index.coffee") |
|||
.pipe(webpack({ |
|||
watch: true, |
|||
module: { |
|||
loaders: [{ test: /\.coffee$/, loader: "coffee-loader" }] |
|||
}, |
|||
resolve: { extensions: ["", ".web.coffee", ".web.js", ".coffee", ".js"] } |
|||
})) |
|||
.pipe(rename("bundle.js")) |
|||
.pipe(gulp.dest("public/js/")); |
|||
}); |
|||
|
|||
gulp.task('sass', function(){ |
|||
// TODO: Put the source SCSS file in a more logical place...
|
|||
return gulp.src("./public/css/*.scss") |
|||
.pipe(sass()) |
|||
.pipe(gulp.dest("./public/css")); |
|||
}); |
|||
|
|||
function checkServerUp(){ |
|||
setTimeout(function(){ |
|||
var sock = new net.Socket(); |
|||
sock.setTimeout(50); |
|||
sock.on("connect", function(){ |
|||
console.log("Trigger page reload..."); |
|||
livereload.changed(); |
|||
sock.destroy(); |
|||
}) |
|||
.on("timeout", checkServerUp) |
|||
.on("error", checkServerUp) |
|||
.connect(3000); |
|||
}, 70); |
|||
} |
|||
|
|||
gulp.task('watch', function () { |
|||
livereload.listen(); |
|||
gulp.watch(['./**/*.css', 'views/**/*.jade', 'package.json', "./public/js/**/*.js"]).on('change', livereload.changed); |
|||
gulp.watch(["./public/css/style.scss"], ["sass"]) |
|||
//gulp.watch(task.source, ['coffee']);
|
|||
// theseus disabled for now, it was screwing with my tracebacks
|
|||
//nodemon({script: "./bin/www", ext: "js", nodeArgs: ['/usr/bin/node-theseus']}).on("start", checkServerUp);
|
|||
nodemon({ |
|||
script: "./bin/www.coffee", |
|||
ext: "coffee", |
|||
delay: 500, |
|||
ignore: ["./frontend/"], |
|||
watch: ["app.coffee", "bin", "lib", "models", "routes", "tasks"] |
|||
}).on("start", checkServerUp).on("restart", function(file){ |
|||
console.log("Restarted triggered by:", file); |
|||
}); |
|||
}); |
|||
|
|||
gulp.task('default', [/*'coffee',*/ 'sass', 'watch', 'webpack']); |
@ -0,0 +1,17 @@ |
|||
# Update with your config settings. |
|||
|
|||
config = require "./config.json" |
|||
|
|||
module.exports = |
|||
# TODO: Do we need an environment name here? |
|||
development: |
|||
client: "mysql2" |
|||
connection: |
|||
database: config.database.database |
|||
user: config.database.username |
|||
password: config.database.password |
|||
pool: |
|||
min: 2 |
|||
max: 10 |
|||
migrations: |
|||
tableName: "knex_migrations" |
@ -0,0 +1,5 @@ |
|||
module.exports = (req, res, next) -> |
|||
if (not res.locals.maintenanceMode) or req.session.isAdmin |
|||
next() |
|||
else |
|||
res.status(503).send(res.locals.maintenanceModeText) |
@ -0,0 +1,25 @@ |
|||
Promise = require "bluebird" |
|||
bhttp = require "bhttp" |
|||
|
|||
lastRates = null |
|||
lastRateCheck = 0 |
|||
|
|||
module.exports = -> |
|||
Promise.try -> |
|||
if Date.now() > lastRateCheck + (5 * 60 * 1000) |
|||
# We need fresh API data, 5 minutes have elapsed. |
|||
Promise.try -> |
|||
Promise.all [ |
|||
bhttp.get "http://api.fixer.io/latest", decodeJSON: true |
|||
bhttp.get "https://blockchain.info/ticker", decodeJSON: true |
|||
] |
|||
.spread (fixerRates, blockchainRates) -> |
|||
eurRates = fixerRates.body.rates |
|||
eurRates.BTC = 1 / blockchainRates.body.EUR["15m"] |
|||
Promise.resolve eurRates |
|||
.then (rates) -> |
|||
lastRates = rates |
|||
lastRateCheck = Date.now() |
|||
Promise.resolve rates |
|||
else |
|||
Promise.resolve lastRates |
@ -0,0 +1,7 @@ |
|||
errors = require "errors" |
|||
|
|||
module.exports = (req, res, next) -> |
|||
if req.session?.isAdmin? |
|||
next() |
|||
else |
|||
next(new errors.NotAuthenticated("You are not logged in as an administrator.")) |
@ -0,0 +1,11 @@ |
|||
errors = require "errors" |
|||
|
|||
amountRegex = /^[0-9]+(?:\.[0-9]+)?$/ |
|||
|
|||
module.exports = (amount) -> |
|||
parsedAmount = parseFloat(amount) |
|||
|
|||
if amountRegex.exec(amount) == null or isNaN(parsedAmount) |
|||
throw new errors.InvalidInput("The specified amount is invalid.") |
|||
|
|||
return parsedAmount |
@ -0,0 +1,5 @@ |
|||
module.exports = (param) -> |
|||
if not param? |
|||
return undefined |
|||
else |
|||
return !!(parseInt(param)) |
@ -0,0 +1,71 @@ |
|||
# NOTE: This module does not currently ensure correct writes. Callbacks are called immediately (but asynchronously). |
|||
|
|||
AbstractClientStore = require "express-brute/lib/AbstractClientStore" |
|||
|
|||
module.exports = class PersistBruteStore extends AbstractClientStore |
|||
constructor: (options) -> |
|||
@persist = options.persist |
|||
@prefix = options.prefix ? "brute" |
|||
|
|||
@_timers = {} |
|||
@_keyMatcher = new RegExp("^#{@prefix}:") |
|||
|
|||
@persist.keys() |
|||
.filter (key) => key.match(@_keyMatcher) |
|||
.forEach (key) => |
|||
@_createExpiryTimer key, @persist.getItem(key).expiry |
|||
|
|||
_createExpiryTimer: (key, expiry) -> |
|||
@_removeExpiryTimer(key) |
|||
|
|||
ttl = expiry - Date.now() |
|||
|
|||
if ttl < 0 |
|||
@_createRemover(key)() |
|||
else |
|||
setTimeout @_createRemover(key), ttl |
|||
|
|||
_removeExpiryTimer: (key) -> |
|||
if @_timers[key]? |
|||
clearTimeout @_timers[key] |
|||
delete @_timers[key] |
|||
|
|||
_createRemover: (key) -> |
|||
return => |
|||
@_removeExpiryTimer(key) |
|||
|
|||
prefixedKey = "#{@prefix}:#{key}" |
|||
|
|||
# TODO: Error handling? |
|||
@persist.removeItem prefixedKey |
|||
|
|||
set: (key, value, lifetime, callback) -> |
|||
prefixedKey = "#{@prefix}:#{key}" |
|||
expiry = (Date.now() + lifetime) |
|||
|
|||
@_createExpiryTimer key, expiry |
|||
@persist.setItem prefixedKey, {value: value, expiry: expiry}, callback |
|||
|
|||
process.nextTick -> |
|||
callback(null) |
|||
|
|||
get: (key, callback) -> |
|||
prefixedKey = "#{@prefix}:#{key}" |
|||
result = @persist.getItem(prefixedKey) |
|||
value = result?.value |
|||
|
|||
# Normalize to dates if we're reading from disk-persisted data... on disk, they're saved as strings. |
|||
if value?.firstRequest? and value?.firstRequest not instanceof Date |
|||
value.firstRequest = new Date(value.firstRequest) |
|||
|
|||
if value?.lastRequest? and value?.lastRequest not instanceof Date |
|||
value.lastRequest = new Date(value.lastRequest) |
|||
|
|||
process.nextTick -> |
|||
callback(null, value) |
|||
|
|||
reset: (key, callback) -> |
|||
# I don't really understand why this is called 'reset'. It's quite clearly a 'remove' function... |
|||
@_createRemover(key)() |
|||
process.nextTick -> |
|||
callback(null) |
@ -0,0 +1,40 @@ |
|||
# This will break horribly in a multi-process setup! Don't do that! |
|||
# NOTE: Does not currently ensure writes. |
|||
|
|||
module.exports = (session) -> |
|||
class PersistSessionStore extends session.Store |
|||
constructor: (options) -> |
|||
@persist = options.persist |
|||
|
|||
get: (sid, callback) -> |
|||
sessionData = @persist.getItem "session:#{sid}" |
|||
process.nextTick -> |
|||
callback(null, sessionData) |
|||
|
|||
set: (sid, sessionData, callback) -> |
|||
sessionData.__lastAccess = Date.now() |
|||
@persist.setItem "session:#{sid}", sessionData |
|||
process.nextTick -> |
|||
callback(null) |
|||
|
|||
destroy: (sid, session, callback) -> |
|||
@persist.removeItem "session:#{sid}" |
|||
process.nextTick -> |
|||
callback(null) |
|||
|
|||
length: (callback) -> |
|||
length = @persist.valuesWithKeyMatch(/^session:/).length |
|||
process.nextTick -> |
|||
callback(null, length) |
|||
|
|||
clear: (callback) -> |
|||
@persist.keys() |
|||
.filter (key) -> key.match(/^session:/) |
|||
.forEach (key) -> |
|||
@persist.removeItem key |
|||
|
|||
process.nextTick -> |
|||
callback(null) |
|||
|
|||
# TODO: .touch |
|||
|
@ -0,0 +1,74 @@ |
|||
Promise = require "bluebird" |
|||
persist = require "node-persist" |
|||
path = require "path" |
|||
xtend = require "xtend" |
|||
|
|||
# We MUST explicitly specify the `persist` directory, otherwise node-persist will bug out and write to its own module directory... |
|||
persist.initSync(continuous: false, dir: path.join(__dirname, "../persist")) |
|||
|
|||
persist.increment = (key, amount = 1) -> |
|||
persist.setItem key, (persist.getItem(key) + amount) |
|||
|
|||
persist.decrement = (key, amount = 1) -> |
|||
persist.setItem key, (persist.getItem(key) - amount) |
|||
|
|||
persist.addListItem = (key, item) -> |
|||
newList = [item].concat (persist.getItem(key) ? []) |
|||
|
|||
persist.setItem key, newList |
|||
|
|||
persist.removeListItem = (key, item) -> |
|||
newList = (persist.getItem(key) ? []) |
|||
.filter (existingItem) -> |
|||
return (item == existingItem) |
|||
|
|||
persist.setItem key, newList |
|||
|
|||
persist.removeListItemByFilter = (key, filter) -> |
|||
newList = (persist.getItem(key) ? []) |
|||
.filter (item) -> |
|||
return !filter(item) |
|||
|
|||
persist.setItem key, newList |
|||
|
|||
persist.setProperty = (key, propertyKey, value) -> |
|||
newObj = {} |
|||
newObj[propertyKey] = value |
|||
oldObj = persist.get(key) |
|||
|
|||
persist.setItem key, xtend(oldObj, newObj) |
|||
|
|||
persist.removeProperty = (key, propertyKey) -> |
|||
# Extremely ghetto shallow clone |
|||
obj = xtend({}, persist.get(key)) |
|||
delete obj[propertyKey] |
|||
|
|||
persist.setItem key, obj |
|||
|
|||
# Rough shim for write queueing... |
|||
writeQueue = [] |
|||
currentlyWriting = false |
|||
_setItem = persist.setItem |
|||
|
|||
persist.setItem = (key, value) -> |
|||
new Promise (resolve, reject) -> |
|||
_setItem.call(persist, key, value) |
|||
addItemToQueue key, value, resolve, reject |
|||
triggerWrite() |
|||
|
|||
addItemToQueue = (key, value, resolveFunc, rejectFunc) -> |
|||
writeQueue.push [key, value, resolveFunc, rejectFunc] |
|||
|
|||
triggerWrite = -> |
|||
if not currentlyWriting and writeQueue.length > 0 |
|||
currentlyWriting = 1 |
|||
[key, value, resolveFunc, rejectFunc] = writeQueue.shift() |
|||
|
|||
Promise.resolve(persist.persistKey(key)) |
|||
.then (result) -> resolveFunc(result) |
|||
.catch (err) -> rejectFunc(err) |
|||
.finally -> |
|||
currentlyWriting = false |
|||
triggerWrite() |
|||
|
|||
module.exports = persist |
@ -0,0 +1,15 @@ |
|||
Promise = require "bluebird" |
|||
crypto = Promise.promisifyAll(require "crypto") |
|||
|
|||
module.exports = (length = 16) -> |
|||
Promise.try -> |
|||
byteLength = Math.ceil(length / 4) * 3 |
|||
return crypto.randomBytesAsync(byteLength) |
|||
.then (bytes) -> |
|||
bytes = bytes |
|||
.toString "base64" |
|||
.replace /\+/g, "-" |
|||
.replace /\//g, "_" |
|||
.slice 0, length |
|||
|
|||
Promise.resolve bytes |
@ -0,0 +1,34 @@ |
|||
class RateLimiter |
|||
constructor: (@limit, @interval, @funcA, @funcB) -> |
|||
@_totalCalls = 0 |
|||
@_startTimer() |
|||
_startTimer: -> |
|||
@_timer = setInterval @_clearCalls, @interval |
|||
_stopTimer: -> |
|||
if @_timer? |
|||
clearInterval @_timer |
|||
@_timer = null |
|||
_clearCalls: -> |
|||
@_totalCalls = 0 |
|||
call: -> |
|||
@_totalCalls += 1 |
|||
|
|||
targetFunc = switch |
|||
when @_totalCalls <= @limit then @funcA |
|||
else @funcB |
|||
|
|||
targetFunc.apply this, arguments |
|||
setInterval: (interval) -> |
|||
@_stopTimer() |
|||
@interval = interval |
|||
@_startTimer() |
|||
setLimit: (limit) -> |
|||
@limit = limit |
|||
|
|||
module.exports = -> |
|||
return (funcA, funcB, options) -> |
|||
if not options.limit? |
|||
throw new Error("No limit specified.") |
|||
options.interval ?= 60 # Default: 60 seconds ie. 1 minute. |
|||
|
|||
return new RateLimiter(options.limit, options.interval, funcA, funcB) |
@ -0,0 +1,12 @@ |
|||
Promise = require "bluebird" |
|||
concatStream = require "concat-stream" |
|||
buffertools = require "buffertools" |
|||
|
|||
module.exports = (stream, needle) -> |
|||
# CAUTION: This buffers up in memory! |
|||
new Promise (resolve, reject) -> |
|||
stream |
|||
.pipe concatStream (result) -> |
|||
resolve buffertools.indexOf(result, needle) != -1 |
|||
.on "error", (err) -> |
|||
reject err |
@ -0,0 +1,13 @@ |
|||
# NOTE: This is purely a `tap` equivalent for errors! Any resolves or rejections are ignored - promises can only be used to wait for async execution of something. |
|||
|
|||
Promise = require "bluebird" |
|||
|
|||
module.exports = (func) -> |
|||
return (err) -> |
|||
Promise.try -> |
|||
func(err) |
|||
.catch -> |
|||
# Consume any errors |
|||
Promise.resolve() |
|||
.then -> |
|||
Promise.reject(err) |
@ -0,0 +1,95 @@ |
|||
EventEmitter = require("events").EventEmitter |
|||
Promise = require "bluebird" |
|||
debug = require("debug")("task-runner") |
|||
|
|||
makeExternalPromise = -> |
|||
extResolve = extReject = null |
|||
new Promise (resolve, reject) -> |
|||
extResolve = resolve |
|||
extReject = reject |
|||
|
|||
module.exports = class TaskRunner extends EventEmitter |
|||
constructor: (@context = {}) -> |
|||
@_taskTypes = {} |
|||
@_queue = {} |
|||