refactor: unocss

This commit is contained in:
alina 🌸 2025-01-25 13:42:13 +03:00
parent 040298b3ef
commit 434a78eb90
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
94 changed files with 3895 additions and 3805 deletions

View file

@ -1,23 +1,27 @@
import { defineConfig } from 'astro/config'
import solid from '@astrojs/solid-js'
import node from '@astrojs/node' import node from '@astrojs/node'
import solid from '@astrojs/solid-js'
import { defineConfig } from 'astro/config'
import UnoCSS from 'unocss/astro'
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
output: 'server', output: 'server',
integrations: [ integrations: [
solid(), solid(),
], UnoCSS({
vite: { injectReset: true,
esbuild: { jsx: 'automatic' },
define: {
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
},
},
adapter: node({
mode: 'standalone',
}), }),
server: { ],
host: true, vite: {
esbuild: { jsx: 'automatic' },
define: {
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
}, },
},
adapter: node({
mode: 'standalone',
}),
server: {
host: true,
},
}) })

View file

@ -1,10 +1,10 @@
import type { Config } from 'drizzle-kit' import type { Config } from 'drizzle-kit'
export default { export default {
out: './drizzle', out: './drizzle',
schema: './src/backend/models/index.ts', schema: './src/backend/models/index.ts',
dialect: 'sqlite', dialect: 'sqlite',
dbCredentials: { dbCredentials: {
url: '.runtime/data.db', url: '.runtime/data.db',
}, },
} satisfies Config } satisfies Config

View file

@ -1,32 +1,26 @@
import antfu from '@antfu/eslint-config' import antfu from '@antfu/eslint-config'
export default antfu({ export default antfu({
stylistic: { ignores: [
indent: 4, 'public',
}, 'drizzle',
typescript: true, ],
astro: true, typescript: true,
solid: true, astro: true,
yaml: false, solid: true,
rules: { yaml: false,
'curly': ['error', 'multi-line'], unocss: true,
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], rules: {
'n/prefer-global/buffer': 'off', 'antfu/no-top-level-await': 'off',
'style/quotes': ['error', 'single', { avoidEscape: true }], 'curly': ['error', 'multi-line'],
'test/consistent-test-it': 'off', 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
'test/prefer-lowercase-title': 'off', 'n/prefer-global/buffer': 'off',
'import/order': ['error', { 'style/quotes': ['error', 'single', { avoidEscape: true }],
'newlines-between': 'always', 'test/consistent-test-it': 'off',
'pathGroups': [ 'test/prefer-lowercase-title': 'off',
{ 'antfu/if-newline': 'off',
pattern: '~/**', 'style/max-statements-per-line': ['error', { max: 2 }],
group: 'parent', 'ts/no-redeclare': 'off',
}, 'node/prefer-global/process': 'off',
], },
}],
'antfu/if-newline': 'off',
'style/max-statements-per-line': ['error', { max: 2 }],
'ts/no-redeclare': 'off',
'node/prefer-global/process': 'off',
},
}) })

View file

@ -1,49 +1,52 @@
{ {
"name": "tei.su", "name": "tei.su",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903", "packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"start:prod": "drizzle-kit migrate && node dist/server/entry.mjs", "start:prod": "drizzle-kit migrate && node dist/server/entry.mjs",
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@astrojs/node": "^9.0.2", "@astrojs/node": "^9.0.2",
"@astrojs/solid-js": "^5.0.4", "@astrojs/solid-js": "^5.0.4",
"@fuman/fetch": "0.0.10", "@fuman/fetch": "0.0.10",
"@fuman/utils": "0.0.10", "@fuman/utils": "0.0.10",
"@mtcute/dispatcher": "^0.17.0", "@iconify-json/gravity-ui": "^1.2.4",
"@mtcute/node": "^0.17.0", "@mtcute/dispatcher": "^0.17.0",
"@tanstack/solid-query": "^5.51.21", "@mtcute/node": "^0.17.0",
"astro": "^5.1.9", "@tanstack/solid-query": "^5.51.21",
"astro-loading-indicator": "^0.5.0", "@unocss/postcss": "^65.4.3",
"better-sqlite3": "^11.1.2", "@unocss/reset": "^65.4.3",
"clsx": "^2.1.1", "astro": "^5.1.9",
"date-fns": "^3.6.0", "astro-loading-indicator": "0.7.0",
"dotenv": "^16.4.5", "better-sqlite3": "^11.1.2",
"drizzle-kit": "^0.23.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.32.1", "date-fns": "^3.6.0",
"parse-duration": "^1.1.0", "dotenv": "^16.4.5",
"rate-limiter-flexible": "^5.0.3", "drizzle-kit": "^0.23.1",
"solid-js": "^1.8.19", "drizzle-orm": "^0.32.1",
"typescript": "^5.7.3", "parse-duration": "^1.1.0",
"zod": "^3.23.8", "rate-limiter-flexible": "^5.0.3",
"zod-validation-error": "^3.3.1" "solid-js": "^1.8.19",
}, "tailwind-merge": "^2.6.0",
"devDependencies": { "typescript": "^5.7.3",
"@antfu/eslint-config": "^2.24.0", "unocss": "^65.4.3",
"@types/better-sqlite3": "^7.6.11", "zod": "^3.23.8",
"@types/node": "^22.0.2", "zod-validation-error": "^3.3.1"
"eslint-plugin-astro": "^1.2.3", },
"eslint-plugin-solid": "0.14", "devDependencies": {
"postcss-custom-media": "^10.0.8", "@antfu/eslint-config": "3.16.0",
"postcss-import": "^16.1.0", "@types/better-sqlite3": "^7.6.11",
"postcss-mixins": "^10.0.1", "@types/node": "^22.0.2",
"postcss-nesting": "^12.1.5" "@unocss/eslint-plugin": "^65.4.3",
} "eslint": "9.19.0",
"eslint-plugin-astro": "^1.2.3",
"eslint-plugin-solid": "0.14.5"
}
} }

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
export default {
plugins: {
'postcss-import': {},
'postcss-mixins': {},
'postcss-custom-media': {},
'postcss-nesting': {},
},
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
import UnoCSS from '@unocss/postcss'
export default {
plugins: [
UnoCSS(),
],
}

View file

@ -6,9 +6,9 @@ import { env } from '~/backend/env'
import { shoutboxDp } from './bot/shoutbox' import { shoutboxDp } from './bot/shoutbox'
export const tg = new TelegramClient({ export const tg = new TelegramClient({
apiId: env.TG_API_ID, apiId: env.TG_API_ID,
apiHash: env.TG_API_HASH, apiHash: env.TG_API_HASH,
storage: '.runtime/bot.session', storage: '.runtime/bot.session',
}) })
const dp = Dispatcher.for(tg) const dp = Dispatcher.for(tg)

View file

@ -4,8 +4,8 @@ import { tg } from '~/backend/bot'
import { env } from '~/backend/env' import { env } from '~/backend/env'
export function telegramNotify(text: InputText, options?: Parameters<TelegramClient['sendText']>[2]): void { export function telegramNotify(text: InputText, options?: Parameters<TelegramClient['sendText']>[2]): void {
tg.sendText(env.TG_CHAT_ID, text, { tg.sendText(env.TG_CHAT_ID, text, {
disableWebPreview: true, disableWebPreview: true,
...options, ...options,
}).catch(console.error) }).catch(console.error)
} }

View file

@ -1,16 +1,16 @@
import { assertMatches } from '@fuman/utils'
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher' import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
import { html } from '@mtcute/node' import { html } from '@mtcute/node'
import parseDuration from 'parse-duration' import parseDuration from 'parse-duration'
import { assertMatches } from '@fuman/utils'
import { env } from '../env' import { env } from '../env'
import { import {
answerBySerial, answerBySerial,
approveShout, approveShout,
banShouts, banShouts,
declineShout, declineShout,
deleteBySerial, deleteBySerial,
unbanShouts, unbanShouts,
} from '../service/shoutbox' } from '../service/shoutbox'
export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action') export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action')
@ -18,83 +18,83 @@ export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action'
const dp = Dispatcher.child() const dp = Dispatcher.child()
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'approve' }), async (ctx) => { dp.onCallbackQuery(ShoutboxAction.filter({ action: 'approve' }), async (ctx) => {
if (ctx.chat.id !== env.TG_CHAT_ID) return if (ctx.chat.id !== env.TG_CHAT_ID) return
const serial = approveShout(ctx.match.id) const serial = approveShout(ctx.match.id)
await ctx.editMessageWith(msg => ({ await ctx.editMessageWith(msg => ({
text: html`${msg.textWithEntities}<br><br>✅ Approved! ID: <code>${serial}</code>`, text: html`${msg.textWithEntities}<br><br>✅ Approved! ID: <code>${serial}</code>`,
})) }))
}) })
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'decline' }), async (ctx) => { dp.onCallbackQuery(ShoutboxAction.filter({ action: 'decline' }), async (ctx) => {
if (ctx.chat.id !== env.TG_CHAT_ID) return if (ctx.chat.id !== env.TG_CHAT_ID) return
declineShout(ctx.match.id) declineShout(ctx.match.id)
await ctx.editMessageWith(msg => ({ await ctx.editMessageWith(msg => ({
text: html`${msg.textWithEntities}<br><br>❌ Declined!`, text: html`${msg.textWithEntities}<br><br>❌ Declined!`,
})) }))
}) })
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_del')), async (ctx) => { dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_del')), async (ctx) => {
const serial = Number(ctx.command[1]) const serial = Number(ctx.command[1])
if (Number.isNaN(serial)) { if (Number.isNaN(serial)) {
await ctx.answerText('invalid serial') await ctx.answerText('invalid serial')
return return
} }
deleteBySerial(serial) deleteBySerial(serial)
await ctx.answerText('deleted') await ctx.answerText('deleted')
}) })
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_ban')), async (ctx) => { dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_ban')), async (ctx) => {
const ip = ctx.command[1] const ip = ctx.command[1]
const duration = parseDuration(ctx.command[2]) const duration = parseDuration(ctx.command[2])
if (!duration) { if (!duration) {
await ctx.answerText('invalid duration') await ctx.answerText('invalid duration')
return return
} }
const until = Date.now() + duration const until = Date.now() + duration
banShouts(ip, until) banShouts(ip, until)
await ctx.answerText(`banned ${ip} until ${new Date(until).toISOString()}`) await ctx.answerText(`banned ${ip} until ${new Date(until).toISOString()}`)
}) })
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_unban')), async (ctx) => { dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_unban')), async (ctx) => {
const ip = ctx.command[1] const ip = ctx.command[1]
unbanShouts(ip) unbanShouts(ip)
await ctx.answerText('done') await ctx.answerText('done')
}) })
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_reply')), async (ctx) => { dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_reply')), async (ctx) => {
const serial = Number(ctx.command[1]) const serial = Number(ctx.command[1])
if (Number.isNaN(serial)) { if (Number.isNaN(serial)) {
await ctx.answerText('invalid serial') await ctx.answerText('invalid serial')
return return
} }
answerBySerial(serial, ctx.command[2]) answerBySerial(serial, ctx.command[2])
await ctx.answerText('done') await ctx.answerText('done')
}) })
// eslint-disable-next-line regexp/no-unused-capturing-group // eslint-disable-next-line regexp/no-unused-capturing-group
const APPROVED_REGEX = /Approved! ID: (\d+)/ const APPROVED_REGEX = /Approved! ID: (\d+)/
dp.onNewMessage( dp.onNewMessage(
filters.replyTo( filters.replyTo(
msg => APPROVED_REGEX.test(msg.text), msg => APPROVED_REGEX.test(msg.text),
), ),
async (ctx) => { async (ctx) => {
const msg = await ctx.getReplyTo() const msg = await ctx.getReplyTo()
const serial = assertMatches(msg.text, APPROVED_REGEX)[1] const serial = assertMatches(msg.text, APPROVED_REGEX)[1]
answerBySerial(Number(serial), ctx.text) answerBySerial(Number(serial), ctx.text)
await ctx.answerText('reply sent') await ctx.answerText('reply sent')
}, },
) )
export { dp as shoutboxDp } export { dp as shoutboxDp }

View file

@ -1,244 +1,254 @@
import { z } from 'zod' import { z } from 'zod'
const UserSchema = z.object({ const UserSchema = z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
name: z.string().optional().nullable(), name: z.string().optional().nullable(),
username: z.string().optional().nullable(), username: z.string().optional().nullable(),
host: z host: z
.string() .string()
.describe('The local host is represented with `null`.') .describe('The local host is represented with `null`.')
.optional().nullable(), .optional()
avatarUrl: z.string().optional().nullable(), .nullable(),
avatarBlurhash: z.string().optional().nullable(), avatarUrl: z.string().optional().nullable(),
avatarDecorations: z avatarBlurhash: z.string().optional().nullable(),
.array( avatarDecorations: z
z.object({ .array(
id: z.string().optional().nullable(), z.object({
angle: z.number().optional().nullable(), id: z.string().optional().nullable(),
flipH: z.boolean().optional().nullable(), angle: z.number().optional().nullable(),
url: z.string().optional().nullable(), flipH: z.boolean().optional().nullable(),
offsetX: z.number().optional().nullable(), url: z.string().optional().nullable(),
offsetY: z.number().optional().nullable(), offsetX: z.number().optional().nullable(),
}), offsetY: z.number().optional().nullable(),
) }),
.optional().nullable(), )
isAdmin: z.boolean().optional().nullable(), .optional()
isModerator: z.boolean().optional().nullable(), .nullable(),
isSilenced: z.boolean().optional().nullable(), isAdmin: z.boolean().optional().nullable(),
noindex: z.boolean().optional().nullable(), isModerator: z.boolean().optional().nullable(),
isBot: z.boolean().optional().nullable(), isSilenced: z.boolean().optional().nullable(),
isCat: z.boolean().optional().nullable(), noindex: z.boolean().optional().nullable(),
speakAsCat: z.boolean().optional().nullable(), isBot: z.boolean().optional().nullable(),
instance: z isCat: z.boolean().optional().nullable(),
.object({ speakAsCat: z.boolean().optional().nullable(),
name: z.string().optional().nullable(), instance: z
softwareName: z.string().optional().nullable(), .object({
softwareVersion: z.string().optional().nullable(), name: z.string().optional().nullable(),
iconUrl: z.string().optional().nullable(), softwareName: z.string().optional().nullable(),
faviconUrl: z.string().optional().nullable(), softwareVersion: z.string().optional().nullable(),
themeColor: z.string().optional().nullable(), iconUrl: z.string().optional().nullable(),
}) faviconUrl: z.string().optional().nullable(),
.optional().nullable(), themeColor: z.string().optional().nullable(),
emojis: z.record(z.string()).optional().nullable(), })
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(), .optional()
badgeRoles: z .nullable(),
.array( emojis: z.record(z.string()).optional().nullable(),
z.object({ onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
name: z.string().optional().nullable(), badgeRoles: z
iconUrl: z.string().optional().nullable(), .array(
displayOrder: z.number().optional().nullable(), z.object({
}), name: z.string().optional().nullable(),
) iconUrl: z.string().optional().nullable(),
.optional().nullable(), displayOrder: z.number().optional().nullable(),
}),
)
.optional()
.nullable(),
}) })
export type MkUser = z.infer<typeof UserSchema> export type MkUser = z.infer<typeof UserSchema>
const NoteSchema = z.object({ const NoteSchema = z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().optional().nullable(), createdAt: z.string().optional().nullable(),
deletedAt: z.string().optional().nullable(), deletedAt: z.string().optional().nullable(),
text: z.string().optional().nullable(), text: z.string().optional().nullable(),
cw: z.string().optional().nullable(), cw: z.string().optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
user: z user: z
.object({}) .object({})
.catchall(z.any()) .catchall(z.any())
.optional().nullable(), .optional()
replyId: z.string().optional().nullable(), .nullable(),
renoteId: z.string().optional().nullable(), replyId: z.string().optional().nullable(),
reply: z renoteId: z.string().optional().nullable(),
.object({}) reply: z
.catchall(z.any()) .object({})
.optional().nullable(), .catchall(z.any())
renote: z .optional()
.object({}) .nullable(),
.catchall(z.any()) renote: z
.optional().nullable(), .object({})
isHidden: z.boolean().optional().nullable(), .catchall(z.any())
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(), .optional()
mentions: z.array(z.string()).optional().nullable(), .nullable(),
visibleUserIds: z.array(z.string()).optional().nullable(), isHidden: z.boolean().optional().nullable(),
fileIds: z.array(z.string()).optional().nullable(), visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
files: z.array(z.object({}).catchall(z.any())).optional().nullable(), mentions: z.array(z.string()).optional().nullable(),
tags: z.array(z.string()).optional().nullable(), visibleUserIds: z.array(z.string()).optional().nullable(),
poll: z fileIds: z.array(z.string()).optional().nullable(),
.object({ files: z.array(z.object({}).catchall(z.any())).optional().nullable(),
expiresAt: z.string().optional().nullable(), tags: z.array(z.string()).optional().nullable(),
multiple: z.boolean().optional().nullable(), poll: z
choices: z .object({
.array( expiresAt: z.string().optional().nullable(),
z.object({ multiple: z.boolean().optional().nullable(),
isVoted: z.boolean().optional().nullable(), choices: z
text: z.string().optional().nullable(), .array(
votes: z.number().optional().nullable(), z.object({
}), isVoted: z.boolean().optional().nullable(),
) text: z.string().optional().nullable(),
.optional().nullable(), votes: z.number().optional().nullable(),
}) }),
.optional().nullable(), )
emojis: z.record(z.string(), z.any()).optional().nullable(), .optional()
channelId: z.string().optional().nullable(), .nullable(),
channel: z })
.object({ .optional()
id: z.string().optional().nullable(), .nullable(),
name: z.string().optional().nullable(), emojis: z.record(z.string(), z.any()).optional().nullable(),
color: z.string().optional().nullable(), channelId: z.string().optional().nullable(),
isSensitive: z.boolean().optional().nullable(), channel: z
allowRenoteToExternal: z.boolean().optional().nullable(), .object({
userId: z.string().optional().nullable(), id: z.string().optional().nullable(),
}) name: z.string().optional().nullable(),
.optional().nullable(), color: z.string().optional().nullable(),
localOnly: z.boolean().optional().nullable(), isSensitive: z.boolean().optional().nullable(),
reactionAcceptance: z.string().optional().nullable(), allowRenoteToExternal: z.boolean().optional().nullable(),
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(), userId: z.string().optional().nullable(),
reactions: z.record(z.string(), z.number()).optional().nullable(), })
renoteCount: z.number().optional().nullable(), .optional()
repliesCount: z.number().optional().nullable(), .nullable(),
uri: z.string().optional().nullable(), localOnly: z.boolean().optional().nullable(),
url: z.string().optional().nullable(), reactionAcceptance: z.string().optional().nullable(),
reactionAndUserPairCache: z.array(z.string()).optional().nullable(), reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
clippedCount: z.number().optional().nullable(), reactions: z.record(z.string(), z.number()).optional().nullable(),
myReaction: z.string().optional().nullable(), renoteCount: z.number().optional().nullable(),
repliesCount: z.number().optional().nullable(),
uri: z.string().optional().nullable(),
url: z.string().optional().nullable(),
reactionAndUserPairCache: z.array(z.string()).optional().nullable(),
clippedCount: z.number().optional().nullable(),
myReaction: z.string().optional().nullable(),
}) })
export type MkNote = z.infer<typeof NoteSchema> export type MkNote = z.infer<typeof NoteSchema>
const NotificationSchema = z.union([ const NotificationSchema = z.union([
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('note').optional().nullable(), type: z.literal('note').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('mention').optional().nullable(), type: z.literal('mention').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('reply').optional().nullable(), type: z.literal('reply').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('renote').optional().nullable(), type: z.literal('renote').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('quote').optional().nullable(), type: z.literal('quote').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('reaction').optional().nullable(), type: z.literal('reaction').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
reaction: z.string().optional().nullable(), reaction: z.string().optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('pollEnded').optional().nullable(), type: z.literal('pollEnded').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.union([z.literal('follow'), z.literal('unfollow')]).optional().nullable(), type: z.union([z.literal('follow'), z.literal('unfollow')]).optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('receiveFollowRequest').optional().nullable(), type: z.literal('receiveFollowRequest').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('followRequestAccepted').optional().nullable(), type: z.literal('followRequestAccepted').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('roleAssigned').optional().nullable(), type: z.literal('roleAssigned').optional().nullable(),
role: z.record(z.any()).optional().nullable(), role: z.record(z.any()).optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('achievementEarned').optional().nullable(), type: z.literal('achievementEarned').optional().nullable(),
achievement: z.string().optional().nullable(), achievement: z.string().optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('app').optional().nullable(), type: z.literal('app').optional().nullable(),
body: z.string().optional().nullable(), body: z.string().optional().nullable(),
header: z.string().optional().nullable(), header: z.string().optional().nullable(),
icon: z.string().optional().nullable(), icon: z.string().optional().nullable(),
}), }),
z.object({ z.object({
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(), createdAt: z.string().datetime().optional().nullable(),
type: z.literal('edited').optional().nullable(), type: z.literal('edited').optional().nullable(),
user: UserSchema.optional().nullable(), user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(), note: NoteSchema.optional().nullable(),
}), }),
] as const) ] as const)
export const MisskeyWebhookBodySchema = z.object({ export const MisskeyWebhookBodySchema = z.object({
server: z.string(), server: z.string(),
hookId: z.string(), hookId: z.string(),
userId: z.string(), userId: z.string(),
eventId: z.string(), eventId: z.string(),
createdAt: z.number(), createdAt: z.number(),
type: z.string(), type: z.string(),
body: z.object({ body: z.object({
notification: NotificationSchema, notification: NotificationSchema,
}).partial(), }).partial(),
}) })

View file

@ -1,23 +1,23 @@
import 'dotenv/config'
import { z } from 'zod' import { z } from 'zod'
import { zodValidateSync } from '~/utils/zod' import { zodValidateSync } from '~/utils/zod'
import 'dotenv/config'
export const env = zodValidateSync( export const env = zodValidateSync(
z.object({ z.object({
UMAMI_HOST: z.string().url(), UMAMI_HOST: z.string().url(),
UMAMI_TOKEN: z.string(), UMAMI_TOKEN: z.string(),
UMAMI_SITE_ID: z.string().uuid(), UMAMI_SITE_ID: z.string().uuid(),
TG_API_ID: z.coerce.number(), TG_API_ID: z.coerce.number(),
TG_API_HASH: z.string(), TG_API_HASH: z.string(),
TG_BOT_TOKEN: z.string(), TG_BOT_TOKEN: z.string(),
TG_CHAT_ID: z.coerce.number(), TG_CHAT_ID: z.coerce.number(),
CURRENCY_API_TOKEN: z.string(), CURRENCY_API_TOKEN: z.string(),
FAKE_DEEPL_SECRET: z.string(), FAKE_DEEPL_SECRET: z.string(),
MK_WEBHOOK_SECRET: z.string(), MK_WEBHOOK_SECRET: z.string(),
QBT_WEBHOOK_SECRET: z.string(), QBT_WEBHOOK_SECRET: z.string(),
CSRF_SECRET: z.string(), CSRF_SECRET: z.string(),
}), }),
process.env, process.env,
) )

View file

@ -4,16 +4,16 @@ import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const shouts = sqliteTable('shouts', { export const shouts = sqliteTable('shouts', {
id: text('id').primaryKey().$defaultFn(randomUUID), id: text('id').primaryKey().$defaultFn(randomUUID),
serial: integer('serial').notNull().default(0), serial: integer('serial').notNull().default(0),
fromIp: text('from_ip'), fromIp: text('from_ip'),
pending: integer('pending', { mode: 'boolean' }).notNull().default(true), pending: integer('pending', { mode: 'boolean' }).notNull().default(true),
text: text('text'), text: text('text'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`), createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
reply: text('reply'), reply: text('reply'),
}) })
export const shoutsBans = sqliteTable('shouts_bans', { export const shoutsBans = sqliteTable('shouts_bans', {
ip: text('ip').primaryKey(), ip: text('ip').primaryKey(),
expires: integer('expires').notNull(), expires: integer('expires').notNull(),
}) })

View file

@ -1,5 +1,5 @@
import { z } from 'zod'
import { AsyncResource } from '@fuman/utils' import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { env } from '../env' import { env } from '../env'
import { ffetch } from '../utils/fetch.ts' import { ffetch } from '../utils/fetch.ts'
@ -8,52 +8,52 @@ export const AVAILABLE_CURRENCIES = ['RUB', 'USD', 'EUR']
const TTL = 60 * 60 * 1000 // 1 hour const TTL = 60 * 60 * 1000 // 1 hour
const schema = z.object({ const schema = z.object({
meta: z.object({ meta: z.object({
last_updated_at: z.string(), last_updated_at: z.string(),
}), }),
data: z.record(z.string(), z.object({ data: z.record(z.string(), z.object({
code: z.string(), code: z.string(),
value: z.number(), value: z.number(),
})), })),
}) })
const reloadable = new AsyncResource<z.infer<typeof schema>>({ const reloadable = new AsyncResource<z.infer<typeof schema>>({
// expiresIn: () => TTL, // expiresIn: () => TTL,
async fetcher() { async fetcher() {
// https://api.currencyapi.com/v3/latest?apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6&currencies=USD%2CEUR // https://api.currencyapi.com/v3/latest?apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6&currencies=USD%2CEUR
// apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6&currencies=USD%2CEUR // apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6&currencies=USD%2CEUR
const res = await ffetch('https://api.currencyapi.com/v3/latest', { const res = await ffetch('https://api.currencyapi.com/v3/latest', {
query: { query: {
apikey: env.CURRENCY_API_TOKEN, apikey: env.CURRENCY_API_TOKEN,
currencies: AVAILABLE_CURRENCIES.slice(1).join(','), currencies: AVAILABLE_CURRENCIES.slice(1).join(','),
base_currency: AVAILABLE_CURRENCIES[0], base_currency: AVAILABLE_CURRENCIES[0],
}, },
}).parsedJson(schema) }).parsedJson(schema)
return { return {
data: res, data: res,
expiresIn: TTL, expiresIn: TTL,
} }
}, },
swr: true, swr: true,
}) })
export function convertCurrencySync(from: string, to: string, amount: number) { export function convertCurrencySync(from: string, to: string, amount: number) {
if (from === to) return amount if (from === to) return amount
if (!AVAILABLE_CURRENCIES.includes(from)) throw new Error(`Invalid currency: ${from}`) if (!AVAILABLE_CURRENCIES.includes(from)) throw new Error(`Invalid currency: ${from}`)
if (!AVAILABLE_CURRENCIES.includes(to)) throw new Error(`Invalid currency: ${to}`) if (!AVAILABLE_CURRENCIES.includes(to)) throw new Error(`Invalid currency: ${to}`)
const data = reloadable.getCached() const data = reloadable.getCached()
if (!data) throw new Error('currencies not available') if (!data) throw new Error('currencies not available')
if (from !== AVAILABLE_CURRENCIES[0]) { if (from !== AVAILABLE_CURRENCIES[0]) {
// convert to base currency first // convert to base currency first
amount /= data.data[from].value amount /= data.data[from].value
} }
return amount * data.data[to].value return amount * data.data[to].value
} }
export async function fetchConvertRates() { export async function fetchConvertRates() {
await reloadable.get() await reloadable.get()
} }

View file

@ -1,118 +1,118 @@
import { randomPick } from '~/utils/random' import { randomPick } from '~/utils/random'
const USER_AGENTS = [ const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-N960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; SM-N960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; LM-X420) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; LM-X420) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; LM-Q710(FGN)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 10; LM-Q710(FGN)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50', 'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50', 'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
'Mozilla/5.0 (Linux; Android 10; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50', 'Mozilla/5.0 (Linux; Android 10; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 EdgiOS/100.1185.50 Mobile/15E148 Safari/605.1.15', 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 EdgiOS/100.1185.50 Mobile/15E148 Safari/605.1.15',
'Mozilla/5.0 (Windows Mobile 10; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Mobile Safari/537.36 Edge/40.15254.603', 'Mozilla/5.0 (Windows Mobile 10; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Mobile Safari/537.36 Edge/40.15254.603',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (iPad; CPU OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPod touch; CPU iPhone 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (iPod touch; CPU iPhone 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
] ]
const Tk = { const Tk = {
ac(input: string) { ac(input: string) {
const e = new TextEncoder().encode(input) const e = new TextEncoder().encode(input)
let f = 0 let f = 0
let a = 0 let a = 0
for (f = 0; f < e.length; f++) { for (f = 0; f < e.length; f++) {
a += e[f] a += e[f]
a = Tk.yc(a, '+-a^+6') a = Tk.yc(a, '+-a^+6')
} }
a = Tk.yc(a, '+-3^+b+-f') a = Tk.yc(a, '+-3^+b+-f')
a ^= 0 a ^= 0
if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 } if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 }
a %= 1e6 a %= 1e6
return `${a}.${a}` return `${a}.${a}`
}, },
yc(a: number, b: string) { yc(a: number, b: string) {
for (let c = 0; c < b.length - 2; c += 3) { for (let c = 0; c < b.length - 2; c += 3) {
const d = b[c + 2] const d = b[c + 2]
const number = d >= 'a' const number = d >= 'a'
// @ts-expect-error lol // @ts-expect-error lol
? d - 87 ? d - 87
: Number.parseInt(d) : Number.parseInt(d)
const number2 = b[c + 1] === '+' const number2 = b[c + 1] === '+'
? a >>> number ? a >>> number
: a << number : a << number
a = b[c] === '+' a = b[c] === '+'
? a + number2 & 0xFFFFFFFF ? a + number2 & 0xFFFFFFFF
: a ^ number2 : a ^ number2
} }
return a return a
}, },
} }
async function translate(text: string, fromLanguage: string, toLanguage: string) { async function translate(text: string, fromLanguage: string, toLanguage: string) {
let json = null let json = null
const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&' const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&'
+ `sl=${encodeURIComponent(fromLanguage)}&tl=${encodeURIComponent(toLanguage)}&dt=t&ie=UTF-8&` + `sl=${encodeURIComponent(fromLanguage)}&tl=${encodeURIComponent(toLanguage)}&dt=t&ie=UTF-8&`
+ 'oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss' + 'oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss'
+ `&tk=${Tk.ac(text)}` + `&tk=${Tk.ac(text)}`
+ '&source=input' + '&source=input'
+ `&q=${encodeURIComponent(text)}`, { + `&q=${encodeURIComponent(text)}`, {
headers: { headers: {
'User-Agent': randomPick(USER_AGENTS), 'User-Agent': randomPick(USER_AGENTS),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
if (!response.ok) { if (!response.ok) {
throw new Error('Error while requesting translation') throw new Error('Error while requesting translation')
} }
const content = await response.text() const content = await response.text()
json = JSON.parse(content) json = JSON.parse(content)
let sourceLanguage = null let sourceLanguage = null
sourceLanguage = json[2] sourceLanguage = json[2]
let result = '' let result = ''
for (let i = 0; i < json[0]?.length; ++i) { for (let i = 0; i < json[0]?.length; ++i) {
const block = json[0][i][0] const block = json[0][i][0]
if (block == null) { continue } if (block == null) { continue }
const blockText = block.toString() const blockText = block.toString()
if (blockText !== 'null') { result += blockText } if (blockText !== 'null') { result += blockText }
} }
return { return {
sourceLanguage, sourceLanguage,
originalText: text, originalText: text,
translatedText: result, translatedText: result,
} }
} }
export async function translateChunked(text: string, fromLanguage: string, toLanguage: string) { export async function translateChunked(text: string, fromLanguage: string, toLanguage: string) {
let result = '' let result = ''
const chunks = text.match(/.{1,5000}/gs)! const chunks = text.match(/.{1,5000}/gs)!
const promises = [] const promises = []
for (let i = 0; i < chunks.length; ++i) { for (let i = 0; i < chunks.length; ++i) {
promises.push(translate(chunks[i], fromLanguage, toLanguage)) promises.push(translate(chunks[i], fromLanguage, toLanguage))
} }
const results = await Promise.all(promises) const results = await Promise.all(promises)
for (let i = 0; i < results.length; ++i) { for (let i = 0; i < results.length; ++i) {
result += results[i].translatedText result += results[i].translatedText
} }
return { return {
sourceLanguage: results[0].sourceLanguage, sourceLanguage: results[0].sourceLanguage,
originalText: text, originalText: text,
translatedText: result, translatedText: result,
} }
} }

View file

@ -1,57 +1,57 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils' import { AsyncResource } from '@fuman/utils'
import { z } from 'zod' import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts' import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed' const ENDPOINT = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed'
const TTL = 3 * 60 * 60 * 1000 // 3 hours const TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
const schema = z.object({ const schema = z.object({
uri: z.string(), uri: z.string(),
record: z.object({ record: z.object({
text: z.string(), text: z.string(),
createdAt: z.string(), createdAt: z.string(),
}), }),
}) })
export const bskyLastSeen = new AsyncResource<LastSeenItem | null>({ export const bskyLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() { async fetcher() {
const res = await ffetch(ENDPOINT, { const res = await ffetch(ENDPOINT, {
query: { query: {
actor: 'did:web:tei.su', actor: 'did:web:tei.su',
filter: 'posts_and_author_threads', filter: 'posts_and_author_threads',
limit: 1, limit: 1,
}, },
}).parsedJson(z.object({ }).parsedJson(z.object({
feed: z.array(z.object({ feed: z.array(z.object({
post: schema, post: schema,
})), })),
})) }))
const post = res.feed[0].post const post = res.feed[0].post
const postId = post.uri.match(/at:\/\/did:web:tei.su\/app\.bsky\.feed\.post\/([a-zA-Z0-9]+)/) const postId = post.uri.match(/at:\/\/did:web:tei.su\/app\.bsky\.feed\.post\/([a-zA-Z0-9]+)/)
if (postId) { if (postId) {
return { return {
data: { data: {
source: 'bsky', source: 'bsky',
sourceLink: 'https://bsky.app/profile/did:web:tei.su', sourceLink: 'https://bsky.app/profile/did:web:tei.su',
time: new Date(post.record.createdAt).getTime(), time: new Date(post.record.createdAt).getTime(),
text: post.record.text.slice(0, 40) || '[no text]', text: post.record.text.slice(0, 40) || '[no text]',
link: `https://bsky.app/profile/did:web:tei.su/post/${postId[1]}`, link: `https://bsky.app/profile/did:web:tei.su/post/${postId[1]}`,
}, },
expiresIn: TTL, expiresIn: TTL,
} }
} }
return { return {
data: null, data: null,
expiresIn: TTL, expiresIn: TTL,
} }
}, },
swr: true, swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL, swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
}) })

View file

@ -1,80 +1,80 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils' import { AsyncResource } from '@fuman/utils'
import { z } from 'zod' import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts' import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://git.stupid.fish/api/v1/users/teidesu/activities/feeds?only-performed-by=true' const ENDPOINT = 'https://git.stupid.fish/api/v1/users/teidesu/activities/feeds?only-performed-by=true'
const TTL = 1 * 60 * 60 * 1000 // 1 hour const TTL = 1 * 60 * 60 * 1000 // 1 hour
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
const schema = z.object({ const schema = z.object({
id: z.number(), id: z.number(),
// create_repo,rename_repo,star_repo,watch_repo,commit_repo,create_issue,create_pull_request,transfer_repo,push_tag,comment_issue, // create_repo,rename_repo,star_repo,watch_repo,commit_repo,create_issue,create_pull_request,transfer_repo,push_tag,comment_issue,
// merge_pull_request,close_issue,reopen_issue,close_pull_request,reopen_pull_request,delete_tag,delete_branch,mirror_sync_push, // merge_pull_request,close_issue,reopen_issue,close_pull_request,reopen_pull_request,delete_tag,delete_branch,mirror_sync_push,
// mirror_sync_create,mirror_sync_delete,approve_pull_request,reject_pull_request,comment_pull,publish_release,pull_review_dismissed, // mirror_sync_create,mirror_sync_delete,approve_pull_request,reject_pull_request,comment_pull,publish_release,pull_review_dismissed,
// pull_request_ready_for_review,auto_merge_pull_request // pull_request_ready_for_review,auto_merge_pull_request
op_type: z.string(), op_type: z.string(),
content: z.string(), content: z.string(),
repo: z.object({ repo: z.object({
full_name: z.string(), full_name: z.string(),
html_url: z.string(), html_url: z.string(),
}), }),
created: z.string(), created: z.string(),
}) })
// {"Commits":[{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"}],"HeadCommit":{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"},"CompareURL":"teidesu/mtcute/compare/db9b083d3595e78240146e8a590fe70049259612...2ee71666823761350bd28a0db9ef9140cd3814cb","Len":1} // {"Commits":[{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"}],"HeadCommit":{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"},"CompareURL":"teidesu/mtcute/compare/db9b083d3595e78240146e8a590fe70049259612...2ee71666823761350bd28a0db9ef9140cd3814cb","Len":1}
const CommitEventSchema = z.object({ const CommitEventSchema = z.object({
Commits: z.array(z.object({ Commits: z.array(z.object({
Message: z.string(), Message: z.string(),
})), })),
}) })
function mkItem(item: z.infer<typeof schema>, text: string) { function mkItem(item: z.infer<typeof schema>, text: string) {
return { return {
source: 'forgejo', source: 'forgejo',
sourceLink: 'https://git.stupid.fish/teidesu', sourceLink: 'https://git.stupid.fish/teidesu',
time: new Date(item.created).getTime(), time: new Date(item.created).getTime(),
text: item.repo.full_name, text: item.repo.full_name,
link: item.repo.html_url, link: item.repo.html_url,
suffix: `: ${text}`, suffix: `: ${text}`,
} }
} }
export const forgejoLastSeen = new AsyncResource<LastSeenItem | null>({ export const forgejoLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() { async fetcher() {
const res = await ffetch(ENDPOINT).parsedJson(z.array(schema)) const res = await ffetch(ENDPOINT).parsedJson(z.array(schema))
// for simplicity (and lack of proper documentation) we'll just support a few common events and return the first supported one // for simplicity (and lack of proper documentation) we'll just support a few common events and return the first supported one
let result: LastSeenItem | null = null let result: LastSeenItem | null = null
for (const item of res) { for (const item of res) {
if (item.op_type === 'commit_repo') { if (item.op_type === 'commit_repo') {
const commits = CommitEventSchema.parse(JSON.parse(item.content)).Commits const commits = CommitEventSchema.parse(JSON.parse(item.content)).Commits
result = mkItem( result = mkItem(
item, item,
commits.length === 1 && commits[0].Message.length > 0 commits.length === 1 && commits[0].Message.length > 0
? `${commits[0].Message.slice(0, 40)}` ? `${commits[0].Message.slice(0, 40)}`
: `pushed ${commits.length} commits`, : `pushed ${commits.length} commits`,
) )
break break
} else if (item.op_type === 'close_pull_request') { } else if (item.op_type === 'close_pull_request') {
result = mkItem(item, 'closed pull request') result = mkItem(item, 'closed pull request')
break break
} else if (item.op_type === 'merge_pull_request') { } else if (item.op_type === 'merge_pull_request') {
result = mkItem(item, 'merged pull request') result = mkItem(item, 'merged pull request')
break break
} }
} }
return { return {
data: result, data: result,
expiresIn: TTL, expiresIn: TTL,
} }
}, },
swr: true, swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL, swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
}) })

View file

@ -1,67 +1,67 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils' import { AsyncResource } from '@fuman/utils'
import { z } from 'zod' import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts' import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://api.github.com/users/teidesu/events/public?per_page=1' const ENDPOINT = 'https://api.github.com/users/teidesu/events/public?per_page=1'
const TTL = 1 * 60 * 60 * 1000 // 1 hour const TTL = 1 * 60 * 60 * 1000 // 1 hour
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
const schema = z.object({ const schema = z.object({
id: z.string(), id: z.string(),
type: z.string(), type: z.string(),
payload: z.any(), payload: z.any(),
repo: z.object({ name: z.string(), url: z.string() }), repo: z.object({ name: z.string(), url: z.string() }),
public: z.boolean(), public: z.boolean(),
created_at: z.string(), created_at: z.string(),
}) })
export const githubLastSeen = new AsyncResource<LastSeenItem | null>({ export const githubLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() { async fetcher() {
const res = await ffetch(ENDPOINT, { const res = await ffetch(ENDPOINT, {
headers: { headers: {
'User-Agent': 'tei.su/1.0', 'User-Agent': 'tei.su/1.0',
'X-GitHub-Api-Version': '2022-11-28', 'X-GitHub-Api-Version': '2022-11-28',
}, },
}).parsedJson(z.array(schema)) }).parsedJson(z.array(schema))
const data = res[0] const data = res[0]
const eventTextMapper: Record<string, () => string> = { const eventTextMapper: Record<string, () => string> = {
CreateEvent: () => `${data.payload.ref_type} created`, CreateEvent: () => `${data.payload.ref_type} created`,
DeleteEvent: () => `${data.payload.ref_type} deleted`, DeleteEvent: () => `${data.payload.ref_type} deleted`,
ForkEvent: () => 'forked', ForkEvent: () => 'forked',
GollumEvent: () => 'wiki updated', GollumEvent: () => 'wiki updated',
IssueCommentEvent: () => `issue comment ${data.payload.action}`, IssueCommentEvent: () => `issue comment ${data.payload.action}`,
IssuesEvent: () => `issue ${data.payload.action}`, IssuesEvent: () => `issue ${data.payload.action}`,
PublicEvent: () => 'made public', PublicEvent: () => 'made public',
PullRequestEvent: () => `pr ${data.payload.action}`, PullRequestEvent: () => `pr ${data.payload.action}`,
PushEvent: () => `pushed ${data.payload.distinct_size} commits`, PushEvent: () => `pushed ${data.payload.distinct_size} commits`,
ReleaseEvent: () => `release ${data.payload.action}`, ReleaseEvent: () => `release ${data.payload.action}`,
WatchEvent: () => 'starred', WatchEvent: () => 'starred',
} }
if (eventTextMapper[data.type]) { if (eventTextMapper[data.type]) {
return { return {
data: { data: {
source: 'github', source: 'github',
sourceLink: 'https://github.com/teidesu', sourceLink: 'https://github.com/teidesu',
time: new Date(data.created_at).getTime(), time: new Date(data.created_at).getTime(),
text: data.repo.name, text: data.repo.name,
suffix: `: ${eventTextMapper[data.type]()}`, suffix: `: ${eventTextMapper[data.type]()}`,
link: `https://github.com/${data.repo.name}`, link: `https://github.com/${data.repo.name}`,
}, },
expiresIn: TTL, expiresIn: TTL,
} }
} }
return { return {
data: null, data: null,
expiresIn: TTL, expiresIn: TTL,
} }
}, },
swr: true, swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL, swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
}) })

View file

@ -1,51 +1,51 @@
import { bskyLastSeen } from './bsky.ts' import { bskyLastSeen } from './bsky.ts'
import { forgejoLastSeen } from './forgejo.ts'
import { githubLastSeen } from './github' import { githubLastSeen } from './github'
import { lastfm } from './listenbrainz.ts' import { lastfm } from './listenbrainz.ts'
import { shikimoriLastSeen } from './shikimori' import { shikimoriLastSeen } from './shikimori'
import { forgejoLastSeen } from './forgejo.ts'
export interface LastSeenItem { export interface LastSeenItem {
source: string source: string
sourceLink: string sourceLink: string
time: number time: number
text: string text: string
suffix?: string suffix?: string
link: string link: string
} }
export async function fetchLastSeen() { export async function fetchLastSeen() {
const [ const [
lastfmData, lastfmData,
bskyData, bskyData,
shikimoriData, shikimoriData,
githubData, githubData,
forgejoData, forgejoData,
] = await Promise.all([ ] = await Promise.all([
lastfm.get(), lastfm.get(),
bskyLastSeen.get(), bskyLastSeen.get(),
shikimoriLastSeen.get(), shikimoriLastSeen.get(),
githubLastSeen.get(), githubLastSeen.get(),
forgejoLastSeen.get(), forgejoLastSeen.get(),
]) ])
const res: LastSeenItem[] = [] const res: LastSeenItem[] = []
if (lastfmData) res.push(lastfmData) if (lastfmData) res.push(lastfmData)
if (bskyData) res.push(bskyData) if (bskyData) res.push(bskyData)
if (shikimoriData) res.push(shikimoriData) if (shikimoriData) res.push(shikimoriData)
if (githubData && forgejoData) { if (githubData && forgejoData) {
// only push the last one // only push the last one
if (forgejoData.time > githubData.time) { if (forgejoData.time > githubData.time) {
res.push(forgejoData) res.push(forgejoData)
} else { } else {
res.push(githubData) res.push(githubData)
}
} else if (githubData) {
res.push(githubData)
} else if (forgejoData) {
res.push(forgejoData)
} }
} else if (githubData) {
res.push(githubData)
} else if (forgejoData) {
res.push(forgejoData)
}
return res return res
} }

View file

@ -1,71 +1,71 @@
import { z } from 'zod' import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils' import { AsyncResource } from '@fuman/utils'
import { ffetch } from '../../utils/fetch.ts' import { z } from 'zod'
import type { LastSeenItem } from './index.ts' import { ffetch } from '../../utils/fetch.ts'
const LB_TTL = 1000 * 60 * 5 // 5 minutes const LB_TTL = 1000 * 60 * 5 // 5 minutes
const LB_STALE_TTL = 1000 * 60 * 60 // 1 hour const LB_STALE_TTL = 1000 * 60 * 60 // 1 hour
const LB_USERNAME = 'teidumb' const LB_USERNAME = 'teidumb'
const LbListen = z.object({ const LbListen = z.object({
listened_at: z.number(), listened_at: z.number(),
track_metadata: z.object({ track_metadata: z.object({
artist_name: z.string(), artist_name: z.string(),
track_name: z.string(), track_name: z.string(),
additional_info: z.object({ additional_info: z.object({
origin_url: z.string().optional(), origin_url: z.string().optional(),
}).optional(), }).optional(),
mbid_mapping: z.object({ mbid_mapping: z.object({
recording_mbid: z.string().optional(), recording_mbid: z.string().optional(),
}).optional(), }).optional(),
}), }),
}) })
const ResponseSchema = z.object({ const ResponseSchema = z.object({
payload: z.object({ payload: z.object({
listens: z.array(LbListen), listens: z.array(LbListen),
}), }),
}) })
export const lastfm = new AsyncResource<LastSeenItem | null>({ export const lastfm = new AsyncResource<LastSeenItem | null>({
async fetcher({ current }) { async fetcher({ current }) {
const res = await ffetch(`https://api.listenbrainz.org/1/user/${LB_USERNAME}/listens`, { const res = await ffetch(`https://api.listenbrainz.org/1/user/${LB_USERNAME}/listens`, {
query: { query: {
count: 1, count: 1,
min_ts: current ? Math.floor(current.time / 1000) : '', min_ts: current ? Math.floor(current.time / 1000) : '',
}, },
}).parsedJson(ResponseSchema) }).parsedJson(ResponseSchema)
if (!res.payload.listens.length) { if (!res.payload.listens.length) {
return { return {
data: current, data: current,
expiresIn: 0, expiresIn: 0,
} }
} }
const listen = res.payload.listens[0] const listen = res.payload.listens[0]
let url: string | undefined let url: string | undefined
if (listen.track_metadata.mbid_mapping?.recording_mbid) { if (listen.track_metadata.mbid_mapping?.recording_mbid) {
url = `https://musicbrainz.org/recording/${listen.track_metadata.mbid_mapping.recording_mbid}` url = `https://musicbrainz.org/recording/${listen.track_metadata.mbid_mapping.recording_mbid}`
} else if (listen.track_metadata.additional_info?.origin_url) { } else if (listen.track_metadata.additional_info?.origin_url) {
url = listen.track_metadata.additional_info.origin_url url = listen.track_metadata.additional_info.origin_url
} else { } else {
url = 'https://listenbrainz.org/user/teidumb/' url = 'https://listenbrainz.org/user/teidumb/'
} }
return { return {
data: { data: {
source: 'listenbrainz', source: 'listenbrainz',
sourceLink: 'https://listenbrainz.org/user/teidumb/', sourceLink: 'https://listenbrainz.org/user/teidumb/',
time: listen.listened_at * 1000, time: listen.listened_at * 1000,
text: `${listen.track_metadata.track_name} ${listen.track_metadata.artist_name}`, text: `${listen.track_metadata.track_name} ${listen.track_metadata.artist_name}`,
link: url, link: url,
}, },
expiresIn: LB_TTL, expiresIn: LB_TTL,
} }
}, },
swr: true, swr: true,
swrValidator: ({ currentExpiresAt }) => Date.now() - currentExpiresAt < LB_STALE_TTL, swrValidator: ({ currentExpiresAt }) => Date.now() - currentExpiresAt < LB_STALE_TTL,
}) })

View file

@ -1,63 +1,63 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils' import { AsyncResource } from '@fuman/utils'
import { z } from 'zod' import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts' import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1' const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1'
const TTL = 3 * 60 * 60 * 1000 // 3 hours const TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
const schema = z.object({ const schema = z.object({
created_at: z.string(), created_at: z.string(),
description: z.string(), description: z.string(),
target: z.object({ target: z.object({
name: z.string(), name: z.string(),
url: z.string(), url: z.string(),
}), }),
}) })
export const shikimoriLastSeen = new AsyncResource<LastSeenItem | null>({ export const shikimoriLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() { async fetcher() {
const res = (await ffetch(ENDPOINT).parsedJson(z.array(schema)))[0] const res = (await ffetch(ENDPOINT).parsedJson(z.array(schema)))[0]
// thx morr for this fucking awesome api // thx morr for this fucking awesome api
const mapper: Record<string, string> = { const mapper: Record<string, string> = {
'Просмотрено': 'completed', 'Просмотрено': 'completed',
'Прочитано': 'completed', 'Прочитано': 'completed',
'Добавлено в список': 'added', 'Добавлено в список': 'added',
'Брошено': 'dropped', 'Брошено': 'dropped',
} }
let event = mapper[res.description] let event = mapper[res.description]
if (!event && res.description.match(/^Просмотрен.*эпизод(ов)?$/)) { if (!event && res.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
event = 'watched' event = 'watched'
} }
if (!event && res.description.match(/^(Просмотрено|Прочитано) и оценено/)) { if (!event && res.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
event = 'completed' event = 'completed'
} }
if (event) { if (event) {
return { return {
data: { data: {
source: 'shiki', source: 'shiki',
sourceLink: 'https://shikimori.one/teidesu', sourceLink: 'https://shikimori.one/teidesu',
time: new Date(res.created_at).getTime(), time: new Date(res.created_at).getTime(),
text: res.target.name, text: res.target.name,
suffix: `: ${event}`, suffix: `: ${event}`,
link: `https://shikimori.one${res.target.url}`, link: `https://shikimori.one${res.target.url}`,
}, },
expiresIn: TTL, expiresIn: TTL,
} }
} }
return { return {
data: null, data: null,
expiresIn: TTL, expiresIn: TTL,
} }
}, },
swr: true, swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL, swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
}) })

View file

@ -1,197 +1,189 @@
import { BotKeyboard, html } from '@mtcute/node' import { BotKeyboard, html } from '@mtcute/node'
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm' import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
import { tg } from '../bot'
import { ShoutboxAction } from '../bot/shoutbox.js' import { ShoutboxAction } from '../bot/shoutbox.js'
import { shouts, shoutsBans } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
import { db } from '../db' import { db } from '../db'
import { env } from '../env' import { env } from '../env'
import { tg } from '../bot' import { shouts, shoutsBans } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
const SHOUTS_PER_PAGE = 5 const SHOUTS_PER_PAGE = 5
const filter = or( const filter = or(
not(shouts.pending), not(shouts.pending),
and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))), and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))),
) )
const fetchTotal = db.select({ const fetchTotal = db.select({
count: sql<number>`count(1)`, count: sql<number>`count(1)`,
}).from(shouts) }).from(shouts).where(filter).prepare()
.where(filter)
.prepare()
const fetchList = db.select({ const fetchList = db.select({
createdAt: shouts.createdAt, createdAt: shouts.createdAt,
text: shouts.text, text: shouts.text,
pending: shouts.pending, pending: shouts.pending,
serial: shouts.serial, serial: shouts.serial,
reply: shouts.reply, reply: shouts.reply,
}).from(shouts) }).from(shouts).where(filter).limit(SHOUTS_PER_PAGE).orderBy(desc(shouts.createdAt)).offset(sql.placeholder('offset')).prepare()
.where(filter)
.limit(SHOUTS_PER_PAGE)
.orderBy(desc(shouts.createdAt))
.offset(sql.placeholder('offset'))
.prepare()
export function fetchShouts(page: number, ip: string) { export function fetchShouts(page: number, ip: string) {
return { return {
items: fetchList.all({ items: fetchList.all({
offset: page * SHOUTS_PER_PAGE, offset: page * SHOUTS_PER_PAGE,
fromIp: ip, fromIp: ip,
}), }),
pageCount: Math.ceil((fetchTotal.get({ pageCount: Math.ceil((fetchTotal.get({
fromIp: ip, fromIp: ip,
})?.count ?? 0) / SHOUTS_PER_PAGE), })?.count ?? 0) / SHOUTS_PER_PAGE),
} }
} }
export type ShoutsData = ReturnType<typeof fetchShouts> export type ShoutsData = ReturnType<typeof fetchShouts>
const fetchNextSerial = db.select({ const fetchNextSerial = db.select({
serial: sql<number>`coalesce(max(serial), 0) + 1`, serial: sql<number>`coalesce(max(serial), 0) + 1`,
}).from(shouts) }).from(shouts).prepare()
.prepare()
export function approveShout(id: string) { export function approveShout(id: string) {
const nextSerial = fetchNextSerial.get({})!.serial const nextSerial = fetchNextSerial.get({})!.serial
db.update(shouts) db.update(shouts)
.set({ pending: false, serial: nextSerial }) .set({ pending: false, serial: nextSerial })
.where(eq(shouts.id, id)) .where(eq(shouts.id, id))
.run() .run()
return nextSerial return nextSerial
} }
export function declineShout(id: string) { export function declineShout(id: string) {
db.delete(shouts) db.delete(shouts)
.where(eq(shouts.id, id)) .where(eq(shouts.id, id))
.run() .run()
} }
export function deleteBySerial(serial: number) { export function deleteBySerial(serial: number) {
db.delete(shouts) db.delete(shouts)
.where(eq(shouts.serial, serial)) .where(eq(shouts.serial, serial))
.run() .run()
// adjust serials // adjust serials
db.update(shouts) db.update(shouts)
.set({ serial: sql<number>`serial - 1` }) .set({ serial: sql<number>`serial - 1` })
.where(and( .where(and(
eq(shouts.pending, false), eq(shouts.pending, false),
gt(shouts.serial, sql.placeholder('serial')), gt(shouts.serial, sql.placeholder('serial')),
)) ))
.run({ serial }) .run({ serial })
} }
export function answerBySerial(serial: number, reply: string) { export function answerBySerial(serial: number, reply: string) {
db.update(shouts) db.update(shouts)
.set({ reply }) .set({ reply })
.where(eq(shouts.serial, serial)) .where(eq(shouts.serial, serial))
.execute() .execute()
} }
export function banShouts(ip: string, expires: number) { export function banShouts(ip: string, expires: number) {
db.insert(shoutsBans) db.insert(shoutsBans)
.values({ .values({
ip, ip,
expires, expires,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: shoutsBans.ip, target: shoutsBans.ip,
set: { expires }, set: { expires },
}) })
.execute() .execute()
} }
export function unbanShouts(ip: string) { export function unbanShouts(ip: string) {
db.delete(shoutsBans) db.delete(shoutsBans)
.where(eq(shoutsBans.ip, ip)) .where(eq(shoutsBans.ip, ip))
.execute() .execute()
} }
export function isShoutboxBanned(ip: string): Date | null { export function isShoutboxBanned(ip: string): Date | null {
const ban = db.select() const ban = db.select()
.from(shoutsBans) .from(shoutsBans)
.where(eq(shoutsBans.ip, ip)) .where(eq(shoutsBans.ip, ip))
.get() .get()
if (!ban) return null if (!ban) return null
const expires = ban.expires const expires = ban.expires
if (Date.now() > expires) return null if (Date.now() > expires) return null
return new Date(ban.expires) return new Date(ban.expires)
} }
function validateShout(text: string, isPublic: boolean) { function validateShout(text: string, isPublic: boolean) {
if (text.length < 3) { if (text.length < 3) {
return 'too short, come on' return 'too short, come on'
}
if (text.length > 300) {
return 'please keep it under 300 characters'
}
if (isPublic) {
const lineCount = text.split('\n').length
if (lineCount > 5) {
return 'too many lines, keep it under 5'
} }
if (text.length > 300) { if (URL_REGEX.test(text)) {
return 'please keep it under 300 characters' return 'no links plz'
} }
}
if (isPublic) { return true
const lineCount = text.split('\n').length
if (lineCount > 5) {
return 'too many lines, keep it under 5'
}
if (URL_REGEX.test(text)) {
return 'no links plz'
}
}
return true
} }
export async function createShout(params: { export async function createShout(params: {
fromIp: string fromIp: string
private: boolean private: boolean
text: string text: string
}): Promise<boolean | string> { }): Promise<boolean | string> {
let { text } = params let { text } = params
text = text.trim() text = text.trim()
const validateResult = validateShout(text, !params.private) const validateResult = validateShout(text, !params.private)
const header = html`${params.private ? 'private message' : 'shout'} from <code>${params.fromIp}</code>` const header = html`${params.private ? 'private message' : 'shout'} from <code>${params.fromIp}</code>`
const subheader = html`<br>via: #api<br><br>` const subheader = html`<br>via: #api<br><br>`
if (params.private || validateResult !== true) { if (params.private || validateResult !== true) {
const was = params.private ? '' : ` was auto-declined (${validateResult})` const was = params.private ? '' : ` was auto-declined (${validateResult})`
await tg.sendText( await tg.sendText(
env.TG_CHAT_ID, env.TG_CHAT_ID,
html` html`
${header}${was}: ${header}${was}:
${subheader} ${subheader}
${text} ${text}
`, `,
) )
} }
if (!params.private && validateResult === true) { if (!params.private && validateResult === true) {
const result = await db.insert(shouts) const result = await db.insert(shouts)
.values(params) .values(params)
.returning({ id: shouts.id }) .returning({ id: shouts.id })
.execute() .execute()
const id = result[0].id const id = result[0].id
await tg.sendText( await tg.sendText(
env.TG_CHAT_ID, env.TG_CHAT_ID,
html` html`
${header}: ${header}:
${subheader} ${subheader}
${text} ${text}
`, `,
{ {
replyMarkup: BotKeyboard.inline([[ replyMarkup: BotKeyboard.inline([[
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })), BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })), BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })),
]]), ]]),
}, },
) )
} }
return validateResult return validateResult
} }

View file

@ -2,64 +2,64 @@ import { ffetchAddons, ffetchBase } from '@fuman/fetch'
import { ffetchZodAdapter } from '@fuman/fetch/zod' import { ffetchZodAdapter } from '@fuman/fetch/zod'
import { z } from 'zod' import { z } from 'zod'
import { isBotUserAgent } from '../utils/bot'
import { env } from '~/backend/env' import { env } from '~/backend/env'
import { isBotUserAgent } from '../utils/bot'
const ffetch = ffetchBase.extend({ const ffetch = ffetchBase.extend({
addons: [ addons: [
ffetchAddons.parser(ffetchZodAdapter()), ffetchAddons.parser(ffetchZodAdapter()),
ffetchAddons.timeout(), ffetchAddons.timeout(),
], ],
baseUrl: env.UMAMI_HOST, baseUrl: env.UMAMI_HOST,
timeout: 1000, timeout: 1000,
}) })
export async function umamiFetchStats(page: string, startAt: number) { export async function umamiFetchStats(page: string, startAt: number) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
return Promise.resolve({ visitors: { value: 1337 } }) return Promise.resolve({ visitors: { value: 1337 } })
} }
return await ffetch(`/api/websites/${env.UMAMI_SITE_ID}/stats`, { return await ffetch(`/api/websites/${env.UMAMI_SITE_ID}/stats`, {
query: { query: {
endAt: Math.floor(Date.now()).toString(), endAt: Math.floor(Date.now()).toString(),
startAt: startAt.toString(), startAt: startAt.toString(),
url: page, url: page,
}, },
headers: { headers: {
Authorization: `Bearer ${env.UMAMI_TOKEN}`, Authorization: `Bearer ${env.UMAMI_TOKEN}`,
}, },
}).parsedJson(z.object({ }).parsedJson(z.object({
visitors: z.object({ visitors: z.object({
value: z.number(), value: z.number(),
}), }),
})) }))
} }
export function umamiLogThisVisit(request: Request, path?: string, website = env.UMAMI_SITE_ID): void { export function umamiLogThisVisit(request: Request, path?: string, website = env.UMAMI_SITE_ID): void {
if (import.meta.env.DEV) return if (import.meta.env.DEV) return
if (isBotUserAgent(request.headers.get('user-agent') || '')) return if (isBotUserAgent(request.headers.get('user-agent') || '')) return
const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || '' const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || ''
ffetch.post('/api/send', { ffetch.post('/api/send', {
json: { json: {
payload: { payload: {
hostname: request.headers.get('host') || '', hostname: request.headers.get('host') || '',
language, language,
referrer: request.headers.get('referer') || '', referrer: request.headers.get('referer') || '',
screen: '', screen: '',
title: '', title: '',
url: path ?? new URL(request.url).pathname, url: path ?? new URL(request.url).pathname,
website, website,
}, },
type: 'event', type: 'event',
}, },
headers: { headers: {
'User-Agent': request.headers.get('user-agent') || '', 'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '', 'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
}, },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`) if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
}).catch((err) => { }).catch((err) => {
console.warn(err) console.warn(err)
}) })
} }

View file

@ -7,26 +7,26 @@ const WEBRING_URL = 'https://otomir23.me/webring/5/data'
const WEBRING_TTL = 1000 * 60 * 60 * 24 // 24 hours const WEBRING_TTL = 1000 * 60 * 60 * 24 // 24 hours
const WebringItem = z.object({ const WebringItem = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
url: z.string(), url: z.string(),
}) })
export type WebringItem = z.infer<typeof WebringItem> export type WebringItem = z.infer<typeof WebringItem>
const WebringData = z.object({ const WebringData = z.object({
prev: WebringItem, prev: WebringItem,
next: WebringItem, next: WebringItem,
}) })
export type WebringData = z.infer<typeof WebringData> export type WebringData = z.infer<typeof WebringData>
export const webring = new AsyncResource<WebringData>({ export const webring = new AsyncResource<WebringData>({
fetcher: async () => { fetcher: async () => {
const res = await ffetch(WEBRING_URL).parsedJson(WebringData) const res = await ffetch(WEBRING_URL).parsedJson(WebringData)
return { return {
data: res, data: res,
expiresIn: WEBRING_TTL, expiresIn: WEBRING_TTL,
} }
}, },
swr: true, swr: true,
}) })

View file

@ -1,3 +1,3 @@
export function isBotUserAgent(userAgent: string) { export function isBotUserAgent(userAgent: string) {
return /bot|crawl|slurp|spider|mediapartners|mastodon|akkoma|pleroma|misskey|firefish|sharkey/i.test(userAgent) return /bot|crawl|slurp|spider|mediapartners|mastodon|akkoma|pleroma|misskey|firefish|sharkey/i.test(userAgent)
} }

View file

@ -8,35 +8,35 @@ const secret = env.CSRF_SECRET
const validity = 300_000 const validity = 300_000
export function getCsrfToken(ip: string) { export function getCsrfToken(ip: string) {
const data = utf8.encoder.encode(JSON.stringify([Date.now(), ip])) const data = utf8.encoder.encode(JSON.stringify([Date.now(), ip]))
const salt = randomBytes(8) as Uint8Array const salt = randomBytes(8) as Uint8Array
const sign = createHmac('sha256', secret).update(data).update(salt).digest() const sign = createHmac('sha256', secret).update(data).update(salt).digest()
return base64.encode(u8.concat3( return base64.encode(u8.concat3(
data, data,
salt, salt,
sign.subarray(0, 8), sign.subarray(0, 8),
), true) ), true)
} }
export function verifyCsrfToken(ip: string, token: string) { export function verifyCsrfToken(ip: string, token: string) {
try { try {
const buf = base64.decode(token, true) const buf = base64.decode(token, true)
if (buf.length < 16) return false if (buf.length < 16) return false
const saltedData = buf.subarray(0, -8) const saltedData = buf.subarray(0, -8)
const correctSign = createHmac('sha256', secret).update(saltedData).digest() const correctSign = createHmac('sha256', secret).update(saltedData).digest()
if (!typed.equal(correctSign.subarray(0, 8) as Uint8Array, buf.subarray(-8))) { if (!typed.equal(new Uint8Array(correctSign.subarray(0, 8)), buf.subarray(-8))) {
return false return false
}
const [issued, correctIp] = JSON.parse(buf.subarray(0, -16).toString())
if (issued + validity < Date.now()) return false
if (ip !== correctIp) return false
return true
} catch {
return false
} }
const [issued, correctIp] = JSON.parse(utf8.decoder.decode(buf.subarray(0, -16)))
if (issued + validity < Date.now()) return false
if (ip !== correctIp) return false
return true
} catch {
return false
}
} }

View file

@ -2,11 +2,11 @@ import { ffetchAddons, ffetchBase } from '@fuman/fetch'
import { ffetchZodAdapter } from '@fuman/fetch/zod' import { ffetchZodAdapter } from '@fuman/fetch/zod'
export const ffetch = ffetchBase.extend({ export const ffetch = ffetchBase.extend({
addons: [ addons: [
ffetchAddons.parser(ffetchZodAdapter()), ffetchAddons.parser(ffetchZodAdapter()),
ffetchAddons.retry(), ffetchAddons.retry(),
], ],
headers: { headers: {
'User-Agent': 'tei.su/1.0', 'User-Agent': 'tei.su/1.0',
}, },
}) })

View file

@ -1,14 +1,14 @@
import { randomPick } from '../../utils/random' import { randomPick } from '../../utils/random'
export function obfuscateEmail(email: string) { export function obfuscateEmail(email: string) {
const opener = randomPick(['[', '{', '(', '<', '|']) const opener = randomPick(['[', '{', '(', '<', '|'])
const closer = { const closer = {
'(': ')', '(': ')',
'[': ']', '[': ']',
'{': '}', '{': '}',
'<': '>', '<': '>',
'|': '|', '|': '|',
}[opener] }[opener]
return email.replace(/@/g, ` ${opener}at${closer} `).replace(/\./g, ` ${opener}dot${closer} `) return email.replace(/@/g, ` ${opener}at${closer} `).replace(/\./g, ` ${opener}dot${closer} `)
} }

View file

@ -1,8 +1,8 @@
import type { APIContext, AstroGlobal } from 'astro' import type { APIContext, AstroGlobal } from 'astro'
export function getRequestIp(ctx: AstroGlobal | APIContext) { export function getRequestIp(ctx: AstroGlobal | APIContext) {
const xForwardedFor = ctx.request.headers.get('x-forwarded-for') const xForwardedFor = ctx.request.headers.get('x-forwarded-for')
if (xForwardedFor) return xForwardedFor.split(',')[0] if (xForwardedFor) return xForwardedFor.split(',')[0]
return ctx.clientAddress return ctx.clientAddress
} }

View file

@ -1,28 +1,28 @@
export class HttpResponse { export class HttpResponse {
private constructor() {} private constructor() {}
static json(body: unknown, init?: ResponseInit) { static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
...init, ...init,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...init?.headers, ...init?.headers,
}, },
}) })
} }
static error(status: number) { static error(status: number) {
return new Response(null, { status }) return new Response(null, { status })
} }
static redirect(url: string, init?: ResponseInit) { static redirect(url: string, init?: ResponseInit) {
return new Response(null, { return new Response(null, {
status: 301, status: 301,
...init, ...init,
headers: { headers: {
Location: url, Location: url,
...init?.headers, ...init?.headers,
}, },
}) })
} }
} }

View file

@ -1454,13 +1454,13 @@ ZW
const TLD_LIST = new Set(TLD_LIST_IANA.trim().split('\n')) const TLD_LIST = new Set(TLD_LIST_IANA.trim().split('\n'))
for (const tld of TLD_LIST) { for (const tld of TLD_LIST) {
if (tld.startsWith('XN--')) { if (tld.startsWith('XN--')) {
// also add the non-punycode version // also add the non-punycode version
TLD_LIST.add(domainToUnicode(tld)) TLD_LIST.add(domainToUnicode(tld))
} }
} }
export const URL_REGEX = new RegExp( export const URL_REGEX = new RegExp(
`(https?://)?([a-z0-9-]+\\.)+(${[...TLD_LIST].join('|')})(/[a-z0-9-._~:/?#[\\]@!$&'()*+,;=]*)?`, `(https?://)?([a-z0-9-]+\\.)+(${[...TLD_LIST].join('|')})(/[a-z0-9-._~:/?#[\\]@!$&'()*+,;=]*)?`,
'i', 'i',
) )

View file

@ -0,0 +1,39 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { createSignal } from 'solid-js'
import { shuffle } from '~/utils/random'
export interface RandomWordProps {
choices: JSX.Element[]
}
export function RandomWord(props: RandomWordProps) {
const [choice, setChoice] = createSignal<JSX.Element>()
let order: JSX.Element[] = []
function pickNew() {
if (order.length === 0) {
order = shuffle(props.choices)
}
setChoice(order.pop())
}
function onClick(evt: MouseEvent) {
evt.preventDefault()
pickNew()
}
pickNew()
return (
<div
class="pos-relative inline-block cursor-pointer underline underline-dotted transition-200 active:select-none"
onClick={onClick}
>
{choice()}
</div>
)
}

View file

@ -1,12 +0,0 @@
.choice {
cursor: pointer;
display: inline-block;
position: relative;
text-decoration: underline dotted;
transition: opacity 200ms, transform 200ms;
/* prevent text selection on 2/3-ple click */
&:active {
user-select: none;
}
}

View file

@ -1,41 +0,0 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { createSignal } from 'solid-js'
import { shuffle } from '~/utils/random'
import css from './RandomWord.module.css'
export interface RandomWordProps {
choices: JSX.Element[]
}
export function RandomWord(props: RandomWordProps) {
const [choice, setChoice] = createSignal<JSX.Element>()
let order: JSX.Element[] = []
function pickNew() {
if (order.length === 0) {
order = shuffle(props.choices)
}
setChoice(order.pop())
}
function onClick(evt: MouseEvent) {
evt.preventDefault()
pickNew()
}
pickNew()
return (
<div
class={css.choice}
onClick={onClick}
>
{choice()}
</div>
)
}

View file

@ -1,10 +1,10 @@
--- ---
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { umamiLogThisVisit } from '~/backend/service/umami'
import moneyImg from '~/assets/money.jpg' import moneyImg from '~/assets/money.jpg'
import { umamiLogThisVisit } from '~/backend/service/umami'
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
import { fetchDonatePageData } from './data' import { fetchDonatePageData } from './data'
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
umamiLogThisVisit(Astro.request, '/donate') umamiLogThisVisit(Astro.request, '/donate')
@ -12,7 +12,7 @@ const data = await fetchDonatePageData(Astro.request)
--- ---
<DefaultLayout <DefaultLayout
og={{ og={{
title: 'teidesu > donate.txt', title: 'teidesu > donate.txt',
description: 'i would be extremely pleased if you sent me some money ❤️', description: 'i would be extremely pleased if you sent me some money ❤️',
image: moneyImg.src, image: moneyImg.src,

View file

@ -1,96 +1,96 @@
/** @jsxImportSource solid-js */ /** @jsxImportSource solid-js */
import { type JSX, createSignal, onMount } from 'solid-js' import type { PaymentMethod } from './constants'
import { Link } from '~/components/ui/Link/Link'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import { TextTable } from '~/components/ui/TextTable/TextTable'
import type { PageData } from './data' import type { PageData } from './data'
import type { PaymentMethod } from './constants' import { createSignal, type JSX, onMount } from 'solid-js'
import { Link } from '../../ui/Link.tsx'
import { SectionTitle } from '../../ui/Section.tsx'
import { TextTable } from '../../ui/TextTable.tsx'
import { deriveKey, dumbHash, xorContinuous } from './crypto-common' import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
export function PaymentMethods(props: { data: PageData }) { export function PaymentMethods(props: { data: PageData }) {
const [items, setItems] = createSignal<PaymentMethod[]>( const [items, setItems] = createSignal<PaymentMethod[]>(
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
props.data.encryptedData.map(it => ({ props.data.encryptedData.map(it => ({
link: undefined, link: undefined,
name: it.name, name: it.name,
text: '[encrypted]', text: '[encrypted]',
})), })),
) )
onMount(() => { onMount(() => {
// force client-side // force client-side
const key = deriveKey(navigator.userAgent, location.href, props.data.salt) const key = deriveKey(navigator.userAgent, location.href, props.data.salt)
const keyHash = dumbHash(key) const keyHash = dumbHash(key)
const xor = [0] const xor = [0]
const probeDec = xorContinuous(keyHash, props.data.probeEnc, xor) const probeDec = xorContinuous(keyHash, props.data.probeEnc, xor)
if (probeDec !== props.data.probe) { if (probeDec !== props.data.probe) {
console.error(`Probe mismatch (expected: ${props.data.probe}, got: ${probeDec})`) console.error(`Probe mismatch (expected: ${props.data.probe}, got: ${probeDec})`)
return return
} }
setItems(props.data.encryptedData.map(it => ({ setItems(props.data.encryptedData.map(it => ({
link: it.link ? xorContinuous(keyHash, it.link!, xor) : undefined, link: it.link ? xorContinuous(keyHash, it.link!, xor) : undefined,
name: it.name, name: it.name,
text: xorContinuous(keyHash, it.text, xor), text: xorContinuous(keyHash, it.text, xor),
}))) })))
}) })
const itemsToRender = () => items().map(it => ({ const itemsToRender = () => items().map(it => ({
name: it.name, name: it.name,
value: () => it.link value: () => it.link
? <Link href={it.link} target="_blank">{it.text}</Link> ? <Link href={it.link} target="_blank">{it.text}</Link>
: it.text, : it.text,
})) }))
return ( return (
<TextTable <TextTable
items={itemsToRender()} items={itemsToRender()}
minColumnWidth={12} minColumnWidth={12}
/> />
) )
} }
export function PageDonate(props: { methods?: JSX.Element, data: PageData }) { export function PageDonate(props: { methods?: JSX.Element, data: PageData }) {
return ( return (
<> <>
<section>heya</section> <section>heya</section>
<section> <section>
i'm not really struggling with money, but if you like what i do and want to support me i i'm not really struggling with money, but if you like what i do and want to support me i
would totally appreciate it &lt;3 would totally appreciate it &lt;3
</section> </section>
<section>when donating crypto, please use stablecoins (usdt/dai) or native token</section> <section>when donating crypto, please use stablecoins (usdt/dai) or native token</section>
<section> <section>
<SectionTitle>my payment addresses (in order of preference):</SectionTitle> <SectionTitle>my payment addresses (in order of preference):</SectionTitle>
{props.methods} {props.methods}
</section> </section>
<noscript> <noscript>
<section> <section>
<SectionTitle> looks like javascript is disabled.</SectionTitle> <SectionTitle> looks like javascript is disabled.</SectionTitle>
that is why payment methods above aren't displayed. that is why payment methods above aren't displayed.
<br /> <br />
to protect myself from osint and bot attacks, i do a little obfuscation. to protect myself from osint and bot attacks, i do a little obfuscation.
<br /> <br />
i promise, there are no trackers here ^_^ i promise, there are no trackers here ^_^
<br /> <br />
<br /> <br />
if you are a weirdo using noscript or lynx or something instead of a browser, you can if you are a weirdo using noscript or lynx or something instead of a browser, you can
dm me for payment details. dm me for payment details.
</section> </section>
</noscript> </noscript>
<section> <section>
total page views so far: total page views so far:
{' '} {' '}
{props.data.pageViews} {props.data.pageViews}
</section> </section>
</> </>
) )
} }

View file

@ -1,9 +1,9 @@
import data from './data.json' with { type: 'json' } import data from './data.json' with { type: 'json' }
export interface PaymentMethod { export interface PaymentMethod {
link?: string link?: string
name: string name: string
text: string text: string
} }
export const PAYMENT_METHODS = data as PaymentMethod[] export const PAYMENT_METHODS = data as PaymentMethod[]

View file

@ -1,34 +1,34 @@
const ascii = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' const ascii = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
export function dumbHash(str: string) { export function dumbHash(str: string) {
let hash = 0 let hash = 0
const len = str.length const len = str.length
for (let s = 0; s < len; s++) { for (let s = 0; s < len; s++) {
hash += str.charCodeAt(s) * (s + 1) * (len - s) hash += str.charCodeAt(s) * (s + 1) * (len - s)
} }
hash >>>= 0 hash >>>= 0
let res = '' let res = ''
while (hash > 0) { while (hash > 0) {
const q = hash % ascii.length const q = hash % ascii.length
hash = ~~(hash / ascii.length) hash = ~~(hash / ascii.length)
res += ascii[q] res += ascii[q]
} }
return res return res
} }
export function deriveKey(userAgent: string, href: string, salt: string) { export function deriveKey(userAgent: string, href: string, salt: string) {
return userAgent.trim() + href.replace(/#.*$/, '') + Math.floor(Date.now() / 100000) + salt return userAgent.trim() + href.replace(/#.*$/, '') + Math.floor(Date.now() / 100000) + salt
} }
export function xorContinuous(key: string, str: string, posRef: number[]) { export function xorContinuous(key: string, str: string, posRef: number[]) {
let pos = posRef[0] let pos = posRef[0]
let ret = '' let ret = ''
for (let s = 0; s < str.length; s++) { for (let s = 0; s < str.length; s++) {
ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos)) ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos))
pos = (pos + 1) % key.length pos = (pos + 1) % key.length
} }
posRef[0] = pos posRef[0] = pos
return ret return ret
} }

View file

@ -1,42 +1,42 @@
import type { PaymentMethod } from './constants'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { umamiFetchStats } from '~/backend/service/umami' import { umamiFetchStats } from '~/backend/service/umami'
import type { PaymentMethod } from './constants'
import { PAYMENT_METHODS } from './constants' import { PAYMENT_METHODS } from './constants'
import { deriveKey, dumbHash, xorContinuous } from './crypto-common' import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
export async function fetchDonatePageData(request: Request) { export async function fetchDonatePageData(request: Request) {
const pageViews = await umamiFetchStats('/donate', 1700088965789) const pageViews = await umamiFetchStats('/donate', 1700088965789)
.then(stats => `${stats.visitors.value + 9089}`) // value before umami .then(stats => `${stats.visitors.value + 9089}`) // value before umami
.catch((err) => { .catch((err) => {
console.error('Failed to fetch page views: ', err) console.error('Failed to fetch page views: ', err)
return '[error]' return '[error]'
}) })
const salt = randomBytes(12).toString('base64') const salt = randomBytes(12).toString('base64')
const probe = randomBytes(12).toString('base64') const probe = randomBytes(12).toString('base64')
const url = new URL(request.url, `${import.meta.env.DEV ? 'http' : 'https'}://${request.headers.get('host')}`) const url = new URL(request.url, `${import.meta.env.DEV ? 'http' : 'https'}://${request.headers.get('host')}`)
const key = deriveKey(request.headers.get('user-agent') || '', url.href, salt) const key = deriveKey(request.headers.get('user-agent') || '', url.href, salt)
const keyHash = dumbHash(key) const keyHash = dumbHash(key)
const xorPos = [0] const xorPos = [0]
const probeEnc = xorContinuous(keyHash, probe, xorPos) const probeEnc = xorContinuous(keyHash, probe, xorPos)
const encryptedData: PaymentMethod[] = PAYMENT_METHODS.map(it => ({ const encryptedData: PaymentMethod[] = PAYMENT_METHODS.map(it => ({
...it, ...it,
link: it.link ? xorContinuous(keyHash, it.link, xorPos) : undefined, link: it.link ? xorContinuous(keyHash, it.link, xorPos) : undefined,
text: xorContinuous(keyHash, it.text, xorPos), text: xorContinuous(keyHash, it.text, xorPos),
})) }))
return { return {
encryptedData, encryptedData,
probe, probe,
probeEnc, probeEnc,
salt, salt,
pageViews, pageViews,
} }
} }
export type PageData = Awaited<ReturnType<typeof fetchDonatePageData>> export type PageData = Awaited<ReturnType<typeof fetchDonatePageData>>

View file

@ -1,11 +1,11 @@
--- ---
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { RandomWord } from '~/components/interactive/RandomWord/RandomWord'
import { umamiLogThisVisit } from '~/backend/service/umami' import { umamiLogThisVisit } from '~/backend/service/umami'
import { RandomWord } from '~/components/interactive/RandomWord'
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { PageMain as PageMainSolid } from './PageMain'
import { PARTTIME_VARIANTS } from './constants' import { PARTTIME_VARIANTS } from './constants'
import { fetchMainPageData } from './data' import { fetchMainPageData } from './data'
import { PageMain as PageMainSolid } from './PageMain'
import Shoutbox from './Shoutbox/Shoutbox.astro' import Shoutbox from './Shoutbox/Shoutbox.astro'
umamiLogThisVisit(Astro.request) umamiLogThisVisit(Astro.request)

View file

@ -1,146 +0,0 @@
@import url('../../shared.css');
.comment {
color: var(--text-secondary);
margin-bottom: 8px;
margin-left: 48px;
}
.commentInline {
color: var(--text-secondary);
margin-bottom: 8px;
margin-left: 2em;
}
.testimonial {
margin-bottom: 4px;
}
.favColor {
background: #be15dc;
border: 1px solid #ccc;
display: inline-block;
height: 10px;
margin-bottom: 2px;
vertical-align: middle;
width: 10px;
}
.webring {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
@mixin font-xs;
}
.lastSeen summary {
position: relative;
list-style: none;
cursor: pointer;
border-radius: 4px;
&::-webkit-details-marker {
display: none;
}
&:hover {
background: var(--control-bg-hover);
}
/* prevent text selection on 2/3-ple click */
&:active {
user-select: none;
}
}
.lastSeenItem {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.lastSeenItem + .lastSeenItem {
margin-top: 4px;
}
.lastSeen[open] {
margin-bottom: 4px;
}
.lastSeenTrigger::before {
content: '(click to expand)';
@mixin font-xs;
margin-left: 1em;
color: var(--text-secondary);
white-space: nowrap;
@media (--tablet) {
content: '(expand)';
}
@media (--mobile) {
content: '<';
}
}
.lastSeen[open] .lastSeenTrigger::before {
content: '(click to collapse)';
@media (--tablet) {
content: '(collapse)';
}
@media (--mobile) {
content: 'v';
}
}
.lastSeenLinkWrap {
display: flex;
align-items: center;
overflow: hidden;
max-width: 100%;
@media (--tablet) {
flex-direction: column;
justify-content: center;
align-items: start;
}
}
.lastSeenLinkWrapInner {
display: flex;
align-items: center;
width: min-content;
overflow: hidden;
max-width: 100%;
}
.lastSeenLink {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@media (--tablet) {
max-width: 300px;
}
}
.lastSeenSuffix {
@mixin font-xs;
white-space: nowrap;
}
.lastSeenSource {
@mixin font-xs;
color: var(--text-secondary);
margin-left: 8px;
white-space: nowrap;
@media (--tablet) {
margin-left: 0;
}
}

View file

@ -1,333 +1,339 @@
/** @jsxImportSource solid-js */ /** @jsxImportSource solid-js */
import { For, type JSX, Show } from 'solid-js' import type { PageData } from './data'
import { Dynamic } from 'solid-js/web' import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
import { intlFormatDistance } from 'date-fns' import { intlFormatDistance } from 'date-fns'
import { Emoji } from '~/components/ui/Emoji/Emoji' import { For, type JSX, Show } from 'solid-js'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle' import { Dynamic } from 'solid-js/web'
import { Link } from '~/components/ui/Link/Link'
import { TextComment } from '~/components/ui/TextComment/TextComment'
import { TextTable } from '~/components/ui/TextTable/TextTable'
import jsLogo from '~/assets/javascript.png'
import ukFlag from '~/assets/flag-united-kingdom_1f1ec-1f1e7.png'
import ruFlag from '~/assets/flag-russia_1f1f7-1f1fa.png'
import cherry from '~/assets/cherry-blossom_1f338.png'
import axolotl from '~/assets/axolotl.png' import axolotl from '~/assets/axolotl.png'
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen' import cherry from '~/assets/cherry-blossom_1f338.png'
import { randomInt } from '~/utils/random' import ruFlag from '~/assets/flag-russia_1f1f7-1f1fa.png'
import ukFlag from '~/assets/flag-united-kingdom_1f1ec-1f1e7.png'
import jsLogo from '~/assets/javascript.png'
import css from './PageMain.module.css' import { randomInt } from '~/utils/random'
import { cn } from '../../../utils/cn.ts'
import { Emoji } from '../../ui/Emoji.tsx'
import { Link } from '../../ui/Link.tsx'
import { SectionTitle } from '../../ui/Section.tsx'
import { TextComment } from '../../ui/TextComment.tsx'
import { TextTable } from '../../ui/TextTable.tsx'
import { SUBLINKS, TESTIMONIALS } from './constants' import { SUBLINKS, TESTIMONIALS } from './constants'
import type { PageData } from './data'
function formatTimeRelative(time: number) { function formatTimeRelative(time: number) {
return intlFormatDistance( return intlFormatDistance(
new Date(time), new Date(time),
new Date(), new Date(),
) )
} }
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) { function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
return ( return (
<Dynamic component={props.first ? 'summary' : 'div'} class={css.lastSeenItem}> <Dynamic
<div class={css.lastSeenLinkWrap}> component={props.first ? 'summary' : 'div'}
<div class={css.lastSeenLinkWrapInner}> class={cn(
<Link 'flex flex-row items-center justify-between',
class={css.lastSeenLink} props.first && 'pos-relative list-none cursor-pointer rounded-md hover:bg-control-bg-hover active:select-none [&::-webkit-details-marker]:hidden',
href={props.item.link} )}
target="_blank" >
title={props.item.text} <div class="max-w-full flex items-center overflow-hidden">
> <div class="width-min max-w-full flex items-center overflow-hidden">
{props.item.text} <Link
</Link> class="max-w-200px overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-300px"
{props.item.suffix && ( href={props.item.link}
<span class={css.lastSeenSuffix}> target="_blank"
{props.item.suffix} title={props.item.text}
</span> >
)} {props.item.text}
</div> </Link>
<i class={css.lastSeenSource}> {props.item.suffix && (
{'@ '} <span class="whitespace-nowrap text-xs">
<Link href={props.item.sourceLink} target="_blank"> {props.item.suffix}
{props.item.source} </span>
</Link> )}
{', '} </div>
{formatTimeRelative(props.item.time)} <i class="ml-2 whitespace-nowrap text-xs text-text-secondary lg:ml-0">
</i> {'@ '}
</div> <Link href={props.item.sourceLink} target="_blank">
<Show when={props.first}> {props.item.source}
<div class={css.lastSeenTrigger} /> </Link>
</Show> {', '}
</Dynamic> {formatTimeRelative(props.item.time)}
) </i>
</div>
<Show when={props.first}>
<div class="before:ml-1em before:whitespace-nowrap before:text-xs before:text-text-secondary before:content-['<'] lg:before:content-['(click_to_expand)'] md:before:content-['(expand)']" />
</Show>
</Dynamic>
)
} }
export function PageMain(props: { export function PageMain(props: {
data: PageData data: PageData
partTimeWords?: JSX.Element partTimeWords?: JSX.Element
shoutbox?: JSX.Element shoutbox?: JSX.Element
}) { }) {
const testimonials = TESTIMONIALS.map((props) => { const testimonials = TESTIMONIALS.map((props) => {
const link = props.href const link = props.href
? ( ? (
<Link href={props.href} target="_blank"> <Link href={props.href} target="_blank">
{props.author} {props.author}
</Link> </Link>
)
: <i>{props.author}</i>
return (
<div class={css.testimonial}>
"
{props.text}
"&nbsp;-&nbsp;
{link}
</div>
) )
}) : <i>{props.author}</i>
/* eslint-disable solid/no-innerhtml */
const sublinks = SUBLINKS.map(item => (
<div>
-
{' '}
<Link
href={item.link}
target="_blank"
data-astro-prefetch={item.noPrefetch ? 'false' : undefined}
>
{item.title}
</Link>
:
{' '}
<span innerHTML={item.subtitle} />
<TextComment
class={css.comment}
innerHTML={item.comment}
/>
</div>
))
/* eslint-enable solid/no-innerhtml */
return ( return (
<> <div class="mb-2">
<section>{`h${'i'.repeat(randomInt(2, 5))}~`}</section> "
{props.text}
<section> "&nbsp;-&nbsp;
i am {link}
{' '} </div>
<b>alina</b>
{' '}
aka
{' '}
<b>teidesu</b>
{' '}
🌸
<br />
full-time js/ts developer, part-time
{' '}
{props.partTimeWords}
{' '}
<br />
more about me as a dev on my
{' '}
<Link href="//github.com/teidesu" target="_blank">
github page
</Link>
</section>
<section>
<SectionTitle>
extremely interesting info (no):
</SectionTitle>
<TextTable
items={[
{ name: 'birthday', value: () => 'july 25 (leo ♌)' },
{
name: 'langs',
value: () => (
<>
<Emoji alt="🇷🇺" src={ruFlag.src} />
{' '}
native,
{' '}
<Emoji alt="🇬🇧" src={ukFlag.src} />
{' '}
c1,
{' '}
<Emoji alt="javascript" src={jsLogo.src} />
{' '}
native
</>
),
},
{
name: 'last seen',
value: () => {
if (!props.data.lastSeen?.length) return
return (
<details class={css.lastSeen}>
<LastSeenItem first item={props.data.lastSeen[0]} />
<For each={props.data.lastSeen.slice(1)}>
{it => <LastSeenItem item={it} />}
</For>
</details>
)
},
},
{
name: 'fav color',
value: () => (
<>
#be15dc
{' '}
<div class={css.favColor} />
</>
),
},
{
name: 'fav flower',
value: () => (
<>
cherry blossom
{' '}
<Emoji alt="🌸" src={cherry.src} />
, lilac
</>
),
},
{
name: 'fav animal',
value: () => (
<>
axolotl
{' '}
<Emoji alt="axolotl" src={axolotl.src} />
</>
),
},
{
name: 'fav anime',
value: () => (
<>
nichijou (
<Link href="//shikimori.one/animes/10165-nichijou" target="_blank">
shiki
</Link>
/
<Link href="//anilist.co/anime/10165/Nichijou" target="_blank">
anilist
</Link>
)
</>
),
},
{
name: 'fav music',
value: () => (
<>
hyperpop, digicore, happy hardcore
</>
),
},
]}
wrap
fill
/>
</section>
<section>
<SectionTitle>
contact me (in order of preference):
</SectionTitle>
<TextTable
items={[
{
name: 'telegram',
value: () => (
<Link href="//t.me/teidumb" target="_blank">
@teidumb
</Link>
),
},
{
name: 'bluesky',
value: () => (
<Link href="https://bsky.app/profile/did:web:tei.su" target="_blank">
@tei.pet
</Link>
),
},
{
name: 'matrix',
value: () => (
<Link href="//matrix.to/#/@teidesu:stupid.fish" target="_blank">
@teidesu:stupid.fish
</Link>
),
},
{
name: 'email',
value: () => props.data.email,
},
{
name: 'phone',
value: () => 'secret :p',
},
{
name: 'post pigeons',
value: () => 'please don\'t',
},
]}
wrap
/>
</section>
<section>
<SectionTitle>
testimonials from THEM:
</SectionTitle>
{testimonials}
<TextComment class={css.commentInline}>
feel free to leave yours :3
</TextComment>
</section>
{props.shoutbox}
<section>
<SectionTitle>
top secret sub-pages:
</SectionTitle>
{sublinks}
</section>
<section>
total page views so far:
{' '}
{props.data.pageViews}
</section>
<Show when={props.data.webring}>
<section class={css.webring}>
<Link href={props.data.webring!.prev.url}>
&lt;
{' '}
{props.data.webring!.prev.name}
</Link>
<Link href="https://otomir23.me/webring" target="_blank">
rutg webring
</Link>
<Link href={props.data.webring!.next.url}>
{props.data.webring!.next.name}
{' '}
&gt;
</Link>
</section>
</Show>
</>
) )
})
/* eslint-disable solid/no-innerhtml */
const sublinks = SUBLINKS.map(item => (
<div>
-
{' '}
<Link
href={item.link}
target="_blank"
data-astro-prefetch={item.noPrefetch ? 'false' : undefined}
>
{item.title}
</Link>
:
{' '}
<span innerHTML={item.subtitle} />
<TextComment
class="mb-2 ml-12 text-text-secondary"
innerHTML={item.comment}
/>
</div>
))
/* eslint-enable solid/no-innerhtml */
return (
<>
<section>{`h${'i'.repeat(randomInt(2, 5))}~`}</section>
<section>
i am
{' '}
<b>alina</b>
{' '}
aka
{' '}
<b>teidesu</b>
{' '}
🌸
<br />
full-time js/ts developer, part-time
{' '}
{props.partTimeWords}
{' '}
<br />
more about me as a dev on my
{' '}
<Link href="//github.com/teidesu" target="_blank">
github page
</Link>
</section>
<section>
<SectionTitle>
extremely interesting info (no):
</SectionTitle>
<TextTable
items={[
{ name: 'birthday', value: () => 'july 25 (leo ♌)' },
{
name: 'langs',
value: () => (
<>
<Emoji alt="🇷🇺" src={ruFlag.src} />
{' '}
native,
{' '}
<Emoji alt="🇬🇧" src={ukFlag.src} />
{' '}
c1,
{' '}
<Emoji alt="javascript" src={jsLogo.src} />
{' '}
native
</>
),
},
{
name: 'last seen',
value: () => {
if (!props.data.lastSeen?.length) return
return (
<details class="open:mb-1">
<LastSeenItem first item={props.data.lastSeen[0]} />
<For each={props.data.lastSeen.slice(1)}>
{it => <LastSeenItem item={it} />}
</For>
</details>
)
},
},
{
name: 'fav color',
value: () => (
<>
#be15dc
{' '}
<div class="mb-0.5 inline-block h-10px w-10px border border-#ccc bg-[#be15dc] align-middle" />
</>
),
},
{
name: 'fav flower',
value: () => (
<>
cherry blossom
{' '}
<Emoji alt="🌸" src={cherry.src} />
, lilac
</>
),
},
{
name: 'fav animal',
value: () => (
<>
axolotl
{' '}
<Emoji alt="axolotl" src={axolotl.src} />
</>
),
},
{
name: 'fav anime',
value: () => (
<>
nichijou (
<Link href="//shikimori.one/animes/10165-nichijou" target="_blank">
shiki
</Link>
/
<Link href="//anilist.co/anime/10165/Nichijou" target="_blank">
anilist
</Link>
)
</>
),
},
{
name: 'fav music',
value: () => (
<>
hyperpop, digicore, happy hardcore
</>
),
},
]}
wrap
fill
/>
</section>
<section>
<SectionTitle>
contact me (in order of preference):
</SectionTitle>
<TextTable
items={[
{
name: 'telegram',
value: () => (
<Link href="//t.me/teidumb" target="_blank">
@teidumb
</Link>
),
},
{
name: 'bluesky',
value: () => (
<Link href="https://bsky.app/profile/did:web:tei.su" target="_blank">
@tei.pet
</Link>
),
},
{
name: 'matrix',
value: () => (
<Link href="//matrix.to/#/@teidesu:stupid.fish" target="_blank">
@teidesu:stupid.fish
</Link>
),
},
{
name: 'email',
value: () => props.data.email,
},
{
name: 'phone',
value: () => 'secret :p',
},
{
name: 'post pigeons',
value: () => 'please don\'t',
},
]}
wrap
/>
</section>
<section>
<SectionTitle>
testimonials from THEM:
</SectionTitle>
{testimonials}
<TextComment class="mb-2 ml-2em text-text-secondary">
feel free to leave yours :3
</TextComment>
</section>
{props.shoutbox}
<section>
<SectionTitle>
top secret sub-pages:
</SectionTitle>
{sublinks}
</section>
<section>
total page views so far:
{' '}
{props.data.pageViews}
</section>
<Show when={props.data.webring}>
<section class="mt-4 flex items-center justify-between text-xs">
<Link href={props.data.webring!.prev.url}>
&lt;
{' '}
{props.data.webring!.prev.name}
</Link>
<Link href="https://otomir23.me/webring" target="_blank">
rutg webring
</Link>
<Link href={props.data.webring!.next.url}>
{props.data.webring!.next.name}
{' '}
&gt;
</Link>
</section>
</Show>
</>
)
} }

View file

@ -1,7 +1,7 @@
--- ---
import { fetchShouts } from '~/backend/service/shoutbox' import { fetchShouts } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request'
import { getCsrfToken } from '~/backend/utils/csrf' import { getCsrfToken } from '~/backend/utils/csrf'
import { getRequestIp } from '~/backend/utils/request'
import { Shoutbox as ShoutboxSolid } from './Shoutbox' import { Shoutbox as ShoutboxSolid } from './Shoutbox'
@ -17,9 +17,9 @@ const csrf = getCsrfToken(ip)
--- ---
<ShoutboxSolid <ShoutboxSolid
client:idle client:idle
csrf={csrf} csrf={csrf}
shoutError={shoutError} shoutError={shoutError}
initPage={page} initPage={page}
initPageData={data} initPageData={data}
/> />

View file

@ -1,75 +0,0 @@
@import '../../../../components/shared.css';
.form {
display: flex;
gap: 8px;
flex-direction: column;
width: 100%;
}
.formInput {
display: flex;
gap: 8px;
width: 100%;
}
.textarea {
width: 100%;
}
.shouts {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.header {
display: flex;
flex-direction: row;
gap: 8px;
color: var(--text-secondary);
}
.shout {
display: flex;
padding: 8px;
gap: 8px;
border: 1px solid var(--control-outline);
background: var(--control-bg);
border-radius: 4px;
width: fit-content;
@media (--mobile) {
flex-direction: column;
width: 100%;
}
}
.time {
white-space: nowrap;
}
.text {
white-space: pre-wrap;
}
.formControls {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.pagination {
display: flex;
gap: 8px;
color: var(--text-secondary);
}
.paginationLink {
color: var(--text-secondary);
}
.reply {
margin-top: 6px;
}

View file

@ -1,222 +1,211 @@
/* eslint-disable no-alert */
/** @jsxImportSource solid-js */ /** @jsxImportSource solid-js */
import { type ComponentProps, Show, createSignal, onMount } from 'solid-js' /* eslint-disable no-alert */
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query' import type { ShoutsData } from '~/backend/service/shoutbox'
import { createQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { format } from 'date-fns/format' import { format } from 'date-fns/format'
import { Button } from '~/components/ui/Button/Button' import { type ComponentProps, createSignal, onMount, Show } from 'solid-js'
import { Checkbox } from '~/components/ui/Checkbox/Checkbox' import { Button } from '../../../ui/Button.tsx'
import { GravityClock } from '~/components/ui/Icons/glyphs/GravityClock'
import { GravityMegaphone } from '~/components/ui/Icons/glyphs/GravityMegaphone'
import { Icon } from '~/components/ui/Icons/Icon'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import { TextArea } from '~/components/ui/TextArea/TextArea'
import { TextComment } from '~/components/ui/TextComment/TextComment'
import type { ShoutsData } from '~/backend/service/shoutbox'
import pageCss from '../PageMain.module.css'
import css from './Shoutbox.module.css' import { Checkbox } from '../../../ui/Checkbox/Checkbox.tsx'
import { SectionTitle } from '../../../ui/Section.tsx'
import { TextArea } from '../../../ui/TextArea.tsx'
import { TextComment } from '../../../ui/TextComment.tsx'
async function fetchShouts(page: number): Promise<ShoutsData> { async function fetchShouts(page: number): Promise<ShoutsData> {
return fetch(`/api/shoutbox?page=${page}`).then(r => r.json()) return fetch(`/api/shoutbox?page=${page}`).then(r => r.json())
} }
function ShoutboxInner(props: { function ShoutboxInner(props: {
initPage: number initPage: number
initPageData: ShoutsData initPageData: ShoutsData
shoutError?: string shoutError?: string
csrf: string csrf: string
}) { }) {
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
const [page, setPage] = createSignal(props.initPage) const [page, setPage] = createSignal(props.initPage)
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
const [initData, setInitData] = createSignal<ShoutsData | undefined>(props.initPageData) const [initData, setInitData] = createSignal<ShoutsData | undefined>(props.initPageData)
const shouts = createQuery(() => ({ const shouts = createQuery(() => ({
queryKey: ['shouts', page()], queryKey: ['shouts', page()],
queryFn: () => fetchShouts(page()), queryFn: () => fetchShouts(page()),
cacheTime: 0, cacheTime: 0,
gcTime: 0, gcTime: 0,
refetchInterval: 30000, refetchInterval: 30000,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
initialData: initData, initialData: initData,
})) }))
const [sending, setSending] = createSignal(false) const [sending, setSending] = createSignal(false)
const [jsEnabled, setJsEnabled] = createSignal(false) const [jsEnabled, setJsEnabled] = createSignal(false)
onMount(() => setJsEnabled(true)) onMount(() => setJsEnabled(true))
const onPageClick = (next: boolean) => (e: MouseEvent) => { const onPageClick = (next: boolean) => (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setInitData(undefined) setInitData(undefined)
const newPage = next ? page() + 1 : page() - 1 const newPage = next ? page() + 1 : page() - 1
const link = e.currentTarget as HTMLAnchorElement const link = e.currentTarget as HTMLAnchorElement
const href = link.href const href = link.href
history.replaceState(null, '', href) history.replaceState(null, '', href)
setPage(newPage) setPage(newPage)
} }
const shoutsRender = () => shouts.data?.items.map((props) => { const shoutsRender = () => shouts.data?.items.map((props) => {
const icon = props.pending const icon = props.pending
? ( ? <div class="i-gravity-ui-clock size-4" title="awaiting moderation" />
<Icon : `#${props.serial}`
glyph={GravityClock}
size={16}
title="awaiting moderation"
/>
)
: `#${props.serial}`
return (
<div class={css.shout}>
<div class={css.header}>
{icon}
<time class={css.time} datetime={props.createdAt}>
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
</time>
</div>
<div class={css.text}>
{props.text}
{props.reply && (
<div class={css.reply}>
<b>reply: </b>
{props.reply}
</div>
)}
</div>
</div>
)
})
let privateCheckbox!: HTMLInputElement
let messageInput!: HTMLTextAreaElement
const onSubmit = (e: Event) => {
e.preventDefault()
setSending(true)
setInitData(undefined)
const isPrivate = privateCheckbox.checked
fetch('/api/shoutbox', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
_csrf: props.csrf,
message: messageInput.value,
private: isPrivate,
}),
})
.then(res => res.json())
.then((data) => {
if (data.error) {
alert(data.error + (data.message ? `: ${data.message}` : ''))
} else if (isPrivate) {
alert('private message sent')
messageInput.value = ''
} else {
alert('shout sent! it will appear after moderation')
shouts.refetch()
messageInput.value = ''
}
setSending(false)
})
}
const placeholder = () => {
if (props.shoutError) return props.shoutError
if (!jsEnabled()) return '⚠️ please enable javascript to use the form.\nim sorry, but there are just too many spammers out there :c'
return 'let the void hear you'
}
return ( return (
<div class="w-fit w-full flex flex-col gap-2 border border-control-outline rounded-md bg-control-bg p-2 md:w-min md:flex-row">
<section> <div class="flex flex-row gap-2 text-text-secondary">
<SectionTitle>shoutbox!</SectionTitle> {icon}
<TextComment class={pageCss.comment}> <time class="whitespace-nowrap" datetime={props.createdAt}>
disclaimer: shouts {format(props.createdAt, 'yyyy-MM-dd HH:mm')}
{' '} </time>
<i>are</i> </div>
{' '} <div class="whitespace-pre-wrap">
pre-moderated, but they do not reflect my&nbsp;views. {props.text}
</TextComment> {props.reply && (
<div class="mt-1.5">
<div class={css.form}> <b>reply: </b>
<input type="hidden" name="_csrf" value={props.csrf} /> {props.reply}
<div class={css.formInput}>
<TextArea
ref={messageInput}
disabled={sending() || !jsEnabled()}
class={css.textarea}
grow
maxRows={5}
name="message"
placeholder={placeholder()}
required
/>
<Button
type="submit"
onClick={onSubmit}
disabled={sending() || !jsEnabled()}
title="submit"
>
<Icon glyph={GravityMegaphone} size={16} />
</Button>
</div>
<div class={css.formControls}>
<Checkbox
ref={privateCheckbox}
label="make it private"
name="private"
/>
<Show when={shouts.data && shouts.data.pageCount > 1}>
<div class={css.pagination}>
<Show when={page() > 0}>
<a
class={css.paginationLink}
rel="external"
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
onClick={onPageClick(false)}
data-astro-reload
>
&lt; prev
</a>
</Show>
<span>{page() + 1}</span>
<Show when={page() < shouts.data!.pageCount - 1}>
<a
class={css.paginationLink}
rel="external"
href={`?shouts_page=${page() + 1}`}
onClick={onPageClick(true)}
data-astro-reload
>
next &gt;
</a>
</Show>
</div>
</Show>
</div>
</div> </div>
)}
<div class={css.shouts}> </div>
{shoutsRender()} </div>
</div>
</section>
) )
})
let privateCheckbox!: HTMLInputElement
let messageInput!: HTMLTextAreaElement
const onSubmit = (e: Event) => {
e.preventDefault()
setSending(true)
setInitData(undefined)
const isPrivate = privateCheckbox.checked
fetch('/api/shoutbox', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
_csrf: props.csrf,
message: messageInput.value,
private: isPrivate,
}),
})
.then(res => res.json())
.then((data) => {
if (data.error) {
alert(data.error + (data.message ? `: ${data.message}` : ''))
} else if (isPrivate) {
alert('private message sent')
messageInput.value = ''
} else {
alert('shout sent! it will appear after moderation')
shouts.refetch()
messageInput.value = ''
}
setSending(false)
})
}
const placeholder = () => {
if (props.shoutError) return props.shoutError
if (!jsEnabled()) return '⚠️ please enable javascript to use the form.\nim sorry, but there are just too many spammers out there :c'
return 'let the void hear you'
}
return (
<section>
<SectionTitle>shoutbox!</SectionTitle>
<TextComment class="mb-2 ml-12 text-text-secondary">
disclaimer: shouts
{' '}
<i>are</i>
{' '}
pre-moderated, but they do not reflect my&nbsp;views.
</TextComment>
<div class="w-full flex flex-col gap-2">
<input type="hidden" name="_csrf" value={props.csrf} />
<div class="w-full flex gap-2">
<TextArea
ref={messageInput}
disabled={sending() || !jsEnabled()}
class="w-full"
grow
maxRows={5}
name="message"
placeholder={placeholder()}
required
/>
<Button
type="submit"
onClick={onSubmit}
disabled={sending() || !jsEnabled()}
title="submit"
>
<div class="i-gravity-ui-megaphone size-5" />
</Button>
</div>
<div class="flex flex-row justify-between">
<Checkbox
ref={privateCheckbox}
label="make it private"
name="private"
/>
<Show when={shouts.data && shouts.data.pageCount > 1}>
<div class="flex gap-2 text-text-secondary">
<Show when={page() > 0}>
<a
class="text-text-secondary underline underline-offset-2"
rel="external"
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
onClick={onPageClick(false)}
data-astro-reload
>
&lt; prev
</a>
</Show>
<span>{page() + 1}</span>
<Show when={page() < shouts.data!.pageCount - 1}>
<a
class="text-text-secondary underline underline-offset-2"
rel="external"
href={`?shouts_page=${page() + 1}`}
onClick={onPageClick(true)}
data-astro-reload
>
next &gt;
</a>
</Show>
</div>
</Show>
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
{shoutsRender()}
</div>
</section>
)
} }
export function Shoutbox(props: ComponentProps<typeof ShoutboxInner>) { export function Shoutbox(props: ComponentProps<typeof ShoutboxInner>) {
const client = new QueryClient() const client = new QueryClient()
return ( return (
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<ShoutboxInner {...props} /> <ShoutboxInner {...props} />
</QueryClientProvider> </QueryClientProvider>
) )
} }

View file

@ -1,67 +1,67 @@
export const PARTTIME_VARIANTS = [ export const PARTTIME_VARIANTS = [
'anime girl', 'anime girl',
'puppygirl', 'puppygirl',
'human being', 'human being',
'shitposter', 'shitposter',
'js fanatic', 'js fanatic',
'dumbass', 'dumbass',
'delulu', 'delulu',
'silly goofball', 'silly goofball',
] ]
export const TESTIMONIALS = [ export const TESTIMONIALS = [
{ author: 'sanspie', href: 'https://akarpov.ru/about', text: 'lil purry cat(cute!)' }, { author: 'sanspie', href: 'https://akarpov.ru/about', text: 'lil purry cat(cute!)' },
{ author: 'attorelle', text: 'today she asks to dm her "tomato", tomorrow she\'ll ask to sign over apartment to her' }, { author: 'attorelle', text: 'today she asks to dm her "tomato", tomorrow she\'ll ask to sign over apartment to her' },
{ author: 'astrra', href: 'https://astrra.space', text: 'why are you in my walls why are you in my walls why are you in my walls why are you in my walls' }, { author: 'astrra', href: 'https://astrra.space', text: 'why are you in my walls why are you in my walls why are you in my walls why are you in my walls' },
{ author: 'wffl', href: 'https://ihatereality.space', text: 'i knew a girl with the same name in my childhood' }, { author: 'wffl', href: 'https://ihatereality.space', text: 'i knew a girl with the same name in my childhood' },
{ author: 'mo', href: 'https://mo.rijndael.cc', text: 'okay okay, i will write a review for you please don\'t be offended' }, { author: 'mo', href: 'https://mo.rijndael.cc', text: 'okay okay, i will write a review for you please don\'t be offended' },
{ author: 'svpra', text: 'i like to write all sorts of cute phrases on other people\'s websites when nobody asks about it. meow.' }, { author: 'svpra', text: 'i like to write all sorts of cute phrases on other people\'s websites when nobody asks about it. meow.' },
{ author: 'toil', href: 'https://toil.cc', text: 'i like cute anime girls (oops it\'s you) <3' }, { author: 'toil', href: 'https://toil.cc', text: 'i like cute anime girls (oops it\'s you) <3' },
{ author: 'jsopn', href: 'https://jsopn.com', text: 'barks at me in js' }, { author: 'jsopn', href: 'https://jsopn.com', text: 'barks at me in js' },
] ]
export const SUBLINKS = [ export const SUBLINKS = [
{ {
link: '/nudes', link: '/nudes',
title: 'nudes', title: 'nudes',
subtitle: '( ͡° ͜ʖ ͡°)', subtitle: '( ͡° ͜ʖ ͡°)',
comment: 'a lot of them, actually', comment: 'a lot of them, actually',
noPrefetch: true, noPrefetch: true,
}, },
{ {
link: '/cheerio/index.html', link: '/cheerio/index.html',
title: 'cheerio', title: 'cheerio',
subtitle: 'cheerio repl for debugging and stuff', subtitle: 'cheerio repl for debugging and stuff',
comment: 'made in 20 minutes, use it all the time, very useful /gen', comment: 'made in 20 minutes, use it all the time, very useful /gen',
}, },
{ {
link: '/gdz', link: '/gdz',
title: 'gdz', title: 'gdz',
subtitle: 'custom frontend for gdz.ru (fetches <i>*everything*</i> for free)', subtitle: 'custom frontend for gdz.ru (fetches <i>*everything*</i> for free)',
comment: 'i made it a long time ago and everything is very bad 💀<br />idk how it still works', comment: 'i made it a long time ago and everything is very bad 💀<br />idk how it still works',
}, },
{ {
link: '/oauth.blank.html', link: '/oauth.blank.html',
title: 'oauth.blank.html', title: 'oauth.blank.html',
subtitle: 'thingy for redirect_uri', subtitle: 'thingy for redirect_uri',
comment: 'i promise it doesn\'t collect your tokens', comment: 'i promise it doesn\'t collect your tokens',
}, },
{ {
link: '/proxifier.html', link: '/proxifier.html',
title: 'proxifier.html', title: 'proxifier.html',
subtitle: 'proxifier keygen', subtitle: 'proxifier keygen',
comment: 'basically a port of some c# implementation bc im lazy', comment: 'basically a port of some c# implementation bc im lazy',
}, },
{ {
link: '/spring.html', link: '/spring.html',
title: 'spring.html', title: 'spring.html',
subtitle: 'no idea', subtitle: 'no idea',
comment: 'spring physics in ui are fun', comment: 'spring physics in ui are fun',
}, },
{ {
link: '/test_voice.ogg', link: '/test_voice.ogg',
title: 'test_voice.ogg', title: 'test_voice.ogg',
subtitle: 'фильм земляне 2005 года смотреть всем', subtitle: 'фильм земляне 2005 года смотреть всем',
comment: 'libopus encoded, valid for telegram', comment: 'libopus encoded, valid for telegram',
}, },
] ]

View file

@ -1,31 +1,31 @@
import { obfuscateEmail } from '~/backend/utils/obfuscate-email'
import { webring } from '~/backend/service/webring'
import { umamiFetchStats } from '~/backend/service/umami'
import { fetchLastSeen } from '~/backend/service/last-seen' import { fetchLastSeen } from '~/backend/service/last-seen'
import { umamiFetchStats } from '~/backend/service/umami'
import { webring } from '~/backend/service/webring'
import { obfuscateEmail } from '~/backend/utils/obfuscate-email'
export async function fetchMainPageData() { export async function fetchMainPageData() {
const [ const [
pageViews, pageViews,
webringData, webringData,
lastSeen, lastSeen,
] = await Promise.all([ ] = await Promise.all([
umamiFetchStats('/', 1700088965789) umamiFetchStats('/', 1700088965789)
.then(stats => `${stats.visitors.value + 321487}`) // value before umami .then(stats => `${stats.visitors.value + 321487}`) // value before umami
.catch((err) => { .catch((err) => {
console.error('Failed to fetch page views: ', err) console.error('Failed to fetch page views: ', err)
return '[error]' return '[error]'
}), }),
webring.get(), webring.get(),
fetchLastSeen(), fetchLastSeen(),
]) ])
return { return {
email: obfuscateEmail('alina@tei.su'), email: obfuscateEmail('alina@tei.su'),
pageViews, pageViews,
shouts: [], shouts: [],
webring: webringData, webring: webringData,
lastSeen, lastSeen,
} }
} }
export type PageData = Awaited<ReturnType<typeof fetchMainPageData>> export type PageData = Awaited<ReturnType<typeof fetchMainPageData>>

View file

@ -0,0 +1,24 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { splitProps } from 'solid-js'
import { cn } from '../../utils/cn.ts'
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
square?: boolean
}
export function Button(props: ButtonProps) {
const [my, rest] = splitProps(props, ['square', 'class'])
return (
<button
{...rest}
class={cn(
'block px-2 py-3 border border-control-outline color-text-primary bg-control-bg hover:bg-control-bg-hover rounded-md active:scale-95 transition-all cursor-pointer',
my.square && 'p-3 h-min',
my.class,
)}
/>
)
}

View file

@ -1,22 +0,0 @@
.button {
padding: 8px 12px;
border: 1px solid var(--control-outline);
background: var(--control-bg);
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s, transform 0.2s;
transition-timing-function: ease-in-out;
&:hover {
background: var(--control-bg-hover);
}
&:active {
transform: scale(0.95);
}
}
.square {
height: min-content;
padding: 12px;
}

View file

@ -1,25 +0,0 @@
/** @jsxImportSource solid-js */
import { splitProps } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'
import clsx from 'clsx'
import css from './Button.module.css'
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
square?: boolean
}
export function Button(props: ButtonProps) {
const [my, rest] = splitProps(props, ['square', 'class'])
return (
<button
{...rest}
class={clsx(
css.button,
my.square && css.square,
my.class,
)}
/>
)
}

View file

@ -1,41 +1,18 @@
.input { .input {
display: none; @apply hidden;
} }
.label { .label {
display: flex; @apply flex items-center gap-2 cursor-pointer select-none;
gap: 8px;
align-items: center;
cursor: pointer;
&:active {
user-select: none;
}
} }
.box { .box {
width: 1em; @apply bg-control-bg border-control-outline hover:bg-control-bg-hover pos-relative h-4 w-4 border rounded-md transition-all;
height: 1em;
background: var(--control-bg);
border: 1px solid var(--control-outline);
border-radius: 4px;
position: relative;
transition: background 0.2s;
&:hover {
background: var(--control-bg-hover);
}
} }
.input:checked + .label .box::before { .input:checked + .label .box::before {
content: ''; content: '';
display: 'block'; display: 'block';
width: 8px;
height: 8px; @apply bg-text-primary absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-2 h-2 rounded-sm;
background: var(--text-primary);
border-radius: 2px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
} }

View file

@ -5,27 +5,27 @@ import { splitProps } from 'solid-js'
import css from './Checkbox.module.css' import css from './Checkbox.module.css'
export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> { export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
class?: string class?: string
label?: JSX.Element label?: JSX.Element
} }
export function Checkbox(props: CheckboxProps) { export function Checkbox(props: CheckboxProps) {
const [my, rest] = splitProps(props, ['label', 'class']) const [my, rest] = splitProps(props, ['label', 'class'])
const id = `checkbox-${Math.random().toString(36).slice(2)}` const id = `checkbox-${Math.random().toString(36).slice(2)}`
return ( return (
<div class={my.class}> <div class={my.class}>
<input <input
{...rest} {...rest}
type="checkbox" type="checkbox"
class={css.input} class={css.input}
id={id} id={id}
/> />
<label class={css.label} for={id} tabIndex={0}> <label class={css.label} for={id} tabIndex={0}>
<div class={css.box} /> <div class={css.box} />
{my.label} {my.label}
</label> </label>
</div> </div>
) )
} }

View file

@ -0,0 +1,16 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import { cn } from '../../utils/cn.ts'
export function Emoji(props: JSX.HTMLElementTags['img']) {
return (
<img
{...props}
class={cn(
'inline-block h-1em w-1em object-contain overflow-hidden align-middle',
props.class,
)}
/>
)
}

View file

@ -1,8 +0,0 @@
.emoji {
display: inline-block;
height: 1em;
object-fit: contain;
overflow: hidden;
vertical-align: middle;
width: 1em;
}

View file

@ -1,14 +0,0 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import clsx from 'clsx'
import css from './Emoji.module.css'
export function Emoji(props: JSX.HTMLElementTags['img']) {
return (
<img
{...props}
class={clsx(css.emoji, props.class)}
/>
)
}

View file

@ -1,3 +0,0 @@
.wrap {
display: inline-flex;
}

View file

@ -1,27 +0,0 @@
/** @jsxImportSource solid-js */
import type { Component, JSX } from 'solid-js'
import { splitProps } from 'solid-js'
import clsx from 'clsx'
import css from './Icon.module.css'
export interface IconProps extends JSX.HTMLAttributes<HTMLSpanElement> {
glyph: Component
size?: number
}
export function Icon(props: IconProps) {
const [my, rest] = splitProps(props, ['glyph', 'size', 'class'])
return (
<span
{...rest}
class={clsx(css.wrap, my.class)}
style={{
'font-size': `${my.size ?? 24}px`,
}}
>
{my.glyph({})}
</span>
)
}

View file

@ -1,23 +0,0 @@
Files matching Gravity*.tsx are licensed under:
The MIT License (MIT)
Copyright (c) 2022 YANDEX LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,13 +0,0 @@
/** @jsxImportSource solid-js */
export function GravityClock() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16">
<path
fill="currentColor"
fill-rule="evenodd"
d="M13.5 8a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0M8.75 4.5a.75.75 0 0 0-1.5 0V8a.75.75 0 0 0 .3.6l2 1.5a.75.75 0 1 0 .9-1.2l-1.7-1.275z"
clip-rule="evenodd"
/>
</svg>
)
}

View file

@ -1,13 +0,0 @@
/** @jsxImportSource solid-js */
export function GravityMegaphone() {
return (
<svg height="1em" viewBox="0 0 16 16" width="1em" xmlns="http://www.w3.org/2000/svg">
<path
clip-rule="evenodd"
d="M11.113 11.615c.374.814.713.885.887.885c.174 0 .513-.071.887-.885c.377-.816.613-2.077.613-3.615c0-1.538-.236-2.799-.613-3.615c-.374-.814-.713-.885-.887-.885c-.174 0-.513.071-.887.885C10.736 5.2 10.5 6.462 10.5 8c0 1.538.236 2.799.613 3.615M9 8c0 1.469.197 2.815.59 3.857L2.902 9.31a1.402 1.402 0 0 1 0-2.62l6.686-2.548C9.196 5.185 9 6.532 9 8m3 6c2 0 3-2.686 3-6s-1-6-3-6c-.661 0-1.317.12-1.934.356L2.369 5.288a2.902 2.902 0 0 0 0 5.424l.827.315a2.5 2.5 0 1 0 4.67 1.78l2.2.837A5.433 5.433 0 0 0 12 14m-5.537-1.729L4.6 11.563a1 1 0 1 0 1.862.71Z"
fill="currentColor"
fill-rule="evenodd"
/>
</svg>
)
}

View file

@ -0,0 +1,17 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { cn } from '../../utils/cn.ts'
export function Link(props: JSX.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a
{...props}
class={cn(
'color-text-accent underline underline-offset-2 hover:no-underline cursor-pointer active:text-text-secondary',
props.class,
)}
>
{props.children}
</a>
)
}

View file

@ -1,3 +0,0 @@
.link {
color: var(--text-accent);
}

View file

@ -1,16 +0,0 @@
/** @jsxImportSource solid-js */
import clsx from 'clsx'
import type { JSX } from 'solid-js/jsx-runtime'
import css from './Link.module.css'
export function Link(props: JSX.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a
{...props}
class={clsx(css.link, props.class)}
>
{props.children}
</a>
)
}

View file

@ -0,0 +1,14 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import { cn } from '../../utils/cn.ts'
export function SectionTitle(props: {
class?: string
children: JSX.Element
}) {
return (
<h3 class={cn('m-0 mb-1 text-md font-bold', props.class)}>
{props.children}
</h3>
)
}

View file

@ -1,37 +0,0 @@
@import '../../shared.css';
.app {
display: flex;
justify-content: center;
}
.content {
display: flex;
flex-direction: column;
gap: 1.5em;
overflow: hidden;
width: 900px;
padding: 24px;
@media (max-width: 900px) {
width: 720px;
}
@media (--tablet) {
width: 100%;
}
}
.footer {
border-top: 1px solid var(--text-secondary);
padding-top: 8px;
margin-inline: 16;
text-align: center;
color: var(--text-secondary);
@mixin font-2xs;
}
.sectionTitle {
font-weight: bold;
margin: 0 0 4px 0;
@mixin font-md;
}

View file

@ -1,12 +0,0 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import css from './SectionTitle.module.css'
export function SectionTitle(props: { children: JSX.Element }) {
return (
<h3 class={css.sectionTitle}>
{props.children}
</h3>
)
}

View file

@ -0,0 +1,70 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { splitProps } from 'solid-js'
import { cn } from '../../utils/cn.ts'
export interface TextAreaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
grow?: boolean
maxRows?: number
}
function calculateLinesByScrollHeight(args: {
height: number
lineHeight: number
paddingBottom: number
paddingTop: number
}) {
const { height, lineHeight } = args
const paddingTop = Number.isNaN(args.paddingTop) ? 0 : args.paddingTop
const paddingBottom = Number.isNaN(args.paddingBottom) ? 0 : args.paddingBottom
return (height - paddingTop - paddingBottom) / lineHeight
}
export function TextArea(props: TextAreaProps) {
const [my, rest] = splitProps(props, ['grow', 'class', 'maxRows'])
const onInput = (e: Event) => {
// @ts-expect-error lol
props.onInput?.(e)
if (!my.grow) return
const control = e.target as HTMLTextAreaElement
// based on https://github.com/gravity-ui/uikit/blob/main/src/components/controls/TextArea/TextAreaControl.tsx
const controlStyles = getComputedStyle(control)
const lineHeight = Number.parseInt(controlStyles.getPropertyValue('line-height'), 10)
const paddingTop = Number.parseInt(controlStyles.getPropertyValue('padding-top'), 10)
const paddingBottom = Number.parseInt(controlStyles.getPropertyValue('padding-bottom'), 10)
const innerValue = control.value
const linesWithCarriageReturn = (innerValue?.match(/\n/g) || []).length + 1
const linesByScrollHeight = calculateLinesByScrollHeight({
height: control.scrollHeight,
paddingTop,
paddingBottom,
lineHeight,
})
control.style.height = 'auto'
const maxRows = my.maxRows
if (maxRows && maxRows < Math.max(linesByScrollHeight, linesWithCarriageReturn)) {
control.style.height = `${maxRows * lineHeight + 2 * paddingTop + 2}px`
} else if (linesWithCarriageReturn > 1 || linesByScrollHeight > 1) {
control.style.height = `${control.scrollHeight + 2}px`
}
}
return (
<textarea
{...rest}
class={cn(
'border border-control-outline bg-control-bg rounded-md text-text-primary min-h-4em resize-none p-2 transition-all text-sm placeholder-text-secondary cursor-pointer hover:bg-control-bg-hover-alt disabled:cursor-not-allowed disabled:bg-control-bg-disabled disabled:border-text-disabled disabled:placeholder-text-disabled focus:border-text-primary focus:bg-control-bg-active focus:outline-1 focus:outline-text-primary focus:hover:cursor-text',
my.class,
)}
onInput={onInput}
/>
)
}

View file

@ -1,39 +0,0 @@
@import '../../shared.css';
.box {
border: 1px solid var(--control-outline);
background: var(--control-bg);
border-radius: 4px;
color: var(--text-primary);
min-height: 4em;
resize: none;
padding: 8px;
transition: background 0.2s;
@mixin font-sm;
&::placeholder {
color: var(--text-secondary);
font-style: italic;
}
&:hover:not(:focus):not([disabled]) {
cursor: pointer;
background: var(--bg-hover-alt);
}
&:focus {
border: 1px solid var(--text-primary);
outline: 1px solid var(--text-primary);
background: var(--bg-active);
}
&[disabled] {
cursor: not-allowed;
background: var(--control-bg-disabled);
border-color: var(--text-disabled);
}
&[disabled]::placeholder {
color: var(--text-disabled);
}
}

View file

@ -1,68 +0,0 @@
/** @jsxImportSource solid-js */
import { splitProps } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'
import clsx from 'clsx'
import css from './TextArea.module.css'
export interface TextAreaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
grow?: boolean
maxRows?: number
}
function calculateLinesByScrollHeight(args: {
height: number
lineHeight: number
paddingBottom: number
paddingTop: number
}) {
const { height, lineHeight } = args
const paddingTop = Number.isNaN(args.paddingTop) ? 0 : args.paddingTop
const paddingBottom = Number.isNaN(args.paddingBottom) ? 0 : args.paddingBottom
return (height - paddingTop - paddingBottom) / lineHeight
}
export function TextArea(props: TextAreaProps) {
const [my, rest] = splitProps(props, ['grow', 'class', 'maxRows'])
const onInput = (e: Event) => {
// @ts-expect-error lol
props.onInput?.(e)
if (!my.grow) return
const control = e.target as HTMLTextAreaElement
// based on https://github.com/gravity-ui/uikit/blob/main/src/components/controls/TextArea/TextAreaControl.tsx
const controlStyles = getComputedStyle(control)
const lineHeight = Number.parseInt(controlStyles.getPropertyValue('line-height'), 10)
const paddingTop = Number.parseInt(controlStyles.getPropertyValue('padding-top'), 10)
const paddingBottom = Number.parseInt(controlStyles.getPropertyValue('padding-bottom'), 10)
const innerValue = control.value
const linesWithCarriageReturn = (innerValue?.match(/\n/g) || []).length + 1
const linesByScrollHeight = calculateLinesByScrollHeight({
height: control.scrollHeight,
paddingTop,
paddingBottom,
lineHeight,
})
control.style.height = 'auto'
const maxRows = my.maxRows
if (maxRows && maxRows < Math.max(linesByScrollHeight, linesWithCarriageReturn)) {
control.style.height = `${maxRows * lineHeight + 2 * paddingTop + 2}px`
} else if (linesWithCarriageReturn > 1 || linesByScrollHeight > 1) {
control.style.height = `${control.scrollHeight + 2}px`
}
}
return (
<textarea
{...rest}
class={clsx(css.box, my.class)}
onInput={onInput}
/>
)
}

View file

@ -0,0 +1,16 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { cn } from '../../utils/cn.ts'
export function TextComment(props: JSX.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
class={cn(
'text-text-secondary pos-relative before:content-dblslash before:pos-absolute before:-left-2em',
props.class,
)}
/>
)
}

View file

@ -1,10 +0,0 @@
.comment {
color: var(--text-secondary);
position: relative;
&:before {
content: '// ';
position: absolute;
left: -2em;
}
}

View file

@ -1,14 +0,0 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import clsx from 'clsx'
import css from './TextComment.module.css'
export function TextComment(props: JSX.HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
class={clsx(css.comment, props.class)}
/>
)
}

View file

@ -0,0 +1,43 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import { cn } from '../../utils/cn.ts'
export interface TextTableProps {
items: {
name: string
value: () => JSX.Element | false | null | undefined
}[]
minColumnWidth?: number
wrap?: boolean
fill?: boolean
}
export function TextTable(props: TextTableProps) {
const rows = () => props.items.map((item) => {
const value = item.value()
if (!value) return null
return (
<>
<div class="whitespace-nowrap p-0 pr-2em align-text-top">
{item.name}
</div>
<div class="overflow-hidden p-0">
{item.value()}
</div>
</>
)
}).filter(Boolean)
return (
<div
class={cn(
'grid grid-cols-[1fr_4fr] md:grid-cols-[1fr_2fr] overflow-hidden border-spacing-0 line-height-18px',
props.wrap ? 'whitespace-pre-wrap' : 'text-ellipsis whitespace-pre',
props.fill && 'w-full',
)}
>
{rows()}
</div>
)
}

View file

@ -1,43 +0,0 @@
.table {
border-spacing: 0;
line-height: 18px;
display: grid;
grid-template-columns: 1fr 4fr;
overflow: hidden;
@media (--tablet) {
grid-template-columns: 1fr 2fr;
}
}
.name {
white-space: nowrap;
padding: 0;
padding-right: 2em;
vertical-align: text-top;
}
.value {
padding: 0;
overflow: hidden;
}
.normal {
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
}
.normal .value {
overflow: hidden;
text-overflow: ellipsis;
}
.wrap {
white-space: pre-wrap;
}
.fill {
width: 100%;
}

View file

@ -1,40 +0,0 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import clsx from 'clsx'
import css from './TextTable.module.css'
export interface TextTableProps {
items: {
name: string
value: () => JSX.Element | false | null | undefined
}[]
minColumnWidth?: number
wrap?: boolean
fill?: boolean
}
export function TextTable(props: TextTableProps) {
const rows = () => props.items.map((item) => {
const value = item.value()
if (!value) return null
return (
<>
<div class={css.name}>{item.name}</div>
<div class={css.value}>{item.value()}</div>
</>
)
}).filter(Boolean)
return (
<div
class={clsx(
css.table,
props.wrap ? css.wrap : css.normal,
props.fill && css.fill,
)}
>
{rows()}
</div>
)
}

View file

@ -1,6 +1,6 @@
--- ---
import { ClientRouter } from 'astro:transitions'
import LoadingIndicator from 'astro-loading-indicator/component' import LoadingIndicator from 'astro-loading-indicator/component'
import { ClientRouter } from 'astro:transitions'
import cherry from '~/assets/cherry-blossom_1f338.png' import cherry from '~/assets/cherry-blossom_1f338.png'
@ -81,18 +81,11 @@ const finalOg = { ...defaultOgTags, ...og }
box-sizing: border-box; box-sizing: border-box;
} }
*:focus { *:not(:active, summary):focus {
outline-color: var(--text-primary) outline: 1px solid var(--text-primary)
} }
body { body {
background-color: var(--bg); @apply text-sm m-0 p-0 overflow-x-hidden overflow-y-auto bg-bg font-mono text-text-primary;
color: var(--text-primary);
font-family: var(--font-family-monospace);
overflow-x: hidden;
overflow-y: scroll;
margin: 0;
padding: 0;
@mixin font-sm;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
--- ---
import { Link } from '../../components/ui/Link/Link' import { Link } from '../../components/ui/Link.tsx'
import BaseLayout, { type Props } from '../BaseLayout.astro' import BaseLayout, { type Props } from '../BaseLayout.astro'
import Header from './Header.astro' import Header from './Header.astro'

View file

@ -1,18 +1,18 @@
--- ---
import karin from '~/assets/karin.gif' import karin from '~/assets/karin.gif'
import { Link } from '~/components/ui/Link/Link' import { Link } from '../../components/ui/Link.tsx'
const PAGES = [ const PAGES = [
{ name: 'hewwo', path: '/' }, { name: 'hewwo', path: '/' },
{ name: 'donate', path: ['/donate', '/$'] }, { name: 'donate', path: ['/donate', '/$'] },
] ]
--- ---
<header class="header"> <header class="pos-relative flex items-center justify-center gap-2">
<img <img
aria-hidden="true" aria-hidden="true"
class="gif" class="pos-absolute right-0 top-0 h-64px w-64px motion-reduce:hidden @dark:filter-brightness-90"
src={karin.src} src={karin.src}
transition:persist transition:persist
/> />
{(() => { {(() => {
const elements = [] const elements = []
@ -20,7 +20,7 @@ const PAGES = [
for (const page of PAGES) { for (const page of PAGES) {
if (elements.length > 0) { if (elements.length > 0) {
elements.push( elements.push(
<span class="delimiter"> / </span>, <span class="select-none text-text-secondary"> / </span>,
) )
} }
@ -33,7 +33,7 @@ const PAGES = [
if (isActive) { if (isActive) {
elements.push( elements.push(
<span class="active">{page.name}</span>, <span class="font-bold">{page.name}</span>,
) )
} else { } else {
const href = Array.isArray(page.path) ? page.path[0] : page.path const href = Array.isArray(page.path) ? page.path[0] : page.path
@ -49,38 +49,3 @@ const PAGES = [
return elements return elements
})()} })()}
</header> </header>
<style>
.header {
align-items: center;
display: flex;
gap: 8px;
justify-content: center;
position: relative;
}
.gif {
height: 64px;
width: 64px;
position: absolute;
right: 0;
top: 0;
@media (prefers-color-scheme: dark) {
filter: brightness(0.9);
}
@media (prefers-reduced-motion: reduce) {
display: none;
}
}
.active {
font-weight: bold;
}
.delimiter {
color: var(--text-secondary);
user-select: none
}
</style>

View file

@ -4,30 +4,30 @@ import { env } from '~/backend/env'
import { translateChunked } from '~/backend/service/gtrans' import { translateChunked } from '~/backend/service/gtrans'
export const POST: APIRoute = async (ctx) => { export const POST: APIRoute = async (ctx) => {
const body = ctx.request.headers.get('content-type') === 'application/json' const body = ctx.request.headers.get('content-type') === 'application/json'
? await ctx.request.json() ? await ctx.request.json()
: Object.fromEntries((await ctx.request.formData()).entries()) : Object.fromEntries((await ctx.request.formData()).entries())
if (body.auth_key !== env.FAKE_DEEPL_SECRET) { if (body.auth_key !== env.FAKE_DEEPL_SECRET) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }
if (!body.text) { if (!body.text) {
return new Response('Bad request', { status: 400 }) return new Response('Bad request', { status: 400 })
} }
const result = await translateChunked(body.text, 'auto', body.target_lang) const result = await translateChunked(body.text, 'auto', body.target_lang)
return new Response(JSON.stringify({ return new Response(JSON.stringify({
translations: [ translations: [
{ {
detected_source_language: result.sourceLanguage, detected_source_language: result.sourceLanguage,
text: result.translatedText, text: result.translatedText,
}, },
], ],
}), { }), {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) })
} }

View file

@ -3,11 +3,11 @@ import type { APIRoute } from 'astro'
import { randomPick } from '~/utils/random' import { randomPick } from '~/utils/random'
export const GET: APIRoute = () => new Response(`${randomPick([ export const GET: APIRoute = () => new Response(`${randomPick([
'mrrrp meow!', 'mrrrp meow!',
'meowwww~', 'meowwww~',
'mrrrrrrrrp', 'mrrrrrrrrp',
'purrrrrrrrrrrrrr', 'purrrrrrrrrrrrrr',
'meow :3', 'meow :3',
'miew >_<', 'miew >_<',
'try BARKing instead', 'try BARKing instead',
])}\n`) ])}\n`)

View file

@ -1,89 +1,89 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import { html } from '@mtcute/node' import { html } from '@mtcute/node'
import { telegramNotify } from '~/backend/bot/notify'
import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey' import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey'
import { env } from '~/backend/env' import { env } from '~/backend/env'
import { zodValidate } from '~/utils/zod' import { zodValidate } from '~/utils/zod'
import { telegramNotify } from '~/backend/bot/notify'
function misskeyMentionUser(user: MkUser, server: string): string { function misskeyMentionUser(user: MkUser, server: string): string {
const fullUsername = user.host ? `@${user.username}@${user.host}` : `@${user.username}` const fullUsername = user.host ? `@${user.username}@${user.host}` : `@${user.username}`
if (user.name) { if (user.name) {
return `<a href="${server}/${fullUsername}">${user.name}</a>` return `<a href="${server}/${fullUsername}">${user.name}</a>`
} }
return `<a href="${server}/${fullUsername}">${fullUsername}</a>` return `<a href="${server}/${fullUsername}">${fullUsername}</a>`
} }
function misskeyNoteBrief(note: MkNote): string { function misskeyNoteBrief(note: MkNote): string {
let text = note.text || '<i>&lt;no text&gt;</i>' let text = note.text || '<i>&lt;no text&gt;</i>'
if (text.length > 100) { if (text.length > 100) {
text = `${text.substring(0, 100)}...` text = `${text.substring(0, 100)}...`
} }
if (note.cw) { if (note.cw) {
text = `CW: ${note.cw}\n\n${text}` text = `CW: ${note.cw}\n\n${text}`
} }
return text return text
} }
function misskeyNoteLink(note: MkNote, server: string, text: string): string { function misskeyNoteLink(note: MkNote, server: string, text: string): string {
return `<a href="${server}/notes/${note.id}">${text}</a>` return `<a href="${server}/notes/${note.id}">${text}</a>`
} }
export const POST: APIRoute = async (ctx) => { export const POST: APIRoute = async (ctx) => {
if (ctx.request.headers.get('x-misskey-hook-secret') !== env.MK_WEBHOOK_SECRET) { if (ctx.request.headers.get('x-misskey-hook-secret') !== env.MK_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }
const parsed = await zodValidate(MisskeyWebhookBodySchema, await ctx.request.json()) const parsed = await zodValidate(MisskeyWebhookBodySchema, await ctx.request.json())
if (!parsed.body.notification) {
return new Response('OK')
}
const notification = parsed.body.notification
const server = parsed.server
let text
switch (notification.type) {
case 'note':
case 'mention':
case 'reply':
case 'renote':
case 'quote':
if (notification.note) {
text = `${misskeyNoteLink(notification.note!, server, `new ${notification.type}`)} from ${misskeyMentionUser(notification.note.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
}
break
case 'follow':
case 'unfollow':
text = `${misskeyMentionUser(notification.user!, server)} ${notification.type}ed you`
break
case 'receiveFollowRequest':
text = `${misskeyMentionUser(notification.user!, server)} sent you a follow request`
break
case 'followRequestAccepted':
text = `${misskeyMentionUser(notification.user!, server)} accepted your follow request`
break
case 'reaction':
if (notification.note) {
text = `${misskeyNoteLink(notification.note!, server, 'note')} received ${notification.reaction} reaction from ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
}
break
case 'edited':
if (notification.note) {
text = `${misskeyNoteLink(notification.note!, server, 'a note')} was edited by ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
}
break
}
if (text) {
telegramNotify(html(text.replace(/\n/g, '<br/>')))
}
if (!parsed.body.notification) {
return new Response('OK') return new Response('OK')
}
const notification = parsed.body.notification
const server = parsed.server
let text
switch (notification.type) {
case 'note':
case 'mention':
case 'reply':
case 'renote':
case 'quote':
if (notification.note) {
text = `${misskeyNoteLink(notification.note!, server, `new ${notification.type}`)} from ${misskeyMentionUser(notification.note.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
}
break
case 'follow':
case 'unfollow':
text = `${misskeyMentionUser(notification.user!, server)} ${notification.type}ed you`
break
case 'receiveFollowRequest':
text = `${misskeyMentionUser(notification.user!, server)} sent you a follow request`
break
case 'followRequestAccepted':
text = `${misskeyMentionUser(notification.user!, server)} accepted your follow request`
break
case 'reaction':
if (notification.note) {
text = `${misskeyNoteLink(notification.note!, server, 'note')} received ${notification.reaction} reaction from ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
}
break
case 'edited':
if (notification.note) {
text = `${misskeyNoteLink(notification.note!, server, 'a note')} was edited by ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
}
break
}
if (text) {
telegramNotify(html(text.replace(/\n/g, '<br/>')))
}
return new Response('OK')
} }

View file

@ -1,17 +1,17 @@
// import { telegramNotify } from '~/backend/bot/notify' // import { telegramNotify } from '~/backend/bot/notify'
import { html } from '@mtcute/node'
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import { html } from '@mtcute/node'
import { env } from '~/backend/env'
import { telegramNotify } from '~/backend/bot/notify' import { telegramNotify } from '~/backend/bot/notify'
import { env } from '~/backend/env'
export const POST: APIRoute = async (ctx) => { export const POST: APIRoute = async (ctx) => {
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) { if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }
telegramNotify(html`📥 Torrent finished: ${await ctx.request.text()}`) telegramNotify(html`📥 Torrent finished: ${await ctx.request.text()}`)
return new Response('OK') return new Response('OK')
} }

View file

@ -1,85 +1,85 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { z } from 'zod' import { z } from 'zod'
import { fromError } from 'zod-validation-error' import { fromError } from 'zod-validation-error'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox' import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request'
import { verifyCsrfToken } from '~/backend/utils/csrf' import { verifyCsrfToken } from '~/backend/utils/csrf'
import { getRequestIp } from '~/backend/utils/request'
import { HttpResponse } from '~/backend/utils/response' import { HttpResponse } from '~/backend/utils/response'
const schema = z.object({ const schema = z.object({
_csrf: z.string(), _csrf: z.string(),
message: z.string(), message: z.string(),
private: z.boolean(), private: z.boolean(),
}) })
const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 }) const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 })
const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 }) const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 })
export const POST: APIRoute = async (ctx) => { export const POST: APIRoute = async (ctx) => {
const body = await schema.safeParseAsync(await ctx.request.json()) const body = await schema.safeParseAsync(await ctx.request.json())
if (body.error) { if (body.error) {
return HttpResponse.json({ return HttpResponse.json({
error: fromError(body.error).message, error: fromError(body.error).message,
}, { status: 400 }) }, { status: 400 })
} }
const ip = getRequestIp(ctx) const ip = getRequestIp(ctx)
if (!verifyCsrfToken(ip, body.data._csrf)) { if (!verifyCsrfToken(ip, body.data._csrf)) {
return HttpResponse.json({ return HttpResponse.json({
error: 'csrf token is invalid', error: 'csrf token is invalid',
}, { status: 400 }) }, { status: 400 })
} }
if (isShoutboxBanned('GLOBAL')) { if (isShoutboxBanned('GLOBAL')) {
return HttpResponse.json({ return HttpResponse.json({
error: 'shoutbox is temporarily disabled', error: 'shoutbox is temporarily disabled',
}, { status: 400 }) }, { status: 400 })
} }
const bannedUntil = isShoutboxBanned(ip) const bannedUntil = isShoutboxBanned(ip)
if (bannedUntil) { if (bannedUntil) {
return HttpResponse.json({ return HttpResponse.json({
error: `you were banned until ${bannedUntil}`, error: `you were banned until ${bannedUntil}`,
}, { status: 400 }) }, { status: 400 })
} }
const remainingLocal = await rateLimitPerIp.get(ip) const remainingLocal = await rateLimitPerIp.get(ip)
const remainingGlobal = await rateLimitGlobal.get('GLOBAL') const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
if (remainingLocal?.remainingPoints === 0) { if (remainingLocal?.remainingPoints === 0) {
return HttpResponse.json({ return HttpResponse.json({
error: 'too many requests', error: 'too many requests',
}, { status: 400 }) }, { status: 400 })
} }
if (remainingGlobal?.remainingPoints === 0) { if (remainingGlobal?.remainingPoints === 0) {
return HttpResponse.json({ return HttpResponse.json({
error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`, error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`,
}, { status: 400 }) }, { status: 400 })
} }
const result = await createShout({ const result = await createShout({
fromIp: ip, fromIp: ip,
private: body.data.private, private: body.data.private,
text: body.data.message, text: body.data.message,
}) })
await rateLimitPerIp.penalty(ip, 1) await rateLimitPerIp.penalty(ip, 1)
await rateLimitGlobal.penalty('GLOBAL', 1) await rateLimitGlobal.penalty('GLOBAL', 1)
return HttpResponse.json( return HttpResponse.json(
typeof result === 'string' ? { error: result } : { ok: true }, typeof result === 'string' ? { error: result } : { ok: true },
) )
} }
export const GET: APIRoute = async (ctx) => { export const GET: APIRoute = async (ctx) => {
const url = new URL(ctx.request.url) const url = new URL(ctx.request.url)
let page = Number(url.searchParams.get('page')) let page = Number(url.searchParams.get('page'))
if (Number.isNaN(page)) page = 0 if (Number.isNaN(page)) page = 0
const data = fetchShouts(page, getRequestIp(ctx)) const data = fetchShouts(page, getRequestIp(ctx))
return HttpResponse.json(data) return HttpResponse.json(data)
} }

View file

@ -1,12 +1,12 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
export const GET: APIRoute = ctx => new Response( export const GET: APIRoute = ctx => new Response(
JSON.stringify( JSON.stringify(
Object.fromEntries(new URL(ctx.request.url).searchParams.entries()), Object.fromEntries(new URL(ctx.request.url).searchParams.entries()),
), ),
{ {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
},
}, },
},
) )

View file

@ -1,8 +1,8 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
export const GET: APIRoute = () => new Response(null, { export const GET: APIRoute = () => new Response(null, {
status: 301, status: 301,
headers: { headers: {
Location: 'https://legacy.tei.su/gdz', Location: 'https://legacy.tei.su/gdz',
}, },
}) })

View file

@ -18,20 +18,20 @@ const HTML = `
`.trim() `.trim()
export const GET: APIRoute = async (ctx) => { export const GET: APIRoute = async (ctx) => {
if (isBotUserAgent(ctx.request.headers.get('user-agent') || '')) { if (isBotUserAgent(ctx.request.headers.get('user-agent') || '')) {
return new Response(HTML, { return new Response(HTML, {
headers: { headers: {
'Content-Type': 'text/html', 'Content-Type': 'text/html',
}, },
})
}
telegramNotify(html`someone (ip ${getRequestIp(ctx)}) got rickrolled >:3`)
return new Response(null, {
status: 302,
headers: {
Location: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
},
}) })
}
telegramNotify(html`someone (ip ${getRequestIp(ctx)}) got rickrolled >:3`)
return new Response(null, {
status: 302,
headers: {
Location: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
},
})
} }

View file

@ -1,8 +1,8 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
export const GET: APIRoute = () => new Response(null, { export const GET: APIRoute = () => new Response(null, {
status: 301, status: 301,
headers: { headers: {
Location: 'https://teidesu.github.io/protoflex/repl', Location: 'https://teidesu.github.io/protoflex/repl',
}, },
}) })

View file

@ -1,8 +1,8 @@
--- ---
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency' import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
import { Link } from '~/components/ui/Link/Link' import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { Link } from '../components/ui/Link.tsx'
import { SectionTitle } from '../components/ui/Section.tsx'
let currentCurrency = new URL(Astro.request.url).searchParams.get('currency') let currentCurrency = new URL(Astro.request.url).searchParams.get('currency')
if (!currentCurrency || !AVAILABLE_CURRENCIES.includes(currentCurrency)) { if (!currentCurrency || !AVAILABLE_CURRENCIES.includes(currentCurrency)) {

View file

@ -4,24 +4,24 @@ import { umamiLogThisVisit } from '../backend/service/umami'
const EMPTY_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64') const EMPTY_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64')
export const GET: APIRoute = async (ctx) => { export const GET: APIRoute = async (ctx) => {
const website = new URL(ctx.request.url).searchParams.get('website') const website = new URL(ctx.request.url).searchParams.get('website')
if (!website) { if (!website) {
return new Response('no website', { return new Response('no website', {
status: 400, status: 400,
})
}
umamiLogThisVisit(
ctx.request,
ctx.request.headers.get('origin') ?? ctx.request.headers.get('referer') ?? undefined,
website,
)
return new Response(EMPTY_GIF, {
headers: {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
},
}) })
}
umamiLogThisVisit(
ctx.request,
ctx.request.headers.get('origin') ?? ctx.request.headers.get('referer') ?? undefined,
website,
)
return new Response(EMPTY_GIF, {
headers: {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
},
})
} }

6
src/utils/cn.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(...inputs))
}

View file

@ -1,11 +1,11 @@
export function randomInt(min: number, max: number): number { export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min return Math.floor(Math.random() * (max - min + 1)) + min
} }
export function randomPick<T>(arr: T[]): T { export function randomPick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)] return arr[Math.floor(Math.random() * arr.length)]
} }
export function shuffle<T>(arr: T[]): T[] { export function shuffle<T>(arr: T[]): T[] {
return arr.slice().sort(() => Math.random() - 0.5) return arr.slice().sort(() => Math.random() - 0.5)
} }

View file

@ -2,13 +2,13 @@ import type { z } from 'zod'
import { fromError } from 'zod-validation-error' import { fromError } from 'zod-validation-error'
export async function zodValidate<T extends z.ZodTypeAny>(schema: T, data: unknown): Promise<z.TypeOf<T>> { export async function zodValidate<T extends z.ZodTypeAny>(schema: T, data: unknown): Promise<z.TypeOf<T>> {
const res = await schema.safeParseAsync(data) const res = await schema.safeParseAsync(data)
if (res.error) throw fromError(res.error) if (res.error) throw fromError(res.error)
return res.data return res.data
} }
export function zodValidateSync<T extends z.ZodTypeAny>(schema: T, data: unknown): z.TypeOf<T> { export function zodValidateSync<T extends z.ZodTypeAny>(schema: T, data: unknown): z.TypeOf<T> {
const res = schema.safeParse(data) const res = schema.safeParse(data)
if (res.error) throw fromError(res.error) if (res.error) throw fromError(res.error)
return res.data return res.data
} }

View file

@ -1,15 +1,15 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }
}, },
"include": [".astro/types.d.ts", "**/*"], "include": [".astro/types.d.ts", "**/*"],
"exclude": [ "exclude": [
"node_modules", "node_modules",
"public", "public",
"dist" "dist"
] ]
} }

37
uno.config.ts Normal file
View file

@ -0,0 +1,37 @@
import { defineConfig, presetIcons, presetUno } from 'unocss'
export default defineConfig({
presets: [
presetUno(),
presetIcons(),
],
shortcuts: {
'content-dblslash': [
{ content: '"//"' },
],
},
theme: {
colors: {
'bg': 'var(--bg)',
'text-accent': 'var(--text-accent)',
'text-primary': 'var(--text-primary)',
'text-secondary': 'var(--text-secondary)',
'text-disabled': 'var(--text-disabled)',
'control-bg': 'var(--control-bg)',
'control-bg-hover': 'var(--control-bg-hover)',
'control-bg-hover-alt': 'var(--control-bg-hover-alt)',
'control-bg-active': 'var(--control-bg-active)',
'control-bg-disabled': 'var(--control-bg-disabled)',
'control-outline': 'var(--control-outline)',
},
fontSize: {
'2xs': ['10px', '12px'],
'xs': ['12px', '14px'],
'sm': ['14px', '16px'],
'md': ['16px', '20px'],
},
borderRadius: {
md: '4px',
},
},
})