diff --git a/package-lock.json b/package-lock.json index 64c26fee37..5fa9c42de4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5811,8 +5811,7 @@ "node_modules/@web3-storage/parse-link-header": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@web3-storage/parse-link-header/-/parse-link-header-3.1.0.tgz", - "integrity": "sha512-K1undnK70vLLauqdE8bq/l98isTF2FDhcP0UPpXVSjkSWe3xhAn5eRXk5jfA1E5ycNm84Ws/rQFUD7ue11nciw==", - "license": "MIT" + "integrity": "sha512-K1undnK70vLLauqdE8bq/l98isTF2FDhcP0UPpXVSjkSWe3xhAn5eRXk5jfA1E5ycNm84Ws/rQFUD7ue11nciw==" }, "node_modules/@web3-storage/tools": { "resolved": "packages/tools", @@ -31146,6 +31145,7 @@ "@mdx-js/react": "^2.1.1", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", + "@web3-storage/parse-link-header": "^3.1.0", "algoliasearch": "^4.13.0", "clsx": "^1.1.1", "countly-sdk-web": "^20.11.2", @@ -44408,6 +44408,7 @@ "@types/react-syntax-highlighter": "13.5.2", "@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/parser": "^5.12.1", + "@web3-storage/parse-link-header": "^3.1.0", "algoliasearch": "^4.13.0", "babel-eslint": "10.1.0", "clsx": "^1.1.1", diff --git a/packages/api/src/errors.js b/packages/api/src/errors.js index a9efd0ddc7..590c2cda01 100644 --- a/packages/api/src/errors.js +++ b/packages/api/src/errors.js @@ -205,3 +205,12 @@ export class PSAErrorDB extends PinningServiceApiError { } } PSAErrorDB.CODE = 'PSA_DB_ERROR' + +export class RangeNotSatisfiableError extends HTTPError { + constructor (msg = 'Range Not Satisfiable') { + super(msg, 416) + this.name = 'RangeNotSatisfiableError' + this.code = RangeNotSatisfiableError.CODE + } +} +RangeNotSatisfiableError.CODE = 'ERROR_RANGE_NOT_SATISFIABLE' diff --git a/packages/api/src/user.js b/packages/api/src/user.js index 3b12290069..db51bda7ff 100644 --- a/packages/api/src/user.js +++ b/packages/api/src/user.js @@ -1,7 +1,7 @@ import * as JWT from './utils/jwt.js' import { JSONResponse } from './utils/json-response.js' import { JWT_ISSUER } from './constants.js' -import { HTTPError } from './errors.js' +import { HTTPError, RangeNotSatisfiableError } from './errors.js' import { getTagValue, hasPendingTagProposal, hasTag } from './utils/tags.js' import { NO_READ_OR_WRITE, @@ -19,6 +19,7 @@ import { magicLinkBypassForE2ETestingInTestmode } from './magic.link.js' * @typedef {{ _id: string, name: string }} AuthToken * @typedef {{ user: User authToken?: AuthToken }} Auth * @typedef {Request & { auth: Auth }} AuthenticatedRequest + * @typedef {import('@web3-storage/db').PageRequest} PageRequest */ /** @@ -263,38 +264,86 @@ export async function userUploadsGet (request, env) { const requestUrl = new URL(request.url) const { searchParams } = requestUrl - const { size, page, offset, before, after, sortBy, sortOrder } = pagination(searchParams) + const pageRequest = pagination(searchParams) - const data = await env.db.listUploads(request.auth.user._id, { - size, - offset, - before, - after, - sortBy, - sortOrder - }) + let data + try { + data = await env.db.listUploads(request.auth.user._id, pageRequest) + } catch (err) { + if (err.code === 'RANGE_NOT_SATISFIABLE_ERROR_DB') { + throw new RangeNotSatisfiableError() + } + throw err + } - let link = '' - // If there's more results to show... - if (page > 1) { - link += `<${requestUrl.pathname}?size=${size}&page=${page - 1}>; rel="previous"` + const headers = { Count: data.count } + + if (pageRequest.size != null) { + headers.Size = pageRequest.size // Deprecated, use Link header instead. } - if (data.uploads.length + offset < data.count) { - if (link !== '') link += ', ' - link += `<${requestUrl.pathname}?size=${size}&page=${page + 1}>; rel="next"` + if (pageRequest.page != null) { + headers.Page = pageRequest.page // Deprecated, use Link header instead. } - const headers = { - Count: data.count, - Size: size, - Page: page, - Link: link + const link = getLinkHeader({ + url: requestUrl.pathname, + pageRequest, + items: data.uploads, + count: data.count + }) + + if (link) { + headers.Link = link } return new JSONResponse(data.uploads, { headers }) } +/** + * Generates a HTTP `Link` header for the given page request and data. + * + * @param {Object} args + * @param {string|URL} args.url Base URL + * @param {PageRequest} args.pageRequest Details for the current page of data + * @param {Array<{ created: string }>} args.items Page items + * @param {number} args.count Total items available + */ +function getLinkHeader ({ url, pageRequest, items, count }) { + const rels = [] + + if ('before' in pageRequest) { + const { size } = pageRequest + if (items.length === size) { + const oldest = items[items.length - 1] + const nextParams = new URLSearchParams({ size, before: oldest.created }) + rels.push(`<${url}?${nextParams}>; rel="next"`) + } + } else if ('page' in pageRequest) { + const { size, page } = pageRequest + const pages = Math.ceil(count / size) + if (page < pages) { + const nextParams = new URLSearchParams({ size, page: page + 1 }) + rels.push(`<${url}?${nextParams}>; rel="next"`) + } + + const lastParams = new URLSearchParams({ size, page: pages }) + rels.push(`<${url}?${lastParams}>; rel="last"`) + + const firstParams = new URLSearchParams({ size, page: 1 }) + rels.push(`<${url}?${firstParams}>; rel="first"`) + + if (page > 1) { + const prevParams = new URLSearchParams({ size, page: page - 1 }) + rels.push(`<${url}?${prevParams}>; rel="previous"`) + } + } else { + throw new Error('unknown page request type') + } + + return rels.join(', ') +} + /** * Delete an user upload. This actually raises a tombstone rather than * deleting it entirely. @@ -341,7 +390,7 @@ export async function userPinsGet (request, env) { const requestUrl = new URL(request.url) const { searchParams } = requestUrl - const { size, page, offset, before, after, sortBy, sortOrder } = pagination(searchParams) + const pageRequest = pagination(searchParams) const urlParams = new URLSearchParams(requestUrl.search) const params = Object.fromEntries(urlParams) @@ -357,36 +406,39 @@ export async function userPinsGet (request, env) { try { pinRequests = await env.db.listPsaPinRequests(tokens, { ...psaParams.data, - limit: size, - offset, - before, - after, - sortBy, - sortOrder + limit: pageRequest.size, + offset: pageRequest.size * (pageRequest.page - 1) }) - } catch (e) { - console.error(e) - throw new HTTPError('No pinning resources found for user', 404) + } catch (err) { + if (err.code === 'RANGE_NOT_SATISFIABLE_ERROR_DB') { + throw new RangeNotSatisfiableError() + } + throw err } const pins = pinRequests.results.map((pinRequest) => toPinStatusResponse(pinRequest)) - let link = '' - // If there's more results to show... - if (page > 1) { - link += `<${requestUrl.pathname}?size=${size}&page=${page - 1}>; rel="previous"` + const headers = { + Count: pinRequests.count } - if (pins.length + offset < pinRequests.count) { - if (link !== '') link += ', ' - link += `<${requestUrl.pathname}?size=${size}&page=${page + 1}>; rel="next"` + if (pageRequest.size != null) { + headers.Size = pageRequest.size // Deprecated, use Link header instead. } - const headers = { - Count: pinRequests.count || 0, - Size: size, - Page: page, - Link: link + if (pageRequest.page != null) { + headers.Page = pageRequest.page // Deprecated, use Link header instead. + } + + const link = getLinkHeader({ + url: requestUrl.pathname, + pageRequest, + items: pinRequests.results, + count: pinRequests.count + }) + + if (link) { + headers.Link = link } return new JSONResponse({ diff --git a/packages/api/src/utils/pagination.js b/packages/api/src/utils/pagination.js index d164c2047e..af11534514 100644 --- a/packages/api/src/utils/pagination.js +++ b/packages/api/src/utils/pagination.js @@ -1,3 +1,8 @@ +/** @type {import('@web3-storage/db').SortField[]} */ +const sortableValues = ['Name', 'Date'] +/** @type {import('@web3-storage/db').SortOrder[]} */ +const sortableOrders = ['Asc', 'Desc'] + /** * Get a parsed and validated list of pagination properties to pass to the DB query. * This standard is used across the website @@ -11,13 +16,10 @@ * ``` * * @param {URLSearchParams} searchParams - * + * @returns {import('@web3-storage/db').PageRequest} */ export function pagination (searchParams) { - const sortableValues = ['Name', 'Date'] - const sortableOrders = ['Asc', 'Desc'] - - let size = 25 + let size if (searchParams.has('size')) { const parsedSize = parseInt(searchParams.get('size')) if (isNaN(parsedSize) || parsedSize <= 0 || parsedSize > 1000) { @@ -26,53 +28,38 @@ export function pagination (searchParams) { size = parsedSize } - let offset = 0 - let page = 1 - 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 }) + if (!searchParams.has('page')) { + let before = new Date() + if (searchParams.has('before')) { + const parsedBefore = new Date(searchParams.get('before')) + if (isNaN(parsedBefore.getTime())) { + throw Object.assign(new Error('invalid before date'), { status: 400 }) + } + before = parsedBefore } - offset = (parsedPage - 1) * size - page = parsedPage + return { before, size } } - let before - if (searchParams.has('before')) { - const parsedBefore = new Date(searchParams.get('before')) - if (isNaN(parsedBefore.getTime())) { - throw Object.assign(new Error('invalid before date'), { status: 400 }) - } - before = parsedBefore.toISOString() + const page = parseInt(searchParams.get('page')) + if (isNaN(page) || page <= 0) { + throw Object.assign(new Error('invalid page number'), { status: 400 }) } - let after - if (searchParams.has('after')) { - const parsedAfter = new Date(searchParams.get('after')) - if (isNaN(parsedAfter.getTime())) { - throw Object.assign(new Error('invalid after date'), { status: 400 }) + let sortOrder + if (searchParams.has('sortOrder')) { + sortOrder = searchParams.get('sortOrder') + if (!sortableOrders.includes(sortOrder)) { + throw Object.assign(new Error(`Sort ordering by '${sortOrder}' is not supported. Supported sort orders are: [${sortableOrders.toString()}]`), { status: 400 }) } - after = parsedAfter.toISOString() } - const sortBy = searchParams.get('sortBy') || 'Date' - const sortOrder = searchParams.get('sortOrder') || 'Desc' - - if (!sortableOrders.includes(sortOrder)) { - throw Object.assign(new Error(`Sort ordering by '${sortOrder}' is not supported. Supported sort orders are: [${sortableOrders.toString()}]`), { status: 400 }) - } - - if (!sortableValues.includes(sortBy)) { - throw Object.assign(new Error(`Sorting by '${sortBy}' is not supported. Supported sort orders are: [${sortableValues.toString()}]`), { status: 400 }) + let sortBy + if (searchParams.has('sortBy')) { + sortBy = searchParams.get('sortBy') + if (!sortableValues.includes(sortBy)) { + throw Object.assign(new Error(`Sorting by '${sortBy}' is not supported. Supported sort orders are: [${sortableValues.toString()}]`), { status: 400 }) + } } - return { - page, - size, - offset, - before, - after, - sortBy, - sortOrder - } + return { page, size, sortBy, sortOrder } } diff --git a/packages/api/test/user.spec.js b/packages/api/test/user.spec.js index 56fe385226..fdc02913d2 100644 --- a/packages/api/test/user.spec.js +++ b/packages/api/test/user.spec.js @@ -4,7 +4,6 @@ import fetch from '@web-std/fetch' import { endpoint } from './scripts/constants.js' import { getTestJWT, getDBClient } from './scripts/helpers.js' import userUploads from './fixtures/pgrest/get-user-uploads.js' -import userPins from './fixtures/pgrest/get-user-pins.js' import { AuthorizationTestContext } from './contexts/authorization.js' describe('GET /user/account', () => { @@ -203,7 +202,7 @@ describe('GET /user/uploads', () => { it('lists uploads sorted by name', async () => { const token = await getTestJWT() - const res = await fetch(new URL('/user/uploads?sortBy=Name', endpoint).toString(), { + const res = await fetch(new URL('/user/uploads?page=1&sortBy=Name', endpoint).toString(), { method: 'GET', headers: { Authorization: `Bearer ${token}` } }) @@ -214,7 +213,7 @@ describe('GET /user/uploads', () => { it('lists uploads sorted by date', async () => { const token = await getTestJWT() - const res = await fetch(new URL('/user/uploads?sortBy=Date', endpoint).toString(), { + const res = await fetch(new URL('/user/uploads?page=1&sortBy=Date', endpoint).toString(), { method: 'GET', headers: { Authorization: `Bearer ${token}` } }) @@ -225,7 +224,7 @@ describe('GET /user/uploads', () => { it('lists uploads in reverse order when sorting by Asc', async () => { const token = await getTestJWT() - const res = await fetch(new URL('/user/uploads?sortBy=Name&sortOrder=Asc', endpoint).toString(), { + const res = await fetch(new URL('/user/uploads?page=1&sortBy=Name&sortOrder=Asc', endpoint).toString(), { method: 'GET', headers: { Authorization: `Bearer ${token}` } }) @@ -262,30 +261,6 @@ describe('GET /user/uploads', () => { assert.deepStrictEqual(uploads, [...uploadsBeforeFilterDate]) }) - it('filters results by after date', async () => { - const token = await getTestJWT() - - const afterFilterDate = new Date('2021-07-10T00:00:00.000000+00:00').toISOString() - const res = await fetch(new URL(`/user/uploads?after=${afterFilterDate}`, endpoint).toString(), { - method: 'GET', - headers: { Authorization: `Bearer ${token}` } - }) - - assert(res.ok) - - const uploads = await res.json() - - assert(uploads.length < userUploads.length, 'Ensure some results are filtered out.') - assert(uploads.length > 0, 'Ensure some results are returned.') - - // Filter uploads fixture by the filter date. - const uploadsAfterFilterDate = userUploads.filter((upload) => { - return upload.created >= afterFilterDate - }) - - assert.deepStrictEqual(uploads, [...uploadsAfterFilterDate]) - }) - it('lists uploads via magic auth', async function () { const token = AuthorizationTestContext.use(this).createUserToken() const res = await fetch(new URL('/user/uploads', endpoint).toString(), { @@ -310,16 +285,7 @@ describe('GET /user/uploads', () => { // Ensure we have all pagination metadata in the headers. const link = res.headers.get('link') assert(link, 'has a link header for the next page') - assert.strictEqual(link, `; rel="previous", ; rel="next"`) - - const resCount = res.headers.get('Count') - assert.strictEqual(parseInt(resCount), userUploads.length, 'has a count for calculating page numbers') - - const resSize = res.headers.get('Size') - assert.strictEqual(parseInt(resSize), size, 'has a size for calculating page numbers') - - const resPage = res.headers.get('Page') - assert.strictEqual(parseInt(resPage), page, 'has a page number for calculating page numbers') + assert.strictEqual(link, `; rel="next", ; rel="last", ; rel="first", ; rel="previous"`) // Should get second result (page 2). const uploads = await res.json() @@ -390,38 +356,7 @@ describe('GET /user/pins', () => { const body = await res.json() assert(body.results.length, size) assert(res.headers.get('size'), size) - assert.strictEqual(res.headers.get('link'), '; rel="next"') - }) - - it('accepts the `sortBy` parameter', async () => { - const sortBy = 'Name' - const opts = new URLSearchParams({ - sortBy, - status: 'queued,pinning,pinned,failed' - }) - const token = await getTestJWT('test-pinning', 'test-pinning') - const res = await fetch(new URL(`user/pins?${opts}`, endpoint).toString(), { - method: 'GET', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } - }) - assert(res.ok) - const body = await res.json() - assert.deepStrictEqual(body.results, [...userPins].sort((a, b) => a.pin.name.localeCompare(b.pin.name))) - }) - it('accepts the `sortOrder` parameter', async () => { - const sortOrder = 'Asc' - const opts = new URLSearchParams({ - sortOrder, - status: 'queued,pinning,pinned,failed' - }) - const token = await getTestJWT('test-pinning', 'test-pinning') - const res = await fetch(new URL(`user/pins?${opts}`, endpoint).toString(), { - method: 'GET', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } - }) - assert(res.ok) - const body = await res.json() - assert(body.results, userPins) + assert.strictEqual(res.headers.get('link'), '; rel="next", ; rel="last", ; rel="first"') }) it('returns the correct headers for pagination', async () => { const size = 1 @@ -442,7 +377,7 @@ describe('GET /user/pins', () => { assert(res.headers.get('size'), size) assert(res.headers.get('count')) assert(res.headers.get('page'), page) - assert.strictEqual(res.headers.get('link'), '; rel="previous", ; rel="next"') + assert.strictEqual(res.headers.get('link'), '; rel="next", ; rel="last", ; rel="first", ; rel="previous"') }) it('returns all pins regardless of the token used', async () => { const opts = new URLSearchParams({ diff --git a/packages/cron/test/cargo.spec.js b/packages/cron/test/cargo.spec.js index 78106fb82f..f4971dc2dd 100644 --- a/packages/cron/test/cargo.spec.js +++ b/packages/cron/test/cargo.spec.js @@ -36,14 +36,14 @@ describe('Fix dag sizes migration', () => { let rwPg async function updateDagSizesWrp ({ user, after = new Date(1990, 1, 1), limit = 1000 }) { - const allUploadsBefore = (await listUploads(dbClient, user._id)).uploads + const allUploadsBefore = (await listUploads(dbClient, user._id, { page: 1 })).uploads await updateDagSizes({ cargoPool, rwPg, after, limit }) - const allUploadsAfter = (await listUploads(dbClient, user._id)).uploads + const allUploadsAfter = (await listUploads(dbClient, user._id, { page: 1 })).uploads const updatedCids = allUploadsAfter.filter((uAfter) => { const beforeUpload = allUploadsBefore.find((uBefore) => uAfter.cid === uBefore.cid) return beforeUpload?.dagSize !== uAfter.dagSize diff --git a/packages/db/db-client-types.ts b/packages/db/db-client-types.ts index 8c973a3379..9b080756f6 100644 --- a/packages/db/db-client-types.ts +++ b/packages/db/db-client-types.ts @@ -235,36 +235,9 @@ export type Location = { region?: definitions['pin_location']['region'] } -export type ListUploadsOptions = { - /** - * Uploads created before a given timestamp. - */ - before?: string - /** - * Uploads created after a given timestamp. - */ - after?: string - /** - * Max records (default: 10). - */ - size?: number - /** - * Offset records (default: 0). - */ - offset?: number - /** - * Sort by given property. - */ - sortBy?: 'Date' | 'Name' - /** - * Sort order. - */ - sortOrder?: 'Asc' | 'Desc' -} - export type ListUploadReturn = { count: number, - uploads: Promise, + uploads: UploadItemOutput[], } // Pinning diff --git a/packages/db/errors.js b/packages/db/errors.js index a672ce6a63..26d50d8b68 100644 --- a/packages/db/errors.js +++ b/packages/db/errors.js @@ -31,3 +31,13 @@ export class ConstraintError extends Error { } ConstraintError.CODE = 'CONSTRAINT_ERROR_DB' + +export class RangeNotSatisfiableDBError extends Error { + constructor (message) { + super(message) + this.name = 'RangeNotSatisfiableError' + this.code = RangeNotSatisfiableDBError.CODE + } +} + +RangeNotSatisfiableDBError.CODE = 'RANGE_NOT_SATISFIABLE_ERROR_DB' diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts index b8cf92f980..f275028046 100644 --- a/packages/db/index.d.ts +++ b/packages/db/index.d.ts @@ -1,13 +1,12 @@ -import { gql } from 'graphql-request' -import { RequestDocument } from 'graphql-request/dist/types' import { PostgrestClient } from '@supabase/postgrest-js' +import { DATE_TIME_PAGE_REQUEST, PAGE_NUMBER_PAGE_REQUEST } from './constants.js' import type { UpsertUserInput, UpsertUserOutput, UserOutput, CreateUploadInput, - ListUploadsOptions, + ListUploadReturn, CreateUploadOutput, UploadItemOutput, ContentItemOutput, @@ -34,8 +33,6 @@ import type { GetUserOptions, } from './db-client-types' -export { gql } - export class DBClient { constructor(config: { endpoint?: string; token: string, postgres?: boolean }) client: PostgrestClient @@ -48,7 +45,7 @@ export class DBClient { logEmailSent(email : LogEmailSentInput): Promise<{id: string}> createUpload (data: CreateUploadInput): Promise getUpload (cid: string, userId: number): Promise - listUploads (userId: number, opts?: ListUploadsOptions): Promise + listUploads (userId: number, pageRequest: PageRequest): Promise renameUpload (userId: number, cid: string, name: string): Promise<{ name: string }> deleteUpload (userId: number, cid: string): Promise<{ _id: number }> getStatus (cid: string): Promise @@ -75,4 +72,30 @@ export class DBClient { } export function parseTextToNumber(n: string): number -export { EMAIL_TYPE } from './constants.js' +export * from './constants.js' + +/** + * Request for a paginated page of data. + */ +export type PageRequest = BeforeDatePageRequest|PageNumberPageRequest + +/** + * A pagination page request that is before a specific date. + */ +export interface BeforeDatePageRequest { + before: Date + size?: number +} + +/** + * A pagination page request that for a specific page number. + */ +export interface PageNumberPageRequest { + page: number + size?: number + sortBy?: SortField + sortOrder?: SortOrder +} + +export type SortField = 'Name'|'Date' +export type SortOrder = 'Asc'|'Desc' diff --git a/packages/db/index.js b/packages/db/index.js index d3120f2c71..aea3f2745a 100644 --- a/packages/db/index.js +++ b/packages/db/index.js @@ -8,7 +8,7 @@ import { normalizePsaPinRequest, parseTextToNumber } from './utils.js' -import { ConstraintError, DBError } from './errors.js' +import { ConstraintError, DBError, RangeNotSatisfiableDBError } from './errors.js' export { EMAIL_TYPE } from './constants.js' export { parseTextToNumber } from './utils.js' @@ -503,41 +503,47 @@ export class DBClient { * List uploads of a given user. * * @param {number} userId - * @param {import('./db-client-types').ListUploadsOptions} [opts] - * @returns {import('./db-client-types').ListUploadReturn} + * @param {import('./index').PageRequest} pageRequest + * @returns {Promise} */ - async listUploads (userId, opts = {}) { - const rangeFrom = opts.offset || 0 - const rangeTo = rangeFrom + (opts.size || 25) - const isAscendingSortOrder = opts.sortOrder === 'Asc' - const defaultSortByColumn = Object.keys(sortableColumnToUploadArgMap)[0] - const sortByColumn = Object.keys(sortableColumnToUploadArgMap).find(key => sortableColumnToUploadArgMap[key] === opts.sortBy) - const sortBy = sortByColumn || defaultSortByColumn - - let query = this._client - .from('upload') - .select(uploadQuery, { count: 'exact' }) - .eq('user_id', userId) - .is('deleted_at', null) - .order( - sortBy, - { ascending: isAscendingSortOrder } - ) - - // Apply filtering - if (opts.before) { - query = query.lt('inserted_at', opts.before) - } - - if (opts.after) { - query = query.gte('inserted_at', opts.after) + async listUploads (userId, pageRequest) { + const size = pageRequest.size || 25 + let query + if ('before' in pageRequest) { + query = this._client + .from('upload') + .select(uploadQuery, { count: 'exact' }) + .eq('user_id', userId) + .is('deleted_at', null) + .lt('inserted_at', pageRequest.before.toISOString()) + .order('inserted_at', { ascending: false }) + .range(0, size - 1) + } else if ('page' in pageRequest) { + const rangeFrom = (pageRequest.page - 1) * size + const rangeTo = rangeFrom + size + const isAscendingSortOrder = pageRequest.sortOrder === 'Asc' + const defaultSortByColumn = Object.keys(sortableColumnToUploadArgMap)[0] + const sortByColumn = Object.keys(sortableColumnToUploadArgMap).find(key => sortableColumnToUploadArgMap[key] === pageRequest.sortBy) + const sortBy = sortByColumn || defaultSortByColumn + + query = this._client + .from('upload') + .select(uploadQuery, { count: 'exact' }) + .eq('user_id', userId) + .is('deleted_at', null) + .order(sortBy, { ascending: isAscendingSortOrder }) + .range(rangeFrom, rangeTo - 1) + } else { + throw new Error('unknown page request type') } - // Apply pagination or limiting. - query = query.range(rangeFrom, rangeTo - 1) + const { data: uploads, error, count, status } = await query - /** @type {{ data: Array, error: Error, count: Number }} */ - const { data: uploads, error, count } = await query + // For some reason, error comes back as empty array when out of range. + // (416 = Range Not Satisfiable) + if (status === 416) { + throw new RangeNotSatisfiableDBError() + } if (error) { throw new DBError(error) @@ -1153,18 +1159,13 @@ export class DBClient { * Get a filtered list of pin requests for a user * * @param {string | [string]} authKey - * @param {import('./db-client-types').ListPsaPinRequestOptions & import('./db-client-types').ListUploadsOptions} [opts] + * @param {import('./db-client-types').ListPsaPinRequestOptions} [opts] * @return {Promise }> } */ async listPsaPinRequests (authKey, opts = {}) { const match = opts?.match || 'exact' const limit = opts?.limit || 10 - const isAscendingSortOrder = opts.sortOrder ?? opts.sortOrder === 'Asc' - const defaultSortByColumn = Object.keys(sortableColumnToUploadArgMap)[0] - const sortByColumn = Object.keys(sortableColumnToUploadArgMap).find(key => sortableColumnToUploadArgMap[key] === opts.sortBy) - const sortBy = sortByColumn || defaultSortByColumn - let query = this._client .from(psaPinRequestTableName) .select(listPinsQuery, { @@ -1172,9 +1173,7 @@ export class DBClient { }) .is('deleted_at', null) .limit(limit) - .order( - sortBy, - { ascending: isAscendingSortOrder }) + .order('inserted_at', { ascending: false }) if (Array.isArray(authKey)) { query.in('auth_key_id', authKey) @@ -1218,11 +1217,11 @@ export class DBClient { } if (opts.before) { - query = query.lte('inserted_at', opts.before) + query = query.lt('inserted_at', opts.before) } if (opts.after) { - query = query.gte('inserted_at', opts.after) + query = query.gt('inserted_at', opts.after) } if (opts.meta) { diff --git a/packages/db/postgres/tables.sql b/packages/db/postgres/tables.sql index dc8ead1599..b29d014c47 100644 --- a/packages/db/postgres/tables.sql +++ b/packages/db/postgres/tables.sql @@ -268,6 +268,8 @@ CREATE TABLE IF NOT EXISTS upload CREATE INDEX IF NOT EXISTS upload_auth_key_id_idx ON upload (auth_key_id); CREATE INDEX IF NOT EXISTS upload_user_id_deleted_at_idx ON upload (user_id) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS upload_content_cid_idx ON upload (content_cid); +CREATE INDEX IF NOT EXISTS upload_name_idx ON upload (name); +CREATE INDEX IF NOT EXISTS upload_inserted_at_idx ON upload (inserted_at); CREATE INDEX IF NOT EXISTS upload_updated_at_idx ON upload (updated_at); CREATE INDEX IF NOT EXISTS upload_source_cid_idx ON upload (source_cid); diff --git a/packages/db/test/upload.spec.js b/packages/db/test/upload.spec.js index e3ed2a0478..f80c0e0bfc 100644 --- a/packages/db/test/upload.spec.js +++ b/packages/db/test/upload.spec.js @@ -131,7 +131,7 @@ describe('upload', () => { assert.strictEqual(backups.length, 3, 'upload has three backups') // Lists single upload - const { uploads: userUploads, count } = await client.listUploads(user._id) + const { uploads: userUploads, count } = await client.listUploads(user._id, { page: 1 }) assert(userUploads, 'user has uploads') assert.strictEqual(userUploads.length, 1, 'partial uploads result in a single upload returned for a user') assert.strictEqual(count, 1, 'partial uploads result in a single upload count for a user') @@ -179,7 +179,7 @@ describe('upload', () => { }) // Lists current user uploads - const { uploads: userUploads } = await client.listUploads(user._id) + const { uploads: userUploads } = await client.listUploads(user._id, { page: 1 }) // Delete previously created upload await client.deleteUpload(user._id, otherCid) @@ -195,7 +195,7 @@ describe('upload', () => { const wasDeletedAgain = await client.deleteUpload(user._id, otherCid) assert.strictEqual(wasDeletedAgain, undefined, 'should fail to delete upload again') - const { uploads: finalUserUploads } = await client.listUploads(user._id) + const { uploads: finalUserUploads } = await client.listUploads(user._id, { page: 1 }) assert(finalUserUploads, 'user upload deleted') assert.strictEqual(finalUserUploads.length, userUploads.length - 1, 'user upload deleted') @@ -274,12 +274,12 @@ describe('upload', () => { }) // Default sort {inserted_at, Desc} - const { uploads: userUploads } = await client.listUploads(user._id) + const { uploads: userUploads } = await client.listUploads(user._id, { page: 1 }) assert.ok(userUploads.find(upload => upload.cid === sourceCid)) }) it('can list user uploads with several options', async () => { - const { uploads: previousUserUploads, count: previousUserUploadCount } = await client.listUploads(user._id) + const { uploads: previousUserUploads, count: previousUserUploadCount } = await client.listUploads(user._id, { page: 1 }) assert(previousUserUploads, 'user has uploads') assert(previousUserUploadCount > 0, 'user has counted uploads') @@ -299,24 +299,27 @@ describe('upload', () => { }) // Default sort {inserted_at, Desc} - const { uploads: userUploadsDefaultSort } = await client.listUploads(user._id) + const { uploads: userUploadsDefaultSort } = await client.listUploads(user._id, { page: 1 }) assert.strictEqual(userUploadsDefaultSort.length, previousUserUploads.length + 1, 'user has the second upload') assert.strictEqual(userUploadsDefaultSort[0].cid, differentCid, 'last upload first') // Sort {inserted_at, Asc} const { uploads: userUploadsAscAndInsertedSort } = await client.listUploads(user._id, { + page: 1, sortOrder: 'Asc' }) assert.notStrictEqual(userUploadsAscAndInsertedSort[0].cid, differentCid, 'first upload first') // Sort {name, Desc} const { uploads: userUploadsByNameSort } = await client.listUploads(user._id, { + page: 1, sortBy: 'Name' }) assert.strictEqual(userUploadsByNameSort[0].cid, differentCid, 'last upload first') // Sort {name, Asc} with size and page const { uploads: userUploadsAscAndByNameSort } = await client.listUploads(user._id, { + page: 1, sortBy: 'Name', sortOrder: 'Asc', size: 1 @@ -325,19 +328,11 @@ describe('upload', () => { assert.notStrictEqual(userUploadsAscAndByNameSort[0].cid, differentCid, 'first upload first') // Filter with before second upload - const { uploads: userUploadsBeforeTheLatest } = await client.listUploads(user._id, { before: created }) + const { uploads: userUploadsBeforeTheLatest } = await client.listUploads(user._id, { before: new Date(created) }) assert.strictEqual(userUploadsBeforeTheLatest.length, previousUserUploads.length, 'list without the second upload') - // Filter with after second upload - const { uploads: userUploadsAfterTheLatest } = await client.listUploads(user._id, { after: created }) - assert.strictEqual(userUploadsAfterTheLatest.length, 1, 'list with only the second upload') - - // offset uploads - const { uploads: offsetUserUploads } = await client.listUploads(user._id, { offset: previousUserUploads.length }) - assert.strictEqual(offsetUserUploads.length, 1, 'list with only the second upload') - // paginate uploads - const { uploads: paginatedUserUploads, count: paginatedUserUploadCount } = await client.listUploads(user._id, { offset: 1, size: 1 }) + const { uploads: paginatedUserUploads, count: paginatedUserUploadCount } = await client.listUploads(user._id, { page: 2, size: 1 }) assert.strictEqual(paginatedUserUploads.length, 1, 'only returns the paginated uploads') assert.strictEqual(paginatedUserUploadCount, previousUserUploads.length + 1, 'only returns the paginated uploads') }) diff --git a/packages/db/test/utils.js b/packages/db/test/utils.js index 55e66fa8c0..faaa12760b 100644 --- a/packages/db/test/utils.js +++ b/packages/db/test/utils.js @@ -249,14 +249,12 @@ export async function getUpload (dbClient, cid, userId) { } /** - * * @param {import('../index').DBClient} dbClient * @param {string} userId - * @param {import('../db-client-types').ListUploadsOptions} [listUploadOptions] - * + * @param {import('../index').PageRequest} pageRequest */ -export async function listUploads (dbClient, userId, listUploadOptions) { - return dbClient.listUploads(userId, listUploadOptions) +export async function listUploads (dbClient, userId, pageRequest) { + return dbClient.listUploads(userId, pageRequest) } /** diff --git a/packages/website/components/account/fileUploader/fileUploader.js b/packages/website/components/account/fileUploader/fileUploader.js index 3663a17542..58e7154c7f 100644 --- a/packages/website/components/account/fileUploader/fileUploader.js +++ b/packages/website/components/account/fileUploader/fileUploader.js @@ -109,7 +109,6 @@ const FileUploader = ({ className = '', content, uploadModalState, background }) }} icon={} dragAreaText={content.drop_prompt} - maxFiles={3} multiple={true} filesInfo={filesInfo} /> diff --git a/packages/website/components/account/filesManager/fileRowItem.scss b/packages/website/components/account/filesManager/fileRowItem.scss index 51b222e4c1..e317841ac9 100644 --- a/packages/website/components/account/filesManager/fileRowItem.scss +++ b/packages/website/components/account/filesManager/fileRowItem.scss @@ -3,6 +3,7 @@ $base-row-width: 75.125rem; $file-select: calculateWidthPercentage(3.5rem, $base-row-width); $file-name: calculateWidthPercentage(23rem, $base-row-width); +$file-requestid: calculateWidthPercentage(16rem, $base-row-width); $file-cid: calculateWidthPercentage(20rem, $base-row-width); $file-availability: calculateWidthPercentage(0rem, $base-row-width); $file-storage-providers: calculateWidthPercentage(14rem, $base-row-width); @@ -208,13 +209,16 @@ $file-date: auto; width: 100%; } } - &-cid { + &-cid, + &-requestid { padding-right: 2rem; margin-top: -0.1rem; justify-content: flex-start; align-items: center; .cid-truncate, - .cid-full { + .cid-full, + .requestid-truncate, + .requestid-full { @include monospace_Text; word-break: break-all; margin-right: 0.625rem; @@ -328,7 +332,8 @@ $file-date: auto; &-name { align-items: end; } - &-cid { + &-cid, + &-requestid { span { font-family: $font_Primary, sans-serif; } @@ -346,6 +351,13 @@ $file-date: auto; } } +.pin-request-row { + grid-template-columns: $file-select $file-name $file-requestid $file-cid $file-date; + @include medium { + grid-template-columns: auto; + } +} + // safari hack since it does not support negative stroke-dashoffset // transition is opacity instead of tick drawing behaviour @include Safari7Plus('.files-manager-row .file-select svg.check .tick') { diff --git a/packages/website/components/account/filesManager/filesManager.js b/packages/website/components/account/filesManager/filesManager.js index f64573f444..136497a335 100644 --- a/packages/website/components/account/filesManager/filesManager.js +++ b/packages/website/components/account/filesManager/filesManager.js @@ -1,27 +1,12 @@ import clsx from 'clsx'; -import filesize from 'filesize'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import countly from 'lib/countly'; -import Loading from 'components/loading/loading'; -import Button, { ButtonVariant } from 'components/button/button'; -import Dropdown from 'ZeroComponents/dropdown/dropdown'; -import Filterable from 'ZeroComponents/filterable/filterable'; -import Sortable from 'ZeroComponents/sortable/sortable'; -import Pagination from 'ZeroComponents/pagination/pagination'; -import Modal from 'modules/zero/components/modal/modal'; -import CloseIcon from 'assets/icons/close'; -import { useUploads } from 'components/contexts/uploadsContext'; -import { useUser } from 'components/contexts/userContext'; -import { useTokens } from 'components/contexts/tokensContext'; import CheckIcon from 'assets/icons/check'; -import SearchIcon from 'assets/icons/search'; -import RefreshIcon from 'assets/icons/refresh'; -import FileRowItem from './fileRowItem'; -import GradientBackground from '../../gradientbackground/gradientbackground.js'; - -const defaultQueryOrder = 'newest'; +import { useUploads } from 'components/contexts/uploadsContext'; +import { usePinRequests } from 'components/contexts/pinRequestsContext'; +import UploadsTable from './uploadsTable'; +import PinRequestsTable from './pinRequestsTable'; /** * @typedef {import('web3.storage').Upload} Upload @@ -41,53 +26,19 @@ const defaultQueryOrder = 'newest'; * @returns */ const FilesManager = ({ className, content, onFileUpload }) => { - const { - uploads, - pinned, - fetchDate, - fetchPinsDate, - getUploads, - listPinned, - isFetchingUploads, - isFetchingPinned, - deleteUpload, - renameUpload, - } = useUploads(); - const { - query: { filter }, - query, - replace, - } = useRouter(); - const { - storageData: { refetch }, - info, - } = useUser(); - const { tokens, getTokens } = useTokens(); + const { count: uploadsCount } = useUploads(); + const { count: pinRequestsCount } = usePinRequests(); + const { query, replace } = useRouter(); const [currentTab, setCurrentTab] = useState('uploaded'); - const [files, setFiles] = useState(/** @type {any} */ (uploads)); - const [filteredFiles, setFilteredFiles] = useState(files); - const [sortedFiles, setSortedFiles] = useState(filteredFiles); - const [paginatedFiles, setPaginatedFiles] = useState(sortedFiles); - const [itemsPerPage, setItemsPerPage] = useState(null); - const [linkPrefix, setLinkPrefix] = useState('w3s.link/ipfs/'); - const [keyword, setKeyword] = useState(filter); - const [deleteSingleCid, setDeleteSingleCid] = useState(''); - const [showCheckOverlay, setShowCheckOverlay] = useState(false); - const deleteModalState = useState(false); - const queryOrderRef = useRef(query.order); - const apiToken = tokens.length ? tokens[0].secret : undefined; - - const [selectedFiles, setSelectedFiles] = useState(/** @type {Upload[]} */ ([])); const [isUpdating, setIsUpdating] = useState(false); - const [nameEditingId, setNameEditingId] = useState(); - const fileRowLabels = content?.table.file_row_labels; + const [showCheckOverlay, setShowCheckOverlay] = useState(false); // Set current tab based on url param on load useEffect(() => { if (query.hasOwnProperty('table') && currentTab !== query?.table) { if (typeof query.table === 'string') { - if (query.table === 'pinned' && pinned.length === 0) { + if (query.table === 'pinned' && pinRequestsCount === 0) { delete query.table; replace( { @@ -101,56 +52,7 @@ const FilesManager = ({ className, content, onFileUpload }) => { setCurrentTab(query.table); } } - }, [query, currentTab, pinned, replace]); - - // Initial fetch on component load - useEffect(() => { - if (!fetchDate && !isFetchingUploads) { - getUploads(); - } - }, [fetchDate, getUploads, isFetchingUploads]); - - // Initial pinned files fetch on component load - useEffect(() => { - if (!fetchPinsDate && !isFetchingPinned && apiToken) { - listPinned('pinned', apiToken); - } - }, [fetchPinsDate, listPinned, isFetchingPinned, apiToken]); - useEffect(() => { - getTokens(); - }, [getTokens]); - - // Set displayed files based on tab selection: 'uploaded' or 'pinned' - useEffect(() => { - if (currentTab === 'uploaded') { - setFiles(uploads); - } else if (currentTab === 'pinned') { - setFiles(pinned.map(item => item.pin)); - } - }, [uploads, pinned, currentTab]); - - // Method to reset the pagination every time query order changes - useEffect(() => { - if ( - (!queryOrderRef.current && !!query.order && query.order !== defaultQueryOrder) || - (!!queryOrderRef.current && !!query.order && query.order !== queryOrderRef.current) - ) { - delete query.page; - - replace( - { - query, - }, - undefined, - { shallow: true } - ); - - const scrollToElement = document.querySelector('.account-files-manager'); - scrollToElement?.scrollIntoView(true); - - queryOrderRef.current = query.order; - } - }, [query.order, query, replace]); + }, [query, currentTab, pinRequestsCount, replace]); const changeCurrentTab = useCallback( /** @type {string} */ tab => { @@ -171,96 +73,14 @@ const FilesManager = ({ className, content, onFileUpload }) => { const getFilesTotal = type => { switch (type) { case 'uploaded': - return uploads.length; + return uploadsCount; case 'pinned': - return pinned.length; + return pinRequestsCount; default: return ''; } }; - const onSelectAllToggle = useCallback( - e => { - const filesToSelect = paginatedFiles.filter(file => !selectedFiles.some(fileSelected => fileSelected === file)); - - if (!filesToSelect.length) { - return setSelectedFiles([]); - } - - return setSelectedFiles(selectedFiles.concat(filesToSelect)); - }, - [selectedFiles, setSelectedFiles, paginatedFiles] - ); - - const onFileSelect = useCallback( - /** @type {Upload} */ file => { - const selectedIndex = selectedFiles.findIndex(fileSelected => fileSelected === file); - if (selectedIndex !== -1) { - selectedFiles.splice(selectedIndex, 1); - return setSelectedFiles([...selectedFiles]); - } - - setSelectedFiles([...selectedFiles, file]); - }, - [selectedFiles, setSelectedFiles] - ); - - const closeDeleteModal = useCallback(() => { - deleteModalState[1](false); - countly.trackEvent(countly.events.FILE_DELETE_CLICK, { - ui: countly.ui.FILES, - totalDeleted: 0, - }); - }, [deleteModalState]); - - const onDeleteSelected = useCallback(async () => { - setIsUpdating(true); - - try { - if (deleteSingleCid !== '') { - await deleteUpload(deleteSingleCid); - } else { - await Promise.all(selectedFiles.map(({ cid }) => deleteUpload(cid))); - } - } catch (e) {} - - countly.trackEvent(countly.events.FILE_DELETE_CLICK, { - ui: countly.ui.FILES, - totalDeleted: selectedFiles.length, - }); - - setIsUpdating(false); - setSelectedFiles([]); - - getUploads(); - setDeleteSingleCid(''); - deleteModalState[1](false); - refetch(); - }, [deleteSingleCid, selectedFiles, getUploads, deleteModalState, deleteUpload, refetch]); - - const onDeleteSingle = useCallback( - async cid => { - deleteModalState[1](true); - setDeleteSingleCid(cid); - }, - [deleteModalState] - ); - - const onEditToggle = useCallback( - targetCID => async (/** @type {string|undefined} */ newFileName) => { - setNameEditingId(targetCID !== nameEditingId ? targetCID : undefined); - - const fileTarget = files.find(({ cid }) => cid === targetCID); - if (!!fileTarget && !!newFileName && newFileName !== fileTarget.name) { - setIsUpdating(true); - await renameUpload(targetCID, newFileName); - fileTarget.name = newFileName; - setIsUpdating(false); - } - }, - [renameUpload, files, nameEditingId] - ); - const showCheckOverlayHandler = useCallback(() => { setShowCheckOverlay(true); setTimeout(() => { @@ -268,34 +88,14 @@ const FilesManager = ({ className, content, onFileUpload }) => { }, 500); }, [setShowCheckOverlay]); - const refreshHandler = useCallback(() => { - if (currentTab === 'uploaded') { - getUploads(); - } else if (currentTab === 'pinned' && apiToken) { - listPinned('pinned', apiToken); - } - showCheckOverlayHandler(); - }, [currentTab, getUploads, listPinned, showCheckOverlayHandler, apiToken]); - - const tableContentLoading = tab => { - switch (tab) { - case 'uploaded': - return isFetchingUploads || !fetchDate; - case 'pinned': - return isFetchingPinned || !fetchPinsDate; - default: - return true; - } - }; - return (
- {pinned.length > 0 && ( + {pinRequestsCount > 0 && (
{content?.tabs.map(tab => (
)} - +
- + */}
diff --git a/packages/website/components/contexts/pinRequestsContext.js b/packages/website/components/contexts/pinRequestsContext.js new file mode 100644 index 0000000000..6fd0b81260 --- /dev/null +++ b/packages/website/components/contexts/pinRequestsContext.js @@ -0,0 +1,91 @@ +import React, { useCallback, useState } from 'react'; + +import { getPinRequests, deletePinRequest } from 'lib/api'; + +/** + * @typedef {Object} PinStatus + * @property {string} requestid + * @property {Pin} pin + * @property {string} status + * @property {string} created + */ + +/** + * @typedef {Object} Pin + * @property {string} cid + * @property {string} name + */ + +/** + * @typedef {Object} PinRequestsContextProps + * @property {PinStatus[]} pinRequests A page of pin requests + * @property {number} pages Total pages of pin requests + * @property {number} count Total pin requests + * @property {(requestid: string) => Promise} deletePinRequest Method to delete an existing pin request + * @property {(args: { status: string, page: number, size: number }) => Promise} getPinRequests Fetch pin requests based on params + * @property {number|undefined} fetchDate The date in which the last pin requests list fetch happened + * @property {boolean} isFetching Whether or not pinned files are being fetched + */ + +/** + * @typedef {Object} PinRequestsProviderProps + * @property {import('react').ReactNode} children + */ + +export const PinRequestsContext = React.createContext(/** @type {any} */ (undefined)); + +/** + * @param {PinRequestsProviderProps} props + */ +export const PinRequestsProvider = ({ children }) => { + const [pinRequests, setPinRequests] = useState(/** @type {PinStatus[]} */ ([])); + const [fetchDate, setFetchDate] = useState(/** @type {number|undefined} */ (undefined)); + const [isFetching, setIsFetching] = useState(false); + const [pages, setPages] = useState(0); + const [count, setCount] = useState(0); + + const pinRequestsCallback = useCallback( + /** @type {(args: { status: string, page: number, size: number }) => Promise} */ + async ({ status, page, size }) => { + setIsFetching(true); + const pinsResponse = await getPinRequests({ status, page, size }); + setPinRequests(pinsResponse.results); + setPages(Math.ceil(pinsResponse.count / size)); + setCount(pinsResponse.count); + setFetchDate(Date.now()); + setIsFetching(false); + return pinsResponse.results; + }, + [setPinRequests, setPages, setCount] + ); + + return ( + + {children} + + ); +}; + +/** + * @returns {PinRequestsContextProps} + */ +export const usePinRequests = () => { + const context = React.useContext(PinRequestsContext); + if (context === undefined) { + throw new Error('usePinRequests must be used within a PinRequestsProvider'); + } + return context; +}; diff --git a/packages/website/components/contexts/uploadsContext.js b/packages/website/components/contexts/uploadsContext.js index 80c48349ec..a7a6a52602 100644 --- a/packages/website/components/contexts/uploadsContext.js +++ b/packages/website/components/contexts/uploadsContext.js @@ -1,7 +1,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { Web3Storage } from 'web3.storage'; -import { API, deleteUpload, getToken, getUploads, renameUpload, listPins } from 'lib/api'; +import { API, deleteUpload, getToken, getUploads, renameUpload } from 'lib/api'; import { useUploadProgress } from './uploadProgressContext'; import { useUser } from './userContext'; @@ -58,33 +58,17 @@ export const STATUS = { * @property {string[]} delegates */ -/** - * @typedef {Object} PinStatus - * @property {string} requestid - * @property {string} status - * @property {string} created - * @property {PinObject} pin - */ - -/** - * @typedef {Object} PinsList - * @property {number} count - * @property {PinStatus[]} results - */ - /** * @typedef {Object} UploadsContextProps - * @property {Upload[]} uploads Uploads available in this account - * @property {PinStatus[]} pinned Files uploaded through the pinning service on this account + * @property {Upload[]} uploads A Page of uploads + * @property {number} pages Total pages of uploads + * @property {number} count Total uploads * @property {(cid: string) => Promise} deleteUpload Method to delete an existing upload * @property {(cid: string, name: string)=>Promise} renameUpload Method to rename an existing upload * @property {(args?: UploadArgs) => Promise} getUploads Method that refetches list of uploads based on certain params - * @property {(status: string, token: string) => Promise} listPinned Method that fetches list of pins * @property {(file:FileProgress) => Promise} uploadFiles Method to upload a new file * @property {boolean} isFetchingUploads Whether or not new uploads are being fetched * @property {number|undefined} fetchDate The date in which the last uploads list fetch happened - * @property {number|undefined} fetchPinsDate The date at which pins were last fetched - * @property {boolean} isFetchingPinned Whether or not pinned files are being fetched * @property {UploadProgress} uploadsProgress The progress of any current uploads * @property {() => boolean } clearUploadedFiles clears completed files from uploads list */ @@ -112,11 +96,10 @@ export const UploadsProvider = ({ children }) => { } = useUser(); const [uploads, setUploads] = useState(/** @type {Upload[]} */ ([])); - const [pinned, setPinned] = useState(/** @type {PinStatus[]} */ ([])); + const [pages, setPages] = useState(0); + const [count, setCount] = useState(0); const [isFetchingUploads, setIsFetchingUploads] = useState(false); const [fetchDate, setFetchDate] = useState(/** @type {number|undefined} */ (undefined)); - const [isFetchingPinned, setIsFetchingPinned] = useState(false); - const [fetchPinsDate, setFetchPinsDate] = useState(/** @type {number|undefined} */ (undefined)); const [filesToUpload, setFilesToUpload] = useState(/** @type {FileProgress[]} */ ([])); const { initialize, updateFileProgress, progress, markFileCompleted, markFileFailed } = useUploadProgress([]); @@ -180,36 +163,18 @@ export const UploadsProvider = ({ children }) => { const getUploadsCallback = useCallback( /** @type {(args?: UploadArgs) => Promise}} */ - async ( - args = { - size: 1000, - before: new Date().toISOString(), - } - ) => { + async args => { setIsFetchingUploads(true); - const updatedUploads = await getUploads(args); - setUploads(updatedUploads); + const { uploads, pages, count } = await getUploads(args); + setUploads(uploads); + setPages(pages); + setCount(count); setFetchDate(Date.now()); setIsFetchingUploads(false); - return updatedUploads; - }, - [setUploads, setIsFetchingUploads] - ); - - const listPinnedCallback = useCallback( - /** @type {(status: string, token: string) => Promise} */ - async (status, token) => { - setIsFetchingPinned(true); - const pinsResponse = await listPins(status, token); // *** CHANGE TO 'pinned' *** - const updatedPinned = pinsResponse.results; - setPinned(updatedPinned); - setFetchPinsDate(Date.now()); - setIsFetchingPinned(false); - - return updatedPinned; + return uploads; }, - [setPinned] + [setUploads, setPages, setCount, setIsFetchingUploads] ); return ( @@ -221,13 +186,11 @@ export const UploadsProvider = ({ children }) => { deleteUpload, renameUpload, getUploads: getUploadsCallback, - listPinned: listPinnedCallback, uploads, - pinned, + pages, + count, isFetchingUploads, fetchDate, - isFetchingPinned, - fetchPinsDate, uploadsProgress: progress, clearUploadedFiles, }) diff --git a/packages/website/components/general/appProviders.js b/packages/website/components/general/appProviders.js index d12c7ac88e..06864db2b5 100644 --- a/packages/website/components/general/appProviders.js +++ b/packages/website/components/general/appProviders.js @@ -4,6 +4,7 @@ import { useRouter } from 'next/router'; import { AuthorizationProvider } from 'components/contexts/authorizationContext'; import { UserProvider } from 'components/contexts/userContext'; import { UploadsProvider } from 'components/contexts/uploadsContext'; +import { PinRequestsProvider } from 'components/contexts/pinRequestsContext'; import { TokensProvider } from 'components/contexts/tokensContext'; const queryClient = new QueryClient({ @@ -27,7 +28,9 @@ const AppProviders = ({ authorizationProps, children }) => { - {children} + + {children} + diff --git a/packages/website/content/pages/app/account.json b/packages/website/content/pages/app/account.json index 56f182274a..a3afb88608 100644 --- a/packages/website/content/pages/app/account.json +++ b/packages/website/content/pages/app/account.json @@ -120,6 +120,10 @@ "label": "CID", "tooltip": "The content identifier for a file or a piece of data. Learn more" }, + "requestid": { + "label": "Request ID", + "tooltip": "The identifier for this pin request. Learn more" + }, "status": { "label": "Status", "tooltip": { @@ -157,41 +161,19 @@ "options": [ { "label": "Newest Upload", - "value": "newest", - "direction": "NEWEST", - "compareFn": "TIMEBASED" + "value": "Date,Desc" }, { "label": "Oldest Upload", - "value": "oldest", - "direction": "OLDEST", - "compareFn": "TIMEBASED" + "value": "Date,Asc" }, { "label": "Alphabetical A-Z", - "key": "name", - "value": "a-z", - "direction": "ASC", - "compareFn": "ALPHANUMERIC" + "value": "Name,Asc" }, { "label": "Alphabetical Z-A", - "key": "name", - "value": "z-a", - "direction": "DESC", - "compareFn": "ALPHANUMERIC" - }, - { - "label": "Largest size", - "value": "largest", - "direction": "LARGEST", - "compareFn": "SIZEBASED" - }, - { - "label": "Smallest size", - "value": "smallest", - "direction": "SMALLEST", - "compareFn": "SIZEBASED" + "value": "Name,Desc" } ] }, diff --git a/packages/website/lib/api.js b/packages/website/lib/api.js index 79e9096b21..ea26c4d50f 100644 --- a/packages/website/lib/api.js +++ b/packages/website/lib/api.js @@ -1,3 +1,5 @@ +import { parseLinkHeader } from '@web3-storage/parse-link-header'; + import { getMagic } from './magic'; import constants from './constants'; @@ -150,8 +152,8 @@ export async function createToken(name) { /** * @typedef {Object} UploadArgs - * @property {number} args.size - * @property {string} args.before + * @property {number} [args.size] + * @property {number} [args.page] * @property {string} [args.sortBy] Can be either "Date" or "Name" - uses "Date" as default * @property {string} [args.sortOrder] Can be either "Asc" or "Desc" - uses "Desc" as default */ @@ -159,18 +161,18 @@ export async function createToken(name) { /** * Gets files * - * @param {UploadArgs} args - * @returns {Promise} + * @param {UploadArgs} [args] + * @returns {Promise<{ uploads: import('web3.storage').Upload[], pages: number, count: number }>} * @throws {Error} When it fails to get uploads */ -export async function getUploads({ size, before, sortBy, sortOrder }) { - const params = new URLSearchParams({ before, size: String(size) }); +export async function getUploads({ size, page, sortBy, sortOrder } = {}) { + const params = new URLSearchParams({ page: String(page || 1), size: String(size || 10) }); if (sortBy) { params.set('sortBy', sortBy); } if (sortOrder) { - params.set('setOrder', sortOrder); + params.set('sortOrder', sortOrder); } const res = await fetch(`${API}/user/uploads?${params}`, { method: 'GET', @@ -184,7 +186,15 @@ export async function getUploads({ size, before, sortBy, sortOrder }) { throw new Error(`failed to get uploads: ${await res.text()}`); } - return res.json(); + const links = parseLinkHeader(res.headers.get('Link') || ''); + if (!links?.last?.page) { + throw new Error('missing last rel in pagination Link header'); + } + + const pages = parseInt(links.last.page); + const count = parseInt(res.headers.get('Count') || '0'); + + return { uploads: await res.json(), pages, count }; } /** @@ -246,19 +256,17 @@ export async function getVersion() { } /** - * Gets files pinned through the pinning API + * Gets pin requests. * - * @param {string} status - * @param {string} token - * @returns {Promise} - * @throws {Error} When it fails to get uploads + * @param {{ status: string, page: number, size: number }} args + * @returns {Promise<{ count: number, results: import('../components/contexts/pinRequestsContext').PinStatus[] }>} */ -export async function listPins(status, token) { - const res = await fetch(`${API}/pins?status=${status}`, { +export async function getPinRequests({ status, size, page }) { + const res = await fetch(`${API}/user/pins?status=${status}&size=${size}&page=${page}`, { method: 'GET', headers: { 'Content-Type': 'application/json', - Authorization: 'Bearer ' + token, // **** this needs to be a token generated from the tokens context + Authorization: 'Bearer ' + (await getToken()), }, }); if (!res.ok) { @@ -267,3 +275,26 @@ export async function listPins(status, token) { return res.json(); } + +/** + * Deletes a pin request. + * + * @param {string} requestid + */ +export async function deletePinRequest(requestid) { + const tokens = await getTokens(); + if (!tokens[0]) { + throw new Error('missing API token'); + } + const res = await fetch(`${API}/pins/${encodeURIComponent(requestid)}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + tokens[0].secret, + }, + }); + + if (!res.ok) { + throw new Error(`failed to delete pin request: ${await res.text()}`); + } +} diff --git a/packages/website/modules/zero/components/pagination/pagination.js b/packages/website/modules/zero/components/pagination/pagination.js index bac525941c..ffbecad4df 100644 --- a/packages/website/modules/zero/components/pagination/pagination.js +++ b/packages/website/modules/zero/components/pagination/pagination.js @@ -102,3 +102,91 @@ Pagination.defaultProps = { } export default Pagination + +/** + * @typedef {Object} ServerPaginationProps + * @prop {string} [className] + * @prop {string|number} itemsPerPage + * @prop {number} pageCount + * @prop {number} visiblePages + * @prop {number} [defaultPage] + * @prop {string} [queryParam] + * @prop {(page: number) => void} [onChange] + * @prop {string} [scrollTarget] + */ + +/** + * @param {ServerPaginationProps} props + */ +export const ServerPagination = ({ + className, + itemsPerPage, + pageCount, + visiblePages, + defaultPage, + queryParam, + onChange, + scrollTarget, +}) => { + const [queryValue, setQueryValue] = useQueryParams(queryParam, defaultPage); + + const [pageList, setPageList] = useState(/** @type {number[]} */([])) + const [activePage, setActivePage] = useState(defaultPage) + + const currentPage = useMemo(() => parseInt(queryParam ? queryValue : activePage), [queryParam, queryValue, activePage]) + + const setCurrentPage = useCallback((page) => { + queryParam ? setQueryValue(page) : setActivePage(page) + if(scrollTarget) { + document.querySelector(scrollTarget)?.scrollIntoView(true) + } + onChange && onChange(page); + }, [queryParam, setQueryValue, setActivePage, scrollTarget]) + + useEffect(() => { + pageCount && setPageList( + Array.from({length: pageCount}, (_, i) => i + 1) + .filter(page => page >= currentPage - visiblePages && page <= currentPage + visiblePages) + ) + }, [itemsPerPage, visiblePages, pageCount, setPageList, currentPage, setCurrentPage, onChange]) + + return ( +
+
    + {currentPage > visiblePages + 1 + && + } + {currentPage > 1 + && + } + {currentPage > visiblePages + 1 + &&
    ...
    + } + {pageCount !== 1 && pageList && pageList.map((page) => ( + + ))} + {pageCount != null && currentPage < pageCount - visiblePages + &&
    ...
    + } + {pageCount != null && currentPage < pageCount + && + } + {pageCount != null && currentPage < pageCount - visiblePages + && + } +
+
+ ) +} + +ServerPagination.defaultProps = { + itemsPerPage: 10, + defaultPage: 1, + queryParam: null +} diff --git a/packages/website/package.json b/packages/website/package.json index 33f893722d..d700673f5c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -29,6 +29,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@magic-ext/oauth": "^0.7.0", "@mdx-js/react": "^2.1.1", + "@web3-storage/parse-link-header": "^3.1.0", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", "algoliasearch": "^4.13.0",