Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(logo): api upload to allow svg #1729

Merged
merged 5 commits into from
Dec 13, 2024
Merged
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store

.direnv
.envrc
.envrc
tavla/ent-tavla-dev-875a70280651.json
emilielr marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,39 +1,79 @@
'use client'
import { ChangeEventHandler, useActionState, useState } from 'react'
import { ChangeEventHandler, useState } from 'react'
import { ImageIcon, UploadIcon } from '@entur/icons'
import { FormError } from 'app/(admin)/components/FormError'
import { getFormFeedbackForField } from 'app/(admin)/utils'
import {
TFormFeedback,
getFormFeedbackForError,
getFormFeedbackForField,
} from 'app/(admin)/utils'
import { Label, Paragraph } from '@entur/typography'
import { Button } from '@entur/button'
import { useFormStatus } from 'react-dom'
import { upload } from './actions'
import { HiddenInput } from 'components/Form/HiddenInput'
import { TOrganizationID } from 'types/settings'
import { SubmitButton } from 'components/Form/SubmitButton'
import { Loader } from '@entur/loader'
import { useRouter } from 'next/navigation'

function LogoInput({ oid }: { oid?: TOrganizationID }) {
const [state, action] = useActionState(upload, undefined)
const [file, setFile] = useState('')
const [state, setFormError] = useState<TFormFeedback | undefined>()
const [file, setFile] = useState<File | null>(null)
const [fileName, setFileName] = useState<string>()
const router = useRouter()

const clearLogo = () => {
setFile('')
setFile(null)
setFileName(undefined)
setFormError(undefined)
}

const submit = async (data: FormData) => {
const response = await fetch(`/api/upload`, {
method: 'POST',
body: data,
})

if (!response.ok) {
clearLogo()
switch (response.status) {
case 400:
return setFormError(getFormFeedbackForError('file/invalid'))
case 403:
return setFormError(
getFormFeedbackForError('auth/operation-not-allowed'),
purusott marked this conversation as resolved.
Show resolved Hide resolved
)
case 413:
return setFormError(
getFormFeedbackForError('file/size-too-big'),
)
case 429:
return setFormError(
getFormFeedbackForError('file/rate-limit'),
)
case 500:
return setFormError(
getFormFeedbackForError('firebase/general'),
)
default:
return setFormError(getFormFeedbackForError('general'))
}
}

clearLogo()
router.refresh()
}

const setLogo: ChangeEventHandler<HTMLInputElement> = (e) => {
if (!e.target) return
setFile(e.target.value)
setFileName(e.target?.files?.item(0)?.name ?? 'Logo uten navn')
const selectedFile = e.target.files?.[0]
purusott marked this conversation as resolved.
Show resolved Hide resolved
if (!selectedFile) return

setFile(selectedFile)
setFileName(selectedFile.name)
}

return (
<form
action={action}
onSubmit={clearLogo}
className="flex flex-col relative"
>
<form action={submit} className="flex flex-col relative">
<HiddenInput id="oid" value={oid} />
<Label
htmlFor="logo"
Expand All @@ -52,12 +92,12 @@ function LogoInput({ oid }: { oid?: TOrganizationID }) {
e.preventDefault()
e.stopPropagation()
}}
value={file}
required
aria-required
/>
</Label>
<div>

<div className="mt-2">
<FormError {...getFormFeedbackForField('file', state)} />
<FormError {...getFormFeedbackForField('general', state)} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use server'

import { TFormFeedback, getFormFeedbackForError } from 'app/(admin)/utils'
import { getFormFeedbackForError } from 'app/(admin)/utils'
import { revalidatePath } from 'next/cache'
import { TLogo, TOrganizationID } from 'types/settings'
import { getFilename } from './utils'
Expand All @@ -10,42 +10,9 @@ import {
initializeAdminApp,
userCanEditOrganization,
} from 'app/(admin)/utils/firebase'
import { getDownloadURL } from 'firebase-admin/storage'
import { redirect } from 'next/navigation'
import { nanoid } from 'nanoid'

initializeAdminApp()

export async function upload(
prevState: TFormFeedback | undefined,
data: FormData,
) {
const logo = data.get('logo') as File
const oid = data.get('oid') as TOrganizationID

if (!logo || !oid) return getFormFeedbackForError()

if (logo.size > 10_000_000)
return getFormFeedbackForError('file/size-too-big')

const access = userCanEditOrganization(oid)
if (!access) return redirect('/')

const bucket = storage().bucket((await getConfig()).bucket)
const file = bucket.file(`logo/${oid}-${nanoid()}`)
await file.save(Buffer.from(await logo.arrayBuffer()))

const logoUrl = await getDownloadURL(file)

if (!logoUrl) return getFormFeedbackForError()

await firestore().collection('organizations').doc(oid).update({
logo: logoUrl,
})

revalidatePath('/')
}

export async function remove(oid?: TOrganizationID, logo?: TLogo) {
if (!oid || !logo)
return getFormFeedbackForError('auth/operation-not-allowed')
Expand Down
14 changes: 14 additions & 0 deletions tavla/app/(admin)/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ export function getFormFeedbackForError(
feedback: 'Du må velge minst èn kolonne',
variant: 'error',
}
case 'file/invalid':
return {
form_type: 'file',
feedback:
'Du må legge til en gyldig fil (APNG, JPEG, PNG, SVG, GIP, WEBP).',
variant: 'error',
}
case 'file/size-too-big': {
return {
form_type: 'file',
Expand Down Expand Up @@ -228,6 +235,13 @@ export function getFormFeedbackForError(
variant: 'error',
}
}
case 'file/rate-limit': {
return {
form_type: 'file',
feedback: 'Noe gikk galt. Vennligst prøv igjen senere',
variant: 'error',
}
}
case 'firebase/general': {
return {
form_type: 'general',
Expand Down
128 changes: 128 additions & 0 deletions tavla/app/api/upload/route.ts
purusott marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use server'

import { TOrganizationID } from 'types/settings'
import { storage, firestore } from 'firebase-admin'
import {
getConfig,
initializeAdminApp,
userCanEditOrganization,
verifySession,
} from 'app/(admin)/utils/firebase'
import { getDownloadURL } from 'firebase-admin/storage'
import { nanoid } from 'nanoid'
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
import { NextRequest } from 'next/server'
import { revalidatePath } from 'next/cache'
import rateLimit from 'utils/rateLimit'

initializeAdminApp()

const rateLimiter = rateLimit({
maxUniqueTokens: 100,
interval: 60000,
})
export async function POST(request: NextRequest) {
const cookies = request.cookies
const token = cookies.get('session')?.value

const user = await verifySession(token)
const response = new Response()
response.headers.set('Content-Type', 'application/json')
if (!user || !token) {
return new Response(JSON.stringify({ error: 'Invalid token' }), {
status: 401,
headers: response.headers,
})
}

try {
await rateLimiter.check(response, 5, token)
} catch {
response.headers.set('Content-Type', 'application/json')
return new Response(JSON.stringify({ error: 'Too Many Requests' }), {
headers: response.headers,
status: 429,
})
}
const data = await request.formData()
const oid = data.get('oid') as TOrganizationID

const logo = data.get('logo') as File

if (!logo || !oid)
return new Response(JSON.stringify({ error: 'Missing values' }), {
headers: response.headers,
status: 400,
})

try {
await userCanEditOrganization(oid)
} catch {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
headers: response.headers,
status: 403,
})
}
if (logo.size > 10_000_000)
return new Response(JSON.stringify({ error: 'File size too big' }), {
headers: response.headers,
status: 413,
})

const allowedFileTypes = [
'image/apng',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/svg',
'image/webp',
]
if (!allowedFileTypes.includes(logo.type)) {
return new Response(
JSON.stringify({ error: 'Unsupported file type' }),
{
headers: response.headers,
status: 415,
},
)
}
let processedFile: Buffer
const window = new JSDOM('').window
const DOMPurify = createDOMPurify(window)

if (logo.type === 'image/svg+xml') {
const svgContent = new TextDecoder().decode(await logo.arrayBuffer())
const sanitizedSVG = DOMPurify.sanitize(svgContent)
processedFile = Buffer.from(sanitizedSVG)
} else {
processedFile = Buffer.from(await logo.arrayBuffer())
}

const bucket = storage().bucket((await getConfig()).bucket)
const file = bucket.file(`logo/${oid}-${nanoid()}`)
await file.save(processedFile)

const logoUrl = await getDownloadURL(file)

if (!logoUrl)
return new Response(
JSON.stringify({ error: 'Failed to get logo url' }),
{
headers: response.headers,
status: 500,
},
)

await firestore().collection('organizations').doc(oid).update({
logo: logoUrl,
})
revalidatePath(`/organizations/${oid}`)
return new Response(
JSON.stringify({ message: 'Logo uploaded successfully' }),
{
headers: response.headers,
status: 200,
},
)
}
3 changes: 3 additions & 0 deletions tavla/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const nextConfig = {
hostname: 'firebasestorage.googleapis.com',
},
],
dangerouslyAllowSVG: true,
purusott marked this conversation as resolved.
Show resolved Hide resolved
contentDispositionType: 'attachment',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
}

Expand Down
7 changes: 7 additions & 0 deletions tavla/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@
"@entur/tokens": "3.17.2",
"@entur/tooltip": "5.1.1",
"@entur/typography": "1.8.47",
"@firebase/database-types": "1.0.6",
"@floating-ui/react": "0.26.25",
"@sentry/nextjs": "^8",
"@types/formidable": "3.4.5",
"@types/jsdom": "21.1.7",
"abortcontroller-polyfill": "1.7.6",
"classnames": "2.5.1",
"dompurify": "3.2.0",
"firebase": "11.0.1",
"firebase-admin": "12.7.0",
"formidable": "3.5.2",
purusott marked this conversation as resolved.
Show resolved Hide resolved
"graphql": "16.9.0",
"jsdom": "25.0.1",
"lodash": "4.17.21",
"nanoid": "5.0.8",
"next": "15.0.3",
Expand Down
34 changes: 34 additions & 0 deletions tavla/src/Shared/utils/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { LRUCache } from 'lru-cache'

type TOptions = {
maxUniqueTokens?: number
interval?: number
}

export default function rateLimit(options: TOptions) {
const cache = new LRUCache({
max: options.maxUniqueTokens || 500,
ttl: options.interval || 60 * 1000,
})

return {
check: async (res: Response, limit: number, token: string) => {
return new Promise<void>((resolve, reject) => {
let tokenCount = (cache.get(token) as number) || 0

tokenCount += 1
cache.set(token, tokenCount)
const currentUsage = tokenCount ?? 0
const isRateLimited = currentUsage >= limit
const remaining = isRateLimited ? 0 : limit - currentUsage
res.headers.set('X-RateLimit-Limit', limit.toString())
res.headers.set('X-RateLimit-Remaining', remaining.toString())
if (isRateLimited) {
return reject()
} else {
return resolve()
}
})
},
}
}
Loading