Skip to content
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,31 @@ be publicly accessible without signing in.
The mail API receives a complete header/body/footer HTML fragment in its `body`
field. Its hosted `generic` mail template should insert that HTML without adding
another branded header or footer.

## Uploading email images

Place the cursor in the HTML body, optionally enter image alt text, and choose
**Upload & insert image**. PNG, JPEG, and GIF files up to 5 MB are supported.
The uploader adds responsive image HTML at the selection, updates the preview,
and provides a public URL to copy. Uploading publishes the image publicly.

Uploads use the existing public `HackIllinois/adonix-metadata` asset repository,
under `email/uploads/`. Each image gets a unique filename and a raw GitHub URL
pinned to its commit, so future uploads cannot replace artwork in sent emails.
The automatic header/footer continue to use the bundled `public/email/` assets.

Before deploying, configure these **server-only** environment variables:

- `EMAIL_IMAGES_GITHUB_TOKEN`: a GitHub credential with Contents write permission
on the asset repository. Use a fine-grained token scoped to that repository;
do not put it in a `NEXT_PUBLIC_` variable or commit it.
- `EMAIL_IMAGES_GITHUB_REPOSITORY`: optional, defaults to `HackIllinois/adonix-metadata`.
- `EMAIL_IMAGES_GITHUB_BRANCH`: optional, defaults to `main`. The credential must
be allowed to create commits on this branch.

For local Next.js development, set them in `.env.local`. On Cloudflare, store the
token as a Worker secret (`npx wrangler secret put EMAIL_IMAGES_GITHUB_TOKEN`) and
configure the optional values as runtime variables. Redeploy the updated app.
The upload route verifies the user's ADMIN role with Adonix on each request,
checks size and image signatures, and rejects private asset repositories.
Missing configuration is reported in the editor without changing the draft.
141 changes: 141 additions & 0 deletions app/api/email/images/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { imageExtension, MAX_EMAIL_IMAGE_BYTES } from "@/util/email-images"

export const runtime = "nodejs"

const jsonError = (message: string, status: number) =>
Response.json({ message }, { status })

export async function POST(request: Request) {
// Require same-origin browser requests, including when authenticating by cookie.
if (request.headers.get("origin") !== new URL(request.url).origin) {
return jsonError("Upload images from the admin email editor.", 403)
}
const authorization = request.headers.get("authorization")
const jwt = request.headers
.get("cookie")
?.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith("jwt="))
if (!authorization && !jwt)
return jsonError("Sign in before uploading images.", 401)

try {
const auth = await fetch(
"https://adonix.hackillinois.org/auth/roles/",
{
headers: authorization
? { Authorization: authorization }
: { Cookie: jwt! },
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(15000),
},
)
if (auth.status === 401)
return jsonError("Your session expired. Sign in again.", 401)
if (!auth.ok)
return jsonError(
"Unable to verify your admin access.",
auth.status === 403 ? 403 : 502,
)
const user = await auth.json()
if (!Array.isArray(user.roles) || !user.roles.includes("ADMIN")) {
return jsonError("Only admins can upload email images.", 403)
}

const token = process.env.EMAIL_IMAGES_GITHUB_TOKEN
const repo =
process.env.EMAIL_IMAGES_GITHUB_REPOSITORY ||
"HackIllinois/adonix-metadata"
const branch = process.env.EMAIL_IMAGES_GITHUB_BRANCH || "main"
if (!token)
return jsonError(
"Image uploads are not configured yet. Ask the site maintainer to connect the GitHub image repository.",
503,
)
if (!/^[\w.-]+\/[\w.-]+$/.test(repo))
return jsonError(
"The image repository configuration is invalid.",
503,
)

const declaredSize = Number(request.headers.get("content-length"))
if (declaredSize > MAX_EMAIL_IMAGE_BYTES)
return jsonError("Choose an image smaller than 5 MB.", 413)
if (!request.body) return jsonError("Choose an image to upload.", 400)
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > MAX_EMAIL_IMAGE_BYTES) {
await reader.cancel()
return jsonError("Choose an image smaller than 5 MB.", 413)
}
chunks.push(value)
}
const bytes = Buffer.concat(chunks)
const extension = imageExtension(bytes)
if (!extension)
return jsonError("Choose a PNG, JPEG, or GIF image.", 415)

const headers = {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "HackIllinois-admin-email-images",
}
// Never produce authenticated/expiring image links from a private repo.
const repository = await fetch(`https://api.github.com/repos/${repo}`, {
headers,
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(15000),
})
if (!repository.ok || (await repository.json()).private !== false) {
return jsonError(
"The image repository must be public and accessible to the upload service.",
503,
)
}
const path = `email/uploads/${crypto.randomUUID()}.${extension}`
const uploaded = await fetch(
`https://api.github.com/repos/${repo}/contents/${path}`,
{
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
message: `Add email image ${path}`,
content: bytes.toString("base64"),
branch,
}),
redirect: "error",
signal: AbortSignal.timeout(30000),
},
)
if (!uploaded.ok)
return jsonError(
"GitHub could not save the image. Check repository write access and branch rules, then try again.",
502,
)
const result = await uploaded.json()
if (!/^[a-f0-9]{40}$/.test(result.commit?.sha))
return jsonError(
"GitHub saved the image but did not return its public URL. Contact the site maintainer.",
502,
)
return Response.json(
{
url: `https://raw.githubusercontent.com/${repo}/${result.commit.sha}/${path}`,
},
{ status: 201 },
)
} catch {
return jsonError(
"The image upload could not finish. Please try again.",
502,
)
}
}
133 changes: 133 additions & 0 deletions app/email/ImageUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client"

import { useRef, useState } from "react"
import { AuthService } from "@/generated"
import { handleError } from "@/util/api-client"
import {
EMAIL_IMAGE_TYPES,
MAX_EMAIL_IMAGE_BYTES,
emailImageHtml,
} from "@/util/email-images"
import styles from "./style.module.scss"

export default function ImageUpload({
disabled,
onInsert,
onBusyChange,
}: {
disabled: boolean
onInsert: (html: string) => void
onBusyChange: (busy: boolean) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
const [alt, setAlt] = useState("")
const [busy, setBusy] = useState(false)
const [error, setError] = useState("")
const [lastUrl, setLastUrl] = useState("")
const [copied, setCopied] = useState(false)

const upload = async (file: File) => {
setError("")
if (!EMAIL_IMAGE_TYPES.includes(file.type)) {
setError("Choose a PNG, JPEG, or GIF image.")
return
}
if (!file.size || file.size > MAX_EMAIL_IMAGE_BYTES) {
setError("Choose a nonempty image smaller than 5 MB.")
return
}
setBusy(true)
onBusyChange(true)
try {
// The API cookie belongs to Adonix; obtain a JWT for this same-origin
// endpoint as well, including on localhost and workers.dev previews.
const session = handleError(await AuthService.getAuthToken())
const response = await fetch("/api/email/images", {
method: "POST",
headers: {
Authorization: `Bearer ${session.jwt}`,
"Content-Type": file.type,
},
body: file,
})
const result = await response.json()
if (!response.ok)
throw new Error(result.message || "Image upload failed.")
onInsert(emailImageHtml(result.url, alt))
setLastUrl(result.url)
setCopied(false)
} catch (err) {
setError(
err instanceof Error ? err.message : "Image upload failed.",
)
} finally {
setBusy(false)
onBusyChange(false)
}
}

return (
<div className={styles.imageUpload}>
<label htmlFor="email-image-alt">
Image description (alt text)
</label>
<input
id="email-image-alt"
value={alt}
placeholder="Describe the banner, or leave blank if decorative"
disabled={disabled || busy}
onChange={(event) => setAlt(event.target.value)}
/>
<div className={styles.imageActions}>
<input
ref={inputRef}
type="file"
accept={EMAIL_IMAGE_TYPES.join(",")}
hidden
disabled={disabled || busy}
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ""
if (file) void upload(file)
}}
/>
<button
type="button"
disabled={disabled || busy}
onClick={() => inputRef.current?.click()}
>
{busy ? "Uploading image…" : "Upload & insert image"}
</button>
<span>PNG, JPEG or GIF · Up to 5 MB · Hosted publicly</span>
</div>
{lastUrl && (
<div className={styles.uploadResult}>
<span role="status">Image inserted.</span>{" "}
<a href={lastUrl} target="_blank" rel="noreferrer">
Open hosted image
</a>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(lastUrl)
setCopied(true)
} catch {
setError(
"Could not copy the URL. Open the hosted image to copy its address.",
)
}
}}
>
{copied ? "Copied!" : "Copy URL"}
</button>
</div>
)}
{error && (
<p className={styles.errorMessage} role="alert">
{error}
</p>
)}
</div>
)
}
37 changes: 34 additions & 3 deletions app/email/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MailService, MailBulkSendResult } from "@/generated"
import { handleError } from "@/util/api-client"
import { renderEmailBody, renderEmailPreview } from "@/util/email-template"
import styles from "./style.module.scss"
import ImageUpload from "./ImageUpload"

type SendState =
| { status: "editing" }
Expand All @@ -17,6 +18,9 @@ export default function Email() {
const [subject, setSubject] = useState("")
const [body, setBody] = useState("")
const [assetBaseUrl, setAssetBaseUrl] = useState("")
const [uploading, setUploading] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
const selectionRef = useRef({ start: 0, end: 0 })
const previewRef = useRef<HTMLIFrameElement>(null)
const [sendState, setSendState] = useState<SendState>({ status: "editing" })
const [sendResult, setSendResult] = useState<MailBulkSendResult | null>(
Expand Down Expand Up @@ -125,6 +129,19 @@ export default function Email() {
setSendResult(null)
}

const insertImage = (html: string) => {
const { start, end } = selectionRef.current
setBody(
(current) => current.slice(0, start) + html + current.slice(end),
)
const cursor = start + html.length
selectionRef.current = { start: cursor, end: cursor }
requestAnimationFrame(() => {
bodyRef.current?.focus()
bodyRef.current?.setSelectionRange(cursor, cursor)
})
}

return (
<div className={styles.container}>
<div className={styles.header}>
Expand All @@ -143,20 +160,34 @@ export default function Email() {
/>
</div>

<ImageUpload
disabled={locked}
onInsert={insertImage}
onBusyChange={setUploading}
/>

<div className={styles.editorLayout}>
<div className={styles.editorPane}>
<label htmlFor="email-body">Body (HTML)</label>
<p className={styles.editorHint} id="email-body-hint">
The HackIllinois header and footer are included
automatically.
automatically. Place your cursor where you want an
image, then upload it above.
</p>
<textarea
ref={bodyRef}
id="email-body"
aria-describedby="email-body-hint"
placeholder="Enter email body HTML..."
value={body}
onChange={(e) => setBody(e.target.value)}
disabled={locked}
disabled={locked || uploading}
onSelect={(event) => {
selectionRef.current = {
start: event.currentTarget.selectionStart,
end: event.currentTarget.selectionEnd,
}
}}
/>
</div>

Expand Down Expand Up @@ -192,7 +223,7 @@ export default function Email() {
<button
className={styles.sendSelfBtn}
onClick={handleSendSelf}
disabled={!subject || !body || !email.body}
disabled={uploading || !subject || !body || !email.body}
>
Send to Self
</button>
Expand Down
Loading
Loading