feat: init

This commit is contained in:
ccbikai
2024-05-25 08:09:30 +08:00
commit bd47e755d5
298 changed files with 21915 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod'
import { destr } from 'destr'
export default eventHandler(async (event) => {
const url = (await getValidatedQuery(event, z.object({
url: z.string().url(),
}).parse)).url
const { cloudflare } = event.context
const { AI } = cloudflare.env
if (AI) {
const { aiPrompt, aiModel } = useRuntimeConfig(event)
const { slugRegex } = useAppConfig(event)
const messages = [
{ role: 'system', content: aiPrompt.replace('{slugRegex}', slugRegex.toString()) },
{ role: 'user', content: 'https://www.cloudflare.com/' },
{ role: 'assistant', content: '{"slug": "cloudflare"}' },
{ role: 'user', content: 'https://github.com/nuxt-hub/' },
{ role: 'assistant', content: '{"slug": "nuxt-hub"}' },
{ role: 'user', content: 'https://sink.cool/' },
{ role: 'assistant', content: '{"slug": "sink-cool"}' },
{ role: 'user', content: 'https://github.com/ccbikai/sink' },
{ role: 'assistant', content: '{"slug": "sink"}' },
{
role: 'user',
content: url,
},
]
const { response } = await AI.run(aiModel, { messages })
return destr(response)
}
else {
throw createError({ status: 501, statusText: 'AI not enabled' })
}
})
+27
View File
@@ -0,0 +1,27 @@
import { LinkSchema } from '@/schemas/link'
export default eventHandler(async (event) => {
const link = await readValidatedBody(event, LinkSchema.parse)
const { cloudflare } = event.context
const { KV } = cloudflare.env
const existingLink = await KV.get(`link:${link.slug}`)
if (existingLink) {
throw createError({
status: 409, // Conflict
statusText: 'Link already exists',
})
}
else {
const expiration = getExpiration(event, link.expiration)
await KV.put(`link:${link.slug}`, JSON.stringify(link), {
expiration,
metadata: {
expiration,
},
})
setResponseStatus(event, 201)
return { link }
}
})
+15
View File
@@ -0,0 +1,15 @@
export default eventHandler(async (event) => {
const { previewMode } = useRuntimeConfig(event).public
if (previewMode) {
throw createError({
status: 403,
statusText: 'Preview mode cannot delete links.',
})
}
const { slug } = await readBody(event)
if (slug) {
const { cloudflare } = event.context
const { KV } = cloudflare.env
await KV.delete(`link:${slug}`)
}
})
+35
View File
@@ -0,0 +1,35 @@
import type { z } from 'zod'
import { LinkSchema } from '@/schemas/link'
export default eventHandler(async (event) => {
const { previewMode } = useRuntimeConfig(event).public
if (previewMode) {
throw createError({
status: 403,
statusText: 'Preview mode cannot edit links.',
})
}
const link = await readValidatedBody(event, LinkSchema.parse)
const { cloudflare } = event.context
const { KV } = cloudflare.env
const existingLink: z.infer<typeof LinkSchema> | null = await KV.get(`link:${link.slug}`, { type: 'json' })
if (existingLink) {
const newLink = {
...existingLink,
...link,
id: existingLink.id, // don't update id
createdAt: existingLink.createdAt, // don't update createdAt
updatedAt: Math.floor(Date.now() / 1000),
}
const expiration = getExpiration(event, newLink.expiration)
await KV.put(`link:${newLink.slug}`, JSON.stringify(newLink), {
expiration,
metadata: {
expiration,
},
})
setResponseStatus(event, 201)
return { link: newLink }
}
})
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod'
export default eventHandler(async (event) => {
const { cloudflare } = event.context
const { KV } = cloudflare.env
const { limit, cursor } = await getValidatedQuery(event, z.object({
limit: z.coerce.number().max(1024).default(20),
cursor: z.string().trim().max(1024).optional(),
}).parse)
const list = await KV.list({
prefix: `link:`,
limit,
cursor,
})
if (Array.isArray(list.keys)) {
list.links = await Promise.all(list.keys.map(async (key: { name: string }) => {
const { metadata, value: link } = await KV.getWithMetadata(key.name, { type: 'json' })
if (link) {
return {
...metadata,
...link,
}
}
return link
}))
}
delete list.keys
return list
})
+18
View File
@@ -0,0 +1,18 @@
export default eventHandler(async (event) => {
const slug = getQuery(event).slug
if (slug) {
const { cloudflare } = event.context
const { KV } = cloudflare.env
const { metadata, value: link } = await KV.getWithMetadata(`link:${slug}`, { type: 'json' })
if (link) {
return {
...metadata,
...link,
}
}
}
throw createError({
status: 404,
statusText: 'Not Found',
})
})
+19
View File
@@ -0,0 +1,19 @@
import type { H3Event } from 'h3'
import { QuerySchema } from '@/schemas/query'
const { select } = SqlBricks
function query2sql(query: Query, event: H3Event): string {
const filter = query2filter(query)
const { dataset } = useRuntimeConfig(event)
// visitors did not consider sampling
const sql = select(`SUM(_sample_interval) as visits, COUNT(DISTINCT ${logsMap.ip}) as visitors, COUNT(DISTINCT ${logsMap.referer}) as referers`).from(dataset).where(filter)
appendTimeFilter(sql, query)
return sql.toString()
}
export default eventHandler(async (event) => {
const query = await getValidatedQuery(event, QuerySchema.parse)
const sql = query2sql(query, event)
return useWAE(event, sql)
})
+25
View File
@@ -0,0 +1,25 @@
import type { H3Event } from 'h3'
import { z } from 'zod'
import { QuerySchema } from '@/schemas/query'
const { select } = SqlBricks
const MetricsQuerySchema = QuerySchema.extend({
type: z.string(),
})
function query2sql(query: z.infer<typeof MetricsQuerySchema>, event: H3Event): string {
const filter = query2filter(query)
const { dataset } = useRuntimeConfig(event)
// @ts-expect-error todo
const sql = select(`${logsMap[query.type]} as name, SUM(_sample_interval) as count`).from(dataset).where(filter).groupBy('name').orderBy('count DESC').limit(query.limit)
appendTimeFilter(sql, query)
return sql.toString()
}
export default eventHandler(async (event) => {
const query = await getValidatedQuery(event, MetricsQuerySchema.parse)
const sql = query2sql(query, event)
return useWAE(event, sql)
})
+29
View File
@@ -0,0 +1,29 @@
import type { H3Event } from 'h3'
import { z } from 'zod'
import { QuerySchema } from '@/schemas/query'
const { select } = SqlBricks
const unitMap: { [x: string]: string } = {
hour: '%Y-%m-%d %H',
day: '%Y-%m-%d',
}
const ViewsQuerySchema = QuerySchema.extend({
unit: z.string(),
clientTimezone: z.string().default('Etc/UTC'),
})
function query2sql(query: z.infer<typeof ViewsQuerySchema>, event: H3Event): string {
const filter = query2filter(query)
const { dataset } = useRuntimeConfig(event)
const sql = select(`formatDateTime(timestamp, '${unitMap[query.unit]}', '${query.clientTimezone}') as time, SUM(_sample_interval) as visits, COUNT(DISTINCT ${logsMap.ip}) as visitors`).from(dataset).where(filter).groupBy('time').orderBy('time')
appendTimeFilter(sql, query)
return sql.toString()
}
export default eventHandler(async (event) => {
const query = await getValidatedQuery(event, ViewsQuerySchema.parse)
const sql = query2sql(query, event)
return useWAE(event, sql)
})
+6
View File
@@ -0,0 +1,6 @@
export default eventHandler(() => {
return {
name: 'Sink',
url: 'https://sink.cool',
}
})
+28
View File
@@ -0,0 +1,28 @@
import type { z } from 'zod'
import { parsePath } from 'ufo'
import type { LinkSchema } from '@/schemas/link'
export default eventHandler(async (event) => {
const { pathname: slug } = parsePath(event.path.slice(1)) // remove leading slash
const { slugRegex, reserveSlug } = useAppConfig(event)
const { homeURL } = useRuntimeConfig(event)
const { cloudflare } = event.context
if (event.path === '/' && homeURL)
return sendRedirect(event, homeURL)
if (slug && !reserveSlug.includes(slug) && slugRegex.test(slug) && cloudflare) {
const { KV } = cloudflare.env
const link: z.infer<typeof LinkSchema> | null = await KV.get(`link:${slug}`, { type: 'json' })
if (link) {
event.context.link = link
try {
await useAccessLog(event)
}
catch (error) {
console.error('Failed write access log:', error)
}
return sendRedirect(event, link.url, +useRuntimeConfig(event).redirectStatusCode)
}
}
})
+15
View File
@@ -0,0 +1,15 @@
export default eventHandler((event) => {
const token = getHeader(event, 'Authorization')?.replace('Bearer ', '')
if (event.path.startsWith('/api/') && !event.path.startsWith('/api/_') && token !== useRuntimeConfig(event).siteToken) {
throw createError({
status: 401,
statusText: 'Unauthorized',
})
}
if (token && token.length < 8) {
throw createError({
status: 401,
statusText: 'Token is too short',
})
}
})
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
+109
View File
@@ -0,0 +1,109 @@
import type { H3Event } from 'h3'
import { parseURL } from 'ufo'
import { UAParser } from 'ua-parser-js'
import {
Apps,
Bots,
CLIs,
Emails,
ExtraDevices,
MediaPlayers,
Modules,
} from 'ua-parser-js/extensions'
import { parseAcceptLanguage } from 'intl-parse-accept-language'
import { getFlag } from '@/utils/flag'
function toBlobNumber(blob: string) {
return +blob.replace(/\D/g, '')
}
export const blobsMap = {
blob1: 'slug',
blob2: 'url',
blob3: 'ua',
blob4: 'ip',
blob5: 'referer',
blob6: 'country',
blob7: 'region',
blob8: 'city',
blob9: 'timezone',
blob10: 'language',
blob11: 'os',
blob12: 'browser',
blob13: 'browserType',
blob14: 'device',
blob15: 'deviceType',
} as const
export type BlobsMap = typeof blobsMap
export type BlobsKey = keyof BlobsMap
export type LogsKey = BlobsMap[BlobsKey]
export type LogsMap = { [key in LogsKey]: string | undefined }
export const logsMap: LogsMap = Object.entries(blobsMap).reduce((acc, [k, v]) => ({ ...acc, [v]: k }), {}) as LogsMap
function logs2blobs(logs: LogsMap) {
// @ts-expect-error todo
return Object.keys(blobsMap).sort((a, b) => toBlobNumber(a) - toBlobNumber(b)).map(key => logs[blobsMap[key]] || '')
}
function blobs2logs(blobs: string[]) {
const logsList = Object.keys(blobsMap)
// @ts-expect-error todo
return blobs.reduce((logs: LogsMap, blob, i) => {
// @ts-expect-error todo
logs[blobsMap[logsList[i]]] = blob
return logs
}, {})
}
export function useAccessLog(event: H3Event) {
const ip = getHeader(event, 'x-real-ip') || getRequestIP(event, { xForwardedFor: true })
const { host: referer } = parseURL(getHeader(event, 'referer'))
const acceptLanguage = getHeader(event, 'accept-language') || ''
const language = (parseAcceptLanguage(acceptLanguage) || [])[0]
const userAgent = getHeader(event, 'user-agent') || ''
const uaInfo = (new UAParser(userAgent, {
browser: [Apps.browser || [], Bots.browser || [], CLIs.browser || [], Emails.browser || [], MediaPlayers.browser || [], Modules.browser || []].flat(),
device: [ExtraDevices.device || []].flat(),
})).getResult()
const { request: { cf } } = event.context.cloudflare
const link = event.context.link || {}
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
const countryName = regionNames.of(cf?.country || 'WD') // fallback to "Worldwide"
const accessLogs = {
url: link.url,
slug: link.slug,
ua: userAgent,
ip,
referer,
country: cf?.country,
region: `${getFlag(cf?.country)} ${[cf?.region, countryName].filter(Boolean).join(',')}`,
city: `${getFlag(cf?.country)} ${[cf?.city, countryName].filter(Boolean).join(',')}`,
timezone: cf?.timezone,
language,
os: uaInfo?.os?.name,
browser: uaInfo?.browser?.name,
// @ts-expect-error todo
browserType: uaInfo?.browser?.type,
device: uaInfo?.device?.model,
deviceType: uaInfo?.device?.type,
}
if (process.env.NODE_ENV === 'production') {
return hubAnalytics().put({
indexes: [link.id], // only one index
blobs: logs2blobs(accessLogs),
})
}
else {
console.log('access logs:', logs2blobs(accessLogs), blobs2logs(logs2blobs(accessLogs)))
return Promise.resolve()
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { H3Event } from 'h3'
export function useWAE(event: H3Event, query: string) {
const { cfAccountId, cfApiToken } = useRuntimeConfig(event)
console.log('useWAE', query)
return $fetch(`https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/analytics_engine/sql`, {
method: 'POST',
headers: {
Authorization: `Bearer ${cfApiToken}`,
},
body: query,
retry: 1,
retryDelay: 100, // ms
})
}
+31
View File
@@ -0,0 +1,31 @@
import type { z } from 'zod'
import type { SelectStatement } from 'sql-bricks'
import type { FilterSchema, QuerySchema } from '@/schemas/query'
export type Query = z.infer<typeof QuerySchema>
export type Filter = z.infer<typeof FilterSchema>
export function query2filter(query: Query): Filter {
const filter: Filter = {}
if (query.id)
filter.index1 = query.id
Object.keys(logsMap).forEach((key) => {
// @ts-expect-error todo
if (query[key]) {
// @ts-expect-error todo
filter[logsMap[key]] = query[key]
}
})
return filter
}
export function appendTimeFilter(sql: SelectStatement, query: Query): unknown {
if (query.startAt)
sql.where(SqlBricks.gte('timestamp', SqlBricks(`toDateTime(${query.startAt})`)))
if (query.endAt)
sql.where(SqlBricks.lte('timestamp', SqlBricks(`toDateTime(${query.endAt})`)))
return sql
}
+2
View File
@@ -0,0 +1,2 @@
// @ts-expect-error todo
export { default as SqlBricks } from 'mysql-bricks'
+13
View File
@@ -0,0 +1,13 @@
import type { H3Event } from 'h3'
export function getExpiration(event: H3Event, expiration: number | undefined) {
const { previewMode } = useRuntimeConfig(event).public
if (previewMode) {
const { previewTTL } = useAppConfig(event)
const previewExpiration = Math.floor(Date.now() / 1000) + previewTTL
if (!expiration || expiration > previewExpiration)
expiration = Math.floor(Date.now() / 1000) + previewTTL
}
return expiration
}