Compare commits
345 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ddb08debd | |||
| 5ec8761b29 | |||
| 990bf306c5 | |||
| b8dbb71781 | |||
| 2ae3ffcf78 | |||
| 27bb383268 | |||
| df5bf28e61 | |||
| 92989282ae | |||
| 9f1c411360 | |||
| 85d70fe3ef | |||
| d5c78b9183 | |||
| 61f0be705d | |||
| 6616a6467e | |||
| 56ab5e177f | |||
| ddb0b80017 | |||
| 5463e9718f | |||
| 723896a45f | |||
| e54298ec2c | |||
| f235be9819 | |||
| 3976d6738b | |||
| 2939ee7df7 | |||
| 40ab67fc4b | |||
| a5af24b53b | |||
| b6e515c664 | |||
| f3b57dd456 | |||
| 11061b628e | |||
| b9975697e2 | |||
| b6fa0d2d04 | |||
| 21e214c235 | |||
| 26d3777ecc | |||
| 630ebf523d | |||
| 57be2ea15b | |||
| 7e11b862e9 | |||
| 1fce7a793d | |||
| 3c04d9bd84 | |||
| 99d1a881f2 | |||
| 134fcd35d8 | |||
| 7df2327424 | |||
| ae883e2df7 | |||
| db7a744433 | |||
| 75c19b2dc2 | |||
| 88f879ffe9 | |||
| 6f450b4982 | |||
| 8367542138 | |||
| 65d6e561d4 | |||
| c2131bb039 | |||
| ab0da7c892 | |||
| 0a9782ac19 | |||
| 5bfe5aa503 | |||
| dd0d50a6f0 | |||
| 0f993a09c2 | |||
| cb574b7be3 | |||
| a4f0c9c753 | |||
| 4350dbc853 | |||
| e782db33dc | |||
| c1af767fa6 | |||
| a0756c53be | |||
| 3ea8eb6013 | |||
| e7a0055a85 | |||
| ae25cb3391 | |||
| 85fe95c988 | |||
| e1df2b0b16 | |||
| f3a9f874d9 | |||
| 4b71950d99 | |||
| 2174feed44 | |||
| f1031d100b | |||
| 1e3b2d6f42 | |||
| af5262682e | |||
| a864f69849 | |||
| 00fea10782 | |||
| b3092413dd | |||
| 1b02f81cb8 | |||
| fff2c079c2 | |||
| b701e8c68c | |||
| 753a233408 | |||
| 5845e08eee | |||
| b291a1ed8a | |||
| 686d5ebb53 | |||
| 966f6c511f | |||
| 5bf7f3769d | |||
| 74b0318d89 | |||
| 18b423396b | |||
| fb278384c6 | |||
| 6298e6897f | |||
| c3d8beee6d | |||
| 293ac6fab2 | |||
| 7f292abf51 | |||
| f056cb646e | |||
| c78e9e3147 | |||
| 388c157c36 | |||
| b0e130a4d8 | |||
| d2e88cc623 | |||
| 70ffd387e3 | |||
| 576cef88b5 | |||
| c75bb65bef | |||
| c3e77b56ff | |||
| 8ddb28d3c5 | |||
| 43ba0f21f6 | |||
| 4437b9a557 | |||
| 50769b0b6f | |||
| 043e5cea64 | |||
| 35165e3d9b | |||
| 1d827cebe4 | |||
| 783fd351ba | |||
| e7b485f185 | |||
| ac1538baad | |||
| 3914025227 | |||
| 401e92ed42 | |||
| 00240b95b9 | |||
| 4ec999cab7 | |||
| 4291711f1d | |||
| fac1af43b0 | |||
| a03622dd55 | |||
| 2e7398b7c3 | |||
| 8588a2ad51 | |||
| 7a0aa661a7 | |||
| 86ac3d687c | |||
| 11f2415942 | |||
| bfb7f6efef | |||
| efde018b13 | |||
| 8fe5ddfa49 | |||
| cfb8a3160e | |||
| 9bd8393685 | |||
| 4af6bcb11a | |||
| 67ce18a113 | |||
| 3755807b5e | |||
| abb709d209 | |||
| 27d712290a | |||
| 59bafb426f | |||
| 3627a249ff | |||
| 7c1c24ef1b | |||
| f85df425f7 | |||
| 306f162639 | |||
| f021c06468 | |||
| cd9bba4b69 | |||
| a71134f467 | |||
| 8b121af2e4 | |||
| 83e8522e0a | |||
| 2397104ad6 | |||
| b433469ab9 | |||
| 119b4d3e0a | |||
| 74186950b2 | |||
| ea0586cd67 | |||
| 15a00b1864 | |||
| 65b5d31b58 | |||
| fa64d9393b | |||
| 9b9e09d002 | |||
| ce0bf867ff | |||
| 7a47656816 | |||
| 2d0eefebc3 | |||
| 4360087ced | |||
| ca0a327052 | |||
| a3c43a7471 | |||
| f8a59e10dd | |||
| d5503d17de | |||
| ea7afecf28 | |||
| ea37298cdd | |||
| a6b3c97df0 | |||
| 6b5e3181fb | |||
| 8f06ba70bb | |||
| 56b688744e | |||
| 09bc4e95d8 | |||
| 149446a26e | |||
| 8c52d74925 | |||
| f45193783e | |||
| adcd066733 | |||
| b10a2b9a8e | |||
| 75da5f4965 | |||
| baa788de38 | |||
| 09dfc9edf8 | |||
| d5fd19df8f | |||
| 58e48fd31b | |||
| 3407206f52 | |||
| 1c6bfc02fb | |||
| d03dd8c815 | |||
| ca7386967a | |||
| d837f96478 | |||
| 12bf796293 | |||
| 4f02da96c8 | |||
| 0ed99b840d | |||
| aa7117edb6 | |||
| a3d4fb652b | |||
| 6694a33603 | |||
| dc3c81c4e2 | |||
| 04cfd06639 | |||
| 0598fc2faa | |||
| 6af78de9ee | |||
| cacc4c5068 | |||
| 14c2d75d49 | |||
| c133661768 | |||
| 63bb8fda2d | |||
| 4bb4452f08 | |||
| 0012c6d40f | |||
| a9fe87b842 | |||
| 1a037d3542 | |||
| 58a97db5b8 | |||
| 8ebf9cc4b1 | |||
| 91c4aa7c2d | |||
| 030c8e268e | |||
| d5ca95233e | |||
| fff3ae8f35 | |||
| d26928a5d8 | |||
| 58102377fd | |||
| 49396451ec | |||
| f6b138f709 | |||
| 0ab6d54093 | |||
| 5641a4393c | |||
| 878b0476dd | |||
| da4dfeb9cf | |||
| 3b0a177544 | |||
| 368cd7bb0e | |||
| c4dce4ce45 | |||
| 3f20e2e3cf | |||
| 83745c923f | |||
| 8621ecd38a | |||
| b23f112689 | |||
| dd2e173514 | |||
| 7c5ff79c8e | |||
| b75341314c | |||
| 58a9dceb33 | |||
| b6a38e7d64 | |||
| 4d9657c819 | |||
| d11b552710 | |||
| e5b9f130a9 | |||
| fc2fba0981 | |||
| 12faa005af | |||
| 363d18a40e | |||
| efbc6d4f1b | |||
| 682f31ec9d | |||
| 6cda641250 | |||
| 615c0c851e | |||
| 2ed133cb63 | |||
| f94edc3f44 | |||
| dd5198f317 | |||
| 340c2c5eaf | |||
| 795fe7455b | |||
| dc9e51dca1 | |||
| 59fa7c0c0d | |||
| d79891a858 | |||
| 51fca95669 | |||
| ed940c637e | |||
| 9e89ddeb1c | |||
| bce3338a02 | |||
| ffb67397e7 | |||
| 29aaf09a8b | |||
| e0ac7d5bdc | |||
| f64245c1fb | |||
| 873d91d466 | |||
| 77a512ae32 | |||
| f769564edf | |||
| 0a0c738790 | |||
| de1b7f7045 | |||
| 897427eed0 | |||
| 45648bd383 | |||
| 8eec632636 | |||
| f75a429f08 | |||
| f089f45781 | |||
| 21a7d47cdc | |||
| 35f64535cb | |||
| 1715afd80e | |||
| 7db8dd8980 | |||
| 707ea5bf06 | |||
| 4d9e686e3b | |||
| 4d6787009c | |||
| cb37647949 | |||
| 55812b0394 | |||
| ba21fddd78 | |||
| 8ff862798f | |||
| f344032ed8 | |||
| 4c48a1f14b | |||
| 4bba59790a | |||
| 4d8537bcd4 | |||
| 5d98b4b06c | |||
| 51f5e6bf90 | |||
| 39c32b0ab1 | |||
| eac4668d73 | |||
| 6ac8389adb | |||
| 52b52617e1 | |||
| 43adc64e5f | |||
| f927e74e52 | |||
| a33e370d22 | |||
| 80197556f1 | |||
| 75aec19230 | |||
| 7deea7ddd4 | |||
| bfcf872921 | |||
| e327bd01ec | |||
| 7d8fca56dc | |||
| 89d99a8701 | |||
| 5c31859f7b | |||
| 73fc0094dd | |||
| 0f931933a7 | |||
| e170447cc6 | |||
| a98c4efc7c | |||
| 22c5aa4da4 | |||
| ca17cf276f | |||
| 95f4cfe24f | |||
| 6316ddf627 | |||
| bdc1ea897f | |||
| 8788708bd2 | |||
| 3dc34be929 | |||
| 7ddbc392c3 | |||
| 5159495274 | |||
| 07c2be255f | |||
| ac08c76168 | |||
| 2237e10ad2 | |||
| c92ef2fe31 | |||
| 32b4063185 | |||
| 853c32b4d8 | |||
| 0012d1236f | |||
| 502bcad701 | |||
| d6ce16d15a | |||
| 4165a02b2d | |||
| 983d85384b | |||
| fb76265fcc | |||
| 5f5d845053 | |||
| a6d49062e6 | |||
| 35e54e24a0 | |||
| 1106517481 | |||
| 73c9de3ce2 | |||
| 443beda741 | |||
| 08cfb09589 | |||
| f142339e06 | |||
| e30575c0dc | |||
| e6213d7aa5 | |||
| fad73fe928 | |||
| b06aa12730 | |||
| 0b24216040 | |||
| 7548c23f19 | |||
| b4e4c15fbb | |||
| 2a83ad1f5c | |||
| 035a1ca18e | |||
| c09b66182d | |||
| 741ce1c176 | |||
| 0af555830e | |||
| 45b10f2c60 | |||
| fa1dfe8b0a | |||
| 01f505d09f | |||
| b80594a554 | |||
| 3ea3b5a4d4 | |||
| 23a14454dc | |||
| 92fe3cde7a | |||
| ecd51bc6f9 | |||
| bd8f0e5a1c | |||
| 004b4e3969 | |||
| ff0b9e30de |
@@ -0,0 +1,2 @@
|
||||
[*.{kt,kts}]
|
||||
indent_size=2
|
||||
@@ -31,6 +31,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: build-and-push-link-aws
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
|
||||
IMAGE_NAME: bskylink
|
||||
|
||||
jobs:
|
||||
link-container-aws:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
file: ./Dockerfile.bskylink
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,53 @@
|
||||
name: build-and-push-ogcard-aws
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
|
||||
IMAGE_NAME: bskyogcard
|
||||
|
||||
jobs:
|
||||
ogcard-container-aws:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
password: ${{ env.PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
file: ./Dockerfile.bskyogcard
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
|
||||
@@ -115,3 +115,6 @@ src/locale/locales/**/*.js
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
# ogcard assets
|
||||
bskyogcard/src/assets/fonts/noto-*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21-bullseye AS build-env
|
||||
FROM golang:1.22-bullseye AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
FROM node:20.11-alpine3.18 as build
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./bskylink/package.json ./
|
||||
COPY ./bskylink/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY ./bskylink ./
|
||||
|
||||
# build then prune dev deps
|
||||
RUN yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:20.11-alpine3.18
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
# Avoid zombie processes, handle signal forwarding
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
RUN mkdir /app/data && chown node /app/data
|
||||
|
||||
VOLUME /app/data
|
||||
EXPOSE 3000
|
||||
ENV LINK_PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
# potential perf issues w/ io_uring on this version of node
|
||||
ENV UV_USE_IO_URING=0
|
||||
|
||||
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
|
||||
USER node
|
||||
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
|
||||
LABEL org.opencontainers.image.description="Bsky Link Service"
|
||||
LABEL org.opencontainers.image.licenses=UNLICENSED
|
||||
@@ -0,0 +1,41 @@
|
||||
FROM node:20.11-alpine3.18 as build
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
|
||||
# build then prune dev deps
|
||||
RUN yarn install-fonts && yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:20.11-alpine3.18
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
# Avoid zombie processes, handle signal forwarding
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
RUN mkdir /app/data && chown node /app/data
|
||||
|
||||
VOLUME /app/data
|
||||
EXPOSE 3000
|
||||
ENV CARD_PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
# potential perf issues w/ io_uring on this version of node
|
||||
ENV UV_USE_IO_URING=0
|
||||
|
||||
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
|
||||
USER node
|
||||
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
|
||||
LABEL org.opencontainers.image.description="Bsky Card Service"
|
||||
LABEL org.opencontainers.image.licenses=UNLICENSED
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21-bullseye AS build-env
|
||||
FROM golang:1.22-bullseye AS build-env
|
||||
|
||||
WORKDIR /usr/src/social-app
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Get the app itself:
|
||||
|
||||
## Development Resources
|
||||
|
||||
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
|
||||
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
|
||||
|
||||
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
|
||||
|
||||
@@ -42,10 +42,10 @@ The Bluesky Social application encompasses a set of schemas and APIs built in th
|
||||
- Open an issue and give some time for discussion before submitting a PR.
|
||||
- Stay away from PRs like...
|
||||
- Changing "Post" to "Skeet."
|
||||
- Refactoring the codebase, eg to replace mobx with redux or something.
|
||||
- Refactoring the codebase, e.g., to replace MobX with Redux or something.
|
||||
- Adding entirely new features without prior discussion.
|
||||
|
||||
Remember, we serve a wide community of users. Our day to day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
|
||||
Remember, we serve a wide community of users. Our day-to-day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
|
||||
|
||||
## Forking guidelines
|
||||
|
||||
@@ -63,7 +63,7 @@ If you discover any security issues, please send an email to security@bsky.app.
|
||||
|
||||
## Are you a developer interested in building on atproto?
|
||||
|
||||
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
|
||||
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party integration can be as seamless as first-party through custom feeds, federated services, clients, and more.
|
||||
|
||||
## License (MIT)
|
||||
|
||||
|
||||
@@ -144,8 +144,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "homeScreenFeedTabs-Good Ppl"
|
||||
- tapOn:
|
||||
id: "e2eGotoLists"
|
||||
- tapOn:
|
||||
id: "list-Good Ppl"
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn: "About"
|
||||
- assertVisible:
|
||||
@@ -170,9 +169,8 @@ appId: xyz.blueskyweb.app
|
||||
id: "profilePager-selector"
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-5"
|
||||
- tapOn:
|
||||
id: "list-Good Ppl"
|
||||
id: "profilePager-selector-6"
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn:
|
||||
label: "Adds and removes users on curatelists from the profile"
|
||||
|
||||
@@ -18,8 +18,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
- tapOn:
|
||||
id: "feed-alice-favs"
|
||||
- tapOn: "alice-favs"
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
id: "bottomBarHomeBtn"
|
||||
|
||||
@@ -23,8 +23,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
- tapOn:
|
||||
id: "feed-alice-favs"
|
||||
- tapOn: "alice-favs"
|
||||
- tapOn: "Pin to Home"
|
||||
- tapOn:
|
||||
id: "bottomBarHomeBtn"
|
||||
|
||||
@@ -21,8 +21,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-4"
|
||||
- assertVisible:
|
||||
id: "feed-alice-favs"
|
||||
- assertVisible: "alice-favs"
|
||||
- swipe:
|
||||
from:
|
||||
id: "profilePager-selector"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
appId: xyz.blueskyweb.app
|
||||
---
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: "?users&posts&feeds"
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
id: "e2eSignInAlice"
|
||||
- tapOn: "/sys/debug"
|
||||
- tapOn:
|
||||
id: "sharedPrefsTestOpenBtn"
|
||||
- tapOn:
|
||||
id: "setStringBtn"
|
||||
- assertVisible: "Hello"
|
||||
- tapOn:
|
||||
id: "removeStringBtn"
|
||||
- assertVisible: "null"
|
||||
- tapOn:
|
||||
id: "setBoolBtn"
|
||||
- assertVisible: "true"
|
||||
- tapOn:
|
||||
id: "setNumberBtn"
|
||||
- assertVisible: "123"
|
||||
- tapOn:
|
||||
id: "addToSetBtn"
|
||||
- assertVisible: "true"
|
||||
- tapOn:
|
||||
id: "removeFromSetBtn"
|
||||
- assertVisible: "false"
|
||||
@@ -1,12 +1,16 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {
|
||||
createStarterPackGooglePlayUri,
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
parseStarterPackUri,
|
||||
} from 'lib/strings/starter-pack'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||
import {ago} from '../../src/lib/strings/time'
|
||||
import {
|
||||
makeRecordUri,
|
||||
toNiceDomain,
|
||||
@@ -142,79 +146,6 @@ describe('makeRecordUri', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Reenable after fixing non-deterministic test.
|
||||
describe.skip('ago', () => {
|
||||
const oneYearDate = new Date(
|
||||
new Date().setMonth(new Date().getMonth() - 11),
|
||||
).setDate(new Date().getDate() - 28)
|
||||
|
||||
const inputs = [
|
||||
1671461038,
|
||||
'04 Dec 1995 00:12:00 GMT',
|
||||
new Date(),
|
||||
new Date().setSeconds(new Date().getSeconds() - 10),
|
||||
new Date().setMinutes(new Date().getMinutes() - 10),
|
||||
new Date().setHours(new Date().getHours() - 1),
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
new Date().setDate(new Date().getDate() - 20),
|
||||
new Date().setDate(new Date().getDate() - 25),
|
||||
new Date().setDate(new Date().getDate() - 28),
|
||||
new Date().setDate(new Date().getDate() - 29),
|
||||
new Date().setDate(new Date().getDate() - 30),
|
||||
new Date().setMonth(new Date().getMonth() - 1),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 20,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 25,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 28,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
|
||||
new Date().getDate() - 29,
|
||||
),
|
||||
new Date().setMonth(new Date().getMonth() - 11),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
|
||||
new Date().getDate() - 20,
|
||||
),
|
||||
new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
|
||||
new Date().getDate() - 25,
|
||||
),
|
||||
oneYearDate,
|
||||
]
|
||||
const outputs = [
|
||||
new Date(1671461038).toLocaleDateString(),
|
||||
new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
|
||||
'now',
|
||||
'10s',
|
||||
'10m',
|
||||
'1h',
|
||||
'1d',
|
||||
'20d',
|
||||
'25d',
|
||||
'28d',
|
||||
'29d',
|
||||
'1mo',
|
||||
'1mo',
|
||||
'1mo',
|
||||
'1mo',
|
||||
'2mo',
|
||||
'2mo',
|
||||
'11mo',
|
||||
'11mo',
|
||||
'11mo',
|
||||
new Date(oneYearDate).toLocaleDateString(),
|
||||
]
|
||||
|
||||
it('correctly calculates how much time passed, in a string', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const result = ago(inputs[i])
|
||||
expect(result).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeValidHandle', () => {
|
||||
const inputs = [
|
||||
'test-handle-123',
|
||||
@@ -409,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
'https://youtube.com/watch?v=videoId',
|
||||
'https://youtube.com/watch?v=videoId&feature=share',
|
||||
'https://youtube.com/shorts/videoId',
|
||||
'https://youtube.com/live/videoId',
|
||||
'https://m.youtube.com/watch?v=videoId',
|
||||
'https://music.youtube.com/watch?v=videoId',
|
||||
|
||||
'https://youtube.com/shorts/',
|
||||
'https://youtube.com/',
|
||||
'https://youtube.com/random',
|
||||
'https://youtube.com/live/',
|
||||
|
||||
'https://twitch.tv/channelName',
|
||||
'https://www.twitch.tv/channelName',
|
||||
@@ -544,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
source: 'youtube',
|
||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
source: 'youtube',
|
||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
||||
},
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'twitch_video',
|
||||
@@ -870,3 +809,179 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStarterPackLinkFromAndroidReferrer', () => {
|
||||
const validOutput = 'at://haileyok.com/app.bsky.graph.starterpack/rkey'
|
||||
|
||||
it('returns a link when input contains utm_source and utm_content', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_source=bluesky&utm_content=starterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(validOutput)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_source=bluesky&utm_content=starterpack_test-lover-9000.com_rkey',
|
||||
),
|
||||
).toEqual('at://test-lover-9000.com/app.bsky.graph.starterpack/rkey')
|
||||
})
|
||||
|
||||
it('returns a link when input contains utm_source and utm_content in different order', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com_rkey&utm_source=bluesky',
|
||||
),
|
||||
).toEqual(validOutput)
|
||||
})
|
||||
|
||||
it('returns a link when input contains other parameters as well', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(validOutput)
|
||||
})
|
||||
|
||||
it('returns null when utm_source is not present', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when utm_content is not present', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when utm_content is malformed', () => {
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com',
|
||||
),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=starterpack_haileyok.com_rkey_more',
|
||||
),
|
||||
).toEqual(null)
|
||||
|
||||
expect(
|
||||
createStarterPackLinkFromAndroidReferrer(
|
||||
'utm_content=notastarterpack_haileyok.com_rkey',
|
||||
),
|
||||
).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseStarterPackHttpUri', () => {
|
||||
const baseUri = 'https://bsky.app/start'
|
||||
|
||||
it('returns a valid at uri when http uri is valid', () => {
|
||||
const validHttpUri = `${baseUri}/haileyok.com/rkey`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
|
||||
const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting`
|
||||
expect(parseStarterPackUri(validHttpUri2)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'ilovetesting',
|
||||
})
|
||||
|
||||
const validHttpUri3 = `${baseUri}/testlover9000.com/rkey`
|
||||
expect(parseStarterPackUri(validHttpUri3)).toEqual({
|
||||
name: 'testlover9000.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when there is no rkey', () => {
|
||||
const validHttpUri = `${baseUri}/haileyok.com`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when there is an extra path', () => {
|
||||
const validHttpUri = `${baseUri}/haileyok.com/rkey/other`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when there is no handle or rkey', () => {
|
||||
const validHttpUri = `${baseUri}`
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the route is not /start or /starter-pack', () => {
|
||||
const validHttpUri = 'https://bsky.app/start/haileyok.com/rkey'
|
||||
expect(parseStarterPackUri(validHttpUri)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
|
||||
const validHttpUri2 = 'https://bsky.app/starter-pack/haileyok.com/rkey'
|
||||
expect(parseStarterPackUri(validHttpUri2)).toEqual({
|
||||
name: 'haileyok.com',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
|
||||
const invalidHttpUri = 'https://bsky.app/profile/haileyok.com/rkey'
|
||||
expect(parseStarterPackUri(invalidHttpUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns the at uri when the input is a valid starterpack at uri', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual({
|
||||
name: 'did:123',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the at uri has no rkey', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the input is undefined', () => {
|
||||
expect(parseStarterPackUri(undefined)).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStarterPackGooglePlayUri', () => {
|
||||
const base =
|
||||
'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack_'
|
||||
|
||||
it('returns valid google play uri when input is valid', () => {
|
||||
expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual(
|
||||
`${base}name_rkey`,
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when no rkey is supplied', () => {
|
||||
// @ts-expect-error test
|
||||
expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when no name or rkey are supplied', () => {
|
||||
// @ts-expect-error test
|
||||
expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when rkey is supplied but no name', () => {
|
||||
// @ts-expect-error test
|
||||
expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,15 @@ module.exports = function (config) {
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
|
||||
const ASSOCIATED_DOMAINS = [
|
||||
'applinks:bsky.app',
|
||||
'applinks:staging.bsky.app',
|
||||
'appclips:bsky.app',
|
||||
'appclips:go.bsky.app', // Allows App Clip to work when scanning QR codes
|
||||
// When testing local services, enter an ngrok (et al) domain here. It must use a standard HTTP/HTTPS port.
|
||||
...(IS_DEV || IS_TESTFLIGHT ? [] : []),
|
||||
]
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT
|
||||
? 'testflight'
|
||||
: IS_PRODUCTION
|
||||
@@ -83,7 +92,7 @@ module.exports = function (config) {
|
||||
NSPhotoLibraryUsageDescription:
|
||||
'Used for profile pictures, posts, and other kinds of content',
|
||||
},
|
||||
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
|
||||
associatedDomains: ASSOCIATED_DOMAINS,
|
||||
splash: {
|
||||
...SPLASH_CONFIG,
|
||||
dark: DARK_SPLASH_CONFIG,
|
||||
@@ -202,6 +211,9 @@ module.exports = function (config) {
|
||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'expo-video',
|
||||
'react-native-compressor',
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.js',
|
||||
@@ -209,6 +221,7 @@ module.exports = function (config) {
|
||||
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
|
||||
'./plugins/shareExtension/withShareExtensions.js',
|
||||
'./plugins/notificationsExtension/withNotificationsExtension.js',
|
||||
'./plugins/withAppDelegateReferrer.js',
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
@@ -234,6 +247,10 @@ module.exports = function (config) {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
targetName: 'BlueskyClip',
|
||||
bundleIdentifier: 'xyz.blueskyweb.app.AppClip',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 476 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 848 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4ZM14 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6ZM14 6a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 183 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4Zm2 1v14h2V5H6Zm8-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1V4Zm2 1v14h2V5h-2Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 6a3 3 0 0 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Zm7 1a3 3 0 1 1 6 0v12a3 3 0 1 1-6 0V6Zm3-1a1 1 0 0 0-1 1v12a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 184 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.507 2.13a1 1 0 0 1 1.008.013l15 9a1 1 0 0 1 0 1.714l-15 9A1 1 0 0 1 5 21V3a1 1 0 0 1 .507-.87ZM7 4.766v14.468L19.056 12 7 4.766Z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 580 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -10,11 +10,12 @@
|
||||
],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": "warn",
|
||||
"simple-import-sort/exports": "warn"
|
||||
"simple-import-sort/exports": "warn",
|
||||
'no-else-return': 'off'
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": "./bskyembed/tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><defs><linearGradient x1="0" y1="0" x2="100%" y2="0" gradientTransform="rotate(45)" id="sky_V5w1FF_xb91wVQ_1euhBX"><stop offset="0" stop-color="#0A7AFF"></stop><stop offset="1" stop-color="#59B9FF"></stop></linearGradient></defs><path fill="url(#sky_V5w1FF_xb91wVQ_1euhBX)" fill-rule="evenodd" clip-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z"></path><path fill="url(#sky_V5w1FF_xb91wVQ_1euhBX)" fill-rule="evenodd" clip-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 982 B |
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.2",
|
||||
"@atproto/api": "0.13.1",
|
||||
"@preact/preset-vite": "^2.8.2",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"preact": "^10.4.8",
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyGraphStarterpack,
|
||||
AppBskyLabelerDefs,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import starterPackIcon from '../../assets/starterPack.svg'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import {getRkey} from '../utils'
|
||||
import {Link} from './link'
|
||||
@@ -105,7 +108,7 @@ export function Embed({
|
||||
// Case 3.2: List
|
||||
if (AppBskyGraphDefs.isListView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
<GenericWithImageEmbed
|
||||
image={record.avatar}
|
||||
title={record.name}
|
||||
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
||||
@@ -122,7 +125,7 @@ export function Embed({
|
||||
// Case 3.3: Feed
|
||||
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
<GenericWithImageEmbed
|
||||
image={record.avatar}
|
||||
title={record.displayName}
|
||||
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
||||
@@ -134,28 +137,27 @@ export function Embed({
|
||||
|
||||
// Case 3.4: Labeler
|
||||
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.creator.avatar}
|
||||
title={record.creator.displayName || record.creator.handle}
|
||||
href={`/profile/${record.creator.did}`}
|
||||
subtitle="Labeler"
|
||||
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||
/>
|
||||
)
|
||||
// Embed type does not exist in the app, so show nothing
|
||||
return null
|
||||
}
|
||||
|
||||
// Case 3.5: Post not found
|
||||
// Case 3.5: Starter pack
|
||||
if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
|
||||
return <StarterPackEmbed content={record} />
|
||||
}
|
||||
|
||||
// Case 3.6: Post not found
|
||||
if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||
return <Info>Quoted post not found, it may have been deleted.</Info>
|
||||
}
|
||||
|
||||
// Case 3.6: Post blocked
|
||||
// Case 3.7: Post blocked
|
||||
if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||
return <Info>The quoted post is blocked.</Info>
|
||||
}
|
||||
|
||||
throw new Error('Unknown embed type')
|
||||
// Unknown embed type
|
||||
return null
|
||||
}
|
||||
|
||||
// Case 4: Record with media
|
||||
@@ -182,7 +184,8 @@ export function Embed({
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('Unsupported embed type')
|
||||
// Unknown embed type
|
||||
return null
|
||||
} catch (err) {
|
||||
return (
|
||||
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
||||
@@ -314,7 +317,7 @@ function ExternalEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
function GenericWithImage({
|
||||
function GenericWithImageEmbed({
|
||||
title,
|
||||
subtitle,
|
||||
href,
|
||||
@@ -350,3 +353,60 @@ function GenericWithImage({
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function StarterPackEmbed({
|
||||
content,
|
||||
}: {
|
||||
content: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!AppBskyGraphStarterpack.isRecord(content.record)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const starterPackHref = getStarterPackHref(content)
|
||||
const imageUri = getStarterPackImage(content)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={starterPackHref}
|
||||
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
|
||||
<img src={imageUri} className="aspect-[1.91/1] object-cover" />
|
||||
<div className="py-3 px-4">
|
||||
<div className="flex space-x-2 items-center">
|
||||
<img src={starterPackIcon} className="w-10 h-10" />
|
||||
<div>
|
||||
<p className="font-semibold leading-[21px]">
|
||||
{content.record.name}
|
||||
</p>
|
||||
<p className="text-sm text-textLight line-clamp-2 leading-[18px]">
|
||||
Starter pack by{' '}
|
||||
{content.creator.displayName || `@${content.creator.handle}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{content.record.description && (
|
||||
<p className="text-sm mt-1">{content.record.description}</p>
|
||||
)}
|
||||
{!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
|
||||
<p className="text-sm font-semibold text-textLight mt-1">
|
||||
{content.joinedAllTimeCount} users have joined!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// from #/lib/strings/starter-pack.ts
|
||||
function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
|
||||
const rkey = new AtUri(starterPack.uri).rkey
|
||||
return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
|
||||
}
|
||||
|
||||
function getStarterPackHref(
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic,
|
||||
) {
|
||||
const rkey = new AtUri(starterPack.uri).rkey
|
||||
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
|
||||
return `/starter-pack/${handleOrDid}/${rkey}`
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-blue-400 hover:underline"
|
||||
disableTracking={!segment.link.uri.startsWith('https://bsky.app')}>
|
||||
disableTracking={
|
||||
!segment.link.uri.startsWith('https://bsky.app') &&
|
||||
!segment.link.uri.startsWith('https://go.bsky.app')
|
||||
}>
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import logo from '../../assets/logo.svg'
|
||||
@@ -12,7 +12,7 @@ import {getRkey} from '../utils'
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
const agent = new BskyAgent({
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
|
||||
@@ -20,15 +20,16 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.5"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@atproto/api@^0.12.2":
|
||||
version "0.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130"
|
||||
integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==
|
||||
"@atproto/api@0.13.1":
|
||||
version "0.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
|
||||
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
"@atproto/xrpc" "^0.5.0"
|
||||
"@atproto/xrpc" "^0.6.0"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
|
||||
@@ -42,29 +43,29 @@
|
||||
uint8arrays "3.0.0"
|
||||
zod "^3.21.4"
|
||||
|
||||
"@atproto/lexicon@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
|
||||
integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
|
||||
"@atproto/lexicon@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
|
||||
integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.21.4"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/syntax@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
||||
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
||||
|
||||
"@atproto/xrpc@^0.5.0":
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
|
||||
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
|
||||
"@atproto/xrpc@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
|
||||
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
zod "^3.21.4"
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
|
||||
version "7.24.2"
|
||||
@@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
await-lock@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
|
||||
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
|
||||
|
||||
babel-plugin-polyfill-corejs2@^0.4.10:
|
||||
version "0.4.10"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz#276f41710b03a64f6467433cab72cbc2653c38b1"
|
||||
@@ -3730,8 +3736,16 @@ stack-trace@^1.0.0-pre2:
|
||||
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c"
|
||||
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||
name string-width-cjs
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8:
|
||||
define-properties "^1.2.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -4192,3 +4213,8 @@ zod@^3.21.4:
|
||||
version "3.22.4"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
||||
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
|
||||
|
||||
zod@^3.23.8:
|
||||
version "3.23.8"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "bskylink",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/common": "^0.4.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"http-terminator": "^3.2.0",
|
||||
"kysely": "^0.27.3",
|
||||
"pg": "^8.12.0",
|
||||
"pino": "^9.2.0",
|
||||
"uint8arrays": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/pg": "^8.11.6",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js'
|
||||
|
||||
async function main() {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg(env)
|
||||
if (cfg.db.migrationUrl) {
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.migrationUrl,
|
||||
schema: cfg.db.schema,
|
||||
})
|
||||
await migrateDb.migrateToLatestOrThrow()
|
||||
await migrateDb.close()
|
||||
}
|
||||
const link = await LinkService.create(cfg)
|
||||
await link.start()
|
||||
httpLogger.info('link service is running')
|
||||
process.on('SIGTERM', async () => {
|
||||
httpLogger.info('link service is stopping')
|
||||
await link.destroy()
|
||||
httpLogger.info('link service is stopped')
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
import {envInt, envList, envStr} from '@atproto/common'
|
||||
|
||||
export type Config = {
|
||||
service: ServiceConfig
|
||||
db: DbConfig
|
||||
}
|
||||
|
||||
export type ServiceConfig = {
|
||||
port: number
|
||||
version?: string
|
||||
hostnames: string[]
|
||||
appHostname: string
|
||||
}
|
||||
|
||||
export type DbConfig = {
|
||||
url: string
|
||||
migrationUrl?: string
|
||||
pool: DbPoolConfig
|
||||
schema?: string
|
||||
}
|
||||
|
||||
export type DbPoolConfig = {
|
||||
size: number
|
||||
maxUses: number
|
||||
idleTimeoutMs: number
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
port?: number
|
||||
version?: string
|
||||
hostnames: string[]
|
||||
appHostname?: string
|
||||
dbPostgresUrl?: string
|
||||
dbPostgresMigrationUrl?: string
|
||||
dbPostgresSchema?: string
|
||||
dbPostgresPoolSize?: number
|
||||
dbPostgresPoolMaxUses?: number
|
||||
dbPostgresPoolIdleTimeoutMs?: number
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
return {
|
||||
port: envInt('LINK_PORT'),
|
||||
version: envStr('LINK_VERSION'),
|
||||
hostnames: envList('LINK_HOSTNAMES'),
|
||||
appHostname: envStr('LINK_APP_HOSTNAME'),
|
||||
dbPostgresUrl: envStr('LINK_DB_POSTGRES_URL'),
|
||||
dbPostgresMigrationUrl: envStr('LINK_DB_POSTGRES_MIGRATION_URL'),
|
||||
dbPostgresSchema: envStr('LINK_DB_POSTGRES_SCHEMA'),
|
||||
dbPostgresPoolSize: envInt('LINK_DB_POSTGRES_POOL_SIZE'),
|
||||
dbPostgresPoolMaxUses: envInt('LINK_DB_POSTGRES_POOL_MAX_USES'),
|
||||
dbPostgresPoolIdleTimeoutMs: envInt(
|
||||
'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS',
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const envToCfg = (env: Environment): Config => {
|
||||
const serviceCfg: ServiceConfig = {
|
||||
port: env.port ?? 3000,
|
||||
version: env.version,
|
||||
hostnames: env.hostnames,
|
||||
appHostname: env.appHostname || 'bsky.app',
|
||||
}
|
||||
if (!env.dbPostgresUrl) {
|
||||
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
||||
}
|
||||
const dbCfg: DbConfig = {
|
||||
url: env.dbPostgresUrl,
|
||||
migrationUrl: env.dbPostgresMigrationUrl,
|
||||
schema: env.dbPostgresSchema,
|
||||
pool: {
|
||||
idleTimeoutMs: env.dbPostgresPoolIdleTimeoutMs ?? 10000,
|
||||
maxUses: env.dbPostgresPoolMaxUses ?? Infinity,
|
||||
size: env.dbPostgresPoolSize ?? 10,
|
||||
},
|
||||
}
|
||||
return {
|
||||
service: serviceCfg,
|
||||
db: dbCfg,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Config} from './config.js'
|
||||
import Database from './db/index.js'
|
||||
|
||||
export type AppContextOptions = {
|
||||
cfg: Config
|
||||
db: Database
|
||||
}
|
||||
|
||||
export class AppContext {
|
||||
cfg: Config
|
||||
db: Database
|
||||
abortController = new AbortController()
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
this.db = this.opts.db
|
||||
}
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
const db = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
schema: cfg.db.schema,
|
||||
poolSize: cfg.db.pool.size,
|
||||
poolMaxUses: cfg.db.pool.maxUses,
|
||||
poolIdleTimeoutMs: cfg.db.pool.idleTimeoutMs,
|
||||
})
|
||||
return new AppContext({
|
||||
cfg,
|
||||
db,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'assert'
|
||||
import {
|
||||
Kysely,
|
||||
KyselyPlugin,
|
||||
Migrator,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
PostgresDialect,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
} from 'kysely'
|
||||
import {default as Pg} from 'pg'
|
||||
|
||||
import {dbLogger as log} from '../logger.js'
|
||||
import {default as migrations} from './migrations/index.js'
|
||||
import {DbMigrationProvider} from './migrations/provider.js'
|
||||
import {DbSchema} from './schema.js'
|
||||
|
||||
export class Database {
|
||||
migrator: Migrator
|
||||
destroyed = false
|
||||
|
||||
constructor(public db: Kysely<DbSchema>, public cfg: PgConfig) {
|
||||
this.migrator = new Migrator({
|
||||
db,
|
||||
migrationTableSchema: cfg.schema,
|
||||
provider: new DbMigrationProvider(migrations),
|
||||
})
|
||||
}
|
||||
|
||||
static postgres(opts: PgOptions): Database {
|
||||
const {schema, url, txLockNonce} = opts
|
||||
const pool =
|
||||
opts.pool ??
|
||||
new Pg.Pool({
|
||||
connectionString: url,
|
||||
max: opts.poolSize,
|
||||
maxUses: opts.poolMaxUses,
|
||||
idleTimeoutMillis: opts.poolIdleTimeoutMs,
|
||||
})
|
||||
|
||||
// Select count(*) and other pg bigints as js integer
|
||||
Pg.types.setTypeParser(Pg.types.builtins.INT8, n => parseInt(n, 10))
|
||||
|
||||
// Setup schema usage, primarily for test parallelism (each test suite runs in its own pg schema)
|
||||
if (schema && !/^[a-z_]+$/i.test(schema)) {
|
||||
throw new Error(`Postgres schema must only contain [A-Za-z_]: ${schema}`)
|
||||
}
|
||||
|
||||
pool.on('error', onPoolError)
|
||||
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new PostgresDialect({pool}),
|
||||
})
|
||||
|
||||
return new Database(db, {
|
||||
pool,
|
||||
schema,
|
||||
url,
|
||||
txLockNonce,
|
||||
})
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (db: Database) => Promise<T>): Promise<T> {
|
||||
const leakyTxPlugin = new LeakyTxPlugin()
|
||||
return this.db
|
||||
.withPlugin(leakyTxPlugin)
|
||||
.transaction()
|
||||
.execute(txn => {
|
||||
const dbTxn = new Database(txn, this.cfg)
|
||||
return fn(dbTxn)
|
||||
.catch(async err => {
|
||||
leakyTxPlugin.endTx()
|
||||
// ensure that all in-flight queries are flushed & the connection is open
|
||||
await dbTxn.db.getExecutor().provideConnection(async () => {})
|
||||
throw err
|
||||
})
|
||||
.finally(() => leakyTxPlugin.endTx())
|
||||
})
|
||||
}
|
||||
|
||||
get schema(): string | undefined {
|
||||
return this.cfg.schema
|
||||
}
|
||||
|
||||
get isTransaction() {
|
||||
return this.db.isTransaction
|
||||
}
|
||||
|
||||
assertTransaction() {
|
||||
assert(this.isTransaction, 'Transaction required')
|
||||
}
|
||||
|
||||
assertNotTransaction() {
|
||||
assert(!this.isTransaction, 'Cannot be in a transaction')
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.destroyed) return
|
||||
await this.db.destroy()
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
async migrateToOrThrow(migration: string) {
|
||||
if (this.schema) {
|
||||
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
|
||||
}
|
||||
const {error, results} = await this.migrator.migrateTo(migration)
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (!results) {
|
||||
throw new Error('An unknown failure occurred while migrating')
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async migrateToLatestOrThrow() {
|
||||
if (this.schema) {
|
||||
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
|
||||
}
|
||||
const {error, results} = await this.migrator.migrateToLatest()
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (!results) {
|
||||
throw new Error('An unknown failure occurred while migrating')
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
export default Database
|
||||
|
||||
export type PgConfig = {
|
||||
pool: Pg.Pool
|
||||
url: string
|
||||
schema?: string
|
||||
txLockNonce?: string
|
||||
}
|
||||
|
||||
type PgOptions = {
|
||||
url: string
|
||||
pool?: Pg.Pool
|
||||
schema?: string
|
||||
poolSize?: number
|
||||
poolMaxUses?: number
|
||||
poolIdleTimeoutMs?: number
|
||||
txLockNonce?: string
|
||||
}
|
||||
|
||||
class LeakyTxPlugin implements KyselyPlugin {
|
||||
private txOver = false
|
||||
|
||||
endTx() {
|
||||
this.txOver = true
|
||||
}
|
||||
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
if (this.txOver) {
|
||||
throw new Error('tx already failed')
|
||||
}
|
||||
return args.node
|
||||
}
|
||||
|
||||
async transformResult(
|
||||
args: PluginTransformResultArgs,
|
||||
): Promise<QueryResult<UnknownRow>> {
|
||||
return args.result
|
||||
}
|
||||
}
|
||||
|
||||
const onPoolError = (err: Error) => log.error({err}, 'db pool error')
|
||||
@@ -0,0 +1,15 @@
|
||||
import {Kysely} from 'kysely'
|
||||
|
||||
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('link')
|
||||
.addColumn('id', 'varchar', col => col.primaryKey())
|
||||
.addColumn('type', 'smallint', col => col.notNull()) // integer enum: 1->starterpack
|
||||
.addColumn('path', 'varchar', col => col.notNull())
|
||||
.addUniqueConstraint('link_path_unique', ['path'])
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema.dropTable('link').execute()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import * as init from './001-init.js'
|
||||
|
||||
export default {
|
||||
'001': init,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {Migration, MigrationProvider} from 'kysely'
|
||||
|
||||
export class DbMigrationProvider implements MigrationProvider {
|
||||
constructor(private migrations: Record<string, Migration>) {}
|
||||
async getMigrations(): Promise<Record<string, Migration>> {
|
||||
return this.migrations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Selectable} from 'kysely'
|
||||
|
||||
export type DbSchema = {
|
||||
link: Link
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
id: string
|
||||
type: LinkType
|
||||
path: string
|
||||
}
|
||||
|
||||
export enum LinkType {
|
||||
StarterPack = 1,
|
||||
}
|
||||
|
||||
export type LinkEntry = Selectable<Link>
|
||||
@@ -0,0 +1,45 @@
|
||||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
|
||||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||
|
||||
import {Config} from './config.js'
|
||||
import {AppContext} from './context.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
|
||||
export * from './config.js'
|
||||
export * from './db/index.js'
|
||||
export * from './logger.js'
|
||||
|
||||
export class LinkService {
|
||||
public server?: http.Server
|
||||
private terminator?: HttpTerminator
|
||||
|
||||
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||
|
||||
static async create(cfg: Config): Promise<LinkService> {
|
||||
let app = express()
|
||||
app.use(cors())
|
||||
|
||||
const ctx = await AppContext.fromConfig(cfg)
|
||||
app = routes(ctx, app)
|
||||
app.use(errorHandler)
|
||||
|
||||
return new LinkService(app, ctx)
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||
this.server.keepAliveTimeout = 90000
|
||||
this.terminator = createHttpTerminator({server: this.server})
|
||||
await events.once(this.server, 'listening')
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.ctx.abortController.abort()
|
||||
await this.terminator?.terminate()
|
||||
await this.ctx.db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {subsystemLogger} from '@atproto/common'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskylink')
|
||||
export const dbLogger = subsystemLogger('bskylink:db')
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import bodyParser from 'body-parser'
|
||||
import {Express, Request} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {LinkType} from '../db/schema.js'
|
||||
import {randomId} from '../util.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.post(
|
||||
'/link',
|
||||
bodyParser.json(),
|
||||
handler(async (req, res) => {
|
||||
let path: string
|
||||
if (typeof req.body?.path === 'string') {
|
||||
path = req.body.path
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message: '"path" parameter is missing or not a string',
|
||||
})
|
||||
}
|
||||
if (!path.startsWith('/')) {
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message:
|
||||
'"path" parameter must be formatted as a path, starting with a "/"',
|
||||
})
|
||||
}
|
||||
const parts = getPathParts(path)
|
||||
if (parts.length === 3 && parts[0] === 'start') {
|
||||
// link pattern: /start/{did}/{rkey}
|
||||
if (!parts[1].startsWith('did:')) {
|
||||
// enforce strong links
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message:
|
||||
'"path" parameter for starter pack must contain the actor\'s DID',
|
||||
})
|
||||
}
|
||||
const id = await ensureLink(ctx, LinkType.StarterPack, parts)
|
||||
return res.json({url: getUrl(ctx, req, id)})
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: 'InvalidPath',
|
||||
message: '"path" parameter does not have a known format',
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ensureLink = async (ctx: AppContext, type: LinkType, parts: string[]) => {
|
||||
const normalizedPath = normalizedPathFromParts(parts)
|
||||
const created = await ctx.db.db
|
||||
.insertInto('link')
|
||||
.values({
|
||||
id: randomId(),
|
||||
type,
|
||||
path: normalizedPath,
|
||||
})
|
||||
.onConflict(oc => oc.column('path').doNothing())
|
||||
.returningAll()
|
||||
.executeTakeFirst()
|
||||
if (created) {
|
||||
return created.id
|
||||
}
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('path', '=', normalizedPath)
|
||||
.executeTakeFirstOrThrow()
|
||||
return found.id
|
||||
}
|
||||
|
||||
const getUrl = (ctx: AppContext, req: Request, id: string) => {
|
||||
if (!ctx.cfg.service.hostnames.length) {
|
||||
assert(req.headers.host, 'request must be made with host header')
|
||||
const baseUrl =
|
||||
req.protocol === 'http' && req.headers.host.startsWith('localhost:')
|
||||
? `http://${req.headers.host}`
|
||||
: `https://${req.headers.host}`
|
||||
return `${baseUrl}/${id}`
|
||||
}
|
||||
const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host)
|
||||
? `https://${req.headers.host}`
|
||||
: `https://${ctx.cfg.service.hostnames[0]}`
|
||||
return `${baseUrl}/${id}`
|
||||
}
|
||||
|
||||
const normalizedPathFromParts = (parts: string[]): string => {
|
||||
return (
|
||||
'/' +
|
||||
parts
|
||||
.map(encodeURIComponent)
|
||||
.map(part => part.replaceAll('%3A', ':')) // preserve colons
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
|
||||
const getPathParts = (path: string): string[] => {
|
||||
if (path === '/') return []
|
||||
if (path.endsWith('/')) {
|
||||
path = path.slice(0, -1) // ignore trailing slash
|
||||
}
|
||||
return path
|
||||
.slice(1) // remove leading slash
|
||||
.split('/')
|
||||
.map(decodeURIComponent)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {Express} from 'express'
|
||||
import {sql} from 'kysely'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/_health',
|
||||
handler(async (_req, res) => {
|
||||
const {version} = ctx.cfg.service
|
||||
try {
|
||||
await sql`select 1`.execute(ctx.db.db)
|
||||
return res.send({version})
|
||||
} catch (err) {
|
||||
return res.status(503).send({version, error: 'Service Unavailable'})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as create} from './create.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as redirect} from './redirect.js'
|
||||
import {default as siteAssociation} from './siteAssociation.js'
|
||||
|
||||
export * from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = siteAssociation(ctx, app) // GET /.well-known/apple-app-site-association
|
||||
app = create(ctx, app) // POST /link
|
||||
app = redirect(ctx, app) // GET /:linkId (should go last due to permissive matching)
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {DAY, SECOND} from '@atproto/common'
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:linkId',
|
||||
handler(async (req, res) => {
|
||||
const linkId = req.params.linkId
|
||||
const contentType = req.accepts(['html', 'json'])
|
||||
assert(
|
||||
typeof linkId === 'string',
|
||||
'express guarantees id parameter is a string',
|
||||
)
|
||||
const found = await ctx.db.db
|
||||
.selectFrom('link')
|
||||
.selectAll()
|
||||
.where('id', '=', linkId)
|
||||
.executeTakeFirst()
|
||||
if (!found) {
|
||||
// potentially broken or mistyped link
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
if (contentType === 'json') {
|
||||
return res
|
||||
.status(404)
|
||||
.json({
|
||||
error: 'NotFound',
|
||||
message: 'Link not found',
|
||||
})
|
||||
.end()
|
||||
}
|
||||
// send the user to the app
|
||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||
return res.status(302).end()
|
||||
}
|
||||
// build url from original url in order to preserve query params
|
||||
const url = new URL(
|
||||
req.originalUrl,
|
||||
`https://${ctx.cfg.service.appHostname}`,
|
||||
)
|
||||
url.pathname = found.path
|
||||
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
|
||||
if (contentType === 'json') {
|
||||
return res.json({url: url.href}).end()
|
||||
}
|
||||
res.setHeader('Location', url.href)
|
||||
return res.status(301).end()
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get('/.well-known/apple-app-site-association', (req, res) => {
|
||||
res.json({
|
||||
applinks: {
|
||||
apps: [],
|
||||
details: [
|
||||
{
|
||||
appID: 'B3LX46C5HS.xyz.blueskyweb.app',
|
||||
paths: ['*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
appclips: {
|
||||
apps: ['B3LX46C5HS.xyz.blueskyweb.app.AppClip'],
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||
|
||||
import {httpLogger} from '../logger.js'
|
||||
|
||||
export type Handler = (req: Request, res: Response) => Awaited<void>
|
||||
|
||||
export const handler = (runHandler: Handler): RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
await runHandler(req, res)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res, next) => {
|
||||
httpLogger.error({err}, 'request error')
|
||||
if (res.headersSent) {
|
||||
return next(err)
|
||||
}
|
||||
return res.status(500).end('server error')
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {randomBytes} from 'node:crypto'
|
||||
|
||||
import {toString} from 'uint8arrays'
|
||||
|
||||
// 40bit random id of 5-7 characters
|
||||
export const randomId = () => {
|
||||
return toString(randomBytes(5), 'base58btc')
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert'
|
||||
import {AddressInfo} from 'node:net'
|
||||
import {after, before, describe, it} from 'node:test'
|
||||
|
||||
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
|
||||
|
||||
describe('link service', async () => {
|
||||
let linkService: LinkService
|
||||
let baseUrl: string
|
||||
before(async () => {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg({
|
||||
...env,
|
||||
hostnames: ['test.bsky.link'],
|
||||
appHostname: 'test.bsky.app',
|
||||
dbPostgresSchema: 'link_test',
|
||||
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
||||
})
|
||||
const migrateDb = Database.postgres({
|
||||
url: cfg.db.url,
|
||||
schema: cfg.db.schema,
|
||||
})
|
||||
await migrateDb.migrateToLatestOrThrow()
|
||||
await migrateDb.close()
|
||||
linkService = await LinkService.create(cfg)
|
||||
await linkService.start()
|
||||
const {port} = linkService.server?.address() as AddressInfo
|
||||
baseUrl = `http://localhost:${port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await linkService?.destroy()
|
||||
})
|
||||
|
||||
it('creates a starter pack link', async () => {
|
||||
const link = await getLink('/start/did:example:alice/xxx')
|
||||
const url = new URL(link)
|
||||
assert.strictEqual(url.origin, 'https://test.bsky.link')
|
||||
assert.match(url.pathname, /^\/[a-z0-9]+$/i)
|
||||
})
|
||||
|
||||
it('normalizes input paths and provides same link each time.', async () => {
|
||||
const link1 = await getLink('/start/did%3Aexample%3Abob/yyy')
|
||||
const link2 = await getLink('/start/did:example:bob/yyy/')
|
||||
assert.strictEqual(link1, link2)
|
||||
})
|
||||
|
||||
it('serves permanent redirect, preserving query params.', async () => {
|
||||
const link = await getLink('/start/did:example:carol/zzz/')
|
||||
const [status, location] = await getRedirect(`${link}?a=b`)
|
||||
assert.strictEqual(status, 301)
|
||||
const locationUrl = new URL(location)
|
||||
assert.strictEqual(
|
||||
locationUrl.pathname + locationUrl.search,
|
||||
'/start/did:example:carol/zzz?a=b',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns json object with url when requested', async () => {
|
||||
const link = await getLink('/start/did:example:carol/zzz/')
|
||||
const [status, json] = await getJsonRedirect(link)
|
||||
assert.strictEqual(status, 200)
|
||||
assert(json.url)
|
||||
const url = new URL(json.url)
|
||||
assert.strictEqual(url.pathname, '/start/did:example:carol/zzz')
|
||||
})
|
||||
|
||||
it('returns 404 for unknown link when requesting json', async () => {
|
||||
const [status, json] = await getJsonRedirect(
|
||||
'https://test.bsky.link/unknown',
|
||||
)
|
||||
assert(json.error)
|
||||
assert(json.message)
|
||||
assert.strictEqual(status, 404)
|
||||
assert.strictEqual(json.error, 'NotFound')
|
||||
assert.strictEqual(json.message, 'Link not found')
|
||||
})
|
||||
|
||||
async function getRedirect(link: string): Promise<[number, string]> {
|
||||
const url = new URL(link)
|
||||
const base = new URL(baseUrl)
|
||||
url.protocol = base.protocol
|
||||
url.host = base.host
|
||||
const res = await fetch(url, {redirect: 'manual'})
|
||||
await res.arrayBuffer() // drain
|
||||
assert(
|
||||
res.status === 301 || res.status === 303,
|
||||
'response was not a redirect',
|
||||
)
|
||||
return [res.status, res.headers.get('location') ?? '']
|
||||
}
|
||||
|
||||
async function getJsonRedirect(
|
||||
link: string,
|
||||
): Promise<[number, {url?: string; error?: string; message?: string}]> {
|
||||
const url = new URL(link)
|
||||
const base = new URL(baseUrl)
|
||||
url.protocol = base.protocol
|
||||
url.host = base.host
|
||||
const res = await fetch(url, {
|
||||
redirect: 'manual',
|
||||
headers: {accept: 'application/json,text/html'},
|
||||
})
|
||||
assert(
|
||||
res.headers.get('content-type')?.startsWith('application/json'),
|
||||
'content type was not json',
|
||||
)
|
||||
const json = await res.json()
|
||||
return [res.status, json]
|
||||
}
|
||||
|
||||
async function getLink(path: string): Promise<string> {
|
||||
const res = await fetch(new URL('/link', baseUrl), {
|
||||
method: 'post',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({path}),
|
||||
})
|
||||
assert.strictEqual(res.status, 200)
|
||||
const payload = await res.json()
|
||||
assert(typeof payload.url === 'string')
|
||||
return payload.url
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Exit if any command fails
|
||||
set -e
|
||||
|
||||
get_container_id() {
|
||||
local compose_file=$1
|
||||
local service=$2
|
||||
if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
|
||||
echo "usage: get_container_id <compose_file> <service>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# first line of jq normalizes for docker compose breaking change, see docker/compose#10958
|
||||
docker compose --file $compose_file ps --format json --status running \
|
||||
| jq -sc '.[] | if type=="array" then .[] else . end' | jq -s \
|
||||
| jq -r '.[]? | select(.Service == "'${service}'") | .ID'
|
||||
}
|
||||
|
||||
# Exports all environment variables
|
||||
export_env() {
|
||||
export_pg_env
|
||||
}
|
||||
|
||||
# Exports postgres environment variables
|
||||
export_pg_env() {
|
||||
# Based on creds in compose.yaml
|
||||
export PGPORT=5433
|
||||
export PGHOST=localhost
|
||||
export PGUSER=pg
|
||||
export PGPASSWORD=password
|
||||
export PGDATABASE=postgres
|
||||
export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
|
||||
}
|
||||
|
||||
|
||||
pg_clear() {
|
||||
local pg_uri=$1
|
||||
|
||||
for schema_name in `psql "${pg_uri}" -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name NOT LIKE 'information_schema';" -t`; do
|
||||
psql "${pg_uri}" -c "DROP SCHEMA \"${schema_name}\" CASCADE;"
|
||||
done
|
||||
}
|
||||
|
||||
pg_init() {
|
||||
local pg_uri=$1
|
||||
|
||||
psql "${pg_uri}" -c "CREATE SCHEMA IF NOT EXISTS \"public\";"
|
||||
}
|
||||
|
||||
main_native() {
|
||||
local services=${SERVICES}
|
||||
local postgres_url_env_var=`[[ $services == *"db_test"* ]] && echo "DB_TEST_POSTGRES_URL" || echo "DB_POSTGRES_URL"`
|
||||
|
||||
postgres_url="${!postgres_url_env_var}"
|
||||
|
||||
if [ -n "${postgres_url}" ]; then
|
||||
echo "Using ${postgres_url_env_var} (${postgres_url}) to connect to postgres."
|
||||
pg_init "${postgres_url}"
|
||||
else
|
||||
echo "Postgres connection string missing did you set ${postgres_url_env_var}?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
local services=$@
|
||||
|
||||
if [ -n "${postgres_url}" ] && [[ $services == *"db_test"* ]]; then
|
||||
pg_clear "${postgres_url}" &> /dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# trap SIGINT and performs cleanup
|
||||
trap "on_sigint ${services}" INT
|
||||
on_sigint() {
|
||||
cleanup $@
|
||||
exit $?
|
||||
}
|
||||
|
||||
# Run the arguments as a command
|
||||
DB_POSTGRES_URL="${postgres_url}" \
|
||||
"$@"
|
||||
code=$?
|
||||
|
||||
cleanup ${services}
|
||||
|
||||
exit ${code}
|
||||
}
|
||||
|
||||
main_docker() {
|
||||
# Expect a SERVICES env var to be set with the docker service names
|
||||
local services=${SERVICES}
|
||||
|
||||
dir=$(dirname $0)
|
||||
compose_file="${dir}/docker-compose.yaml"
|
||||
|
||||
# whether this particular script started the container(s)
|
||||
started_container=false
|
||||
|
||||
# performs cleanup as necessary, i.e. taking down containers
|
||||
# if this script started them
|
||||
cleanup() {
|
||||
local services=$@
|
||||
echo # newline
|
||||
if $started_container; then
|
||||
docker compose --file $compose_file rm --force --stop --volumes ${services}
|
||||
fi
|
||||
}
|
||||
|
||||
# trap SIGINT and performs cleanup
|
||||
trap "on_sigint ${services}" INT
|
||||
on_sigint() {
|
||||
cleanup $@
|
||||
exit $?
|
||||
}
|
||||
|
||||
# check if all services are running already
|
||||
not_running=false
|
||||
for service in $services; do
|
||||
container_id=$(get_container_id $compose_file $service)
|
||||
if [ -z $container_id ]; then
|
||||
not_running=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# if any are missing, recreate all services
|
||||
if $not_running; then
|
||||
started_container=true
|
||||
docker compose --file $compose_file up --wait --force-recreate ${services}
|
||||
else
|
||||
echo "all services ${services} are already running"
|
||||
fi
|
||||
|
||||
# do not exit when following commands fail, so we can intercept exit code & tear down docker
|
||||
set +e
|
||||
|
||||
# setup environment variables and run args
|
||||
export_env
|
||||
"$@"
|
||||
# save return code for later
|
||||
code=$?
|
||||
|
||||
# performs cleanup as necessary
|
||||
cleanup ${services}
|
||||
exit ${code}
|
||||
}
|
||||
|
||||
# Main entry point
|
||||
main() {
|
||||
if ! docker ps >/dev/null 2>&1; then
|
||||
echo "Docker unavailable. Running on host."
|
||||
main_native $@
|
||||
else
|
||||
main_docker $@
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
# An ephermerally-stored postgres database for single-use test runs
|
||||
db_test: &db_test
|
||||
image: postgres:14.11-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=pg
|
||||
- POSTGRES_PASSWORD=password
|
||||
ports:
|
||||
- '5433:5432'
|
||||
# Healthcheck ensures db is queryable when `docker-compose up --wait` completes
|
||||
healthcheck:
|
||||
test: 'pg_isready -U pg'
|
||||
interval: 500ms
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
# A persistently-stored postgres database
|
||||
db:
|
||||
<<: *db_test
|
||||
ports:
|
||||
- '5432:5432'
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
- link_db:/var/lib/postgresql/data
|
||||
volumes:
|
||||
link_db:
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Example usage:
|
||||
# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
|
||||
|
||||
dir=$(dirname $0)
|
||||
. ${dir}/_common.sh
|
||||
|
||||
SERVICES="db_test" main "$@"
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"lib": ["ES2021.String"]
|
||||
},
|
||||
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "bskyogcard",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --loader ts-node/esm ./src/bin.ts",
|
||||
"dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
|
||||
"build": "tsc && cp -r src/assets dist/",
|
||||
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.12.19-next.0",
|
||||
"@atproto/common": "^0.4.0",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"express": "^4.19.2",
|
||||
"http-terminator": "^3.2.0",
|
||||
"pino": "^9.2.0",
|
||||
"react": "^18.3.1",
|
||||
"satori": "^0.10.13",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {writeFile} from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const FONTS = [
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
|
||||
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
|
||||
]
|
||||
|
||||
async function main() {
|
||||
await Promise.all(
|
||||
FONTS.map(async urlStr => {
|
||||
const url = new URL(urlStr)
|
||||
const res = await fetch(url)
|
||||
const font = await res.arrayBuffer()
|
||||
const filename = url.pathname
|
||||
.split('/')
|
||||
.slice(-2)
|
||||
.join('/')
|
||||
.replace(/@[\d.]+\//, '-')
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
|
||||
}
|
||||
await writeFile(
|
||||
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
|
||||
Buffer.from(font),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import cluster, {Worker} from 'node:cluster'
|
||||
|
||||
import {envInt} from '@atproto/common'
|
||||
|
||||
import {CardService, envToCfg, httpLogger, readEnv} from './index.js'
|
||||
|
||||
async function main() {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg(env)
|
||||
const card = await CardService.create(cfg)
|
||||
await card.start()
|
||||
httpLogger.info('card service is running')
|
||||
process.on('SIGTERM', async () => {
|
||||
httpLogger.info('card service is stopping')
|
||||
await card.destroy()
|
||||
httpLogger.info('card service is stopped')
|
||||
if (cluster.isWorker) process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT')
|
||||
|
||||
if (workerCount) {
|
||||
if (cluster.isPrimary) {
|
||||
httpLogger.info(`primary ${process.pid} is running`)
|
||||
const workers = new Set<Worker>()
|
||||
for (let i = 0; i < workerCount; ++i) {
|
||||
workers.add(cluster.fork())
|
||||
}
|
||||
let teardown = false
|
||||
cluster.on('exit', worker => {
|
||||
workers.delete(worker)
|
||||
if (!teardown) {
|
||||
workers.add(cluster.fork()) // restart on crash
|
||||
}
|
||||
})
|
||||
process.on('SIGTERM', () => {
|
||||
teardown = true
|
||||
httpLogger.info('disconnecting workers')
|
||||
workers.forEach(w => w.kill('SIGTERM'))
|
||||
})
|
||||
} else {
|
||||
httpLogger.info(`worker ${process.pid} is running`)
|
||||
main()
|
||||
}
|
||||
} else {
|
||||
main() // non-clustering
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 568 501"
|
||||
{...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Img(
|
||||
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable bsky-internal/avoid-unwrapped-text */
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
|
||||
import {Butterfly} from './Butterfly.js'
|
||||
import {Img} from './Img.js'
|
||||
|
||||
export const STARTERPACK_HEIGHT = 630
|
||||
export const STARTERPACK_WIDTH = 1200
|
||||
export const TILE_SIZE = STARTERPACK_HEIGHT / 3
|
||||
|
||||
const GRADIENT_TOP = '#0A7AFF'
|
||||
const GRADIENT_BOTTOM = '#59B9FF'
|
||||
const IMAGE_STROKE = '#359CFF'
|
||||
|
||||
export function StarterPack(props: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackView
|
||||
images: Map<string, Buffer>
|
||||
}) {
|
||||
const {starterPack, images} = props
|
||||
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
|
||||
? starterPack.record
|
||||
: null
|
||||
const imagesArray = [...images.values()]
|
||||
const imageOfCreator = images.get(starterPack.creator.did)
|
||||
const imagesExceptCreator = [...images.entries()]
|
||||
.filter(([did]) => did !== starterPack.creator.did)
|
||||
.map(([, image]) => image)
|
||||
const imagesAcross: Buffer[] = []
|
||||
if (imageOfCreator) {
|
||||
if (imagesExceptCreator.length >= 6) {
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, 3))
|
||||
imagesAcross.push(imageOfCreator)
|
||||
imagesAcross.push(...imagesExceptCreator.slice(3, 6))
|
||||
} else {
|
||||
const firstHalf = Math.floor(imagesExceptCreator.length / 2)
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf))
|
||||
imagesAcross.push(imageOfCreator)
|
||||
imagesAcross.push(
|
||||
...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
|
||||
}
|
||||
const isLongTitle = record ? record.name.length > 30 : false
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: STARTERPACK_WIDTH,
|
||||
height: STARTERPACK_HEIGHT,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
fontFamily: 'Inter',
|
||||
}}>
|
||||
{/* image tiles */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'stretch',
|
||||
width: TILE_SIZE * 6,
|
||||
height: TILE_SIZE * 3,
|
||||
}}>
|
||||
{[...Array(18)].map((_, i) => {
|
||||
const image = imagesArray.at(i % imagesArray.length)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: TILE_SIZE,
|
||||
width: TILE_SIZE,
|
||||
}}>
|
||||
{image && <Img height="100%" width="100%" src={image} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* background overlay */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* foreground text & images */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
color: 'white',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
color: 'white',
|
||||
padding: 60,
|
||||
fontSize: 40,
|
||||
}}>
|
||||
JOIN THE CONVERSATION
|
||||
</div>
|
||||
<div style={{display: 'flex'}}>
|
||||
{imagesAcross.map((image, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: 172 + 15 * 2,
|
||||
width: 172 + 15 * 2,
|
||||
margin: -15,
|
||||
border: `15px solid ${IMAGE_STROKE}`,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Img height="100%" width="100%" src={image} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '75px 30px 0px',
|
||||
fontSize: isLongTitle ? 55 : 65,
|
||||
display: 'flex',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{record?.name || 'Starter Pack'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
fontSize: 40,
|
||||
justifyContent: 'center',
|
||||
padding: '30px 30px 10px',
|
||||
}}>
|
||||
on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {envInt, envStr} from '@atproto/common'
|
||||
|
||||
export type Config = {
|
||||
service: ServiceConfig
|
||||
}
|
||||
|
||||
export type ServiceConfig = {
|
||||
port: number
|
||||
version?: string
|
||||
appviewUrl: string
|
||||
originVerify?: string
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
port?: number
|
||||
version?: string
|
||||
appviewUrl?: string
|
||||
originVerify?: string
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
return {
|
||||
port: envInt('CARD_PORT'),
|
||||
version: envStr('CARD_VERSION'),
|
||||
appviewUrl: envStr('CARD_APPVIEW_URL'),
|
||||
originVerify: envStr('CARD_ORIGIN_VERIFY'),
|
||||
}
|
||||
}
|
||||
|
||||
export const envToCfg = (env: Environment): Config => {
|
||||
const serviceCfg: ServiceConfig = {
|
||||
port: env.port ?? 3000,
|
||||
version: env.version,
|
||||
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
|
||||
originVerify: env.originVerify,
|
||||
}
|
||||
return {
|
||||
service: serviceCfg,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {readdirSync, readFileSync} from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
|
||||
import {Config} from './config.js'
|
||||
|
||||
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export type AppContextOptions = {
|
||||
cfg: Config
|
||||
appviewAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer}[]
|
||||
}
|
||||
|
||||
export class AppContext {
|
||||
cfg: Config
|
||||
appviewAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer}[]
|
||||
abortController = new AbortController()
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
this.appviewAgent = this.opts.appviewAgent
|
||||
this.fonts = this.opts.fonts
|
||||
}
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
|
||||
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
|
||||
const fontFiles = readdirSync(fontDirectory)
|
||||
const fonts = fontFiles.map(file => {
|
||||
return {
|
||||
name: path.basename(file, path.extname(file)),
|
||||
data: readFileSync(path.join(fontDirectory, file)),
|
||||
}
|
||||
})
|
||||
return new AppContext({
|
||||
cfg,
|
||||
appviewAgent,
|
||||
fonts,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
|
||||
import express from 'express'
|
||||
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||
|
||||
import {Config} from './config.js'
|
||||
import {AppContext} from './context.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
|
||||
export * from './config.js'
|
||||
export * from './logger.js'
|
||||
|
||||
export class CardService {
|
||||
public server?: http.Server
|
||||
private terminator?: HttpTerminator
|
||||
|
||||
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||
|
||||
static async create(cfg: Config): Promise<CardService> {
|
||||
let app = express()
|
||||
|
||||
const ctx = await AppContext.fromConfig(cfg)
|
||||
app = routes(ctx, app)
|
||||
app.use(errorHandler)
|
||||
|
||||
return new CardService(app, ctx)
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||
this.server.keepAliveTimeout = 90000
|
||||
this.terminator = createHttpTerminator({server: this.server})
|
||||
await events.once(this.server, 'listening')
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.ctx.abortController.abort()
|
||||
await this.terminator?.terminate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {subsystemLogger} from '@atproto/common'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskyogcard')
|
||||
export const renderLogger = subsystemLogger('bskyogcard:render')
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/_health',
|
||||
handler(async (_req, res) => {
|
||||
const {version} = ctx.cfg.service
|
||||
return res.send({version})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as starterPack} from './starter-pack.js'
|
||||
|
||||
export * from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = starterPack(ctx, app) // GET /start/:actor/:rkey
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {
|
||||
StarterPack,
|
||||
STARTERPACK_HEIGHT,
|
||||
STARTERPACK_WIDTH,
|
||||
} from '../components/StarterPack.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/start/:actor/:rkey',
|
||||
originVerifyMiddleware(ctx),
|
||||
handler(async (req, res) => {
|
||||
const {actor, rkey} = req.params
|
||||
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
|
||||
let starterPack: AppBskyGraphDefs.StarterPackView
|
||||
try {
|
||||
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
|
||||
{starterPack: uri.toString()},
|
||||
)
|
||||
starterPack = result.data.starterPack
|
||||
} catch (err) {
|
||||
httpLogger.warn(
|
||||
{err, uri: uri.toString()},
|
||||
'could not fetch starter pack',
|
||||
)
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
const imageEntries = await Promise.all(
|
||||
[starterPack.creator]
|
||||
.concat(starterPack.listItemsSample.map(li => li.subject))
|
||||
// has avatar
|
||||
.filter(p => p.avatar)
|
||||
// no sensitive labels
|
||||
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
|
||||
.map(async p => {
|
||||
try {
|
||||
assert(p.avatar)
|
||||
const image = await getImage(p.avatar)
|
||||
return [p.did, image] as const
|
||||
} catch (err) {
|
||||
httpLogger.warn(
|
||||
{err, uri: uri.toString(), did: p.did},
|
||||
'could not fetch image',
|
||||
)
|
||||
return [p.did, null] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
const images = new Map(
|
||||
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
|
||||
)
|
||||
const svg = await satori(
|
||||
<StarterPack starterPack={starterPack} images={images} />,
|
||||
{
|
||||
fonts: ctx.fonts,
|
||||
height: STARTERPACK_HEIGHT,
|
||||
width: STARTERPACK_WIDTH,
|
||||
loadAdditionalAsset: async (code, text) => {
|
||||
if (code === 'emoji') {
|
||||
return await loadEmojiAsSvg(text)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
const output = await resvg.renderAsync(svg)
|
||||
res.statusCode = 200
|
||||
res.setHeader('content-type', 'image/png')
|
||||
res.setHeader('cdn-tag', [...images.keys()].join(','))
|
||||
return res.end(output.asPng())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function getImage(url: string) {
|
||||
const response = await fetch(url)
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
'porn',
|
||||
'sexual',
|
||||
'nudity',
|
||||
'sexual-figurative',
|
||||
'graphic-media',
|
||||
'self-harm',
|
||||
'sensitive',
|
||||
'security',
|
||||
'impersonation',
|
||||
'scam',
|
||||
'spam',
|
||||
'misleading',
|
||||
'inauthentic',
|
||||
])
|
||||
@@ -0,0 +1,36 @@
|
||||
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
|
||||
export type Handler = (req: Request, res: Response) => Awaited<void>
|
||||
|
||||
export const handler = (runHandler: Handler): RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
await runHandler(req, res)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function originVerifyMiddleware(ctx: AppContext): RequestHandler {
|
||||
const {originVerify} = ctx.cfg.service
|
||||
if (!originVerify) return (_req, _res, next) => next()
|
||||
return (req, res, next) => {
|
||||
const verifyHeader = req.headers['x-origin-verify']
|
||||
if (verifyHeader !== originVerify) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
httpLogger.error({err}, 'request error')
|
||||
if (res.headersSent) {
|
||||
return next(err)
|
||||
}
|
||||
return res.status(500).end('server error')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import twemoji from 'twemoji'
|
||||
|
||||
import {renderLogger} from './logger.js'
|
||||
|
||||
const U200D = String.fromCharCode(0x200d)
|
||||
const UFE0F_REGEXP = /\uFE0F/g
|
||||
|
||||
export async function loadEmojiAsSvg(chars: string) {
|
||||
const cached = emojiCache.get(chars)
|
||||
if (cached) return cached
|
||||
const iconCode = twemoji.convert.toCodePoint(
|
||||
chars.indexOf(U200D) < 0 ? chars.replace(UFE0F_REGEXP, '') : chars,
|
||||
)
|
||||
const res = await fetch(getEmojiUrl(iconCode))
|
||||
const body = await res.arrayBuffer()
|
||||
if (!res.ok) {
|
||||
renderLogger.warn(
|
||||
{status: res.status, err: Buffer.from(body).toString()},
|
||||
'could not fetch emoji',
|
||||
)
|
||||
return
|
||||
}
|
||||
const svg =
|
||||
'data:image/svg+xml;base64,' + Buffer.from(body).toString('base64')
|
||||
emojiCache.set(chars, svg)
|
||||
return svg
|
||||
}
|
||||
|
||||
const emojiCache = new Map<string, string>()
|
||||
|
||||
function getEmojiUrl(code: string) {
|
||||
return (
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
|
||||
code.toLowerCase() +
|
||||
'.svg'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
|
||||
|
||||
### Golang Daemon
|
||||
|
||||
Install golang. We are generally using v1.21+.
|
||||
Install golang. We are generally using v1.22+.
|
||||
|
||||
In this directory (`bskyweb/`):
|
||||
|
||||
|
||||
@@ -35,11 +35,17 @@ func run(args []string) {
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "appview-host",
|
||||
Usage: "method, hostname, and port of PDS instance",
|
||||
Usage: "scheme, hostname, and port of PDS instance",
|
||||
Value: "http://localhost:2584",
|
||||
// retain old PDS env var for easy transition
|
||||
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ogcard-host",
|
||||
Usage: "scheme, hostname, and port of ogcard service",
|
||||
Required: false,
|
||||
EnvVars: []string{"OGCARD_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
Usage: "Specify the local IP/port to bind to",
|
||||
@@ -47,6 +53,13 @@ func run(args []string) {
|
||||
Value: ":8100",
|
||||
EnvVars: []string{"HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "link-host",
|
||||
Usage: "scheme, hostname, and port of link service",
|
||||
Required: false,
|
||||
Value: "",
|
||||
EnvVars: []string{"LINK_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "Enable debug mode",
|
||||
@@ -54,6 +67,13 @@ func run(args []string) {
|
||||
Required: false,
|
||||
EnvVars: []string{"DEBUG"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "basic-auth-password",
|
||||
Usage: "optional password to restrict access to web interface",
|
||||
Required: false,
|
||||
Value: "",
|
||||
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -30,12 +32,24 @@ type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
debug bool
|
||||
httpAddress string
|
||||
appviewHost string
|
||||
ogcardHost string
|
||||
linkHost string
|
||||
}
|
||||
|
||||
func serve(cctx *cli.Context) error {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
appviewHost := cctx.String("appview-host")
|
||||
ogcardHost := cctx.String("ogcard-host")
|
||||
linkHost := cctx.String("link-host")
|
||||
basicAuthPassword := cctx.String("basic-auth-password")
|
||||
|
||||
// Echo
|
||||
e := echo.New()
|
||||
@@ -71,6 +85,13 @@ func serve(cctx *cli.Context) error {
|
||||
server := &Server{
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
cfg: &Config{
|
||||
debug: debug,
|
||||
httpAddress: httpAddress,
|
||||
appviewHost: appviewHost,
|
||||
ogcardHost: ogcardHost,
|
||||
linkHost: linkHost,
|
||||
},
|
||||
}
|
||||
|
||||
// Create the HTTP server.
|
||||
@@ -121,6 +142,18 @@ func serve(cctx *cli.Context) error {
|
||||
},
|
||||
}))
|
||||
|
||||
// optional password gating of entire web interface
|
||||
if basicAuthPassword != "" {
|
||||
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||
// Be careful to use constant time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}))
|
||||
}
|
||||
|
||||
// redirect trailing slash to non-trailing slash.
|
||||
// all of our current endpoints have no trailing slash.
|
||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
||||
@@ -178,6 +211,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/feeds", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/notifications/settings", server.WebGeneric)
|
||||
e.GET("/lists", server.WebGeneric)
|
||||
e.GET("/moderation", server.WebGeneric)
|
||||
e.GET("/moderation/modlists", server.WebGeneric)
|
||||
@@ -191,6 +225,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/threads", server.WebGeneric)
|
||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||
e.GET("/settings/accessibility", server.WebGeneric)
|
||||
e.GET("/settings/appearance", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
@@ -220,6 +255,19 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
|
||||
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
if linkHost != "" {
|
||||
linkUrl, err := url.Parse(linkHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl))
|
||||
}
|
||||
|
||||
// Start the server.
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
@@ -292,6 +340,30 @@ func (srv *Server) Download(c echo.Context) error {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// Handler for proxying top-level paths to link service, which ends up serving a redirect
|
||||
func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
|
||||
return middleware.ProxyWithConfig(
|
||||
middleware.ProxyConfig{
|
||||
Balancer: middleware.NewRoundRobinBalancer(
|
||||
[]*middleware.ProxyTarget{{URL: url}},
|
||||
),
|
||||
Skipper: func(c echo.Context) bool {
|
||||
req := c.Request()
|
||||
if req.Method == "GET" &&
|
||||
strings.LastIndex(strings.TrimRight(req.URL.Path, "/"), "/") == 0 && // top-level path
|
||||
!strings.HasPrefix(req.URL.Path, "/_") { // e.g. /_health endpoint
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
RetryCount: 2,
|
||||
ErrorHandler: func(c echo.Context, err error) error {
|
||||
return c.Redirect(302, "/")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// handler for endpoint that have no specific server-side handling
|
||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
@@ -377,6 +449,45 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||
req := c.Request()
|
||||
ctx := req.Context()
|
||||
data := pongo2.Context{}
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
// sanity check arguments. don't 4xx, just let app handle if not expected format
|
||||
rkeyParam := c.Param("rkey")
|
||||
rkey, err := syntax.ParseRecordKey(rkeyParam)
|
||||
if err != nil {
|
||||
log.Errorf("bad rkey: %v", err)
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
handleOrDIDParam := c.Param("handleOrDID")
|
||||
handleOrDID, err := syntax.ParseAtIdentifier(handleOrDIDParam)
|
||||
if err != nil {
|
||||
log.Errorf("bad identifier: %v", err)
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
identifier := handleOrDID.Normalize().String()
|
||||
starterPackURI := fmt.Sprintf("at://%s/app.bsky.graph.starterpack/%s", identifier, rkey)
|
||||
spv, err := appbsky.GraphGetStarterPack(ctx, srv.xrpcc, starterPackURI)
|
||||
if err != nil {
|
||||
log.Errorf("failed to fetch starter pack view for: %s\t%v", starterPackURI, err)
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
if spv.StarterPack == nil || spv.StarterPack.Record == nil {
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
rec, ok := spv.StarterPack.Record.Val.(*appbsky.GraphStarterpack)
|
||||
if !ok {
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
data["title"] = rec.Name
|
||||
if srv.cfg.ogcardHost != "" {
|
||||
data["imgThumbUrl"] = fmt.Sprintf("%s/start/%s/%s", srv.cfg.ogcardHost, identifier, rkey)
|
||||
}
|
||||
return c.Render(http.StatusOK, "starterpack.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := pongo2.Context{}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
module github.com/bluesky-social/social-app/bskyweb
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
toolchain go1.22.4
|
||||
|
||||
require (
|
||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5
|
||||
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@@ -19,7 +21,7 @@ require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
@@ -48,7 +50,7 @@ require (
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/labstack/gommon v0.4.1 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
@@ -58,10 +60,9 @@ require (
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.18 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
@@ -69,6 +70,7 @@ require (
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/orandin/slog-gorm v1.3.2 // indirect
|
||||
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
|
||||
github.com/prometheus/client_golang v1.17.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
@@ -79,28 +81,28 @@ require (
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f // indirect
|
||||
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 // indirect
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
|
||||
go.opentelemetry.io/otel v1.21.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.21.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.21.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.15.0 // indirect
|
||||
golang.org/x/net v0.18.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.5.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gorm.io/driver/postgres v1.5.0 // indirect
|
||||
gorm.io/driver/sqlite v1.5.4 // indirect
|
||||
gorm.io/gorm v1.25.5 // indirect
|
||||
gorm.io/driver/postgres v1.5.7 // indirect
|
||||
gorm.io/driver/sqlite v1.5.5 // indirect
|
||||
gorm.io/gorm v1.25.9 // indirect
|
||||
lukechampine.com/blake3 v1.2.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5 h1:Zk1c+mxCYH6G/vLL0+9lO2Eci4OT3AFy73qPWa9auDM=
|
||||
github.com/bluesky-social/indigo v0.0.0-20231216010655-ad730a7da4f5/go.mod h1:a8cPbqDkRX+aPwJnXF7kAi3PF26hYiR4w5H8624MB7k=
|
||||
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141 h1:F3ZceqS82fFfVV3ychWRLNDRFaFlbOwrPic0qJiUVqw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20240624223826-fd66f93ce141/go.mod h1:dBIOGhsiK0rgEETnxiGiuEyrSx6DKxEotHIPbiKD6WU=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
|
||||
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
@@ -11,7 +11,6 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -23,8 +22,8 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw
|
||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
|
||||
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||
@@ -35,7 +34,6 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -60,7 +58,6 @@ github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
||||
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
||||
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
|
||||
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
|
||||
github.com/ipfs/go-cid v0.0.6/go.mod h1:6Ux9z5e+HpkQdckYoX1PG/6xqKspzlEIR5SDmgqgC/I=
|
||||
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
|
||||
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
|
||||
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
|
||||
@@ -88,10 +85,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
||||
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
||||
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||
@@ -109,11 +104,9 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
||||
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -143,37 +136,28 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ=
|
||||
github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8=
|
||||
github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA=
|
||||
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
|
||||
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
|
||||
github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM=
|
||||
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
|
||||
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
|
||||
github.com/multiformats/go-multibase v0.0.3/go.mod h1:5+1R4eQrT3PkYZ24C3W2Ue2tPwIdYQD509ZjSb5y9Oc=
|
||||
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
|
||||
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
|
||||
github.com/multiformats/go-multihash v0.0.13/go.mod h1:VdAWLKTwram9oKAatUcLxBNUjdtcVwxObEQBtRfuyjc=
|
||||
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-varint v0.0.5/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
|
||||
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -188,7 +172,6 @@ github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGy
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -213,9 +196,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
@@ -225,8 +208,8 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f h1:SBuSxXJL0/ZJMtTxbXZgHZkThl9dNrzyaNhlyaqscRo=
|
||||
github.com/whyrusleeping/cbor-gen v0.0.0-20230923211252-36a87e1ba72f/go.mod h1:fgkXqYy7bV2cFeIEOkVTZS/WjXARfBqSH6Q2qHL33hQ=
|
||||
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302 h1:MhInbXe4SzcImAKktUvWBCWZgcw6MYf5NfumTj1BhAw=
|
||||
github.com/whyrusleeping/cbor-gen v0.1.1-0.20240311221002-68b9f235c302/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
|
||||
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
@@ -239,8 +222,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS
|
||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0 h1:pginetY7+onl4qN1vl0xW/V/v6OBZ0vVdH+esuJgvmM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.42.0/go.mod h1:XiYsayHc36K3EByOO6nbAXnAWbrUxdjUROCEeeROOH8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
|
||||
go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
|
||||
go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
|
||||
go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
|
||||
@@ -265,14 +248,12 @@ go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -290,8 +271,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -316,8 +297,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -365,13 +346,12 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
|
||||
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "B3LX46C5HS.xyz.blueskyweb.app",
|
||||
@@ -9,5 +8,8 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"appclips": {
|
||||
"apps": ["B3LX46C5HS.xyz.blueskyweb.app.AppClip"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block html_head_extra -%}
|
||||
<meta name="apple-itunes-app" content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card">
|
||||
{%- if requestURI %}
|
||||
<meta property="og:url" content="{{ requestURI }}">
|
||||
{% endif -%}
|
||||
{%- if imgThumbUrl %}
|
||||
<meta property="og:image" content="{{ imgThumbUrl }}">
|
||||
<meta property="twitter:image" content="{{ imgThumbUrl }}">
|
||||
{%- else -%}
|
||||
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
|
||||
{% endif -%}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{%- if title %}
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta name="twitter:title" content="{{ title }}" />
|
||||
{%- else -%}
|
||||
<meta property="og:title" content="Bluesky" />
|
||||
<meta name="twitter:title" content="Bluesky" />
|
||||
{% endif -%}
|
||||
<meta name="description" content="Join the conversation" />
|
||||
<meta name="og:description" content="Join the conversation" />
|
||||
<meta name="twitter:description" content="Join the conversation" />
|
||||
{%- endblock %}
|
||||
@@ -3,6 +3,7 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-exact-imports': require('./use-exact-imports'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable bsky-internal/use-exact-imports */
|
||||
const BANNED_IMPORTS = [
|
||||
'@fortawesome/free-regular-svg-icons',
|
||||
'@fortawesome/free-solid-svg-icons',
|
||||
]
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string') {
|
||||
return
|
||||
}
|
||||
if (BANNED_IMPORTS.includes(node.value)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Import the specific thing you want instead of the entire package',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||