Compare commits

..

No commits in common. "caa51bea9b0e94461ffd86f56ddcc089853ecd3b" and "8c4694714409d48ff0c10ff0c28dee17964c8249" have entirely different histories.

100 changed files with 4632 additions and 6094 deletions

2
.gitignore vendored
View file

@ -23,5 +23,3 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
.vscode
*.tsbuildinfo

View file

@ -1,71 +1,23 @@
import node from '@astrojs/node'
import solid from '@astrojs/solid-js'
import { Graphviz } from '@hpcc-js/wasm-graphviz'
import { defineConfig } from 'astro/config'
import { toString } from 'mdast-util-to-string'
import getReadingTime from 'reading-time'
import { visit } from 'unist-util-visit'
import UnoCSS from 'unocss/astro'
function remarkReadingTime() {
return function (tree, { data }) {
const textOnPage = toString(tree)
const readingTime = getReadingTime(textOnPage)
data.astro.frontmatter.minutesRead = readingTime.text
}
}
function remarkGraphvizSvg() {
return async function (tree) {
const graphviz = await Graphviz.load()
const instances = []
visit(tree, { type: 'code', lang: 'dot' }, (node, index, parent) => {
instances.push([node.value, index, parent])
})
for (const [dot, index, parent] of instances) {
const svg = graphviz.dot(dot, 'svg')
parent.children.splice(index, 1, {
type: 'html',
value: `<div class="graphviz-svg">${svg}</div>`,
})
}
}
}
import solid from '@astrojs/solid-js'
import node from '@astrojs/node'
// https://astro.build/config
export default defineConfig({
output: 'server',
integrations: [
solid(),
UnoCSS({
injectReset: true,
}),
],
markdown: {
remarkPlugins: [
remarkReadingTime,
remarkGraphvizSvg,
output: 'server',
integrations: [
solid(),
],
smartypants: false,
shikiConfig: {
themes: {
dark: 'catppuccin-mocha',
light: 'catppuccin-latte',
},
vite: {
esbuild: { jsx: 'automatic' },
define: {
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
},
},
},
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,
},
},
adapter: node({
mode: 'standalone',
}),
server: {
host: true,
},
})

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

8
postcss.config.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,118 +1,118 @@
import { randomPick } from '~/utils/random'
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; 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 (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; 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-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; 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-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 (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; 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 (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 (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 (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 (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 (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 (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-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-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-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 (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 (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; 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 (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 (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 (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 = {
ac(input: string) {
const e = new TextEncoder().encode(input)
let f = 0
let a = 0
for (f = 0; f < e.length; f++) {
a += e[f]
a = Tk.yc(a, '+-a^+6')
}
a = Tk.yc(a, '+-3^+b+-f')
a ^= 0
if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 }
a %= 1e6
return `${a}.${a}`
},
yc(a: number, b: string) {
for (let c = 0; c < b.length - 2; c += 3) {
const d = b[c + 2]
const number = d >= 'a'
// @ts-expect-error lol
? d - 87
: Number.parseInt(d)
const number2 = b[c + 1] === '+'
? a >>> number
: a << number
a = b[c] === '+'
? a + number2 & 0xFFFFFFFF
: a ^ number2
}
return a
},
ac(input: string) {
const e = new TextEncoder().encode(input)
let f = 0
let a = 0
for (f = 0; f < e.length; f++) {
a += e[f]
a = Tk.yc(a, '+-a^+6')
}
a = Tk.yc(a, '+-3^+b+-f')
a ^= 0
if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 }
a %= 1e6
return `${a}.${a}`
},
yc(a: number, b: string) {
for (let c = 0; c < b.length - 2; c += 3) {
const d = b[c + 2]
const number = d >= 'a'
// @ts-expect-error lol
? d - 87
: Number.parseInt(d)
const number2 = b[c + 1] === '+'
? a >>> number
: a << number
a = b[c] === '+'
? a + number2 & 0xFFFFFFFF
: a ^ number2
}
return a
},
}
async function translate(text: string, fromLanguage: string, toLanguage: string) {
let json = null
const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&'
+ `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'
+ `&tk=${Tk.ac(text)}`
+ '&source=input'
+ `&q=${encodeURIComponent(text)}`, {
headers: {
'User-Agent': randomPick(USER_AGENTS),
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error('Error while requesting translation')
}
const content = await response.text()
json = JSON.parse(content)
let json = null
const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&'
+ `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'
+ `&tk=${Tk.ac(text)}`
+ '&source=input'
+ `&q=${encodeURIComponent(text)}`, {
headers: {
'User-Agent': randomPick(USER_AGENTS),
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error('Error while requesting translation')
}
const content = await response.text()
json = JSON.parse(content)
let sourceLanguage = null
sourceLanguage = json[2]
let result = ''
let sourceLanguage = null
sourceLanguage = json[2]
let result = ''
for (let i = 0; i < json[0]?.length; ++i) {
const block = json[0][i][0]
if (block == null) { continue }
const blockText = block.toString()
if (blockText !== 'null') { result += blockText }
}
for (let i = 0; i < json[0]?.length; ++i) {
const block = json[0][i][0]
if (block == null) { continue }
const blockText = block.toString()
if (blockText !== 'null') { result += blockText }
}
return {
sourceLanguage,
originalText: text,
translatedText: result,
}
return {
sourceLanguage,
originalText: text,
translatedText: result,
}
}
export async function translateChunked(text: string, fromLanguage: string, toLanguage: string) {
let result = ''
const chunks = text.match(/.{1,5000}/gs)!
const promises = []
let result = ''
const chunks = text.match(/.{1,5000}/gs)!
const promises = []
for (let i = 0; i < chunks.length; ++i) {
promises.push(translate(chunks[i], fromLanguage, toLanguage))
}
for (let i = 0; i < chunks.length; ++i) {
promises.push(translate(chunks[i], fromLanguage, toLanguage))
}
const results = await Promise.all(promises)
for (let i = 0; i < results.length; ++i) {
result += results[i].translatedText
}
const results = await Promise.all(promises)
for (let i = 0; i < results.length; ++i) {
result += results[i].translatedText
}
return {
sourceLanguage: results[0].sourceLanguage,
originalText: text,
translatedText: result,
}
return {
sourceLanguage: results[0].sourceLanguage,
originalText: text,
translatedText: result,
}
}

View file

@ -1,57 +1,57 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
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 TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
const schema = z.object({
uri: z.string(),
record: z.object({
text: z.string(),
createdAt: z.string(),
}),
uri: z.string(),
record: z.object({
text: z.string(),
createdAt: z.string(),
}),
})
export const bskyLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() {
const res = await ffetch(ENDPOINT, {
query: {
actor: 'did:web:tei.su',
filter: 'posts_and_author_threads',
limit: 1,
},
}).parsedJson(z.object({
feed: z.array(z.object({
post: schema,
})),
}))
async fetcher() {
const res = await ffetch(ENDPOINT, {
query: {
actor: 'did:web:tei.su',
filter: 'posts_and_author_threads',
limit: 1,
},
}).parsedJson(z.object({
feed: z.array(z.object({
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]+)/)
if (postId) {
return {
data: {
source: 'bsky',
sourceLink: 'https://bsky.app/profile/did:web:tei.su',
time: new Date(post.record.createdAt).getTime(),
text: post.record.text.slice(0, 40) || '[no text]',
link: `https://bsky.app/profile/did:web:tei.su/post/${postId[1]}`,
},
expiresIn: TTL,
}
}
const postId = post.uri.match(/at:\/\/did:web:tei.su\/app\.bsky\.feed\.post\/([a-zA-Z0-9]+)/)
if (postId) {
return {
data: {
source: 'bsky',
sourceLink: 'https://bsky.app/profile/did:web:tei.su',
time: new Date(post.record.createdAt).getTime(),
text: post.record.text.slice(0, 40) || '[no text]',
link: `https://bsky.app/profile/did:web:tei.su/post/${postId[1]}`,
},
expiresIn: TTL,
}
}
return {
data: null,
expiresIn: TTL,
}
},
swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
return {
data: null,
expiresIn: TTL,
}
},
swr: true,
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 { z } from 'zod'
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 TTL = 1 * 60 * 60 * 1000 // 1 hour
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
const schema = z.object({
id: z.number(),
// 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,
// 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
op_type: z.string(),
content: z.string(),
repo: z.object({
full_name: z.string(),
html_url: z.string(),
}),
created: z.string(),
id: z.number(),
// 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,
// 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
op_type: z.string(),
content: z.string(),
repo: z.object({
full_name: z.string(),
html_url: 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}
const CommitEventSchema = z.object({
Commits: z.array(z.object({
Message: z.string(),
})),
Commits: z.array(z.object({
Message: z.string(),
})),
})
function mkItem(item: z.infer<typeof schema>, text: string) {
return {
source: 'forgejo',
sourceLink: 'https://git.stupid.fish/teidesu',
time: new Date(item.created).getTime(),
text: item.repo.full_name,
link: item.repo.html_url,
suffix: `: ${text}`,
}
return {
source: 'forgejo',
sourceLink: 'https://git.stupid.fish/teidesu',
time: new Date(item.created).getTime(),
text: item.repo.full_name,
link: item.repo.html_url,
suffix: `: ${text}`,
}
}
export const forgejoLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() {
const res = await ffetch(ENDPOINT).parsedJson(z.array(schema))
async fetcher() {
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) {
if (item.op_type === 'commit_repo') {
const commits = CommitEventSchema.parse(JSON.parse(item.content)).Commits
for (const item of res) {
if (item.op_type === 'commit_repo') {
const commits = CommitEventSchema.parse(JSON.parse(item.content)).Commits
result = mkItem(
item,
commits.length === 1 && commits[0].Message.length > 0
? `${commits[0].Message.slice(0, 40)}`
: `pushed ${commits.length} commits`,
)
break
} else if (item.op_type === 'close_pull_request') {
result = mkItem(item, 'closed pull request')
break
} else if (item.op_type === 'merge_pull_request') {
result = mkItem(item, 'merged pull request')
break
}
}
result = mkItem(
item,
commits.length === 1 && commits[0].Message.length > 0
? `${commits[0].Message.slice(0, 40)}`
: `pushed ${commits.length} commits`,
)
break
} else if (item.op_type === 'close_pull_request') {
result = mkItem(item, 'closed pull request')
break
} else if (item.op_type === 'merge_pull_request') {
result = mkItem(item, 'merged pull request')
break
}
}
return {
data: result,
expiresIn: TTL,
}
},
swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
return {
data: result,
expiresIn: TTL,
}
},
swr: true,
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 { z } from 'zod'
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 TTL = 1 * 60 * 60 * 1000 // 1 hour
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
const schema = z.object({
id: z.string(),
type: z.string(),
payload: z.any(),
repo: z.object({ name: z.string(), url: z.string() }),
public: z.boolean(),
created_at: z.string(),
id: z.string(),
type: z.string(),
payload: z.any(),
repo: z.object({ name: z.string(), url: z.string() }),
public: z.boolean(),
created_at: z.string(),
})
export const githubLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() {
const res = await ffetch(ENDPOINT, {
headers: {
'User-Agent': 'tei.su/1.0',
'X-GitHub-Api-Version': '2022-11-28',
},
}).parsedJson(z.array(schema))
async fetcher() {
const res = await ffetch(ENDPOINT, {
headers: {
'User-Agent': 'tei.su/1.0',
'X-GitHub-Api-Version': '2022-11-28',
},
}).parsedJson(z.array(schema))
const data = res[0]
const data = res[0]
const eventTextMapper: Record<string, () => string> = {
CreateEvent: () => `${data.payload.ref_type} created`,
DeleteEvent: () => `${data.payload.ref_type} deleted`,
ForkEvent: () => 'forked',
GollumEvent: () => 'wiki updated',
IssueCommentEvent: () => `issue comment ${data.payload.action}`,
IssuesEvent: () => `issue ${data.payload.action}`,
PublicEvent: () => 'made public',
PullRequestEvent: () => `pr ${data.payload.action}`,
PushEvent: () => `pushed ${data.payload.distinct_size} commits`,
ReleaseEvent: () => `release ${data.payload.action}`,
WatchEvent: () => 'starred',
}
const eventTextMapper: Record<string, () => string> = {
CreateEvent: () => `${data.payload.ref_type} created`,
DeleteEvent: () => `${data.payload.ref_type} deleted`,
ForkEvent: () => 'forked',
GollumEvent: () => 'wiki updated',
IssueCommentEvent: () => `issue comment ${data.payload.action}`,
IssuesEvent: () => `issue ${data.payload.action}`,
PublicEvent: () => 'made public',
PullRequestEvent: () => `pr ${data.payload.action}`,
PushEvent: () => `pushed ${data.payload.distinct_size} commits`,
ReleaseEvent: () => `release ${data.payload.action}`,
WatchEvent: () => 'starred',
}
if (eventTextMapper[data.type]) {
return {
data: {
source: 'github',
sourceLink: 'https://github.com/teidesu',
time: new Date(data.created_at).getTime(),
text: data.repo.name,
suffix: `: ${eventTextMapper[data.type]()}`,
link: `https://github.com/${data.repo.name}`,
},
expiresIn: TTL,
}
}
if (eventTextMapper[data.type]) {
return {
data: {
source: 'github',
sourceLink: 'https://github.com/teidesu',
time: new Date(data.created_at).getTime(),
text: data.repo.name,
suffix: `: ${eventTextMapper[data.type]()}`,
link: `https://github.com/${data.repo.name}`,
},
expiresIn: TTL,
}
}
return {
data: null,
expiresIn: TTL,
}
},
swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
return {
data: null,
expiresIn: TTL,
}
},
swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
})

View file

@ -1,51 +1,53 @@
import { assertMatches } from '@fuman/utils'
import { bskyLastSeen } from './bsky.ts'
import { forgejoLastSeen } from './forgejo.ts'
import { githubLastSeen } from './github'
import { lastfm } from './listenbrainz.ts'
import { shikimoriLastSeen } from './shikimori'
import { forgejoLastSeen } from './forgejo.ts'
export interface LastSeenItem {
source: string
sourceLink: string
time: number
text: string
suffix?: string
link: string
source: string
sourceLink: string
time: number
text: string
suffix?: string
link: string
}
export async function fetchLastSeen() {
const [
lastfmData,
bskyData,
shikimoriData,
githubData,
forgejoData,
] = await Promise.all([
lastfm.get(),
bskyLastSeen.get(),
shikimoriLastSeen.get(),
githubLastSeen.get(),
forgejoLastSeen.get(),
])
const [
lastfmData,
bskyData,
shikimoriData,
githubData,
forgejoData,
] = await Promise.all([
lastfm.get(),
bskyLastSeen.get(),
shikimoriLastSeen.get(),
githubLastSeen.get(),
forgejoLastSeen.get(),
])
const res: LastSeenItem[] = []
const res: LastSeenItem[] = []
if (lastfmData) res.push(lastfmData)
if (bskyData) res.push(bskyData)
if (shikimoriData) res.push(shikimoriData)
if (lastfmData) res.push(lastfmData)
if (bskyData) res.push(bskyData)
if (shikimoriData) res.push(shikimoriData)
if (githubData && forgejoData) {
// only push the last one
if (forgejoData.time > githubData.time) {
res.push(forgejoData)
} else {
res.push(githubData)
if (githubData && forgejoData) {
// only push the last one
if (forgejoData.time > githubData.time) {
res.push(forgejoData)
} else {
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 type { LastSeenItem } from './index.ts'
import { z } from 'zod'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const LB_TTL = 1000 * 60 * 5 // 5 minutes
const LB_STALE_TTL = 1000 * 60 * 60 // 1 hour
const LB_USERNAME = 'teidumb'
const LbListen = z.object({
listened_at: z.number(),
track_metadata: z.object({
artist_name: z.string(),
track_name: z.string(),
additional_info: z.object({
origin_url: z.string().optional(),
}).optional(),
mbid_mapping: z.object({
recording_mbid: z.string().optional(),
}).optional(),
}),
listened_at: z.number(),
track_metadata: z.object({
artist_name: z.string(),
track_name: z.string(),
additional_info: z.object({
origin_url: z.string().optional(),
}).optional(),
mbid_mapping: z.object({
recording_mbid: z.string().optional(),
}).optional(),
}),
})
const ResponseSchema = z.object({
payload: z.object({
listens: z.array(LbListen),
}),
payload: z.object({
listens: z.array(LbListen),
}),
})
export const lastfm = new AsyncResource<LastSeenItem | null>({
async fetcher({ current }) {
const res = await ffetch(`https://api.listenbrainz.org/1/user/${LB_USERNAME}/listens`, {
query: {
count: 1,
min_ts: current ? Math.floor(current.time / 1000) : '',
},
}).parsedJson(ResponseSchema)
async fetcher({ current }) {
const res = await ffetch(`https://api.listenbrainz.org/1/user/${LB_USERNAME}/listens`, {
query: {
count: 1,
min_ts: current ? Math.floor(current.time / 1000) : '',
},
}).parsedJson(ResponseSchema)
if (!res.payload.listens.length) {
return {
data: current,
expiresIn: 0,
}
}
if (!res.payload.listens.length) {
return {
data: current,
expiresIn: 0,
}
}
const listen = res.payload.listens[0]
const listen = res.payload.listens[0]
let url: string | undefined
if (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) {
url = listen.track_metadata.additional_info.origin_url
} else {
url = 'https://listenbrainz.org/user/teidumb/'
}
return {
data: {
source: 'listenbrainz',
sourceLink: 'https://listenbrainz.org/user/teidumb/',
time: listen.listened_at * 1000,
text: `${listen.track_metadata.track_name} ${listen.track_metadata.artist_name}`,
link: url,
},
expiresIn: LB_TTL,
}
},
swr: true,
swrValidator: ({ currentExpiresAt }) => Date.now() - currentExpiresAt < LB_STALE_TTL,
let url: string | undefined
if (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) {
url = listen.track_metadata.additional_info.origin_url
} else {
url = 'https://listenbrainz.org/user/teidumb/'
}
return {
data: {
source: 'listenbrainz',
sourceLink: 'https://listenbrainz.org/user/teidumb/',
time: listen.listened_at * 1000,
text: `${listen.track_metadata.track_name} ${listen.track_metadata.artist_name}`,
link: url,
},
expiresIn: LB_TTL,
}
},
swr: true,
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 { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1'
const TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
const schema = z.object({
created_at: z.string(),
description: z.string(),
target: z.object({
name: z.string(),
url: z.string(),
}),
created_at: z.string(),
description: z.string(),
target: z.object({
name: z.string(),
url: z.string(),
}),
})
export const shikimoriLastSeen = new AsyncResource<LastSeenItem | null>({
async fetcher() {
const res = (await ffetch(ENDPOINT).parsedJson(z.array(schema)))[0]
async fetcher() {
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> = {
'Просмотрено': 'completed',
'Прочитано': 'completed',
'Добавлено в список': 'added',
'Брошено': 'dropped',
}
let event = mapper[res.description]
const mapper: Record<string, string> = {
'Просмотрено': 'completed',
'Прочитано': 'completed',
'Добавлено в список': 'added',
'Брошено': 'dropped',
}
let event = mapper[res.description]
if (!event && res.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
event = 'watched'
}
if (!event && res.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
event = 'completed'
}
if (!event && res.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
event = 'watched'
}
if (!event && res.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
event = 'completed'
}
if (event) {
return {
data: {
source: 'shiki',
sourceLink: 'https://shikimori.one/teidesu',
time: new Date(res.created_at).getTime(),
text: res.target.name,
suffix: `: ${event}`,
link: `https://shikimori.one${res.target.url}`,
},
expiresIn: TTL,
}
}
if (event) {
return {
data: {
source: 'shiki',
sourceLink: 'https://shikimori.one/teidesu',
time: new Date(res.created_at).getTime(),
text: res.target.name,
suffix: `: ${event}`,
link: `https://shikimori.one${res.target.url}`,
},
expiresIn: TTL,
}
}
return {
data: null,
expiresIn: TTL,
}
},
swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
return {
data: null,
expiresIn: TTL,
}
},
swr: true,
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
})

View file

@ -1,189 +1,197 @@
import { BotKeyboard, html } from '@mtcute/node'
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
import { tg } from '../bot'
import { ShoutboxAction } from '../bot/shoutbox.js'
import { db } from '../db'
import { env } from '../env'
import { shouts, shoutsBans } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
import { db } from '../db'
import { env } from '../env'
import { tg } from '../bot'
const SHOUTS_PER_PAGE = 5
const filter = or(
not(shouts.pending),
and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))),
not(shouts.pending),
and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))),
)
const fetchTotal = db.select({
count: sql<number>`count(1)`,
}).from(shouts).where(filter).prepare()
count: sql<number>`count(1)`,
}).from(shouts)
.where(filter)
.prepare()
const fetchList = db.select({
createdAt: shouts.createdAt,
text: shouts.text,
pending: shouts.pending,
serial: shouts.serial,
reply: shouts.reply,
}).from(shouts).where(filter).limit(SHOUTS_PER_PAGE).orderBy(desc(shouts.createdAt)).offset(sql.placeholder('offset')).prepare()
createdAt: shouts.createdAt,
text: shouts.text,
pending: shouts.pending,
serial: shouts.serial,
reply: shouts.reply,
}).from(shouts)
.where(filter)
.limit(SHOUTS_PER_PAGE)
.orderBy(desc(shouts.createdAt))
.offset(sql.placeholder('offset'))
.prepare()
export function fetchShouts(page: number, ip: string) {
return {
items: fetchList.all({
offset: page * SHOUTS_PER_PAGE,
fromIp: ip,
}),
pageCount: Math.ceil((fetchTotal.get({
fromIp: ip,
})?.count ?? 0) / SHOUTS_PER_PAGE),
}
return {
items: fetchList.all({
offset: page * SHOUTS_PER_PAGE,
fromIp: ip,
}),
pageCount: Math.ceil((fetchTotal.get({
fromIp: ip,
})?.count ?? 0) / SHOUTS_PER_PAGE),
}
}
export type ShoutsData = ReturnType<typeof fetchShouts>
const fetchNextSerial = db.select({
serial: sql<number>`coalesce(max(serial), 0) + 1`,
}).from(shouts).prepare()
serial: sql<number>`coalesce(max(serial), 0) + 1`,
}).from(shouts)
.prepare()
export function approveShout(id: string) {
const nextSerial = fetchNextSerial.get({})!.serial
const nextSerial = fetchNextSerial.get({})!.serial
db.update(shouts)
.set({ pending: false, serial: nextSerial })
.where(eq(shouts.id, id))
.run()
db.update(shouts)
.set({ pending: false, serial: nextSerial })
.where(eq(shouts.id, id))
.run()
return nextSerial
return nextSerial
}
export function declineShout(id: string) {
db.delete(shouts)
.where(eq(shouts.id, id))
.run()
db.delete(shouts)
.where(eq(shouts.id, id))
.run()
}
export function deleteBySerial(serial: number) {
db.delete(shouts)
.where(eq(shouts.serial, serial))
.run()
// adjust serials
db.update(shouts)
.set({ serial: sql<number>`serial - 1` })
.where(and(
eq(shouts.pending, false),
gt(shouts.serial, sql.placeholder('serial')),
))
.run({ serial })
db.delete(shouts)
.where(eq(shouts.serial, serial))
.run()
// adjust serials
db.update(shouts)
.set({ serial: sql<number>`serial - 1` })
.where(and(
eq(shouts.pending, false),
gt(shouts.serial, sql.placeholder('serial')),
))
.run({ serial })
}
export function answerBySerial(serial: number, reply: string) {
db.update(shouts)
.set({ reply })
.where(eq(shouts.serial, serial))
.execute()
db.update(shouts)
.set({ reply })
.where(eq(shouts.serial, serial))
.execute()
}
export function banShouts(ip: string, expires: number) {
db.insert(shoutsBans)
.values({
ip,
expires,
})
.onConflictDoUpdate({
target: shoutsBans.ip,
set: { expires },
})
.execute()
db.insert(shoutsBans)
.values({
ip,
expires,
})
.onConflictDoUpdate({
target: shoutsBans.ip,
set: { expires },
})
.execute()
}
export function unbanShouts(ip: string) {
db.delete(shoutsBans)
.where(eq(shoutsBans.ip, ip))
.execute()
db.delete(shoutsBans)
.where(eq(shoutsBans.ip, ip))
.execute()
}
export function isShoutboxBanned(ip: string): Date | null {
const ban = db.select()
.from(shoutsBans)
.where(eq(shoutsBans.ip, ip))
.get()
if (!ban) return null
const ban = db.select()
.from(shoutsBans)
.where(eq(shoutsBans.ip, ip))
.get()
if (!ban) return null
const expires = ban.expires
if (Date.now() > expires) return null
return new Date(ban.expires)
const expires = ban.expires
if (Date.now() > expires) return null
return new Date(ban.expires)
}
function validateShout(text: string, isPublic: boolean) {
if (text.length < 3) {
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 < 3) {
return 'too short, come on'
}
if (URL_REGEX.test(text)) {
return 'no links plz'
if (text.length > 300) {
return 'please keep it under 300 characters'
}
}
return true
if (isPublic) {
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: {
fromIp: string
private: boolean
text: string
fromIp: string
private: boolean
text: 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 subheader = html`<br>via: #api<br><br>`
const header = html`${params.private ? 'private message' : 'shout'} from <code>${params.fromIp}</code>`
const subheader = html`<br>via: #api<br><br>`
if (params.private || validateResult !== true) {
const was = params.private ? '' : ` was auto-declined (${validateResult})`
await tg.sendText(
env.TG_CHAT_ID,
html`
if (params.private || validateResult !== true) {
const was = params.private ? '' : ` was auto-declined (${validateResult})`
await tg.sendText(
env.TG_CHAT_ID,
html`
${header}${was}:
${subheader}
${text}
`,
)
}
)
}
if (!params.private && validateResult === true) {
const result = await db.insert(shouts)
.values(params)
.returning({ id: shouts.id })
.execute()
const id = result[0].id
if (!params.private && validateResult === true) {
const result = await db.insert(shouts)
.values(params)
.returning({ id: shouts.id })
.execute()
const id = result[0].id
await tg.sendText(
env.TG_CHAT_ID,
html`
await tg.sendText(
env.TG_CHAT_ID,
html`
${header}:
${subheader}
${text}
`,
{
replyMarkup: BotKeyboard.inline([[
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })),
]]),
},
)
}
{
replyMarkup: BotKeyboard.inline([[
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
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 { z } from 'zod'
import { env } from '~/backend/env'
import { isBotUserAgent } from '../utils/bot'
import { env } from '~/backend/env'
const ffetch = ffetchBase.extend({
addons: [
ffetchAddons.parser(ffetchZodAdapter()),
ffetchAddons.timeout(),
],
baseUrl: env.UMAMI_HOST,
timeout: 1000,
addons: [
ffetchAddons.parser(ffetchZodAdapter()),
ffetchAddons.timeout(),
],
baseUrl: env.UMAMI_HOST,
timeout: 1000,
})
export async function umamiFetchStats(page: string, startAt: number) {
if (import.meta.env.DEV) {
return Promise.resolve({ visitors: { value: 1337 } })
}
if (import.meta.env.DEV) {
return Promise.resolve({ visitors: { value: 1337 } })
}
return await ffetch(`/api/websites/${env.UMAMI_SITE_ID}/stats`, {
query: {
endAt: Math.floor(Date.now()).toString(),
startAt: startAt.toString(),
url: page,
},
headers: {
Authorization: `Bearer ${env.UMAMI_TOKEN}`,
},
}).parsedJson(z.object({
visitors: z.object({
value: z.number(),
}),
}))
return await ffetch(`/api/websites/${env.UMAMI_SITE_ID}/stats`, {
query: {
endAt: Math.floor(Date.now()).toString(),
startAt: startAt.toString(),
url: page,
},
headers: {
Authorization: `Bearer ${env.UMAMI_TOKEN}`,
},
}).parsedJson(z.object({
visitors: z.object({
value: z.number(),
}),
}))
}
export function umamiLogThisVisit(request: Request, path?: string, website = env.UMAMI_SITE_ID): void {
if (import.meta.env.DEV) return
if (isBotUserAgent(request.headers.get('user-agent') || '')) return
const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || ''
if (import.meta.env.DEV) return
if (isBotUserAgent(request.headers.get('user-agent') || '')) return
const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || ''
ffetch.post('/api/send', {
json: {
payload: {
hostname: request.headers.get('host') || '',
language,
referrer: request.headers.get('referer') || '',
screen: '',
title: '',
url: path ?? new URL(request.url).pathname,
website,
},
type: 'event',
},
headers: {
'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
},
}).then(async (r) => {
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
}).catch((err) => {
console.warn(err)
})
ffetch.post('/api/send', {
json: {
payload: {
hostname: request.headers.get('host') || '',
language,
referrer: request.headers.get('referer') || '',
screen: '',
title: '',
url: path ?? new URL(request.url).pathname,
website,
},
type: 'event',
},
headers: {
'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
},
}).then(async (r) => {
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
}).catch((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 WebringItem = z.object({
id: z.number(),
name: z.string(),
url: z.string(),
id: z.number(),
name: z.string(),
url: z.string(),
})
export type WebringItem = z.infer<typeof WebringItem>
const WebringData = z.object({
prev: WebringItem,
next: WebringItem,
prev: WebringItem,
next: WebringItem,
})
export type WebringData = z.infer<typeof WebringData>
export const webring = new AsyncResource<WebringData>({
fetcher: async () => {
const res = await ffetch(WEBRING_URL).parsedJson(WebringData)
fetcher: async () => {
const res = await ffetch(WEBRING_URL).parsedJson(WebringData)
return {
data: res,
expiresIn: WEBRING_TTL,
}
},
swr: true,
return {
data: res,
expiresIn: WEBRING_TTL,
}
},
swr: true,
})

View file

@ -1,3 +1,3 @@
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

@ -1,42 +1,40 @@
import { createHmac, randomBytes } from 'node:crypto'
import { base64, typed, u8, utf8 } from '@fuman/utils'
import { env } from '~/backend/env'
const secret = env.CSRF_SECRET
const validity = 300_000
export function getCsrfToken(ip: string) {
const data = utf8.encoder.encode(JSON.stringify([Date.now(), ip]))
const salt = randomBytes(8) as Uint8Array
const sign = createHmac('sha256', secret).update(data).update(salt).digest()
const data = Buffer.from(JSON.stringify([Date.now(), ip]))
const salt = randomBytes(8)
const sign = createHmac('sha256', secret).update(data).update(salt).digest()
return base64.encode(u8.concat3(
data,
salt,
sign.subarray(0, 8),
), true)
return Buffer.concat([
data,
salt,
sign.subarray(0, 8),
]).toString('base64url')
}
export function verifyCsrfToken(ip: string, token: string) {
try {
const buf = base64.decode(token, true)
if (buf.length < 16) return false
try {
const buf = Buffer.from(token, 'base64url')
if (buf.length < 16) return false
const saltedData = buf.subarray(0, -8)
const correctSign = createHmac('sha256', secret).update(saltedData).digest()
const saltedData = buf.subarray(0, -8)
const correctSign = createHmac('sha256', secret).update(saltedData).digest()
if (!typed.equal(new Uint8Array(correctSign.subarray(0, 8)), buf.subarray(-8))) {
return false
if (Buffer.compare(correctSign.subarray(0, 8), buf.subarray(-8)) !== 0) {
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'
export const ffetch = ffetchBase.extend({
addons: [
ffetchAddons.parser(ffetchZodAdapter()),
ffetchAddons.retry(),
],
headers: {
'User-Agent': 'tei.su/1.0',
},
addons: [
ffetchAddons.parser(ffetchZodAdapter()),
ffetchAddons.retry(),
],
headers: {
'User-Agent': 'tei.su/1.0',
},
})

View file

@ -1,14 +1,14 @@
import { randomPick } from '../../utils/random'
export function obfuscateEmail(email: string) {
const opener = randomPick(['[', '{', '(', '<', '|'])
const closer = {
'(': ')',
'[': ']',
'{': '}',
'<': '>',
'|': '|',
}[opener]
const opener = randomPick(['[', '{', '(', '<', '|'])
const closer = {
'(': ')',
'[': ']',
'{': '}',
'<': '>',
'|': '|',
}[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'
export function getRequestIp(ctx: AstroGlobal | APIContext) {
const xForwardedFor = ctx.request.headers.get('x-forwarded-for')
if (xForwardedFor) return xForwardedFor.split(',')[0]
const xForwardedFor = ctx.request.headers.get('x-forwarded-for')
if (xForwardedFor) return xForwardedFor.split(',')[0]
return ctx.clientAddress
return ctx.clientAddress
}

View file

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

View file

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

View file

@ -1,39 +0,0 @@
/** @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

@ -0,0 +1,12 @@
.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

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

View file

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

View file

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

View file

@ -1,34 +1,34 @@
const ascii = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
export function dumbHash(str: string) {
let hash = 0
const len = str.length
for (let s = 0; s < len; s++) {
hash += str.charCodeAt(s) * (s + 1) * (len - s)
}
hash >>>= 0
let hash = 0
const len = str.length
for (let s = 0; s < len; s++) {
hash += str.charCodeAt(s) * (s + 1) * (len - s)
}
hash >>>= 0
let res = ''
while (hash > 0) {
const q = hash % ascii.length
hash = ~~(hash / ascii.length)
res += ascii[q]
}
let res = ''
while (hash > 0) {
const q = hash % ascii.length
hash = ~~(hash / ascii.length)
res += ascii[q]
}
return res
return res
}
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[]) {
let pos = posRef[0]
let ret = ''
for (let s = 0; s < str.length; s++) {
ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos))
pos = (pos + 1) % key.length
}
posRef[0] = pos
return ret
let pos = posRef[0]
let ret = ''
for (let s = 0; s < str.length; s++) {
ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos))
pos = (pos + 1) % key.length
}
posRef[0] = pos
return ret
}

View file

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

View file

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

View file

@ -0,0 +1,146 @@
@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,339 +1,333 @@
/** @jsxImportSource solid-js */
import type { PageData } from './data'
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
import { intlFormatDistance } from 'date-fns'
import { For, type JSX, Show } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import axolotl from '~/assets/axolotl.png'
import cherry from '~/assets/cherry-blossom_1f338.png'
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 { intlFormatDistance } from 'date-fns'
import { Emoji } from '~/components/ui/Emoji/Emoji'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
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 type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
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 css from './PageMain.module.css'
import { SUBLINKS, TESTIMONIALS } from './constants'
import type { PageData } from './data'
function formatTimeRelative(time: number) {
return intlFormatDistance(
new Date(time),
new Date(),
)
return intlFormatDistance(
new Date(time),
new Date(),
)
}
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
return (
<Dynamic
component={props.first ? 'summary' : 'div'}
class={cn(
'flex flex-row items-center justify-between',
props.first && 'pos-relative list-none cursor-pointer rounded-md hover:bg-control-bg-hover active:select-none [&::-webkit-details-marker]:hidden',
)}
>
<div class="max-w-full flex flex-col overflow-hidden sm:flex-row sm:items-center">
<div class="max-w-full w-min flex flex-row items-center overflow-hidden">
<Link
class="max-w-200px overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-300px"
href={props.item.link}
target="_blank"
title={props.item.text}
>
{props.item.text}
</Link>
{props.item.suffix && (
<span class="whitespace-nowrap text-xs">
{props.item.suffix}
</span>
)}
</div>
<i class="ml-2 whitespace-nowrap text-xs text-text-secondary">
{'@ '}
<Link href={props.item.sourceLink} target="_blank">
{props.item.source}
</Link>
{', '}
{formatTimeRelative(props.item.time)}
</i>
</div>
<Show when={props.first}>
<div data-expand-label class="before:(ml-1em whitespace-nowrap text-xs text-text-secondary content-['<'] md:content-['(click_to_expand)'] sm:content-['(expand)'])" />
</Show>
</Dynamic>
)
return (
<Dynamic component={props.first ? 'summary' : 'div'} class={css.lastSeenItem}>
<div class={css.lastSeenLinkWrap}>
<div class={css.lastSeenLinkWrapInner}>
<Link
class={css.lastSeenLink}
href={props.item.link}
target="_blank"
title={props.item.text}
>
{props.item.text}
</Link>
{props.item.suffix && (
<span class={css.lastSeenSuffix}>
{props.item.suffix}
</span>
)}
</div>
<i class={css.lastSeenSource}>
{'@ '}
<Link href={props.item.sourceLink} target="_blank">
{props.item.source}
</Link>
{', '}
{formatTimeRelative(props.item.time)}
</i>
</div>
<Show when={props.first}>
<div class={css.lastSeenTrigger} />
</Show>
</Dynamic>
)
}
export function PageMain(props: {
data: PageData
partTimeWords?: JSX.Element
shoutbox?: JSX.Element
data: PageData
partTimeWords?: JSX.Element
shoutbox?: JSX.Element
}) {
const testimonials = TESTIMONIALS.map((props) => {
const link = props.href
? (
<Link href={props.href} target="_blank">
{props.author}
</Link>
const testimonials = TESTIMONIALS.map((props) => {
const link = props.href
? (
<Link href={props.href} target="_blank">
{props.author}
</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 (
<div class="mb-2">
"
{props.text}
"&nbsp;-&nbsp;
{link}
</div>
<>
<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={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 [&[open]_[data-expand-label]]:before:(content-['v'] md:content-['(click_to_collapse)'] sm:content-['(collapse)'])">
<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 { getCsrfToken } from '~/backend/utils/csrf'
import { getRequestIp } from '~/backend/utils/request'
import { getCsrfToken } from '~/backend/utils/csrf'
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
@ -17,9 +17,9 @@ const csrf = getCsrfToken(ip)
---
<ShoutboxSolid
client:idle
csrf={csrf}
shoutError={shoutError}
initPage={page}
initPageData={data}
client:idle
csrf={csrf}
shoutError={shoutError}
initPage={page}
initPageData={data}
/>

View file

@ -0,0 +1,75 @@
@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,211 +1,222 @@
/** @jsxImportSource solid-js */
/* eslint-disable no-alert */
import type { ShoutsData } from '~/backend/service/shoutbox'
import { createQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/solid-query'
/** @jsxImportSource solid-js */
import { type ComponentProps, Show, createSignal, onMount } from 'solid-js'
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query'
import { format } from 'date-fns/format'
import { type ComponentProps, createSignal, onMount, Show } from 'solid-js'
import { Button } from '../../../ui/Button.tsx'
import { Button } from '~/components/ui/Button/Button'
import { Checkbox } from '~/components/ui/Checkbox/Checkbox'
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 { Checkbox } from '../../../ui/Checkbox/Checkbox.tsx'
import { SectionTitle } from '../../../ui/Section.tsx'
import { TextArea } from '../../../ui/TextArea.tsx'
import { TextComment } from '../../../ui/TextComment.tsx'
import css from './Shoutbox.module.css'
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: {
initPage: number
initPageData: ShoutsData
shoutError?: string
csrf: string
initPage: number
initPageData: ShoutsData
shoutError?: string
csrf: string
}) {
// eslint-disable-next-line solid/reactivity
const [page, setPage] = createSignal(props.initPage)
// eslint-disable-next-line solid/reactivity
const [initData, setInitData] = createSignal<ShoutsData | undefined>(props.initPageData)
// eslint-disable-next-line solid/reactivity
const [page, setPage] = createSignal(props.initPage)
// eslint-disable-next-line solid/reactivity
const [initData, setInitData] = createSignal<ShoutsData | undefined>(props.initPageData)
const shouts = createQuery(() => ({
queryKey: ['shouts', page()],
queryFn: () => fetchShouts(page()),
cacheTime: 0,
gcTime: 0,
refetchInterval: 30000,
placeholderData: keepPreviousData,
initialData: initData,
}))
const [sending, setSending] = createSignal(false)
const [jsEnabled, setJsEnabled] = createSignal(false)
onMount(() => setJsEnabled(true))
const shouts = createQuery(() => ({
queryKey: ['shouts', page()],
queryFn: () => fetchShouts(page()),
cacheTime: 0,
gcTime: 0,
refetchInterval: 30000,
placeholderData: keepPreviousData,
initialData: initData,
}))
const [sending, setSending] = createSignal(false)
const [jsEnabled, setJsEnabled] = createSignal(false)
onMount(() => setJsEnabled(true))
const onPageClick = (next: boolean) => (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setInitData(undefined)
const onPageClick = (next: boolean) => (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setInitData(undefined)
const newPage = next ? page() + 1 : page() - 1
const newPage = next ? page() + 1 : page() - 1
const link = e.currentTarget as HTMLAnchorElement
const href = link.href
const link = e.currentTarget as HTMLAnchorElement
const href = link.href
history.replaceState(null, '', href)
setPage(newPage)
}
history.replaceState(null, '', href)
setPage(newPage)
}
const shoutsRender = () => shouts.data?.items.map((props) => {
const icon = props.pending
? <div class="i-gravity-ui-clock size-4" title="awaiting moderation" />
: `#${props.serial}`
const shoutsRender = () => shouts.data?.items.map((props) => {
const icon = props.pending
? (
<Icon
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 (
<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">
<div class="flex flex-row gap-2 text-text-secondary">
{icon}
<time class="whitespace-nowrap" datetime={props.createdAt}>
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
</time>
</div>
<div class="whitespace-pre-wrap">
{props.text}
{props.reply && (
<div class="mt-1.5">
<b>reply: </b>
{props.reply}
<section>
<SectionTitle>shoutbox!</SectionTitle>
<TextComment class={pageCss.comment}>
disclaimer: shouts
{' '}
<i>are</i>
{' '}
pre-moderated, but they do not reflect my&nbsp;views.
</TextComment>
<div class={css.form}>
<input type="hidden" name="_csrf" value={props.csrf} />
<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>
<div class={css.shouts}>
{shoutsRender()}
</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>) {
const client = new QueryClient()
return (
<QueryClientProvider client={client}>
<ShoutboxInner {...props} />
</QueryClientProvider>
)
const client = new QueryClient()
return (
<QueryClientProvider client={client}>
<ShoutboxInner {...props} />
</QueryClientProvider>
)
}

View file

@ -1,67 +1,67 @@
export const PARTTIME_VARIANTS = [
'anime girl',
'puppygirl',
'human being',
'shitposter',
'js fanatic',
'dumbass',
'delulu',
'silly goofball',
'anime girl',
'puppygirl',
'human being',
'shitposter',
'js fanatic',
'dumbass',
'delulu',
'silly goofball',
]
export const TESTIMONIALS = [
{ 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: '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: '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: '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: '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: '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: '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: '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' },
]
export const SUBLINKS = [
{
link: '/nudes',
title: 'nudes',
subtitle: '( ͡° ͜ʖ ͡°)',
comment: 'a lot of them, actually',
noPrefetch: true,
},
{
link: '/cheerio/index.html',
title: 'cheerio',
subtitle: 'cheerio repl for debugging and stuff',
comment: 'made in 20 minutes, use it all the time, very useful /gen',
},
{
link: '/gdz',
title: 'gdz',
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',
},
{
link: '/oauth.blank.html',
title: 'oauth.blank.html',
subtitle: 'thingy for redirect_uri',
comment: 'i promise it doesn\'t collect your tokens',
},
{
link: '/proxifier.html',
title: 'proxifier.html',
subtitle: 'proxifier keygen',
comment: 'basically a port of some c# implementation bc im lazy',
},
{
link: '/spring.html',
title: 'spring.html',
subtitle: 'no idea',
comment: 'spring physics in ui are fun',
},
{
link: '/test_voice.ogg',
title: 'test_voice.ogg',
subtitle: 'фильм земляне 2005 года смотреть всем',
comment: 'libopus encoded, valid for telegram',
},
{
link: '/nudes',
title: 'nudes',
subtitle: '( ͡° ͜ʖ ͡°)',
comment: 'a lot of them, actually',
noPrefetch: true,
},
{
link: '/cheerio/index.html',
title: 'cheerio',
subtitle: 'cheerio repl for debugging and stuff',
comment: 'made in 20 minutes, use it all the time, very useful /gen',
},
{
link: '/gdz',
title: 'gdz',
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',
},
{
link: '/oauth.blank.html',
title: 'oauth.blank.html',
subtitle: 'thingy for redirect_uri',
comment: 'i promise it doesn\'t collect your tokens',
},
{
link: '/proxifier.html',
title: 'proxifier.html',
subtitle: 'proxifier keygen',
comment: 'basically a port of some c# implementation bc im lazy',
},
{
link: '/spring.html',
title: 'spring.html',
subtitle: 'no idea',
comment: 'spring physics in ui are fun',
},
{
link: '/test_voice.ogg',
title: 'test_voice.ogg',
subtitle: 'фильм земляне 2005 года смотреть всем',
comment: 'libopus encoded, valid for telegram',
},
]

View file

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

View file

@ -1,24 +0,0 @@
/** @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

@ -0,0 +1,22 @@
.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

@ -0,0 +1,25 @@
/** @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,18 +1,41 @@
.input {
@apply hidden;
display: none;
}
.label {
@apply flex items-center gap-2 cursor-pointer select-none;
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
&:active {
user-select: none;
}
}
.box {
@apply bg-control-bg border-control-outline hover:bg-control-bg-hover pos-relative h-4 w-4 border rounded-md transition-all;
width: 1em;
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 {
content: '';
display: 'block';
@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;
width: 8px;
height: 8px;
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'
export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
class?: string
label?: JSX.Element
class?: string
label?: JSX.Element
}
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 (
<div class={my.class}>
<input
{...rest}
type="checkbox"
class={css.input}
id={id}
/>
<label class={css.label} for={id} tabIndex={0}>
<div class={css.box} />
{my.label}
</label>
</div>
)
return (
<div class={my.class}>
<input
{...rest}
type="checkbox"
class={css.input}
id={id}
/>
<label class={css.label} for={id} tabIndex={0}>
<div class={css.box} />
{my.label}
</label>
</div>
)
}

View file

@ -1,16 +0,0 @@
/** @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

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

View file

@ -0,0 +1,14 @@
/** @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

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

View file

@ -0,0 +1,27 @@
/** @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

@ -0,0 +1,23 @@
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

@ -0,0 +1,13 @@
/** @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

@ -0,0 +1,13 @@
/** @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

@ -1,17 +0,0 @@
/** @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

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

View file

@ -0,0 +1,16 @@
/** @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

@ -1,14 +0,0 @@
/** @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

@ -0,0 +1,37 @@
@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

@ -0,0 +1,12 @@
/** @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

@ -1,70 +0,0 @@
/** @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(
'outline-none 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 bg-control-bg-active outline outline-text-primary hover:cursor-text outline-offset-0)',
my.class,
)}
onInput={onInput}
/>
)
}

View file

@ -0,0 +1,39 @@
@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

@ -0,0 +1,68 @@
/** @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

@ -1,16 +0,0 @@
/** @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

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

View file

@ -0,0 +1,14 @@
/** @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

@ -1,43 +0,0 @@
/** @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_2fr] sm:grid-cols-[1fr_4fr] 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

@ -0,0 +1,43 @@
.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

@ -0,0 +1,40 @@
/** @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,27 +0,0 @@
import { glob } from 'astro/loaders'
import { type CollectionEntry, defineCollection, z } from 'astro:content'
const blog = defineCollection({
loader: glob({
base: './src/content/posts',
pattern: [
'*.md',
...(import.meta.env.PROD ? ['!sample.md'] : []),
],
}),
schema: () => z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
}),
})
export const collections = { blog }
export function sortPostsByDate(a: CollectionEntry<'blog'>, b: CollectionEntry<'blog'>) {
return a.data.date.valueOf() - b.data.date.valueOf()
}
export function sortPostsByDateReverse(a: CollectionEntry<'blog'>, b: CollectionEntry<'blog'>) {
return b.data.date.valueOf() - a.data.date.valueOf()
}

View file

@ -1,317 +0,0 @@
---
date: '2025-01-25'
title: 'how i built mtcute repl'
description: 'a tale about browser inconsistencies, threading in javascript and insane workarounds'
---
hey so uhh quick intro
for the past like two years i've been working on [mtcute](https://github.com/mtcute/mtcute), an mtproto client library in typescript. in case you didn't know, mtproto **FUCKING SUCKS**, but that's for another post.
and i've always wanted to build an online in-browser interactive tool that would allow me to poke around with telegram api, with minimum friction and maximum convenience. there are quite a few use-cases for this, and im not really going to go into detail here, but trust me, it's a pretty cool idea.
and so after a few weeks of work, i made a thing: [play.mtcute.dev](https://play.mtcute.dev)!<br/>
and while building it, i encountered *quite a few* issues that i want to share
## mtcute is a library
and as such, we need to somehow make it available to the user's code.
something like [`https://esm.sh`](https://esm.sh) would probably work, but i *really* don't trust such services. and hosting something like this myself would defeat the entire point of it being fully in-browser.
so i decided to go with a different approach.
instead of using a cdn, i download the library directly from `registry.npmjs.org` (along with all its dependencies), untar and save them to indexeddb. and then... well, we need to somehow run it.
> one might ask how is npm better than esm.sh in terms of security, and to that i have no definite answer,
> but like, if npm stars serving malicious code half the internet will be screwed anyway
initially i wanted to simply go with something like `esbuild-wasm` with user's code as an entrypoint, bundle everything into a single file and then just run that file as a web worker.
except esbuild-wasm weighs about **11mb** 🥴
not even considering the bundling performance overhead, that's a lot of wasted bandwidth. surely there's a better way?
### import maps
a good friend of mine (s/o [@kamillaova](https://github.com/kamillaova)) reminded me about import maps.
in case you haven't heard of them, it's a [recent addition](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) to the web platform,
allowing you to map esm imports to *somewhere*.
a simple `esm.sh` sourcemap for mtcute would look something like this:
```html
<script type="importmap">
{
"imports": {
"@mtcute/web": "https://esm.sh/@mtcute/web",
}
}
</script>
```
the issues were immediately obvious, however:
1. the library must be available by url
2. dynamic import maps are [not invented yet](https://github.com/WICG/import-maps/issues/92),
meaning we can't add an import map at runtime
3. import maps are [not supported by web workers](https://github.com/WICG/import-maps/issues/2)
but still, import maps would allow us to avoid bundling *at all*!
and the issues above *could probably be worked around*.<br/>
so i still decided to give it a try.
### importing by url
to make the library available by url without any external backend, we can just serve it from a service worker.
service workers allow intercepting all requests on our origin, and they also have full access
to indexeddb (where we store the downloaded code).<br/>
so serving the library from a service worker is as simple as:
```js
globalThis.addEventListener('fetch', (event) => {
if (event.request.url.startsWith('/sw/runtime/')) {
event.respondWith(serveFromIdb(event.request.url))
}
})
```
and then we can just use that url in the import map:
```html
<script type="importmap">
{
"imports": {
"@mtcute/web": "/sw/runtime/@mtcute/web/index.js",
...
}
}
</script>
```
> note: since mtcute and all its deps use npm-s `package.json`, when generating the import map we
> need to keep in mind the respective `exports` fields (and also `module/main/browser` god damnit 🤮)
### dynamic import maps
the remaining issues are very much linked together we need to provide import maps at runtime.
since we can't use a web worker, let's use an iframe instead!
and since we can't add import maps at runtime, we can just generate the entire html page on the fly:
```js
const html = `
<html>
<script type="importmap">${generateImportMap()}</script>
...some more html idk...
</html>
`
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
const iframe = document.createElement('iframe')
iframe.src = url
document.body.appendChild(iframe)
```
except... **[BAM](https://crbug.com/880768)!**
due to this chrome bug, our generated iframe won't be able to load the libraries
from our service worker, because it's not considered the *same origin*
no biggie, let's just serve the html directly from our service worker:
```js
globalThis.addEventListener('fetch', (event) => {
if (event.request.url === '/sw/runtime/_iframe.html') {
event.respondWith(generateIframeHtml())
}
})
```
and at that point, everything *seemed* to work??
## threading in browsers
as you probably know, javascript at its core is single-threaded. this is useful in most cases, however can be quite annoying in some others. especially for this particular one a repl.
in case a user ends up writing a computation-heavy task (or just accidentally do a `while (true) {}`),
the entire page will **freeze and potentially crash**. and it is something i specifically don't want in our case.
an obvious solution just run the user-provided code in a web worker..?
except we can't (see above).
<p class="thought">
but wait. we are already running the user's code in an iframe! <br/>
isn't it already a separate thread?
</p>
<span class="shout">NO</span><br/>
i used to think that it would run in a separate thread too, honestly.
but when i actually tried to run a `while (true) {}` **in an iframe**, it froze the entire page, not just the iframe.
the same issue can be seen in many in-browser playgrounds out there, like [solid.js one](https://playground.solidjs.com/),
[vue.js one](https://play.vuejs.org), and probably more...
### but why?
> this section is very much a *probably*, i didn't do a lot of research on this, but this sounds reasonable enough
afaiu, the fact that it's run on the same thread is primarily due to the `contentWindow` api.
see, when the iframe is same-origin, we can actually access its DOM from the parent window:
```js
const iframe = document.querySelector('iframe')
iframe.contentWindow.document.body.innerHTML = 'meow'
```
and because of that, browsers have to share the same javascript runtime thread between the iframe and the parent window.
for cross-origin iframes, however, the only way of communication is via `postMessage` (similar to web workers!),
and as such the browser can create a separate thread for it.
### what even is a cross-origin iframe?
i found [this post](https://webperf.tips/tip/iframe-multi-process/) that explains the issue in detail,
as well as providing *some pointers* on how to work around it.
tldr this is very much implementation-specific. but usually, the iframe is considered a *cross-origin*
if the `etld+1` of the parent and the child are different.
> etld is the [effective top-level domain](https://developer.mozilla.org/en-US/docs/Glossary/eTLD).<br/>
> (dont worry, i haven't heard this term before either)
>
> example.com and very.example.com have the same etld+1 (`example.com`), but example.com and example.co.uk don't.
one way we can force the browser to isolate the iframe is using the `sandbox` attribute:
```html
<iframe sandbox="allow-scripts"></iframe>
```
but... well, it's no longer cross-origin :D
and that's an issue!<br />
because we can no longer access our service worker from the iframe and load the libraries from it.
## what do we do?
so basically to make things work the way i intended, i would need some kind of `sandbox` attribute that would
isolate the javascript runtime thread (and disable the `contentWindow` api that i dont even use anyway),
while the frame is still considered same-origin.
and browsers don't have anything like that!! :<
at this point i basically had two options:
- give up on trying to separate the worker into a separate thread
- make an actual **cross-origin** iframe where most of the work would happen,
and our "main" window would just be a frontend talking to it via `postMessage`
### the great separation
i went with the latter because i really wanted to make my repl resilient to broken user code.
> <span class="big">important!</span>
>
> having two origins might be a security concern! what if a bad actor embeds my "worker" iframe in their website
> and steals everything?
>
> i had to be extra careful to verify the origin of every embedded message, to make sure it's our "frontend" talking
> to our "worker" iframe, and not some malicious website.
at this point i already had like 90% of the project finished, so refactoring everything to two-origin
architecture took some effort, but it was definitely worth it.
i had to move the following to the "worker" iframe:
- authorization and session management
- library downloading
- the service worker, along with iframe html generation
and the overall architecture ended up looking something like this:
<!-- i hate graphviz -->
```dot
digraph G {
subgraph cluster_worker {
label = "worker origin";
labeljust = "r";
sw[label="service worker",shape=rect,style=dashed]
worker[label = "worker iframe",shape=rect]
runner[label = "runner iframe",shape=cds]
idb[label = "indexeddb",shape=cylinder]
spacer[label = "",shape=rect,style=invis,fixedsize=true,width=2]
worker -> sw [label=" talks"]
sw -> runner [label=" serves"]
sw -> idb [label="stores libs",constraint=false]
runner -> idb [label="stores sessions",constraint=false]
}
frontend
frontend -> worker [dir="both",label="embeds + talks",constraint=false]
frontend -> runner [dir="both",label=" embeds + talks ",constraint=false]
}
```
- **frontend** is the actual app the user interacts with. just a normal frontend app, but instead of some rest api, it uses `postMessage` to talk to...
- **worker iframe** which implements most of the business logic, as well as manages the service worker and storage
- **service worker** is used to serve the library code, as well as the...
- **runner iframe** which is the "sandbox" in which the user's code is actually run
phew. that's some enterprise-grade backend architecture, right in your browser!<br/>
i really hope that one day browsers will make this kind of stuff easier to implement 🙏
### cross-origin is not the silver bullet, actually
at this point everything was working fine... in chrome :D
as soon as i opened the page in firefox, i was greeted with an incredibly helpful "The operation is insecure"
after some digging, it turned out that firefox has something called [state partitioning](https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning)
tldr: normally browsers always keyed websites' data by their origin. but trackers can (and do! ~~*cant have shit in this economy*~~) abuse this to track users across websites.
a simple example of that would be:
```js
// https://tracking.tei.su/get-user-id.html
globalThis.addEventListener('message', (event) => {
if (!localStorage.userId) localStorage.userId = crypto.randomUUID()
event.source.postMessage(localStorage.userId)
})
// which can then be used to track users across websites by simply doing:
const iframe = document.createElement('iframe')
iframe.src = 'https://tracking.tei.su/get-user-id.html'
document.body.appendChild(iframe)
iframe.addEventListener('message', (event) => {
console.log('you are %s!', event.data)
})
```
to avoid this, firefox keys the data by a combination of the iframe's origin and the top window's origin.
this way, the above code would return different results for different websites.
<p class="thought">
this... doesn't really sound like an issue for our case though?
</p>
ikr?? we barely store anything outside of the worker's origin, and only access our worker from a single origin.
but for **WHATEVER REASON** (likely due to some bug in firefox) our runner iframe *seemed* to have a separate service worker from the worker iframe. and a separate indexeddb. and only in some cases. 🥴
some stuff did seemingly get fixed by simply updating firefox to the latest version, and some other stuff i had to refactorfrom the worker iframe to the runner iframe. ugh, so annoying.
...
i have no idea how people even write outros so uhh<br/>
thanks for reading this rambling of a post i guess?
ok bye

View file

@ -1,38 +0,0 @@
---
date: '1970-01-01'
title: 'test post'
description: 'sample post to test markdown rendering'
---
## sub title
### sub sub title
#### sub sub sub title
##### sub sub sub sub title
###### sub sub sub sub sub title
> test quote
some text **bold** and _italic_ and `code` and also ~~strikethrough~~ as well as <u>underline</u>
- list item
- list item
- list item
- sub list item
- sub list item
1. list item
2. list item
3. list item
1. sub list item
2. sub list item
| table header | table header |
| ------------ | ------------ |
| table cell | table cell |
| table cell | table cell |
```js
const a = 1
```
a [link](https://github.com/teidesu/tei.su)

View file

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

View file

@ -1,5 +1,5 @@
---
import { Link } from '../../components/ui/Link.tsx'
import { Link } from '../../components/ui/Link/Link'
import BaseLayout, { type Props } from '../BaseLayout.astro'
import Header from './Header.astro'
@ -7,12 +7,11 @@ import Header from './Header.astro'
<BaseLayout {...Astro.props}>
<slot name="head" slot="head" />
<div class="min-h-screen flex flex-col items-center">
<div class="min-h-screen w-full flex flex-col gap-6 p-6 md:w-720px">
<div class="app">
<div class="content">
<Header />
<slot />
<div class="flex-1" />
<footer class="mx-4 border-t border-text-secondary pt-2 text-center text-2xs text-text-secondary">
<footer class="footer">
<div>
&lt;3 teidesu
{' / '}
@ -33,3 +32,37 @@ import Header from './Header.astro'
</div>
</div>
</BaseLayout>
<style>
@import '../../components/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;
}
</style>

View file

@ -1,19 +1,18 @@
---
import karin from '~/assets/karin.gif'
import { Link } from '../../components/ui/Link.tsx'
import { Link } from '~/components/ui/Link/Link'
const PAGES = [
{ name: 'hewwo', path: '/', match: /^\/$/ },
{ name: 'blog', path: '/blog', match: /^\/blog(\/|$)/ },
{ name: 'donate', path: '/donate', match: /^\/(donate|\$)\/?$/ },
{ name: 'hewwo', path: '/' },
{ name: 'donate', path: ['/donate', '/$'] },
]
---
<header class="pos-relative flex items-center justify-center gap-2">
<header class="header">
<img
aria-hidden="true"
class="pos-absolute right-0 top-0 h-64px w-64px motion-reduce:hidden @dark:filter-brightness-90"
src={karin.src}
transition:persist
aria-hidden="true"
class="gif"
src={karin.src}
transition:persist
/>
{(() => {
const elements = []
@ -21,15 +20,20 @@ const PAGES = [
for (const page of PAGES) {
if (elements.length > 0) {
elements.push(
<span class="select-none text-text-secondary"> / </span>,
<span class="delimiter"> / </span>,
)
}
const isActive = page.match.test(Astro.url.pathname)
let isActive
if (Array.isArray(page.path)) {
isActive = page.path.includes(Astro.url.pathname)
} else {
isActive = Astro.url.pathname === page.path
}
if (isActive) {
elements.push(
<a href={page.path} class="font-bold">{page.name}</a>,
<span class="active">{page.name}</span>,
)
} else {
const href = Array.isArray(page.path) ? page.path[0] : page.path
@ -45,3 +49,38 @@ const PAGES = [
return elements
})()}
</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'
export const POST: APIRoute = async (ctx) => {
const body = ctx.request.headers.get('content-type') === 'application/json'
? await ctx.request.json()
: Object.fromEntries((await ctx.request.formData()).entries())
const body = ctx.request.headers.get('content-type') === 'application/json'
? await ctx.request.json()
: Object.fromEntries((await ctx.request.formData()).entries())
if (body.auth_key !== env.FAKE_DEEPL_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
if (body.auth_key !== env.FAKE_DEEPL_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
if (!body.text) {
return new Response('Bad request', { status: 400 })
}
if (!body.text) {
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({
translations: [
{
detected_source_language: result.sourceLanguage,
text: result.translatedText,
},
],
}), {
headers: {
'Content-Type': 'application/json',
},
})
return new Response(JSON.stringify({
translations: [
{
detected_source_language: result.sourceLanguage,
text: result.translatedText,
},
],
}), {
headers: {
'Content-Type': 'application/json',
},
})
}

View file

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

View file

@ -1,89 +1,89 @@
import type { APIRoute } from 'astro'
import { html } from '@mtcute/node'
import { telegramNotify } from '~/backend/bot/notify'
import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey'
import { env } from '~/backend/env'
import { zodValidate } from '~/utils/zod'
import { telegramNotify } from '~/backend/bot/notify'
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) {
return `<a href="${server}/${fullUsername}">${user.name}</a>`
}
if (user.name) {
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 {
let text = note.text || '<i>&lt;no text&gt;</i>'
let text = note.text || '<i>&lt;no text&gt;</i>'
if (text.length > 100) {
text = `${text.substring(0, 100)}...`
}
if (text.length > 100) {
text = `${text.substring(0, 100)}...`
}
if (note.cw) {
text = `CW: ${note.cw}\n\n${text}`
}
if (note.cw) {
text = `CW: ${note.cw}\n\n${text}`
}
return text
return text
}
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) => {
if (ctx.request.headers.get('x-misskey-hook-secret') !== env.MK_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
if (ctx.request.headers.get('x-misskey-hook-secret') !== env.MK_WEBHOOK_SECRET) {
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')
}
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 type { APIRoute } from 'astro'
import { html } from '@mtcute/node'
import type { APIRoute } from 'astro'
import { telegramNotify } from '~/backend/bot/notify'
import { env } from '~/backend/env'
import { telegramNotify } from '~/backend/bot/notify'
export const POST: APIRoute = async (ctx) => {
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {
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 { RateLimiterMemory } from 'rate-limiter-flexible'
import { z } from 'zod'
import { fromError } from 'zod-validation-error'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
import { verifyCsrfToken } from '~/backend/utils/csrf'
import { getRequestIp } from '~/backend/utils/request'
import { verifyCsrfToken } from '~/backend/utils/csrf'
import { HttpResponse } from '~/backend/utils/response'
const schema = z.object({
_csrf: z.string(),
message: z.string(),
private: z.boolean(),
_csrf: z.string(),
message: z.string(),
private: z.boolean(),
})
const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 })
const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 })
export const POST: APIRoute = async (ctx) => {
const body = await schema.safeParseAsync(await ctx.request.json())
if (body.error) {
return HttpResponse.json({
error: fromError(body.error).message,
}, { status: 400 })
}
const body = await schema.safeParseAsync(await ctx.request.json())
if (body.error) {
return HttpResponse.json({
error: fromError(body.error).message,
}, { status: 400 })
}
const ip = getRequestIp(ctx)
const ip = getRequestIp(ctx)
if (!verifyCsrfToken(ip, body.data._csrf)) {
return HttpResponse.json({
error: 'csrf token is invalid',
}, { status: 400 })
}
if (!verifyCsrfToken(ip, body.data._csrf)) {
return HttpResponse.json({
error: 'csrf token is invalid',
}, { status: 400 })
}
if (isShoutboxBanned('GLOBAL')) {
return HttpResponse.json({
error: 'shoutbox is temporarily disabled',
}, { status: 400 })
}
if (isShoutboxBanned('GLOBAL')) {
return HttpResponse.json({
error: 'shoutbox is temporarily disabled',
}, { status: 400 })
}
const bannedUntil = isShoutboxBanned(ip)
if (bannedUntil) {
return HttpResponse.json({
error: `you were banned until ${bannedUntil}`,
}, { status: 400 })
}
const bannedUntil = isShoutboxBanned(ip)
if (bannedUntil) {
return HttpResponse.json({
error: `you were banned until ${bannedUntil}`,
}, { status: 400 })
}
const remainingLocal = await rateLimitPerIp.get(ip)
const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
if (remainingLocal?.remainingPoints === 0) {
return HttpResponse.json({
error: 'too many requests',
}, { status: 400 })
}
if (remainingGlobal?.remainingPoints === 0) {
return HttpResponse.json({
error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`,
}, { status: 400 })
}
const remainingLocal = await rateLimitPerIp.get(ip)
const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
if (remainingLocal?.remainingPoints === 0) {
return HttpResponse.json({
error: 'too many requests',
}, { status: 400 })
}
if (remainingGlobal?.remainingPoints === 0) {
return HttpResponse.json({
error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`,
}, { status: 400 })
}
const result = await createShout({
fromIp: ip,
private: body.data.private,
text: body.data.message,
})
const result = await createShout({
fromIp: ip,
private: body.data.private,
text: body.data.message,
})
await rateLimitPerIp.penalty(ip, 1)
await rateLimitGlobal.penalty('GLOBAL', 1)
await rateLimitPerIp.penalty(ip, 1)
await rateLimitGlobal.penalty('GLOBAL', 1)
return HttpResponse.json(
typeof result === 'string' ? { error: result } : { ok: true },
)
return HttpResponse.json(
typeof result === 'string' ? { error: result } : { ok: true },
)
}
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'))
if (Number.isNaN(page)) page = 0
let page = Number(url.searchParams.get('page'))
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,176 +0,0 @@
---
import { getCollection, render } from 'astro:content'
import DefaultLayout from '../../layouts/DefaultLayout/DefaultLayout.astro'
export const prerender = true
export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}))
}
const { post } = Astro.props
const { Content, remarkPluginFrontmatter } = await render(post);
---
<DefaultLayout
title={post.data.title}
og={{
title: post.data.title,
description: post.data.description,
date: post.data.date.toISOString(),
}}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1 border-b border-text-secondary pb-2">
<div class="text-2xl font-bold">{post.data.title}</div>
<p class="text-xs text-text-secondary">
{post.data.date.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}).toLowerCase()}
//
{remarkPluginFrontmatter.minutesRead}
</p>
</div>
<div data-md-content>
<Content />
</div>
<script>
document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el) => {
const anchor = document.createElement('a')
anchor.setAttribute('data-anchor-link', '')
anchor.setAttribute('href', `#${el.id}`)
anchor.textContent = '#'
el.appendChild(anchor)
})
</script>
</div>
</DefaultLayout>
<style is:global>
[data-md-content] {
--text-content: var(--text-primary);
@media (prefers-color-scheme: dark) {
/* our default primary text color is not suitable for large bodies of text */
--text-content: #bea7b0;
.astro-code,
.astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--control-bg) !important;
/* Optional, if you also want font styles */
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}
color: var(--text-content);
h1 { @apply text-4xl font-bold mb-4; }
h2 { @apply text-3xl font-bold mb-3 mt-3; }
h3 { @apply text-2xl font-bold mb-2 mt-2; }
h4 { @apply text-xl font-bold mb-2; }
h5 { @apply text-lg font-bold mb-2; }
h6 { @apply text-md font-bold mb-2; }
.big { @apply text-2xl font-bold; }
.shout { @apply text-4xl font-bold; }
.thought {
&::before {
content: '💭';
@apply text-4xl font-bold;
}
@apply p-4 mb-4 bg-control-bg-hover rounded-lg flex flex-row gap-4 items-center;
}
h1, h2, h3, h4, h5, h6 {
@apply relative text-text-primary cursor-pointer -ml-1em pl-1em;
[data-anchor-link] {
@apply hidden absolute left-0 text-text-secondary no-underline hover:underline;
}
&:hover [data-anchor-link] {
@apply inline;
}
}
p {
@apply text-sm mb-5;
line-height: 1.5;
}
ul { @apply list-disc list-outside mb-4 ml-4; }
ol { @apply mb-4 list-decimal list-outside ml-2em; }
li {
@apply text-sm mt-1;
line-height: 1.5;
}
code {
@apply text-sm bg-control-bg-hover px-1 rounded-md;
color: var(--text-content);
}
blockquote {
@apply border-l-4 border-text-secondary pl-4 mb-4 py-2;
p { @apply text-sm mb-0; }
p + p { @apply mt-4; }
}
pre {
@apply bg-control-bg-hover text-text-secondary p-2 rounded-md mb-4;
code { @apply bg-transparent; }
}
table {
@apply border-collapse border-solid border-text-secondary border-spacing-0 mb-2;
th, td {
@apply border border-text-secondary p-1;
}
th {
@apply font-bold text-left;
}
td {
@apply text-right;
}
}
a {
@apply text-text-accent underline hover:no-underline;
}
.graphviz-svg {
@apply w-full flex justify-center mb-4;
svg {
@apply border border-text-secondary rounded-md
}
.graph {
text {
@apply font-mono;
fill: var(--text-content);
font-size: 10px;
line-height: 1;
}
path, ellipse, polygon { @apply stroke-text-secondary; }
> polygon {
@apply fill-transparent stroke-none;
}
}
}
}
</style>

View file

@ -1,40 +0,0 @@
---
import { parallelMap } from '@fuman/utils'
import { getCollection, render } from 'astro:content'
import { Link } from '../../components/ui/Link.tsx'
import { sortPostsByDateReverse } from '../../content.config.ts'
import DefaultLayout from '../../layouts/DefaultLayout/DefaultLayout.astro'
export const prerender = true
const posts = (await getCollection('blog')).sort(sortPostsByDateReverse)
const postsRendered = await parallelMap(posts, async post => ({
post,
rendered: await render(post),
}))
---
<DefaultLayout title="alina's silly little blog">
{postsRendered.map(({ post, rendered }) => (
<article class="flex flex-col">
<Link href={`/blog/${post.id}`} class="mb-1 w-max text-lg font-bold">
{post.data.title}
</Link>
<p class="mb-2 text-xs text-text-secondary">
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}).toLowerCase()}
</time>
//
{rendered.remarkPluginFrontmatter.minutesRead}
</p>
<p class="text-sm text-text-primary">
{post.data.description}
</p>
</article>
))}
</DefaultLayout>

View file

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

View file

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

View file

@ -18,20 +18,20 @@ const HTML = `
`.trim()
export const GET: APIRoute = async (ctx) => {
if (isBotUserAgent(ctx.request.headers.get('user-agent') || '')) {
return new Response(HTML, {
headers: {
'Content-Type': 'text/html',
},
if (isBotUserAgent(ctx.request.headers.get('user-agent') || '')) {
return new Response(HTML, {
headers: {
'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'
export const GET: APIRoute = () => new Response(null, {
status: 301,
headers: {
Location: 'https://teidesu.github.io/protoflex/repl',
},
status: 301,
headers: {
Location: 'https://teidesu.github.io/protoflex/repl',
},
})

View file

@ -1,8 +1,8 @@
---
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { Link } from '../components/ui/Link.tsx'
import { SectionTitle } from '../components/ui/Section.tsx'
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
import { Link } from '~/components/ui/Link/Link'
let currentCurrency = new URL(Astro.request.url).searchParams.get('currency')
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')
export const GET: APIRoute = async (ctx) => {
const website = new URL(ctx.request.url).searchParams.get('website')
if (!website) {
return new Response('no website', {
status: 400,
const website = new URL(ctx.request.url).searchParams.get('website')
if (!website) {
return new Response('no website', {
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',
},
})
}

View file

@ -1,6 +0,0 @@
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 {
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 {
return arr[Math.floor(Math.random() * arr.length)]
return arr[Math.floor(Math.random() * arr.length)]
}
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'
export async function zodValidate<T extends z.ZodTypeAny>(schema: T, data: unknown): Promise<z.TypeOf<T>> {
const res = await schema.safeParseAsync(data)
if (res.error) throw fromError(res.error)
return res.data
const res = await schema.safeParseAsync(data)
if (res.error) throw fromError(res.error)
return res.data
}
export function zodValidateSync<T extends z.ZodTypeAny>(schema: T, data: unknown): z.TypeOf<T> {
const res = schema.safeParse(data)
if (res.error) throw fromError(res.error)
return res.data
const res = schema.safeParse(data)
if (res.error) throw fromError(res.error)
return res.data
}

View file

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

View file

@ -1,40 +0,0 @@
import { defineConfig, presetIcons, presetUno, transformerVariantGroup } from 'unocss'
export default defineConfig({
presets: [
presetUno(),
presetIcons(),
],
transformers: [
transformerVariantGroup(),
],
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',
},
},
})