commit
9fe7060bf3
30 changed files with 8513 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.{js,json,yml}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
32
.forgejo/workflows/0-semver.yml
Normal file
32
.forgejo/workflows/0-semver.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: Semver
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
jobs:
|
||||||
|
semver:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "21"
|
||||||
|
- name: Setup Yarn
|
||||||
|
run: |
|
||||||
|
yarn set version canary
|
||||||
|
yarn config set nodeLinker node-modules
|
||||||
|
|
||||||
|
- name: yarn install
|
||||||
|
run: yarn install --immutable
|
||||||
|
- name: Semantic Release
|
||||||
|
id: semantic
|
||||||
|
run: yarn run semantic-release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
44
.forgejo/workflows/10-docker_image.yml
Normal file
44
.forgejo/workflows/10-docker_image.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
name: Build Docker Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
jobs:
|
||||||
|
build-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Docker Metadata
|
||||||
|
id: metadata
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.thornbush.dev/rose/fixbluesky
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=
|
||||||
|
type=edge,branch=devel
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
- name: Docker Buildx
|
||||||
|
uses: https://code.forgejo.org/docker/setup-buildx-action@v3
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: https://code.forgejo.org/docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.thornbush.dev
|
||||||
|
username: ${{github.actor}}
|
||||||
|
password: ${{secrets.OAUTH_TOKEN}}
|
||||||
|
- name: Build Image
|
||||||
|
uses: https://code.forgejo.org/docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/.yarn/** linguist-vendored
|
||||||
|
/.yarn/releases/* binary
|
||||||
|
/.yarn/plugins/**/* binary
|
||||||
|
/.pnp.* binary linguist-generated
|
||||||
130
.gitignore
vendored
Normal file
130
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
12
.vscode/settings.json
vendored
Normal file
12
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"biome.enabled": true,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
}
|
||||||
|
}
|
||||||
925
.yarn/releases/yarn-4.5.0.cjs
vendored
Normal file
925
.yarn/releases/yarn-4.5.0.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.5.0.cjs
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
FROM node:21-alpine AS base
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY .gitignore .gitignore
|
||||||
|
COPY yarn.lock ./
|
||||||
|
COPY package.json ./
|
||||||
|
RUN yarn set version canary
|
||||||
|
RUN yarn config set nodeLinker node-modules
|
||||||
|
RUN yarn install --immutable
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Don't run production as root
|
||||||
|
RUN addgroup --system --gid 1001 fixbluesky
|
||||||
|
RUN adduser --system --uid 1001 fixbluesky
|
||||||
|
USER fixbluesky
|
||||||
|
COPY --from=builder /app .
|
||||||
|
|
||||||
|
CMD node ./dist/main.js
|
||||||
31
biome.json
Normal file
31
biome.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.7.1/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"useTemplate": "warn",
|
||||||
|
"noNonNullAssertion": "warn"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"lineWidth": 72,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"formatWithErrors": true,
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
commitlint.config.ts
Normal file
7
commitlint.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
extends: ["gitmoji"],
|
||||||
|
rules: {
|
||||||
|
"type-empty": [0, "never"],
|
||||||
|
"subject-empty": [0, "never"],
|
||||||
|
},
|
||||||
|
};
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
services:
|
||||||
|
keydb:
|
||||||
|
container_name: keydb
|
||||||
|
image: eqalpha/keydb
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- fixbluesky
|
||||||
|
fixbluesky:
|
||||||
|
container_name: fixbluesky
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 8787:8787
|
||||||
|
environment:
|
||||||
|
- REDIS_HOSTNAME=${REDIS_HOSTNAME:-keydb}
|
||||||
|
- BSKY_SERVICE_URL=${BSKY_SERVICE_URL}
|
||||||
|
- BSKY_AUTH_USERNAME=${BSKY_AUTH_USERNAME}
|
||||||
|
- BSKY_AUTH_PASSWORD=${BSKY_AUTH_PASSWORD}
|
||||||
|
- FIXBLUESKY_APP_DOMAIN=${FIXBLUESKY_APP_DOMAIN}
|
||||||
|
networks:
|
||||||
|
- fixbluesky
|
||||||
|
depends_on:
|
||||||
|
- keydb
|
||||||
|
networks:
|
||||||
|
fixbluesky:
|
||||||
|
name: fixbluesky
|
||||||
10
lefthook.yml
Normal file
10
lefthook.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
"biome lint":
|
||||||
|
run: yarn run lint
|
||||||
|
|
||||||
|
commit-msg:
|
||||||
|
commands:
|
||||||
|
"lint commit message":
|
||||||
|
run: yarn run commitlint --edit {1}
|
||||||
43
package.json
Normal file
43
package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"name": "fixbluesky",
|
||||||
|
"private": true,
|
||||||
|
"volta": {
|
||||||
|
"node": "21.7.3"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/main.js",
|
||||||
|
"module": "./dist/main.js",
|
||||||
|
"types": "./dist/main.d.ts",
|
||||||
|
"exports": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/main.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx ./src/main.ts",
|
||||||
|
"lint": "biome ci ./src/**/*",
|
||||||
|
"build": "pkgroll"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "1.7.1",
|
||||||
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
"@semantic-release/exec": "^6.0.3",
|
||||||
|
"@semantic-release/git": "^10.0.1",
|
||||||
|
"@types/node": "20.12.7",
|
||||||
|
"commitlint": "^19.5.0",
|
||||||
|
"commitlint-config-gitmoji": "^2.3.1",
|
||||||
|
"lefthook": "^1.7.18",
|
||||||
|
"pkgroll": "2.0.2",
|
||||||
|
"semantic-release": "^24.1.2",
|
||||||
|
"semantic-release-gitmoji": "^1.6.8",
|
||||||
|
"tsx": "4.7.2",
|
||||||
|
"typescript": "5.4.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto/api": "0.12.5",
|
||||||
|
"@hono/node-server": "1.11.0",
|
||||||
|
"hono": "4.2.7",
|
||||||
|
"ioredis": "5.4.1"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.5.0"
|
||||||
|
}
|
||||||
41
release.config.mjs
Normal file
41
release.config.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/**
|
||||||
|
* @type {import('semantic-release').GlobalConfig}
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
branches: ["main", { name: "devel", prerelease: true }],
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
"semantic-release-gitmoji",
|
||||||
|
{
|
||||||
|
releaseRules: {
|
||||||
|
major: [":boom:"],
|
||||||
|
minor: [":sparkles:"],
|
||||||
|
patch: [
|
||||||
|
":bug:",
|
||||||
|
":ambulance:",
|
||||||
|
":lipstick:",
|
||||||
|
":lock:",
|
||||||
|
":zap:",
|
||||||
|
":chart_with_upwards_trend:",
|
||||||
|
":globe_with_meridians:",
|
||||||
|
":alien:",
|
||||||
|
":wheelchair:",
|
||||||
|
":mag:",
|
||||||
|
":children_crossing:",
|
||||||
|
":speech_balloon:",
|
||||||
|
":iphone:",
|
||||||
|
":pencil2:",
|
||||||
|
":bento:",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
successCmd:
|
||||||
|
"echo 'NOTES<<EOF' >> $GITHUB_OUTPUT && echo '${nextRelease.notes}' >> $GITHUB_OUTPUT && echo 'EOF' >> $GITHUB_OUTPUT && echo 'VERSION=${nextRelease.version}' >> $GITHUB_OUTPUT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
27
src/components/Layout.tsx
Normal file
27
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { html } from "hono/html";
|
||||||
|
|
||||||
|
export interface LayoutProps {
|
||||||
|
url: string;
|
||||||
|
children: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout = ({ url, children }: LayoutProps) => {
|
||||||
|
const removeLeadingSlash = url.substring(1);
|
||||||
|
const redirectUrl = removeLeadingSlash.startsWith("https://")
|
||||||
|
? removeLeadingSlash
|
||||||
|
: `https://bsky.app/${removeLeadingSlash}`;
|
||||||
|
return html`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="canonical" href="${url.substring(1)}" />
|
||||||
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||||
|
<meta content="#0085ff" name="theme-color" />
|
||||||
|
<meta property="og:site_name" content="FixBluesky" />
|
||||||
|
|
||||||
|
${children}
|
||||||
|
<meta http-equiv="refresh" content="0;url=${redirectUrl}" />
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
};
|
||||||
52
src/components/Post.tsx
Normal file
52
src/components/Post.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { AppBskyFeedDefs } from "@atproto/api";
|
||||||
|
|
||||||
|
import { parseEmbedDescription } from "../lib/parseEmbedDescription.ts";
|
||||||
|
import { parseEmbedImage } from "../lib/parseEmbedImage.ts";
|
||||||
|
import { OEmbedTypes } from "../routes/getOEmbed.ts";
|
||||||
|
import { Layout } from "./Layout.tsx";
|
||||||
|
|
||||||
|
interface PostProps {
|
||||||
|
post: AppBskyFeedDefs.PostView;
|
||||||
|
url: string;
|
||||||
|
appDomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Post = ({ post, url, appDomain }: PostProps) => {
|
||||||
|
const image = parseEmbedImage(post);
|
||||||
|
return (
|
||||||
|
<Layout url={url}>
|
||||||
|
<meta name="twitter:creator" content={`@${post.author.handle}`} />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content={parseEmbedDescription(post)}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content={`${post.author.displayName} (@${post.author.handle})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!(image === post.author.avatar) && (
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(image) ? (
|
||||||
|
image.map((i) => (
|
||||||
|
<meta property="og:image" content={i} key={i} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<link
|
||||||
|
type="application/json+oembed"
|
||||||
|
href={`https:/${appDomain}/oembed?type=${
|
||||||
|
OEmbedTypes.Post
|
||||||
|
}&replies=${post.replyCount}&reposts=${
|
||||||
|
post.repostCount
|
||||||
|
}&likes=${post.likeCount}&avatar=${encodeURIComponent(
|
||||||
|
post.author.avatar ?? "",
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/components/Profile.tsx
Normal file
34
src/components/Profile.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { AppBskyActorDefs } from "@atproto/api";
|
||||||
|
|
||||||
|
import { OEmbedTypes } from "../routes/getOEmbed.ts";
|
||||||
|
import { Layout } from "./Layout.tsx";
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed;
|
||||||
|
url: string;
|
||||||
|
appDomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Profile = ({ profile, url, appDomain }: ProfileProps) => (
|
||||||
|
<Layout url={url}>
|
||||||
|
<meta name="twitter:creator" content={`@${profile.handle}`} />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content={profile.description ?? ""}
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content={`${profile.displayName} (@${profile.handle})`}
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content={profile.avatar} />
|
||||||
|
|
||||||
|
<link
|
||||||
|
type="application/json+oembed"
|
||||||
|
href={`https://${appDomain}/oembed?type=${
|
||||||
|
OEmbedTypes.Profile
|
||||||
|
}&follows=${profile.followsCount}&posts=${
|
||||||
|
profile.postsCount
|
||||||
|
}&avatar=${encodeURIComponent(profile.avatar ?? "")}`}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
9
src/globals.d.ts
vendored
Normal file
9
src/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { BskyAgent } from "@atproto/api";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HonoEnv {
|
||||||
|
Variables: {
|
||||||
|
Agent: BskyAgent;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/lib/fetchPostData.ts
Normal file
18
src/lib/fetchPostData.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { BskyAgent } from "@atproto/api";
|
||||||
|
|
||||||
|
export interface fetchPostOptions {
|
||||||
|
user: string;
|
||||||
|
post: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPost(
|
||||||
|
agent: BskyAgent,
|
||||||
|
{ user, post }: fetchPostOptions,
|
||||||
|
) {
|
||||||
|
const { data: userData } = await agent.getProfile({
|
||||||
|
actor: user,
|
||||||
|
});
|
||||||
|
return agent.getPosts({
|
||||||
|
uris: [`at://${userData.did}/app.bsky.feed.post/${post}`],
|
||||||
|
});
|
||||||
|
}
|
||||||
14
src/lib/fetchProfile.ts
Normal file
14
src/lib/fetchProfile.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { BskyAgent } from "@atproto/api";
|
||||||
|
|
||||||
|
export interface fetchProfileOptions {
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(
|
||||||
|
agent: BskyAgent,
|
||||||
|
{ user }: fetchProfileOptions,
|
||||||
|
) {
|
||||||
|
return agent.getProfile({
|
||||||
|
actor: user,
|
||||||
|
});
|
||||||
|
}
|
||||||
46
src/lib/parseEmbedDescription.ts
Normal file
46
src/lib/parseEmbedDescription.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import {
|
||||||
|
AppBskyEmbedRecord,
|
||||||
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
} from "@atproto/api";
|
||||||
|
|
||||||
|
export function parseEmbedDescription(post: AppBskyFeedDefs.PostView) {
|
||||||
|
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||||
|
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||||
|
const { success: isView } = AppBskyEmbedRecord.validateView(
|
||||||
|
post.embed,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
isView &&
|
||||||
|
AppBskyEmbedRecord.isViewRecord(post.embed.record)
|
||||||
|
) {
|
||||||
|
const { success: isViewRecord } =
|
||||||
|
AppBskyEmbedRecord.validateViewRecord(post.embed.record);
|
||||||
|
if (isViewRecord) {
|
||||||
|
// @ts-expect-error For some reason the original post value is typed as {}
|
||||||
|
return `${post.record.text}\n\nQuoting @${post.embed.record.author.handle}\n➥ ${post.embed.record.value.text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||||
|
const { success: isView } =
|
||||||
|
AppBskyEmbedRecordWithMedia.validateView(post.embed);
|
||||||
|
if (
|
||||||
|
isView &&
|
||||||
|
AppBskyEmbedRecord.isViewRecord(post.embed.record.record)
|
||||||
|
) {
|
||||||
|
const { success: isViewRecord } =
|
||||||
|
AppBskyEmbedRecord.validateViewRecord(
|
||||||
|
post.embed.record.record,
|
||||||
|
);
|
||||||
|
if (isViewRecord) {
|
||||||
|
// @ts-expect-error For some reason the original post value is typed as {}
|
||||||
|
return `${post.record.text}\n\nQuoting @${post.embed.record.record.author.handle}\n➥ ${post.embed.record.record.value.text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return post.record.text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
58
src/lib/parseEmbedImage.ts
Normal file
58
src/lib/parseEmbedImage.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import {
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyEmbedRecord,
|
||||||
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
} from "@atproto/api";
|
||||||
|
|
||||||
|
export function parseEmbedImage(post: AppBskyFeedDefs.PostView) {
|
||||||
|
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||||
|
const { success: isView } = AppBskyEmbedRecord.validateView(
|
||||||
|
post.embed,
|
||||||
|
);
|
||||||
|
if (isView && AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
|
||||||
|
const { success: isViewRecord } =
|
||||||
|
AppBskyEmbedRecord.validateViewRecord(post.embed.record);
|
||||||
|
if (
|
||||||
|
isViewRecord &&
|
||||||
|
post.embed.record.embeds &&
|
||||||
|
post.embed.record.embeds.every((v) =>
|
||||||
|
AppBskyEmbedImages.isView(v),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const validImageView = post.embed.record.embeds.every((v) =>
|
||||||
|
AppBskyEmbedImages.validateView(v),
|
||||||
|
);
|
||||||
|
if (validImageView) {
|
||||||
|
// return post.embed.record.embeds[0].images[0].fullsize;
|
||||||
|
const embeds = post.embed.record
|
||||||
|
.embeds as AppBskyEmbedImages.View[];
|
||||||
|
return embeds.flatMap((e) => e.images.map((i) => i.fullsize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||||
|
const { success: isView } =
|
||||||
|
AppBskyEmbedRecordWithMedia.validateView(post.embed);
|
||||||
|
if (isView && AppBskyEmbedImages.isView(post.embed.media)) {
|
||||||
|
const { success: isImageView } = AppBskyEmbedImages.validateView(
|
||||||
|
post.embed.media,
|
||||||
|
);
|
||||||
|
if (isImageView) {
|
||||||
|
// return post.embed.media.images[0].fullsize;
|
||||||
|
return post.embed.media.images.map((i) => i.fullsize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (AppBskyEmbedImages.isView(post.embed)) {
|
||||||
|
const { success: isImageView } = AppBskyEmbedImages.validateView(
|
||||||
|
post.embed,
|
||||||
|
);
|
||||||
|
if (isImageView) {
|
||||||
|
// return post.embed.images[0].fullsize;
|
||||||
|
return post.embed.images.map((i) => i.fullsize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return post.author.avatar ?? "";
|
||||||
|
}
|
||||||
76
src/main.ts
Normal file
76
src/main.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import "./util/checkEnv.ts";
|
||||||
|
|
||||||
|
import { BskyAgent } from "@atproto/api";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
import { getOEmbed } from "./routes/getOEmbed.ts";
|
||||||
|
import { getPost } from "./routes/getPost.tsx";
|
||||||
|
import { getProfile } from "./routes/getProfile.tsx";
|
||||||
|
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||||
|
const redis = new Redis(6379, process.env.REDIS_HOSTNAME!);
|
||||||
|
|
||||||
|
const agent = new BskyAgent({
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||||
|
service: process.env.BSKY_SERVICE_URL!,
|
||||||
|
persistSession: async (_evt, session) => {
|
||||||
|
if (session) {
|
||||||
|
await redis.set("session", JSON.stringify(session));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
const session = await redis.get("session");
|
||||||
|
try {
|
||||||
|
if (session) {
|
||||||
|
const login = await agent.resumeSession(JSON.parse(session));
|
||||||
|
if (!login.success) {
|
||||||
|
await agent.login({
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||||
|
identifier: process.env.BSKY_AUTH_USERNAME!,
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||||
|
password: process.env.BSKY_AUTH_PASSWORD!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await agent.login({
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||||
|
identifier: process.env.BSKY_AUTH_USERNAME!,
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||||
|
password: process.env.BSKY_AUTH_PASSWORD!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
c.set("Agent", agent);
|
||||||
|
} catch (error) {
|
||||||
|
const err = new Error("Failed to login to Bluesky!", {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
throw new HTTPException(500, {
|
||||||
|
message: `${err.message} \n\n ${err.cause} \n\n ${err.stack}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/profile/:user/post/:post", getPost);
|
||||||
|
app.get("/https://bsky.app/profile/:user/post/:post", getPost);
|
||||||
|
|
||||||
|
app.get("/profile/:user", getProfile);
|
||||||
|
app.get("/https://bsky.app/profile/:user", getProfile);
|
||||||
|
|
||||||
|
app.get("/oembed", getOEmbed);
|
||||||
|
|
||||||
|
serve(
|
||||||
|
{
|
||||||
|
...app,
|
||||||
|
port: Number(process.env.PORT ?? 8787),
|
||||||
|
},
|
||||||
|
(addr) => {
|
||||||
|
console.log(`Listening on http://localhost:${addr.port}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
35
src/routes/getOEmbed.ts
Normal file
35
src/routes/getOEmbed.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Handler } from "hono";
|
||||||
|
|
||||||
|
export enum OEmbedTypes {
|
||||||
|
Post = 1,
|
||||||
|
Profile = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOEmbed: Handler<HonoEnv, "/oembed"> = async (c) => {
|
||||||
|
const type = +(c.req.query("type") ?? 0);
|
||||||
|
const avatar = c.req.query("avatar");
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
provider_name: "FixBluesky",
|
||||||
|
provider_url: "https://bsyy.app/",
|
||||||
|
thumbnail_url: avatar,
|
||||||
|
thumbnail_width: 1000,
|
||||||
|
thumbnail_height: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === OEmbedTypes.Post) {
|
||||||
|
const { replies, reposts, likes } = c.req.query();
|
||||||
|
return c.json({
|
||||||
|
author_name: `🗨️ ${replies} ♻️ ${reposts} 💙 ${likes}`,
|
||||||
|
...defaults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === OEmbedTypes.Profile) {
|
||||||
|
const { follows, posts } = c.req.query();
|
||||||
|
return c.json({
|
||||||
|
author_name: `👤 ${follows} followers\n🗨️ ${posts} skeets`,
|
||||||
|
...defaults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return c.json(defaults, 400);
|
||||||
|
};
|
||||||
27
src/routes/getPost.tsx
Normal file
27
src/routes/getPost.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Handler } from "hono";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import { Post } from "../components/Post.tsx";
|
||||||
|
import { fetchPost } from "../lib/fetchPostData.ts";
|
||||||
|
|
||||||
|
export const getPost: Handler<
|
||||||
|
HonoEnv,
|
||||||
|
| "/profile/:user/post/:post"
|
||||||
|
| "/https://bsky.app/profile/:user/post/:post"
|
||||||
|
> = async (c) => {
|
||||||
|
const { user, post } = c.req.param();
|
||||||
|
const agent = c.get("Agent");
|
||||||
|
const { data, success } = await fetchPost(agent, { user, post });
|
||||||
|
if (!success) {
|
||||||
|
throw new HTTPException(500, {
|
||||||
|
message: "Failed to fetch the post!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Post
|
||||||
|
post={data.posts[0]}
|
||||||
|
url={c.req.path}
|
||||||
|
appDomain={process.env.FIXBLUESKY_APP_DOMAIN ?? "bsyy.app"}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
25
src/routes/getProfile.tsx
Normal file
25
src/routes/getProfile.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Handler } from "hono";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import { Profile } from "../components/Profile.tsx";
|
||||||
|
import { fetchProfile } from "../lib/fetchProfile.ts";
|
||||||
|
|
||||||
|
export const getProfile: Handler<
|
||||||
|
HonoEnv,
|
||||||
|
"/profile/:user" | "/https://bsky.app/profile/:user"
|
||||||
|
> = async (c) => {
|
||||||
|
const { user } = c.req.param();
|
||||||
|
const agent = c.get("Agent");
|
||||||
|
const { data, success } = await fetchProfile(agent, { user });
|
||||||
|
if (!success) {
|
||||||
|
throw new HTTPException(500, {
|
||||||
|
message: "Failed to fetch the profile!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return c.html(
|
||||||
|
<Profile
|
||||||
|
profile={data}
|
||||||
|
url={c.req.path}
|
||||||
|
appDomain={process.env.FIXBLUESKY_APP_DOMAIN ?? "bsyy.app"}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
15
src/util/checkEnv.ts
Normal file
15
src/util/checkEnv.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
if (!process.env.REDIS_HOSTNAME) {
|
||||||
|
throw new Error("REDIS_HOSTNAME is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.BSKY_SERVICE_URL) {
|
||||||
|
throw new Error("BSKY_SERVICE_URL is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.BSKY_AUTH_USERNAME) {
|
||||||
|
throw new Error("BSKY_AUTH_USERNAME is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.BSKY_AUTH_PASSWORD) {
|
||||||
|
throw new Error("BSKY_AUTH_PASSWORD is not defined");
|
||||||
|
}
|
||||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"importHelpers": false,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"newLine": "lf",
|
||||||
|
"noEmit": true,
|
||||||
|
"noEmitHelpers": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"preserveConstEnums": true,
|
||||||
|
"pretty": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist/**/*"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue