Skip to content

Commit

Permalink
feat: api endpoint to get from bucket and gateway using worker binding
Browse files Browse the repository at this point in the history
  • Loading branch information
vasco-santos committed Jul 1, 2022
1 parent 08b48f8 commit d098a81
Show file tree
Hide file tree
Showing 17 changed files with 465 additions and 413 deletions.
4 changes: 2 additions & 2 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@ $ curl -X GET -H 'Authorization: Bearer YOUR_API_KEY' https://api.nftstorage.lin
]
```

### 🔒 `GET /perma-cache/status`
### 🔒 `GET /perma-cache/account`

> Get perma-cache account status.
```console
$ curl -X GET -H 'Authorization: Bearer YOUR_API_KEY' https://api.nftstorage.link/perma-cache/status -s | jq
$ curl -X GET -H 'Authorization: Bearer YOUR_API_KEY' https://api.nftstorage.link/perma-cache/account -s | jq
{
"usedStorage": "8036759278"
}
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export class TokenNotFoundError extends HTTPError {
}
TokenNotFoundError.CODE = 'ERROR_TOKEN_NOT_FOUND'

export class UrlNotFoundError extends Error {
/**
* @param {string} message
*/
constructor(message = 'URL Not Found') {
super(message)
this.name = 'UrlNotFoundError'
this.status = 404
this.code = UrlNotFoundError.CODE
}
}
UrlNotFoundError.CODE = 'ERROR_URL_NOT_FOUND'

export class UnrecognisedTokenError extends HTTPError {
constructor(msg = 'Could not parse provided API token') {
super(msg, 401)
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Router } from 'itty-router'
import { withAuth } from './auth.js'
import {
permaCachePost,
permaCacheGet,
permaCacheListGet,
permaCacheAccountGet,
permaCacheDelete,
Expand Down Expand Up @@ -32,6 +33,7 @@ router
.get('/perma-cache/status', (request) => {
return Response.redirect(request.url.replace('status', 'account'), 302)
})
.get('/perma-cache/:url', permaCacheGet)
.delete('/perma-cache/:url', auth['🔒'](permaCacheDelete))

/**
Expand Down
3 changes: 1 addition & 2 deletions packages/api/src/perma-cache/delete.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-env serviceworker, browser */

// TODO: Move to separate file
import { getSourceUrl, getNormalizedUrl } from './post.js'
import { getSourceUrl, getNormalizedUrl } from '../utils/url.js'
import { JSONResponse } from '../utils/json-response.js'
/**
* @typedef {import('../env').Env} Env
Expand Down
92 changes: 12 additions & 80 deletions packages/api/src/perma-cache/get.js
Original file line number Diff line number Diff line change
@@ -1,96 +1,28 @@
/* eslint-env serviceworker, browser */
/* global Response */

import { JSONResponse } from '../utils/json-response.js'
import { getSourceUrl, getNormalizedUrl } from '../utils/url.js'
import { UrlNotFoundError } from '../errors.js'

/**
* @typedef {import('../env').Env} Env
*/

/**
* Handle perma-cache put request
* Handle perma-cache get request
*
* @param {Request} request
* @param {Env} env
*/
export async function permaCacheListGet(request, env) {
const requestUrl = new URL(request.url)
const { searchParams } = requestUrl
const { size, page, sort, order } = parseSearchParams(searchParams)

const entries = await env.db.listPermaCache(request.auth.user.id, {
size,
page,
sort,
order,
})

// Get next page link
const headers =
entries.length === size
? {
Link: `<${requestUrl.pathname}?size=${size}&page=${
page + 1
}>; rel="next"`,
}
: undefined
return new JSONResponse(entries, { headers })
}

/**
* @param {URLSearchParams} searchParams
*/
function parseSearchParams(searchParams) {
// Parse size parameter
let size = 25
if (searchParams.has('size')) {
const parsedSize = parseInt(searchParams.get('size'))
if (isNaN(parsedSize) || parsedSize <= 0 || parsedSize > 1000) {
throw Object.assign(new Error('invalid page size'), { status: 400 })
}
size = parsedSize
export async function permaCacheGet(request, env) {
const sourceUrl = getSourceUrl(request, env)
const normalizedUrl = getNormalizedUrl(sourceUrl, env)
const r2Key = normalizedUrl.toString()

const r2Object = await env.SUPERHOT.get(r2Key)
if (r2Object) {
return new Response(r2Object.body)
}

// Parse cursor parameter
let page = 0
if (searchParams.has('page')) {
const parsedPage = parseInt(searchParams.get('page'))
if (isNaN(parsedPage) || parsedPage <= 0) {
throw Object.assign(new Error('invalid page number'), { status: 400 })
}
page = parsedPage
}

// Parse sort parameter
let sort = 'date'
if (searchParams.has('sort')) {
const parsedSort = searchParams.get('sort')
if (parsedSort !== 'date' && parsedSort !== 'size') {
throw Object.assign(
new Error('invalid list sort, either "date" or "size"'),
{ status: 400 }
)
}
sort = parsedSort
}

// Parse order parameter
let order = 'asc'
if (searchParams.has('order')) {
const parsedOrder = searchParams.get('order')
if (parsedOrder !== 'asc' && parsedOrder !== 'desc') {
throw Object.assign(
new Error('invalid list sort order, either "asc" or "desc"'),
{ status: 400 }
)
}
sort = parsedOrder
}

return {
size,
page,
sort,
order,
}
throw new UrlNotFoundError()
}
3 changes: 2 additions & 1 deletion packages/api/src/perma-cache/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { permaCachePost } from './post.js'
export { permaCacheListGet } from './get.js'
export { permaCacheGet } from './get.js'
export { permaCacheAccountGet } from './account.js'
export { permaCacheListGet } from './list.js'
export { permaCacheDelete } from './delete.js'
96 changes: 96 additions & 0 deletions packages/api/src/perma-cache/list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/* eslint-env serviceworker, browser */
/* global Response */

import { JSONResponse } from '../utils/json-response.js'

/**
* @typedef {import('../env').Env} Env
*/

/**
* Handle perma-cache list get request
*
* @param {Request} request
* @param {Env} env
*/
export async function permaCacheListGet(request, env) {
const requestUrl = new URL(request.url)
const { searchParams } = requestUrl
const { size, page, sort, order } = parseSearchParams(searchParams)

const entries = await env.db.listPermaCache(request.auth.user.id, {
size,
page,
sort,
order,
})

// Get next page link
const headers =
entries.length === size
? {
Link: `<${requestUrl.pathname}?size=${size}&page=${
page + 1
}>; rel="next"`,
}
: undefined
return new JSONResponse(entries, { headers })
}

/**
* @param {URLSearchParams} searchParams
*/
function parseSearchParams(searchParams) {
// Parse size parameter
let size = 25
if (searchParams.has('size')) {
const parsedSize = parseInt(searchParams.get('size'))
if (isNaN(parsedSize) || parsedSize <= 0 || parsedSize > 1000) {
throw Object.assign(new Error('invalid page size'), { status: 400 })
}
size = parsedSize
}

// Parse cursor parameter
let page = 0
if (searchParams.has('page')) {
const parsedPage = parseInt(searchParams.get('page'))
if (isNaN(parsedPage) || parsedPage <= 0) {
throw Object.assign(new Error('invalid page number'), { status: 400 })
}
page = parsedPage
}

// Parse sort parameter
let sort = 'date'
if (searchParams.has('sort')) {
const parsedSort = searchParams.get('sort')
if (parsedSort !== 'date' && parsedSort !== 'size') {
throw Object.assign(
new Error('invalid list sort, either "date" or "size"'),
{ status: 400 }
)
}
sort = parsedSort
}

// Parse order parameter
let order = 'asc'
if (searchParams.has('order')) {
const parsedOrder = searchParams.get('order')
if (parsedOrder !== 'asc' && parsedOrder !== 'desc') {
throw Object.assign(
new Error('invalid list sort order, either "asc" or "desc"'),
{ status: 400 }
)
}
sort = parsedOrder
}

return {
size,
page,
sort,
order,
}
}
93 changes: 3 additions & 90 deletions packages/api/src/perma-cache/post.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
/* eslint-env serviceworker, browser */
/* global Response */

import {
MAX_ALLOWED_URL_LENGTH,
INVALID_PERMA_CACHE_CACHE_CONTROL_DIRECTIVES,
} from '../constants.js'
import {
InvalidUrlError,
TimeoutError,
HTTPError,
ExpectationFailedError,
} from '../errors.js'
import { INVALID_PERMA_CACHE_CACHE_CONTROL_DIRECTIVES } from '../constants.js'
import { TimeoutError, HTTPError, ExpectationFailedError } from '../errors.js'
import { JSONResponse } from '../utils/json-response.js'
import { normalizeCid } from '../utils/cid.js'
import { getSourceUrl, getNormalizedUrl } from '../utils/url.js'

/**
* @typedef {import('../env').Env} Env
Expand Down Expand Up @@ -114,85 +106,6 @@ async function getResponse(request, env, url) {
return response
}

/**
* Verify if provided url is a valid nftstorage.link URL
* Returns subdomain format.
*
* @param {Request} request
* @param {Env} env
*/
export function getSourceUrl(request, env) {
let candidateUrl
try {
candidateUrl = new URL(decodeURIComponent(request.params.url))
} catch (err) {
throw new InvalidUrlError(
`invalid URL provided: ${request.params.url}: ${err.message}`
)
}

const urlString = candidateUrl.toString()
if (urlString.length > MAX_ALLOWED_URL_LENGTH) {
throw new InvalidUrlError(
`invalid URL provided: ${request.params.url}: maximum allowed length or URL is ${MAX_ALLOWED_URL_LENGTH}`
)
}
if (!urlString.includes(env.GATEWAY_DOMAIN)) {
throw new InvalidUrlError(
`invalid URL provided: ${urlString}: not nftstorage.link URL`
)
}

return candidateUrl
}

/**
* Verify if candidate url has IPFS path or IPFS subdomain, returning subdomain format.
*
* @param {URL} candidateUrl
* @param {Env} env
*/
export function getNormalizedUrl(candidateUrl, env) {
// Verify if IPFS path resolution URL
const ipfsPathParts = candidateUrl.pathname.split('/ipfs/')
if (ipfsPathParts.length > 1) {
const pathParts = ipfsPathParts[1].split(/\/(.*)/s)
const cid = getCid(pathParts[0])

// Parse path + query params
const path = pathParts[1] ? `/${pathParts[1]}` : ''
const queryParamsCandidate = candidateUrl.searchParams.toString()
const queryParams = queryParamsCandidate.length
? `?${queryParamsCandidate}`
: ''

return new URL(
`${candidateUrl.protocol}//${cid}.ipfs.${env.GATEWAY_DOMAIN}${path}${queryParams}`
)
}

// Verify if subdomain resolution URL
const subdomainParts = candidateUrl.hostname.split('.ipfs.')
if (subdomainParts.length <= 1) {
throw new InvalidUrlError(
`invalid URL provided: ${candidateUrl}: not subdomain nor ipfs path available`
)
}

return candidateUrl
}

/**
* @param {string} candidateCid
*/
function getCid(candidateCid) {
try {
return normalizeCid(candidateCid)
} catch (err) {
throw new InvalidUrlError(`invalid CID: ${candidateCid}: ${err.message}`)
}
}

/**
* Validates cache control header to verify if we should perma cache the response.
* Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
Expand Down
Loading

0 comments on commit d098a81

Please sign in to comment.