Merge pull request #4763 from freesewing/joost
fix(backend/shared): Catch JSON errors in backend and add border to measurements input
This commit is contained in:
commit
e8e6705504
10 changed files with 193 additions and 56 deletions
|
@ -69,3 +69,24 @@ FlowsController.prototype.createIssue = async (req, res, tools) => {
|
||||||
|
|
||||||
return Flow.sendResponse(res)
|
return Flow.sendResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is a blog or showcase slug available?
|
||||||
|
*
|
||||||
|
* This is the endpoint that verifies whether a blog or showcase slug is available
|
||||||
|
* See: https://freesewing.dev/reference/backend/api/apikey
|
||||||
|
*/
|
||||||
|
FlowsController.prototype.isSlugAvailable = async (req, res, tools, type) => {
|
||||||
|
const Flow = new FlowModel(tools)
|
||||||
|
const available = await Flow.isSlugAvailable(req, type)
|
||||||
|
|
||||||
|
if (!available)
|
||||||
|
Flow.setResponse(200, false, {
|
||||||
|
result: 'success',
|
||||||
|
slug: req.params.slug,
|
||||||
|
available: false,
|
||||||
|
})
|
||||||
|
else Flow.setResponse(404)
|
||||||
|
|
||||||
|
return Flow.sendResponse(res)
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
import { i18nUrl } from '../utils/index.mjs'
|
import { i18nUrl } from '../utils/index.mjs'
|
||||||
import { decorateModel } from '../utils/model-decorator.mjs'
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
import { ensureImage, replaceImage, removeImage } from '../utils/cloudflare-images.mjs'
|
import { ensureImage, replaceImage, removeImage } from '../utils/cloudflare-images.mjs'
|
||||||
import { createIssue, createFile, createBranch, createPullRequest } from '../utils/github.mjs'
|
import {
|
||||||
|
createIssue,
|
||||||
|
createFile,
|
||||||
|
createBranch,
|
||||||
|
createPullRequest,
|
||||||
|
getFileList,
|
||||||
|
} from '../utils/github.mjs'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This model handles all flows (typically that involves sending out emails)
|
* This model handles all flows (typically that involves sending out emails)
|
||||||
|
@ -315,3 +321,28 @@ FlowModel.prototype.createShowcasePr = async function ({ body, user }) {
|
||||||
*/
|
*/
|
||||||
return pr ? this.setResponse201({ branch, file, pr }) : this.setResponse(400)
|
return pr ? this.setResponse201({ branch, file, pr }) : this.setResponse(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Checks to see whether a slug is available
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (URL) parameters
|
||||||
|
* @param {user} object - The user as loaded by auth middleware
|
||||||
|
* @param {type} object - The type to check (either 'blog' or 'showcase')
|
||||||
|
* @returns {FlowModel} object - The FlowModel
|
||||||
|
*/
|
||||||
|
FlowModel.prototype.isSlugAvailable = async function ({ params }, type) {
|
||||||
|
/*
|
||||||
|
* Is slug set?
|
||||||
|
*/
|
||||||
|
if (!params.slug) return this.setResponse(400, `slugMissing`)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Load the list of folders from github
|
||||||
|
*/
|
||||||
|
const folders = (await getFileList(`markdown/org/${type}`)).map((folder) => folder.name)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return whether or not params.slug is already included in the list of slugs
|
||||||
|
*/
|
||||||
|
return !folders.includes(params.slug)
|
||||||
|
}
|
||||||
|
|
|
@ -49,4 +49,14 @@ export function flowsRoutes(tools) {
|
||||||
|
|
||||||
// Create Issue - No auth needed
|
// Create Issue - No auth needed
|
||||||
app.post('/issues', (req, res) => Flow.createIssue(req, res, tools))
|
app.post('/issues', (req, res) => Flow.createIssue(req, res, tools))
|
||||||
|
|
||||||
|
// See if a showcase or blog slug is available
|
||||||
|
for (const type of ['showcase', 'blog']) {
|
||||||
|
app.get(`/slugs/${type}/:slug/jwt`, passport.authenticate(...jwt), (req, res) =>
|
||||||
|
Flow.isSlugAvailable(req, res, tools, type)
|
||||||
|
)
|
||||||
|
app.get(`/slugs/${type}/:slug/key`, passport.authenticate(...bsc), (req, res) =>
|
||||||
|
Flow.isSlugAvailable(req, res, tools, type)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,40 @@ import { githubToken as token } from '../config.mjs'
|
||||||
|
|
||||||
const api = 'https://api.github.com/repos/freesewing/freesewing'
|
const api = 'https://api.github.com/repos/freesewing/freesewing'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sometimes we'd like to cache responses.
|
||||||
|
* This is a poor man's cache
|
||||||
|
*/
|
||||||
|
const cache = {}
|
||||||
|
const fresh = 1800000 // 30 minutes
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper method to run a requests against the GitHub API
|
||||||
|
* with built-in cachine
|
||||||
|
*/
|
||||||
|
const cachedApiRequest = async (method, url, body = false, success = 200) => {
|
||||||
|
const id = JSON.stringify({ method, url, body, success })
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is this reponse cached?
|
||||||
|
*/
|
||||||
|
if (cache[id]) {
|
||||||
|
/*
|
||||||
|
* It is. But Is it fresh?
|
||||||
|
*/
|
||||||
|
if (cache[id].timestamp && now - cache[id].timestamp < fresh) return cache[id].data
|
||||||
|
/*
|
||||||
|
* It is in the cache, but stale. Remove cache entry
|
||||||
|
*/ else delete cache[id]
|
||||||
|
} else {
|
||||||
|
const data = apiRequest(method, url, body, success)
|
||||||
|
cache[id] = { timestamp: now, data }
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper method to run requests against the GitHub API
|
* Helper method to run requests against the GitHub API
|
||||||
*/
|
*/
|
||||||
|
@ -79,3 +113,8 @@ export const createPullRequest = async ({ title, body, from, to = 'develop' }) =
|
||||||
},
|
},
|
||||||
201
|
201
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Retrieves a list of files for a folder
|
||||||
|
*/
|
||||||
|
export const getFileList = async (path) => await cachedApiRequest('GET', `${api}/contents/${path}`)
|
||||||
|
|
|
@ -107,7 +107,11 @@ export function decorateModel(Model, tools, modelConfig) {
|
||||||
if (this.record) {
|
if (this.record) {
|
||||||
for (const field of this.jsonFields) {
|
for (const field of this.jsonFields) {
|
||||||
if (this.encryptedFields && this.encryptedFields.includes(field)) {
|
if (this.encryptedFields && this.encryptedFields.includes(field)) {
|
||||||
this.clear[field] = JSON.parse(this.clear[field])
|
try {
|
||||||
|
this.clear[field] = JSON.parse(this.clear[field])
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err, typeof this.clear[field])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.record[field] = JSON.parse(this.record[field])
|
this.record[field] = JSON.parse(this.record[field])
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,7 @@ export const CreateShowcasePost = () => {
|
||||||
const [designs, setDesigns] = useState([])
|
const [designs, setDesigns] = useState([])
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [slug, setSlug] = useState(false)
|
const [slug, setSlug] = useState(false)
|
||||||
|
const [slugAvailable, setSlugAvailable] = useState(true)
|
||||||
const [img, setImg] = useState(false)
|
const [img, setImg] = useState(false)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
const [intro, setIntro] = useState('')
|
const [intro, setIntro] = useState('')
|
||||||
|
@ -96,6 +97,13 @@ export const CreateShowcasePost = () => {
|
||||||
setExtraImages(newImages)
|
setExtraImages(newImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verifySlug = async (newSlug) => {
|
||||||
|
setSlug(newSlug)
|
||||||
|
const result = await backend.isSlugAvailable({ slug: newSlug, type: 'showcase' })
|
||||||
|
console.log(result)
|
||||||
|
setSlugAvailable(result.available === true ? true : false)
|
||||||
|
}
|
||||||
|
|
||||||
const setExtraImg = (key, img) => {
|
const setExtraImg = (key, img) => {
|
||||||
const newImages = { ...extraImages }
|
const newImages = { ...extraImages }
|
||||||
newImages[key] = img
|
newImages[key] = img
|
||||||
|
@ -108,7 +116,6 @@ export const CreateShowcasePost = () => {
|
||||||
title,
|
title,
|
||||||
setTitle,
|
setTitle,
|
||||||
slug,
|
slug,
|
||||||
setSlug,
|
|
||||||
img,
|
img,
|
||||||
setImg,
|
setImg,
|
||||||
caption,
|
caption,
|
||||||
|
@ -123,6 +130,8 @@ export const CreateShowcasePost = () => {
|
||||||
setExtraImg,
|
setExtraImg,
|
||||||
account,
|
account,
|
||||||
t,
|
t,
|
||||||
|
verifySlug,
|
||||||
|
slugAvailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -272,7 +281,8 @@ const ShowcaseEditor = ({
|
||||||
title,
|
title,
|
||||||
setTitle,
|
setTitle,
|
||||||
slug,
|
slug,
|
||||||
setSlug,
|
verifySlug,
|
||||||
|
slugAvailable,
|
||||||
img,
|
img,
|
||||||
setImg,
|
setImg,
|
||||||
caption,
|
caption,
|
||||||
|
@ -332,7 +342,7 @@ const ShowcaseEditor = ({
|
||||||
<Item
|
<Item
|
||||||
title={
|
title={
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
{slug.length > 3 ? (
|
{slugAvailable && slug.length > 3 ? (
|
||||||
<OkIcon stroke={4} className="w-5 h-5 text-success" />
|
<OkIcon stroke={4} className="w-5 h-5 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<KoIcon stroke={3} className="w-5 h-5 text-error" />
|
<KoIcon stroke={3} className="w-5 h-5 text-error" />
|
||||||
|
@ -350,7 +360,7 @@ const ShowcaseEditor = ({
|
||||||
The slug is the part of the URL that uniquely identifies the post. We can generate one based
|
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.
|
on the title, but you can also customize it.
|
||||||
</Tip>
|
</Tip>
|
||||||
<SlugInput {...{ title, slug, setSlug }} />
|
<SlugInput {...{ title, slug, setSlug: verifySlug, slugAvailable }} />
|
||||||
</Item>
|
</Item>
|
||||||
<Item
|
<Item
|
||||||
title={
|
title={
|
||||||
|
|
|
@ -13,7 +13,9 @@ export const ns = ['account']
|
||||||
|
|
||||||
export const CaptionInput = ({ caption, setCaption }) => (
|
export const CaptionInput = ({ caption, setCaption }) => (
|
||||||
<input
|
<input
|
||||||
className="input input-text input-bordered input-lg w-full"
|
className={`input input-text input-bordered input-lg w-full ${
|
||||||
|
caption.length < 4 ? 'input-error' : 'input-success'
|
||||||
|
}`}
|
||||||
value={caption}
|
value={caption}
|
||||||
placeholder="Type your caption here"
|
placeholder="Type your caption here"
|
||||||
onChange={(evt) => setCaption(evt.target.value)}
|
onChange={(evt) => setCaption(evt.target.value)}
|
||||||
|
@ -22,7 +24,9 @@ export const CaptionInput = ({ caption, setCaption }) => (
|
||||||
|
|
||||||
export const IntroInput = ({ intro, setIntro }) => (
|
export const IntroInput = ({ intro, setIntro }) => (
|
||||||
<input
|
<input
|
||||||
className="input input-text input-bordered input-lg w-full"
|
className={`input input-text input-bordered input-lg w-full ${
|
||||||
|
intro.length < 4 ? 'input-error' : 'input-success'
|
||||||
|
}`}
|
||||||
value={intro}
|
value={intro}
|
||||||
placeholder="Type your intro here"
|
placeholder="Type your intro here"
|
||||||
onChange={(evt) => setIntro(evt.target.value)}
|
onChange={(evt) => setIntro(evt.target.value)}
|
||||||
|
@ -31,7 +35,9 @@ export const IntroInput = ({ intro, setIntro }) => (
|
||||||
|
|
||||||
export const BodyInput = ({ body, setBody }) => (
|
export const BodyInput = ({ body, setBody }) => (
|
||||||
<textarea
|
<textarea
|
||||||
className="input input-text input-bordered input-lg w-full h-96"
|
className={`input input-text input-bordered input-lg w-full h-96 ${
|
||||||
|
body.length < 4 ? 'input-error' : 'input-success'
|
||||||
|
}`}
|
||||||
placeholder="Type your post body here"
|
placeholder="Type your post body here"
|
||||||
onChange={(evt) => setBody(evt.target.value)}
|
onChange={(evt) => setBody(evt.target.value)}
|
||||||
rows={16}
|
rows={16}
|
||||||
|
@ -41,21 +47,26 @@ export const BodyInput = ({ body, setBody }) => (
|
||||||
|
|
||||||
export const TitleInput = ({ title, setTitle }) => (
|
export const TitleInput = ({ title, setTitle }) => (
|
||||||
<input
|
<input
|
||||||
className="input input-text input-bordered input-lg w-full"
|
className={`input input-text input-bordered input-lg w-full ${
|
||||||
|
title.length < 11 ? 'input-error' : 'input-success'
|
||||||
|
}`}
|
||||||
value={title}
|
value={title}
|
||||||
placeholder="Type your title here"
|
placeholder="Type your title here"
|
||||||
onChange={(evt) => setTitle(evt.target.value)}
|
onChange={(evt) => setTitle(evt.target.value)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SlugInput = ({ slug, setSlug, title }) => {
|
export const SlugInput = ({ slug, setSlug, title, slugAvailable }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title !== slug) setSlug(slugify(title))
|
if (title !== slug) setSlug(slugify(title))
|
||||||
}, [title])
|
}, [title])
|
||||||
|
console.log(slugAvailable)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className="input input-text input-bordered input-lg w-full mb-2"
|
className={`input input-text input-bordered input-lg w-full mb-2 ${
|
||||||
|
!slugAvailable || slug.length < 4 ? 'input-error' : 'input-success'
|
||||||
|
}`}
|
||||||
value={slug}
|
value={slug}
|
||||||
placeholder="Type your title here"
|
placeholder="Type your title here"
|
||||||
onChange={(evt) => setSlug(slugifyNoTrim(evt.target.value))}
|
onChange={(evt) => setSlug(slugifyNoTrim(evt.target.value))}
|
||||||
|
|
|
@ -144,60 +144,58 @@ export const MeasieInput = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-control mb-2 ">
|
<div className="form-control mb-2 ">
|
||||||
<div className="flex items-center gap-4 flex-wrap mx-auto">
|
<label className="shrink-0 grow max-w-full">
|
||||||
<label className="shrink-0 grow max-w-full">
|
{children}
|
||||||
{children}
|
<span className="input-group">
|
||||||
<span className="input-group">
|
<NumberInput
|
||||||
<NumberInput
|
className={`border-r-0 w-full ${valid === null && ''}`}
|
||||||
className={`border-r-0 w-full ${valid === null && 'border-base-200'}`}
|
value={val}
|
||||||
value={val}
|
onUpdate={update}
|
||||||
onUpdate={update}
|
onMount={setValid}
|
||||||
onMount={setValid}
|
/>
|
||||||
/>
|
<span
|
||||||
<span
|
className={`bg-transparent border-y w-20
|
||||||
className={`bg-transparent border-y w-20
|
|
||||||
${valid === false && 'border-error text-neutral-content'}
|
${valid === false && 'border-error text-neutral-content'}
|
||||||
${valid && 'border-success text-neutral-content'}
|
${valid && 'border-success text-neutral-content'}
|
||||||
${valid === null && 'border-base-200 text-base-content'}
|
${valid === null && 'border-secondary text-base-content'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Mval
|
<Mval
|
||||||
imperial={mset.imperial}
|
imperial={mset.imperial}
|
||||||
val={isDegree ? val : measurementAsMm(val, units)}
|
val={isDegree ? val : measurementAsMm(val, units)}
|
||||||
m={m}
|
m={m}
|
||||||
className="text-base-content bg-transparent text-success text-xs font-bold p-0"
|
className="text-base-content bg-transparent text-success text-xs font-bold p-0"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
className={`bg-transparent border-y
|
className={`bg-transparent border-y
|
||||||
${valid === false && 'border-error text-neutral-content'}
|
${valid === false && 'border-error text-neutral-content'}
|
||||||
${valid && 'border-success text-neutral-content'}
|
${valid && 'border-success text-neutral-content'}
|
||||||
${valid === null && 'border-base-200 text-base-content'}
|
${valid === null && 'border-secondary text-base-content'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{valid && '👍'}
|
{valid && '👍'}
|
||||||
{valid === false && '🤔'}
|
{valid === false && '🤔'}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`w-14 text-center
|
className={`w-14 text-center border-secondary border-solid border border-l-0
|
||||||
${valid === false && 'bg-error text-neutral-content'}
|
${valid === false && 'bg-error text-neutral-content'}
|
||||||
${valid && 'bg-success text-neutral-content'}
|
${valid && 'bg-success text-neutral-content'}
|
||||||
${valid === null && 'bg-base-200 text-base-content'}
|
${valid === null && 'bg-base-200 text-base-content border-secondary'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{isDegree ? '°' : mset.imperial ? 'in' : 'cm'}
|
{isDegree ? '°' : mset.imperial ? 'in' : 'cm'}
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</span>
|
||||||
{mset.imperial && (
|
</label>
|
||||||
<div className="grow my-2 sm:min-w-[22rem]">
|
{mset.imperial && (
|
||||||
{!isDegree && <FractionButtons {...{ t, fraction }} />}
|
<div className="grow my-2 sm:min-w-[22rem]">
|
||||||
</div>
|
{!isDegree && <FractionButtons {...{ t, fraction }} />}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
{backend && (
|
{backend && (
|
||||||
<button className="btn btn-secondary w-24 mt-4" onClick={save} disabled={!valid}>
|
<button className="btn btn-secondary mt-4" onClick={save} disabled={!valid}>
|
||||||
{t('save')}
|
{t('save')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -119,7 +119,7 @@ export const NumberInput = ({
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="number"
|
inputMode="number"
|
||||||
className={`input input-bordered ${className || 'input-sm grow text-base-content'}
|
className={`input input-secondary ${className || 'input-sm grow text-base-content'}
|
||||||
${valid.current === false && 'input-error'}
|
${valid.current === false && 'input-error'}
|
||||||
${valid.current && 'input-success'}
|
${valid.current && 'input-success'}
|
||||||
`}
|
`}
|
||||||
|
|
|
@ -298,12 +298,25 @@ Backend.prototype.createIssue = async function (data) {
|
||||||
return responseHandler(await api.post(`/issues`, data), 201)
|
return responseHandler(await api.post(`/issues`, data), 201)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check whether a slug is available
|
||||||
|
*/
|
||||||
|
Backend.prototype.isSlugAvailable = async function ({ slug, type }) {
|
||||||
|
const response = await api.get(`/slugs/${type}/${slug}/jwt`, this.auth)
|
||||||
|
|
||||||
|
// 404 means username is available, which is success in this case
|
||||||
|
return response.status === 200
|
||||||
|
? { success: false, available: false, response }
|
||||||
|
: { success: true, data: false, available: true, response }
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Create showcase Pull Request
|
* Create showcase Pull Request
|
||||||
*/
|
*/
|
||||||
Backend.prototype.createShowcasePr = async function (data) {
|
Backend.prototype.createShowcasePr = async function (data) {
|
||||||
return responseHandler(await api.post(`/flows/pr/showcase/jwt`, data, this.auth), 201)
|
return responseHandler(await api.post(`/flows/pr/showcase/jwt`, data, this.auth), 201)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Send translation invite
|
* Send translation invite
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue