initial commit
6
.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
dist
|
||||
node_modules
|
||||
.runtime
|
||||
.astro
|
||||
.vscode
|
||||
.env
|
49
.github/workflows/publish.yaml
vendored
Normal file
|
@ -0,0 +1,49 @@
|
|||
name: Release - Tag and publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
create_release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
env:
|
||||
DONATE_PAGE_DATA: ${{ vars.DONATE_PAGE_DATA }}
|
||||
run: |
|
||||
echo "$DONATE_PAGE_DATA" > src/components/pages/PageDonate/data.json
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/teidesu/tei.su
|
||||
tags: type=sha
|
||||
flavor: latest=true
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
25
.gitignore
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
.vscode
|
2
.runtime/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
28
Dockerfile
Normal file
|
@ -0,0 +1,28 @@
|
|||
FROM node:20-slim@sha256:a22f79e64de59efd3533828aecc9817bfdc1cd37dde598aa27d6065e7b1f0abc AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
ENV HOME="/app"
|
||||
RUN chmod -R 777 /app
|
||||
RUN corepack enable && corepack prepare
|
||||
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/dist /app/dist
|
||||
|
||||
EXPOSE 4321
|
||||
CMD [ "pnpm", "run", "start:prod" ]
|
||||
|
||||
|
15
README.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
# tei.su
|
||||
|
||||
source code for my place on the internet :3
|
||||
|
||||
finally with a modern stack (astro + solid)
|
||||
|
||||
## dev
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# fill the variables
|
||||
echo '[]' > src/components/pages/PageDonate/data.json
|
||||
|
||||
pnpm dev
|
||||
```
|
26
astro.config.mjs
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from 'astro/config'
|
||||
import solid from '@astrojs/solid-js'
|
||||
import node from '@astrojs/node'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
integrations: [
|
||||
solid(),
|
||||
],
|
||||
vite: {
|
||||
esbuild: { jsx: 'automatic' },
|
||||
define: {
|
||||
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
|
||||
},
|
||||
},
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
security: {
|
||||
checkOrigin: true,
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
},
|
||||
})
|
10
drizzle.config.ts
Normal file
|
@ -0,0 +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',
|
||||
},
|
||||
} satisfies Config
|
8
drizzle/0000_legal_gamma_corps.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE `shouts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`serial` integer DEFAULT 0 NOT NULL,
|
||||
`from_ip` text,
|
||||
`pending` integer DEFAULT true NOT NULL,
|
||||
`text` text,
|
||||
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
|
||||
);
|
71
drizzle/meta/0000_snapshot.json
Normal file
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "4ec9eb07-fd65-4fd0-9e47-10bdc1522d0e",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"shouts": {
|
||||
"name": "shouts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"serial": {
|
||||
"name": "serial",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"from_ip": {
|
||||
"name": "from_ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pending": {
|
||||
"name": "pending",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"text": {
|
||||
"name": "text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(CURRENT_TIMESTAMP)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
13
drizzle/meta/_journal.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1722558165317,
|
||||
"tag": "0000_legal_gamma_corps",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
31
eslint.config.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu({
|
||||
stylistic: {
|
||||
indent: 4,
|
||||
},
|
||||
typescript: true,
|
||||
astro: true,
|
||||
solid: true,
|
||||
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',
|
||||
},
|
||||
})
|
45
package.json
Normal file
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"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",
|
||||
"@mtcute/dispatcher": "^0.16.0",
|
||||
"@mtcute/node": "^0.16.3",
|
||||
"@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",
|
||||
"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"
|
||||
}
|
||||
}
|
7856
pnpm-lock.yaml
Normal file
8
postcss.config.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'postcss-mixins': {},
|
||||
'postcss-custom-media': {},
|
||||
'postcss-nesting': {},
|
||||
},
|
||||
}
|
26
public/.well-known/did.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": "did:web:tei.su",
|
||||
"alsoKnownAs": [
|
||||
"at://tei.su"
|
||||
],
|
||||
"verificationMethod": [
|
||||
{
|
||||
"id": "did:web:tei.su#atproto",
|
||||
"type": "Multikey",
|
||||
"controller": "did:web:tei.su",
|
||||
"publicKeyMultibase": "zQ3shf6oh9xDdYdLCHQeJskxP3pazTQ1CkSBGVynpX79fH3Na"
|
||||
}
|
||||
],
|
||||
"service": [
|
||||
{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": "https://pds.stupid.fish"
|
||||
}
|
||||
]
|
||||
}
|
BIN
public/88x31.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
22
public/ataturk-thing.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
<!-- Random Turkish Identification Number Generator -->
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<p id="tc"></p>
|
||||
|
||||
<script type="text/javascript">
|
||||
var a = "" + Math.floor(900000001 * Math.random() + 1e8),
|
||||
b = a.split("").map(function (t) {
|
||||
return parseInt(t, 10)
|
||||
}),
|
||||
c = b[0] + b[2] + b[4] + b[6] + b[8],
|
||||
d = b[1] + b[3] + b[5] + b[7],
|
||||
e = (7 * c - d) % 10;
|
||||
|
||||
document.getElementById("tc").textContent = a + ("" + e) + ("" + (d + c + e) % 10)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
1
public/cheerio/README.txt
Normal file
|
@ -0,0 +1 @@
|
|||
i lost source code for this one lol
|
1
public/cheerio/css/app.5f428797.css
Normal file
|
@ -0,0 +1 @@
|
|||
.repl-output{background:#ededed;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;border:1px solid #dbdbdb;border-radius:4px;height:85vh;max-height:85vh;width:45vw;max-width:45vw;overflow:auto;position:relative}.repl-output pre{padding:8px}.repl-spacer{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.repl-header.level{margin:0!important;padding:8px 16px;border-bottom:1px solid #dbdbdb;right:0;-webkit-box-shadow:0 1px 8px -1px #555;box-shadow:0 1px 8px -1px #555}.repl-header.level,.repl-input{position:-webkit-sticky;position:sticky;background:#fff;top:0;left:0}.repl-input{bottom:0;border-top:1px solid #dbdbdb;-webkit-box-shadow:0 -1px 8px -1px #555;box-shadow:0 -1px 8px -1px #555}.repl-input button,.repl-input textarea{border:none!important;outline:none!important;-webkit-box-shadow:none!important;box-shadow:none!important;height:48px;min-height:48px!important;max-height:384px!important;background:#fff}.repl-input button{width:48px}.repl-history-item pre{background:transparent!important;font-size:14px;color:#010101}.repl-history-item .message{margin:4px!important}.repl-history-item .message-body{padding:8px;overflow-y:auto;max-width:100%}.repl-placeholder{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;color:#000;font-size:24px}.repl-history-item{border-bottom:1px solid #dbdbdb;padding:4px 0}.repl-history-item,.repl-history-item__code{background-color:#fdfdfd}.repl-history-item:first-child{padding-top:12px}.repl-history-item:last-child{padding-bottom:18px}.repl-input__field textarea{resize:none;height:100%;word-break:keep-all;white-space:nowrap;overflow:auto}.hljs{background:transparent!important;padding:0!important}.page-title{font-size:21px;font-weight:500;text-align:center;border-bottom:1px solid #dbdbdb;margin:8px 25%;padding:4px}.is-family-monospace input,.is-family-monospace textarea{font-family:monospace!important}.vue-codemirror{margin:8px 0}.CodeMirror,.vue-codemirror{line-height:1em;font-family:monospace;height:100%!important;max-height:80vh!important;position:relative;overflow:hidden;width:45vw;max-width:45vw}.CodeMirror-scroll{overflow:auto;height:100%;position:relative;outline:none}.hide-btn{margin-right:8px}.repl-full-width{width:100%!important;max-width:100%!important}
|
1
public/cheerio/css/chunk-6f440a42.0068aa0c.css
Normal file
5
public/cheerio/css/chunk-vendors.526cbe67.css
Normal file
1
public/cheerio/index.html
Normal file
|
@ -0,0 +1 @@
|
|||
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=stylesheet href=https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css><title>Cheerio REPL</title><link href=css/chunk-6f440a42.0068aa0c.css rel=prefetch><link href=js/chunk-6f440a42.cb49e7e1.js rel=prefetch><link href=css/app.5f428797.css rel=preload as=style><link href=css/chunk-vendors.526cbe67.css rel=preload as=style><link href=js/app.b5356de7.js rel=preload as=script><link href=js/chunk-vendors.16b365e4.js rel=preload as=script><link href=css/chunk-vendors.526cbe67.css rel=stylesheet><link href=css/app.5f428797.css rel=stylesheet></head><body><noscript><strong>We're sorry but cheerio-repl doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=js/chunk-vendors.16b365e4.js></script><script src=js/app.b5356de7.js></script></body></html>
|
2
public/cheerio/js/app.b5356de7.js
Normal file
1
public/cheerio/js/app.b5356de7.js.map
Normal file
75
public/cheerio/js/chunk-6f440a42.cb49e7e1.js
Normal file
1
public/cheerio/js/chunk-6f440a42.cb49e7e1.js.map
Normal file
15
public/cheerio/js/chunk-vendors.16b365e4.js
Normal file
1
public/cheerio/js/chunk-vendors.16b365e4.js.map
Normal file
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 103 KiB |
2
public/keys
Normal file
|
@ -0,0 +1,2 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBCVyAT4PpOb4poB9OrOQTY5a/a9QfNnsEnbRjHPDrbU alina@tei.su
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHXaJrbD5SHp3HDtRX7YxrjO7wpcoY/L41Oc78IdT/l4 git@tei.su
|
1
public/keys@git
Normal file
|
@ -0,0 +1 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHXaJrbD5SHp3HDtRX7YxrjO7wpcoY/L41Oc78IdT/l4 git@tei.su
|
1
public/keys@ssh
Normal file
|
@ -0,0 +1 @@
|
|||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBCVyAT4PpOb4poB9OrOQTY5a/a9QfNnsEnbRjHPDrbU alina@tei.su
|
23
public/oauth.blank.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content="text/html; charset=utf8" http-equiv="content-type" />
|
||||
<title>OAuth Blank</title>
|
||||
</head>
|
||||
<body>
|
||||
Please, <b>do not copy</b> data from the address bar or the field below to third-party services. This way you may
|
||||
<b>lose</b> your data and/or account.<br />
|
||||
Your token: <br />
|
||||
<textarea id="token" readonly>JavaScript is not available</textarea>
|
||||
<script>
|
||||
var q = location.search.match(/[\?&](?:access_)?token=(.*?)(?:&|$)/)
|
||||
q1 = location.hash.match(/[#&](?:access_)?token=(.*?)(?:&|$)/),
|
||||
i = document.getElementById('token')
|
||||
i.value = q ? decodeURIComponent(q[1]) : q1 ? decodeURIComponent(q1[1]) : 'Token not found'
|
||||
i.onfocus = i.select
|
||||
</script>
|
||||
<style>
|
||||
#token { min-width: 480px; min-height: 200px; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
BIN
public/placeholder.m4a
Normal file
196
public/proxifier.html
Normal file
|
@ -0,0 +1,196 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Proxifier keygen</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<label for="product">Product:</label>
|
||||
<select id="product">
|
||||
<option value="0">Proxifier Standard Edition</option>
|
||||
<option value="1">Proxifier Portable Edition</option>
|
||||
<option value="2">Proxifier for Mac</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="exp-year">Exp. year:</label>
|
||||
<input type="number" min="2000" value="2077" id="exp-year" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="exp-month">Exp. month:</label>
|
||||
<input type="number" min="1" max="12" value="12" id="exp-month" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="fourth">4th part:</label>
|
||||
<input
|
||||
maxlength="5"
|
||||
pattern="[0-9A-Z]+"
|
||||
id="fourth"
|
||||
placeholder="random"
|
||||
value="SORRY"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onclick="updateKey()">Generate</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<p
|
||||
style="text-align: center; font-weight: bold; font-size: 1.4em"
|
||||
id="key"
|
||||
></p>
|
||||
</body>
|
||||
<script>
|
||||
const keyEl = document.querySelector('#key')
|
||||
const productEl = document.querySelector('#product')
|
||||
const expYearEl = document.querySelector('#exp-year')
|
||||
const expMonthEl = document.querySelector('#exp-month')
|
||||
const fourthEl = document.querySelector('#fourth')
|
||||
|
||||
window.onload = updateKey
|
||||
productEl.onchange = updateKey
|
||||
expYearEl.onchange = updateKey
|
||||
expMonthEl.onchange = updateKey
|
||||
fourthEl.onchange = updateKey
|
||||
|
||||
function updateKey() {
|
||||
const expiry =
|
||||
(parseInt(expYearEl.value) - 2000) * 12 +
|
||||
(parseInt(expMonthEl.value) - 1)
|
||||
keyEl.innerText = generateKey(
|
||||
parseInt(productEl.value),
|
||||
fourthEl.value,
|
||||
expiry
|
||||
)
|
||||
}
|
||||
|
||||
// below is based on
|
||||
// https://github.com/thedroidgeek/proxifier-keygen/blob/master/ProxifierKeygen/Keygen.cs
|
||||
function randRange(from, to) {
|
||||
return from + Math.round(Math.random() * (to - from))
|
||||
}
|
||||
|
||||
function compileString(str) {
|
||||
let result = 0
|
||||
for (let i = str.length - 1; i >= 0; i--) {
|
||||
result *= 32
|
||||
const c = str[i]
|
||||
|
||||
if (c === 'W') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (c === 'X') {
|
||||
result += 24
|
||||
} else if (c === 'Y') {
|
||||
result += 1
|
||||
} else if (c === 'Z') {
|
||||
result += 18
|
||||
} else if (c <= 57) {
|
||||
// '0' to '9'
|
||||
result += c.charCodeAt(0) - 48
|
||||
} // 'A' to 'V'
|
||||
else {
|
||||
result += c.charCodeAt(0) - 55
|
||||
}
|
||||
}
|
||||
return result >>> 0
|
||||
}
|
||||
|
||||
function decompileString(value, length) {
|
||||
value >>>= 0
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
const tmp = value % 32
|
||||
value = Math.trunc(value / 32)
|
||||
|
||||
if (tmp === 0) {
|
||||
result += 'W'
|
||||
} else if (tmp === 24) {
|
||||
result += 'X'
|
||||
} else if (tmp === 1) {
|
||||
result += 'Y'
|
||||
} else if (tmp === 18) {
|
||||
result += 'Z'
|
||||
} else if (tmp <= 9) {
|
||||
result += String.fromCharCode(tmp + 48)
|
||||
} else {
|
||||
result += String.fromCharCode(tmp + 55)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function crc32LikeThingy(data) {
|
||||
data = new Uint8Array(data)
|
||||
let result = -1
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result ^= data[i] << 24
|
||||
for (let j = 0; j < 8; j++) {
|
||||
if (result < 0) {
|
||||
result <<= 1
|
||||
result ^= 0x4c11db7
|
||||
} else {
|
||||
result <<= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function chunk(str, size) {
|
||||
let chunks = []
|
||||
let pos = 0
|
||||
while (pos < str.length) {
|
||||
chunks.push(str.substr(pos, size))
|
||||
pos += size
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function generateKey(
|
||||
product = 0,
|
||||
fourthKeyPart = '',
|
||||
expirationDate = 0
|
||||
) {
|
||||
const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const param1 =
|
||||
randRange(
|
||||
0x2580,
|
||||
0xffff
|
||||
) /* < 0x2580 ==> outdated key message */ +
|
||||
(product << 21)
|
||||
const param2 =
|
||||
randRange(0, 0xffff) +
|
||||
(expirationDate << 16) /* 0 ==> no expiration */
|
||||
|
||||
while (fourthKeyPart.length < 5) {
|
||||
fourthKeyPart += charset[randRange(0, charset.length - 1)]
|
||||
}
|
||||
|
||||
const param3 = compileString(fourthKeyPart)
|
||||
const data = new ArrayBuffer(12)
|
||||
const intArr = new Int32Array(data)
|
||||
intArr[0] = param1
|
||||
intArr[1] = param2
|
||||
intArr[2] = param3
|
||||
const value1 = crc32LikeThingy(data) & 0x1ffffff
|
||||
const value2 = value1 ^ (value1 << 7)
|
||||
const value3 = param1 ^ value2 ^ 0x12345678
|
||||
const value4 = param2 ^ value2 ^ 0x87654321
|
||||
let key = decompileString(value3, 7)
|
||||
key += decompileString(value4, 7)
|
||||
key += key[2] // 15th char becomes 3rd
|
||||
key += fourthKeyPart
|
||||
key += decompileString(value1, 5)
|
||||
// 3rd char doesn't affect the key check, as long as it's not a 'Y' ==> Proxifier v2 key (outdated)
|
||||
let rndChar = charset[randRange(0, charset.Length - 1)]
|
||||
if (rndChar === 'Y') rndChar = 'Z'
|
||||
return chunk(key, 5).join('-')
|
||||
}
|
||||
</script>
|
||||
</html>
|
3
public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
User-Agent: *
|
||||
Disallow: /donate
|
||||
Disallow: /nudes
|
59
public/spring.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>spring rofl</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/rebound@0.1.0/dist/rebound.js"></script>
|
||||
<style>
|
||||
.thingy {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: red;
|
||||
margin: 10px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thingy.plain.active {
|
||||
max-height: 100px;
|
||||
}
|
||||
.thingy.plain {
|
||||
max-height: 0;
|
||||
transition: max-height 0.25s ease-out;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="btn">toggle</button>
|
||||
<div style="height: 120px; background: lightblue">
|
||||
<div id="plain" class="plain thingy active"></div>
|
||||
<div id="fancy" class="thingy" style="max-height: 100px;"></div>
|
||||
</div>
|
||||
<script>
|
||||
var plain = document.getElementById('plain')
|
||||
|
||||
var fancy = document.getElementById('fancy')
|
||||
var fancyActive = true
|
||||
var springSystem = new rebound.SpringSystem();
|
||||
var spring = springSystem.createSpring(95, 13);
|
||||
|
||||
spring.addListener({
|
||||
onSpringUpdate: function(spring) {
|
||||
var val = spring.getCurrentValue();
|
||||
val = rebound.MathUtil
|
||||
.mapValueInRange(val, 0, 1, 100, 0);
|
||||
fancy.style.maxHeight = val + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn').addEventListener('click', function () {
|
||||
plain.classList.toggle('active')
|
||||
|
||||
spring.setEndValue(fancyActive + 0)
|
||||
fancyActive = !fancyActive
|
||||
})
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
BIN
public/test_sticker.webp
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
public/test_voice.ogg
Normal file
BIN
src/assets/axolotl.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/cherry-blossom_1f338.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
src/assets/flag-russia_1f1f7-1f1fa.png
Normal file
After Width: | Height: | Size: 8.3 KiB |
BIN
src/assets/flag-united-kingdom_1f1ec-1f1e7.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/javascript.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
src/assets/karin.gif
Normal file
After Width: | Height: | Size: 20 KiB |
17
src/backend/bot.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Dispatcher } from '@mtcute/dispatcher'
|
||||
import { TelegramClient } from '@mtcute/node'
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
const dp = Dispatcher.for(tg)
|
||||
dp.extend(shoutboxDp)
|
||||
|
||||
await tg.start({ botToken: env.TG_BOT_TOKEN })
|
11
src/backend/bot/notify.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type { InputText, TelegramClient } from '@mtcute/node'
|
||||
|
||||
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)
|
||||
}
|
41
src/backend/bot/shoutbox.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
|
||||
import { html } from '@mtcute/node'
|
||||
|
||||
import { env } from '../env'
|
||||
import { approveShout, declineShout, deleteBySerial } from '../service/shoutbox'
|
||||
|
||||
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
|
||||
|
||||
approveShout(ctx.match.id)
|
||||
await ctx.editMessageWith(msg => ({
|
||||
text: html`${msg.textWithEntities}<br><br>✅ Approved!`,
|
||||
}))
|
||||
})
|
||||
|
||||
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'decline' }), async (ctx) => {
|
||||
if (ctx.chat.id !== env.TG_CHAT_ID) return
|
||||
|
||||
declineShout(ctx.match.id)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
deleteBySerial(serial)
|
||||
await ctx.answerText('deleted')
|
||||
})
|
||||
|
||||
export { dp as shoutboxDp }
|
5
src/backend/db.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
|
||||
export const sqlite = new Database('.runtime/data.db')
|
||||
export const db = drizzle(sqlite)
|
244
src/backend/domain/misskey.ts
Normal file
|
@ -0,0 +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(),
|
||||
})
|
||||
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(),
|
||||
})
|
||||
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(),
|
||||
}),
|
||||
] 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(),
|
||||
})
|
23
src/backend/env.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import 'dotenv/config'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zodValidateSync } from '~/utils/zod'
|
||||
|
||||
export const env = zodValidateSync(
|
||||
z.object({
|
||||
UMAMI_HOST: z.string().url(),
|
||||
UMAMI_TOKEN: z.string(),
|
||||
UMAMI_SITE_ID: z.string().uuid(),
|
||||
LASTFM_TOKEN: z.string(),
|
||||
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(),
|
||||
}),
|
||||
process.env,
|
||||
)
|
1
src/backend/models/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './shoutbox'
|
13
src/backend/models/shoutbox.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
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)`),
|
||||
})
|
60
src/backend/service/currency.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { Reloadable } from '../utils/reloadable'
|
||||
import { env } from '../env'
|
||||
import { zodValidate } from '../../utils/zod'
|
||||
|
||||
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(),
|
||||
})),
|
||||
})
|
||||
|
||||
const reloadable = new Reloadable({
|
||||
name: 'currencies',
|
||||
expiresIn: () => TTL,
|
||||
async fetch() {
|
||||
// https://api.currencyapi.com/v3/latest?apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6¤cies=USD%2CEUR
|
||||
// apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6¤cies=USD%2CEUR
|
||||
const res = await fetch(`https://api.currencyapi.com/v3/latest?${new URLSearchParams({
|
||||
apikey: env.CURRENCY_API_TOKEN,
|
||||
currencies: AVAILABLE_CURRENCIES.slice(1).join(','),
|
||||
base_currency: AVAILABLE_CURRENCIES[0],
|
||||
})}`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch currencies: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
return zodValidate(schema, await res.json())
|
||||
},
|
||||
lazy: true,
|
||||
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}`)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return amount * data.data[to].value
|
||||
}
|
||||
|
||||
export async function fetchConvertRates() {
|
||||
await reloadable.get()
|
||||
}
|
118
src/backend/service/gtrans.ts
Normal file
|
@ -0,0 +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',
|
||||
]
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
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 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 }
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return {
|
||||
sourceLanguage: results[0].sourceLanguage,
|
||||
originalText: text,
|
||||
translatedText: result,
|
||||
}
|
||||
}
|
49
src/backend/service/last-seen/fedi.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { Reloadable } from '~/backend/utils/reloadable'
|
||||
import { zodValidate } from '~/utils/zod'
|
||||
|
||||
const ENDPOINT = 'https://very.stupid.fish/api/users/notes'
|
||||
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
||||
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
||||
const BODY = {
|
||||
userId: '9o5tqc3ok6pf5hjx',
|
||||
withRenotes: false,
|
||||
withReplies: false,
|
||||
withChannelNotes: false,
|
||||
withFiles: false,
|
||||
limit: 1,
|
||||
allowPartial: true,
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
text: z.nullable(z.string()),
|
||||
})
|
||||
|
||||
export const fediLastSeen = new Reloadable<z.infer<typeof schema>>({
|
||||
name: 'fedi-last-seen',
|
||||
async fetch() {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(BODY),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch fedi last seen: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
const data = await zodValidate(z.array(schema), await res.json())
|
||||
|
||||
return data[0]
|
||||
},
|
||||
expiresIn: () => TTL,
|
||||
lazy: true,
|
||||
swr: true,
|
||||
swrValidator: (_data, time) => Date.now() - time < STALE_TTL,
|
||||
})
|
41
src/backend/service/last-seen/github.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { Reloadable } from '~/backend/utils/reloadable'
|
||||
import { zodValidate } from '~/utils/zod'
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
export const githubLastSeen = new Reloadable<z.infer<typeof schema>>({
|
||||
name: 'github-last-seen',
|
||||
async fetch() {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
headers: {
|
||||
'User-Agent': 'tei.su/1.0',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch github last seen: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
const data = await zodValidate(z.array(schema), await res.json())
|
||||
|
||||
return data[0]
|
||||
},
|
||||
expiresIn: () => TTL,
|
||||
lazy: true,
|
||||
swr: true,
|
||||
swrValidator: (_data, time) => Date.now() - time < STALE_TTL,
|
||||
})
|
107
src/backend/service/last-seen/index.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { fediLastSeen } from './fedi'
|
||||
import { githubLastSeen } from './github'
|
||||
import { lastfm } from './lastfm'
|
||||
import { shikimoriLastSeen } from './shikimori'
|
||||
|
||||
export interface LastSeenItem {
|
||||
source: string
|
||||
sourceLink: string
|
||||
time: number
|
||||
text: string
|
||||
suffix?: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export async function fetchLastSeen() {
|
||||
const [
|
||||
lastfmData,
|
||||
fediData,
|
||||
shikimoriData,
|
||||
githubData,
|
||||
] = await Promise.all([
|
||||
lastfm.get(),
|
||||
fediLastSeen.get(),
|
||||
shikimoriLastSeen.get(),
|
||||
githubLastSeen.get(),
|
||||
])
|
||||
|
||||
const res: LastSeenItem[] = []
|
||||
|
||||
if (lastfmData) {
|
||||
res.push({
|
||||
source: 'last.fm',
|
||||
sourceLink: 'https://last.fm/user/teidesu',
|
||||
time: Number(lastfmData.date!.uts) * 1000,
|
||||
text: `${lastfmData.name} – ${lastfmData.artist['#text']}`,
|
||||
link: lastfmData.url,
|
||||
})
|
||||
}
|
||||
|
||||
if (fediData) {
|
||||
res.push({
|
||||
source: 'fedi',
|
||||
sourceLink: 'https://very.stupid.fish/@teidesu',
|
||||
time: new Date(fediData.updatedAt).getTime(),
|
||||
text: fediData.text?.slice(0, 40) || '[no text]',
|
||||
link: `https://very.stupid.fish/notes/${fediData.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (shikimoriData) {
|
||||
// thx morr for this fucking awesome api
|
||||
|
||||
const mapper: Record<string, string> = {
|
||||
'Просмотрено': 'completed',
|
||||
'Прочитано': 'completed',
|
||||
'Добавлено в список': 'added',
|
||||
'Брошено': 'dropped',
|
||||
}
|
||||
let event = mapper[shikimoriData.description]
|
||||
|
||||
if (!event && shikimoriData.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
|
||||
event = 'watched'
|
||||
}
|
||||
if (!event && shikimoriData.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
|
||||
event = 'completed'
|
||||
}
|
||||
|
||||
if (event) {
|
||||
res.push({
|
||||
source: 'shiki',
|
||||
sourceLink: 'https://shikimori.one/teidesu',
|
||||
time: new Date(shikimoriData.created_at).getTime(),
|
||||
text: shikimoriData.target.name,
|
||||
suffix: `: ${event}`,
|
||||
link: `https://shikimori.one${shikimoriData.target.url}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (githubData) {
|
||||
const eventTextMapper: Record<string, () => string> = {
|
||||
CreateEvent: () => `${githubData.payload.ref_type} created`,
|
||||
DeleteEvent: () => `${githubData.payload.ref_type} deleted`,
|
||||
ForkEvent: () => 'forked',
|
||||
GollumEvent: () => 'wiki updated',
|
||||
IssueCommentEvent: () => `issue comment ${githubData.payload.action}`,
|
||||
IssuesEvent: () => `issue ${githubData.payload.action}`,
|
||||
PublicEvent: () => 'made public',
|
||||
PullRequestEvent: () => `pr ${githubData.payload.action}`,
|
||||
PushEvent: () => `pushed ${githubData.payload.distinct_size} commits`,
|
||||
ReleaseEvent: () => `release ${githubData.payload.action}`,
|
||||
WatchEvent: () => 'starred',
|
||||
}
|
||||
if (eventTextMapper[githubData.type]) {
|
||||
res.push({
|
||||
source: 'github',
|
||||
sourceLink: 'https://github.com/teidesu',
|
||||
time: new Date(githubData.created_at).getTime(),
|
||||
text: githubData.repo.name,
|
||||
suffix: `: ${eventTextMapper[githubData.type]()}`,
|
||||
link: `https://github.com/${githubData.repo.name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.sort((a, b) => b.time - a.time)
|
||||
}
|
71
src/backend/service/last-seen/lastfm.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { Reloadable } from '~/backend/utils/reloadable'
|
||||
import { zodValidate } from '~/utils/zod'
|
||||
import { env } from '~/backend/env'
|
||||
|
||||
const LASTFM_TTL = 1000 * 60 * 5 // 5 minutes
|
||||
const LASTFM_STALE_TTL = 1000 * 60 * 60 // 1 hour
|
||||
const LASTFM_USERNAME = 'teidesu'
|
||||
const LASTFM_TOKEN = env.LASTFM_TOKEN
|
||||
|
||||
const LastfmTrack = z.object({
|
||||
'artist': z.object({ 'mbid': z.string(), '#text': z.string() }),
|
||||
'name': z.string(),
|
||||
'url': z.string(),
|
||||
'date': z.object({ uts: z.string() }).optional(),
|
||||
'@attr': z.object({
|
||||
nowplaying: z.literal('true'),
|
||||
}).partial().optional(),
|
||||
})
|
||||
export type LastfmTrack = z.infer<typeof LastfmTrack>
|
||||
|
||||
const ResponseSchema = z.object({
|
||||
recenttracks: z.object({
|
||||
'track': z.array(LastfmTrack),
|
||||
'@attr': z.object({
|
||||
user: z.string(),
|
||||
totalPages: z.string(),
|
||||
page: z.string(),
|
||||
perPage: z.string(),
|
||||
total: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const lastfm = new Reloadable<LastfmTrack>({
|
||||
name: 'last-track',
|
||||
async fetch(prev) {
|
||||
const params = new URLSearchParams({
|
||||
method: 'user.getrecenttracks',
|
||||
user: LASTFM_USERNAME,
|
||||
api_key: LASTFM_TOKEN,
|
||||
format: 'json',
|
||||
limit: '1',
|
||||
})
|
||||
if (prev?.date) {
|
||||
params.set('from', prev.date!.uts)
|
||||
}
|
||||
const res = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch last.fm data: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const parsed = await zodValidate(ResponseSchema, data)
|
||||
|
||||
const track = parsed.recenttracks.track[0]
|
||||
if (!track.date && track['@attr']?.nowplaying) {
|
||||
track.date = { uts: Math.floor(Date.now() / 1000).toString() }
|
||||
} else if (!track.date) {
|
||||
throw new Error('no track found')
|
||||
}
|
||||
|
||||
return track
|
||||
},
|
||||
expiresIn: () => LASTFM_TTL,
|
||||
lazy: true,
|
||||
swr: true,
|
||||
swrValidator: (_data, time) => Date.now() - time < LASTFM_STALE_TTL,
|
||||
})
|
40
src/backend/service/last-seen/shikimori.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { Reloadable } from '~/backend/utils/reloadable'
|
||||
import { zodValidate } from '~/utils/zod'
|
||||
|
||||
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(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const shikimoriLastSeen = new Reloadable<z.infer<typeof schema>>({
|
||||
name: 'shikimori-last-seen',
|
||||
async fetch() {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
headers: {
|
||||
'User-Agent': 'tei.su/1.0',
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch shikimori last seen: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
const data = await zodValidate(z.array(schema), await res.json())
|
||||
|
||||
return data[0]
|
||||
},
|
||||
expiresIn: () => TTL,
|
||||
lazy: true,
|
||||
swr: true,
|
||||
swrValidator: (_data, time) => Date.now() - time < STALE_TTL,
|
||||
})
|
155
src/backend/service/shoutbox.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { BotKeyboard, html } from '@mtcute/node'
|
||||
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
|
||||
|
||||
import { ShoutboxAction } from '../bot/shoutbox.js'
|
||||
import { shouts } 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'))),
|
||||
)
|
||||
|
||||
const fetchTotal = db.select({
|
||||
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,
|
||||
}).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),
|
||||
}
|
||||
}
|
||||
export type ShoutsData = ReturnType<typeof fetchShouts>
|
||||
|
||||
const fetchNextSerial = db.select({
|
||||
serial: sql<number>`coalesce(max(serial), 0) + 1`,
|
||||
}).from(shouts)
|
||||
.prepare()
|
||||
|
||||
export function approveShout(id: string) {
|
||||
const nextSerial = fetchNextSerial.get({})!.serial
|
||||
|
||||
db.update(shouts)
|
||||
.set({ pending: false, serial: nextSerial })
|
||||
.where(eq(shouts.id, id))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function declineShout(id: string) {
|
||||
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 })
|
||||
}
|
||||
|
||||
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 (URL_REGEX.test(text)) {
|
||||
return 'no links plz'
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createShout(params: {
|
||||
fromIp: string
|
||||
private: boolean
|
||||
text: string
|
||||
}): Promise<boolean | string> {
|
||||
let { text } = params
|
||||
|
||||
text = text.trim()
|
||||
|
||||
const validateResult = validateShout(text, !params.private)
|
||||
|
||||
const kindText = params.private ? 'private message' : 'shout'
|
||||
|
||||
if (params.private || validateResult !== true) {
|
||||
const was = params.private ? '' : ` was auto-declined (${validateResult})`
|
||||
await tg.sendText(
|
||||
env.TG_CHAT_ID,
|
||||
html`
|
||||
${kindText} from <code>${params.fromIp}</code>${was}:
|
||||
<br><br>
|
||||
${text}
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
${kindText} from <code>${params.fromIp}</code>:
|
||||
<br><br>
|
||||
${text}
|
||||
`,
|
||||
{
|
||||
replyMarkup: BotKeyboard.inline([[
|
||||
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
|
||||
BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })),
|
||||
]]),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return validateResult
|
||||
}
|
51
src/backend/service/umami.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { isBotUserAgent } from '../utils/bot'
|
||||
import { env } from '~/backend/env'
|
||||
|
||||
export async function umamiFetchStats(page: string, startAt: number) {
|
||||
if (import.meta.env.DEV) {
|
||||
return Promise.resolve({ uniques: { value: 1337 } })
|
||||
}
|
||||
|
||||
const res = await fetch(`${env.UMAMI_HOST}/api/websites/${env.UMAMI_SITE_ID}/stats?${new URLSearchParams({
|
||||
endAt: Math.floor(Date.now()).toString(),
|
||||
startAt: startAt.toString(),
|
||||
url: page,
|
||||
})}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.UMAMI_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
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] || ''
|
||||
|
||||
fetch(`${env.UMAMI_HOST}/api/send`, {
|
||||
body: JSON.stringify({
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': request.headers.get('user-agent') || '',
|
||||
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
|
||||
},
|
||||
method: 'POST',
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
|
||||
}).catch((err) => {
|
||||
console.warn(err)
|
||||
})
|
||||
}
|
39
src/backend/service/webring.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { Reloadable } from '~/backend/utils/reloadable'
|
||||
import { zodValidate } from '~/utils/zod'
|
||||
|
||||
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(),
|
||||
})
|
||||
export type WebringItem = z.infer<typeof WebringItem>
|
||||
|
||||
const WebringData = z.object({
|
||||
prev: WebringItem,
|
||||
next: WebringItem,
|
||||
})
|
||||
export type WebringData = z.infer<typeof WebringData>
|
||||
|
||||
export const webring = new Reloadable({
|
||||
name: 'webring',
|
||||
fetch: async () => {
|
||||
const response = await fetch(WEBRING_URL)
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Failed to fetch webring data: ${response.status} ${text}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const parsed = await zodValidate(WebringData, data)
|
||||
|
||||
return parsed
|
||||
},
|
||||
expiresIn: () => WEBRING_TTL,
|
||||
lazy: true,
|
||||
swr: true,
|
||||
})
|
3
src/backend/utils/bot.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function isBotUserAgent(userAgent: string) {
|
||||
return /bot|crawl|slurp|spider|mediapartners|mastodon|akkoma|pleroma|misskey|firefish|sharkey/i.test(userAgent)
|
||||
}
|
14
src/backend/utils/obfuscate-email.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { randomPick } from '../../utils/random'
|
||||
|
||||
export function obfuscateEmail(email: string) {
|
||||
const opener = randomPick(['[', '{', '(', '<', '|'])
|
||||
const closer = {
|
||||
'(': ')',
|
||||
'[': ']',
|
||||
'{': '}',
|
||||
'<': '>',
|
||||
'|': '|',
|
||||
}[opener]
|
||||
|
||||
return email.replace(/@/g, ` ${opener}at${closer} `).replace(/\./g, ` ${opener}dot${closer} `)
|
||||
}
|
25
src/backend/utils/promise.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* A promise that can be resolved or rejected from outside.
|
||||
*/
|
||||
export type ControllablePromise<T = unknown> = Promise<T> & {
|
||||
resolve: (val: T) => void
|
||||
reject: (err?: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that can be resolved or rejected from outside.
|
||||
*/
|
||||
export function createControllablePromise<T = unknown>(): ControllablePromise<T> {
|
||||
let _resolve: ControllablePromise<T>['resolve']
|
||||
let _reject: ControllablePromise<T>['reject']
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
_resolve = resolve
|
||||
_reject = reject
|
||||
})
|
||||
// ts doesn't like this, but it's fine
|
||||
|
||||
;(promise as ControllablePromise<T>).resolve = _resolve!
|
||||
;(promise as ControllablePromise<T>).reject = _reject!
|
||||
|
||||
return promise as ControllablePromise<T>
|
||||
}
|
83
src/backend/utils/reloadable.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { type ControllablePromise, createControllablePromise } from './promise'
|
||||
|
||||
export interface ReloadableParams<T> {
|
||||
name: string
|
||||
// whether to avoid automatically reloading
|
||||
lazy?: boolean
|
||||
// whether to return old value while a new one is fetching
|
||||
swr?: boolean
|
||||
// if `swr` is enabled, whether the stale data can still be used
|
||||
swrValidator?: (prev: T, prevTime: number) => boolean
|
||||
fetch: (prev: T | null, prevTime: number) => Promise<T>
|
||||
expiresIn: (data: T) => number
|
||||
}
|
||||
|
||||
export class Reloadable<T> {
|
||||
constructor(readonly params: ReloadableParams<T>) {}
|
||||
|
||||
private data: T | null = null
|
||||
private lastFetchTime = 0
|
||||
private expiresAt = 0
|
||||
|
||||
private updating?: ControllablePromise<void>
|
||||
private timeout?: NodeJS.Timeout
|
||||
|
||||
async update(force = false): Promise<void> {
|
||||
if (this.updating) {
|
||||
await this.updating
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && this.data && Date.now() < this.expiresAt) {
|
||||
return
|
||||
}
|
||||
|
||||
this.updating = createControllablePromise()
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await this.params.fetch(this.data, this.lastFetchTime)
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch ${this.params.name}:`, e)
|
||||
this.updating.resolve()
|
||||
this.updating = undefined
|
||||
return
|
||||
}
|
||||
|
||||
this.updating.resolve()
|
||||
this.updating = undefined
|
||||
|
||||
this.data = result
|
||||
const expiresIn = this.params.expiresIn(result)
|
||||
this.lastFetchTime = Date.now()
|
||||
this.expiresAt = this.lastFetchTime + expiresIn
|
||||
|
||||
if (!this.params.lazy) {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
this.timeout = setTimeout(() => {
|
||||
this.update()
|
||||
}, expiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
async get(): Promise<T | null> {
|
||||
if (this.params.swr && this.data) {
|
||||
const validator = this.params.swrValidator
|
||||
|
||||
if (!validator || validator(this.data, this.expiresAt)) {
|
||||
this.update().catch(() => {})
|
||||
return this.data
|
||||
}
|
||||
}
|
||||
|
||||
await this.update()
|
||||
|
||||
return this.data
|
||||
}
|
||||
|
||||
getCached(): T | null {
|
||||
return this.data
|
||||
}
|
||||
}
|
5
src/backend/utils/request.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import type { APIContext, AstroGlobal } from 'astro'
|
||||
|
||||
export function getRequestIp(ctx: AstroGlobal | APIContext) {
|
||||
return ctx.request.headers.get('x-forwarded-for') ?? ctx.clientAddress
|
||||
}
|
1466
src/backend/utils/url.ts
Normal file
12
src/components/interactive/RandomWord/RandomWord.module.css
Normal 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;
|
||||
}
|
||||
}
|
41
src/components/interactive/RandomWord/RandomWord.tsx
Normal 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>
|
||||
)
|
||||
}
|
4
src/components/pages/PageDonate/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
# i don't want this file to be in the repo as it *will* be crawled by github,
|
||||
# and i don't want people to be able to reverse-search up me by my crypto wallets
|
||||
# that's pretty much the main reason i do this obfuscation in the first place
|
||||
data.json
|
17
src/components/pages/PageDonate/PageDonate.astro
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||
|
||||
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
|
||||
import { fetchDonatePageData } from './data'
|
||||
|
||||
umamiLogThisVisit(Astro.request, '/donate')
|
||||
|
||||
const data = await fetchDonatePageData(Astro.request)
|
||||
---
|
||||
|
||||
<DefaultLayout>
|
||||
<PageDonateSolid {data}>
|
||||
<PaymentMethods {data} slot="methods" client:idle />
|
||||
</PageDonateSolid>
|
||||
</DefaultLayout>
|
96
src/components/pages/PageDonate/PageDonate.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
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 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]',
|
||||
})),
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
return (
|
||||
<TextTable
|
||||
items={itemsToRender()}
|
||||
minColumnWidth={12}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageDonate(props: { methods?: JSX.Element, data: PageData }) {
|
||||
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 <3
|
||||
</section>
|
||||
|
||||
<section>when donating crypto, please use stablecoins (usdt/dai) or native token</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>my payment addresses (in order of preference):</SectionTitle>
|
||||
|
||||
{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>
|
||||
|
||||
<section>
|
||||
total page views so far:
|
||||
{' '}
|
||||
{props.data.pageViews}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
9
src/components/pages/PageDonate/constants.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import data from './data.json' with { type: 'json' }
|
||||
|
||||
export interface PaymentMethod {
|
||||
link?: string
|
||||
name: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export const PAYMENT_METHODS = data as PaymentMethod[]
|
34
src/components/pages/PageDonate/crypto-common.ts
Normal file
|
@ -0,0 +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 res = ''
|
||||
while (hash > 0) {
|
||||
const q = hash % ascii.length
|
||||
hash = ~~(hash / ascii.length)
|
||||
res += ascii[q]
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export function deriveKey(userAgent: string, href: string, salt: string) {
|
||||
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
|
||||
}
|
42
src/components/pages/PageDonate/data.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
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 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 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),
|
||||
}))
|
||||
|
||||
return {
|
||||
encryptedData,
|
||||
probe,
|
||||
probeEnc,
|
||||
salt,
|
||||
pageViews,
|
||||
}
|
||||
}
|
||||
|
||||
export type PageData = Awaited<ReturnType<typeof fetchDonatePageData>>
|
21
src/components/pages/PageMain/PageMain.astro
Normal file
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
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 Shoutbox from './Shoutbox/Shoutbox.astro'
|
||||
|
||||
umamiLogThisVisit(Astro.request)
|
||||
|
||||
const data = await fetchMainPageData()
|
||||
---
|
||||
|
||||
<DefaultLayout>
|
||||
<PageMainSolid {data}>
|
||||
<RandomWord slot="part-time-words" choices={PARTTIME_VARIANTS} client:idle />
|
||||
<Shoutbox slot="shoutbox" />
|
||||
</PageMainSolid>
|
||||
</DefaultLayout>
|
128
src/components/pages/PageMain/PageMain.module.css
Normal file
|
@ -0,0 +1,128 @@
|
|||
@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;
|
||||
|
||||
&: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 {
|
||||
/* spaces are a crutch to avoid table resizing lol */
|
||||
content: ' (click to expand)';
|
||||
@mixin font-xs;
|
||||
margin-left: 1em;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@media (--tablet) {
|
||||
content: ' (expand)';
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeen[open] .lastSeenTrigger::before {
|
||||
content: '(click to collapse)';
|
||||
|
||||
@media (--tablet) {
|
||||
content: '(collapse)';
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenLinkWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media (--tablet) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenLinkWrapInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lastSeenLink {
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
|
||||
@media (--tablet) {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenSuffix {
|
||||
@mixin font-xs;
|
||||
}
|
||||
|
||||
.lastSeenSource {
|
||||
@mixin font-xs;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8px;
|
||||
|
||||
@media (--tablet) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
325
src/components/pages/PageMain/PageMain.tsx
Normal file
|
@ -0,0 +1,325 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import { For, type JSX, Show } from 'solid-js'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
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 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(),
|
||||
)
|
||||
}
|
||||
|
||||
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
||||
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
|
||||
}) {
|
||||
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}
|
||||
" -
|
||||
{link}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/* eslint-disable solid/no-innerhtml */
|
||||
const sublinks = SUBLINKS.map(item => (
|
||||
<div>
|
||||
-
|
||||
{' '}
|
||||
<Link href={item.link} target="_blank">
|
||||
{item.title}
|
||||
</Link>
|
||||
:
|
||||
{' '}
|
||||
<span innerHTML={item.subtitle} />
|
||||
<TextComment
|
||||
class={css.comment}
|
||||
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={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: 'fedi',
|
||||
value: () => (
|
||||
<Link href="https://very.stupid.fish/@teidesu" target="_blank">
|
||||
@teidesu@very.stupid.fish
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'imessage',
|
||||
value: () => props.data.email,
|
||||
},
|
||||
{
|
||||
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}>
|
||||
<
|
||||
{' '}
|
||||
{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}
|
||||
{' '}
|
||||
>
|
||||
</Link>
|
||||
</section>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
18
src/components/pages/PageMain/Shoutbox/Shoutbox.astro
Normal file
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
import { fetchShouts } from '~/backend/service/shoutbox'
|
||||
import { getRequestIp } from '~/backend/utils/request'
|
||||
|
||||
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
|
||||
|
||||
const url = new URL(Astro.request.url)
|
||||
let page = Number(url.searchParams.get('shouts_page'))
|
||||
if (Number.isNaN(page)) page = 0
|
||||
|
||||
const data = fetchShouts(page, getRequestIp(Astro))
|
||||
---
|
||||
|
||||
<ShoutboxSolid
|
||||
client:idle
|
||||
initPage={page}
|
||||
initPageData={data}
|
||||
/>
|
67
src/components/pages/PageMain/Shoutbox/Shoutbox.module.css
Normal file
|
@ -0,0 +1,67 @@
|
|||
@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;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.paginationLink {
|
||||
color: var(--text-secondary);
|
||||
}
|
196
src/components/pages/PageMain/Shoutbox/Shoutbox.tsx
Normal file
|
@ -0,0 +1,196 @@
|
|||
/* eslint-disable no-alert */
|
||||
/** @jsxImportSource solid-js */
|
||||
import { type ComponentProps, Show, createSignal } from 'solid-js'
|
||||
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query'
|
||||
import { format } from 'date-fns/format'
|
||||
|
||||
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 css from './Shoutbox.module.css'
|
||||
|
||||
async function fetchShouts(page: number): Promise<ShoutsData> {
|
||||
return fetch(`/api/shoutbox?page=${page}`).then(r => r.json())
|
||||
}
|
||||
|
||||
function ShoutboxInner(props: {
|
||||
initPage: number
|
||||
initPageData: ShoutsData
|
||||
}) {
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const [page, setPage] = createSignal(props.initPage)
|
||||
|
||||
const shouts = createQuery(() => ({
|
||||
queryKey: ['shouts', page()],
|
||||
queryFn: () => fetchShouts(page()),
|
||||
refetchInterval: 30000,
|
||||
placeholderData: keepPreviousData,
|
||||
initialData: props.initPageData,
|
||||
}))
|
||||
const [sending, setSending] = createSignal(false)
|
||||
|
||||
const onPageClick = (next: boolean) => (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const newPage = next ? page() + 1 : page() - 1
|
||||
|
||||
const link = e.currentTarget as HTMLAnchorElement
|
||||
const href = link.href
|
||||
|
||||
history.replaceState(null, '', href)
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
let form!: HTMLFormElement
|
||||
|
||||
const onSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setSending(true)
|
||||
|
||||
const isPrivate = (form.elements.namedItem('private') as HTMLInputElement).checked
|
||||
fetch('/api/shoutbox', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// _csrf: shouts.data?.csrf,
|
||||
message: (form.elements.namedItem('message') as HTMLInputElement).value,
|
||||
private: isPrivate ? '' : undefined,
|
||||
}),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
alert(data.error + (data.message ? `: ${data.message}` : ''))
|
||||
} else if (isPrivate) {
|
||||
alert('private message sent')
|
||||
form.reset()
|
||||
} else {
|
||||
alert('shout sent! it will appear after moderation')
|
||||
shouts.refetch()
|
||||
form.reset()
|
||||
}
|
||||
|
||||
setSending(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<section>
|
||||
<SectionTitle>shoutbox!</SectionTitle>
|
||||
<TextComment class={pageCss.comment}>
|
||||
disclaimer: shouts
|
||||
{' '}
|
||||
<i>are</i>
|
||||
{' '}
|
||||
pre-moderated, but they do not reflect my views.
|
||||
</TextComment>
|
||||
|
||||
<form action="/api/shoutbox" class={css.form} method="post" ref={form}>
|
||||
{/* <input type="hidden" name="_csrf" value={shouts.data.csrf} /> */}
|
||||
<div class={css.formInput}>
|
||||
<TextArea
|
||||
disabled={sending()}
|
||||
class={css.textarea}
|
||||
grow
|
||||
maxRows={5}
|
||||
name="message"
|
||||
// placeholder={initData.shoutError || 'let the void hear you'}
|
||||
placeholder="let the void hear you"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={onSubmit}
|
||||
title="submit"
|
||||
>
|
||||
<Icon glyph={GravityMegaphone} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<Checkbox
|
||||
label="make it private"
|
||||
name="private"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class={css.shouts}>
|
||||
{shoutsRender()}
|
||||
</div>
|
||||
|
||||
<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
|
||||
>
|
||||
< 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 >
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function Shoutbox(props: ComponentProps<typeof ShoutboxInner>) {
|
||||
const client = new QueryClient()
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<ShoutboxInner {...props} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
65
src/components/pages/PageMain/constants.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
export const PARTTIME_VARIANTS = [
|
||||
'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' },
|
||||
]
|
||||
|
||||
export const SUBLINKS = [
|
||||
{
|
||||
link: '/nudes',
|
||||
title: 'nudes',
|
||||
subtitle: '( ͡° ͜ʖ ͡°)',
|
||||
comment: 'a lot of them, actually',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
]
|
31
src/components/pages/PageMain/data.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { obfuscateEmail } from '~/backend/utils/obfuscate-email'
|
||||
import { webring } from '~/backend/service/webring'
|
||||
import { umamiFetchStats } from '~/backend/service/umami'
|
||||
import { fetchLastSeen } from '~/backend/service/last-seen'
|
||||
|
||||
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(),
|
||||
])
|
||||
|
||||
return {
|
||||
email: obfuscateEmail('alina@tei.su'),
|
||||
pageViews,
|
||||
shouts: [],
|
||||
webring: webringData,
|
||||
lastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
export type PageData = Awaited<ReturnType<typeof fetchMainPageData>>
|
22
src/components/shared.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
@custom-media --mobile (max-width: 480px);
|
||||
@custom-media --tablet (max-width: 720px);
|
||||
|
||||
@define-mixin font-2xs {
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
@define-mixin font-xs {
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
@define-mixin font-sm {
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
@define-mixin font-md {
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
22
src/components/ui/Button/Button.module.css
Normal 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;
|
||||
}
|
25
src/components/ui/Button/Button.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
41
src/components/ui/Checkbox/Checkbox.module.css
Normal file
|
@ -0,0 +1,41 @@
|
|||
.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
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';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
31
src/components/ui/Checkbox/Checkbox.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import { splitProps } from 'solid-js'
|
||||
|
||||
import css from './Checkbox.module.css'
|
||||
|
||||
export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
|
||||
class?: string
|
||||
label?: JSX.Element
|
||||
}
|
||||
|
||||
export function Checkbox(props: CheckboxProps) {
|
||||
const [my, rest] = splitProps(props, ['label', 'class'])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
8
src/components/ui/Emoji/Emoji.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.emoji {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
}
|
14
src/components/ui/Emoji/Emoji.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
3
src/components/ui/Icons/Icon.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.wrap {
|
||||
display: inline-flex;
|
||||
}
|
27
src/components/ui/Icons/Icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
23
src/components/ui/Icons/glyphs/COPYING
Normal 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.
|
13
src/components/ui/Icons/glyphs/GravityClock.tsx
Normal 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>
|
||||
)
|
||||
}
|
13
src/components/ui/Icons/glyphs/GravityMegaphone.tsx
Normal 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>
|
||||
)
|
||||
}
|
3
src/components/ui/Link/Link.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.link {
|
||||
color: var(--text-accent);
|
||||
}
|
16
src/components/ui/Link/Link.tsx
Normal 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>
|
||||
)
|
||||
}
|