(core) add docs.options column to home db to store doc description, icon, openMode

Summary:
Bundles some new document options into a JSON column.
The icon option is treated somewhat gingerly.  It is intended, at
least initially, to store an image thumbnail for a document as a
url to hand-prepared assets (for examples and templates), so it is
locked down to a particular url prefix to avoid opening the door to
mischief.

Test Plan: added test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D2916
This commit is contained in:
Paul Fitzpatrick 2021-07-15 17:38:21 -04:00
parent e5eeb3ec80
commit 997be24a21
4 changed files with 76 additions and 3 deletions

View File

@ -4,7 +4,7 @@ import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {BillingAPI, BillingAPIImpl} from 'app/common/BillingAPI';
import {BrowserSettings} from 'app/common/BrowserSettings';
import {BulkColValues, TableColValues, UserAction} from 'app/common/DocActions';
import {DocCreationInfo} from 'app/common/DocListAPI';
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
import {Features} from 'app/common/Features';
import {isClient} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
@ -104,11 +104,22 @@ export interface Workspace extends WorkspaceProperties {
isSupportWorkspace?: boolean;
}
// Non-core options for a document.
// "Non-core" means bundled into a single options column in the database.
// TODO: consider smoothing over this distinction in the API.
export interface DocumentOptions {
description?: string|null;
icon?: string|null;
openMode?: OpenDocMode|null;
}
export interface DocumentProperties extends CommonProperties {
isPinned: boolean;
urlId: string|null;
options: DocumentOptions|null;
}
export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId'];
export const documentPropertyKeys = [...commonPropertyKeys, 'isPinned', 'urlId', 'options'];
export interface Document extends DocumentProperties {
id: string;

View File

@ -1,6 +1,6 @@
import {ApiError} from 'app/common/ApiError';
import {Role} from 'app/common/roles';
import {DocumentProperties, documentPropertyKeys, NEW_DOCUMENT_CODE} from "app/common/UserAPI";
import {DocumentOptions, DocumentProperties, documentPropertyKeys, NEW_DOCUMENT_CODE} from "app/common/UserAPI";
import {nativeValues} from 'app/gen-server/lib/values';
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
import {AclRuleDoc} from "./AclRule";
@ -55,6 +55,9 @@ export class Document extends Resource {
@OneToMany(type => Alias, alias => alias.doc)
public aliases: Alias[];
@Column({name: 'options', type: nativeValues.jsonEntityType, nullable: true})
public options: DocumentOptions | null;
public checkProperties(props: any): props is Partial<DocumentProperties> {
return super.checkProperties(props, documentPropertyKeys);
}
@ -68,5 +71,45 @@ export class Document extends Resource {
}
this.urlId = props.urlId;
}
if (props.options !== undefined) {
// Options are merged over the existing state - unless options
// object is set to "null", in which case the state is wiped
// completely.
if (props.options === null) {
this.options = null;
} else {
this.options = this.options || {};
if (props.options.description !== undefined) {
this.options.description = props.options.description;
}
if (props.options.openMode !== undefined) {
this.options.openMode = props.options.openMode;
}
if (props.options.icon !== undefined) {
this.options.icon = sanitizeIcon(props.options.icon);
}
// Normalize so that null equates with absence.
for (const key of Object.keys(this.options) as Array<keyof DocumentOptions>) {
if (this.options[key] === null) {
delete this.options[key];
}
}
// Normalize so that no options set equates with absense.
if (Object.keys(this.options).length === 0) {
this.options = null;
}
}
}
}
}
// Check that icon points to an expected location. This will definitely
// need changing, it is just a placeholder as the icon feature is developed.
function sanitizeIcon(icon: string|null) {
if (icon === null) { return icon; }
const url = new URL(icon);
if (url.protocol !== 'https:' || url.host !== 'grist-static.com' || !url.pathname.startsWith('/icons/')) {
throw new ApiError('invalid document icon', 400);
}
return url.href;
}

View File

@ -0,0 +1,17 @@
import { nativeValues } from 'app/gen-server/lib/values';
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class DocOptions1626369037484 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.addColumn('docs', new TableColumn({
name: 'options',
type: nativeValues.jsonType,
isNullable: true,
}));
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropColumn('docs', 'options');
}
}

View File

@ -193,6 +193,8 @@ export function pruneAPIResult<T>(data: T): T {
// Do not include removedAt field if it is not set. It is not relevant to regular
// situations where the user is working with non-deleted resources.
if (key === 'removedAt' && value === null) { return undefined; }
// Don't bother sending option fields if there are no options set.
if (key === 'options' && value === null) { return undefined; }
return INTERNAL_FIELDS.has(key) ? undefined : value;
});
return JSON.parse(output);