Skip to content
Draft

Cleanup #5433

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
4 changes: 3 additions & 1 deletion .github/workflows/deploy-gh-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
# Full history so generate-removed.js can read git log for the
# "recently removed" page.
fetch-depth: 0
- uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
Expand Down
4 changes: 3 additions & 1 deletion api/middleware/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export default corsEventHandler(
/** no-op */
},
{
// Origin kept permissive: the API is read-mostly and may have external
// consumers. Methods are restricted to what the routes actually use.
origin: '*',
methods: '*'
methods: ['GET', 'POST', 'OPTIONS']
}
)
35 changes: 24 additions & 11 deletions api/middleware/ratelimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,36 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// `node:net` isn't available in the Workers runtime, so validate with a regex.
const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/
const IPV6 = /^[0-9a-fA-F:]+$/
function isValidIP(ip: string): boolean {
return IPV4.test(ip) || (ip.includes(':') && IPV6.test(ip))
}

export default defineEventHandler(async (event) => {
const { cloudflare } = event.context

const ipAddress =
getHeader(event, 'cf-connecting-ip') ||
getHeader(event, 'x-forwarded-for') ||
event.node.req.socket.remoteAddress
// Prefer `cf-connecting-ip` (Cloudflare-set, not client-spoofable); fall back
// to the last hop of `x-forwarded-for`, then the socket address.
const cf = getHeader(event, 'cf-connecting-ip')
const xff = getHeader(event, 'x-forwarded-for')
const lastHop = xff
?.split(',')
.map((p) => p.trim())
.at(-1)
const candidate = cf || lastHop || event.node.req.socket.remoteAddress
const ipAddress = candidate && isValidIP(candidate) ? candidate : undefined

if (ipAddress && cloudflare?.env?.RATE_LIMITER) {
const { success } = await (
cloudflare.env as unknown as Env
).RATE_LIMITER.limit({
key: ipAddress
})
const limiter = cloudflare?.env?.RATE_LIMITER
if (ipAddress && limiter) {
const { success } = await limiter.limit({ key: ipAddress })

if (!success) {
throw createError('Failure – rate limit exceeded')
throw createError({
statusCode: 429,
statusMessage: 'Rate limit exceeded'
})
}
}
})
69 changes: 59 additions & 10 deletions api/routes/feedback.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,55 @@ import {
getFeedbackOption
} from '../../docs/.vitepress/types/Feedback'

const MAX_BODY_BYTES = 4096

// `node:net` isn't available in the Workers runtime, so validate with a regex.
const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/
const IPV6 = /^[0-9a-fA-F:]+$/
function isValidIP(ip: string): boolean {
return IPV4.test(ip) || (ip.includes(':') && IPV6.test(ip))
}

/**
* Resolve the client IP for rate limiting. Prefers `cf-connecting-ip` (set by
* Cloudflare and not spoofable by the client). Falls back to the last hop of
* `x-forwarded-for` (closest to our edge), then the socket address. Returns
* `undefined` if nothing validates as an IP.
*/
function resolveClientIP(
event: Parameters<typeof getHeader>[0]
): string | undefined {
const cf = getHeader(event, 'cf-connecting-ip')
if (cf && isValidIP(cf)) return cf

const xff = getHeader(event, 'x-forwarded-for')
if (xff) {
const parts = xff.split(',').map((p) => p.trim())
const last = parts[parts.length - 1]
if (last && isValidIP(last)) return last
}

const remote = event.node.req.socket.remoteAddress
return remote && isValidIP(remote) ? remote : undefined
}

/** Neutralize Discord-specific markup before embedding user content. */
function sanitizeForDiscord(input: string): string {
return input
.replace(/@(everyone|here)/gi, '[at]$1')
.replace(/```/g, "'''")
.slice(0, 1000)
}

export default defineEventHandler(async (event) => {
const contentLength = Number(getHeader(event, 'content-length') ?? '0')
if (contentLength > MAX_BODY_BYTES) {
throw createError({
statusCode: 413,
statusMessage: 'Payload Too Large'
})
}

const { message, page, type, heading } = await readValidatedBody(
event,
FeedbackSchema.parseAsync
Expand All @@ -35,25 +83,22 @@ export default defineEventHandler(async (event) => {
},
{
name: 'Message',
value: message,
value: sanitizeForDiscord(message),
inline: false
}
]

if (heading) {
fields.unshift({
name: 'Section',
value: heading,
value: sanitizeForDiscord(heading),
inline: true
})
}

const clientIP =
getHeader(event, 'cf-connecting-ip') ||
getHeader(event, 'x-forwarded-for') ||
event.node.req.socket.remoteAddress
const clientIP = resolveClientIP(event)

const cf = event.context.cloudflare
const cf = event.context.cloudflare as any
if (clientIP && cf?.env?.RATE_LIMITER) {
const key = `feedback:${clientIP}`
const { success } = await cf.env.RATE_LIMITER.limit({ key })
Expand All @@ -72,8 +117,8 @@ export default defineEventHandler(async (event) => {
},
body: JSON.stringify({
username: 'Feedback',
avatar_url:
'https://i.kym-cdn.com/entries/icons/facebook/000/043/403/cover3.jpg',
// Self-hosted so the avatar can't break if a third-party host changes it.
avatar_url: 'https://fmhy.net/feedback-avatar.jpg',
embeds: [
{
color: 3447003,
Expand All @@ -85,8 +130,12 @@ export default defineEventHandler(async (event) => {
})

if (!response.ok) {
const body = await response.text().catch(() => 'Could not read body')
console.error(
`Discord webhook failed: ${response.status} ${response.statusText} - ${body}`
)
throw createError({
statusCode: response.status,
statusCode: 502,
statusMessage: 'Failed to send feedback to Discord'
})
}
Expand Down
28 changes: 21 additions & 7 deletions api/routes/single-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,36 @@ const files = (
url: `https://raw.githubusercontent.com/fmhy/edit/main/docs/${file}`
}))

const FETCH_TIMEOUT = 10_000 // 10 seconds

export default defineCachedEventHandler(
async (event) => {
let body = '<!-- This is autogenerated content, do not edit manually. -->\n'

const contents = await Promise.all(
files.map(async (file) => {
const content = await $fetch<string>(file.url)

return content
})
// Use allSettled so a single slow/unavailable file doesn't fail the whole
// page, and cap each fetch with a timeout so the request can't hang.
const results = await Promise.allSettled(
files.map((file) => $fetch<string>(file.url, { timeout: FETCH_TIMEOUT }))
)

const contents = results
.filter(
(result): result is PromiseFulfilledResult<string> =>
result.status === 'fulfilled'
)
.map((result) => result.value)

for (const result of results) {
if (result.status === 'rejected') {
console.error('single-page: failed to fetch a doc:', result.reason)
}
}

body += contents.join('\n\n')

appendResponseHeaders(event, {
'content-type': 'text/markdown;charset=utf-8',
'cache-control': 'public, max-age=7200'
'cache-control': 'public, max-age=300, stale-while-revalidate=3600'
})
return body
},
Expand Down
Loading