diff --git a/common/post.ts b/common/post.ts new file mode 100644 index 00000000..05eab685 --- /dev/null +++ b/common/post.ts @@ -0,0 +1,12 @@ +import { JSONContent } from '@tiptap/core' + +export type Post = { + id: string + title: string + content: JSONContent + creatorId: string // User id + createdTime: number + slug: string +} + +export const MAX_POST_TITLE_LENGTH = 480 diff --git a/firestore.rules b/firestore.rules index 4cd718d3..fe45071b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -175,5 +175,14 @@ service cloud.firestore { allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); } } + + match /posts/{postId} { + allow read; + allow update: if request.auth.uid == resource.data.creatorId + && request.resource.data.diff(resource.data) + .affectedKeys() + .hasOnly(['name', 'content']); + allow delete: if request.auth.uid == resource.data.creatorId; + } } } diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts new file mode 100644 index 00000000..40d39bba --- /dev/null +++ b/functions/src/create-post.ts @@ -0,0 +1,83 @@ +import * as admin from 'firebase-admin' + +import { getUser } from './utils' +import { slugify } from '../../common/util/slugify' +import { randomString } from '../../common/util/random' +import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' + +const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const postSchema = z.object({ + title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), + content: contentSchema, +}) + +export const createpost = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { title, content } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + if (!creator) + throw new APIError(400, 'No user exists with the authenticated user ID.') + + console.log('creating post owned by', creator.username, 'titled', title) + + const slug = await getSlug(title) + + const postRef = firestore.collection('posts').doc() + + const post: Post = { + id: postRef.id, + creatorId: creator.id, + slug, + title, + createdTime: Date.now(), + content: content, + } + + await postRef.create(post) + + return { status: 'success', post } +}) + +export const getSlug = async (title: string) => { + const proposedSlug = slugify(title) + + const preexistingPost = await getPostFromSlug(proposedSlug) + + return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug +} + +export async function getPostFromSlug(slug: string) { + const firestore = admin.firestore() + const snap = await firestore + .collection('posts') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Post) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 012ba241..32bc16c4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -72,6 +72,7 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -97,6 +98,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const getCustomTokenFunction = toCloudFunction(getcustomtoken) +const createPostFunction = toCloudFunction(createpost) export { healthFunction as health, @@ -120,4 +122,5 @@ export { getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, getCustomTokenFunction as getcustomtoken, + createPostFunction as createpost, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 8d848f7f..db847a70 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { getcustomtoken } from './get-custom-token' +import { createpost } from './create-post' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) +addEndpointRoute('/createpost', createpost) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 2d620728..a0878e4f 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -4,6 +4,7 @@ import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' +import { Post } from 'common/post' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => { return getDoc('groups', groupId) } +export const getPost = (postId: string) => { + return getDoc('posts', postId) +} + export const getUser = (userId: string) => { return getDoc('users', userId) } diff --git a/web/components/file-upload-button.tsx b/web/components/file-upload-button.tsx index 3ff15d91..0872fc1b 100644 --- a/web/components/file-upload-button.tsx +++ b/web/components/file-upload-button.tsx @@ -10,7 +10,11 @@ export function FileUploadButton(props: { const ref = useRef(null) return ( <> - void +}) { + const { isOpen, setOpen, shareUrl } = props + + const linkIcon =