Compare commits
33 commits
ead9d18b3d
...
9fe7060bf3
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fe7060bf3 | |||
| 45ad1c18dd | |||
| bca046931f | |||
| 932f571f99 | |||
| 1ef4035e56 | |||
| c64d61a9f1 | |||
| 489b284a56 | |||
| 7e0a35cca5 | |||
| 682f861d21 | |||
| 999980c055 | |||
| a62cb9bc20 | |||
| 31d7e01b9a | |||
| 2ff4fa2913 | |||
| f9474ceaf3 | |||
| 49e8b95f29 | |||
| ee813459c7 | |||
| 0206511463 | |||
| 0e4d363af9 | |||
| df376480d5 | |||
| 345da2b68e | |||
| 3fceb35f79 | |||
| e16300aeb2 | |||
| be3058d17a | |||
| 2e62c38ff2 | |||
| 59350d61f3 | |||
| eb97ade514 | |||
| 5b5e21e614 | |||
| c90a4c141e | |||
| ceac231a46 | |||
| b8a928b14f | |||
| f1a6a247b0 | |||
| 12f5b1eb68 | |||
| 114c9f93d4 |
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