mirror of
https://github.com/Nezumi-2711/Sink.git
synced 2026-09-22 05:41:49 +00:00
feat: init
This commit is contained in:
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export default eventHandler(() => {
|
||||
return {
|
||||
name: 'Sink',
|
||||
url: 'https://sink.cool',
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @ts-expect-error todo
|
||||
export { default as SqlBricks } from 'mysql-bricks'
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user