Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/api-versions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const API_VERSION_2 = 2;
export const API_VERSION_3 = 3;
export const API_VERSION_4 = 4;
export const API_VERSION_5 = 5;

export const API_VERSION_ACTUAL = API_VERSION_4;
export const API_VERSION_ACTUAL = API_VERSION_5;
export const API_VERSION_MINIMAL = API_VERSION_2;
2 changes: 2 additions & 0 deletions app/controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export { default as SearchController } from './controllers/api/v2/SearchControll
export { default as EventsController } from './controllers/api/v2/EventsController';
export { default as CommentLikesController } from './controllers/api/v2/CommentLikesController';
export { default as InvitationsController } from './controllers/api/v2/InvitationsController';

export { default as DocumentsController } from './controllers/api/v5/DocumentsController';
257 changes: 257 additions & 0 deletions app/controllers/api/v5/DocumentsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import createDebug from 'debug';
import compose from 'koa-compose';

import { serializeDocument } from '../../../serializers/v2/document';
import { serializeUsersByIds } from '../../../serializers/v2/user';
import {
reportError,
NotFoundException,
ValidationException,
} from '../../../support/exceptions';
import { authRequired } from '../../middlewares';
import { dbAdapter, Document } from '../../../models';

const debug = createDebug('freefeed:controller:documents');

export default class DocumentsController {
app;

constructor(app) {
this.app = app;
debug('DocumentsController created');
}

async _resolveUsername(userId) {
if (!userId) return undefined;
try {
const user = await dbAdapter.getUserById(userId);
return user?.username;
} catch {
return undefined;
}
}

create = compose([
authRequired(),
async (ctx) => {
const body = ctx.request.body;
const { user } = ctx.state;

if (!body.title || !body.title.trim()) {
throw new ValidationException('Title is required');
}

if (body.title && body.title.length > 200) {
throw new ValidationException('Title must not exceed 200 characters');
}

if (body.slug && body.slug.length > 200) {
throw new ValidationException('Slug must not exceed 200 characters');
}

const visibility = ['public', 'protected', 'private'].includes(body.visibility) ? body.visibility : 'public';

try {
const slug = body.slug ? Document.slugify(body.slug) : undefined;
const params = {
title: body.title.trim(),
slug,
body: body.body || '',
parentId: body.parentId || null,
isPublished: body.isPublished !== false,
visibility,
tags: Array.isArray(body.tags) ? body.tags : [],
};

const doc = await Document.create(params, user);

ctx.status = 201;
ctx.body = {
documents: serializeDocument(doc, true, user.username),
users: await serializeUsersByIds([doc.userId], user.id),
};
} catch (e) {
if (e.status === 409) {
throw e;
}
reportError(ctx)(e);
}
},
]);

list = compose([
authRequired(),
async (ctx) => {
const { user } = ctx.state;
const limit = Math.min(parseInt(ctx.query.limit) || 50, 100);
const offset = parseInt(ctx.query.offset) || 0;

const docs = await dbAdapter.getDocumentsByUser(user.id, { limit, offset });
const total = await dbAdapter.countUserDocuments(user.id);

ctx.body = {
documents: docs.map((d) => serializeDocument(d, false, user.username)),
total,
limit,
offset,
};
},
]);

getById = compose([
authRequired(),
async (ctx) => {
const { user } = ctx.state;
const doc = await dbAdapter.getDocumentById(ctx.params.docId);

if (!doc || doc.userId !== user.id) {
throw new NotFoundException('Document not found');
}

ctx.body = {
documents: serializeDocument(doc, true, user.username),
users: await serializeUsersByIds([doc.userId], user.id),
};
},
]);
update = compose([
authRequired(),
async (ctx) => {
const { user } = ctx.state;
const body = ctx.request.body;

const doc = await dbAdapter.getDocumentById(ctx.params.docId);
if (!doc || doc.userId !== user.id) {
throw new NotFoundException('Document not found');
}

const updateParams = {};

if (body.title !== undefined) {
if (body.title.length > 200) {
throw new ValidationException('Title must not exceed 200 characters');
}
updateParams.title = body.title.trim();
}
if (body.slug !== undefined) {
if (body.slug.length > 200) {
throw new ValidationException('Slug must not exceed 200 characters');
}
updateParams.slug = body.slug;
}
if (body.body !== undefined) {
updateParams.body = body.body;
}
if (body.parentId !== undefined) {
updateParams.parentId = body.parentId || null;
}
if (body.isPublished !== undefined) {
updateParams.isPublished = body.isPublished;
}
if (body.visibility !== undefined && ['public', 'protected', 'private'].includes(body.visibility)) {
updateParams.visibility = body.visibility;
}
if (body.tags !== undefined) {
updateParams.tags = Array.isArray(body.tags) ? body.tags : [];
}

try {
const updated = await doc.update(updateParams);
ctx.body = {
documents: serializeDocument(updated, true, user.username),
};
} catch (e) {
if (e.status === 409) {
throw e;
}
reportError(ctx)(e);
}
},
]);

destroy = compose([
authRequired(),
async (ctx) => {
const { user } = ctx.state;

const doc = await dbAdapter.getDocumentById(ctx.params.docId);
if (!doc || doc.userId !== user.id) {
throw new NotFoundException('Document not found');
}

await doc.destroy();
ctx.body = { success: true };
},
]);

tree = compose([
authRequired(),
async (ctx) => {
const { user } = ctx.state;
const docs = await dbAdapter.getDocumentsByUser(user.id);
const tags = await dbAdapter.getUserDocumentTags(user.id);

const childrenMap = new Map();

for (const doc of docs) {
const pid = doc.parentId || '';
if (!childrenMap.has(pid)) {
childrenMap.set(pid, []);
}
childrenMap.get(pid).push(doc);
}

function buildSubtree(parentId) {
const items = childrenMap.get(parentId || '') || [];
return items.map((doc) => ({
id: doc.id,
title: doc.title,
slug: doc.slug,
tags: doc.tags,
children: buildSubtree(doc.id),
}));
}

ctx.body = {
tree: buildSubtree(null),
tags,
};
},
]);

getByUserAndSlug = async (ctx) => { const { slug, username } = ctx.params;
const viewerId = ctx.state.user?.id;
let doc;

if (username) {
const user = await dbAdapter.getUserByUsername(username);
if (!user) {
throw new NotFoundException('Document not found');
}
doc = await dbAdapter.getDocumentByUserAndSlug(user.id, slug);
} else {
doc = await dbAdapter.getDocumentBySlug(slug);
}

if (!doc) {
throw new NotFoundException('Document not found');
}

if (!doc.isPublished) {
throw new NotFoundException('Document not found');
}

if (doc.visibility === 'private' && doc.userId !== viewerId) {
throw new NotFoundException('Document not found');
}
if (doc.visibility === 'protected' && doc.userId !== viewerId) {
throw new NotFoundException('Document not found');
}

const docUsername = username || await this._resolveUsername(doc.userId);

ctx.body = {
documents: serializeDocument(doc, true, docUsername),
};
};
}
35 changes: 35 additions & 0 deletions app/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,41 @@ export class Attachment {
destroy(destroyedBy?: User): Promise<void>;
}

type DocumentParams = {
id?: UUID;
userId: UUID;
title: string;
slug: string;
body: string;
parentId?: UUID | null;
isPublished?: boolean;
tags?: string[];
createdAt?: string;
updatedAt?: string;
};
export class Document {
constructor(params: DocumentParams);
id: UUID;
userId: UUID;
title: string;
slug: string;
body: string;
parentId: UUID | null;
isPublished: boolean;
tags: string[];
createdAt: Date;
updatedAt: Date;
readonly url: string;
static create(params: Partial<DocumentParams>, user: User): Promise<Document>;
update(params: Partial<DocumentParams>): Promise<Document>;
destroy(): Promise<void>;
addTag(tag: string): Promise<void>;
removeTag(tag: string): Promise<void>;
getChildren(): Promise<Document[]>;
getParent(): Promise<Document | null>;
static slugify(text: string): string;
}

export class Comment {
static VISIBLE: 0;
static DELETED: 1;
Expand Down
2 changes: 2 additions & 0 deletions app/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { addModel as groupModel } from './models/group';
import { addModel as postModel } from './models/post';
import { addModel as timelineModel } from './models/timeline';
import { addModel as userModel } from './models/user';
import { addModel as documentModel } from './models/document';
import { addServerInfoModel } from './models/server-info';
import { addJobModel, addJobManagerModel } from './models/job';
import { SessionTokenV1Store } from './models/auth-tokens';
Expand All @@ -34,6 +35,7 @@ export const Group = groupModel(dbAdapter);
export const Post = postModel(dbAdapter);
export const Timeline = timelineModel(dbAdapter);
export const Attachment = attachmentModel(dbAdapter);
export const Document = documentModel(dbAdapter);
export const Comment = commentModel(dbAdapter);
export const ServerInfo = addServerInfoModel(dbAdapter);
export const Job = addJobModel(dbAdapter);
Expand Down
Loading