"use strict" ;
const Promise = require ( "bluebird" ) ;
const express = require ( "express" ) ;
const bhttp = require ( "bhttp" ) ;
const knex = require ( "knex" ) ;
const expressReactViews = require ( "@joepie91/express-react-views" ) ;
const expressSession = require ( "express-session" ) ;
const KnexSessionStore = require ( "connect-session-knex" ) ( expressSession ) ;
const expressPromiseRouter = require ( "express-promise-router" ) ;
const bodyParser = require ( "body-parser" ) ;
const path = require ( "path" ) ;
const url = require ( "url" ) ;
const createError = require ( "create-error" ) ;
const stripTags = require ( "striptags" ) ;
const escapeStringRegexp = require ( "escape-string-regexp" ) ;
const configuration = require ( process . env . PACT _CONFIG _FILE ? ? "./configuration.json" ) ;
const createRateLimiter = require ( "./rate-limiter" ) ;
const normalizeAddress = require ( "./normalize-address" ) ;
const lookupUser = require ( "./lookup-user" ) ;
const hashtagRegex = new RegExp ( ` ${ escapeStringRegexp ( configuration . hashtag ) } \\ b ` ) ;
const AdminValidationError = createError ( "AdminValidationError" ) ;
const NotSignedError = createError ( "NotSignedError" ) ;
const SignatureExistsError = createError ( "SignatureExistsError" ) ;
const InvalidInputError = createError ( "InvalidInputError" ) ;
const RateLimitExceeded = createError ( "RateLimitExceeded" ) ;
// Max 10 submissions per IP
let rateLimiter = createRateLimiter ( 10 ) ;
let db = knex ( require ( "./knexfile" ) ) ;
function checkExistence ( submitter , hostname ) {
return Promise . try ( ( ) => {
let query = db ( "signatories" ) . where ( { submitter : submitter } ) ;
if ( hostname != null ) {
query = query . orWhere ( { hostname : hostname } ) ;
}
return query ;
} ) . then ( ( results ) => {
if ( results . length > 0 ) {
return true ;
} else {
return false ;
}
} ) ;
}
function requireAdmin ( req , res , next ) {
if ( req . session . loggedIn ) {
next ( ) ;
} else {
res . redirect ( "/login" ) ;
}
}
let app = express ( ) ;
app . set ( "trust proxy" , "loopback" ) ;
app . set ( path . resolve ( _ _dirname , "./views" ) ) ;
app . set ( "view engine" , "jsx" ) ;
app . engine ( "jsx" , expressReactViews . createEngine ( ) ) ;
app . use ( express . static ( path . resolve ( _ _dirname , "./static" ) ) ) ;
app . use ( expressSession ( {
secret : configuration . sessionSecret ,
resave : false ,
saveUninitialized : false ,
store : new KnexSessionStore ( {
knex : db
} )
} ) ) ;
app . use ( bodyParser . urlencoded ( { extended : false } ) ) ;
let router = expressPromiseRouter ( ) ;
router . get ( "/" , ( req , res ) => {
return Promise . try ( async ( ) => {
let signatories = await ( db ( "signatories" ) . where ( { isVerified : true } ) ) ;
res . render ( "index" , {
hashtag : configuration . hashtag ,
signatories : signatories
} ) ;
} ) ;
} ) ;
// GET requests (see also 3.2 Retrieving objects) with an Accept header of application/ld+json; profile="https://www.w3.org/ns/activitystreams".
router . post ( "/submit" , ( req , res ) => {
const { testValue } = require ( "@validatem/core" ) ;
const required = require ( "@validatem/required" ) ;
const isNonEmptyString = require ( "@validatem/is-non-empty-string" ) ;
const allowExtraProperties = require ( "@validatem/allow-extra-properties" ) ;
return Promise . try ( async ( ) => {
let validInput = testValue ( req . body , {
submitter : [ required , isNonEmptyString ]
} ) ;
if ( ! validInput ) {
throw new InvalidInputError ( "invalid input" ) ;
}
let address = normalizeAddress ( req . body . submitter ) ;
let user = await lookupUser ( address ) ;
let exists = await checkExistence ( address , user . hostname ) ;
if ( exists ) {
throw new SignatureExistsError ( ` Already signed ` ) ;
}
if ( user . apiURL == null ) {
throw new AdminValidationError ( ` no webfinger response ` , { submitter : address } ) ;
}
let instanceResponse = await bhttp . get ( user . apiURL , {
headers : {
"User-Agent" : "anti-meta-pact signatory verifier (trouble: admin@cryto.net)"
}
} ) ;
if ( instanceResponse . statusCode !== 200 ) {
throw new AdminValidationError ( ` invalid response code ` , { submitter : address } ) ;
}
let validResponse = testValue ( instanceResponse . body , allowExtraProperties ( {
contact _account : allowExtraProperties ( {
username : [ required , isNonEmptyString ] ,
note : [ required , isNonEmptyString ]
} ) ,
uri : [ required , isNonEmptyString ]
} ) ) ;
if ( ! validResponse ) {
throw new AdminValidationError ( ` invalid API response ` , { submitter : address } ) ;
}
let uri = instanceResponse . body . uri ;
let domain = uri . includes ( "://" )
? url . parse ( uri ) . host // Workaround for GoToSocial bug
: uri ;
let adminID = ` ${ instanceResponse . body . contact _account . username } @ ${ domain } ` ;
let bio = instanceResponse . body . contact _account . note ;
// Hacky way to not break the regex matching (as just stripping tags altogether might remove spaces necessary for word boundaries)
if ( ! hashtagRegex . test ( stripTags ( bio , [ "p" , "div" ] ) ) ) {
throw new NotSignedError ( ` Signature not found in bio ` ) ;
}
if ( ! rateLimiter . tryMark ( req . ip ) ) {
throw new RateLimitExceeded ( ` Rate limit exceeded ` ) ;
}
await db ( "signatories" ) . insert ( {
submitter : address ,
admin : adminID ,
hostname : domain ,
isVerified : true ,
isManual : false ,
addedDate : new Date ( )
} ) ;
res . send ( "Thanks! We've successfully verified your signature, and you've been added to the list." ) ;
} ) . catch ( AdminValidationError , async ( error ) => {
await db ( "signatories" ) . insert ( {
submitter : error . submitter ,
isVerified : false ,
isManual : false ,
addedDate : new Date ( )
} ) ;
res . send ( ` Thanks! We could not automatically validate your submission ( ${ error . message } ), but we've added it to the manual approval list. ` ) ;
} ) . catch ( SignatureExistsError , ( _error ) => {
res . send ( ` Error: We already have a submission for this instance; if it hasn't appeared on the site yet, it's still in queue to be approved manually! ` ) ;
} ) . catch ( NotSignedError , ( _error ) => {
res . send ( ` Error: We could not find the hashtag in your admin account's bio; please add it, and try again. ` ) ;
} ) . catch ( InvalidInputError , ( _error ) => {
res . send ( "Error: you need to enter your fedi ID to sign this! Please go back and try again." ) ;
} ) . catch ( RateLimitExceeded , ( _error ) => {
res . send ( "You've already submitted 10 entries; you cannot submit any more. Please contact us if you really do run more instances than that." ) ;
} ) ;
} ) ;
router . get ( "/login" , ( req , res ) => {
res . render ( "login" ) ;
} ) ;
router . post ( "/login" , ( req , res ) => {
return Promise . try ( async ( ) => {
if ( req . body . password === configuration . adminPassword ) {
req . session . loggedIn = true ;
await Promise . promisify ( req . session . save . bind ( req . session ) ) ( ) ;
res . redirect ( "/admin" ) ;
} else {
res . send ( "Invalid password" ) ;
}
} ) ;
} ) ;
router . get ( "/admin" , requireAdmin , ( req , res ) => {
return Promise . try ( async ( ) => {
let queue = await db ( "signatories" ) . where ( { isVerified : false } ) ;
res . render ( "admin" , { queue : queue } ) ;
} ) ;
} ) ;
router . post ( "/approve" , requireAdmin , ( req , res ) => {
return Promise . try ( async ( ) => {
if ( req . body . decision === "approve" ) {
await db ( "signatories" )
. update ( {
isVerified : true ,
admin : req . body . submitter
} )
. where ( { submitter : req . body . submitter } ) ;
res . redirect ( "/admin" ) ;
} else if ( req . body . decision === "reject" ) {
await db ( "signatories" )
. delete ( )
. where ( { submitter : req . body . submitter } ) ;
res . redirect ( "/admin" ) ;
} else {
throw new Error ( ` Unexpected decision value ` ) ;
}
} ) ;
} ) ;
router . post ( "/add" , requireAdmin , ( req , res ) => {
return Promise . try ( async ( ) => {
await db ( "signatories" ) . insert ( {
submitter : normalizeAddress ( req . body . address ) ,
admin : normalizeAddress ( req . body . address ) ,
isVerified : true ,
isManual : true ,
addedDate : new Date ( )
} ) ;
res . redirect ( "/admin" ) ;
} ) ;
} ) ;
app . use ( router ) ;
app . use ( ( req , res ) => {
res . status ( 404 ) . send ( "Not found" ) ;
} ) ;
app . use ( ( error , req , res , _next ) => {
console . error ( error . message ) ;
console . error ( error . stack ) ;
res . status ( 500 ) . send ( "Internal server error" ) ;
} ) ;
app . listen ( 3500 , ( ) => {
console . log ( "Server running at port 3500" ) ;
} ) ;