mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-11 18:24:43 +02:00
Server: Add basic file manager
This commit is contained in:
parent
70381a233b
commit
ee2ec28cd4
@ -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
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||
|
@ -214,7 +214,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
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');
|
||||
|
25
packages/server/package-lock.json
generated
25
packages/server/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
0
packages/server/public/css/index/files.css
Normal file
0
packages/server/public/css/index/files.css
Normal file
@ -25,3 +25,11 @@ input.form-control {
|
||||
.main {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
table.table .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.table .stretch {
|
||||
width: 100%;
|
||||
}
|
@ -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...');
|
||||
|
@ -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;
|
||||
|
@ -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<View> {
|
||||
public async getIndex(sessionId: string, dirId: string, query: any): Promise<View> {
|
||||
// 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<any> {
|
||||
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<View> {
|
||||
// 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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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_;
|
||||
}
|
||||
|
@ -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<File> {
|
||||
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<string> {
|
||||
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<File[]> {
|
||||
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<File> {
|
||||
public async loadByName(name: string, options: LoadOptions = {}): Promise<File> {
|
||||
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<any> {
|
||||
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<any> {
|
||||
const file: File = await this.db<File>(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<File[]> {
|
||||
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<File[]> {
|
||||
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<File> {
|
||||
public async load(id: string, options: LoadOptions = {}): Promise<File> {
|
||||
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<number> {
|
||||
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<PaginatedFiles> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const file: File = await this.load(id);
|
||||
if (!file) return;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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<PaginatedResults> {
|
||||
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);
|
||||
|
||||
|
@ -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') {
|
||||
|
@ -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<string> {
|
||||
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}`);
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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');
|
||||
}
|
||||
|
15
packages/server/src/utils/urlUtils.ts
Normal file
15
packages/server/src/utils/urlUtils.ts
Normal file
@ -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();
|
||||
}
|
@ -2,6 +2,9 @@
|
||||
<div class="container">
|
||||
<div class="notification is-danger">
|
||||
{{error.message}}
|
||||
{{#stack}}
|
||||
<pre>{{.}}</pre>
|
||||
{{/stack}}
|
||||
</div>
|
||||
<p><a href="{{{global.baseUrl}}}/login">Back to the login page</a></p>
|
||||
</div>
|
||||
|
@ -1,5 +1,38 @@
|
||||
<div>
|
||||
<form id="file_form" action="{{{postUrl}}}" method="POST" class="block">
|
||||
<input type="hidden" name="parent_id" value="{{parentId}}"/>
|
||||
<input type="submit" name="delete_all_button" class="button is-danger" value="Delete all" />
|
||||
</form>
|
||||
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="stretch">Name</th>
|
||||
<th class="nowrap">Mime</th>
|
||||
<th class="nowrap">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#paginatedFiles.items}}
|
||||
<div>{{name}}</div>
|
||||
<tr>
|
||||
<td class="stretch item-{{type}}">
|
||||
<a href="{{url}}"><span class="icon"><i class="{{icon}}"></i></span>{{name}}</a>
|
||||
</td>
|
||||
<td class="nowrap">{{mime}}</td>
|
||||
<td class="nowrap">{{timestamp}}</td>
|
||||
</tr>
|
||||
{{/paginatedFiles.items}}
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{>pagination}}
|
||||
|
||||
<script>
|
||||
onDocumentReady(function() {
|
||||
document.getElementById("file_form").addEventListener('submit', function(event) {
|
||||
if (event.submitter.getAttribute('name') === 'delete_all_button') {
|
||||
const ok = confirm('Delete all files and directories in this directory?');
|
||||
if (!ok) event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@ -5,6 +5,7 @@
|
||||
<link rel="stylesheet" href="{{{baseUrl}}}/css/bulma.min.css" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{{baseUrl}}}/css/bulma-prefers-dark.min.css" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{{baseUrl}}}/css/main.css" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{{baseUrl}}}/css/fontawesome/css/all.min.css" crossorigin="anonymous">
|
||||
<script src="{{{baseUrl}}}/js/main.js"></script>
|
||||
{{#cssFiles}}
|
||||
<link rel="stylesheet" href="{{{.}}}" crossorigin="anonymous">
|
||||
|
16
packages/server/src/views/partials/pagination.mustache
Normal file
16
packages/server/src/views/partials/pagination.mustache
Normal file
@ -0,0 +1,16 @@
|
||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
||||
<ul class="pagination-list">
|
||||
{{#paginationLinks}}
|
||||
{{#isEllipsis}}
|
||||
<li>
|
||||
<span class="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
{{/isEllipsis}}
|
||||
{{^isEllipsis}}
|
||||
<li>
|
||||
<a class="pagination-link {{#isCurrent}}is-current{{/isCurrent}}" aria-label="Goto page {{page}}" href="{{url}}">{{page}}</a>
|
||||
</li>
|
||||
{{/isEllipsis}}
|
||||
{{/paginationLinks}}
|
||||
</ul>
|
||||
</nav>
|
Loading…
Reference in New Issue
Block a user