From ff4b94d913897e021af70feef7b5b42e11b061da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=98=99=E2=97=A6=20The=20Tablet=20=E2=9D=80=20GamerGirla?= =?UTF-8?q?ndCo=20=E2=97=A6=E2=9D=A7?= Date: Mon, 2 Oct 2023 15:44:55 -0400 Subject: [PATCH] feat(api): :pencil2: add story api endpoints (update/create/delete) --- .../middlewareButNotReally/storyCheck.ts | 28 +++++++ .../middlewareButNotReally/storyPrivileges.ts | 11 +++ lib/server/storyHelpers/bodyHandler.ts | 38 +++++++++ .../storyHelpers/formChapterTransform.ts | 23 ++++++ lib/server/storyHelpers/getBucket.ts | 9 +++ lib/server/storyHelpers/index.ts | 4 + lib/server/storyHelpers/replaceGridFS.ts | 16 ++++ server/api/story/[id]/[chapter]/index.get.ts | 12 +++ server/api/story/[id]/[chapter]/index.put.ts | 56 +++++++++++++ server/api/story/[id]/index.delete.ts | 18 +++++ server/api/story/[id]/index.get.ts | 15 ++++ server/api/story/[id]/index.put.ts | 80 +++++++++++++++++++ server/api/story/new.post.ts | 49 ++++++++++++ 13 files changed, 359 insertions(+) create mode 100644 lib/server/middlewareButNotReally/storyCheck.ts create mode 100644 lib/server/middlewareButNotReally/storyPrivileges.ts create mode 100644 lib/server/storyHelpers/bodyHandler.ts create mode 100644 lib/server/storyHelpers/formChapterTransform.ts create mode 100644 lib/server/storyHelpers/getBucket.ts create mode 100644 lib/server/storyHelpers/index.ts create mode 100644 lib/server/storyHelpers/replaceGridFS.ts create mode 100644 server/api/story/[id]/[chapter]/index.get.ts create mode 100644 server/api/story/[id]/[chapter]/index.put.ts create mode 100644 server/api/story/[id]/index.delete.ts create mode 100644 server/api/story/[id]/index.get.ts create mode 100644 server/api/story/[id]/index.put.ts create mode 100644 server/api/story/new.post.ts diff --git a/lib/server/middlewareButNotReally/storyCheck.ts b/lib/server/middlewareButNotReally/storyCheck.ts new file mode 100644 index 0000000..a023c79 --- /dev/null +++ b/lib/server/middlewareButNotReally/storyCheck.ts @@ -0,0 +1,28 @@ +import type { H3Event, EventHandlerRequest } from "h3"; +import type { Document } from "mongoose"; +import { isFicmasHidden } from "~/lib/functions"; +import { IStory } from "~/models/stories"; +export default async function ( + event: H3Event, + story: IStory, +) { + let ret: any = {}; + let num: number = event.context.chapterIndex; + if (story.ficmas != null) { + if (isFicmasHidden(story)) { + ret = { + statusCode: 423, + message: `TOP SECRET! This story is part of an ongoing challenge. You'll be able to read it after the challenge's reveal date.`, + }; + } + } + if ( + story.chapters[num]?.hidden || + (event.context.currentUser._id !== story.author._id && + !event.context.currentUser.isAdmin) + ) { + ret.statusCode = 403; + ret.message = "Forbidden"; + } + return !!Object.keys(ret).length ? ret : null; +} diff --git a/lib/server/middlewareButNotReally/storyPrivileges.ts b/lib/server/middlewareButNotReally/storyPrivileges.ts new file mode 100644 index 0000000..7a79419 --- /dev/null +++ b/lib/server/middlewareButNotReally/storyPrivileges.ts @@ -0,0 +1,11 @@ +import type { H3Event, EventHandlerRequest } from "h3"; +import { IStory } from "~/models/stories"; +export function canDelete(event: H3Event, story: IStory) { + return ( + event.context.currentUser?.profile.isAdmin || + story.author._id === event.context.currentUser?._id + ); +} +export function canModify(event: H3Event, story: IStory) { + return event.context.currentUser?._id === story.author._id; +} diff --git a/lib/server/storyHelpers/bodyHandler.ts b/lib/server/storyHelpers/bodyHandler.ts new file mode 100644 index 0000000..3cce1bb --- /dev/null +++ b/lib/server/storyHelpers/bodyHandler.ts @@ -0,0 +1,38 @@ +import { extname, resolve } from "path"; +import { readFileSync } from "fs"; +import { marked } from "marked"; +import * as mammoth from "mammoth"; +import * as san from "sanitize-html"; +import { sanitizeConf } from "../constants"; +import { FormChapter } from "~/lib/client/types/FormStory"; + +export default async function (bodyObj: FormChapter): Promise { + let str: string = ""; + if (bodyObj.content) { + str = bodyObj.content; + } else if (bodyObj.file) { + let ext = extname(bodyObj.file).toLowerCase(); + if (ext === "md" || ext === "markdown") + str = marked.parse( + readFileSync(resolve(`tmp/${bodyObj.file}`)).toString(), + ); + else if (ext === "doc" || ext === "docx") + str = ( + await mammoth.convertToHtml( + { path: resolve(`tmp/${bodyObj.file}`) }, + { styleMap: ["b => b", "i => i", "u => u"] }, + ) + ).value; + else + throw createError({ + statusCode: 400, + message: "bad file type", + }); + } else { + throw createError({ + statusCode: 400, + message: "no content", + }); + } + return san(str, sanitizeConf); +} diff --git a/lib/server/storyHelpers/formChapterTransform.ts b/lib/server/storyHelpers/formChapterTransform.ts new file mode 100644 index 0000000..1f2bf1e --- /dev/null +++ b/lib/server/storyHelpers/formChapterTransform.ts @@ -0,0 +1,23 @@ +import san from "sanitize-html"; +import { FormChapter } from "~/lib/client/types/FormStory"; +import { countWords } from "~/lib/functions"; +import { IChapter } from "~/models/stories/chapter"; +import { sanitizeConf } from "../constants"; +import bodyHandler from "./bodyHandler"; + + +export default function(c: FormChapter): IChapter { + let t: IChapter = { + title: c.chapterTitle, + summary: san(c.summary, sanitizeConf), + notes: san(c.notes, sanitizeConf), + bands: c.bands, + characters: c.characters, + relationships: c.relationships, + nsfw: c.nsfw, + genre: c.genre, + loggedInOnly: c.loggedInOnly, + hidden: c.hidden + } + return t; +} \ No newline at end of file diff --git a/lib/server/storyHelpers/getBucket.ts b/lib/server/storyHelpers/getBucket.ts new file mode 100644 index 0000000..e84abf9 --- /dev/null +++ b/lib/server/storyHelpers/getBucket.ts @@ -0,0 +1,9 @@ +import { GridFSBucket } from "mongodb"; +import mongoose from "mongoose"; + +export default function () { + // @ts-ignore SHUT UP MEG + return new GridFSBucket(mongoose.connection.db, { + bucketName: "story_text", + }); +} diff --git a/lib/server/storyHelpers/index.ts b/lib/server/storyHelpers/index.ts new file mode 100644 index 0000000..1a90fde --- /dev/null +++ b/lib/server/storyHelpers/index.ts @@ -0,0 +1,4 @@ +export { default as bodyHandler } from "./bodyHandler"; +export { default as getBucket } from "./getBucket"; +export { default as replaceOrUploadContent } from "./replaceGridFS"; +export { default as modelFormChapter } from "./formChapterTransform"; diff --git a/lib/server/storyHelpers/replaceGridFS.ts b/lib/server/storyHelpers/replaceGridFS.ts new file mode 100644 index 0000000..95dc481 --- /dev/null +++ b/lib/server/storyHelpers/replaceGridFS.ts @@ -0,0 +1,16 @@ +import getBucket from "./getBucket"; +import {Readable} from "stream" +export default async function replaceGridFS(chapterID: number | undefined, content: string) { + let filename = `/stories/${chapterID}.txt`; + const bucket = getBucket() + if(chapterID) { + const curs = bucket.find({filename}).limit(1) + for await(const d of curs) { + await bucket.delete(d._id); + } + } + const readable = new Readable(); + readable.push(content); + readable.push(null); + readable.pipe(bucket.openUploadStream(filename)); +} \ No newline at end of file diff --git a/server/api/story/[id]/[chapter]/index.get.ts b/server/api/story/[id]/[chapter]/index.get.ts new file mode 100644 index 0000000..4b2dcca --- /dev/null +++ b/server/api/story/[id]/[chapter]/index.get.ts @@ -0,0 +1,12 @@ +import chapterTransformer from "~/lib/server/dbHelpers/chapterTransformer"; +import storyQuerier from "~/lib/server/dbHelpers/storyQuerier"; +import storyCheck from "~/lib/server/middlewareButNotReally/storyCheck"; + +export default eventHandler(async (ev) => { + const story = await storyQuerier(ev); + const chres = await storyCheck(ev, story); + if (chres != null) { + throw createError(chres); + } + return await chapterTransformer(story, ev); +}); diff --git a/server/api/story/[id]/[chapter]/index.put.ts b/server/api/story/[id]/[chapter]/index.put.ts new file mode 100644 index 0000000..fa63b2b --- /dev/null +++ b/server/api/story/[id]/[chapter]/index.put.ts @@ -0,0 +1,56 @@ +import { FormChapter } from "~/lib/client/types/FormStory"; +import { countWords } from "~/lib/functions"; +import storyQuerier from "~/lib/server/dbHelpers/storyQuerier"; +import isLoggedIn from "~/lib/server/middlewareButNotReally/isLoggedIn"; +import { canModify } from "~/lib/server/middlewareButNotReally/storyPrivileges"; +import { replaceContent, bodyHandler } from "~/lib/server/storyHelpers"; +import { Story } from "~/models/stories"; + +export default eventHandler(async (ev) => { + isLoggedIn(ev); + const story = await storyQuerier(ev); + if (!canModify(ev, story)) { + throw createError({ + statusCode: 403, + message: "Forbidden", + }); + } + const body = await readBody(ev); + const cc: FormChapter = body.chapters[0]; + const cid = story.chapters[ev.context.chapterIndex].id; + const content = await bodyHandler(cc); + await replaceContent(cid!, content); + let ns; + try { + ns = await Story.findOneAndUpdate( + { + "chapters.id": cid, + }, + { + $set: { + "chapters.$.title": cc.chapterTitle, + "chapters.$.summary": cc.summary, + "chapters.$.characters": cc.characters, + "chapters.$.relationships": Array.from(new Set(cc.relationships)), + "chapters.$.bands": cc.bands, + "chapters.$.nsfw": !!cc.nsfw, + "chapters.$.notes": cc.notes, + "chapters.$.words": countWords(content), + "chapters.$.genre": cc.genre, + "chapters.$.loggedInOnly": !!cc.loggedInOnly, + }, + }, + { new: true }, + ); + } catch (e: any) { + throw createError({ + statusCode: 500, + message: e.toString(), + }); + } + return { + data: ns, + message: "Chapter updated", + succes: true, + }; +}); diff --git a/server/api/story/[id]/index.delete.ts b/server/api/story/[id]/index.delete.ts new file mode 100644 index 0000000..50affc0 --- /dev/null +++ b/server/api/story/[id]/index.delete.ts @@ -0,0 +1,18 @@ +import storyQuerier from "~/lib/server/dbHelpers/storyQuerier"; +import { canDelete } from "~/lib/server/middlewareButNotReally/storyPrivileges"; +import { Story } from "~/models/stories"; + +export default eventHandler(async (ev) => { + const tmpS = await storyQuerier(ev); + if (canDelete(ev, tmpS)) { + await Story.findByIdAndDelete(tmpS._id); + return { + success: true, + message: "story deleted", + }; + } + throw createError({ + statusCode: 403, + message: "Forbidden", + }); +}); diff --git a/server/api/story/[id]/index.get.ts b/server/api/story/[id]/index.get.ts new file mode 100644 index 0000000..9a55eaa --- /dev/null +++ b/server/api/story/[id]/index.get.ts @@ -0,0 +1,15 @@ +import queryStory from "~/lib/server/dbHelpers/storyQuerier"; +import storyCheck from "~/lib/server/middlewareButNotReally/storyCheck"; +export default eventHandler(async (ev) => { + const story = await queryStory(ev); + let chrs = await storyCheck(ev, story); + if (chrs != null) { + throw createError(chrs); + } + if (story.chapters.some((a) => a.loggedInOnly) && !ev.context.currentUser) + throw createError({ + statusCode: 401, + message: "Authentication required", + }); + return story; +}); diff --git a/server/api/story/[id]/index.put.ts b/server/api/story/[id]/index.put.ts new file mode 100644 index 0000000..49e4c39 --- /dev/null +++ b/server/api/story/[id]/index.put.ts @@ -0,0 +1,80 @@ +import { Readable } from "stream"; +import { Document } from "mongoose"; +import { IStory, Story } from "~/models/stories"; +import { FormStory } from "~/lib/client/types/FormStory"; +import storyQuerier from "~/lib/server/dbHelpers/storyQuerier"; +import isLoggedIn from "~/lib/server/middlewareButNotReally/isLoggedIn"; +import { canModify } from "~/lib/server/middlewareButNotReally/storyPrivileges"; +import { + bodyHandler, + getBucket, + modelFormChapter, + replaceOrUploadContent, +} from "~/lib/server/storyHelpers"; +import { countWords } from "~/lib/functions"; + +export default eventHandler(async (ev) => { + let os: + | (Document & + IStory & + Required<{ + _id: number; + }>) + | null = await storyQuerier(ev); + isLoggedIn(ev); + if (!canModify(ev, os)) { + throw createError({ + statusCode: 403, + message: "Forbidden", + }); + } + const body = await readBody(ev); + const update: Partial = { + title: body.title, + completed: body.completed, + chapters: [], + }; + for (const oc of os.chapters) { + let filename = `/stories/${oc.id}.txt`; + const bucket = getBucket(); + const curs = bucket.find({ filename }).limit(1); + for await (const d of curs) { + await bucket.delete(d._id); + } + } + for (const c of body.chapters) { + let idx = os.chapters.findIndex((k) => k.id === c.id); + const cont = await bodyHandler(c); + if (idx === -1) { + update.chapters!.push({ + ...modelFormChapter(c), + posted: new Date(Date.now()), + }); + } else { + update.chapters!.push({ + ...modelFormChapter(c), + id: os.chapters[idx].id, + posted: os.chapters[idx].posted, + }); + replaceOrUploadContent(os.chapters![idx].id, cont); + } + update.chapters![update.chapters!.length - 1].words = countWords(cont); + } + os = await Story.findOneAndUpdate( + { + _id: os._id, + }, + update, + { new: true }, + ); + if (!os) { + throw createError({ + statusCode: 500, + message: "Something went wrong.", + }); + } + return { + success: true, + data: os.toObject(), + }; +}); diff --git a/server/api/story/new.post.ts b/server/api/story/new.post.ts new file mode 100644 index 0000000..ac83b31 --- /dev/null +++ b/server/api/story/new.post.ts @@ -0,0 +1,49 @@ +import { Readable } from "stream"; +import san from "sanitize-html"; +import { FormStory } from "~/lib/client/types/FormStory"; +import isLoggedIn from "~/lib/server/middlewareButNotReally/isLoggedIn"; +import { + getBucket, + bodyHandler, + modelFormChapter, +} from "~/lib/server/storyHelpers"; +import { Story } from "~/models/stories"; +import { sanitizeConf } from "~/lib/server/constants"; +import { countWords } from "~/lib/functions"; + +export default eventHandler(async (ev) => { + isLoggedIn(ev); + const bucket = getBucket(); + const body = await readBody(ev); + const story = new Story({ + title: body.title, + author: ev.context.currentUser!._id, + views: 0, + reviews: 0, + downloads: 0, + ficmas: body.ficmas || null, + challenge: body.challenge || null, + completed: body.completed, + }); + for (const c of body.chapters) { + story.chapters.push(modelFormChapter(c)); + story.chapters[story.chapters.length - 1].words = countWords( + await bodyHandler(c), + ); + } + await story.save(); + + for (let i = 0; i < story.chapters.length; i++) { + let c = story.chapters[i]; + const content = await bodyHandler(body.chapters[i]); + const readable = new Readable(); + readable.push(content); + readable.push(null); + readable.pipe(bucket.openUploadStream(`/stories/${c.id}.txt`)); + } + return { + success: true, + url: `/story/${story._id}/1`, + story, + }; +});