Commit b3db0a44 authored by Dan Notestein's avatar Dan Notestein

Merge branch 'hivesigner' into 'master'

Upload images with Hivesigner access token

See merge request !1
parents 869c0543 64602940
......@@ -2,7 +2,7 @@
imagehoster
===========
Steem-powered image hosting and proxying service.
Hive-powered image hosting and proxying service.
Developing
......@@ -55,7 +55,21 @@ Returns a JSON object containing the url to the uploaded image, example:
}
```
Requires a signature from a Steem account in good standing, see the "Signing uploads" section below for more information.
Requires a signature from a Hive account in good standing, see the "Signing uploads" section below for more information.
#### `POST /hs/<accesstoken>` - upload an image with Hivesigner accessToken.
Multipart image upload, will only consider first file if there are multiple.
Returns a JSON object containing the url to the uploaded image, example:
```json
{
"url": "https://images.example.com/DQmZi174Xz96UrRVBMNRHb6A2FfU3z1HRPwPPQCgSMgdiUT/test.jpg"
}
```
Requires a access token from a Hivesigner authorized account, for more info: https://hivesigner.com.
#### `GET /<image_hash>/[<filename>]` - fetch an uploaded image.
......@@ -138,7 +152,7 @@ Note that the avatars follow the same sizing rules as proxied images, so you are
Signing uploads
---------------
Uploads require a signature made with by a Steem account's posting authority, further that account has to be above a (service configurable) reputation threshold.
Uploads require a signature made with by a Hive account's posting authority, further that account has to be above a (service configurable) reputation threshold.
Creating a signature (psuedocode):
......@@ -146,12 +160,12 @@ Creating a signature (psuedocode):
signature = secp256k1_sign(sha256('ImageSigningChallenge'+image_data), account_private_posting_key)
```
Creating a signature (node.js & [dsteem](https://github.com/jnordberg/dsteem))
Creating a signature (node.js & [dhive](https://github.com/openhive-network/dhive))
```js
#!/usr/bin/env node
const dsteem = require('dsteem')
const dhive = require('@hiveio/dhive')
const crypto = require('crypto')
const fs = require('fs')
......@@ -163,7 +177,7 @@ if (!wif || !file) {
}
const data = fs.readFileSync(file)
const key = dsteem.PrivateKey.fromString(wif)
const key = dhive.PrivateKey.fromString(wif)
const imageHash = crypto.createHash('sha256')
.update('ImageSigningChallenge')
.update(data)
......
......@@ -38,6 +38,7 @@ max_image_size = 10000000 # 10mb
duration = 604800000 # in ms (=1 week)
max = 300 # max requests within duration
reputation = 10 # minimum reputation needed for upload
app_account = 'hive.blog'
# blob stores, valid types are: memory, s3
# the s3 type additionally needs the s3_bucket key to be set and expects
......
......@@ -9,13 +9,14 @@
"node": ">=8"
},
"dependencies": {
"@hiveio/dhive": "^0.14.0",
"@koa/cors": "^2.2.1",
"abstract-blob-store": "^3.3.4",
"aws-sdk": "^2.188.0",
"bunyan": "^2.0.2",
"busboy": "^0.2.14",
"config": "^1.29.4",
"dsteem": "^0.9.0",
"hivesigner": "^3.2.0",
"koa": "^2.3.0",
"koa-router": "^7.4.0",
"mmmagic": "^0.5.0",
......
......@@ -3,7 +3,7 @@
import * as config from 'config'
import { base58Enc } from './utils'
import { Account } from 'dsteem'
import { Account } from '@hiveio/dhive'
import {KoaContext, rpcClient} from './common'
import {APIError} from './error'
......
......@@ -2,7 +2,7 @@
import {AbstractBlobStore} from 'abstract-blob-store'
import * as config from 'config'
import {Client} from 'dsteem'
import {Client} from '@hiveio/dhive'
import {IRouterContext} from 'koa-router'
import * as Redis from 'redis'
......
......@@ -7,7 +7,7 @@ import {KoaContext} from './common'
import {legacyProxyHandler} from './legacy-proxy'
import {proxyHandler} from './proxy'
import {serveHandler} from './serve'
import {uploadHandler} from './upload'
import {uploadHandler, uploadHsHandler} from './upload'
const version = require('./version')
const router = new Router()
......@@ -22,6 +22,7 @@ async function healthcheck(ctx: KoaContext) {
router.get('/', healthcheck as any)
router.get('/.well-known/healthcheck.json', healthcheck as any)
router.get('/u/:username/avatar/:size?', avatarHandler as any)
router.post('/hs/:accesstoken', uploadHsHandler as any)
router.post('/:username/:signature', uploadHandler as any)
router.get('/:width(\\d+)x:height(\\d+)/:url(.*)', legacyProxyHandler as any)
router.get('/p/:url', proxyHandler as any)
......
......@@ -3,11 +3,12 @@
import * as Busboy from 'busboy'
import * as config from 'config'
import {createHash} from 'crypto'
import {Client, Signature} from 'dsteem'
import {Client, Signature} from '@hiveio/dhive'
import * as http from 'http'
import * as multihash from 'multihashes'
import * as RateLimiter from 'ratelimiter'
import {URL} from 'url'
import * as hivesigner from 'hivesigner'
import {accountBlacklist} from './blacklist'
import {KoaContext, redisClient, rpcClient, uploadStore} from './common'
......@@ -77,7 +78,122 @@ async function getRatelimit(account: string) {
})
})
}
const b64uLookup = {
'/': '_', _: '/', '+': '-', '-': '+', '=': '.', '.': '=',
}
function b64uToB64 (str: string) {
const tt = str.replace(/(-|_|\.)/g, function(m) { return b64uLookup[m]})
return tt
}
export async function uploadHsHandler(ctx: KoaContext) {
ctx.tag({handler: 'hsupload'})
let validSignature = false
APIError.assert(ctx.method === 'POST', {code: APIError.Code.InvalidMethod})
APIError.assertParams(ctx.params, ['accesstoken'])
APIError.assert(ctx.get('content-type').includes('multipart/form-data'),
{message: 'Only multipart uploads are supported'})
const contentLength = Number.parseInt(ctx.get('content-length'))
APIError.assert(Number.isFinite(contentLength),
APIError.Code.LengthRequired)
APIError.assert(contentLength <= MAX_IMAGE_SIZE,
APIError.Code.PayloadTooLarge)
const file = await parseMultipart(ctx.req)
const data = await readStream(file.stream)
// extra check if client manges to lie about the content-length
APIError.assert((file.stream as any).truncated !== true,
APIError.Code.PayloadTooLarge)
const imageHash = createHash('sha256')
.update('ImageSigningChallenge')
.update(data)
.digest()
const token = ctx.params['accesstoken']
const decoded = Buffer.from(b64uToB64(token), 'base64').toString()
const tokenObj = JSON.parse(decoded)
const signedMessage = tokenObj.signed_message
if (
tokenObj.authors
&& tokenObj.authors[0]
&& tokenObj.signatures
&& tokenObj.signatures[0]
&& signedMessage
&& signedMessage.type
&& ['login', 'posting', 'offline', 'code', 'refresh']
.includes(signedMessage.type)
&& signedMessage.app
) {
const username = tokenObj.authors[0]
let account = {
name: '',
reputation: 0,
}
const cl = new hivesigner.Client({
app: UPLOAD_LIMITS.app_account,
accessToken: token,
})
await cl.me(function (err: any, res: any) {
if (!err && res) {
account = res.account
APIError.assert(account, APIError.Code.NoSuchAccount)
ctx.log.warn('uploading app %s', signedMessage.app)
APIError.assert(username === account.name, APIError.Code.InvalidSignature)
APIError.assert(signedMessage.app === UPLOAD_LIMITS.app_account, APIError.Code.InvalidSignature)
APIError.assert(res.scope.includes('comment'), APIError.Code.InvalidSignature)
if (account && account.name) {
['posting', 'active', 'owner'].forEach((type) => {
account[type].account_auths.forEach((key: string[]) => {
if (
!validSignature
&& key[0] === UPLOAD_LIMITS.app_account
) {
validSignature = true;
}
});
});
}
}
});
APIError.assert(validSignature, APIError.Code.InvalidSignature)
APIError.assert(!accountBlacklist.includes(account.name), APIError.Code.Blacklisted)
let limit: RateLimit = {total: 0, remaining: Infinity, reset: 0}
try {
limit = await getRatelimit(account.name)
} catch (error) {
ctx.log.warn(error, 'unable to enforce upload rate limits')
}
APIError.assert(limit.remaining > 0, APIError.Code.QoutaExceeded)
APIError.assert(repLog10(account.reputation) >= UPLOAD_LIMITS.reputation, APIError.Code.Deplorable)
const key = 'D' + multihash.toB58String(multihash.encode(imageHash, 'sha2-256'))
const url = new URL(`${ key }/${ file.name }`, SERVICE_URL)
if (!(await storeExists(uploadStore, key))) {
await storeWrite(uploadStore, key, data)
} else {
ctx.log.debug('key %s already exists in store', key)
}
ctx.log.info({uploader: account.name, size: data.byteLength}, 'image uploaded')
ctx.status = 200
ctx.body = {url}
}
}
export async function uploadHandler(ctx: KoaContext) {
ctx.tag({handler: 'upload'})
......
import 'mocha'
import * as assert from 'assert'
import {PrivateKey} from 'dsteem'
import {PrivateKey} from '@hiveio/dhive'
import {rpcClient} from './../src/common'
......@@ -41,7 +41,7 @@ export const mockAccounts: any = {
}
before(() => {
// mock out dsteem rpc calls
// mock out dhive rpc calls
const _client = rpcClient as any
_client.call = async (api: string, method: string, params = []) => {
const apiMethod = `${ api }-${ method }`
......
......@@ -5,7 +5,7 @@ import * as needle from 'needle'
import * as path from 'path'
import * as fs from 'fs'
import * as crypto from 'crypto'
import {PrivateKey} from 'dsteem'
import {PrivateKey} from '@hiveio/dhive'
import {app} from './../src/app'
import {rpcClient} from './../src/common'
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment