From 2f64be21d6be2e2c38d3f08c99d019a66178d56e Mon Sep 17 00:00:00 2001 From: Joost De Cock Date: Fri, 18 Aug 2023 17:26:23 +0200 Subject: [PATCH 1/3] feat(backend): Be more strict about validating tokens accros backends --- sites/backend/prisma/schema.prisma | 1 + sites/backend/src/config.mjs | 6 ++++-- sites/backend/src/controllers/flows.mjs | 4 ++-- sites/backend/src/middleware.mjs | 13 ++++++++++--- sites/backend/src/models/apikey.mjs | 1 + sites/backend/src/models/flow.mjs | 15 ++++++++------- sites/backend/src/models/user.mjs | 2 +- sites/backend/src/routes/flows.mjs | 16 +++++++++------- 8 files changed, 36 insertions(+), 22 deletions(-) diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 0812554db0a..8b18930698e 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -10,6 +10,7 @@ datasource db { model Apikey { id String @id @default(uuid()) + aud String @default("") createdAt DateTime @default(now()) expiresAt DateTime name String @default("") diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 686fdc56f23..01edf595d19 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -50,6 +50,8 @@ const baseConfig = { env: process.env.NODE_ENV || 'development', // Maintainer contact maintainer: 'joost@freesewing.org', + // Instance + instance: process.env.BACKEND_INSTANCE || Date.now(), // Feature flags use: { github: envToBool(process.env.BACKEND_ENABLE_GITHUB), @@ -110,8 +112,7 @@ const baseConfig = { }, jwt: { secretOrKey: encryptionKey, - issuer: process.env.BACKEND_JWT_ISSUER || 'freesewing.org', - audience: process.env.BACKEND_JWT_ISSUER || 'freesewing.org', + issuer: api, expiresIn: process.env.BACKEND_JWT_EXPIRY || '7d', }, languages, @@ -232,6 +233,7 @@ export const cloudflareImages = config.cloudflareImages || {} export const forwardmx = config.forwardmx || {} export const website = config.website export const githubToken = config.github.token +export const instance = config.instance const vars = { BACKEND_DB_URL: ['required', 'db.url'], diff --git a/sites/backend/src/controllers/flows.mjs b/sites/backend/src/controllers/flows.mjs index 7ac31575c86..e131cb1fc9a 100644 --- a/sites/backend/src/controllers/flows.mjs +++ b/sites/backend/src/controllers/flows.mjs @@ -50,9 +50,9 @@ FlowsController.prototype.removeImage = async (req, res, tools) => { * Creates a pull request for a new showcase post * See: https://freesewing.dev/reference/backend/api */ -FlowsController.prototype.createShowcasePr = async (req, res, tools) => { +FlowsController.prototype.createPostPr = async (req, res, tools, type) => { const Flow = new FlowModel(tools) - await Flow.createShowcasePr(req) + await Flow.createPostPr(req, type) return Flow.sendResponse(res) } diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index 71ed9755fed..e1ddbea66e9 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -3,6 +3,7 @@ import http from 'passport-http' import jwt from 'passport-jwt' import { ApikeyModel } from './models/apikey.mjs' import { UserModel } from './models/user.mjs' +import { api, instance } from './config.mjs' /* * In v2 we ended up with a bug where we did not properly track the last login @@ -10,8 +11,14 @@ import { UserModel } from './models/user.mjs' * this field. It's a bit of a perf hit to write to the database on ever API call * but it's worth it to actually know which accounts are used and which are not. */ -async function checkAccess(uid, tools, type) { +async function checkAccess(payload, tools, type) { + /* + * Don't allow tokens/keys to be used on different instances, + * even with the same encryption key + */ + if (payload.aud !== `${api}/${instance}`) return false const User = new UserModel(tools) + const uid = payload.userId || payload._id const ok = await User.papersPlease(uid, type) return ok @@ -29,7 +36,7 @@ function loadPassportMiddleware(passport, tools) { /* * We check more than merely the API key */ - const ok = Apikey.verified ? await checkAccess(Apikey.record.userId, tools, 'key') : false + const ok = Apikey.verified ? await checkAccess(Apikey.record, tools, 'key') : false return ok ? done(null, { @@ -50,7 +57,7 @@ function loadPassportMiddleware(passport, tools) { /* * We check more than merely the token */ - const ok = await checkAccess(jwt_payload._id, tools, 'jwt') + const ok = await checkAccess(jwt_payload, tools, 'jwt') return ok ? done(null, { diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs index 90e688b9cee..56073fd1caa 100644 --- a/sites/backend/src/models/apikey.mjs +++ b/sites/backend/src/models/apikey.mjs @@ -261,6 +261,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) { try { this.record = await this.prisma.apikey.create({ data: this.cloak({ + aud: `${this.config.api}/${this.config.instance}`, expiresAt, name: body.name, level: body.level, diff --git a/sites/backend/src/models/flow.mjs b/sites/backend/src/models/flow.mjs index f4296500472..ef2af8acc7a 100644 --- a/sites/backend/src/models/flow.mjs +++ b/sites/backend/src/models/flow.mjs @@ -261,13 +261,14 @@ to English prior to merging. ` /* - * Create a (GitHub) pull request for a new showcase post + * Create a (GitHub) pull request for a new blog or showcase post * * @param {body} object - The request body * @param {user} object - The user as loaded by auth middleware + * @param {type} string - One of blog or showcase * @returns {FlowModel} object - The FlowModel */ -FlowModel.prototype.createShowcasePr = async function ({ body, user }) { +FlowModel.prototype.createPostPr = async function ({ body, user }, type) { /* * Is markdown set? */ @@ -283,16 +284,16 @@ FlowModel.prototype.createShowcasePr = async function ({ body, user }) { /* * Create a new feature branch for this */ - const branchName = `showcase-${body.slug}` + const branchName = `${type}-${body.slug}` const branch = await createBranch({ name: branchName }) /* * Create the file */ const file = await createFile({ - path: `markdown/org/showcase/${body.slug}/en.md`, + path: `markdown/org/${type}/${body.slug}/en.md`, body: { - message: `feat: New showcase post ${body.slug} by ${this.User.record.username}${ + message: `feat: New ${type} post ${body.slug} by ${this.User.record.username}${ body.language !== 'en' ? nonEnWarning : '' }`, content: new Buffer.from(body.markdown).toString('base64'), @@ -308,8 +309,8 @@ FlowModel.prototype.createShowcasePr = async function ({ body, user }) { * New create the pull request */ const pr = await createPullRequest({ - title: `feat: New showcase post ${body.slug} by ${this.User.record.username}`, - body: `Hey @joostdecock you should check out this awesome showcase post.${ + title: `feat: New ${type} post ${body.slug} by ${this.User.record.username}`, + body: `Paging @joostdecock to check out this proposed ${type} post.${ body.language !== 'en' ? nonEnWarning : '' }`, from: branchName, diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 52b464efeeb..6a3f5686280 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1281,7 +1281,7 @@ UserModel.prototype.getToken = function () { username: this.record.username, role: this.record.role, status: this.record.status, - aud: this.config.jwt.audience, + aud: `${this.config.api}/${this.config.instance}`, iss: this.config.jwt.issuer, }, this.config.jwt.secretOrKey, diff --git a/sites/backend/src/routes/flows.mjs b/sites/backend/src/routes/flows.mjs index 9ca39eeda69..03c1b28f2f7 100644 --- a/sites/backend/src/routes/flows.mjs +++ b/sites/backend/src/routes/flows.mjs @@ -39,13 +39,15 @@ export function flowsRoutes(tools) { Flow.removeImage(req, res, tools) ) - // Submit a pull request for a new showcase - app.post('/flows/pr/showcase/jwt', passport.authenticate(...jwt), (req, res) => - Flow.createShowcasePr(req, res, tools) - ) - app.post('/flows/pr/showcase/key', passport.authenticate(...bsc), (req, res) => - Flow.createShowcasePr(req, res, tools) - ) + // Submit a pull request for a new showcase or blog post + for (const type of ['blog', 'showcase']) { + app.post(`/flows/pr/${type}/jwt`, passport.authenticate(...jwt), (req, res) => + Flow.createPostPr(req, res, tools, type) + ) + app.post(`/flows/pr/${type}/key`, passport.authenticate(...bsc), (req, res) => + Flow.createPostPr(req, res, tools, type) + ) + } // Create Issue - No auth needed app.post('/issues', (req, res) => Flow.createIssue(req, res, tools)) From 4532bb6654220b841befc261ab889768cf22366c Mon Sep 17 00:00:00 2001 From: Joost De Cock Date: Fri, 18 Aug 2023 17:26:41 +0200 Subject: [PATCH 2/3] chore: Add tester role --- config/roles.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/roles.mjs b/config/roles.mjs index 08cf9204fcc..0f7f3e9d888 100644 --- a/config/roles.mjs +++ b/config/roles.mjs @@ -5,6 +5,7 @@ export const roles = { readOnly: 2, writeSome: 3, user: 4, + tester: 4, curator: 5, bughunter: 6, support: 8, From e0e242a66c92ba93e5acd49c114cac2ecbe535d3 Mon Sep 17 00:00:00 2001 From: Joost De Cock Date: Fri, 18 Aug 2023 17:27:13 +0200 Subject: [PATCH 3/3] feat(org): Also allow the creation of blog posts --- sites/org/components/github/create-post.mjs | 527 ++++++++++++++++++++ sites/org/pages/new/blog.mjs | 14 +- sites/org/pages/new/showcase.mjs | 4 +- sites/shared/hooks/use-backend.mjs | 6 +- 4 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 sites/org/components/github/create-post.mjs diff --git a/sites/org/components/github/create-post.mjs b/sites/org/components/github/create-post.mjs new file mode 100644 index 00000000000..347a33ddb67 --- /dev/null +++ b/sites/org/components/github/create-post.mjs @@ -0,0 +1,527 @@ +// Dependencies +import { nsMerge, capitalize, cloudflareImageUrl, yyyymmdd } from 'shared/utils.mjs' +// Hooks +import { useState, Fragment } from 'react' +import { useAccount } from 'shared/hooks/use-account.mjs' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useTranslation } from 'next-i18next' +// Components +import { Popout } from 'shared/components/popout/index.mjs' +import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs' +import { DesignPicker } from './design-picker.mjs' +import { + TitleInput, + SlugInput, + ImageInput, + CaptionInput, + IntroInput, + BodyInput, +} from './inputs.mjs' +import { Tab } from 'shared/components/account/bio.mjs' +import { CodeBox } from 'shared/components/code-box.mjs' +import { PostArticle, ns as mdxNs } from 'site/components/mdx/posts/article.mjs' +import { PageLink } from 'shared/components/page-link.mjs' +import { OkIcon, WarningIcon as KoIcon } from 'shared/components/icons.mjs' +import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' +import { WebLink } from 'shared/components/web-link.mjs' + +export const ns = nsMerge('account', 'posts', authNs, mdxNs) + +const Tip = ({ children }) =>

{children}

+ +const Item = ({ title, children }) => ( +
+ +
{title}
+
{children}
+
+) + +const dataAsMd = ({ title, user, caption, intro, designs, body }, type) => { + let md = `--- +title: "${title}" +caption: "${caption}" +date: ${yyyymmdd()} +intro: "${intro}"` + if (type === 'showcase') + md += ` +designs: [${designs.map((design) => `"${design}"`).join(', ')}] +maker: ${user}` + else + md += ` +author: ${user}` + md += ` +--- + +${body} + +` + + return md +} + +export const CreatePost = ({ type = 'showcase' }) => { + // Hooks + const backend = useBackend() + const { account } = useAccount() + const { t, i18n } = useTranslation(ns) + const { loading, setLoadingStatus, LoadingStatus } = useLoadingStatus() + + // State + const [designs, setDesigns] = useState([]) + const [title, setTitle] = useState('') + const [slug, setSlug] = useState(false) + const [slugAvailable, setSlugAvailable] = useState(true) + const [img, setImg] = useState(false) + const [caption, setCaption] = useState('') + const [intro, setIntro] = useState('') + const [body, setBody] = useState('') + const [extraImages, setExtraImages] = useState({}) + const [activeTab, setActiveTab] = useState('create') + const [pr, setPr] = useState(false) + + // Method that submits the post to the backend + const submitPost = async () => { + setLoadingStatus([true, `Creating ${type} post & pull request`]) + const result = await backend.createPost(type, { + markdown: dataAsMd( + { + title, + user: account.username, + caption, + intro, + designs, + body, + }, + type + ), + slug, + language: i18n.language, + }) + if (result.success) setPr(result.data) + setLoadingStatus([false]) + } + + // Shared props for tabs + const tabProps = { activeTab, setActiveTab, t } + + const addImage = () => { + const id = Object.keys(extraImages).length + 1 + const newImages = { ...extraImages } + newImages[id] = null + setExtraImages(newImages) + } + + const verifySlug = async (newSlug) => { + setSlug(newSlug) + const result = await backend.isSlugAvailable({ slug: newSlug, type }) + console.log(result) + setSlugAvailable(result.available === true ? true : false) + } + + const setExtraImg = (key, img) => { + const newImages = { ...extraImages } + newImages[key] = img + setExtraImages(newImages) + } + + const childProps = { + type, + designs, + setDesigns, + title, + setTitle, + slug, + img, + setImg, + caption, + setCaption, + intro, + setIntro, + body, + setBody, + extraImages, + setExtraImages, + addImage, + setExtraImg, + account, + t, + verifySlug, + slugAvailable, + } + + return ( + + + {pr ? ( +
+

Thank you for submitting this {type} post

+

Here is what happened while you were waiting:

+
    +
  • + We created a new branch:{' '} + + + +
  • +
  • + We committed your work:{' '} + + + +
  • +
  • + We created a pull request:{' '} + + + +
  • +
+

Next steps:

+
    +
  • + Joost will review the pull request to make sure everything is ok +
  • +
  • + If everything looks fine, they will merge it. +
  • +
  • + This will trigger a preview build of the website +
  • +
  • + If that goes without any hiccups, this preview build will be{' '} + deployed in production. +
  • +
  • + When that happens, your post will go live at:{' '} + +
  • +
+

+ To summarize: You did great 💜 and we'll take it from here{' '} + 🙌 +

+ +
+ ) : ( + <> +
+
+ +
+
+ +
+
+
+
+ + +
+ {activeTab === 'create' ? ( + + ) : ( + + )} +
+
+ {!(title && slug && img && (type === 'blog' || designs.length > 0)) && ( + +
You are missing the following:
+
    + {type === 'showcase' && designs.length < 1 &&
  • Design
  • } + {!title &&
  • Title
  • } + {!slug &&
  • Slug
  • } + {!img &&
  • Main Image
  • } +
+
+ )} + + {!account.data?.githubUser && !account.data?.githubEmail && ( + +
+ Optional: Are you on GitHub? +
+

+ If you configure your GitHub username{' '} + , we will credit these + changes to you. +

+
+ )} +
+ + )} +
+ ) +} + +const PostPreview = ({ designs, title, img, caption, intro, body, account }) => ( + <> +

{title}

+ + +) + +const PostEditor = ({ + type, + designs, + setDesigns, + title, + setTitle, + slug, + verifySlug, + slugAvailable, + img, + setImg, + caption, + setCaption, + intro, + setIntro, + body, + setBody, + extraImages, + addImage, + setExtraImg, + t, +}) => ( + <> +

Create a new {type} post

+ {t(`${type}NewInfo`)} + {type === 'showcase' && ( + + {designs.length > 0 ? ( + + ) : ( + + )} + Design: + {designs.length > 0 ? ( + {designs.map((d) => capitalize(d)).join(', ')} + ) : ( + Please select at least 1 design + )} + + } + > + Pick one or more designs that are featured in this post. + + + )} + + {title.length > 10 ? ( + + ) : ( + + )} + Title: + {title.length > 10 ? ( + {title} + ) : ( + Please enter a post title + )} + + } + > + Give your post a title. A good title is more than just a few words. + + + + {slugAvailable && slug.length > 3 ? ( + + ) : ( + + )} + Slug: + {slug.length > 3 ? ( + {slug} + ) : ( + Please enter a slug (or post title) + )} + + } + > + + The slug is the part of the URL that uniquely identifies the post. We can generate one based + on the title, but you can also customize it. + + + + + {img.length > 3 ? ( + + ) : ( + + )} + Main Image: + {img.length > 3 ? ( + {img} + ) : ( + Please provide a main image for the post + )} + + } + > + + The main image will be shown at the top of the post, and as the only image on the {type} + index page. + + + + + {caption.length > 3 ? ( + + ) : ( + + )} + Main Image Caption: + {caption.length > 3 ? ( + {caption} + ) : ( + + Please provide a caption for the main image + + )} + + } + > + + The caption is the text that goes under the main image. Can include copyrights/credits. + Markdown is allowed. + + + + + {intro.length > 3 ? ( + + ) : ( + + )} + Intro: + {intro.length > 3 ? ( + {intro} + ) : ( + Please provide an intro for link proviews + )} + + } + > + A brief paragraph that will be shown on post previews on social media and so on. + + + + Additional Images: {Object.keys(extraImages).length} + + } + > + {img ? ( + <> + Here you can add any images you want to include in the post body. + {Object.keys(extraImages).map((key) => { + const markup = + '![The image alt goes here](' + + cloudflareImageUrl({ id: extraImages[key], variant: 'public' }) + + ' "The image caption/title goes here")' + return ( + + setExtraImg(key, img)} + type={type} + subId={key} + img={extraImages[key]} + slug={slug} + /> + {extraImages[key] && ( + <> +

To include this image in your post, use this markdown snippet:

+ +

+ +

+ + )} +
+ ) + })} + + + ) : ( + + Please add a main image first + + )} +
+ + {body.length > 3 ? ( + + ) : ( + + )} + Post body: + {body.length > 3 ? ( + {body.slice(0, 30) + '...'} + ) : ( + Please provide a post body + )} + + } + > + The actual post body. Supports Markdown. + + + +) diff --git a/sites/org/pages/new/blog.mjs b/sites/org/pages/new/blog.mjs index f900ac5c8b0..b4cec2ec86a 100644 --- a/sites/org/pages/new/blog.mjs +++ b/sites/org/pages/new/blog.mjs @@ -6,11 +6,11 @@ import { useTranslation } from 'next-i18next' // Components import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' -//import { BareLayout } from 'site/components/layouts/bare.mjs' -import { Popout } from 'shared/components/popout/index.mjs' +import { CreatePost, ns as createNs } from 'site/components/github/create-post.mjs' +import { BareLayout } from 'site/components/layouts/bare.mjs' // Translation namespaces used on this page -const namespaces = nsMerge(authNs, pageNs) +const namespaces = nsMerge(createNs, authNs, pageNs) /* * Each page MUST be wrapped in the PageWrapper component. @@ -22,11 +22,9 @@ const NewBlogPage = ({ page }) => { const { t } = useTranslation(namespaces) return ( - +
- - This is not (yet) implemented - +
) @@ -40,7 +38,7 @@ export async function getStaticProps({ locale }) { ...(await serverSideTranslations(locale, namespaces)), page: { locale, - path: ['new', 'showcase'], + path: ['new', 'blog'], }, }, } diff --git a/sites/org/pages/new/showcase.mjs b/sites/org/pages/new/showcase.mjs index e5d28525fcb..8a4316ce8fa 100644 --- a/sites/org/pages/new/showcase.mjs +++ b/sites/org/pages/new/showcase.mjs @@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next' // Components import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' -import { CreateShowcasePost, ns as createNs } from 'site/components/github/create-showcase.mjs' +import { CreatePost, ns as createNs } from 'site/components/github/create-post.mjs' import { BareLayout } from 'site/components/layouts/bare.mjs' // Translation namespaces used on this page @@ -24,7 +24,7 @@ const NewShowcasePage = ({ page }) => { return (
- +
) diff --git a/sites/shared/hooks/use-backend.mjs b/sites/shared/hooks/use-backend.mjs index 38ec4642d56..560a3ba31fb 100644 --- a/sites/shared/hooks/use-backend.mjs +++ b/sites/shared/hooks/use-backend.mjs @@ -311,10 +311,10 @@ Backend.prototype.isSlugAvailable = async function ({ slug, type }) { } /* - * Create showcase Pull Request + * Create showcase/blog post (pull request) */ -Backend.prototype.createShowcasePr = async function (data) { - return responseHandler(await api.post(`/flows/pr/showcase/jwt`, data, this.auth), 201) +Backend.prototype.createPost = async function (type, data) { + return responseHandler(await api.post(`/flows/pr/${type}/jwt`, data, this.auth), 201) } /*