1
0
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:
Laurent Cozic 2020-12-29 18:04:57 +00:00
parent 70381a233b
commit ee2ec28cd4
26 changed files with 449 additions and 96 deletions

View File

@ -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
View File

@ -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

View File

@ -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');

View File

@ -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",

View File

@ -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",

View File

@ -25,3 +25,11 @@ input.form-control {
.main {
padding: 0 3rem;
}
table.table .nowrap {
white-space: nowrap;
}
table.table .stretch {
width: 100%;
}

View File

@ -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...');

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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_;
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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 },
]);
});
});

View File

@ -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);

View File

@ -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') {

View File

@ -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}`);

View File

@ -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();

View File

@ -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);
}

View File

@ -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() {

View File

@ -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');
}

View 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();
}

View File

@ -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>

View File

@ -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>

View File

@ -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">

View 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">&hellip;</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>