Article Viewer
Overview
The article viewer renders content authored in the CMS. It supports the capabilities real content publishing needs: bilingual content (Thai and English), password-locked articles, audience-restricted access requiring LINE login, Open Graph metadata for link sharing, and a floating add-friend call-to-action button pinned to the bottom of the screen.
Content is stored as HTML produced by the Tiptap editor on the CMS side, then rendered back through Tiptap in read-only mode. The project never uses dangerouslySetInnerHTML anywhere, and a guard test enforces that rule.
Business Flow
- The user lands on
/:hash/content/:token. - Server side: the page layout calls the content endpoint in metadata-only mode with a 60-second revalidate window to build OG and meta tags. Titles are chosen in priority order: the OG title first, then the SEO title, then the article title. If the article is password-locked, only minimal metadata is emitted so content cannot leak through a link preview.
- Client side: the app loads the OA data from the hash and fetches the content, attaching
x-liff-tokenonly when a LIFF session already exists. - The flow then branches on the API response, as described in the table below.
- Language selection prefers Thai and falls back to English. When an article has more than one translation, a sticky language switcher appears in the header.
- If the article is linked to a friend-add campaign, a CTA button is pinned to the bottom of the screen. Tapping it navigates to that campaign's LIFF URL, forwarding the referral value when present.
- The referral value is read both from the query parameter directly and from inside
liff.state, because the LIFF login step strips the original query parameters.
Handling API responses
| Response | Screen behaviour |
|---|---|
| Normal success | Renders the article content |
| Password required | Shows the password entry screen, then refetches with the entered password. An incorrect password produces a clear message to the user |
| Forbidden (403) | Starts LIFF login at that moment. Public content needs no login, so the app only authenticates when it must. Once a profile is available, the content is refetched; if the audience condition still fails, an access-denied screen is shown |
| Any other error | Renders the Not Found page |
Key Screens & Components
Pages
- The article page (
src/app/[hash]/content/[token]/page.tsx) drives the whole flow: loading data, deciding whether a password or login is needed, and selecting the language. - The page layout (
layout.tsx) is responsible for server-side metadata generation. - The content fetch hook (
hooks/useFetchContent.ts) disables automatic retries, sets a 5-minute stale time, and retrieves the LIFF ID token itself when the user is already logged in.
Content rendering components (all under src/components/content-viewer/)
- The Tiptap viewer renders HTML content in read-only mode.
- A password entry screen for locked content.
- An access-denied screen for users outside the configured audience.
- A Thai/English language switcher.
- The floating add-friend CTA button, with an attention-drawing animation.
- Custom Tiptap extensions covering line height, text background colour, safe highlighting, and a module that validates CSS values.
Services and types
- The content service (
src/service/content.service.ts) fetches an article by token, and also exposes content list and category list calls. - Types for the article, its translations, campaign linkage, and the password-required response shape live in
src/service/types/content.type.ts.
Endpoints used
GET /public-content/content/:token, accepting query parameters for the password and metadata-only mode, plus thex-liff-tokenheader.GET /public-content/contentsand the categories endpoint exist in the service layer but no screen consumes them yet.
CSS and HTML safety
The CSS validation module applies a strict allowlist at every point where a CSS value is injected. Colour values must be a hex code or an rgb() or rgba() function, and line-height values must be a number with an allowed unit.
The key principle: when a value fails validation, the style is omitted entirely rather than partially escaped. This prevents an attacker from closing a declaration and appending dangerous CSS — for example using url() to exfiltrate data, or laying a fixed-position overlay over the screen. That matters especially here, because the page runs inside a webview carrying a live LINE session.
Unit tests cover the no-raw-HTML rule, CSS value validation, and the highlight extension's behaviour.
Dependencies
- Tiptap with StarterKit plus extensions for links, text alignment, and resizable images forms the rendering core.
- Ant Design supplies the primitives: loading spinner, typography system, cards, password input, and result screens.
- Connects to the Friend-add Tracking Campaign through the campaign data attached to an article.
- Users arrive here from the Public Menu and the Content Link Listing Page.
- The Tiptap viewer is reused by the Post-submission Thank-you Page for custom body content.
Backend Details (Client API)
:token accepts both a public token and a slug
The backend looks the article up by public token first, and if that misses it retries by slug before returning 404 Content not found. The web app can therefore use readable slug URLs with no changes, and slug links working is intentional rather than incidental.
Three different response shapes depending on mode
This single endpoint returns differently shaped bodies depending on which branch it takes, which is why the web app must fork its result handling:
1. Metadata-only mode (used when server-rendering OG tags)
- It skips the audience and password checks entirely and does not count a view — important, because it keeps view counts from being inflated by crawlers repeatedly fetching link previews.
- It returns only meta/OG fields for every language, with no article body, so leakage through link previews is prevented at the backend, not merely in the web app.
- The backend generates an excerpt automatically when the article has no excerpt, OG description, or meta description but does have body content: HTML tags are stripped,
becomes a space, runs of whitespace collapse, and the text is cut at 200 characters with an ellipsis — counted in real characters (runes), so Thai text is truncated correctly.
2. Password-required mode — a quirk you must know
- A password-protected article responds with HTTP 200, not 401 or 403, both when no password was supplied and when the supplied one is wrong. This is a quirk of the legacy controller, ported verbatim.
- The web app must therefore read the body, not the status code: no password supplied → the body carries
requirePassword: truewith a message saying the content is locked; wrong password → the body carriesrequirePassword: truepluserror: "INVALID_PASSWORD". - Passwords are compared with bcrypt; a malformed or empty hash simply fails to match (no error), and hashes written by the legacy system use the same format, so they all still verify.
3. Full-content mode — returns the body in every language, plus friend-add campaign data when present.
Audience restriction — why it is always 403, never 401
- An article with an audience configured and no
x-liff-tokenat all → 403Authentication required for this content(not 401, despite the wording) — which matches the web app's use of 403 as the signal to start LIFF login. - With a token, verification uses the content-specific path, which uses the primary channel only, with no fallback to the form channel, and every failure is a 403 — bad token, wrong channel, or non-matching audience alike.
- No audience overlap → 403
You do not have access to this content. - The consequence: the web app cannot tell from the status code whether 403 means "not logged in yet" or "logged in but not permitted", so it must try logging in once and only show the access-denied screen if a second 403 comes back — exactly what this page does.
View counting and the friend-add block
- The view count is incremented only after every gate has passed (audience and password) and only in full-content mode — a wrong password or an audience rejection never counts as a view.
- A side effect worth knowing: every content fetch increments the count. Refreshing the page, or refetching after logging in to satisfy an audience check, adds to it. The number therefore means "successful content loads", not "unique readers".
- When an article is linked to a friend-add campaign, the backend resolves the campaign token along with the button label (defaulting to the Thai for "add friend") and the LIFF ID needed — so the web app can assemble the CTA without an extra call.
The listing endpoints the service already supports
No screen calls them from this page yet, but the backend does provide public article listing endpoints, with limits worth knowing before wiring up a screen (full details in Content Link Listing Page):
- They are public endpoints with a route-specific rate limit of 10 requests per 60 seconds per IP, stricter than the general application-level limiter.
lineOaIdis mandatory and the OA must be active, otherwise 404Invalid or inactive channel.- Search terms are sanitized before validation: the characters
<>'"%;()&are stripped, then only Thai/Latin letters, digits, spaces, hyphens, and underscores may remain, up to 100 characters — and a term shorter than 2 characters returns 400. - There is deep-pagination protection: an offset beyond 500 items returns 400
Page number exceeds maximum allowed, so very long lists must be narrowed with filters rather than paged through indefinitely. - A published-date range wider than 365 days is rejected.
Edge cases worth knowing
- Unpublished articles are indistinguishable from missing ones (404) — nothing reveals that a draft exists.
- Metadata mode is not gated by audience at all, so adding any sensitive field to the meta set in future would leak immediately through this path — a point to watch when changing it.
- The backend returns all language translations in a single response, so switching languages in the web app requires no new request and adds no view.