diff --git a/.eslintignore b/.eslintignore index 7545cb33e..581d7b51b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1484,6 +1484,9 @@ packages/server/src/models/ChangeModel.test.js.map packages/server/src/models/FileModel.d.ts packages/server/src/models/FileModel.js packages/server/src/models/FileModel.js.map +packages/server/src/models/FileModel.test.d.ts +packages/server/src/models/FileModel.test.js +packages/server/src/models/FileModel.test.js.map packages/server/src/models/PermissionModel.d.ts packages/server/src/models/PermissionModel.js packages/server/src/models/PermissionModel.js.map @@ -1601,6 +1604,9 @@ packages/server/src/utils/time.js.map packages/server/src/utils/types.d.ts packages/server/src/utils/types.js packages/server/src/utils/types.js.map +packages/server/src/utils/urlUtils.d.ts +packages/server/src/utils/urlUtils.js +packages/server/src/utils/urlUtils.js.map packages/server/src/utils/uuidgen.d.ts packages/server/src/utils/uuidgen.js packages/server/src/utils/uuidgen.js.map diff --git a/.gitignore b/.gitignore index 08a63ee7f..f712b9018 100644 --- a/.gitignore +++ b/.gitignore @@ -1473,6 +1473,9 @@ packages/server/src/models/ChangeModel.test.js.map packages/server/src/models/FileModel.d.ts packages/server/src/models/FileModel.js packages/server/src/models/FileModel.js.map +packages/server/src/models/FileModel.test.d.ts +packages/server/src/models/FileModel.test.js +packages/server/src/models/FileModel.test.js.map packages/server/src/models/PermissionModel.d.ts packages/server/src/models/PermissionModel.js packages/server/src/models/PermissionModel.js.map @@ -1590,6 +1593,9 @@ packages/server/src/utils/time.js.map packages/server/src/utils/types.d.ts packages/server/src/utils/types.js packages/server/src/utils/types.js.map +packages/server/src/utils/urlUtils.d.ts +packages/server/src/utils/urlUtils.js +packages/server/src/utils/urlUtils.js.map packages/server/src/utils/uuidgen.d.ts packages/server/src/utils/uuidgen.js packages/server/src/utils/uuidgen.js.map diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index 34cac91ac..0f0a1e4e3 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -214,7 +214,7 @@ class MainScreenComponent extends React.Component { let output = null; try { - output = loadLayout(userLayout, defaultLayout, rootLayoutSize); + output = loadLayout(Object.keys(userLayout).length ? userLayout : null, defaultLayout, rootLayoutSize); if (!findItemByKey(output, 'sideBar') || !findItemByKey(output, 'noteList') || !findItemByKey(output, 'editor')) { throw new Error('"sideBar", "noteList" and "editor" must be present in the layout'); diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 47d29b5e5..7a9b6a31d 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -429,6 +429,11 @@ "minimist": "^1.2.0" } }, + "@fortawesome/fontawesome-free": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.1.tgz", + "integrity": "sha512-OEdH7SyC1suTdhBGW91/zBfR6qaIhThbcN8PUXtXilY4GYnSBbVqOntdHbC1vXwsDnX0Qix2m2+DSU1J51ybOQ==" + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1552,6 +1557,14 @@ "dev": true, "requires": { "sprintf-js": "~1.0.2" + }, + "dependencies": { + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + } } }, "arr-diff": { @@ -2439,6 +2452,11 @@ "whatwg-url": "^8.0.0" } }, + "dayjs": { + "version": "1.9.8", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.9.8.tgz", + "integrity": "sha512-F42qBtJRa30FKF7XDnOQyNUTsaxDkuaZRj/i7BejSHC34LlLfPoIU4aeopvWfM+m1dJ6/DHKAWLg2ur+pLgq1w==" + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -7160,10 +7178,9 @@ } }, "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" }, "sqlite3": { "version": "4.1.0", diff --git a/packages/server/package.json b/packages/server/package.json index 1a6591009..b6371ae58 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,10 +12,12 @@ "watch": "tsc --watch --project tsconfig.json" }, "dependencies": { + "@fortawesome/fontawesome-free": "^5.15.1", "@joplin/lib": "^1.0.9", "bcryptjs": "^2.4.3", "bulma": "^0.9.1", "bulma-prefers-dark": "^0.1.0-beta.0", + "dayjs": "^1.9.8", "formidable": "^1.2.2", "fs-extra": "^8.1.0", "html-entities": "^1.3.1", diff --git a/packages/server/public/css/index/files.css b/packages/server/public/css/index/files.css new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/public/css/main.css b/packages/server/public/css/main.css index 4884c8cea..6efa61786 100644 --- a/packages/server/public/css/main.css +++ b/packages/server/public/css/main.css @@ -25,3 +25,11 @@ input.form-control { .main { padding: 0 3rem; } + +table.table .nowrap { + white-space: nowrap; +} + +table.table .stretch { + width: 100%; +} \ No newline at end of file diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 32f4eea3a..0ee1deab0 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -87,6 +87,7 @@ app.use(async (ctx: Koa.Context) => { path: 'index/error', content: { error, + stack: env === 'dev' ? error.stack : '', }, }; ctx.response.body = await mustacheService.renderView(view); @@ -150,8 +151,8 @@ async function main() { delete connectionCheckLogInfo.connection; appLogger().info('Connection check:', connectionCheckLogInfo); - appContext.db = connectionCheck.connection;// - appContext.models = modelFactory(appContext.db); + appContext.db = connectionCheck.connection; + appContext.models = modelFactory(appContext.db, baseUrl()); appContext.controllers = controllerFactory(appContext.models); appLogger().info('Migrating database...'); diff --git a/packages/server/src/config-dev.ts b/packages/server/src/config-dev.ts index 894c071ed..a9f430acb 100644 --- a/packages/server/src/config-dev.ts +++ b/packages/server/src/config-dev.ts @@ -3,20 +3,20 @@ import configBase from './config-base'; const config: Config = { ...configBase, - // database: { - // name: 'dev', - // client: 'sqlite3', - // asyncStackTraces: true, - // }, database: { - client: 'pg', - name: 'joplin', - user: 'joplin', - host: 'localhost', - port: 5432, - password: 'joplin', + name: 'dev', + client: 'sqlite3', asyncStackTraces: true, }, + // database: { + // client: 'pg', + // name: 'joplin', + // user: 'joplin', + // host: 'localhost', + // port: 5432, + // password: 'joplin', + // asyncStackTraces: true, + // }, }; export default config; diff --git a/packages/server/src/controllers/index/FileController.ts b/packages/server/src/controllers/index/FileController.ts index ca90356ad..ecc2c7ba0 100644 --- a/packages/server/src/controllers/index/FileController.ts +++ b/packages/server/src/controllers/index/FileController.ts @@ -1,44 +1,93 @@ import BaseController from '../BaseController'; import { View } from '../../services/MustacheService'; import defaultView from '../../utils/defaultView'; -import { Pagination } from '../../models/utils/pagination'; +import { Pagination, pageMaxSize, PaginationOrder, requestPaginationOrder, PaginationOrderDir, validatePagination, createPaginationLinks } from '../../models/utils/pagination'; import { File } from '../../db'; +import { baseUrl } from '../../config'; +import { formatDateTime } from '../../utils/time'; +import { setQueryParameters } from '../../utils/urlUtils'; + +export function makeFilePagination(query: any): Pagination { + const limit = Number(query.limit) || pageMaxSize; + const order: PaginationOrder[] = requestPaginationOrder(query, 'name', PaginationOrderDir.ASC); + order.splice(0, 0, { by: 'is_directory', dir: PaginationOrderDir.DESC }); + const page: number = 'page' in query ? Number(query.page) : 1; + + const output: Pagination = { limit, order, page }; + validatePagination(output); + return output; +} export default class FileController extends BaseController { - public async getIndex(sessionId: string, dirId: string, pagination: Pagination): Promise { + public async getIndex(sessionId: string, dirId: string, query: any): Promise { + // Query parameters that should be appended to pagination-related URLs + const baseUrlQuery: any = {}; + if (query.limit) baseUrlQuery.limit = query.limit; + if (query.order_by) baseUrlQuery.order_by = query.order_by; + if (query.order_dir) baseUrlQuery.order_dir = query.order_dir; + + const pagination = makeFilePagination(query); const owner = await this.initSession(sessionId); - const user = await this.initSession(sessionId); - const fileModel = this.models.file({ userId: user.id }); - const parent: File = dirId ? await fileModel.entityFromItemId(dirId) : await fileModel.userRootFile(); + const fileModel = this.models.file({ userId: owner.id }); + const root = await fileModel.userRootFile(); + const parentTemp: File = dirId ? await fileModel.entityFromItemId(dirId) : root; + const parent: File = await fileModel.load(parentTemp.id); const paginatedFiles = await fileModel.childrens(parent.id, pagination); + const pageCount = Math.ceil((await fileModel.childrenCount(parent.id)) / pagination.limit); + const parentBaseUrl = await fileModel.fileUrl(parent.id); + const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); + + async function fileToViewItem(file: File): Promise { + const filePath = await fileModel.itemFullPath(file); + + let url = `${baseUrl()}/files/${filePath}`; + if (!file.is_directory) { + url += '/content'; + } else { + url = setQueryParameters(url, baseUrlQuery); + } + + return { + name: file.name, + url, + type: file.is_directory ? 'directory' : 'file', + icon: file.is_directory ? 'far fa-folder' : 'far fa-file', + timestamp: formatDateTime(file.updated_time), + mime: !file.is_directory ? (file.mime_type || 'binary') : '', + }; + } + + const files: any[] = []; + + if (parent.id !== root.id) { + const p = await fileModel.load(parent.parent_id); + files.push({ + ...await fileToViewItem(p), + icon: 'fas fa-arrow-left', + name: '..', + }); + } + + for (const file of paginatedFiles.items) { + files.push(await fileToViewItem(file)); + } const view: View = defaultView('files', owner); - view.content.paginatedFiles = paginatedFiles; + view.content.paginatedFiles = { ...paginatedFiles, items: files }; + view.content.paginationLinks = paginationLinks; + view.content.postUrl = `${baseUrl()}/files`; + view.content.parentId = parent.id; + view.cssFiles = ['index/files']; + view.partials.push('pagination'); return view; } - // public async getOne(sessionId: string, isNew: boolean, userIdOrString: string | User = null, error: any = null): Promise { - // const owner = await this.initSession(sessionId); - // const userModel = this.models.user({ userId: owner.id }); - - // let user: User = {}; - - // if (typeof userIdOrString === 'string') { - // user = await userModel.load(userIdOrString as string); - // } else { - // user = userIdOrString as User; - // } - - // const view: View = defaultView('user', owner); - // view.content.user = user; - // view.content.isNew = isNew; - // view.content.buttonTitle = isNew ? 'Create user' : 'Update profile'; - // view.content.error = error; - // view.content.postUrl = `${baseUrl()}/users${isNew ? '/new' : `/${user.id}`}`; - // view.partials.push('errorBanner'); - - // return view; - // } + public async deleteAll(sessionId: string, dirId: string): Promise { + const owner = await this.initSession(sessionId); + const fileModel = this.models.file({ userId: owner.id }); + const parent: File = await fileModel.entityFromItemId(dirId, { returnFullEntity: true }); + await fileModel.deleteChildren(parent.id); + } } diff --git a/packages/server/src/models/BaseModel.ts b/packages/server/src/models/BaseModel.ts index 1aaa6b98d..e5b831543 100644 --- a/packages/server/src/models/BaseModel.ts +++ b/packages/server/src/models/BaseModel.ts @@ -34,10 +34,12 @@ export default abstract class BaseModel { private db_: DbConnection; private transactionHandler_: TransactionHandler; private modelFactory_: Function; + private baseUrl_: string; - public constructor(db: DbConnection, modelFactory: Function, options: ModelOptions = null) { + public constructor(db: DbConnection, modelFactory: Function, baseUrl: string, options: ModelOptions = null) { this.db_ = db; this.modelFactory_ = modelFactory; + this.baseUrl_ = baseUrl; this.options_ = Object.assign({}, options); this.transactionHandler_ = new TransactionHandler(db); @@ -52,6 +54,10 @@ export default abstract class BaseModel { return this.modelFactory_(db || this.db); } + protected get baseUrl(): string { + return this.baseUrl_; + } + protected get options(): ModelOptions { return this.options_; } diff --git a/packages/server/src/models/FileModel.ts b/packages/server/src/models/FileModel.ts index d7eb10a68..049bdd88f 100644 --- a/packages/server/src/models/FileModel.ts +++ b/packages/server/src/models/FileModel.ts @@ -4,6 +4,7 @@ import { ErrorForbidden, ErrorUnprocessableEntity, ErrorNotFound, ErrorBadReques import uuidgen from '../utils/uuidgen'; import { splitItemPath, filePathInfo } from '../utils/routeUtils'; import { paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination'; +import { setQueryParameters } from '../utils/urlUtils'; const mimeUtils = require('@joplin/lib/mime-utils.js').mime; @@ -15,6 +16,11 @@ export interface PaginatedFiles extends PaginatedResults { export interface EntityFromItemIdOptions { mustExist?: boolean; + returnFullEntity?: boolean; +} + +export interface LoadOptions { + skipPermissionCheck?: boolean; } export default class FileModel extends BaseModel { @@ -68,14 +74,16 @@ export default class FileModel extends BaseModel { } public async entityFromItemId(idOrPath: string, options: EntityFromItemIdOptions = {}): Promise { + if (!idOrPath) throw new Error('ID cannot be null'); + options = { mustExist: true, ...options }; const specialDirId = await this.specialDirId(idOrPath); if (specialDirId) { - return { id: specialDirId }; + return options.returnFullEntity ? this.load(specialDirId) : { id: specialDirId }; } else if (idOrPath.indexOf(':') < 0) { - return { id: idOrPath }; + return options.returnFullEntity ? this.load(idOrPath) : { id: idOrPath }; } else { // When this input is a path, there can be two cases: // - A path to an existing file - in which case we return the file @@ -99,7 +107,7 @@ export default class FileModel extends BaseModel { // This is an existing file const existingFile = await this.fileByName(parentId, fileInfo.basename); - if (existingFile) return { id: existingFile.id }; + if (existingFile) return options.returnFullEntity ? existingFile : { id: existingFile.id }; if (options.mustExist) throw new ErrorNotFound(`file not found: ${idOrPath}`); @@ -248,6 +256,11 @@ export default class FileModel extends BaseModel { return this.reservedCharacters.some(c => path.indexOf(c) >= 0); } + public async fileUrl(idOrPath: string, query: any = null): Promise { + const file: File = await this.entityFromItemId(idOrPath, { returnFullEntity: true }); + return setQueryParameters(`${this.baseUrl}/files/${await this.itemFullPath(file)}`, query); + } + private async pathToFiles(path: string, mustExist: boolean = true): Promise { const filenames = splitItemPath(path); const output: File[] = []; @@ -278,35 +291,35 @@ export default class FileModel extends BaseModel { // Mostly makes sense for testing/debugging because the filename would // have to globally unique, which is not a requirement. - public async loadByName(name: string): Promise { + public async loadByName(name: string, options: LoadOptions = {}): Promise { const file: File = await this.db(this.tableName) .select(this.defaultFields) .where({ name: name }) .andWhere({ owner_id: this.userId }) .first(); if (!file) throw new ErrorNotFound(`No such file: ${name}`); - await this.checkCanReadPermissions(file); + if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file); return file; } - public async loadWithContent(id: string): Promise { + public async loadWithContent(id: string, options: LoadOptions = {}): Promise { const file: File = await this.db(this.tableName).select('*').where({ id: id }).first(); if (!file) return null; - await this.checkCanReadPermissions(file); + if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file); return file; } - public async loadByIds(ids: string[]): Promise { + public async loadByIds(ids: string[], options: LoadOptions = {}): Promise { const files: File[] = await super.loadByIds(ids); if (!files.length) return []; - await this.checkCanReadPermissions(files); + if (!options.skipPermissionCheck) await this.checkCanReadPermissions(files); return files; } - public async load(id: string): Promise { + public async load(id: string, options: LoadOptions = {}): Promise { const file: File = await super.load(id); if (!file) return null; - await this.checkCanReadPermissions(file); + if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file); return file; } @@ -332,6 +345,13 @@ export default class FileModel extends BaseModel { return super.save(file, options); } + public async childrenCount(id: string): Promise { + const parent = await this.load(id); + await this.checkCanReadPermissions(parent); + const r = await this.db(this.tableName).select('id').where('parent_id', id).count('id', { as: 'total' }); + return r.length && r[0].total ? r[0].total : 0; + } + public async childrens(id: string, pagination: Pagination): Promise { const parent = await this.load(id); await this.checkCanReadPermissions(parent); @@ -343,6 +363,20 @@ export default class FileModel extends BaseModel { return output.map(r => r.id); } + public async deleteChildren(id: string): Promise { + const file: File = await this.load(id); + if (!file) return; + await this.checkCanWritePermission(file); + if (!file.is_directory) throw new ErrorBadRequest(`Not a directory: ${id}`); + + await this.withTransaction(async () => { + const childrenIds = await this.childrenIds(file.id); + for (const childId of childrenIds) { + await this.delete(childId); + } + }); + } + public async delete(id: string, options: DeleteOptions = {}): Promise { const file: File = await this.load(id); if (!file) return; diff --git a/packages/server/src/models/factory.ts b/packages/server/src/models/factory.ts index c59cf0b92..143683088 100644 --- a/packages/server/src/models/factory.ts +++ b/packages/server/src/models/factory.ts @@ -66,36 +66,38 @@ import ChangeModel from './ChangeModel'; export class Models { private db_: DbConnection; + private baseUrl_: string; - public constructor(db: DbConnection) { + public constructor(db: DbConnection, baseUrl: string) { this.db_ = db; + this.baseUrl_ = baseUrl; } public file(options: ModelOptions = null) { - return new FileModel(this.db_, newModelFactory, options); + return new FileModel(this.db_, newModelFactory, this.baseUrl_, options); } public user(options: ModelOptions = null) { - return new UserModel(this.db_, newModelFactory, options); + return new UserModel(this.db_, newModelFactory, this.baseUrl_, options); } public apiClient(options: ModelOptions = null) { - return new ApiClientModel(this.db_, newModelFactory, options); + return new ApiClientModel(this.db_, newModelFactory, this.baseUrl_, options); } public permission(options: ModelOptions = null) { - return new PermissionModel(this.db_, newModelFactory, options); + return new PermissionModel(this.db_, newModelFactory, this.baseUrl_, options); } public session(options: ModelOptions = null) { - return new SessionModel(this.db_, newModelFactory, options); + return new SessionModel(this.db_, newModelFactory, this.baseUrl_, options); } public change(options: ModelOptions = null) { - return new ChangeModel(this.db_, newModelFactory, options); + return new ChangeModel(this.db_, newModelFactory, this.baseUrl_, options); } } -export default function newModelFactory(db: DbConnection): Models { - return new Models(db); +export default function newModelFactory(db: DbConnection, baseUrl: string): Models { + return new Models(db, baseUrl); } diff --git a/packages/server/src/models/utils/pagination.test.ts b/packages/server/src/models/utils/pagination.test.ts index a37f9e922..ab4069e3d 100644 --- a/packages/server/src/models/utils/pagination.test.ts +++ b/packages/server/src/models/utils/pagination.test.ts @@ -1,5 +1,5 @@ import { expectThrow } from '../../utils/testUtils'; -import { defaultPagination, Pagination, requestPagination } from './pagination'; +import { defaultPagination, Pagination, createPaginationLinks, requestPagination } from './pagination'; describe('pagination', function() { @@ -69,4 +69,56 @@ describe('pagination', function() { await expectThrow(async () => requestPagination({ page: 0 })); }); + test('should create page link logic', async function() { + expect(createPaginationLinks(1, 5)).toEqual([ + { page: 1, isCurrent: true }, + { page: 2 }, + { page: 3 }, + { page: 4 }, + { page: 5 }, + ]); + + expect(createPaginationLinks(3, 5)).toEqual([ + { page: 1 }, + { page: 2 }, + { page: 3, isCurrent: true }, + { page: 4 }, + { page: 5 }, + ]); + + expect(createPaginationLinks(1, 10)).toEqual([ + { page: 1, isCurrent: true }, + { page: 2 }, + { page: 3 }, + { page: 4 }, + { page: 5 }, + { isEllipsis: true }, + { page: 9 }, + { page: 10 }, + ]); + + expect(createPaginationLinks(10, 20)).toEqual([ + { page: 1 }, + { page: 2 }, + { isEllipsis: true }, + { page: 8 }, + { page: 9 }, + { page: 10, isCurrent: true }, + { page: 11 }, + { page: 12 }, + { isEllipsis: true }, + { page: 19 }, + { page: 20 }, + ]); + + expect(createPaginationLinks(20, 20)).toEqual([ + { page: 1 }, + { page: 2 }, + { isEllipsis: true }, + { page: 18 }, + { page: 19 }, + { page: 20, isCurrent: true }, + ]); + }); + }); diff --git a/packages/server/src/models/utils/pagination.ts b/packages/server/src/models/utils/pagination.ts index 822424763..6da7f3c56 100644 --- a/packages/server/src/models/utils/pagination.ts +++ b/packages/server/src/models/utils/pagination.ts @@ -26,17 +26,17 @@ export interface PaginatedResults { cursor?: string; } -const pageMaxSize = 1000; -const defaultOrderField = 'updated_time'; -const defaultOrderDir = PaginationOrderDir.DESC; +export const pageMaxSize = 1000; +const defaultOrderField_ = 'updated_time'; +const defaultOrderDir_ = PaginationOrderDir.DESC; export function defaultPagination(): Pagination { return { limit: pageMaxSize, order: [ { - by: defaultOrderField, - dir: defaultOrderDir, + by: defaultOrderField_, + dir: defaultOrderDir_, }, ], page: 1, @@ -47,7 +47,10 @@ function dbOffset(pagination: Pagination): number { return pagination.limit * (pagination.page - 1); } -function requestPaginationOrder(query: any): PaginationOrder[] { +export function requestPaginationOrder(query: any, defaultOrderField: string = null, defaultOrderDir: PaginationOrderDir = null): PaginationOrder[] { + if (defaultOrderField === null) defaultOrderField = defaultOrderField_; + if (defaultOrderDir === null) defaultOrderDir = defaultOrderDir_; + const orderBy: string = 'order_by' in query ? query.order_by : defaultOrderField; const orderDir: PaginationOrderDir = 'order_dir' in query ? query.order_dir : defaultOrderDir; @@ -59,7 +62,7 @@ function requestPaginationOrder(query: any): PaginationOrder[] { }]; } -function validatePagination(p: Pagination): Pagination { +export function validatePagination(p: Pagination): Pagination { if (p.limit < 0 || p.limit > pageMaxSize) throw new ErrorBadRequest(`Limit out of bond: ${p.limit}`); if (p.page <= 0) throw new ErrorBadRequest(`Invalid page number: ${p.page}`); @@ -104,11 +107,74 @@ export function requestChangePagination(query: any): ChangePagination { return output; } +export interface PageLink { + page?: number; + isEllipsis?: boolean; + isCurrent?: boolean; + url?: string; +} + +export function createPaginationLinks(page: number, pageCount: number, urlTemplate: string = null): PageLink[] { + if (!pageCount) return []; + + let output: PageLink[] = []; + const firstPage: number = Math.max(page - 2, 1); + + for (let p = firstPage; p <= firstPage + 4; p++) { + if (p > pageCount) break; + output.push({ page: p }); + } + + const firstPages: PageLink[] = []; + for (let p = 1; p <= 2; p++) { + if (output.find(o => o.page === p) || p > pageCount) continue; + firstPages.push({ page: p }); + } + + if (firstPages.length && (output[0].page - firstPages[firstPages.length - 1].page) > 1) { + firstPages.push({ isEllipsis: true }); + } + + output = firstPages.concat(output); + + const lastPages: PageLink[] = []; + for (let p = pageCount - 1; p <= pageCount; p++) { + if (output.find(o => o.page === p) || p > pageCount || p < 1) continue; + lastPages.push({ page: p }); + } + + if (lastPages.length && (lastPages[0].page - output[output.length - 1].page) > 1) { + output.push({ isEllipsis: true }); + } + + output = output.concat(lastPages); + + output = output.map(o => { + return o.page === page ? { ...o, isCurrent: true } : o; + }); + + if (urlTemplate) { + output = output.map(o => { + if (o.isEllipsis) return o; + return { ...o, url: urlTemplate.replace(/PAGE_NUMBER/, o.page.toString()) }; + }); + } + + return output; +} + export async function paginateDbQuery(query: Knex.QueryBuilder, pagination: Pagination): Promise { pagination = processCursor(pagination); + const orderSql: any[] = pagination.order.map(o => { + return { + column: o.by, + order: o.dir, + }; + }); + const items = await query - .orderBy(pagination.order[0].by, pagination.order[0].dir) + .orderBy(orderSql) .offset(dbOffset(pagination)) .limit(pagination.limit); diff --git a/packages/server/src/routes/api/files.ts b/packages/server/src/routes/api/files.ts index ddafe4081..c84b359c1 100644 --- a/packages/server/src/routes/api/files.ts +++ b/packages/server/src/routes/api/files.ts @@ -1,7 +1,7 @@ import { ErrorNotFound, ErrorMethodNotAllowed, ErrorBadRequest } from '../../utils/errors'; import { File } from '../../db'; import { bodyFields, formParse, headerSessionId } from '../../utils/requestUtils'; -import { SubPath, Route, ResponseType, Response } from '../../utils/routeUtils'; +import { SubPath, Route, respondWithFileContent } from '../../utils/routeUtils'; import { AppContext } from '../../utils/types'; import * as fs from 'fs-extra'; import { requestChangePagination, requestPagination } from '../../models/utils/pagination'; @@ -31,12 +31,8 @@ const route: Route = { if (path.link === 'content') { if (ctx.method === 'GET') { - const koaResponse = ctx.response; const file: File = await fileController.getFileContent(headerSessionId(ctx.headers), path.id); - koaResponse.body = file.content; - koaResponse.set('Content-Type', file.mime_type); - koaResponse.set('Content-Length', file.size.toString()); - return new Response(ResponseType.KoaResponse, koaResponse); + return respondWithFileContent(ctx.response, file); } if (ctx.method === 'PUT') { diff --git a/packages/server/src/routes/default.ts b/packages/server/src/routes/default.ts index f28b3e54c..af56d610e 100644 --- a/packages/server/src/routes/default.ts +++ b/packages/server/src/routes/default.ts @@ -13,14 +13,17 @@ interface PathToFileMap { } // Most static assets should be in /public, but for those that are not, for -// example if they are in node_modules, use the map below +// example if they are in node_modules, use the map below. const pathToFileMap: PathToFileMap = { 'css/bulma.min.css': 'node_modules/bulma/css/bulma.min.css', 'css/bulma-prefers-dark.min.css': 'node_modules/bulma-prefers-dark/css/bulma-prefers-dark.min.css', + 'css/fontawesome/css/all.min.css': 'node_modules/@fortawesome/fontawesome-free/css/all.min.css', }; async function findLocalFile(path: string): Promise { if (path in pathToFileMap) return pathToFileMap[path]; + // For now a bit of a hack to load FontAwesome fonts. + if (path.indexOf('css/fontawesome/webfonts/fa-') === 0) return `node_modules/@fortawesome/fontawesome-free/${path.substr(16)}`; let localPath = normalize(path); if (localPath.indexOf('..') >= 0) throw new ErrorNotFound(`Cannot resolve path: ${path}`); diff --git a/packages/server/src/routes/index/files.ts b/packages/server/src/routes/index/files.ts index b306d8fc7..a802e3564 100644 --- a/packages/server/src/routes/index/files.ts +++ b/packages/server/src/routes/index/files.ts @@ -1,8 +1,8 @@ -import { SubPath, Route } from '../../utils/routeUtils'; +import { SubPath, Route, respondWithFileContent, redirect } from '../../utils/routeUtils'; import { AppContext } from '../../utils/types'; -import { contextSessionId } from '../../utils/requestUtils'; -import { ErrorMethodNotAllowed } from '../../utils/errors'; -import { requestPagination } from '../../models/utils/pagination'; +import { contextSessionId, formParse } from '../../utils/requestUtils'; +import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors'; +import { File } from '../../db'; const route: Route = { @@ -10,7 +10,29 @@ const route: Route = { const sessionId = contextSessionId(ctx); if (ctx.method === 'GET') { - return ctx.controllers.indexFiles().getIndex(sessionId, path.id, requestPagination(ctx.query)); + if (!path.link) { + return ctx.controllers.indexFiles().getIndex(sessionId, path.id, ctx.query); + } else if (path.link === 'content') { + const file: File = await ctx.controllers.apiFile().getFileContent(sessionId, path.id); + return respondWithFileContent(ctx.response, file); + } + + throw new ErrorNotFound(); + } + + if (ctx.method === 'POST') { + const body = await formParse(ctx.req); + const fields = body.fields; + const parentId = fields.parent_id; + const user = await ctx.models.session().sessionUser(sessionId); + + if (fields.delete_all_button) { + await ctx.controllers.indexFiles().deleteAll(sessionId, parentId); + } else { + throw new Error('Invalid form button'); + } + + return redirect(ctx, await ctx.models.file({ userId: user.id }).fileUrl(parentId, ctx.query)); } throw new ErrorMethodNotAllowed(); diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index 9accae974..c2ac8c3ba 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -1,4 +1,4 @@ -import { ItemAddressingType } from '../db'; +import { File, ItemAddressingType } from '../db'; import { ErrorBadRequest } from './errors'; import { AppContext } from './types'; @@ -190,3 +190,10 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute { throw new Error('Unreachable'); } + +export function respondWithFileContent(koaResponse: any, file: File): Response { + koaResponse.body = file.content; + koaResponse.set('Content-Type', file.mime_type); + koaResponse.set('Content-Length', file.size.toString()); + return new Response(ResponseType.KoaResponse, koaResponse); +} diff --git a/packages/server/src/utils/testUtils.ts b/packages/server/src/utils/testUtils.ts index 6665cf600..94f5330f3 100644 --- a/packages/server/src/utils/testUtils.ts +++ b/packages/server/src/utils/testUtils.ts @@ -57,8 +57,12 @@ export function db() { return db_; } +function baseUrl() { + return 'http://localhost:22300'; +} + export function models() { - return modelFactory(db()); + return modelFactory(db(), baseUrl()); } export function controllers() { diff --git a/packages/server/src/utils/time.ts b/packages/server/src/utils/time.ts index 1b5972d56..8b5a4c1b3 100644 --- a/packages/server/src/utils/time.ts +++ b/packages/server/src/utils/time.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +import dayjs = require('dayjs'); export function msleep(ms: number) { return new Promise((resolve: Function) => { @@ -7,3 +7,7 @@ export function msleep(ms: number) { }, ms); }); } + +export function formatDateTime(ms: number): string { + return dayjs(ms).format('D MMM YY HH:mm:ss'); +} diff --git a/packages/server/src/utils/urlUtils.ts b/packages/server/src/utils/urlUtils.ts new file mode 100644 index 000000000..e3d52ec8f --- /dev/null +++ b/packages/server/src/utils/urlUtils.ts @@ -0,0 +1,15 @@ +/* eslint-disable import/prefer-default-export */ + +import { URL } from 'url'; + +export function setQueryParameters(url: string, query: any): string { + if (!query) return url; + + const u = new URL(url); + + for (const k of Object.keys(query)) { + u.searchParams.set(k, query[k]); + } + + return u.toString(); +} diff --git a/packages/server/src/views/index/error.mustache b/packages/server/src/views/index/error.mustache index 76d144eb6..c0e84232f 100644 --- a/packages/server/src/views/index/error.mustache +++ b/packages/server/src/views/index/error.mustache @@ -2,6 +2,9 @@
{{error.message}} + {{#stack}} +
{{.}}
+ {{/stack}}

Back to the login page

diff --git a/packages/server/src/views/index/files.mustache b/packages/server/src/views/index/files.mustache index 6f0ebcc86..2f1da2d8f 100644 --- a/packages/server/src/views/index/files.mustache +++ b/packages/server/src/views/index/files.mustache @@ -1,5 +1,38 @@ -
- {{#paginatedFiles.items}} -
{{name}}
- {{/paginatedFiles.items}} -
\ No newline at end of file +
+ + +
+ + + + + + + + + + + {{#paginatedFiles.items}} + + + + + + {{/paginatedFiles.items}} + +
NameMimeTimestamp
+ {{name}} + {{mime}}{{timestamp}}
+ +{{>pagination}} + + diff --git a/packages/server/src/views/layouts/default.mustache b/packages/server/src/views/layouts/default.mustache index 6f0cad667..2c9d5c7c2 100644 --- a/packages/server/src/views/layouts/default.mustache +++ b/packages/server/src/views/layouts/default.mustache @@ -5,6 +5,7 @@ + {{#cssFiles}} diff --git a/packages/server/src/views/partials/pagination.mustache b/packages/server/src/views/partials/pagination.mustache new file mode 100644 index 000000000..c97da2496 --- /dev/null +++ b/packages/server/src/views/partials/pagination.mustache @@ -0,0 +1,16 @@ +