Compare commits
307 Commits
sync-engine
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fa380ac76 | |||
| 591da5dd7b | |||
| b08cb796d7 | |||
| 2919cb12b2 | |||
| d60b948262 | |||
| fec7f5d6d8 | |||
| d14cec5885 | |||
| 3d80ac2993 | |||
| 428f9ea1c9 | |||
| f2f8602263 | |||
| 661577ecd2 | |||
| c6ce1bdc65 | |||
| bfe53b46cf | |||
| 129e4772dd | |||
| 92c12ad0bd | |||
| c11c5074ce | |||
| fa9f88f6f6 | |||
| 9249cb4107 | |||
| 095b9876f9 | |||
| a358774106 | |||
| d24947d5ce | |||
| 9e3a30f74e | |||
| 4facc6b371 | |||
| ca3bbf204e | |||
| 66d53497c5 | |||
| b9a8529e72 | |||
| 95f2362947 | |||
| 9fd7e611f3 | |||
| 849d41fa06 | |||
| cf94003b3f | |||
| aa2b2f7432 | |||
| e886c56dd9 | |||
| 946d141897 | |||
| 92894c4af8 | |||
| f38835ceee | |||
| a94a986fbf | |||
| 1aceab582c | |||
| c1f5b6c8c1 | |||
| 9184d002d3 | |||
| 0439c51c2e | |||
| 628c191137 | |||
| 79cc14490a | |||
| 99622743c9 | |||
| fa3c8268b2 | |||
| a8a0b7db1b | |||
| 2fe562ce2b | |||
| fb771a7408 | |||
| 189d5e296b | |||
| 613a350292 | |||
| 2eba834838 | |||
| 7b000429e9 | |||
| 0cd126818a | |||
| 832ebe9376 | |||
| 9645391f6a | |||
| 0ae3ab5505 | |||
| 3bfc15945d | |||
| 91abacc2de | |||
| e5d1349ad7 | |||
| 563c91e7d5 | |||
| e804cee229 | |||
| 72bc41733f | |||
| 96f5a727d6 | |||
| 746e52158d | |||
| 64f02d1c3b | |||
| 173c63508a | |||
| 54bc77444b | |||
| 636ef484ad | |||
| 0eddf4639a | |||
| e7fed5f037 | |||
| ec01ca77e4 | |||
| d262f734f0 | |||
| a564ea819b | |||
| 9fd28e26a0 | |||
| 2492188c50 | |||
| 4b468ed1ae | |||
| ff2ed72e22 | |||
| 127c57fa7c | |||
| 1a08bfac8c | |||
| 5b8aefd994 | |||
| 43ad4d3b62 | |||
| 13d3df2b51 | |||
| 424f27737b | |||
| aaf9ad4518 | |||
| 3051b1558f | |||
| a6a4757a8e | |||
| 6832c273cd | |||
| 39ac3ed290 | |||
| 89906dba7c | |||
| 91a2030f9b | |||
| ef7d5bfa59 | |||
| e3461dd5cd | |||
| 3a11a82cd3 | |||
| 3d9fe67936 | |||
| aacea01c1f | |||
| eb1e77b4c1 | |||
| 1f2ab9b752 | |||
| a216b0722f | |||
| f642d13ba1 | |||
| 70087cf4e5 | |||
| 0f73d685c2 | |||
| 48d6d5cb07 | |||
| 013c453dd3 | |||
| 6b755a426b | |||
| 13c6bbd0fb | |||
| cf143f232d | |||
| a686d85e96 | |||
| 03ced6d180 | |||
| 1c3b4447cf | |||
| 1e7a14d703 | |||
| 0ac395ceb6 | |||
| 110f4594be | |||
| 2e9ff742a0 | |||
| 64a21198b3 | |||
| bd7a0c4fb8 | |||
| 7bc06d1dc5 | |||
| 2deef1fe37 | |||
| 30469fda66 | |||
| 5e930d3c2e | |||
| 98fdc98c76 | |||
| ef899ee5cd | |||
| 28b1ff542b | |||
| 61a6904cc2 | |||
| 567884433d | |||
| 7da3ec6ab6 | |||
| ae38234e17 | |||
| b508499dbd | |||
| 111acb445c | |||
| 6755ab9a1b | |||
| 772d8cf3e1 | |||
| a03200472f | |||
| 46170eb5d3 | |||
| 17f84d5425 | |||
| dd8e7da911 | |||
| 07f02ecfeb | |||
| 637342cb92 | |||
| cee7958e03 | |||
| c2d0ed254b | |||
| 226b284ad2 | |||
| f987f04ff0 | |||
| 67826b92c0 | |||
| aff18d82db | |||
| fe2a003e94 | |||
| 91a973b7fb | |||
| 4e76ce902f | |||
| ae91145762 | |||
| f676baf8b3 | |||
| 38bd098ca6 | |||
| 106254ef83 | |||
| 1579de8c6d | |||
| 3659863647 | |||
| 1cdaac66db | |||
| f2b439cc86 | |||
| 67091e9d13 | |||
| bf6fdd15b5 | |||
| 81a82eee38 | |||
| bca45c5e89 | |||
| 5b6924f7ca | |||
| dbe61e447a | |||
| af0a319399 | |||
| 5a6db9bd48 | |||
| e1d2bf11da | |||
| 6b20fc2ee6 | |||
| be9cf6c5f0 | |||
| 7568ceb80d | |||
| f03868e65b | |||
| bea723a90a | |||
| f64380c421 | |||
| e58edaddd3 | |||
| bfa970d706 | |||
| 484ee9d4fb | |||
| 2bc4ce7060 | |||
| 4cb562e1ea | |||
| 4d9a415981 | |||
| 762c0ccf6e | |||
| 66aec4478f | |||
| 5ea8476f54 | |||
| 08f6b95b9b | |||
| 51c4c6c822 | |||
| 6fe5af8d8d | |||
| d024751135 | |||
| e9af91e0c3 | |||
| bea33d29d3 | |||
| 3d8fa79657 | |||
| ce368c9e6e | |||
| 47b0d320f6 | |||
| 0ab2a58f10 | |||
| 9cda5a3aa3 | |||
| 3995777965 | |||
| d88d28a94e | |||
| 070684deb5 | |||
| f91068a4c1 | |||
| 5fadc6c8fe | |||
| aaa0b1891f | |||
| fbce997180 | |||
| be7a42c483 | |||
| bebd814c46 | |||
| 382f195847 | |||
| 5f80bd15d5 | |||
| ac7336592a | |||
| bcd0fa37ef | |||
| ead17ba45f | |||
| 4fc5ecb6fb | |||
| 8cbc04fff7 | |||
| 7e11d66dc2 | |||
| f95ccf134d | |||
| d8b4c488b5 | |||
| 97437c7089 | |||
| 4a1ded9655 | |||
| 9312f095b2 | |||
| 695adddaff | |||
| 45fa4cb0e5 | |||
| 9c36a8f94d | |||
| cd0d817cf7 | |||
| cc190ba85f | |||
| 4a9cb7fc58 | |||
| c5525bb980 | |||
| 1e3d946037 | |||
| e117f58dbe | |||
| 7c77d02ab5 | |||
| 4d1ad717ad | |||
| 47e2a9baa4 | |||
| 3de3ca7cef | |||
| 7f490b4596 | |||
| c54a75c3c5 | |||
| ffb4ae6c5a | |||
| 7a8b31dbcb | |||
| a5e1ea1acf | |||
| 65ea901626 | |||
| d10de739b3 | |||
| d56541bd13 | |||
| 7f93aa9db3 | |||
| 086cf4f1ee | |||
| 14285575e4 | |||
| 9f6633bf1f | |||
| 76c538dca2 | |||
| c797d7253e | |||
| d6280439d0 | |||
| 3564055c29 | |||
| 21062a7786 | |||
| 43c0a217c6 | |||
| 48c73893b3 | |||
| 7042b2d575 | |||
| 5eaa51ffcc | |||
| 3d71dbaed0 | |||
| be966d78a8 | |||
| acac407fd0 | |||
| c471c28bb0 | |||
| bf1a9257ff | |||
| 8cb9eb55c7 | |||
| f3ddceecfc | |||
| 60daec38ff | |||
| 739bb0cf80 | |||
| d525d6a588 | |||
| 0ab2ec2bb8 | |||
| d817e637c0 | |||
| 3c1d9cd4a7 | |||
| 986a389606 | |||
| 92bbaa11b3 | |||
| 0111ea28af | |||
| d8a45b22d3 | |||
| 86bdc78852 | |||
| 3d52fa6ccc | |||
| bf0147d542 | |||
| 3bf3f16be7 | |||
| 83190727f8 | |||
| 6f033e2a73 | |||
| 1fb31a0d04 | |||
| 33a1318958 | |||
| fa91ce0382 | |||
| 161c89acf7 | |||
| 0f993772c9 | |||
| 97b2e7914e | |||
| 356a166c7e | |||
| e61f838a98 | |||
| 59ffa7ad6e | |||
| f6ee5e998a | |||
| 4a4d213eb6 | |||
| 18f587864f | |||
| 8168b7d8f5 | |||
| e488cc79d0 | |||
| 35f24cb0ef | |||
| 3eea235ab1 | |||
| e96f5cbe8a | |||
| 79d8d0eac0 | |||
| a87b1d86a1 | |||
| 80afa58ac9 | |||
| 8ee8aba5a2 | |||
| d6b7ecc8fd | |||
| 6bf2f2217a | |||
| cbace421d2 | |||
| 83c7b2ad53 | |||
| 44f4dc772a | |||
| 4edb6c853a | |||
| cba24c5d95 | |||
| 2262bf4f3b | |||
| e1375eddfa | |||
| 9ca12922b4 | |||
| 67bdcddd33 | |||
| f89a38ba7d | |||
| a1b1a46875 | |||
| 2915a30fe0 | |||
| e1295b434b | |||
| 01c5d9be0c | |||
| 4b66d8e185 | |||
| 990137b829 | |||
| ff785f409f | |||
| 51857bed63 |
@@ -0,0 +1,85 @@
|
|||||||
|
name: Deploy (reusable)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
module:
|
||||||
|
description: "Skaffold module: orion, worker, pusher, emailnotifierjob, memberreconciler, or migrations"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
environment:
|
||||||
|
description: "Target environment: dev or prod"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: go
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go/go.mod
|
||||||
|
cache-dependency-path: go/go.sum
|
||||||
|
|
||||||
|
- name: Resolve environment settings
|
||||||
|
id: env
|
||||||
|
run: |
|
||||||
|
case "${{ inputs.environment }}" in
|
||||||
|
dev)
|
||||||
|
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
prod)
|
||||||
|
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "region=us-west2" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Authenticate to Google Cloud
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
|
||||||
|
|
||||||
|
- uses: google-github-actions/setup-gcloud@v2
|
||||||
|
|
||||||
|
- name: Install gke-gcloud-auth-plugin
|
||||||
|
run: gcloud components install gke-gcloud-auth-plugin --quiet
|
||||||
|
|
||||||
|
- name: Configure Docker for Artifact Registry
|
||||||
|
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
|
||||||
|
|
||||||
|
- name: Get GKE credentials
|
||||||
|
run: |
|
||||||
|
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
|
||||||
|
--region "${{ steps.env.outputs.region }}" \
|
||||||
|
--project "${{ steps.env.outputs.project }}"
|
||||||
|
|
||||||
|
- name: Install skaffold
|
||||||
|
run: |
|
||||||
|
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
|
||||||
|
sudo install skaffold /usr/local/bin/
|
||||||
|
skaffold version
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.module }}" = "migrations" ]; then
|
||||||
|
kubectl delete job migrations --ignore-not-found
|
||||||
|
skaffold run -p migrations
|
||||||
|
else
|
||||||
|
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
|
||||||
|
fi
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Build Windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
# Azure Trusted Signing uses OIDC federated credentials from GitHub.
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js/desktop
|
||||||
|
env:
|
||||||
|
APP_ENV: prod
|
||||||
|
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
|
||||||
|
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: yarn
|
||||||
|
cache-dependency-path: js/desktop/yarn.lock
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile --network-timeout 600000
|
||||||
|
|
||||||
|
- name: Azure login (OIDC)
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||||
|
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||||
|
allow-no-subscriptions: true
|
||||||
|
|
||||||
|
- name: Install Trusted Signing client dlib
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: .
|
||||||
|
run: |
|
||||||
|
nuget install Microsoft.Trusted.Signing.Client -Version 1.0.60 -OutputDirectory $env:RUNNER_TEMP\trusted-signing -ExcludeVersion
|
||||||
|
$dlib = Join-Path $env:RUNNER_TEMP "trusted-signing\Microsoft.Trusted.Signing.Client\bin\x64\Azure.CodeSigning.Dlib.dll"
|
||||||
|
if (-not (Test-Path $dlib)) { throw "Dlib not found at $dlib" }
|
||||||
|
"AZURE_DLIB_PATH=$dlib" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
"AZURE_METADATA_JSON_PATH=$env:GITHUB_WORKSPACE\js\desktop\signing-metadata.json" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
|
- name: Locate signtool.exe
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: .
|
||||||
|
run: |
|
||||||
|
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object FullName -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $signtool) { throw "signtool.exe not found in Windows Kits" }
|
||||||
|
"SIGNTOOL_PATH=$($signtool.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
|
- name: Authenticate to Google Cloud
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ secrets.PROD_GKE_SERVICE_ACCOUNT_KEY }}
|
||||||
|
|
||||||
|
- name: Set up gcloud
|
||||||
|
uses: google-github-actions/setup-gcloud@v2
|
||||||
|
|
||||||
|
- name: Publish signed installer (x64)
|
||||||
|
run: yarn publish:win
|
||||||
|
|
||||||
|
- name: Invalidate RELEASES cache
|
||||||
|
shell: pwsh
|
||||||
|
working-directory: .
|
||||||
|
run: gsutil setmeta -h "Cache-Control:no-cache, no-store, must-revalidate" gs://flowy-releases/llink/win32/x64/RELEASES
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy emailnotifierjob
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-emailnotifierjob-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: ./.github/workflows/_deploy.yml
|
||||||
|
with:
|
||||||
|
module: emailnotifierjob
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: Deploy llink-web
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-llink-web-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js/desktop
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve environment settings
|
||||||
|
id: env
|
||||||
|
run: |
|
||||||
|
case "${{ inputs.environment }}" in
|
||||||
|
dev)
|
||||||
|
echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cluster=cluster" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
prod)
|
||||||
|
echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "region=us-west2" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Authenticate to Google Cloud
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
|
||||||
|
|
||||||
|
- uses: google-github-actions/setup-gcloud@v2
|
||||||
|
|
||||||
|
- name: Install gke-gcloud-auth-plugin
|
||||||
|
run: gcloud components install gke-gcloud-auth-plugin --quiet
|
||||||
|
|
||||||
|
- name: Configure Docker for Artifact Registry
|
||||||
|
run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
|
||||||
|
|
||||||
|
- name: Get GKE credentials
|
||||||
|
run: |
|
||||||
|
gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
|
||||||
|
--region "${{ steps.env.outputs.region }}" \
|
||||||
|
--project "${{ steps.env.outputs.project }}"
|
||||||
|
|
||||||
|
- name: Install skaffold
|
||||||
|
run: |
|
||||||
|
curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
|
||||||
|
sudo install skaffold /usr/local/bin/
|
||||||
|
skaffold version
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
|
||||||
|
run: skaffold run -p ${{ inputs.environment }}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy memberreconciler
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-memberreconciler-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: ./.github/workflows/_deploy.yml
|
||||||
|
with:
|
||||||
|
module: memberreconciler
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy migrations
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-migrations-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: ./.github/workflows/_deploy.yml
|
||||||
|
with:
|
||||||
|
module: migrations
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy orion
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-orion-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: ./.github/workflows/_deploy.yml
|
||||||
|
with:
|
||||||
|
module: orion
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy worker (particleprocessorworker)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-worker-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: ./.github/workflows/_deploy.yml
|
||||||
|
with:
|
||||||
|
module: particleprocessor
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy pusher
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-pusher-${{ inputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: ./.github/workflows/_deploy.yml
|
||||||
|
with:
|
||||||
|
module: pusher
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: PR Quality Gate (Desktop)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "js/desktop/**"
|
||||||
|
branches: [ main ]
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Lint & format check (desktop)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js/desktop
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: yarn typecheck
|
||||||
|
|
||||||
|
- name: Run Code Linter
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Run Format Check
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Golang format and test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "go/**"
|
||||||
|
branches: [ main ]
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: go
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go/go.mod
|
||||||
|
cache-dependency-path: go/go.sum
|
||||||
|
|
||||||
|
- name: Verify gofmt
|
||||||
|
run: |
|
||||||
|
if [ -n "$(gofmt -l .)" ]; then
|
||||||
|
echo "The following files are not formatted correctly:"
|
||||||
|
gofmt -l .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./...
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: PR Quality Gate (Mobile)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "js/mobile/**"
|
||||||
|
branches: [ main ]
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Lint & format check (mobile)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js/mobile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
|
||||||
|
- name: Run Code Linter
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Run Format Check
|
||||||
|
run: yarn format:check
|
||||||
@@ -4,3 +4,5 @@ build/
|
|||||||
.cache/
|
.cache/
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
CMakeLists.txt.user
|
CMakeLists.txt.user
|
||||||
|
tags
|
||||||
|
project.el
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# Project Rules
|
# Project Rules
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- Electron typescript/react app is in `js/` folder
|
- Electron typescript/react app is in `js/desktop` folder
|
||||||
- Orion is the api server which lives in the `go/` folder
|
- Mobile react native expo app is in `js/mobile`
|
||||||
|
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
|
||||||
- `cpp/` points to our prototype of a C++ Qt widgets client
|
- `cpp/` points to our prototype of a C++ Qt widgets client
|
||||||
|
|
||||||
Whenever implementing anything, make sure to take into account best practices without over-engineering.
|
Whenever implementing anything, make sure to take into account best practices without over-engineering.
|
||||||
|
|
||||||
## Electron App
|
## Electron App
|
||||||
### Quality
|
### Quality
|
||||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
|
||||||
|
|
||||||
As an example, we have as high of a bar as a product team like Linear, which outputs high quality software. Let's avoid slop at all costs.
|
Another tradeoff we make is simpler, maintainable code over clever behavior.
|
||||||
|
|
||||||
|
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
Don't add non-essential comments everywhere. Clean them up if so.
|
||||||
|
|
||||||
### Package Manager
|
### Package Manager
|
||||||
- Use **yarn** (not npm) for all dependency management
|
- Use **yarn** (not npm) for all dependency management
|
||||||
@@ -23,6 +29,3 @@ Whenever possible, we should use the design system components. If we need to add
|
|||||||
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
|
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
|
||||||
|
|
||||||
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
|
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
|
||||||
|
|
||||||
## Verification
|
|
||||||
Check using `yarn compile` which lives in the package.json as a script.
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Flowy.llink
|
||||||
|
|
||||||
|
This project was a radical experiment to transform how teams communicate. The experience revolves around async voice and video clips, with the playback experience being somewhat like Instagram stories that auto-play.
|
||||||
|
|
||||||
|
The goal was to replace live Zoom-style meetings, which are draining, and bring relief to teams by making more conversations async, while retaining natural and high-fidelity with pitch, tone, facial expressions.
|
||||||
|
|
||||||
|
Due to changing conditions in the environment with regards to AI and data sovereignty (and lack of desire to maintain and market this product), we have decided to open source it. Now you can clone, modify, and host it in your own cloud.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Deployment Runbooks
|
||||||
|
|
||||||
|
## Desktop + web
|
||||||
|
1. Create a new branch
|
||||||
|
2. Bump version in `package.json`
|
||||||
|
3. Create a pull request and merge
|
||||||
|
4. Manually trigger github action to deploy llink-web, windows desktop
|
||||||
|
5. Deploy macOS desktop app from a local mac computer
|
||||||
|
6. Create a pull request in the `research-site` repo to update the download links
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
1. Create a new branch
|
||||||
|
2. Bump version in `package.json`
|
||||||
|
3. Create a pull request and merge
|
||||||
|
4. Switch to main and pull latest locally (on any machine with expo installed)
|
||||||
|
5. Manually run `yarn publish:ios`
|
||||||
|
|
||||||
|
## Golang services
|
||||||
|
1. Create a new branch
|
||||||
|
2. Make changes
|
||||||
|
3. Create a pull request
|
||||||
|
4. Deploy the appropriate github action for the service you're touching (input the branch name and dev)
|
||||||
|
5. Test with the dev deployment
|
||||||
|
6. Merge, then deploy the corresponding prod service
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# golang two stage build
|
||||||
|
FROM golang:1.25 AS first-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app/cmd/emailnotifierjob
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
|
||||||
|
RUN ls
|
||||||
|
|
||||||
|
FROM alpine:latest AS second-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=first-stage /app/cmd/emailnotifierjob .
|
||||||
|
RUN echo "copied over binary to production stage"
|
||||||
|
CMD ["./main"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# golang two stage build
|
||||||
|
FROM golang:1.25 AS first-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app/cmd/memberreconciler
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
|
||||||
|
RUN ls
|
||||||
|
|
||||||
|
FROM alpine:latest AS second-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=first-stage /app/cmd/memberreconciler .
|
||||||
|
RUN echo "copied over binary to production stage"
|
||||||
|
CMD ["./main"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# golang two stage build
|
||||||
|
FROM golang:1.25 AS first-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app/cmd/particleprocessorworker
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
|
||||||
|
RUN ls
|
||||||
|
|
||||||
|
FROM alpine:latest AS second-stage
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=first-stage /app/cmd/particleprocessorworker .
|
||||||
|
RUN echo "copied over binary to production stage"
|
||||||
|
CMD ["./main"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# golang two stage build
|
||||||
|
FROM golang:1.25 AS first-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app/cmd/pusherservice
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
|
||||||
|
RUN ls
|
||||||
|
|
||||||
|
FROM alpine:latest AS second-stage
|
||||||
|
# for health check
|
||||||
|
RUN apk --no-cache add curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=first-stage /app/cmd/pusherservice .
|
||||||
|
RUN echo "copied over binary to production stage"
|
||||||
|
CMD ["./main"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# golang two stage build
|
||||||
|
FROM golang:1.25 AS first-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /app/cmd/transcodebackfill
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main
|
||||||
|
RUN ls
|
||||||
|
|
||||||
|
FROM alpine:latest AS second-stage
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=first-stage /app/cmd/transcodebackfill .
|
||||||
|
RUN echo "copied over binary to production stage"
|
||||||
|
CMD ["./main"]
|
||||||
+32
-24
@@ -8,19 +8,13 @@ generate:
|
|||||||
test:
|
test:
|
||||||
go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
|
go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
|
||||||
|
|
||||||
.PHONY: migrate-dev-db
|
|
||||||
migrate-dev-db:
|
|
||||||
./migrate_dev.sh
|
|
||||||
|
|
||||||
# .PHONY: migrate-prod-db
|
|
||||||
# migrate-prod-db:
|
|
||||||
# ./migrate_prod.sh
|
|
||||||
|
|
||||||
# ex: make create-migration ARG="create_users_table"
|
# ex: make create-migration ARG="create_users_table"
|
||||||
create-migration:
|
create-migration:
|
||||||
echo "Creating migration with name $(ARG)"
|
echo "Creating migration with name $(ARG)"
|
||||||
docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations $(ARG)
|
docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations $(ARG)
|
||||||
|
|
||||||
|
# ---- Database access ----
|
||||||
|
|
||||||
dev-database:
|
dev-database:
|
||||||
kubectl run postgres-client \
|
kubectl run postgres-client \
|
||||||
--rm -it --image=postgres:latest \
|
--rm -it --image=postgres:latest \
|
||||||
@@ -28,23 +22,37 @@ dev-database:
|
|||||||
--context dev \
|
--context dev \
|
||||||
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
|
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
|
||||||
|
|
||||||
# prod-database:
|
prod-database:
|
||||||
# kubectl run postgres-client \
|
kubectl run postgres-client \
|
||||||
# --rm -it --image=postgres:latest \
|
--rm -it --image=postgres:latest \
|
||||||
# --env="LLINK_POSTGRES_CONNECTION_URL=$$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
|
--env="LLINK_POSTGRES_CONNECTION_URL=$$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context prod | base64 --decode)" \
|
||||||
# --context prod \
|
--context prod \
|
||||||
# --command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
|
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
|
||||||
|
|
||||||
|
# ---- Migrations ----
|
||||||
|
# NOTE: if you run into issues with 'dirty' migrations: https://github.com/golang-migrate/migrate/issues/282#issuecomment-530743258
|
||||||
|
|
||||||
|
DEV_REPO := us-west2-docker.pkg.dev/flowy-dev-440017/deployments
|
||||||
|
PROD_REPO := us-west2-docker.pkg.dev/flowy-prod-440017/deployments
|
||||||
|
|
||||||
|
.PHONY: migrate-dev
|
||||||
|
migrate-dev:
|
||||||
|
kubectl delete job migrations --ignore-not-found --context=dev
|
||||||
|
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev
|
||||||
|
|
||||||
|
.PHONY: migrate-prod
|
||||||
|
migrate-prod:
|
||||||
|
kubectl delete job migrations --ignore-not-found --context=prod
|
||||||
|
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p migrations --kube-context prod
|
||||||
|
|
||||||
|
# ---- Deploy ----
|
||||||
|
# Use MODULE=orion or MODULE=particleprocessor or MODULE=pusher or MODULE=emailnotifierjob to deploy a single service, e.g.:
|
||||||
|
# make deploy-dev MODULE=orion
|
||||||
|
|
||||||
.PHONY: deploy-dev
|
.PHONY: deploy-dev
|
||||||
deploy-dev: generate test
|
deploy-dev: generate test
|
||||||
./deploy_dev.sh
|
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p dev $(if $(MODULE),-m $(MODULE)) --kube-context dev --port-forward --tail
|
||||||
|
|
||||||
# .PHONY: release
|
.PHONY: deploy-prod
|
||||||
# deploy-prod: generate test
|
deploy-prod: generate test
|
||||||
# # Check for any changes (both staged and unstaged)
|
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p prod $(if $(MODULE),-m $(MODULE)) --kube-context prod --port-forward --tail
|
||||||
# @if [ -n "$(git status --porcelain)" ]; then \
|
|
||||||
# echo "Error: You have uncommitted changes in your Git repository."; \
|
|
||||||
# exit 1; \
|
|
||||||
# fi
|
|
||||||
# ./deploy_prod.sh
|
|
||||||
#
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Orion
|
||||||
|
|
||||||
|
API server, jobs, and worker services for llink.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy all services
|
||||||
|
make deploy-dev
|
||||||
|
make deploy-prod
|
||||||
|
|
||||||
|
# Deploy a single service
|
||||||
|
make deploy-dev MODULE=orion
|
||||||
|
make deploy-dev MODULE=particleprocessor
|
||||||
|
make deploy-dev MODULE=pusher
|
||||||
|
make deploy-dev MODULE=emailnotifierjob
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migrate-dev
|
||||||
|
make migrate-prod
|
||||||
|
|
||||||
|
# Create a new migration
|
||||||
|
make create-migration ARG="create_users_table"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-database
|
||||||
|
make prod-database
|
||||||
|
```
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
||||||
|
"github.com/flowy-live/llink/internal/db"
|
||||||
|
"github.com/flowy-live/llink/internal/human"
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxActivityAge = 24 * time.Hour // ignore streams idle longer than this
|
||||||
|
unreadThreshold = 10 * time.Minute // grace window before a message is "unread"
|
||||||
|
emailCooldown = 12 * time.Hour // min gap between emails to the same user
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db.Init()
|
||||||
|
defer db.Cleanup()
|
||||||
|
|
||||||
|
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||||
|
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create Firestore client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer firestoreClient.Close()
|
||||||
|
|
||||||
|
aeroAddr := utils.MustGetEnv("AERO_ADDR")
|
||||||
|
aeroConn, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to connect to aero", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer aeroConn.Close()
|
||||||
|
aeroSvc := pbaero.NewPrimaryClient(aeroConn)
|
||||||
|
|
||||||
|
pusherAddr := utils.MustGetEnv("PUSHER_GRPC_ADDR")
|
||||||
|
pusherConn, err := grpc.NewClient(pusherAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to connect to pusher", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pusherConn.Close()
|
||||||
|
pusherSvc := pbpusher.NewPusherServiceClient(pusherConn)
|
||||||
|
|
||||||
|
humanSvc := human.NewService(db.Pool())
|
||||||
|
networkSvc := network.NewReader(db.Pool())
|
||||||
|
|
||||||
|
flog.Info("starting email notification cycle")
|
||||||
|
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
|
||||||
|
flog.Error("notification cycle failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
flog.Info("email notification cycle complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNotificationCycle(
|
||||||
|
ctx context.Context,
|
||||||
|
fsClient *firestore.Client,
|
||||||
|
aeroSvc pbaero.PrimaryClient,
|
||||||
|
pusherSvc pbpusher.PusherServiceClient,
|
||||||
|
humanSvc human.Service,
|
||||||
|
networkReader network.Reader,
|
||||||
|
) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
networks, err := networkReader.ListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing networks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allHumans, err := humanSvc.ListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing humans: %w", err)
|
||||||
|
}
|
||||||
|
humansById := make(map[string]*human.Human, len(allHumans))
|
||||||
|
for _, h := range allHumans {
|
||||||
|
humansById[h.ID] = h
|
||||||
|
}
|
||||||
|
|
||||||
|
behindCounts := map[string]int{}
|
||||||
|
latestActivity := map[string]time.Time{}
|
||||||
|
|
||||||
|
allOnline := map[string]bool{}
|
||||||
|
onlineResp, err := pusherSvc.GetOnlineHumanIds(ctx, &pbpusher.GetOnlineHumanIdsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get online humans: %w", err)
|
||||||
|
}
|
||||||
|
flog.Info("gathered online presence", "onlineCount", len(onlineResp.HumanIds), "humanIds", onlineResp.HumanIds)
|
||||||
|
for _, id := range onlineResp.HumanIds {
|
||||||
|
allOnline[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, net := range networks {
|
||||||
|
streams, err := getOpenStreams(ctx, fsClient, net.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to query streams", "networkId", net.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// net.MemberHumanIds already includes the admin.
|
||||||
|
for _, stream := range streams {
|
||||||
|
if stream.LastChildCreatedAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(*stream.LastChildCreatedAt) > maxActivityAge {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(*stream.LastChildCreatedAt) < unreadThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, humanId := range network.ResolveVisibility(stream.VisibleTo, net.MemberHumanIds) {
|
||||||
|
marker, hasMarker := stream.PlaybackMarkers[humanId]
|
||||||
|
if hasMarker && !marker.Before(*stream.LastChildCreatedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
behindCounts[humanId]++
|
||||||
|
if stream.LastChildCreatedAt.After(latestActivity[humanId]) {
|
||||||
|
latestActivity[humanId] = *stream.LastChildCreatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
sentCount := 0
|
||||||
|
for humanId, count := range behindCounts {
|
||||||
|
if allOnline[humanId] {
|
||||||
|
flog.Info("human online...skipping email", "humanId", humanId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
h, ok := humansById[humanId]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.EmailNotificationsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if nothing new since the previous email.
|
||||||
|
if h.LastEmailNotificationSentAt != nil && !latestActivity[humanId].After(*h.LastEmailNotificationSentAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.LastEmailNotificationSentAt != nil && now.Sub(*h.LastEmailNotificationSentAt) < emailCooldown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sendNotificationEmail(ctx, aeroSvc, h, count); err != nil {
|
||||||
|
flog.Error("failed to send email", "humanId", humanId, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := humanSvc.UpdateLastEmailNotificationSentAt(ctx, humanId, now); err != nil {
|
||||||
|
flog.Error("failed to update last_email_notification_sent_at", "humanId", humanId, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sentCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
flog.Info("notification cycle summary",
|
||||||
|
"networks", len(networks),
|
||||||
|
"humansBehind", len(behindCounts),
|
||||||
|
"emailsSent", sentCount,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOpenStreams(ctx context.Context, client *firestore.Client, networkId string) ([]particle.FirestoreStreamParticle, error) {
|
||||||
|
collPath := fmt.Sprintf("networks/%s/children", networkId)
|
||||||
|
docs, err := client.Collection(collPath).
|
||||||
|
Where("type", "==", "stream").
|
||||||
|
Where("status", "==", "open").
|
||||||
|
Documents(ctx).
|
||||||
|
GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
streams := make([]particle.FirestoreStreamParticle, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
var s particle.FirestoreStreamParticle
|
||||||
|
if err := doc.DataTo(&s); err != nil {
|
||||||
|
flog.Warn("failed to unmarshal stream particle", "docId", doc.Ref.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
streams = append(streams, s)
|
||||||
|
}
|
||||||
|
return streams, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNotificationEmail(ctx context.Context, aeroSvc pbaero.PrimaryClient, h *human.Human, streamCount int) error {
|
||||||
|
streamsWord := "stream"
|
||||||
|
if streamCount != 1 {
|
||||||
|
streamsWord = "streams"
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("You have unseen messages in %d %s", streamCount, streamsWord)
|
||||||
|
html := buildEmailHTML(h.EmailPrefix, streamCount, streamsWord)
|
||||||
|
|
||||||
|
_, err := aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||||
|
ToEmails: []string{h.Email},
|
||||||
|
Subject: subject,
|
||||||
|
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
|
||||||
|
SimpleHtmlData: &pbaero.SimpleHtmlData{
|
||||||
|
Html: html,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEmailHTML(name string, count int, streamsWord string) string {
|
||||||
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a1a; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||||
|
<p>Hi %s,</p>
|
||||||
|
<p>You have unread messages in <strong>%d %s</strong> on Flowy.llink.</p>
|
||||||
|
<p>Open the app to catch up with your team.</p>
|
||||||
|
<p style="margin: 24px 0;">
|
||||||
|
<a href="https://llink.flowy.live" style="display: inline-block; background: #1a1a1a; color: #ffffff; text-decoration: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 500;">Open Flowy.llink</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #666; font-size: 13px; margin-top: 32px;">
|
||||||
|
Best,<br>Flowy Team <br>Note: disable email notifications from in-app settings.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>`, name, count, streamsWord)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// memberreconciler reconciles the Firestore membership mirror
|
||||||
|
// (humans/{humanId}.networks) against the authoritative Postgres
|
||||||
|
// network_members table. Safe to run on a cron — only humans whose mirrored
|
||||||
|
// set differs from Postgres are written, so a steady-state run is nearly free.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
|
"google.golang.org/api/iterator"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/db"
|
||||||
|
"github.com/flowy-live/llink/internal/human"
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db.Init()
|
||||||
|
defer db.Cleanup()
|
||||||
|
|
||||||
|
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||||
|
fs, err := firestore.NewClient(ctx, gcpProject)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create Firestore client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer fs.Close()
|
||||||
|
|
||||||
|
humanSvc := human.NewService(db.Pool())
|
||||||
|
networkSvc := network.NewReader(db.Pool())
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("reconciliation failed", "error", err, "elapsed", time.Since(started))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
flog.Info("reconciliation complete",
|
||||||
|
"humans_scanned", scanned,
|
||||||
|
"humans_written", written,
|
||||||
|
"elapsed", time.Since(started),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcile(
|
||||||
|
ctx context.Context,
|
||||||
|
fs *firestore.Client,
|
||||||
|
humanSvc human.Service,
|
||||||
|
networkReader network.Reader,
|
||||||
|
) (written, scanned int, err error) {
|
||||||
|
humans, err := humanSvc.ListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
memberships, err := networkReader.ListAllMemberships(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := snapshotMirror(ctx, fs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bulk := fs.BulkWriter(ctx)
|
||||||
|
for _, h := range humans {
|
||||||
|
scanned++
|
||||||
|
desired := memberships[h.ID]
|
||||||
|
if desired == nil {
|
||||||
|
desired = []string{}
|
||||||
|
}
|
||||||
|
if sameSet(current[h.ID], desired) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := bulk.Set(fs.Collection("humans").Doc(h.ID), map[string]any{
|
||||||
|
"networks": desired,
|
||||||
|
"updated_at": firestore.ServerTimestamp,
|
||||||
|
}, firestore.MergeAll); err != nil {
|
||||||
|
return written, scanned, err
|
||||||
|
}
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
bulk.End()
|
||||||
|
return written, scanned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// One iterator, N billed reads — returns humanId → mirrored networks.
|
||||||
|
func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) {
|
||||||
|
out := map[string][]string{}
|
||||||
|
iter := fs.Collection("humans").Documents(ctx)
|
||||||
|
defer iter.Stop()
|
||||||
|
for {
|
||||||
|
doc, err := iter.Next()
|
||||||
|
if err == iterator.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
Networks []string `firestore:"networks"`
|
||||||
|
}
|
||||||
|
if err := doc.DataTo(&data); err != nil {
|
||||||
|
flog.Warn("skipping malformed mirror doc", "id", doc.Ref.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[doc.Ref.ID] = data.Networks
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set equality (order- and duplicate-insensitive); Firestore array ops don't
|
||||||
|
// preserve order.
|
||||||
|
func sameSet(a, b []string) bool {
|
||||||
|
if len(a) == 0 && len(b) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ac := slices.Clone(a)
|
||||||
|
bc := slices.Clone(b)
|
||||||
|
slices.Sort(ac)
|
||||||
|
slices.Sort(bc)
|
||||||
|
ac = slices.Compact(ac)
|
||||||
|
bc = slices.Compact(bc)
|
||||||
|
return slices.Equal(ac, bc)
|
||||||
|
}
|
||||||
+113
-47
@@ -3,83 +3,118 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
"cloud.google.com/go/storage"
|
"cloud.google.com/go/storage"
|
||||||
|
firebase "firebase.google.com/go/v4"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
"github.com/flowy-live/llink/internal"
|
"github.com/flowy-live/llink/internal"
|
||||||
"github.com/flowy-live/llink/internal/auth"
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/handler"
|
"github.com/flowy-live/llink/internal/handler"
|
||||||
"github.com/flowy-live/llink/internal/human"
|
"github.com/flowy-live/llink/internal/human"
|
||||||
|
"github.com/flowy-live/llink/internal/human/pushnotify"
|
||||||
|
"github.com/flowy-live/llink/internal/livekit"
|
||||||
|
"github.com/flowy-live/llink/internal/livestore"
|
||||||
"github.com/flowy-live/llink/internal/middleware"
|
"github.com/flowy-live/llink/internal/middleware"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/flowy-live/llink/internal/waitlist"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// FIX: Use separate redis instance. We start with higher number because use this same instance in helios.
|
|
||||||
REDIS_DATABASE_FOR_AUTH int = 4
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func redisForAuth() *redis.Client {
|
func redisForAuth() *redis.Client {
|
||||||
return internal.ConnectAndTestRedis(REDIS_DATABASE_FOR_AUTH)
|
return internal.ConnectAndTestRedis(db.RedisDBAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := utils.MustGetEnv("PORT")
|
port := utils.MustGetEnv("PORT")
|
||||||
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
db.Init()
|
db.Init()
|
||||||
defer db.Cleanup()
|
defer db.Cleanup()
|
||||||
|
|
||||||
// Initialize Redis for auth
|
|
||||||
redisClient := redisForAuth()
|
redisClient := redisForAuth()
|
||||||
|
|
||||||
// Initialize GCS client
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storageClient, err := storage.NewClient(ctx)
|
storageClient, err := storage.NewClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create GCS client", "error", err)
|
flog.Error("failed to create GCS client", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer storageClient.Close()
|
defer storageClient.Close()
|
||||||
|
|
||||||
aeroAddr := utils.MustGetEnv("AERO_ADDR")
|
aeroAddr := utils.MustGetEnv("AERO_ADDR")
|
||||||
if aeroAddr == "" {
|
if aeroAddr == "" {
|
||||||
slog.Error("must provide AERO_ADDR")
|
flog.Error("must provide AERO_ADDR")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("connection to aero server invalid", "error", err)
|
flog.Error("connection to aero server invalid", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer aeroServer.Close()
|
defer aeroServer.Close()
|
||||||
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
||||||
|
|
||||||
// Initialize services
|
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||||
authSvc := auth.NewAuthService(redisClient, aeroSvc)
|
fbApp, err := firebase.NewApp(ctx, &firebase.Config{ProjectID: gcpProject})
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to init Firebase Admin app", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fbAuth, err := fbApp.Auth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create Firebase auth client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
authSvc := auth.NewAuthService(redisClient, aeroSvc, fbAuth)
|
||||||
humanSvc := human.NewService(db.Pool())
|
humanSvc := human.NewService(db.Pool())
|
||||||
networkSvc := network.NewService(db.Pool())
|
|
||||||
|
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
|
||||||
|
SecretKey: utils.MustGetEnv("STRIPE_SECRET_KEY"),
|
||||||
|
WebhookSecret: utils.MustGetEnv("STRIPE_WEBHOOK_SECRET"),
|
||||||
|
PriceMonthlyID: utils.MustGetEnv("STRIPE_PRICE_PRO_MONTHLY"),
|
||||||
|
PriceAnnualID: utils.MustGetEnv("STRIPE_PRICE_PRO_ANNUAL"),
|
||||||
|
SuccessURL: utils.MustGetEnv("BILLING_SUCCESS_URL"),
|
||||||
|
CancelURL: utils.MustGetEnv("BILLING_CANCEL_URL"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to initialize billing service", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create Firestore client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer firestoreClient.Close()
|
||||||
|
|
||||||
|
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, livestore.NewMembershipPublisher(firestoreClient), humanSvc)
|
||||||
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
||||||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
BucketName: gcsBucket,
|
BucketName: gcsBucket,
|
||||||
})
|
})
|
||||||
|
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||||
|
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||||
|
livekitClient := livekit.NewClient()
|
||||||
|
|
||||||
// Initialize handler
|
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient)
|
||||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc)
|
|
||||||
|
|
||||||
// Helper to wrap handlers with auth middleware
|
|
||||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||||
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
||||||
}
|
}
|
||||||
@@ -97,6 +132,9 @@ func main() {
|
|||||||
})
|
})
|
||||||
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
|
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
|
||||||
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
|
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
|
||||||
|
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
|
||||||
|
mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook)
|
||||||
|
mux.HandleFunc("POST /webhooks/stripe", h.HandleStripeWebhook)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Protected routes (auth required)
|
// Protected routes (auth required)
|
||||||
@@ -105,47 +143,75 @@ func main() {
|
|||||||
// Auth
|
// Auth
|
||||||
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
|
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
|
||||||
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
|
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
|
||||||
|
mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken))
|
||||||
|
|
||||||
// Bootstrap startup data
|
// Settings
|
||||||
mux.Handle("GET /startup", withAuth(h.StartupData))
|
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||||
|
mux.Handle("PUT /humans/me/avatar", withAuth(h.UpdateAvatar))
|
||||||
|
mux.Handle("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar))
|
||||||
|
|
||||||
|
// Get avatar download url, given objectId
|
||||||
|
mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl))
|
||||||
|
|
||||||
|
// Push notification tokens (per-device)
|
||||||
|
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
||||||
|
mux.Handle("DELETE /humans/me/push-tokens", withAuth(h.UnregisterPushToken))
|
||||||
|
|
||||||
// Networks
|
// Networks
|
||||||
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
|
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
|
||||||
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
||||||
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
||||||
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
||||||
// TODO: what about members who are part of streams visibility within this network?
|
mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
||||||
mux.Handle("DELETE /networks/{id}/members/{email}", withAuth(h.RemoveMemberFromNetwork))
|
|
||||||
mux.Handle("PUT /networks/{id}/capacity", withAuth(h.SetOpenStreamCapacity))
|
|
||||||
|
|
||||||
// Streams
|
// Billing (network admin only; admin check happens inside each handler)
|
||||||
mux.Handle("POST /networks/{network_id}/streams", withAuth(h.CreateStream))
|
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
|
||||||
mux.Handle("GET /streams/{id}", withAuth(h.GetStream))
|
mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession))
|
||||||
mux.Handle("PATCH /streams/{id}", withAuth(h.UpdateStream))
|
mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession))
|
||||||
mux.Handle("POST /streams/{id}/particles", withAuth(h.CreateStreamParticle))
|
|
||||||
mux.Handle("POST /streams/{id}/open", withAuth(h.OpenStream))
|
// Freemium usage (any network member)
|
||||||
mux.Handle("POST /streams/{id}/close", withAuth(h.CloseStream))
|
mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage))
|
||||||
mux.Handle("POST /streams/{id}/members", withAuth(h.AddMembers))
|
|
||||||
mux.Handle("DELETE /streams/{id}/members", withAuth(h.RemoveMembers))
|
// Network Invitations
|
||||||
|
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
|
||||||
|
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
|
||||||
|
mux.Handle("GET /invitations", withAuth(h.ListMyInvitations))
|
||||||
|
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
||||||
|
|
||||||
// Particles
|
// Particles
|
||||||
mux.Handle("GET /networks/{network_id}/particles", withAuth(h.ListParticles))
|
mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
|
||||||
mux.Handle("GET /particles/{id}", withAuth(h.GetParticle))
|
|
||||||
mux.Handle("PATCH /particles/{id}", withAuth(h.UpdateParticle))
|
// Link metadata
|
||||||
mux.Handle("DELETE /particles/{id}", withAuth(h.DeleteParticle))
|
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||||
mux.Handle("POST /particles/{id}/seen", withAuth(h.MarkSeen))
|
|
||||||
mux.Handle("POST /particles/{id}/ack", withAuth(h.AckParticle))
|
|
||||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticle))
|
|
||||||
mux.Handle("POST /particles/seen", withAuth(h.MarkSeenBatch))
|
|
||||||
|
|
||||||
// Depot
|
// Depot
|
||||||
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
||||||
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
|
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
|
||||||
|
|
||||||
|
// LiveKit
|
||||||
|
mux.Handle("POST /livekit/token", withAuth(h.GetLivekitToken))
|
||||||
|
|
||||||
|
// Waitlist (admin-only)
|
||||||
|
mux.Handle("GET /waitlist", withAuth(h.GetWaitlist))
|
||||||
|
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
|
||||||
|
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
|
||||||
|
|
||||||
|
// CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client.
|
||||||
|
// Empty / unset = allow all
|
||||||
|
var allowedOrigins []string
|
||||||
|
if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
|
||||||
|
for _, o := range strings.Split(raw, ",") {
|
||||||
|
if o = strings.TrimSpace(o); o != "" {
|
||||||
|
allowedOrigins = append(allowedOrigins, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
muxWithCors := middleware.CORS(allowedOrigins)(mux)
|
||||||
|
|
||||||
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
addr := fmt.Sprintf("0.0.0.0:%s", port)
|
||||||
slog.Info("running server", "addr", addr)
|
flog.Info("running server", "addr", addr)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, muxWithCors); err != nil {
|
||||||
slog.Error("server failed", "error", err)
|
flog.Error("server failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
|
"github.com/flowy-live/llink/internal/db"
|
||||||
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
|
"github.com/flowy-live/llink/internal/human"
|
||||||
|
"github.com/flowy-live/llink/internal/human/pushnotify"
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
|
"github.com/flowy-live/llink/internal/speech"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createClient(ctx context.Context) *firestore.Client {
|
||||||
|
projectId := utils.MustGetEnv("GCP_PROJECT")
|
||||||
|
|
||||||
|
client, err := firestore.NewClient(ctx, projectId)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create client: %v", err)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listens for new particles and runs per-particle side effects: transcripts,
|
||||||
|
// transcode, parent stream's last_child_created_at, freemium usage, and push
|
||||||
|
// notifications for offline recipients.
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db.Init()
|
||||||
|
defer db.Cleanup()
|
||||||
|
processingRepo := particle.NewProcessingRepository(db.Pool())
|
||||||
|
billingSvc := billing.NewServiceForWorker(db.Pool())
|
||||||
|
|
||||||
|
storageClient, err := storage.NewClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create GCS client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer storageClient.Close()
|
||||||
|
|
||||||
|
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
||||||
|
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||||
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
|
BucketName: gcsBucket,
|
||||||
|
})
|
||||||
|
|
||||||
|
speechSvc := speech.NewSpeechService(ctx)
|
||||||
|
|
||||||
|
humanSvc := human.NewService(db.Pool())
|
||||||
|
networkReader := network.NewReader(db.Pool())
|
||||||
|
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||||
|
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
|
||||||
|
// project (otherwise anyone holding one of our push tokens could spam users).
|
||||||
|
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
|
||||||
|
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)
|
||||||
|
|
||||||
|
client := createClient(ctx)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-5 * time.Minute)
|
||||||
|
it := client.CollectionGroup("children").
|
||||||
|
Where("created_at", ">", cutoff).
|
||||||
|
Snapshots(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
snap, err := it.Next()
|
||||||
|
if e := status.Code(err); e == codes.DeadlineExceeded || e == codes.Canceled {
|
||||||
|
panic(fmt.Errorf("error: %w", err))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("error in processing snapshot", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if snap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, change := range snap.Changes {
|
||||||
|
if change.Kind != firestore.DocumentAdded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
particleID := change.Doc.Ref.ID
|
||||||
|
|
||||||
|
processed, err := processingRepo.IsProcessed(ctx, particleID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to check processing status", "particleID", particleID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if processed {
|
||||||
|
flog.Debug("skipping already processed particle", "particleID", particleID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
|
||||||
|
|
||||||
|
// Side effects below are best-effort — failures don't prevent
|
||||||
|
// marking the particle as processed.
|
||||||
|
parentDoc := loadParentParticle(ctx, change.Doc)
|
||||||
|
updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
|
||||||
|
transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||||
|
particle.Transcode(ctx, depotSvc, change.Doc)
|
||||||
|
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||||
|
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
|
||||||
|
|
||||||
|
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||||
|
flog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the structured transcript to Firestore and returns the raw text;
|
||||||
|
// returns "" for non-media particles or on any error (logged internally).
|
||||||
|
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
|
||||||
|
var mediaParticle particle.FirestoreMediaParticle
|
||||||
|
err := doc.DataTo(&mediaParticle)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("unable to marshal particle data", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
particleType, err := particle.ParseParticleType(mediaParticle.Type)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("invalid particle type", "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if particleType != particle.TypeMedia {
|
||||||
|
flog.Info("received a particle of type", "particle type", particleType)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := speechSvc.Transcribe(ctx, downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript := toFirestoreTranscript(result)
|
||||||
|
|
||||||
|
_, err = doc.Ref.Set(ctx, map[string]interface{}{
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"transcript": transcript,
|
||||||
|
},
|
||||||
|
}, firestore.MergeAll)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
flog.Info("transcribed media particle", "particleID", doc.Ref.ID)
|
||||||
|
return transcript.Transcript
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
|
||||||
|
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
|
||||||
|
for i, w := range result.Words {
|
||||||
|
words[i] = particle.FirestoreTranscriptWord{
|
||||||
|
Word: w.Word,
|
||||||
|
Start: w.Start,
|
||||||
|
End: w.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphs := make([]particle.FirestoreTranscriptParagraph, len(result.Paragraphs))
|
||||||
|
for i, p := range result.Paragraphs {
|
||||||
|
sentences := make([]particle.FirestoreTranscriptSentence, len(p.Sentences))
|
||||||
|
for j, s := range p.Sentences {
|
||||||
|
sentences[j] = particle.FirestoreTranscriptSentence{
|
||||||
|
Text: s.Text,
|
||||||
|
Start: s.Start,
|
||||||
|
End: s.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paragraphs[i] = particle.FirestoreTranscriptParagraph{
|
||||||
|
Sentences: sentences,
|
||||||
|
Start: p.Start,
|
||||||
|
End: p.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return particle.FirestoreTranscript{
|
||||||
|
Transcript: result.Transcript,
|
||||||
|
Words: words,
|
||||||
|
Paragraphs: paragraphs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bumps the network's daily message counter for non-container particles.
|
||||||
|
// The surrounding processed_particles guard keeps this idempotent across
|
||||||
|
// crashes/restarts.
|
||||||
|
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
|
||||||
|
rawType, err := doc.DataAt("type")
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typeStr, ok := rawType.(string)
|
||||||
|
if !ok {
|
||||||
|
flog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
particleType, err := particle.ParseParticleType(typeStr)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Containers don't count toward the daily message cap.
|
||||||
|
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil {
|
||||||
|
flog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns nil (and logs) if the path has no parent or the read fails.
|
||||||
|
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
|
||||||
|
parentChildrenCollectionRef := doc.Ref.Parent
|
||||||
|
if parentChildrenCollectionRef == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parentParticleDocRef := parentChildrenCollectionRef.Parent
|
||||||
|
if parentParticleDocRef == nil {
|
||||||
|
flog.Error("particle has no parent document", "particleID", doc.Ref.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parentParticleDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets last_child_created_at to the child's created_at so it stays directly
|
||||||
|
// comparable with playback markers (which also store child created_at values).
|
||||||
|
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
|
||||||
|
if parent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamParticle particle.FirestoreStreamParticle
|
||||||
|
if err := parent.DataTo(&streamParticle); err != nil {
|
||||||
|
flog.Error("failed to parse stream particle", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
particleType, err := particle.ParseParticleType(streamParticle.Type)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("invalid particle type", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if particleType != particle.TypeStream {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
childCreatedAt, err := doc.DataAt("created_at")
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parent.Ref.Update(ctx, []firestore.Update{
|
||||||
|
{
|
||||||
|
Path: "last_child_created_at",
|
||||||
|
Value: childCreatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("unable to update parent particle `last_child_created_at`", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skips containers and particles whose parent isn't a stream — notifications
|
||||||
|
// are scoped to stream messages today. The transcript arg becomes the preview
|
||||||
|
// body for media particles when available.
|
||||||
|
func notifyForParticle(
|
||||||
|
ctx context.Context,
|
||||||
|
notifier *pushnotify.Notifier,
|
||||||
|
humanSvc human.Service,
|
||||||
|
doc *firestore.DocumentSnapshot,
|
||||||
|
parent *firestore.DocumentSnapshot,
|
||||||
|
transcript string,
|
||||||
|
) {
|
||||||
|
if parent == nil {
|
||||||
|
flog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
typeStr, _ := doc.DataAt("type")
|
||||||
|
typeName, _ := typeStr.(string)
|
||||||
|
pType, err := particle.ParseParticleType(typeName)
|
||||||
|
if err != nil {
|
||||||
|
flog.Info("notify: skip — unparseable particle type",
|
||||||
|
"particleID", doc.Ref.ID, "type", typeName, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pType == particle.TypeStream || pType == particle.TypeFolder {
|
||||||
|
flog.Info("notify: skip — container particle",
|
||||||
|
"particleID", doc.Ref.ID, "type", pType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentStream particle.FirestoreStreamParticle
|
||||||
|
if err := parent.DataTo(&parentStream); err != nil {
|
||||||
|
flog.Error("notify: failed to parse parent stream", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parentType, err := particle.ParseParticleType(parentStream.Type)
|
||||||
|
if err != nil || parentType != particle.TypeStream {
|
||||||
|
flog.Info("notify: skip — parent isn't a stream",
|
||||||
|
"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderHumanID := parentStream.CreatedByHumanId
|
||||||
|
if v, err := doc.DataAt("created_by_human_id"); err == nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
senderHumanID = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamName := ""
|
||||||
|
if v, err := parent.DataAt("properties.name"); err == nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
streamName = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
senderEmailPrefix := ""
|
||||||
|
if senderHumanID != "" {
|
||||||
|
if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
|
||||||
|
senderEmailPrefix = sender.EmailPrefix
|
||||||
|
} else {
|
||||||
|
flog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
|
||||||
|
NetworkID: networkID,
|
||||||
|
SenderHumanID: senderHumanID,
|
||||||
|
SenderEmailPrefix: senderEmailPrefix,
|
||||||
|
ParticleID: doc.Ref.ID,
|
||||||
|
ParticleKind: string(pType),
|
||||||
|
StreamID: parent.Ref.ID,
|
||||||
|
StreamName: streamName,
|
||||||
|
StreamVisibleTo: parentStream.VisibleTo,
|
||||||
|
Body: previewForParticle(pType, doc, transcript),
|
||||||
|
}); err != nil {
|
||||||
|
flog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the notification body. Kept short — lockscreens truncate aggressively.
|
||||||
|
// Media prefers transcript text and falls back to a generic "Sent a …" line.
|
||||||
|
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
|
||||||
|
switch pType {
|
||||||
|
case particle.TypeText:
|
||||||
|
if v, err := doc.DataAt("properties.content"); err == nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return truncatePreview(s, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Sent a message"
|
||||||
|
case particle.TypeMedia:
|
||||||
|
if t := strings.TrimSpace(transcript); t != "" {
|
||||||
|
return truncatePreview(t, 140)
|
||||||
|
}
|
||||||
|
mime := ""
|
||||||
|
if v, err := doc.DataAt("properties.mime_type"); err == nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
mime = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mime, "video/") {
|
||||||
|
return "Sent a video"
|
||||||
|
}
|
||||||
|
return "Sent a voice message"
|
||||||
|
case particle.TypeFile:
|
||||||
|
return "Sent a file"
|
||||||
|
case particle.TypeQuest:
|
||||||
|
if v, err := doc.DataAt("properties.title"); err == nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return "Quest: " + truncatePreview(s, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Added a quest"
|
||||||
|
case particle.TypePaper:
|
||||||
|
if v, err := doc.DataAt("properties.title"); err == nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return "Paper: " + truncatePreview(s, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Added a paper"
|
||||||
|
default:
|
||||||
|
return "New activity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncatePreview(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal"
|
||||||
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
|
"github.com/flowy-live/llink/internal/db"
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
"github.com/flowy-live/llink/internal/pusher"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := utils.MustGetEnv("PORT")
|
||||||
|
grpcPort := utils.MustGetEnv("GRPC_PORT")
|
||||||
|
|
||||||
|
db.Init()
|
||||||
|
defer db.Cleanup()
|
||||||
|
|
||||||
|
authRedis := internal.ConnectAndTestRedis(db.RedisDBAuth) // shared with orion
|
||||||
|
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // presence + pub/sub
|
||||||
|
|
||||||
|
sessionReader := auth.NewSessionReader(authRedis)
|
||||||
|
networkReader := network.NewReader(db.Pool())
|
||||||
|
|
||||||
|
// Hostname is the k8s pod name.
|
||||||
|
podID, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
podID = fmt.Sprintf("pod-%d", os.Getpid())
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge := pusher.NewRedisBridge(pusherRedis, podID)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
authorizer := pusher.NewAuthorizer(networkReader)
|
||||||
|
hub := pusher.NewHub(bridge, authorizer)
|
||||||
|
bridge.SetHub(hub)
|
||||||
|
server := pusher.NewServer(ctx, hub, bridge, sessionReader)
|
||||||
|
|
||||||
|
go hub.Run(ctx)
|
||||||
|
go bridge.Listen(ctx)
|
||||||
|
go bridge.Heartbeat(ctx)
|
||||||
|
|
||||||
|
// --- gRPC server (internal presence queries) ---
|
||||||
|
grpcListener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", grpcPort))
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to listen for gRPC", "port", grpcPort, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
grpcServer := grpc.NewServer()
|
||||||
|
pbpusher.RegisterPusherServiceServer(grpcServer, server)
|
||||||
|
go func() {
|
||||||
|
flog.Info("gRPC server listening", "port", grpcPort)
|
||||||
|
if err := grpcServer.Serve(grpcListener); err != nil {
|
||||||
|
flog.Error("gRPC server failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --- HTTP server (WebSocket + health) ---
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /ws", server.HandleWebSocket)
|
||||||
|
|
||||||
|
httpAddr := fmt.Sprintf("0.0.0.0:%s", port)
|
||||||
|
httpServer := &http.Server{Addr: httpAddr, Handler: mux}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
flog.Info("HTTP server listening", "addr", httpAddr)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
flog.Error("HTTP server failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --- Graceful shutdown ---
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
<-sigCh
|
||||||
|
|
||||||
|
flog.Info("shutting down...")
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
grpcServer.GracefulStop()
|
||||||
|
httpServer.Shutdown(context.Background())
|
||||||
|
flog.Info("shutdown complete")
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// transcodebackfill walks every doc under the "children" collection group and
|
||||||
|
// re-runs transcode for media particles missing a transcoded variant.
|
||||||
|
// Idempotent: particle.Transcode short-circuits on transcoded_object_id != "".
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
|
"cloud.google.com/go/storage"
|
||||||
|
"google.golang.org/api/iterator"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/db"
|
||||||
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db.Init()
|
||||||
|
defer db.Cleanup()
|
||||||
|
|
||||||
|
storageClient, err := storage.NewClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create GCS client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer storageClient.Close()
|
||||||
|
|
||||||
|
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||||
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
|
BucketName: utils.MustGetEnv("GCS_BUCKET"),
|
||||||
|
})
|
||||||
|
|
||||||
|
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||||
|
fs, err := firestore.NewClient(ctx, gcpProject)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create Firestore client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer fs.Close()
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
stats, err := run(ctx, fs, depotSvc)
|
||||||
|
elapsed := time.Since(started)
|
||||||
|
|
||||||
|
flog.Info("transcode_backfill_summary",
|
||||||
|
"scanned", stats.scanned,
|
||||||
|
"media", stats.media,
|
||||||
|
"transcoded", stats.transcoded,
|
||||||
|
"skippedAlreadyDone", stats.skippedAlreadyDone,
|
||||||
|
"skippedIOSPlayable", stats.skippedIOSPlayable,
|
||||||
|
"skippedNonMedia", stats.skippedNonMedia,
|
||||||
|
"failures", stats.failures,
|
||||||
|
"elapsed_seconds", elapsed.Seconds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode backfill aborted", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if stats.failures > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stats struct {
|
||||||
|
scanned int
|
||||||
|
media int
|
||||||
|
transcoded int
|
||||||
|
skippedAlreadyDone int
|
||||||
|
skippedIOSPlayable int
|
||||||
|
skippedNonMedia int
|
||||||
|
failures int
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, fs *firestore.Client, depotSvc depot.Service) (stats, error) {
|
||||||
|
var s stats
|
||||||
|
|
||||||
|
it := fs.CollectionGroup("children").
|
||||||
|
OrderBy(firestore.DocumentID, firestore.Asc).
|
||||||
|
Documents(ctx)
|
||||||
|
defer it.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
doc, err := it.Next()
|
||||||
|
if err == iterator.Done {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.scanned++
|
||||||
|
|
||||||
|
// DataAt avoids unmarshalling the full doc; most children aren't media.
|
||||||
|
rawType, err := doc.DataAt("type")
|
||||||
|
if err != nil {
|
||||||
|
s.skippedNonMedia++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
typeStr, ok := rawType.(string)
|
||||||
|
if !ok || typeStr != string(particle.TypeMedia) {
|
||||||
|
s.skippedNonMedia++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.media++
|
||||||
|
|
||||||
|
result := particle.Transcode(ctx, depotSvc, doc)
|
||||||
|
switch {
|
||||||
|
case result.Err != nil:
|
||||||
|
s.failures++
|
||||||
|
flog.Error("transcode_backfill_failure",
|
||||||
|
"particleID", doc.Ref.ID,
|
||||||
|
"path", doc.Ref.Path,
|
||||||
|
"error", result.Err,
|
||||||
|
)
|
||||||
|
case result.Skipped && result.SkipReason == particle.SkipReasonAlreadyTranscoded:
|
||||||
|
s.skippedAlreadyDone++
|
||||||
|
case result.Skipped && result.SkipReason == particle.SkipReasonIOSPlayable:
|
||||||
|
s.skippedIOSPlayable++
|
||||||
|
case result.Skipped:
|
||||||
|
s.skippedNonMedia++
|
||||||
|
default:
|
||||||
|
s.transcoded++
|
||||||
|
flog.Info("transcode_backfill_progress",
|
||||||
|
"particleID", doc.Ref.ID,
|
||||||
|
"transcodedObjectID", result.TranscodedObjectID,
|
||||||
|
"outputMime", result.OutputMimeType,
|
||||||
|
"idx", s.transcoded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
export KUBE_CONTEXT=dev
|
|
||||||
export SKAFFOLD_DEFAULT_REPO=us-west2-docker.pkg.dev/flowy-dev-440017/deployments
|
|
||||||
skaffold run -p dev --kube-context dev --port-forward --tail
|
|
||||||
+3
-311
@@ -51,54 +51,6 @@ Returns the authenticated user.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Startup
|
|
||||||
|
|
||||||
### Get Startup Data
|
|
||||||
`GET /startup` (Protected)
|
|
||||||
|
|
||||||
Bootstrap endpoint for initial app load. Returns all networks the user belongs to, with all streams and their particles fully enriched.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"networks": [
|
|
||||||
{
|
|
||||||
"id": "net-456",
|
|
||||||
"name": "My Team",
|
|
||||||
"admin_human": { "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." },
|
|
||||||
"humans": [{ "id": "...", "email": "...", "email_prefix": "...", "created_at": "..." }],
|
|
||||||
"open_stream_count": 2,
|
|
||||||
"open_stream_capacity": 5,
|
|
||||||
"created_at": "2025-01-15T10:30:00Z",
|
|
||||||
"streams": [
|
|
||||||
{
|
|
||||||
"id": "p-001",
|
|
||||||
"name": "Sprint Planning",
|
|
||||||
"description": "Weekly sync",
|
|
||||||
"status": "open",
|
|
||||||
"members": ["alice@example.com"],
|
|
||||||
"particles": [
|
|
||||||
{
|
|
||||||
"id": "p-002",
|
|
||||||
"type": "text",
|
|
||||||
"data": { "content": "Hello" },
|
|
||||||
"created_by_email": "alice@example.com",
|
|
||||||
"seen": true,
|
|
||||||
"acks": [],
|
|
||||||
"updated_at": "...",
|
|
||||||
"created_at": "..."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"unseen_count": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
### Create Network
|
### Create Network
|
||||||
@@ -136,183 +88,15 @@ Returns a specific network by ID.
|
|||||||
|
|
||||||
Removes a member by email from the network.
|
Removes a member by email from the network.
|
||||||
|
|
||||||
### Set Open Stream Capacity
|
|
||||||
`PUT /networks/{id}/capacity` (Protected, Admin only)
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"capacity": 10
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Streams
|
### Download Particle Object
|
||||||
|
|
||||||
Streams are top-level particles of type `stream`. They have dedicated endpoints for creation and management, and contain child particles.
|
|
||||||
|
|
||||||
### Create Stream
|
|
||||||
`POST /networks/{network_id}/streams` (Protected)
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Sprint Planning",
|
|
||||||
"description": "Weekly sync",
|
|
||||||
"visibility": "custom",
|
|
||||||
"members": ["user@example.com"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `visibility`: `network_all` (default) or `custom`
|
|
||||||
- `members` is required when visibility is `custom`
|
|
||||||
|
|
||||||
**Response:** `201 Created` — returns a [Stream](#stream-1) object.
|
|
||||||
|
|
||||||
### Get Stream
|
|
||||||
`GET /streams/{id}` (Protected)
|
|
||||||
|
|
||||||
Returns a stream with all its child particles, enriched with seen/ack state.
|
|
||||||
|
|
||||||
**Response:** returns a [Stream](#stream-1) object.
|
|
||||||
|
|
||||||
### Update Stream
|
|
||||||
`PATCH /streams/{id}` (Protected)
|
|
||||||
|
|
||||||
Updates a stream's name and/or description. Status is not affected (use the open/close endpoints instead). Only provided fields are updated.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "New Name",
|
|
||||||
"description": "New description"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Both fields are optional — omit a field to leave it unchanged
|
|
||||||
- `name` cannot be empty if provided
|
|
||||||
|
|
||||||
**Response:** returns the updated [Stream](#stream-1) object.
|
|
||||||
|
|
||||||
### Create Stream Particle
|
|
||||||
`POST /streams/{id}/particles` (Protected)
|
|
||||||
|
|
||||||
Creates a child particle inside a stream. Child particles inherit visibility from the stream.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "text|media|file|quest|paper",
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Cannot create `stream` or `folder` types as children
|
|
||||||
- For `media` and `file` types, `data` must include a valid `object_id` from depot
|
|
||||||
|
|
||||||
**Response:** `201 Created` — returns a [StreamParticle](#streamparticle) object.
|
|
||||||
|
|
||||||
### Open Stream
|
|
||||||
`POST /streams/{id}/open` (Protected)
|
|
||||||
|
|
||||||
Opens a closed stream. Fails with `409` if capacity would be exceeded.
|
|
||||||
|
|
||||||
### Close Stream
|
|
||||||
`POST /streams/{id}/close` (Protected)
|
|
||||||
|
|
||||||
Closes an open stream.
|
|
||||||
|
|
||||||
### Add Members to Stream
|
|
||||||
`POST /streams/{id}/members` (Protected)
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"emails": ["user@example.com"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remove Members from Stream
|
|
||||||
`DELETE /streams/{id}/members` (Protected)
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"emails": ["user@example.com"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Particles
|
|
||||||
|
|
||||||
### List Particles
|
|
||||||
`GET /networks/{network_id}/particles` (Protected)
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `parent_id` (optional): Filter by parent particle
|
|
||||||
- `cursor` (optional): Pagination cursor
|
|
||||||
- `direction` (optional): `after` or `before` (default: `after`)
|
|
||||||
- `type` (optional, repeatable): Filter by particle type
|
|
||||||
|
|
||||||
**Response enrichment:**
|
|
||||||
Each particle in the response includes:
|
|
||||||
- `seen` (boolean): Whether the requester has marked this particle as seen
|
|
||||||
- `acks` (array): List of acknowledgments `[{email, acked_at}]`
|
|
||||||
- `unseen_count` (integer, streams only): Count of unseen child particles
|
|
||||||
|
|
||||||
### Get Particle
|
|
||||||
`GET /particles/{id}` (Protected)
|
|
||||||
|
|
||||||
### Update Particle
|
|
||||||
`PATCH /particles/{id}` (Protected)
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Particle
|
|
||||||
`DELETE /particles/{id}` (Protected)
|
|
||||||
|
|
||||||
Deletes the particle and all children. If it references a depot object, that is also deleted.
|
|
||||||
|
|
||||||
### Download Particle
|
|
||||||
`GET /particles/{id}/download` (Protected)
|
`GET /particles/{id}/download` (Protected)
|
||||||
|
|
||||||
|
For now, the `{id}` should be an object id. Not the particle id.
|
||||||
|
|
||||||
Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles.
|
Returns a `302` redirect to a signed download URL. Only works for `media` and `file` particles.
|
||||||
|
|
||||||
### Mark Seen
|
|
||||||
`POST /particles/{id}/seen` (Protected)
|
|
||||||
|
|
||||||
Marks a particle as seen by the requester. This is private state, only visible to the requester.
|
|
||||||
|
|
||||||
Returns `204 No Content` on success.
|
|
||||||
|
|
||||||
### Mark Seen (Batch)
|
|
||||||
`POST /particles/seen` (Protected)
|
|
||||||
|
|
||||||
Marks multiple particles as seen by the requester.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"particle_ids": ["particle_uuid1", "particle_uuid2"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns `204 No Content` on success.
|
|
||||||
|
|
||||||
### Acknowledge Particle
|
|
||||||
`POST /particles/{id}/ack` (Protected)
|
|
||||||
|
|
||||||
Acknowledges a particle. Acknowledgments are public and permanent, visible to all users with access. Also marks the particle as seen.
|
|
||||||
|
|
||||||
Returns `204 No Content` on success.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Depot (File Storage)
|
## Depot (File Storage)
|
||||||
@@ -395,8 +179,6 @@ Confirms that an upload has been completed.
|
|||||||
| `name` | `string` | Display name of the network. |
|
| `name` | `string` | Display name of the network. |
|
||||||
| `admin_human` | `Human` | The network administrator. |
|
| `admin_human` | `Human` | The network administrator. |
|
||||||
| `humans` | `Human[]` | All members of the network (including admin). |
|
| `humans` | `Human[]` | All members of the network (including admin). |
|
||||||
| `open_stream_count` | `integer` | Number of currently open streams. |
|
|
||||||
| `open_stream_capacity` | `integer` | Maximum number of concurrent open streams (default: 5). |
|
|
||||||
| `created_at` | `string` | ISO 8601 timestamp. |
|
| `created_at` | `string` | ISO 8601 timestamp. |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -407,86 +189,10 @@ Confirms that an upload has been completed.
|
|||||||
"humans": [
|
"humans": [
|
||||||
{ "id": "abc-123", "email": "alice@example.com", "email_prefix": "alice", "created_at": "..." }
|
{ "id": "abc-123", "email": "alice@example.com", "email_prefix": "alice", "created_at": "..." }
|
||||||
],
|
],
|
||||||
"open_stream_count": 2,
|
|
||||||
"open_stream_capacity": 5,
|
|
||||||
"created_at": "2025-01-15T10:30:00Z"
|
"created_at": "2025-01-15T10:30:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stream
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `id` | `string` | Unique identifier (this is a particle ID). |
|
|
||||||
| `name` | `string` | Stream name. |
|
|
||||||
| `description` | `string` | Stream description. |
|
|
||||||
| `status` | `string` | `"open"`, `"closed"`, or `"unspecified"`. |
|
|
||||||
| `members` | `string[]` | Emails of stream members. Omitted for `network_all` visibility. |
|
|
||||||
| `particles` | `StreamParticle[]` | Child particles in the stream. |
|
|
||||||
| `unseen_count` | `integer` | Number of unseen child particles for the requester. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "p-001",
|
|
||||||
"name": "Sprint Planning",
|
|
||||||
"description": "Weekly sync",
|
|
||||||
"status": "open",
|
|
||||||
"members": ["alice@example.com"],
|
|
||||||
"particles": [],
|
|
||||||
"unseen_count": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### StreamParticle
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `id` | `string` | Unique identifier. |
|
|
||||||
| `type` | `string` | One of: `media`, `file`, `text`, `quest`, `paper`. |
|
|
||||||
| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
|
|
||||||
| `created_by_email` | `string` | Email of the creator. |
|
|
||||||
| `seen` | `boolean` | Whether the requester has seen this particle. |
|
|
||||||
| `acks` | `AckInfo[]` | Acknowledgments from users. |
|
|
||||||
| `updated_at` | `string` | ISO 8601 timestamp. |
|
|
||||||
| `created_at` | `string` | ISO 8601 timestamp. |
|
|
||||||
|
|
||||||
### Particle
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `id` | `string` | Unique identifier. |
|
|
||||||
| `type` | `string` | One of: `stream`, `folder`, `media`, `file`, `text`, `quest`, `paper`. |
|
|
||||||
| `network_id` | `string` | The network this particle belongs to. |
|
|
||||||
| `parent_id` | `string \| null` | Parent particle ID, if nested. |
|
|
||||||
| `created_by_email` | `string` | Email of the creator. |
|
|
||||||
| `visibility` | `string` | `"network_all"`, `"custom"`, or `"inherited"`. |
|
|
||||||
| `stream_status` | `string \| null` | Only on `stream` type: `"open"` or `"closed"`. |
|
|
||||||
| `data` | `object` | Type-specific payload (see [Particle Data by Type](#particle-data-by-type)). |
|
|
||||||
| `download_url` | `string \| null` | Signed download URL. Only on `media`/`file` particles. |
|
|
||||||
| `seen` | `boolean \| null` | Whether the requester has seen this particle. Only in list responses. |
|
|
||||||
| `acks` | `AckInfo[]` | Acknowledgments. Only in list responses. |
|
|
||||||
| `unseen_count` | `integer \| null` | Unseen child count. Only on `stream` particles in list responses. |
|
|
||||||
| `updated_at` | `string` | ISO 8601 timestamp. |
|
|
||||||
| `created_at` | `string` | ISO 8601 timestamp. |
|
|
||||||
|
|
||||||
### AckInfo
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `email` | `string` | Email of the user who acknowledged. |
|
|
||||||
| `acked_at` | `string` | ISO 8601 timestamp of the acknowledgment. |
|
|
||||||
|
|
||||||
### ParticleList
|
|
||||||
|
|
||||||
Returned by `GET /networks/{network_id}/particles`.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| `particles` | `Particle[]` | Array of enriched particle objects. |
|
|
||||||
| `has_more` | `boolean` | Whether more results exist beyond this page. |
|
|
||||||
| `next_cursor` | `string \| null` | Cursor to fetch the next page. |
|
|
||||||
| `prev_cursor` | `string \| null` | Cursor to fetch the previous page. |
|
|
||||||
|
|
||||||
### DepotObject
|
### DepotObject
|
||||||
|
|
||||||
Returned by `POST /depot/objects/{id}/confirm`.
|
Returned by `POST /depot/objects/{id}/confirm`.
|
||||||
@@ -591,20 +297,6 @@ The `data` field on a Particle is a JSON object whose schema depends on the part
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Visibility
|
|
||||||
|
|
||||||
Particles support three visibility modes:
|
|
||||||
|
|
||||||
| Mode | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `network_all` | Visible to all network members. |
|
|
||||||
| `custom` | Visible only to specified members (requires `members` list). |
|
|
||||||
| `inherited` | Inherits visibility from parent particle. Used for child particles in streams. |
|
|
||||||
|
|
||||||
Root-level particles (streams, folders) use `network_all` or `custom`. Child particles created via `POST /streams/{id}/particles` automatically use `inherited`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return standard HTTP status codes with plain text error bodies:
|
All endpoints return standard HTTP status codes with plain text error bodies:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// Per-human membership mirror maintained by Orion's service account.
|
||||||
|
// Locked from clients so the membership list cannot leak or be mutated.
|
||||||
|
match /humans/{humanId} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Particles: caller must be a current member of the network in the path.
|
||||||
|
// get() on the mirror is cached across rule evaluations within a single
|
||||||
|
// request, so this costs 1 billed read per request regardless of query size.
|
||||||
|
match /networks/{networkId}/children/{particleId=**} {
|
||||||
|
allow read, write: if request.auth != null
|
||||||
|
&& networkId in get(/databases/$(database)/documents/humans/$(request.auth.uid)).data.networks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
resource "google_firebase_project" "default" {
|
||||||
|
provider = google-beta
|
||||||
|
project = var.project_id
|
||||||
|
|
||||||
|
depends_on = [module.project-services]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Provisions the Firestore database instance.
|
||||||
|
resource "google_firestore_database" "firestore" {
|
||||||
|
provider = google-beta
|
||||||
|
project = google_firebase_project.default.project
|
||||||
|
name = "(default)"
|
||||||
|
# See available locations: https://firebase.google.com/docs/firestore/locations
|
||||||
|
location_id = var.location
|
||||||
|
# "FIRESTORE_NATIVE" is required to use Firestore with Firebase SDKs, authentication, and Firebase Security Rules.
|
||||||
|
type = "FIRESTORE_NATIVE"
|
||||||
|
concurrency_mode = "OPTIMISTIC"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firebaserules_ruleset" "default" {
|
||||||
|
provider = google-beta
|
||||||
|
project = google_firestore_database.firestore.project
|
||||||
|
source {
|
||||||
|
files {
|
||||||
|
name = "firestore.rules"
|
||||||
|
# Write security rules in a local file named "firestore.rules".
|
||||||
|
# Learn more: https://firebase.google.com/docs/firestore/security/get-started
|
||||||
|
content = file("firestore.rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firebaserules_release" "firestore" {
|
||||||
|
provider = google-beta
|
||||||
|
name = "cloud.firestore"
|
||||||
|
ruleset_name = google_firebaserules_ruleset.default.name
|
||||||
|
project = google_firestore_database.firestore.project
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firebase_web_app" "llink" {
|
||||||
|
provider = google-beta
|
||||||
|
project = google_firebase_project.default.project
|
||||||
|
display_name = "Llink app"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "google_firebase_web_app_config" "llink" {
|
||||||
|
provider = google-beta
|
||||||
|
project = var.project_id
|
||||||
|
web_app_id = google_firebase_web_app.llink.app_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below indices support the new structure where all subcollections (including network root particles) live under a collection of name "children"
|
||||||
|
// instead of having `network/n_xxx/particles/p_yyy/children` where the top level subcollection of particles has a distinct name
|
||||||
|
|
||||||
|
// Collection index for particles in a single subcollection
|
||||||
|
resource "google_firestore_index" "list_particles_index" {
|
||||||
|
project = var.project_id
|
||||||
|
collection = "children"
|
||||||
|
query_scope = "COLLECTION"
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "visible_to"
|
||||||
|
array_config = "CONTAINS"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "status"
|
||||||
|
order = "ASCENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "last_child_created_at"
|
||||||
|
order = "DESCENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields {
|
||||||
|
field_path = "__name__"
|
||||||
|
order = "DESCENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [google_firestore_database.firestore]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection group index for particles across all subcollections
|
||||||
|
resource "google_firestore_field" "particle_children_created_at" {
|
||||||
|
database = google_firestore_database.firestore.name
|
||||||
|
project = var.project_id
|
||||||
|
collection = "children"
|
||||||
|
field = "created_at"
|
||||||
|
|
||||||
|
index_config {
|
||||||
|
indexes {
|
||||||
|
order = "DESCENDING"
|
||||||
|
query_scope = "COLLECTION_GROUP"
|
||||||
|
}
|
||||||
|
indexes {
|
||||||
|
order = "ASCENDING"
|
||||||
|
query_scope = "COLLECTION_GROUP"
|
||||||
|
}
|
||||||
|
indexes {
|
||||||
|
order = "DESCENDING"
|
||||||
|
query_scope = "COLLECTION"
|
||||||
|
}
|
||||||
|
indexes {
|
||||||
|
order = "ASCENDING"
|
||||||
|
query_scope = "COLLECTION"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.35.1
|
||||||
|
// protoc v5.28.3
|
||||||
|
// source: llink/pusher/pusher.proto
|
||||||
|
|
||||||
|
package pbpusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetOnlineHumanIdsRequest struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsRequest) Reset() {
|
||||||
|
*x = GetOnlineHumanIdsRequest{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetOnlineHumanIdsRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetOnlineHumanIdsRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetOnlineHumanIdsRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOnlineHumanIdsResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsResponse) Reset() {
|
||||||
|
*x = GetOnlineHumanIdsResponse{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetOnlineHumanIdsResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetOnlineHumanIdsResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetOnlineHumanIdsResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetOnlineHumanIdsResponse) GetHumanIds() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.HumanIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsOnlineRequest struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IsOnlineRequest) Reset() {
|
||||||
|
*x = IsOnlineRequest{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IsOnlineRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*IsOnlineRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *IsOnlineRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use IsOnlineRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*IsOnlineRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IsOnlineRequest) GetHumanIds() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.HumanIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IsOnlineResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Online map[string]bool `protobuf:"bytes,1,rep,name=online,proto3" json:"online,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IsOnlineResponse) Reset() {
|
||||||
|
*x = IsOnlineResponse{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IsOnlineResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*IsOnlineResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *IsOnlineResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use IsOnlineResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*IsOnlineResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *IsOnlineResponse) GetOnline() map[string]bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Online
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetChannelPresenceRequest struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
ChannelIds []string `protobuf:"bytes,1,rep,name=channel_ids,json=channelIds,proto3" json:"channel_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceRequest) Reset() {
|
||||||
|
*x = GetChannelPresenceRequest{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetChannelPresenceRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetChannelPresenceRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetChannelPresenceRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceRequest) GetChannelIds() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ChannelIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetChannelPresenceResponse struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Presences map[string]*ChannelPresence `protobuf:"bytes,1,rep,name=presences,proto3" json:"presences,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceResponse) Reset() {
|
||||||
|
*x = GetChannelPresenceResponse{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetChannelPresenceResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetChannelPresenceResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetChannelPresenceResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetChannelPresenceResponse) GetPresences() map[string]*ChannelPresence {
|
||||||
|
if x != nil {
|
||||||
|
return x.Presences
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelPresence struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
HumanIds []string `protobuf:"bytes,1,rep,name=human_ids,json=humanIds,proto3" json:"human_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ChannelPresence) Reset() {
|
||||||
|
*x = ChannelPresence{}
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ChannelPresence) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ChannelPresence) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ChannelPresence) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llink_pusher_pusher_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ChannelPresence.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ChannelPresence) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ChannelPresence) GetHumanIds() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.HumanIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_llink_pusher_pusher_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_llink_pusher_pusher_proto_rawDesc = []byte{
|
||||||
|
0x0a, 0x19, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2f, 0x70,
|
||||||
|
0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x6c, 0x6c, 0x69,
|
||||||
|
0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x22, 0x1a, 0x0a, 0x18, 0x47, 0x65, 0x74,
|
||||||
|
0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65,
|
||||||
|
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x38, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69,
|
||||||
|
0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||||
|
0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18,
|
||||||
|
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x22,
|
||||||
|
0x2e, 0x0a, 0x0f, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||||
|
0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x5f, 0x69, 0x64, 0x73, 0x18,
|
||||||
|
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x22,
|
||||||
|
0x91, 0x01, 0x0a, 0x10, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70,
|
||||||
|
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01,
|
||||||
|
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73,
|
||||||
|
0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52, 0x65, 0x73, 0x70,
|
||||||
|
0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
||||||
|
0x52, 0x06, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x1a, 0x39, 0x0a, 0x0b, 0x4f, 0x6e, 0x6c, 0x69,
|
||||||
|
0x6e, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
|
||||||
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
|
||||||
|
0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
|
||||||
|
0x02, 0x38, 0x01, 0x22, 0x3c, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
|
||||||
|
0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||||
|
0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x73, 0x18,
|
||||||
|
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x64,
|
||||||
|
0x73, 0x22, 0xd0, 0x01, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
|
||||||
|
0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||||
|
0x12, 0x55, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20,
|
||||||
|
0x03, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68,
|
||||||
|
0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65,
|
||||||
|
0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x72,
|
||||||
|
0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x70, 0x72,
|
||||||
|
0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x1a, 0x5b, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x73, 0x65,
|
||||||
|
0x6e, 0x63, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79,
|
||||||
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76,
|
||||||
|
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6c, 0x6c, 0x69,
|
||||||
|
0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
|
||||||
|
0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
|
||||||
|
0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x0f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50,
|
||||||
|
0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x75, 0x6d, 0x61, 0x6e,
|
||||||
|
0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x68, 0x75, 0x6d, 0x61,
|
||||||
|
0x6e, 0x49, 0x64, 0x73, 0x32, 0xa9, 0x02, 0x0a, 0x0d, 0x50, 0x75, 0x73, 0x68, 0x65, 0x72, 0x53,
|
||||||
|
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x64, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c,
|
||||||
|
0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x12, 0x26, 0x2e, 0x6c, 0x6c,
|
||||||
|
0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e,
|
||||||
|
0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61, 0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x71, 0x75,
|
||||||
|
0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68,
|
||||||
|
0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x48, 0x75, 0x6d, 0x61,
|
||||||
|
0x6e, 0x49, 0x64, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x08,
|
||||||
|
0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x1d, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b,
|
||||||
|
0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65,
|
||||||
|
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e,
|
||||||
|
0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x49, 0x73, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x52,
|
||||||
|
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x43, 0x68,
|
||||||
|
0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x27, 0x2e,
|
||||||
|
0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74,
|
||||||
|
0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52,
|
||||||
|
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x70,
|
||||||
|
0x75, 0x73, 0x68, 0x65, 0x72, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c,
|
||||||
|
0x50, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||||
|
0x42, 0x3c, 0x5a, 0x3a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x66,
|
||||||
|
0x6c, 0x6f, 0x77, 0x79, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2f, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f,
|
||||||
|
0x67, 0x65, 0x6e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6c, 0x6c, 0x69, 0x6e, 0x6b, 0x2f, 0x70,
|
||||||
|
0x75, 0x73, 0x68, 0x65, 0x72, 0x3b, 0x70, 0x62, 0x70, 0x75, 0x73, 0x68, 0x65, 0x72, 0x62, 0x06,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_llink_pusher_pusher_proto_rawDescOnce sync.Once
|
||||||
|
file_llink_pusher_pusher_proto_rawDescData = file_llink_pusher_pusher_proto_rawDesc
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_llink_pusher_pusher_proto_rawDescGZIP() []byte {
|
||||||
|
file_llink_pusher_pusher_proto_rawDescOnce.Do(func() {
|
||||||
|
file_llink_pusher_pusher_proto_rawDescData = protoimpl.X.CompressGZIP(file_llink_pusher_pusher_proto_rawDescData)
|
||||||
|
})
|
||||||
|
return file_llink_pusher_pusher_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_llink_pusher_pusher_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||||
|
var file_llink_pusher_pusher_proto_goTypes = []any{
|
||||||
|
(*GetOnlineHumanIdsRequest)(nil), // 0: llink.pusher.GetOnlineHumanIdsRequest
|
||||||
|
(*GetOnlineHumanIdsResponse)(nil), // 1: llink.pusher.GetOnlineHumanIdsResponse
|
||||||
|
(*IsOnlineRequest)(nil), // 2: llink.pusher.IsOnlineRequest
|
||||||
|
(*IsOnlineResponse)(nil), // 3: llink.pusher.IsOnlineResponse
|
||||||
|
(*GetChannelPresenceRequest)(nil), // 4: llink.pusher.GetChannelPresenceRequest
|
||||||
|
(*GetChannelPresenceResponse)(nil), // 5: llink.pusher.GetChannelPresenceResponse
|
||||||
|
(*ChannelPresence)(nil), // 6: llink.pusher.ChannelPresence
|
||||||
|
nil, // 7: llink.pusher.IsOnlineResponse.OnlineEntry
|
||||||
|
nil, // 8: llink.pusher.GetChannelPresenceResponse.PresencesEntry
|
||||||
|
}
|
||||||
|
var file_llink_pusher_pusher_proto_depIdxs = []int32{
|
||||||
|
7, // 0: llink.pusher.IsOnlineResponse.online:type_name -> llink.pusher.IsOnlineResponse.OnlineEntry
|
||||||
|
8, // 1: llink.pusher.GetChannelPresenceResponse.presences:type_name -> llink.pusher.GetChannelPresenceResponse.PresencesEntry
|
||||||
|
6, // 2: llink.pusher.GetChannelPresenceResponse.PresencesEntry.value:type_name -> llink.pusher.ChannelPresence
|
||||||
|
0, // 3: llink.pusher.PusherService.GetOnlineHumanIds:input_type -> llink.pusher.GetOnlineHumanIdsRequest
|
||||||
|
2, // 4: llink.pusher.PusherService.IsOnline:input_type -> llink.pusher.IsOnlineRequest
|
||||||
|
4, // 5: llink.pusher.PusherService.GetChannelPresence:input_type -> llink.pusher.GetChannelPresenceRequest
|
||||||
|
1, // 6: llink.pusher.PusherService.GetOnlineHumanIds:output_type -> llink.pusher.GetOnlineHumanIdsResponse
|
||||||
|
3, // 7: llink.pusher.PusherService.IsOnline:output_type -> llink.pusher.IsOnlineResponse
|
||||||
|
5, // 8: llink.pusher.PusherService.GetChannelPresence:output_type -> llink.pusher.GetChannelPresenceResponse
|
||||||
|
6, // [6:9] is the sub-list for method output_type
|
||||||
|
3, // [3:6] is the sub-list for method input_type
|
||||||
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_llink_pusher_pusher_proto_init() }
|
||||||
|
func file_llink_pusher_pusher_proto_init() {
|
||||||
|
if File_llink_pusher_pusher_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: file_llink_pusher_pusher_proto_rawDesc,
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 9,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_llink_pusher_pusher_proto_goTypes,
|
||||||
|
DependencyIndexes: file_llink_pusher_pusher_proto_depIdxs,
|
||||||
|
MessageInfos: file_llink_pusher_pusher_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_llink_pusher_pusher_proto = out.File
|
||||||
|
file_llink_pusher_pusher_proto_rawDesc = nil
|
||||||
|
file_llink_pusher_pusher_proto_goTypes = nil
|
||||||
|
file_llink_pusher_pusher_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v5.28.3
|
||||||
|
// source: llink/pusher/pusher.proto
|
||||||
|
|
||||||
|
package pbpusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
PusherService_GetOnlineHumanIds_FullMethodName = "/llink.pusher.PusherService/GetOnlineHumanIds"
|
||||||
|
PusherService_IsOnline_FullMethodName = "/llink.pusher.PusherService/IsOnline"
|
||||||
|
PusherService_GetChannelPresence_FullMethodName = "/llink.pusher.PusherService/GetChannelPresence"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PusherServiceClient is the client API for PusherService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
type PusherServiceClient interface {
|
||||||
|
// Returns all currently connected human IDs.
|
||||||
|
GetOnlineHumanIds(ctx context.Context, in *GetOnlineHumanIdsRequest, opts ...grpc.CallOption) (*GetOnlineHumanIdsResponse, error)
|
||||||
|
// Returns whether specific humans are currently online.
|
||||||
|
IsOnline(ctx context.Context, in *IsOnlineRequest, opts ...grpc.CallOption) (*IsOnlineResponse, error)
|
||||||
|
// Returns presence (human IDs) for specific channels.
|
||||||
|
GetChannelPresence(ctx context.Context, in *GetChannelPresenceRequest, opts ...grpc.CallOption) (*GetChannelPresenceResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pusherServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPusherServiceClient(cc grpc.ClientConnInterface) PusherServiceClient {
|
||||||
|
return &pusherServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pusherServiceClient) GetOnlineHumanIds(ctx context.Context, in *GetOnlineHumanIdsRequest, opts ...grpc.CallOption) (*GetOnlineHumanIdsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetOnlineHumanIdsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PusherService_GetOnlineHumanIds_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pusherServiceClient) IsOnline(ctx context.Context, in *IsOnlineRequest, opts ...grpc.CallOption) (*IsOnlineResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(IsOnlineResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PusherService_IsOnline_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pusherServiceClient) GetChannelPresence(ctx context.Context, in *GetChannelPresenceRequest, opts ...grpc.CallOption) (*GetChannelPresenceResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetChannelPresenceResponse)
|
||||||
|
err := c.cc.Invoke(ctx, PusherService_GetChannelPresence_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PusherServiceServer is the server API for PusherService service.
|
||||||
|
// All implementations must embed UnimplementedPusherServiceServer
|
||||||
|
// for forward compatibility.
|
||||||
|
type PusherServiceServer interface {
|
||||||
|
// Returns all currently connected human IDs.
|
||||||
|
GetOnlineHumanIds(context.Context, *GetOnlineHumanIdsRequest) (*GetOnlineHumanIdsResponse, error)
|
||||||
|
// Returns whether specific humans are currently online.
|
||||||
|
IsOnline(context.Context, *IsOnlineRequest) (*IsOnlineResponse, error)
|
||||||
|
// Returns presence (human IDs) for specific channels.
|
||||||
|
GetChannelPresence(context.Context, *GetChannelPresenceRequest) (*GetChannelPresenceResponse, error)
|
||||||
|
mustEmbedUnimplementedPusherServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedPusherServiceServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedPusherServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedPusherServiceServer) GetOnlineHumanIds(context.Context, *GetOnlineHumanIdsRequest) (*GetOnlineHumanIdsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetOnlineHumanIds not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPusherServiceServer) IsOnline(context.Context, *IsOnlineRequest) (*IsOnlineResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method IsOnline not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPusherServiceServer) GetChannelPresence(context.Context, *GetChannelPresenceRequest) (*GetChannelPresenceResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetChannelPresence not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedPusherServiceServer) mustEmbedUnimplementedPusherServiceServer() {}
|
||||||
|
func (UnimplementedPusherServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafePusherServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to PusherServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafePusherServiceServer interface {
|
||||||
|
mustEmbedUnimplementedPusherServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterPusherServiceServer(s grpc.ServiceRegistrar, srv PusherServiceServer) {
|
||||||
|
// If the following call pancis, it indicates UnimplementedPusherServiceServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&PusherService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PusherService_GetOnlineHumanIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetOnlineHumanIdsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PusherServiceServer).GetOnlineHumanIds(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PusherService_GetOnlineHumanIds_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PusherServiceServer).GetOnlineHumanIds(ctx, req.(*GetOnlineHumanIdsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PusherService_IsOnline_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(IsOnlineRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PusherServiceServer).IsOnline(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PusherService_IsOnline_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PusherServiceServer).IsOnline(ctx, req.(*IsOnlineRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _PusherService_GetChannelPresence_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetChannelPresenceRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(PusherServiceServer).GetChannelPresence(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: PusherService_GetChannelPresence_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(PusherServiceServer).GetChannelPresence(ctx, req.(*GetChannelPresenceRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PusherService_ServiceDesc is the grpc.ServiceDesc for PusherService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var PusherService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "llink.pusher.PusherService",
|
||||||
|
HandlerType: (*PusherServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "GetOnlineHumanIds",
|
||||||
|
Handler: _PusherService_GetOnlineHumanIds_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "IsOnline",
|
||||||
|
Handler: _PusherService_IsOnline_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetChannelPresence",
|
||||||
|
Handler: _PusherService_GetChannelPresence_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "llink/pusher/pusher.proto",
|
||||||
|
}
|
||||||
@@ -1,108 +1,189 @@
|
|||||||
module github.com/flowy-live/llink
|
module github.com/flowy-live/llink
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/firestore v1.21.0
|
||||||
cloud.google.com/go/storage v1.59.1
|
cloud.google.com/go/storage v1.59.1
|
||||||
|
firebase.google.com/go/v4 v4.19.0
|
||||||
|
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.7.2
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
github.com/livekit/protocol v1.45.1
|
||||||
|
github.com/livekit/server-sdk-go/v2 v2.16.1
|
||||||
github.com/redis/go-redis/v9 v9.17.2
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/stripe/stripe-go/v85 v85.0.1
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0
|
github.com/testcontainers/testcontainers-go v0.40.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||||
go.jetify.com/typeid v1.3.0
|
go.jetify.com/typeid v1.3.0
|
||||||
google.golang.org/grpc v1.78.0
|
go.uber.org/mock v0.6.0
|
||||||
|
google.golang.org/api v0.256.0
|
||||||
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cel.dev/expr v0.24.0 // indirect
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
|
||||||
|
buf.build/go/protovalidate v1.1.2 // indirect
|
||||||
|
buf.build/go/protoyaml v0.6.0 // indirect
|
||||||
|
cel.dev/expr v0.25.1 // indirect
|
||||||
cloud.google.com/go v0.123.0 // indirect
|
cloud.google.com/go v0.123.0 // indirect
|
||||||
cloud.google.com/go/auth v0.17.0 // indirect
|
cloud.google.com/go/auth v0.17.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
cloud.google.com/go/iam v1.5.3 // indirect
|
cloud.google.com/go/iam v1.5.3 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||||
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||||
|
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/platforms v1.0.0-rc.2 // indirect
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dennwc/iters v1.2.2 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/dvonthenen/websocket v1.5.1-dyv.2 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/frostbyte73/core v0.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/gammazero/deque v1.2.1 // indirect
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/gofrs/uuid/v5 v5.2.0 // indirect
|
github.com/gofrs/uuid/v5 v5.2.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/cel-go v0.27.0 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
|
github.com/gorilla/schema v1.3.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
|
||||||
|
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/jxskiss/base62 v1.1.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
||||||
|
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 // indirect
|
||||||
|
github.com/livekit/psrpc v0.7.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/magefile/mage v1.15.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.1.0 // indirect
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/pion/datachannel v1.6.0 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.2.0 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.44 // indirect
|
||||||
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
|
github.com/pion/rtp v1.10.1 // indirect
|
||||||
|
github.com/pion/sctp v1.9.2 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.17 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||||
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
|
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||||
|
github.com/pion/webrtc/v4 v4.2.6 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
|
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.64.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.1 // indirect
|
||||||
|
go.uber.org/zap/exp v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/api v0.256.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
|
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
k8s.io/klog/v2 v2.110.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool go.uber.org/mock/mockgen
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
|
||||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
|
||||||
|
buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0=
|
||||||
|
buf.build/go/protovalidate v1.1.2/go.mod h1:Ez3z+w4c+wG+EpW8ovgZaZPnPl2XVF6kaxgcv1NG/QE=
|
||||||
|
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
|
||||||
|
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
|
||||||
|
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||||
|
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||||
@@ -8,6 +14,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
|||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
|
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
|
||||||
|
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
|
||||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||||
@@ -22,10 +30,12 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4
|
|||||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
|
||||||
|
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||||
@@ -34,88 +44,149 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
|
|||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||||
|
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
|
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||||
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||||
|
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||||
|
github.com/benbjohnson/clock v1.3.5/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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
|
||||||
|
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||||
|
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
||||||
|
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0 h1:ug48j1DVNRKrkXti18/aFT3NP5HV2Q2CN3QMwTvHmy4=
|
||||||
|
github.com/deepgram/deepgram-go-sdk/v3 v3.5.0/go.mod h1:wVr0PDvlJFWVLUmf65u+K80SJVf/PUWvkFFubGPW/As=
|
||||||
|
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
|
||||||
|
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/cli v29.0.0+incompatible h1:KgsN2RUFMNM8wChxryicn4p46BdQWpXOA1XLGBGPGAw=
|
||||||
|
github.com/docker/cli v29.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||||
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/dvonthenen/websocket v1.5.1-dyv.2 h1:OXlWJJkeHt8k4+MEI0Y8SQjY2ihHYD2z/tI7sZZfsnA=
|
||||||
|
github.com/dvonthenen/websocket v1.5.1-dyv.2/go.mod h1:q2GbopbpFJvBP4iqVvqwwahVmvu2HnCfdqCWDoQVKMM=
|
||||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||||
|
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
|
||||||
|
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||||
|
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM=
|
github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM=
|
||||||
github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
|
||||||
|
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||||
|
github.com/gorilla/schema v1.3.0 h1:rbciOzXAx3IB8stEFnfTwO3sYa6EWlQk79XdyustPDA=
|
||||||
|
github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||||
|
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
|
||||||
|
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -124,24 +195,51 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
|||||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
|
||||||
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||||
|
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
|
||||||
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
|
||||||
|
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA=
|
||||||
|
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A=
|
||||||
|
github.com/livekit/protocol v1.45.1 h1:4cbynsPZW32gS2z6nUWfAfr4YaTUwZSKUiLpSpjX+lQ=
|
||||||
|
github.com/livekit/protocol v1.45.1/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0=
|
||||||
|
github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw=
|
||||||
|
github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk=
|
||||||
|
github.com/livekit/server-sdk-go/v2 v2.16.1 h1:ZkIA9OdVvQ6Up1uW/RtQ0YJUgYMJ6+ywOmDg0jX7bTg=
|
||||||
|
github.com/livekit/server-sdk-go/v2 v2.16.1/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||||
|
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
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/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||||
|
github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg=
|
||||||
|
github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
|
||||||
|
github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8=
|
||||||
|
github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE=
|
||||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||||
@@ -152,14 +250,60 @@ github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
|||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4=
|
||||||
|
github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI=
|
||||||
|
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
|
||||||
|
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
|
||||||
|
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||||
|
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||||
|
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||||
|
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||||
|
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
|
||||||
|
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
|
||||||
|
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
|
||||||
|
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
|
||||||
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||||
|
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||||
|
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||||
|
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
|
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
|
||||||
|
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
|
||||||
|
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
|
||||||
|
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||||
|
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||||
|
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||||
|
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||||
|
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||||
|
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||||
|
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||||
|
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||||
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
|
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||||
|
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.6 h1:e9H/du7PbYA2qMJkqKp9Ou2z5Igb/6qbKSeEeUCVv0M=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.6/go.mod h1:+GAy0jwidoZAHsgjsx77sH09spnV0YWjpB3ROAXmz5A=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
@@ -169,12 +313,26 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
|
||||||
|
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||||
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
|
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
|
||||||
|
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||||
|
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||||
|
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||||
@@ -186,6 +344,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stripe/stripe-go/v85 v85.0.1 h1:vlIo5VHrR9GkYneH5D9YGOPwNDRD6LW/THhtx9zNs6M=
|
||||||
|
github.com/stripe/stripe-go/v85 v85.0.1/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||||
@@ -194,71 +354,149 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
|
|||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||||
|
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||||
|
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||||
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY=
|
go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY=
|
||||||
go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk=
|
go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||||
|
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||||
|
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||||
|
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
|
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
|
||||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
|
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -271,3 +509,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
|
||||||
|
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
|
||||||
|
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||||
|
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionReader interface {
|
||||||
|
// GetSession returns ErrSessionNotFound if no valid session.
|
||||||
|
GetSession(ctx context.Context, sessionToken string) (*Session, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionReaderImpl struct {
|
||||||
|
redisClient *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposes the concrete type so authServiceImpl can embed it without
|
||||||
|
// hiding redisClient behind the SessionReader interface.
|
||||||
|
func newSessionReader(redisClient *redis.Client) *sessionReaderImpl {
|
||||||
|
return &sessionReaderImpl{redisClient: redisClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionReader(redisClient *redis.Client) SessionReader {
|
||||||
|
return newSessionReader(redisClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sessionReaderImpl) GetSession(ctx context.Context, token string) (*Session, error) {
|
||||||
|
sessionInfo, err := r.redisClient.Get(ctx, token).Result()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return nil, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("error getting session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var session Session
|
||||||
|
err = json.Unmarshal([]byte(sessionInfo), &session)
|
||||||
|
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
+54
-36
@@ -2,16 +2,20 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/genproto/aero"
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
|
||||||
|
firebaseauth "firebase.google.com/go/v4/auth"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.jetify.com/typeid"
|
"go.jetify.com/typeid"
|
||||||
|
|
||||||
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -37,34 +41,54 @@ func newSessionToken() (sessionToken, error) {
|
|||||||
return typeid.New[sessionToken]()
|
return typeid.New[sessionToken]()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
HumanId string `json:"human_id"`
|
||||||
|
}
|
||||||
|
|
||||||
type AuthService interface {
|
type AuthService interface {
|
||||||
// RequestSignInCode generates a code and emails it to the provided email.
|
SessionReader
|
||||||
// To retrieve a session, client must verify with VerifySignInCode.
|
|
||||||
|
// RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
|
||||||
RequestSignInCode(ctx context.Context, email string) error
|
RequestSignInCode(ctx context.Context, email string) error
|
||||||
// VerifySignInCode returns ErrInvalidCode if incorrect code
|
// VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
|
||||||
VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error)
|
// a session keyed to (email, humanId).
|
||||||
// GetSession returns ErrSessionNotFound if no valid session
|
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
|
||||||
GetSession(ctx context.Context, sessionToken string) (email string, err error)
|
// ExtendSession returns ErrSessionNotFound if no valid session.
|
||||||
// ExtendSession returns ErrSessionNotFound if no valid session
|
|
||||||
ExtendSession(ctx context.Context, sessionToken string) error
|
ExtendSession(ctx context.Context, sessionToken string) error
|
||||||
SignOut(ctx context.Context, sessionToken string) error
|
SignOut(ctx context.Context, sessionToken string) error
|
||||||
|
|
||||||
|
// MintFirebaseCustomToken issues a Firebase custom token with uid=humanId and no claims.
|
||||||
|
MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error)
|
||||||
|
|
||||||
IsSystemAdmin(ctx context.Context, email string) bool
|
IsSystemAdmin(ctx context.Context, email string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type authServiceImpl struct {
|
type authServiceImpl struct {
|
||||||
redisClient *redis.Client
|
*sessionReaderImpl
|
||||||
aeroSvc pbaero.PrimaryClient
|
aeroSvc pbaero.PrimaryClient
|
||||||
|
fbAuth *firebaseauth.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
|
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
|
||||||
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
|
return &authServiceImpl{
|
||||||
|
sessionReaderImpl: newSessionReader(redisClient),
|
||||||
|
aeroSvc: aeroSvc,
|
||||||
|
fbAuth: fbAuth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *authServiceImpl) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) {
|
||||||
|
if humanId == "" {
|
||||||
|
return "", errors.New("humanId is required")
|
||||||
|
}
|
||||||
|
return a.fbAuth.CustomToken(ctx, humanId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
||||||
formattedEmail, err := utils.NormalizeEmail(email)
|
formattedEmail, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("problem validating email", "error", err)
|
flog.Error("problem validating email", "error", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +112,12 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
|
|||||||
|
|
||||||
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
|
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("error setting code in redis", "error", err)
|
flog.Error("error setting code in redis", "error", err)
|
||||||
return fmt.Errorf("error storing sign-in code: %w", err)
|
return fmt.Errorf("error storing sign-in code: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code)
|
message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code)
|
||||||
subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink")
|
subject := fmt.Sprint("Sign In - Your One-Time Code for Flowy.llink")
|
||||||
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||||
ToEmails: []string{formattedEmail},
|
ToEmails: []string{formattedEmail},
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
@@ -107,11 +131,11 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
|
|||||||
return fmt.Errorf("an error occurred while sending the email: %w", err)
|
return fmt.Errorf("an error occurred while sending the email: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("sent sign in code", "email", formattedEmail)
|
flog.Info("sent sign in code", "email", formattedEmail)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) {
|
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, humanId string) (string, error) {
|
||||||
formattedEmail, err := utils.NormalizeEmail(email)
|
formattedEmail, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid email: %w", err)
|
return "", fmt.Errorf("invalid email: %w", err)
|
||||||
@@ -122,7 +146,7 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
|
|||||||
if errors.Is(err, redis.Nil) {
|
if errors.Is(err, redis.Nil) {
|
||||||
return "", ErrInvalidCode
|
return "", ErrInvalidCode
|
||||||
}
|
}
|
||||||
slog.Error("error getting code from redis", "error", err)
|
flog.Error("error getting code from redis", "error", err)
|
||||||
return "", fmt.Errorf("error verifying code: %w", err)
|
return "", fmt.Errorf("error verifying code: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +155,10 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
|
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
|
||||||
slog.Error("error deleting code from redis", "error", err)
|
flog.Error("error deleting code from redis", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := a.createSession(ctx, formattedEmail)
|
token, err := a.createSession(ctx, formattedEmail, humanId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -142,18 +166,6 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code stri
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) {
|
|
||||||
email, err := a.redisClient.Get(ctx, token).Result()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, redis.Nil) {
|
|
||||||
return "", ErrSessionNotFound
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("error getting session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return email, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error {
|
func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error {
|
||||||
ttl, err := a.redisClient.TTL(ctx, token).Result()
|
ttl, err := a.redisClient.TTL(ctx, token).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,7 +192,7 @@ func (a *authServiceImpl) SignOut(ctx context.Context, token string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) {
|
func (a *authServiceImpl) createSession(ctx context.Context, email, humanId string) (string, error) {
|
||||||
formattedEmail, err := utils.NormalizeEmail(email)
|
formattedEmail, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid email: %w", err)
|
return "", fmt.Errorf("invalid email: %w", err)
|
||||||
@@ -191,7 +203,13 @@ func (a *authServiceImpl) createSession(ctx context.Context, email string) (stri
|
|||||||
return "", fmt.Errorf("error generating session token: %w", err)
|
return "", fmt.Errorf("error generating session token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil {
|
session := Session{Email: formattedEmail, HumanId: humanId}
|
||||||
|
data, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error marshaling session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.redisClient.Set(ctx, token.String(), data, sessionExpiry).Err(); err != nil {
|
||||||
return "", fmt.Errorf("error storing session: %w", err)
|
return "", fmt.Errorf("error storing session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SecretKey string
|
||||||
|
WebhookSecret string
|
||||||
|
PriceMonthlyID string
|
||||||
|
PriceAnnualID string
|
||||||
|
|
||||||
|
SuccessURL string
|
||||||
|
CancelURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
required := map[string]string{
|
||||||
|
"SecretKey": c.SecretKey,
|
||||||
|
"WebhookSecret": c.WebhookSecret,
|
||||||
|
"PriceMonthlyID": c.PriceMonthlyID,
|
||||||
|
"PriceAnnualID": c.PriceAnnualID,
|
||||||
|
"SuccessURL": c.SuccessURL,
|
||||||
|
"CancelURL": c.CancelURL,
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for name, value := range required {
|
||||||
|
if value == "" {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("billing config missing: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
|
// Source: ./service.go
|
||||||
|
//
|
||||||
|
// Generated by this command:
|
||||||
|
//
|
||||||
|
// mockgen -source ./service.go -destination ./mocks/service.go
|
||||||
|
//
|
||||||
|
|
||||||
|
// Package mock_billing is a generated GoMock package.
|
||||||
|
package mock_billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
reflect "reflect"
|
||||||
|
time "time"
|
||||||
|
|
||||||
|
billing "github.com/flowy-live/llink/internal/billing"
|
||||||
|
gomock "go.uber.org/mock/gomock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockService is a mock of Service interface.
|
||||||
|
type MockService struct {
|
||||||
|
ctrl *gomock.Controller
|
||||||
|
recorder *MockServiceMockRecorder
|
||||||
|
isgomock struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockServiceMockRecorder is the mock recorder for MockService.
|
||||||
|
type MockServiceMockRecorder struct {
|
||||||
|
mock *MockService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockService creates a new mock instance.
|
||||||
|
func NewMockService(ctrl *gomock.Controller) *MockService {
|
||||||
|
mock := &MockService{ctrl: ctrl}
|
||||||
|
mock.recorder = &MockServiceMockRecorder{mock}
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||||
|
func (m *MockService) EXPECT() *MockServiceMockRecorder {
|
||||||
|
return m.recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckoutSession mocks base method.
|
||||||
|
func (m *MockService) CreateCheckoutSession(ctx context.Context, p billing.CheckoutParams) (string, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "CreateCheckoutSession", ctx, p)
|
||||||
|
ret0, _ := ret[0].(string)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckoutSession indicates an expected call of CreateCheckoutSession.
|
||||||
|
func (mr *MockServiceMockRecorder) CreateCheckoutSession(ctx, p any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCheckoutSession", reflect.TypeOf((*MockService)(nil).CreateCheckoutSession), ctx, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePortalSession mocks base method.
|
||||||
|
func (m *MockService) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "CreatePortalSession", ctx, networkID)
|
||||||
|
ret0, _ := ret[0].(string)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePortalSession indicates an expected call of CreatePortalSession.
|
||||||
|
func (mr *MockServiceMockRecorder) CreatePortalSession(ctx, networkID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePortalSession", reflect.TypeOf((*MockService)(nil).CreatePortalSession), ctx, networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus mocks base method.
|
||||||
|
func (m *MockService) GetStatus(ctx context.Context, networkID string) (*billing.Status, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetStatus", ctx, networkID)
|
||||||
|
ret0, _ := ret[0].(*billing.Status)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus indicates an expected call of GetStatus.
|
||||||
|
func (mr *MockServiceMockRecorder) GetStatus(ctx, networkID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockService)(nil).GetStatus), ctx, networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsage mocks base method.
|
||||||
|
func (m *MockService) GetUsage(ctx context.Context, networkID string) (*billing.Usage, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetUsage", ctx, networkID)
|
||||||
|
ret0, _ := ret[0].(*billing.Usage)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsage indicates an expected call of GetUsage.
|
||||||
|
func (mr *MockServiceMockRecorder) GetUsage(ctx, networkID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsage", reflect.TypeOf((*MockService)(nil).GetUsage), ctx, networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook mocks base method.
|
||||||
|
func (m *MockService) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "HandleWebhook", ctx, payload, signature)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleWebhook indicates an expected call of HandleWebhook.
|
||||||
|
func (mr *MockServiceMockRecorder) HandleWebhook(ctx, payload, signature any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleWebhook", reflect.TypeOf((*MockService)(nil).HandleWebhook), ctx, payload, signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementDailyUsage mocks base method.
|
||||||
|
func (m *MockService) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "IncrementDailyUsage", ctx, networkID, at)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementDailyUsage indicates an expected call of IncrementDailyUsage.
|
||||||
|
func (mr *MockServiceMockRecorder) IncrementDailyUsage(ctx, networkID, at any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementDailyUsage", reflect.TypeOf((*MockService)(nil).IncrementDailyUsage), ctx, networkID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSeats mocks base method.
|
||||||
|
func (m *MockService) SyncSeats(ctx context.Context, networkID string, seats int) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "SyncSeats", ctx, networkID, seats)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSeats indicates an expected call of SyncSeats.
|
||||||
|
func (mr *MockServiceMockRecorder) SyncSeats(ctx, networkID, seats any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncSeats", reflect.TypeOf((*MockService)(nil).SyncSeats), ctx, networkID, seats)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Cadence string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CadenceMonthly Cadence = "monthly"
|
||||||
|
CadenceAnnual Cadence = "annual"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c Cadence) IsValid() bool {
|
||||||
|
return c == CadenceMonthly || c == CadenceAnnual
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plan string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlanFree Plan = "free"
|
||||||
|
PlanPro Plan = "pro"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscription is the persisted projection of a Stripe subscription,
|
||||||
|
// reconciled on every webhook event.
|
||||||
|
type Subscription struct {
|
||||||
|
ID string
|
||||||
|
NetworkID string
|
||||||
|
StripeCustomerID string
|
||||||
|
Status string
|
||||||
|
PriceID string
|
||||||
|
Cadence Cadence
|
||||||
|
Quantity int
|
||||||
|
CancelAtPeriodEnd bool
|
||||||
|
CurrentPeriodStart time.Time
|
||||||
|
CurrentPeriodEnd time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Plan Plan `json:"plan"`
|
||||||
|
PlanStatus string `json:"plan_status"`
|
||||||
|
Cadence *Cadence `json:"cadence"`
|
||||||
|
Seats int `json:"seats"` // 0 on free plan; sub.Quantity on pro
|
||||||
|
CurrentPeriodEnd *time.Time `json:"current_period_end"`
|
||||||
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||||
|
PriceMonthlyCents int64 `json:"price_monthly_cents"`
|
||||||
|
PriceAnnualCents int64 `json:"price_annual_cents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckoutParams struct {
|
||||||
|
NetworkID string
|
||||||
|
AdminHumanID string
|
||||||
|
AdminEmail string
|
||||||
|
Cadence Cadence
|
||||||
|
Seats int
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
type repository interface {
|
||||||
|
getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error)
|
||||||
|
upsertSubscription(ctx context.Context, sub *Subscription) error
|
||||||
|
deleteSubscriptionByID(ctx context.Context, subscriptionID string) error
|
||||||
|
|
||||||
|
getStripeCustomerID(ctx context.Context, networkID string) (string, error)
|
||||||
|
setStripeCustomerID(ctx context.Context, networkID, customerID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRepository(pool *pgxpool.Pool) repository {
|
||||||
|
return &repositoryImpl{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionColumns = `id, network_id, stripe_customer_id, status, price_id, cadence, quantity, cancel_at_period_end, current_period_start, current_period_end, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanSubscription(row pgx.Row, s *Subscription) error {
|
||||||
|
return row.Scan(
|
||||||
|
&s.ID, &s.NetworkID, &s.StripeCustomerID, &s.Status, &s.PriceID,
|
||||||
|
&s.Cadence, &s.Quantity, &s.CancelAtPeriodEnd,
|
||||||
|
&s.CurrentPeriodStart, &s.CurrentPeriodEnd,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error) {
|
||||||
|
var s Subscription
|
||||||
|
err := scanSubscription(
|
||||||
|
r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+subscriptionColumns+` FROM network_subscriptions WHERE network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
),
|
||||||
|
&s,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) upsertSubscription(ctx context.Context, sub *Subscription) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO network_subscriptions (
|
||||||
|
id, network_id, stripe_customer_id, status, price_id, cadence,
|
||||||
|
quantity, cancel_at_period_end, current_period_start, current_period_end
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
stripe_customer_id = EXCLUDED.stripe_customer_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
price_id = EXCLUDED.price_id,
|
||||||
|
cadence = EXCLUDED.cadence,
|
||||||
|
quantity = EXCLUDED.quantity,
|
||||||
|
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
|
||||||
|
current_period_start = EXCLUDED.current_period_start,
|
||||||
|
current_period_end = EXCLUDED.current_period_end,
|
||||||
|
updated_at = NOW()
|
||||||
|
`,
|
||||||
|
sub.ID, sub.NetworkID, sub.StripeCustomerID, sub.Status, sub.PriceID, sub.Cadence,
|
||||||
|
sub.Quantity, sub.CancelAtPeriodEnd, sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) deleteSubscriptionByID(ctx context.Context, subscriptionID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`DELETE FROM network_subscriptions WHERE id = $1`,
|
||||||
|
subscriptionID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getStripeCustomerID(ctx context.Context, networkID string) (string, error) {
|
||||||
|
var customerID string
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT stripe_customer_id FROM network_stripe_customers WHERE network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
).Scan(&customerID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", errNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return customerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) setStripeCustomerID(ctx context.Context, networkID, customerID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO network_stripe_customers (network_id, stripe_customer_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (network_id) DO NOTHING
|
||||||
|
`, networkID, customerID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stripe/stripe-go/v85"
|
||||||
|
billingportalsession "github.com/stripe/stripe-go/v85/billingportal/session"
|
||||||
|
checkoutsession "github.com/stripe/stripe-go/v85/checkout/session"
|
||||||
|
stripecustomer "github.com/stripe/stripe-go/v85/customer"
|
||||||
|
stripeprice "github.com/stripe/stripe-go/v85/price"
|
||||||
|
stripesub "github.com/stripe/stripe-go/v85/subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
GetStatus(ctx context.Context, networkID string) (*Status, error)
|
||||||
|
CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error)
|
||||||
|
CreatePortalSession(ctx context.Context, networkID string) (url string, err error)
|
||||||
|
// SyncSeats updates the Stripe subscription quantity with proration.
|
||||||
|
// No-op if the network has no active subscription.
|
||||||
|
SyncSeats(ctx context.Context, networkID string, seats int) error
|
||||||
|
HandleWebhook(ctx context.Context, payload []byte, signature string) error
|
||||||
|
|
||||||
|
// GetUsage reports today's freemium quota state for a network.
|
||||||
|
// Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit.
|
||||||
|
GetUsage(ctx context.Context, networkID string) (*Usage, error)
|
||||||
|
IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
|
||||||
|
ErrInvalidCadence = errors.New("invalid billing cadence")
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceImpl struct {
|
||||||
|
cfg Config
|
||||||
|
repo repository
|
||||||
|
usageRepo usageRepository
|
||||||
|
priceMonthlyCents int64
|
||||||
|
priceAnnualCents int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stripe.Key = cfg.SecretKey
|
||||||
|
|
||||||
|
monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{
|
||||||
|
Params: stripe.Params{Context: ctx},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch stripe monthly price: %w", err)
|
||||||
|
}
|
||||||
|
annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{
|
||||||
|
Params: stripe.Params{Context: ctx},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch stripe annual price: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &serviceImpl{
|
||||||
|
cfg: cfg,
|
||||||
|
repo: newRepository(pool),
|
||||||
|
usageRepo: newUsageRepository(pool),
|
||||||
|
priceMonthlyCents: monthly.UnitAmount,
|
||||||
|
priceAnnualCents: annual.UnitAmount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceForWorker skips Stripe client setup since workers only exercise
|
||||||
|
// the usage-tracking path. No API key required.
|
||||||
|
func NewServiceForWorker(pool *pgxpool.Pool) Service {
|
||||||
|
return &serviceImpl{
|
||||||
|
usageRepo: newUsageRepository(pool),
|
||||||
|
repo: newRepository(pool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
|
||||||
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
|
if err != nil && !errors.Is(err, errNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &Status{
|
||||||
|
Plan: PlanFree,
|
||||||
|
PlanStatus: "active",
|
||||||
|
PriceMonthlyCents: s.priceMonthlyCents,
|
||||||
|
PriceAnnualCents: s.priceAnnualCents,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sub != nil {
|
||||||
|
cadence := sub.Cadence
|
||||||
|
periodEnd := sub.CurrentPeriodEnd
|
||||||
|
// past_due keeps access: Stripe still considers the subscription live
|
||||||
|
// during the dunning window.
|
||||||
|
switch stripe.SubscriptionStatus(sub.Status) {
|
||||||
|
case stripe.SubscriptionStatusActive,
|
||||||
|
stripe.SubscriptionStatusTrialing,
|
||||||
|
stripe.SubscriptionStatusPastDue:
|
||||||
|
status.Plan = PlanPro
|
||||||
|
}
|
||||||
|
status.PlanStatus = sub.Status
|
||||||
|
status.Cadence = &cadence
|
||||||
|
status.Seats = sub.Quantity
|
||||||
|
status.CurrentPeriodEnd = &periodEnd
|
||||||
|
status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) {
|
||||||
|
if !p.Cadence.IsValid() {
|
||||||
|
return "", ErrInvalidCadence
|
||||||
|
}
|
||||||
|
if p.Seats < 1 {
|
||||||
|
return "", fmt.Errorf("seats must be >= 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, err := s.ensureStripeCustomer(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ensure stripe customer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID := s.cfg.PriceAnnualID
|
||||||
|
switch p.Cadence {
|
||||||
|
case CadenceAnnual:
|
||||||
|
priceID = s.cfg.PriceAnnualID
|
||||||
|
break
|
||||||
|
case CadenceMonthly:
|
||||||
|
priceID = s.cfg.PriceMonthlyID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
ClientReferenceID: stripe.String(p.NetworkID),
|
||||||
|
SuccessURL: stripe.String(s.cfg.SuccessURL),
|
||||||
|
CancelURL: stripe.String(s.cfg.CancelURL),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{{
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(int64(p.Seats)),
|
||||||
|
}},
|
||||||
|
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"network_id": p.NetworkID,
|
||||||
|
"admin_human_id": p.AdminHumanID,
|
||||||
|
"cadence": string(p.Cadence),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
sess, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stripe checkout: %w", err)
|
||||||
|
}
|
||||||
|
return sess.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
|
||||||
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return "", ErrNoActiveSubscription
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.BillingPortalSessionParams{
|
||||||
|
Customer: stripe.String(sub.StripeCustomerID),
|
||||||
|
ReturnURL: stripe.String(s.cfg.SuccessURL),
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
sess, err := billingportalsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stripe portal: %w", err)
|
||||||
|
}
|
||||||
|
return sess.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error {
|
||||||
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sub.Quantity == seats {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{
|
||||||
|
Params: stripe.Params{Context: ctx},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch stripe subscription: %w", err)
|
||||||
|
}
|
||||||
|
if len(liveSub.Items.Data) == 0 {
|
||||||
|
return fmt.Errorf("stripe subscription %s has no items", sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.SubscriptionParams{
|
||||||
|
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||||
|
Items: []*stripe.SubscriptionItemsParams{{
|
||||||
|
ID: stripe.String(liveSub.Items.Data[0].ID),
|
||||||
|
Quantity: stripe.Int64(int64(seats)),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
if _, err := stripesub.Update(sub.ID, params); err != nil {
|
||||||
|
return fmt.Errorf("update stripe subscription quantity: %w", err)
|
||||||
|
}
|
||||||
|
// customer.subscription.updated webhook arrives within seconds and
|
||||||
|
// reconciles quantity in our DB.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error {
|
||||||
|
return s.usageRepo.incrementDaily(ctx, networkID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) {
|
||||||
|
now := time.Now()
|
||||||
|
used, err := s.usageRepo.getDaily(ctx, networkID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read daily usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := s.GetStatus(ctx, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get network billing status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &Usage{
|
||||||
|
Plan: sub.Plan,
|
||||||
|
Used: used,
|
||||||
|
ResetAt: nextUTCMidnight(now),
|
||||||
|
Limit: nil,
|
||||||
|
}
|
||||||
|
if sub.Plan == PlanFree {
|
||||||
|
limit := FreemiumDailyLimit
|
||||||
|
u.Limit = &limit
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextUTCMidnight(now time.Time) time.Time {
|
||||||
|
utc := now.UTC()
|
||||||
|
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
|
||||||
|
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
|
||||||
|
if err == nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errNotFound) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CustomerParams{
|
||||||
|
Email: stripe.String(p.AdminEmail),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"network_id": p.NetworkID,
|
||||||
|
"admin_human_id": p.AdminHumanID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
cust, err := stripecustomer.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create stripe customer: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil {
|
||||||
|
return "", fmt.Errorf("persist stripe customer id: %w", err)
|
||||||
|
}
|
||||||
|
return cust.ID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Per-network daily cap on the free plan. Unit-agnostic.
|
||||||
|
const FreemiumDailyLimit = 50
|
||||||
|
|
||||||
|
type Usage struct {
|
||||||
|
Plan Plan `json:"plan"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Limit *int `json:"limit"` // nil = unlimited (pro)
|
||||||
|
ResetAt time.Time `json:"reset_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type usageRepository interface {
|
||||||
|
incrementDaily(ctx context.Context, networkID string, at time.Time) error
|
||||||
|
getDaily(ctx context.Context, networkID string, at time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageRepositoryImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUsageRepository(pool *pgxpool.Pool) usageRepository {
|
||||||
|
return &usageRepositoryImpl{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *usageRepositoryImpl) incrementDaily(ctx context.Context, networkID string, at time.Time) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO network_message_usage (network_id, usage_date, message_count, updated_at)
|
||||||
|
VALUES ($1, ($2 AT TIME ZONE 'UTC')::date, 1, NOW())
|
||||||
|
ON CONFLICT (network_id, usage_date) DO UPDATE
|
||||||
|
SET message_count = network_message_usage.message_count + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
`, networkID, at)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *usageRepositoryImpl) getDaily(ctx context.Context, networkID string, at time.Time) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT message_count FROM network_message_usage
|
||||||
|
WHERE network_id = $1 AND usage_date = ($2 AT TIME ZONE 'UTC')::date
|
||||||
|
`, networkID, at).Scan(&count)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v85"
|
||||||
|
"github.com/stripe/stripe-go/v85/webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
|
||||||
|
event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("verify stripe signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flog.Info("stripe webhook", "type", event.Type, "id", event.ID)
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
// subscription.created fires right after with full detail; we handle
|
||||||
|
// the subscription there.
|
||||||
|
return nil
|
||||||
|
case "customer.subscription.created", "customer.subscription.updated":
|
||||||
|
return s.handleSubscriptionUpsert(ctx, event)
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
return s.handleSubscriptionDeleted(ctx, event)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
return fmt.Errorf("decode subscription: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := sub.Metadata["network_id"]
|
||||||
|
if networkID == "" {
|
||||||
|
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
local, err := subscriptionFromStripe(&sub, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.upsertSubscription(ctx, local); err != nil {
|
||||||
|
return fmt.Errorf("upsert subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
return fmt.Errorf("decode subscription: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := sub.Metadata["network_id"]
|
||||||
|
if networkID == "" {
|
||||||
|
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil {
|
||||||
|
return fmt.Errorf("delete subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) {
|
||||||
|
if len(sub.Items.Data) == 0 {
|
||||||
|
return nil, fmt.Errorf("subscription %s has no items", sub.ID)
|
||||||
|
}
|
||||||
|
item := sub.Items.Data[0]
|
||||||
|
cadence, err := cadenceFromInterval(item.Price)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerID string
|
||||||
|
if sub.Customer != nil {
|
||||||
|
customerID = sub.Customer.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Subscription{
|
||||||
|
ID: sub.ID,
|
||||||
|
NetworkID: networkID,
|
||||||
|
StripeCustomerID: customerID,
|
||||||
|
Status: string(sub.Status),
|
||||||
|
PriceID: item.Price.ID,
|
||||||
|
Cadence: cadence,
|
||||||
|
Quantity: int(item.Quantity),
|
||||||
|
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
|
||||||
|
CurrentPeriodStart: time.Unix(item.CurrentPeriodStart, 0).UTC(),
|
||||||
|
CurrentPeriodEnd: time.Unix(item.CurrentPeriodEnd, 0).UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cadenceFromInterval(price *stripe.Price) (Cadence, error) {
|
||||||
|
if price == nil || price.Recurring == nil {
|
||||||
|
return "", fmt.Errorf("price is not recurring")
|
||||||
|
}
|
||||||
|
switch price.Recurring.Interval {
|
||||||
|
case stripe.PriceRecurringIntervalMonth:
|
||||||
|
return CadenceMonthly, nil
|
||||||
|
case stripe.PriceRecurringIntervalYear:
|
||||||
|
return CadenceAnnual, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
const FlowyAdminEmail = "team@flowylabs.ai"
|
||||||
@@ -2,9 +2,10 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,24 +22,24 @@ func Pool() *pgxpool.Pool {
|
|||||||
func Init() {
|
func Init() {
|
||||||
connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL")
|
connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL")
|
||||||
if connString == "" {
|
if connString == "" {
|
||||||
slog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
|
flog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
dbpool, err := pgxpool.New(context.Background(), connString)
|
dbpool, err := pgxpool.New(context.Background(), connString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("unable to create connection pool", "error", err)
|
flog.Error("unable to create connection pool", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var greeting string
|
var greeting string
|
||||||
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
|
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("queryRow failed", "error", err)
|
flog.Error("queryRow failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("successfully connected to database", "greeting", greeting)
|
flog.Info("successfully connected to database", "greeting", greeting)
|
||||||
|
|
||||||
db = dbpool
|
db = dbpool
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
// FIX: move to a dedicated Redis instance. The high DB numbers exist because
|
||||||
|
// this instance is shared with helios.
|
||||||
|
const (
|
||||||
|
RedisDBAuth = 4 // auth sessions
|
||||||
|
RedisDBPusher = 5 // presence, pub/sub
|
||||||
|
)
|
||||||
@@ -2,7 +2,6 @@ package depot
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Object represents a stored object in the depot
|
|
||||||
type Object struct {
|
type Object struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -14,22 +13,26 @@ type Object struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareUploadInput represents the input for preparing an upload
|
|
||||||
type PrepareUploadInput struct {
|
type PrepareUploadInput struct {
|
||||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
Prefix string // optional, e.g. network_id
|
||||||
Name string
|
Name string
|
||||||
ContentType string
|
ContentType string
|
||||||
ContentLength int64
|
ContentLength int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareUploadResult represents the result of preparing an upload
|
|
||||||
type PrepareUploadResult struct {
|
type PrepareUploadResult struct {
|
||||||
ObjectID string
|
ObjectID string
|
||||||
UploadURL string
|
UploadURL string
|
||||||
UploadHeaders map[string]string
|
UploadHeaders map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds configuration for the depot service
|
// For server-side direct uploads (no presigned URL).
|
||||||
|
type CreateFromReaderInput struct {
|
||||||
|
Prefix string // optional, e.g. network_id
|
||||||
|
Name string
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
GoogleServiceAccountEmail string
|
GoogleServiceAccountEmail string
|
||||||
BucketName string
|
BucketName string
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
"cloud.google.com/go/storage"
|
"cloud.google.com/go/storage"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -20,6 +22,7 @@ const (
|
|||||||
type Service interface {
|
type Service interface {
|
||||||
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
|
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
|
||||||
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
|
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
|
||||||
|
CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error)
|
||||||
GetByID(ctx context.Context, objectID string) (*Object, error)
|
GetByID(ctx context.Context, objectID string) (*Object, error)
|
||||||
GetDownloadURL(ctx context.Context, objectID string) (string, error)
|
GetDownloadURL(ctx context.Context, objectID string) (string, error)
|
||||||
Delete(ctx context.Context, objectID string) error
|
Delete(ctx context.Context, objectID string) error
|
||||||
@@ -47,7 +50,7 @@ func NewService(pool *pgxpool.Pool, storageClient *storage.Client, config Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
if config.GoogleServiceAccountEmail == "" {
|
if config.GoogleServiceAccountEmail == "" {
|
||||||
slog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
|
flog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
|
||||||
panic("GoogleServiceAccountEmail is required for signed URL generation")
|
panic("GoogleServiceAccountEmail is required for signed URL generation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,10 +75,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
|||||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate object key: {prefix}/{uuid}/{filename}
|
// {prefix}/{uuid}/{filename}
|
||||||
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||||
|
|
||||||
// Create the database record (contains_content = false initially)
|
// Row is written first with contains_content=false; ConfirmUpload flips it.
|
||||||
obj := &Object{
|
obj := &Object{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
ContentType: input.ContentType,
|
ContentType: input.ContentType,
|
||||||
@@ -90,8 +93,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a signed URL for uploading with Content-Length enforcement
|
// Content-Length is part of the signature, so the client must send it verbatim.
|
||||||
// The Headers field specifies headers that MUST be included in the upload request
|
|
||||||
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
|
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
|
||||||
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
|
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
|
||||||
GoogleAccessID: s.googleServiceAccountEmail,
|
GoogleAccessID: s.googleServiceAccountEmail,
|
||||||
@@ -101,10 +103,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
|||||||
Headers: []string{contentLengthHeader},
|
Headers: []string{contentLengthHeader},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
flog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||||
// Clean up the database record if we can't generate the URL
|
// Roll back the placeholder row.
|
||||||
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
||||||
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
flog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -128,22 +130,19 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the object exists in GCS and check its size matches expected
|
|
||||||
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
|
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, storage.ErrObjectNotExist) {
|
if errors.Is(err, storage.ErrObjectNotExist) {
|
||||||
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
|
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
|
||||||
}
|
}
|
||||||
slog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
flog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify content length matches what was declared
|
|
||||||
if attrs.Size != obj.ContentLength {
|
if attrs.Size != obj.ContentLength {
|
||||||
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
|
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as containing content
|
|
||||||
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
|
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -151,10 +150,58 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and return the updated object
|
|
||||||
return s.repo.getByID(ctx, objectID)
|
return s.repo.getByID(ctx, objectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateFromReader streams bytes straight to GCS and writes the row in one
|
||||||
|
// shot — no signed URL, no client round-trip. For server-side flows that
|
||||||
|
// already have the bytes (e.g. transcoded variants).
|
||||||
|
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
|
||||||
|
if input.Name == "" {
|
||||||
|
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
|
||||||
|
}
|
||||||
|
if input.ContentType == "" {
|
||||||
|
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||||
|
|
||||||
|
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
|
||||||
|
w.ContentType = input.ContentType
|
||||||
|
if _, err := io.Copy(w, body); err != nil {
|
||||||
|
// Always release the writer; surface the copy error, not Close's.
|
||||||
|
if cerr := w.Close(); cerr != nil {
|
||||||
|
flog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
|
||||||
|
}
|
||||||
|
flog.Error("failed to stream object to GCS", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
flog.Error("failed to close GCS writer", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := &Object{
|
||||||
|
Name: input.Name,
|
||||||
|
ContentType: input.ContentType,
|
||||||
|
ContentLength: w.Attrs().Size,
|
||||||
|
BucketName: s.bucketName,
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
ContainsContent: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.repo.create(ctx, obj)
|
||||||
|
if err != nil {
|
||||||
|
// Best-effort: drop the now-untracked GCS object.
|
||||||
|
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
|
||||||
|
flog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
|
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
|
||||||
obj, err := s.repo.getByID(ctx, objectID)
|
obj, err := s.repo.getByID(ctx, objectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,14 +222,13 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a signed URL for downloading
|
|
||||||
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
||||||
GoogleAccessID: s.googleServiceAccountEmail,
|
GoogleAccessID: s.googleServiceAccountEmail,
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
Expires: time.Now().Add(s.downloadURLExpiry),
|
Expires: time.Now().Add(s.downloadURLExpiry),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
flog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,14 +244,13 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from GCS (ignore not found errors)
|
// GCS first so we don't strand an object after the row vanishes; missing object is fine.
|
||||||
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
|
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
|
||||||
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
|
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
|
||||||
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
flog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||||
return gcsErr
|
return gcsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
if err := s.repo.delete(ctx, objectID); err != nil {
|
if err := s.repo.delete(ctx, objectID); err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
package depot_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
|
||||||
"github.com/flowy-live/llink/internal/testhelper"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
var dbPool *pgxpool.Pool
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
dbPool = testhelper.SetupTestDB()
|
|
||||||
defer testhelper.TeardownTestDB()
|
|
||||||
|
|
||||||
ret := m.Run()
|
|
||||||
os.Exit(ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDepotRepository tests the repository layer directly
|
|
||||||
// These tests can run without GCS since they only test database operations
|
|
||||||
func TestDepotRepository_CreateAndGet(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create a depot object directly in the database for testing
|
|
||||||
var id string
|
|
||||||
err := dbPool.QueryRow(ctx,
|
|
||||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
"dpo_test123", "test-file.txt", "text/plain", int64(1024), "test-bucket", "test-key-123", false,
|
|
||||||
).Scan(&id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "dpo_test123", id)
|
|
||||||
|
|
||||||
// Query the object back
|
|
||||||
var obj struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
ContentType string
|
|
||||||
ContentLength int64
|
|
||||||
BucketName string
|
|
||||||
ObjectKey string
|
|
||||||
ContainsContent bool
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`SELECT id, name, content_type, content_length, bucket_name, object_key, contains_content, created_at
|
|
||||||
FROM depot_objects WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
).Scan(&obj.ID, &obj.Name, &obj.ContentType, &obj.ContentLength,
|
|
||||||
&obj.BucketName, &obj.ObjectKey, &obj.ContainsContent, &obj.CreatedAt)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "test-file.txt", obj.Name)
|
|
||||||
assert.Equal(t, "text/plain", obj.ContentType)
|
|
||||||
assert.Equal(t, int64(1024), obj.ContentLength)
|
|
||||||
assert.Equal(t, "test-bucket", obj.BucketName)
|
|
||||||
assert.Equal(t, "test-key-123", obj.ObjectKey)
|
|
||||||
assert.False(t, obj.ContainsContent)
|
|
||||||
assert.False(t, obj.CreatedAt.IsZero())
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDepotRepository_ConfirmUpload(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create a depot object
|
|
||||||
var id string
|
|
||||||
err := dbPool.QueryRow(ctx,
|
|
||||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
"dpo_confirm123", "confirm-file.txt", "text/plain", int64(2048), "test-bucket", "confirm-key", false,
|
|
||||||
).Scan(&id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify it starts with contains_content = false
|
|
||||||
var containsContent bool
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`SELECT contains_content FROM depot_objects WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
).Scan(&containsContent)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.False(t, containsContent)
|
|
||||||
|
|
||||||
// Confirm upload
|
|
||||||
_, err = dbPool.Exec(ctx,
|
|
||||||
`UPDATE depot_objects SET contains_content = TRUE WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify it's now true
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`SELECT contains_content FROM depot_objects WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
).Scan(&containsContent)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.True(t, containsContent)
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDepotRepository_Delete(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create a depot object
|
|
||||||
var id string
|
|
||||||
err := dbPool.QueryRow(ctx,
|
|
||||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
"dpo_delete123", "delete-file.txt", "text/plain", int64(512), "test-bucket", "delete-key", false,
|
|
||||||
).Scan(&id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Delete it
|
|
||||||
result, err := dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), result.RowsAffected())
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
var count int
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`SELECT COUNT(*) FROM depot_objects WHERE id = $1`,
|
|
||||||
id,
|
|
||||||
).Scan(&count)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, 0, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDepotRepository_Exists(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create a depot object
|
|
||||||
var id string
|
|
||||||
err := dbPool.QueryRow(ctx,
|
|
||||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
"dpo_exists123", "exists-file.txt", "text/plain", int64(256), "test-bucket", "exists-key", true,
|
|
||||||
).Scan(&id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Check exists
|
|
||||||
var exists bool
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
|
|
||||||
id,
|
|
||||||
).Scan(&exists)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.True(t, exists)
|
|
||||||
|
|
||||||
// Check non-existent
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
|
|
||||||
"dpo_nonexistent",
|
|
||||||
).Scan(&exists)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDepotRepository_OrphanedIndex(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create an orphaned object (contains_content = false)
|
|
||||||
var orphanID string
|
|
||||||
err := dbPool.QueryRow(ctx,
|
|
||||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
"dpo_orphan123", "orphan-file.txt", "text/plain", int64(128), "test-bucket", "orphan-key", false,
|
|
||||||
).Scan(&orphanID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a confirmed object (contains_content = true)
|
|
||||||
var confirmedID string
|
|
||||||
err = dbPool.QueryRow(ctx,
|
|
||||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
"dpo_confirmed123", "confirmed-file.txt", "text/plain", int64(128), "test-bucket", "confirmed-key", true,
|
|
||||||
).Scan(&confirmedID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Query orphaned objects using the index
|
|
||||||
rows, err := dbPool.Query(ctx,
|
|
||||||
`SELECT id FROM depot_objects WHERE contains_content = FALSE`)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var orphanedIDs []string
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
err := rows.Scan(&id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
orphanedIDs = append(orphanedIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our orphan should be in the list
|
|
||||||
assert.Contains(t, orphanedIDs, orphanID)
|
|
||||||
assert.NotContains(t, orphanedIDs, confirmedID)
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id IN ($1, $2)`, orphanID, confirmedID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDepotModels tests the model structures
|
|
||||||
func TestDepotModels(t *testing.T) {
|
|
||||||
// Test Config defaults
|
|
||||||
config := depot.Config{
|
|
||||||
BucketName: "test-bucket",
|
|
||||||
}
|
|
||||||
assert.Equal(t, "test-bucket", config.BucketName)
|
|
||||||
assert.Equal(t, time.Duration(0), config.UploadURLExpiry)
|
|
||||||
assert.Equal(t, time.Duration(0), config.DownloadURLExpiry)
|
|
||||||
|
|
||||||
// Test with explicit values
|
|
||||||
config = depot.Config{
|
|
||||||
BucketName: "custom-bucket",
|
|
||||||
UploadURLExpiry: 10 * time.Minute,
|
|
||||||
DownloadURLExpiry: 12 * time.Hour,
|
|
||||||
}
|
|
||||||
assert.Equal(t, "custom-bucket", config.BucketName)
|
|
||||||
assert.Equal(t, 10*time.Minute, config.UploadURLExpiry)
|
|
||||||
assert.Equal(t, 12*time.Hour, config.DownloadURLExpiry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDepotErrors tests the error definitions
|
|
||||||
func TestDepotErrors(t *testing.T) {
|
|
||||||
assert.Error(t, depot.ErrNotFound)
|
|
||||||
assert.Error(t, depot.ErrInvalidInput)
|
|
||||||
assert.Equal(t, "object not found", depot.ErrNotFound.Error())
|
|
||||||
assert.Equal(t, "invalid input", depot.ErrInvalidInput.Error())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
|
"github.com/flowy-live/llink/internal/middleware"
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateCheckoutSessionRequest struct {
|
||||||
|
Cadence string `json:"cadence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckoutSessionResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortalSessionResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNetworkUsage reports today's usage, daily limit (nil on pro), and reset
|
||||||
|
// time. Open to any network member since the UI surfaces it to every sender.
|
||||||
|
func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := r.PathValue("id")
|
||||||
|
if networkID == "" {
|
||||||
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to check network membership", "error", err, "network_id", networkID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := h.billingSvc.GetUsage(r.Context(), networkID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to get network usage", "error", err, "network_id", networkID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) {
|
||||||
|
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := h.billingSvc.GetStatus(r.Context(), net.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to get billing status", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
net, adminHumanId, ok := h.loadNetworkForAdmin(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateCheckoutSessionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cadence := billing.Cadence(req.Cadence)
|
||||||
|
if !cadence.IsValid() {
|
||||||
|
http.Error(w, "invalid cadence", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminHuman, err := h.humanSvc.GetByID(r.Context(), adminHumanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to load admin human", "error", err, "human_id", adminHumanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seats, err := h.networkSvc.CountSeats(r.Context(), net.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to count seats", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := h.billingSvc.CreateCheckoutSession(r.Context(), billing.CheckoutParams{
|
||||||
|
NetworkID: net.ID,
|
||||||
|
AdminHumanID: adminHumanId,
|
||||||
|
AdminEmail: adminHuman.Email,
|
||||||
|
Cadence: cadence,
|
||||||
|
Seats: seats,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create checkout session", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, CheckoutSessionResponse{URL: url})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := h.billingSvc.CreatePortalSession(r.Context(), net.ID)
|
||||||
|
if errors.Is(err, billing.ErrNoActiveSubscription) {
|
||||||
|
http.Error(w, "no active subscription", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to create portal session", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, PortalSessionResponse{URL: url})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxStripeWebhookBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(r.Body, maxStripeWebhookBytes))
|
||||||
|
if err != nil {
|
||||||
|
flog.Warn("stripe webhook: failed to read body", "error", err)
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signature := r.Header.Get("Stripe-Signature")
|
||||||
|
|
||||||
|
if err := h.billingSvc.HandleWebhook(r.Context(), payload, signature); err != nil {
|
||||||
|
flog.Error("stripe webhook failed", "error", err)
|
||||||
|
formattedErr := fmt.Errorf("webhook processing failed: %w", err)
|
||||||
|
http.Error(w, formattedErr.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves {id}, verifies the caller is admin. On failure writes the HTTP
|
||||||
|
// error and returns ok=false.
|
||||||
|
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := r.PathValue("id")
|
||||||
|
if networkID == "" {
|
||||||
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
net, err := h.networkSvc.GetByID(r.Context(), networkID)
|
||||||
|
if errors.Is(err, network.ErrNotFound) {
|
||||||
|
http.Error(w, "network not found", http.StatusNotFound)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to load network", "error", err, "network_id", networkID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if net.AdminHumanId != humanId {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return net, humanId, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
|
flog.Error("failed to write json", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+655
-1481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
|
||||||
|
// All clients (web and Electron) call this endpoint — the Electron main-process
|
||||||
|
// fetcher was retired so both clients share one parser and the server-side cache.
|
||||||
|
type LinkMetadata struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Image *string `json:"image"`
|
||||||
|
Favicon *string `json:"favicon"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
metadataFetchTimeout = 5 * time.Second
|
||||||
|
metadataMaxBytes = 50 * 1024
|
||||||
|
metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In-process cache, unbounded but only successful results are stored.
|
||||||
|
// URL space is bounded in practice.
|
||||||
|
var metadataCache sync.Map // map[string]LinkMetadata
|
||||||
|
|
||||||
|
func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := r.URL.Query().Get("url")
|
||||||
|
if raw == "" {
|
||||||
|
http.Error(w, "url is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||||
|
http.Error(w, "invalid url", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cached, ok := metadataCache.Load(raw); ok {
|
||||||
|
writeJSON(w, cached)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := guardSSRF(parsed.Hostname()); err != nil {
|
||||||
|
// Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
|
||||||
|
// client treats a null body as a progressive-enhancement miss.
|
||||||
|
writeJSON(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := fetchLinkMetadata(r.Context(), parsed)
|
||||||
|
if err != nil {
|
||||||
|
flog.Warn("link metadata fetch failed", "url", raw, "error", err)
|
||||||
|
writeJSON(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataCache.Store(raw, *meta)
|
||||||
|
writeJSON(w, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSSRF resolves the host and rejects loopback, private, link-local, and
|
||||||
|
// unspecified addresses so the endpoint can't be turned into an internal-network
|
||||||
|
// probe.
|
||||||
|
func guardSSRF(host string) error {
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return errors.New("no addresses for host")
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
||||||
|
return errors.New("private or local address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", metadataUserAgent)
|
||||||
|
req.Header.Set("Accept", "text/html")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, errors.New("upstream non-2xx")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
html := string(body)
|
||||||
|
|
||||||
|
domain := strings.TrimPrefix(target.Hostname(), "www.")
|
||||||
|
title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
|
||||||
|
description := firstNonNil(
|
||||||
|
metaContent(html, "og:description"),
|
||||||
|
metaContent(html, "description"),
|
||||||
|
)
|
||||||
|
image := resolveURL(metaContent(html, "og:image"), target)
|
||||||
|
favicon := parseFavicon(html, target)
|
||||||
|
|
||||||
|
return &LinkMetadata{
|
||||||
|
URL: target.String(),
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Image: image,
|
||||||
|
Favicon: favicon,
|
||||||
|
Domain: domain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleRe = regexp.MustCompile(`(?i)<title[^>]*>([^<]*)</title>`)
|
||||||
|
|
||||||
|
func parseTitle(html string) *string {
|
||||||
|
m := titleRe.FindStringSubmatch(html)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(m[1])
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaContent finds <meta property|name="<prop>" content="..."> in either attribute order.
|
||||||
|
func metaContent(html, prop string) *string {
|
||||||
|
escaped := regexp.QuoteMeta(prop)
|
||||||
|
re := regexp.MustCompile(
|
||||||
|
`(?i)<meta[^>]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
|
||||||
|
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
|
||||||
|
)
|
||||||
|
m := re.FindStringSubmatch(html)
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, g := range m[1:] {
|
||||||
|
if g != "" {
|
||||||
|
return &g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
faviconRe1 = regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
|
||||||
|
faviconRe2 = regexp.MustCompile(`(?i)<link[^>]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseFavicon(html string, base *url.URL) *string {
|
||||||
|
for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
|
||||||
|
if m := re.FindStringSubmatch(html); len(m) >= 2 {
|
||||||
|
return resolveURL(&m[1], base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to /favicon.ico
|
||||||
|
fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
|
||||||
|
return &fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveURL(src *string, base *url.URL) *string {
|
||||||
|
if src == nil || *src == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(*src)
|
||||||
|
if err != nil {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
resolved := base.ResolveReference(parsed).String()
|
||||||
|
return &resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonNil(values ...*string) *string {
|
||||||
|
for _, v := range values {
|
||||||
|
if v != nil && *v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/human/pushnotify"
|
||||||
|
"github.com/flowy-live/llink/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterPushTokenRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnregisterPushTokenRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterPushToken upserts an Expo token; ON CONFLICT transparently re-binds
|
||||||
|
// a token to a new human after a device-level account switch.
|
||||||
|
func (h *Handler) RegisterPushToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req RegisterPushTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.pushTokenSvc.Register(r.Context(), humanId, pushnotify.RegisterInput{
|
||||||
|
Token: req.Token,
|
||||||
|
Platform: pushnotify.Platform(req.Platform),
|
||||||
|
AppVersion: req.AppVersion,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pushnotify.ErrInvalidPlatform) || errors.Is(err, pushnotify.ErrInvalidToken) {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flog.Error("failed to register push token", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterPushToken returns 204 whether or not the token existed (idempotent).
|
||||||
|
func (h *Handler) UnregisterPushToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UnregisterPushTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Token == "" {
|
||||||
|
http.Error(w, "token is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.pushTokenSvc.Unregister(r.Context(), humanId, req.Token)
|
||||||
|
if err != nil && !errors.Is(err, pushnotify.ErrNotFound) {
|
||||||
|
flog.Error("failed to unregister push token", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
|
// Source: ./service.go
|
||||||
|
//
|
||||||
|
// Generated by this command:
|
||||||
|
//
|
||||||
|
// mockgen -source ./service.go -destination ./mocks/service.go
|
||||||
|
//
|
||||||
|
|
||||||
|
// Package mock_human is a generated GoMock package.
|
||||||
|
package mock_human
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
reflect "reflect"
|
||||||
|
time "time"
|
||||||
|
|
||||||
|
human "github.com/flowy-live/llink/internal/human"
|
||||||
|
gomock "go.uber.org/mock/gomock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockService is a mock of Service interface.
|
||||||
|
type MockService struct {
|
||||||
|
ctrl *gomock.Controller
|
||||||
|
recorder *MockServiceMockRecorder
|
||||||
|
isgomock struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockServiceMockRecorder is the mock recorder for MockService.
|
||||||
|
type MockServiceMockRecorder struct {
|
||||||
|
mock *MockService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockService creates a new mock instance.
|
||||||
|
func NewMockService(ctrl *gomock.Controller) *MockService {
|
||||||
|
mock := &MockService{ctrl: ctrl}
|
||||||
|
mock.recorder = &MockServiceMockRecorder{mock}
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||||
|
func (m *MockService) EXPECT() *MockServiceMockRecorder {
|
||||||
|
return m.recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail mocks base method.
|
||||||
|
func (m *MockService) GetByEmail(ctx context.Context, email string) (*human.Human, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetByEmail", ctx, email)
|
||||||
|
ret0, _ := ret[0].(*human.Human)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail indicates an expected call of GetByEmail.
|
||||||
|
func (mr *MockServiceMockRecorder) GetByEmail(ctx, email any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByEmail", reflect.TypeOf((*MockService)(nil).GetByEmail), ctx, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID mocks base method.
|
||||||
|
func (m *MockService) GetByID(ctx context.Context, id string) (*human.Human, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetByID", ctx, id)
|
||||||
|
ret0, _ := ret[0].(*human.Human)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID indicates an expected call of GetByID.
|
||||||
|
func (mr *MockServiceMockRecorder) GetByID(ctx, id any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockService)(nil).GetByID), ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateByEmail mocks base method.
|
||||||
|
func (m *MockService) GetOrCreateByEmail(ctx context.Context, email string) (*human.Human, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetOrCreateByEmail", ctx, email)
|
||||||
|
ret0, _ := ret[0].(*human.Human)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateByEmail indicates an expected call of GetOrCreateByEmail.
|
||||||
|
func (mr *MockServiceMockRecorder) GetOrCreateByEmail(ctx, email any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateByEmail", reflect.TypeOf((*MockService)(nil).GetOrCreateByEmail), ctx, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll mocks base method.
|
||||||
|
func (m *MockService) ListAll(ctx context.Context) ([]*human.Human, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "ListAll", ctx)
|
||||||
|
ret0, _ := ret[0].([]*human.Human)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll indicates an expected call of ListAll.
|
||||||
|
func (mr *MockServiceMockRecorder) ListAll(ctx any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockService)(nil).ListAll), ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEmailNotificationsEnabled mocks base method.
|
||||||
|
func (m *MockService) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "UpdateEmailNotificationsEnabled", ctx, id, enabled)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEmailNotificationsEnabled indicates an expected call of UpdateEmailNotificationsEnabled.
|
||||||
|
func (mr *MockServiceMockRecorder) UpdateEmailNotificationsEnabled(ctx, id, enabled any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEmailNotificationsEnabled", reflect.TypeOf((*MockService)(nil).UpdateEmailNotificationsEnabled), ctx, id, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastEmailNotificationSentAt mocks base method.
|
||||||
|
func (m *MockService) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "UpdateLastEmailNotificationSentAt", ctx, id, t)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastEmailNotificationSentAt indicates an expected call of UpdateLastEmailNotificationSentAt.
|
||||||
|
func (mr *MockServiceMockRecorder) UpdateLastEmailNotificationSentAt(ctx, id, t any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastEmailNotificationSentAt", reflect.TypeOf((*MockService)(nil).UpdateLastEmailNotificationSentAt), ctx, id, t)
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ package human
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Human struct {
|
type Human struct {
|
||||||
ID string
|
ID string
|
||||||
Email string
|
Email string
|
||||||
EmailPrefix string
|
EmailPrefix string
|
||||||
CreatedAt time.Time
|
EmailNotificationsEnabled bool
|
||||||
|
AvatarObjectID *string
|
||||||
|
LastEmailNotificationSentAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
expoPushAPIURL = "https://exp.host/--/api/v2/push/send"
|
||||||
|
// expoMaxBatchSize is the documented per-request cap on push messages.
|
||||||
|
expoMaxBatchSize = 100
|
||||||
|
|
||||||
|
// Ticket error codes returned by Expo Push API. The only one we act on is
|
||||||
|
// DeviceNotRegistered — others are logged but not retried (per product call).
|
||||||
|
ExpoErrorDeviceNotRegistered = "DeviceNotRegistered"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sound defaults to "default" when empty (set in Send).
|
||||||
|
type Message struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Data map[string]any `json:"data,omitempty"`
|
||||||
|
Sound string `json:"sound,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is "ok" or "error". On error, Details["error"] carries the code
|
||||||
|
// (e.g. "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials").
|
||||||
|
type Ticket struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpoClient does NOT poll receipts and does NOT retry — fire-and-forget,
|
||||||
|
// with DeviceNotRegistered handled out-of-band by the notifier.
|
||||||
|
type ExpoClient struct {
|
||||||
|
http *http.Client
|
||||||
|
accessToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExpoClient(accessToken string) *ExpoClient {
|
||||||
|
return &ExpoClient{
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
accessToken: accessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type expoSendResponse struct {
|
||||||
|
Data []Ticket `json:"data"`
|
||||||
|
Errors []map[string]any `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send batches msgs (cap expoMaxBatchSize) and preserves input order:
|
||||||
|
// tickets[i] corresponds to msgs[i]. A request-level failure aborts the
|
||||||
|
// remaining batches; tickets already collected are returned with the error.
|
||||||
|
func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) {
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range msgs {
|
||||||
|
if msgs[i].Sound == "" {
|
||||||
|
msgs[i].Sound = "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tickets := make([]Ticket, 0, len(msgs))
|
||||||
|
for start := 0; start < len(msgs); start += expoMaxBatchSize {
|
||||||
|
end := start + expoMaxBatchSize
|
||||||
|
if end > len(msgs) {
|
||||||
|
end = len(msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := msgs[start:end]
|
||||||
|
batchTickets, err := c.sendBatch(ctx, batch)
|
||||||
|
tickets = append(tickets, batchTickets...)
|
||||||
|
if err != nil {
|
||||||
|
return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tickets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) {
|
||||||
|
body, err := json.Marshal(batch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal batch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||||
|
if c.accessToken != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("do request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed expoSendResponse
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if len(parsed.Data) != len(batch) {
|
||||||
|
return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch))
|
||||||
|
}
|
||||||
|
return parsed.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotifyInput struct {
|
||||||
|
NetworkID string
|
||||||
|
SenderHumanID string
|
||||||
|
SenderEmailPrefix string
|
||||||
|
|
||||||
|
ParticleID string
|
||||||
|
// One of "text", "media", "file", "quest", "paper". Containers (stream,
|
||||||
|
// folder) are dropped by the caller before reaching the notifier.
|
||||||
|
ParticleKind string
|
||||||
|
|
||||||
|
// Parent stream context — drives the title and the recipient set.
|
||||||
|
StreamID string
|
||||||
|
StreamName string
|
||||||
|
StreamVisibleTo []string
|
||||||
|
|
||||||
|
// Body — already formatted by the caller (e.g. truncated text, "Sent a
|
||||||
|
// voice message"). Title is derived inside the notifier.
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier fans out one particle to Expo:
|
||||||
|
// 1. Resolve recipients (visibility ∩ network members, minus sender).
|
||||||
|
// 2. Send a batched Expo request for every recipient's tokens.
|
||||||
|
// 3. Prune tokens Expo reports as DeviceNotRegistered.
|
||||||
|
//
|
||||||
|
// Online/offline presence is intentionally NOT consulted: a live WebSocket
|
||||||
|
// is a poor proxy for "user is actively consuming this particle right now"
|
||||||
|
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
|
||||||
|
// false-negatives outweigh the duplicate-notification cost on a focused
|
||||||
|
// device, which the OS handles via Focus modes and per-app settings.
|
||||||
|
type Notifier struct {
|
||||||
|
networkR network.Reader
|
||||||
|
tokens Service
|
||||||
|
expo *ExpoClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
|
||||||
|
return &Notifier{
|
||||||
|
networkR: networkR,
|
||||||
|
tokens: tokens,
|
||||||
|
expo: expo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
|
||||||
|
if in.NetworkID == "" || in.ParticleID == "" {
|
||||||
|
flog.Info("pushnotify: skip — missing ids",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := n.networkR.ListMembers(ctx, in.NetworkID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list network members: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
|
||||||
|
recipientsBeforeSenderFilter := len(recipients)
|
||||||
|
recipients = filterOut(recipients, in.SenderHumanID)
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
flog.Info("pushnotify: skip — no recipients",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
"senderHumanID", in.SenderHumanID,
|
||||||
|
"members", len(members),
|
||||||
|
"visibleTo", in.StreamVisibleTo,
|
||||||
|
"resolved", recipientsBeforeSenderFilter,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := n.tokens.ListForHumans(ctx, recipients)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("token lookup: %w", err)
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
flog.Info("pushnotify: skip — no tokens for recipients",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
"recipients", len(recipients),
|
||||||
|
"recipientIDs", recipients,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := buildMessages(tokens, in)
|
||||||
|
tickets, sendErr := n.expo.Send(ctx, msgs)
|
||||||
|
flog.Info("pushnotify: dispatch",
|
||||||
|
"networkID", in.NetworkID,
|
||||||
|
"particleID", in.ParticleID,
|
||||||
|
"recipients", len(recipients),
|
||||||
|
"tokens", len(tokens),
|
||||||
|
"sent", len(tickets),
|
||||||
|
)
|
||||||
|
|
||||||
|
n.cleanupDeadTokens(ctx, msgs, tickets)
|
||||||
|
|
||||||
|
if sendErr != nil {
|
||||||
|
return fmt.Errorf("expo send: %w", sendErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceNotRegistered is the one feedback signal we honor; other ticket
|
||||||
|
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
|
||||||
|
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
|
||||||
|
for i, t := range tickets {
|
||||||
|
if i >= len(msgs) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if t.Status != "error" || t.Details == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code, _ := t.Details["error"].(string)
|
||||||
|
if code != ExpoErrorDeviceNotRegistered {
|
||||||
|
if t.Status == "error" {
|
||||||
|
flog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
|
||||||
|
flog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
|
||||||
|
} else {
|
||||||
|
flog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
|
||||||
|
title := in.SenderEmailPrefix
|
||||||
|
if in.StreamName != "" {
|
||||||
|
title = in.SenderEmailPrefix + " in " + in.StreamName
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"kind": "particle_created",
|
||||||
|
"network_id": in.NetworkID,
|
||||||
|
"stream_id": in.StreamID,
|
||||||
|
"particle_id": in.ParticleID,
|
||||||
|
"sender_human_id": in.SenderHumanID,
|
||||||
|
"particle_kind": in.ParticleKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := make([]Message, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
msgs = append(msgs, Message{
|
||||||
|
To: t.Token,
|
||||||
|
Title: title,
|
||||||
|
Body: in.Body,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterOut(ids []string, exclude string) []string {
|
||||||
|
if exclude == "" {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
out := ids[:0:len(ids)]
|
||||||
|
for _, id := range ids {
|
||||||
|
if id != exclude {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repository interface {
|
||||||
|
upsert(ctx context.Context, t *PushToken) error
|
||||||
|
deleteForHuman(ctx context.Context, humanID, token string) error
|
||||||
|
deleteByToken(ctx context.Context, token string) error
|
||||||
|
listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRepository(pool *pgxpool.Pool) repository {
|
||||||
|
return &repositoryImpl{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO push_tokens (token, human_id, platform, app_version)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||||
|
ON CONFLICT (token) DO UPDATE SET
|
||||||
|
human_id = EXCLUDED.human_id,
|
||||||
|
platform = EXCLUDED.platform,
|
||||||
|
app_version = EXCLUDED.app_version,
|
||||||
|
last_seen_at = NOW()`,
|
||||||
|
t.Token, t.HumanID, string(t.Platform), t.AppVersion,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error {
|
||||||
|
result, err := r.pool.Exec(ctx,
|
||||||
|
`DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`,
|
||||||
|
humanID, token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`DELETE FROM push_tokens WHERE token = $1`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
|
||||||
|
if len(humanIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT token, human_id, platform, app_version, created_at, last_seen_at
|
||||||
|
FROM push_tokens
|
||||||
|
WHERE human_id = ANY($1)`,
|
||||||
|
humanIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tokens []*PushToken
|
||||||
|
for rows.Next() {
|
||||||
|
var t PushToken
|
||||||
|
var appVersion *string
|
||||||
|
var platform string
|
||||||
|
if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Platform = Platform(platform)
|
||||||
|
if appVersion != nil {
|
||||||
|
t.AppVersion = *appVersion
|
||||||
|
}
|
||||||
|
tokens = append(tokens, &t)
|
||||||
|
}
|
||||||
|
return tokens, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service stores per-device Expo push tokens and exposes the operations
|
||||||
|
// needed by both the HTTP handlers and the worker-side notifier.
|
||||||
|
type Service interface {
|
||||||
|
// Register returns ErrInvalidToken / ErrInvalidPlatform on bad input.
|
||||||
|
Register(ctx context.Context, humanID string, in RegisterInput) error
|
||||||
|
// Unregister is scoped to humanID so a user can't delete another user's
|
||||||
|
// token. Returns ErrNotFound if the token isn't owned by humanID.
|
||||||
|
Unregister(ctx context.Context, humanID, token string) error
|
||||||
|
// ListForHumans returns an empty slice when nothing matches.
|
||||||
|
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
|
||||||
|
// DeleteByToken removes a token regardless of owner — used to prune after
|
||||||
|
// Expo reports DeviceNotRegistered.
|
||||||
|
DeleteByToken(ctx context.Context, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterInput struct {
|
||||||
|
Token string
|
||||||
|
Platform Platform
|
||||||
|
AppVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceImpl struct {
|
||||||
|
repo repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(pool *pgxpool.Pool) Service {
|
||||||
|
return &serviceImpl{repo: newRepository(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error {
|
||||||
|
if !in.Platform.Valid() {
|
||||||
|
return ErrInvalidPlatform
|
||||||
|
}
|
||||||
|
if !IsValidExpoToken(in.Token) {
|
||||||
|
return ErrInvalidToken
|
||||||
|
}
|
||||||
|
return s.repo.upsert(ctx, &PushToken{
|
||||||
|
Token: in.Token,
|
||||||
|
HumanID: humanID,
|
||||||
|
Platform: in.Platform,
|
||||||
|
AppVersion: in.AppVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return ErrInvalidToken
|
||||||
|
}
|
||||||
|
return s.repo.deleteForHuman(ctx, humanID, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
|
||||||
|
return s.repo.listForHumans(ctx, humanIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error {
|
||||||
|
return s.repo.deleteByToken(ctx, token)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Package pushnotify owns mobile push notification delivery: storage of per-device
|
||||||
|
// Expo push tokens, and the worker-side orchestration of sending notifications
|
||||||
|
// to offline recipients via the Expo Push API.
|
||||||
|
package pushnotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Platform string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlatformIOS Platform = "ios"
|
||||||
|
PlatformAndroid Platform = "android"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p Platform) Valid() bool {
|
||||||
|
return p == PlatformIOS || p == PlatformAndroid
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushToken struct {
|
||||||
|
Token string
|
||||||
|
HumanID string
|
||||||
|
Platform Platform
|
||||||
|
AppVersion string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidPlatform = errors.New("invalid platform")
|
||||||
|
ErrInvalidToken = errors.New("invalid expo push token")
|
||||||
|
ErrNotFound = errors.New("push token not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValidExpoToken matches the two prefix formats Expo currently uses.
|
||||||
|
// We don't validate the inner contents — Expo's server will reject malformed
|
||||||
|
// tokens with a per-message error and we'll clean those up via DeviceNotRegistered.
|
||||||
|
func IsValidExpoToken(token string) bool {
|
||||||
|
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -33,6 +34,10 @@ type repository interface {
|
|||||||
getByID(ctx context.Context, id string) (*Human, error)
|
getByID(ctx context.Context, id string) (*Human, error)
|
||||||
create(ctx context.Context, email string) (*Human, error)
|
create(ctx context.Context, email string) (*Human, error)
|
||||||
exists(ctx context.Context, email string) (bool, error)
|
exists(ctx context.Context, email string) (bool, error)
|
||||||
|
listAll(ctx context.Context) ([]*Human, error)
|
||||||
|
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
|
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
updateAvatarObjectID(ctx context.Context, id string, objectID *string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type repositoryImpl struct {
|
type repositoryImpl struct {
|
||||||
@@ -46,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository {
|
|||||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||||
var h Human
|
var h Human
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, created_at FROM humans WHERE email = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE email = $1`,
|
||||||
email,
|
email,
|
||||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -62,9 +67,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
|||||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||||
var h Human
|
var h Human
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, created_at FROM humans WHERE id = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -84,9 +89,9 @@ func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, erro
|
|||||||
var h Human
|
var h Human
|
||||||
err = r.pool.QueryRow(ctx,
|
err = r.pool.QueryRow(ctx,
|
||||||
`INSERT INTO humans (id, email) VALUES ($1, $2)
|
`INSERT INTO humans (id, email) VALUES ($1, $2)
|
||||||
RETURNING id, email, created_at`,
|
RETURNING id, email, email_notifications_enabled, last_email_notification_sent_at, created_at`,
|
||||||
id.String(), email,
|
id.String(), email,
|
||||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -106,3 +111,66 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
|||||||
}
|
}
|
||||||
return exists, nil
|
return exists, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var humans []*Human
|
||||||
|
for rows.Next() {
|
||||||
|
var h Human
|
||||||
|
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.EmailPrefix = emailPrefix(h.Email)
|
||||||
|
humans = append(humans, &h)
|
||||||
|
}
|
||||||
|
return humans, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
|
||||||
|
result, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE humans SET email_notifications_enabled = $2 WHERE id = $1`,
|
||||||
|
id, enabled,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
|
||||||
|
result, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE humans SET last_email_notification_sent_at = $2 WHERE id = $1`,
|
||||||
|
id, t,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error {
|
||||||
|
result, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE humans SET avatar_object_id = $2 WHERE id = $1`,
|
||||||
|
id, objectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,30 @@ package human
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("human not found")
|
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("human not found")
|
||||||
|
ErrInvalidParam = errors.New("invalid param")
|
||||||
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||||
// GetByEmail returns ErrNotFound if no human found
|
// GetByEmail returns ErrNotFound if no human found.
|
||||||
GetByEmail(ctx context.Context, email string) (*Human, error)
|
GetByEmail(ctx context.Context, email string) (*Human, error)
|
||||||
|
// GetByID returns ErrNotFound if no human found.
|
||||||
|
GetByID(ctx context.Context, id string) (*Human, error)
|
||||||
|
ListAll(ctx context.Context) ([]*Human, error)
|
||||||
|
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
|
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
UpdateAvatar(ctx context.Context, id string, objectID string) error
|
||||||
|
DeleteAvatar(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
@@ -52,3 +65,50 @@ func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, err
|
|||||||
}
|
}
|
||||||
return h, err
|
return h, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Human, error) {
|
||||||
|
h, err := s.repo.getByID(ctx, id)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return h, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) ListAll(ctx context.Context) ([]*Human, error) {
|
||||||
|
return s.repo.listAll(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error {
|
||||||
|
err := s.repo.updateEmailNotificationsEnabled(ctx, id, enabled)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error {
|
||||||
|
err := s.repo.updateLastEmailNotificationSentAt(ctx, id, t)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error {
|
||||||
|
if objectID == "" {
|
||||||
|
return ErrInvalidParam
|
||||||
|
}
|
||||||
|
err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID))
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error {
|
||||||
|
err := s.repo.updateAvatarObjectID(ctx, id, nil)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
|
|||||||
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
||||||
assert.Equal(t, "another@example.com", anotherHuman.Email)
|
assert.Equal(t, "another@example.com", anotherHuman.Email)
|
||||||
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
||||||
|
|
||||||
|
// Test avatar handling
|
||||||
|
objectID := "obj_xxx"
|
||||||
|
err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, objectID, *anotherHuman.AvatarObjectID)
|
||||||
|
|
||||||
|
err = svc.DeleteAvatar(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, anotherHuman.AvatarObjectID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package livekit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
"github.com/livekit/protocol/auth"
|
||||||
|
"github.com/livekit/protocol/livekit"
|
||||||
|
lksdk "github.com/livekit/server-sdk-go/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
// GetJoinToken mints a participant JWT; name surfaces as the display name.
|
||||||
|
GetJoinToken(roomId string, humanId string, name string) (string, error)
|
||||||
|
ServerUrl() string
|
||||||
|
ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error)
|
||||||
|
// KeyProvider is used by handlers to verify webhook signatures.
|
||||||
|
KeyProvider() auth.KeyProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientImpl struct {
|
||||||
|
apiSecret string
|
||||||
|
apiKey string
|
||||||
|
hostUrl string
|
||||||
|
roomService *lksdk.RoomServiceClient
|
||||||
|
keyProvider auth.KeyProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient() Client {
|
||||||
|
apiKey := utils.MustGetEnv("LIVEKIT_API_KEY")
|
||||||
|
apiSecret := utils.MustGetEnv("LIVEKIT_API_SECRET")
|
||||||
|
hostUrl := utils.MustGetEnv("LIVEKIT_URL")
|
||||||
|
|
||||||
|
roomService := lksdk.NewRoomServiceClient(hostUrl, apiKey, apiSecret)
|
||||||
|
|
||||||
|
return &clientImpl{
|
||||||
|
apiSecret: apiSecret,
|
||||||
|
apiKey: apiKey,
|
||||||
|
hostUrl: hostUrl,
|
||||||
|
roomService: roomService,
|
||||||
|
keyProvider: auth.NewSimpleKeyProvider(apiKey, apiSecret),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientImpl) ServerUrl() string {
|
||||||
|
return c.hostUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientImpl) GetJoinToken(room, humanId, name string) (string, error) {
|
||||||
|
at := auth.NewAccessToken(c.apiKey, c.apiSecret)
|
||||||
|
grant := &auth.VideoGrant{
|
||||||
|
RoomJoin: true,
|
||||||
|
Room: room,
|
||||||
|
}
|
||||||
|
at.SetVideoGrant(grant).
|
||||||
|
SetIdentity(humanId).
|
||||||
|
SetName(name).
|
||||||
|
SetValidFor(time.Hour)
|
||||||
|
|
||||||
|
return at.ToJWT()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientImpl) ListParticipants(ctx context.Context, roomName string) ([]*livekit.ParticipantInfo, error) {
|
||||||
|
resp, err := c.roomService.ListParticipants(ctx, &livekit.ListParticipantsRequest{
|
||||||
|
Room: roomName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Participants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientImpl) KeyProvider() auth.KeyProvider {
|
||||||
|
return c.keyProvider
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package livestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
|
||||||
|
|
||||||
|
// MembershipPublisher fans network membership changes out to Firestore.
|
||||||
|
// Postgres is the source of truth; the reconciler heals drift, so publish
|
||||||
|
// failures are safe to log and ignore.
|
||||||
|
type MembershipPublisher interface {
|
||||||
|
Add(ctx context.Context, humanId, networkID string) error
|
||||||
|
Remove(ctx context.Context, humanId, networkID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMembershipPublisher(fs *firestore.Client) MembershipPublisher {
|
||||||
|
return &firestoreMembershipPublisher{fs: fs}
|
||||||
|
}
|
||||||
|
|
||||||
|
type firestoreMembershipPublisher struct {
|
||||||
|
fs *firestore.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *firestoreMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error {
|
||||||
|
_, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
|
||||||
|
"networks": firestore.ArrayUnion(networkID),
|
||||||
|
"updated_at": firestore.ServerTimestamp,
|
||||||
|
}, firestore.MergeAll)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *firestoreMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error {
|
||||||
|
_, err := p.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{
|
||||||
|
"networks": firestore.ArrayRemove(networkID),
|
||||||
|
"updated_at": firestore.ServerTimestamp,
|
||||||
|
}, firestore.MergeAll)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
|
// Source: ./membershippublisher.go
|
||||||
|
//
|
||||||
|
// Generated by this command:
|
||||||
|
//
|
||||||
|
// mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
|
||||||
|
//
|
||||||
|
|
||||||
|
// Package mock_livestore is a generated GoMock package.
|
||||||
|
package mock_livestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
reflect "reflect"
|
||||||
|
|
||||||
|
gomock "go.uber.org/mock/gomock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockMembershipPublisher is a mock of MembershipPublisher interface.
|
||||||
|
type MockMembershipPublisher struct {
|
||||||
|
ctrl *gomock.Controller
|
||||||
|
recorder *MockMembershipPublisherMockRecorder
|
||||||
|
isgomock struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockMembershipPublisherMockRecorder is the mock recorder for MockMembershipPublisher.
|
||||||
|
type MockMembershipPublisherMockRecorder struct {
|
||||||
|
mock *MockMembershipPublisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockMembershipPublisher creates a new mock instance.
|
||||||
|
func NewMockMembershipPublisher(ctrl *gomock.Controller) *MockMembershipPublisher {
|
||||||
|
mock := &MockMembershipPublisher{ctrl: ctrl}
|
||||||
|
mock.recorder = &MockMembershipPublisherMockRecorder{mock}
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||||
|
func (m *MockMembershipPublisher) EXPECT() *MockMembershipPublisherMockRecorder {
|
||||||
|
return m.recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mocks base method.
|
||||||
|
func (m *MockMembershipPublisher) Add(ctx context.Context, humanId, networkID string) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "Add", ctx, humanId, networkID)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add indicates an expected call of Add.
|
||||||
|
func (mr *MockMembershipPublisherMockRecorder) Add(ctx, humanId, networkID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockMembershipPublisher)(nil).Add), ctx, humanId, networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove mocks base method.
|
||||||
|
func (m *MockMembershipPublisher) Remove(ctx context.Context, humanId, networkID string) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "Remove", ctx, humanId, networkID)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove indicates an expected call of Remove.
|
||||||
|
func (mr *MockMembershipPublisherMockRecorder) Remove(ctx, humanId, networkID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockMembershipPublisher)(nil).Remove), ctx, humanId, networkID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsIOSPlayableMime(mime string) bool {
|
||||||
|
switch mime {
|
||||||
|
case "video/mp4", "video/quicktime", "audio/mp4", "audio/aac", "audio/x-m4a", "audio/mpeg":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAudio(mime string) bool {
|
||||||
|
return strings.HasPrefix(mime, "audio/")
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscodeInput struct {
|
||||||
|
SourceURL string
|
||||||
|
MimeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscodeOutput struct {
|
||||||
|
TempLocalFilePath string
|
||||||
|
OutputMimeType string
|
||||||
|
OutputExt string // e.g. ".m4a" or ".mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidInput error = errors.New("invalid input")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TranscodeToMp4 writes the result to a temp file; caller is responsible
|
||||||
|
// for deleting TempLocalFilePath.
|
||||||
|
func TranscodeToMp4(ctx context.Context, input TranscodeInput) (*TranscodeOutput, error) {
|
||||||
|
if input.SourceURL == "" || input.MimeType == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
isAudio := IsAudio(input.MimeType)
|
||||||
|
var outputExt, outputMime string
|
||||||
|
if isAudio {
|
||||||
|
outputExt = ".m4a"
|
||||||
|
outputMime = "audio/mp4"
|
||||||
|
} else {
|
||||||
|
outputExt = ".mp4"
|
||||||
|
outputMime = "video/mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "transcode-*"+outputExt)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: failed to create temp file", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
if isAudio {
|
||||||
|
args = []string{
|
||||||
|
"-y", "-i", input.SourceURL,
|
||||||
|
"-vn",
|
||||||
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
tmpPath,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 1440p–4K screen recordings + libx264's lookahead buffers can OOM the
|
||||||
|
// worker. Bound parallelism/lookahead and downscale to 1080p; the
|
||||||
|
// original WebM stays in GCS untouched.
|
||||||
|
args = []string{
|
||||||
|
"-y", "-i", input.SourceURL,
|
||||||
|
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||||
|
"-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
|
||||||
|
"-x264-params", "rc-lookahead=20:ref=2",
|
||||||
|
"-threads", "2", "-filter_threads", "2",
|
||||||
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
tmpPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, errors.Join(err, errors.New(stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TranscodeOutput{
|
||||||
|
TempLocalFilePath: tmpPath,
|
||||||
|
OutputMimeType: outputMime,
|
||||||
|
OutputExt: outputExt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -2,29 +2,51 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/auth"
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const emailContextKey contextKey = "email"
|
const (
|
||||||
|
emailContextKey contextKey = "email"
|
||||||
|
humanIdContextKey contextKey = "humanId"
|
||||||
|
isAdminContextKey contextKey = "isAdmin"
|
||||||
|
)
|
||||||
|
|
||||||
// WithEmail adds the email to the context
|
|
||||||
func WithEmail(ctx context.Context, email string) context.Context {
|
func WithEmail(ctx context.Context, email string) context.Context {
|
||||||
return context.WithValue(ctx, emailContextKey, email)
|
return context.WithValue(ctx, emailContextKey, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailFromContext extracts the email from the context
|
|
||||||
func EmailFromContext(ctx context.Context) (string, bool) {
|
func EmailFromContext(ctx context.Context) (string, bool) {
|
||||||
email, ok := ctx.Value(emailContextKey).(string)
|
email, ok := ctx.Value(emailContextKey).(string)
|
||||||
return email, ok
|
return email, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns a middleware that validates the session token and adds the email to the context
|
func WithHumanId(ctx context.Context, humanId string) context.Context {
|
||||||
|
return context.WithValue(ctx, humanIdContextKey, humanId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HumanIdFromContext(ctx context.Context) (string, bool) {
|
||||||
|
humanId, ok := ctx.Value(humanIdContextKey).(string)
|
||||||
|
return humanId, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithIsAdmin(ctx context.Context, isAdmin bool) context.Context {
|
||||||
|
return context.WithValue(ctx, isAdminContextKey, isAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAdminFromContext(ctx context.Context) bool {
|
||||||
|
isAdmin, ok := ctx.Value(isAdminContextKey).(bool)
|
||||||
|
return ok && isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth validates the bearer session token and populates email/humanId/isAdmin
|
||||||
|
// into the request context for downstream handlers.
|
||||||
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -34,24 +56,24 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email, err := authSvc.GetSession(r.Context(), token)
|
session, err := authSvc.GetSession(r.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-extend session
|
|
||||||
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
|
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
|
||||||
slog.Warn("failed to extend session", "error", err)
|
flog.Warn("failed to extend session", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := WithEmail(r.Context(), email)
|
ctx := WithEmail(r.Context(), session.Email)
|
||||||
|
ctx = WithHumanId(ctx, session.HumanId)
|
||||||
|
ctx = WithIsAdmin(ctx, authSvc.IsSystemAdmin(r.Context(), session.Email))
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractBearerToken extracts the token from the Authorization header
|
|
||||||
func extractBearerToken(r *http.Request) string {
|
func extractBearerToken(r *http.Request) string {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// CORS adds CORS headers and short-circuits preflight requests.
|
||||||
|
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||||
|
originSet := make(map[string]struct{}, len(allowedOrigins))
|
||||||
|
for _, o := range allowedOrigins {
|
||||||
|
originSet[o] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
|
// Empty allowedOrigins means allow all.
|
||||||
|
allowed := len(originSet) == 0
|
||||||
|
if !allowed {
|
||||||
|
_, allowed = originSet[origin]
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed && origin != "" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,16 @@ package network
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
AdminEmail string
|
AdminHumanId string
|
||||||
MemberEmails []string
|
MemberHumanIds []string
|
||||||
OpenStreamCapacity int
|
CreatedAt time.Time
|
||||||
OpenStreamCount int
|
}
|
||||||
CreatedAt time.Time
|
|
||||||
|
type Invitation struct {
|
||||||
|
NetworkID string
|
||||||
|
NetworkName string
|
||||||
|
Email string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Reader interface {
|
||||||
|
// GetByID returns ErrNotFound if network doesn't exist.
|
||||||
|
GetByID(ctx context.Context, id string) (*Network, error)
|
||||||
|
// ListForHuman returns ErrInvalidHumanId if humanId is empty.
|
||||||
|
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
|
// IsMember returns ErrInvalidHumanId if humanId is empty.
|
||||||
|
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
|
// ListMembers returns an empty slice if the network doesn't exist.
|
||||||
|
ListMembers(ctx context.Context, networkID string) ([]string, error)
|
||||||
|
ListAll(ctx context.Context) ([]*Network, error)
|
||||||
|
// ListAllMemberships returns humanId -> networkIds for every human with at
|
||||||
|
// least one membership. Humans with zero memberships are absent from the map.
|
||||||
|
ListAllMemberships(ctx context.Context) (map[string][]string, error)
|
||||||
|
|
||||||
|
CountSeats(ctx context.Context, networkID string) (int, error)
|
||||||
|
// ListInvitationsForEmail returns ErrInvalidEmail if normalization fails.
|
||||||
|
ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error)
|
||||||
|
ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readerImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
repo repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// newReader exposes the concrete type so the service can embed it without
|
||||||
|
// hiding pool/repo behind the Reader interface.
|
||||||
|
func newReader(pool *pgxpool.Pool) *readerImpl {
|
||||||
|
return &readerImpl{
|
||||||
|
pool: pool,
|
||||||
|
repo: newRepository(pool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReader(pool *pgxpool.Pool) Reader {
|
||||||
|
return newReader(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) GetByID(ctx context.Context, id string) (*Network, error) {
|
||||||
|
n, err := r.repo.getByID(ctx, id)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||||
|
if humanId == "" {
|
||||||
|
return nil, ErrInvalidHumanId
|
||||||
|
}
|
||||||
|
return r.repo.getNetworksForHuman(ctx, humanId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) IsMember(ctx context.Context, networkID, humanId string) (bool, error) {
|
||||||
|
if humanId == "" {
|
||||||
|
return false, ErrInvalidHumanId
|
||||||
|
}
|
||||||
|
return r.repo.isMember(ctx, networkID, humanId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) ListMembers(ctx context.Context, networkID string) ([]string, error) {
|
||||||
|
// Admin is guaranteed to be in network_members: Create() calls AddMembers
|
||||||
|
// for the admin, and RemoveMemberFromNetwork rejects admin removal.
|
||||||
|
return r.repo.getMemberHumanIds(ctx, networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) ListAll(ctx context.Context) ([]*Network, error) {
|
||||||
|
return r.repo.listAll(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) {
|
||||||
|
return r.repo.listAllMemberships(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) CountSeats(ctx context.Context, networkID string) (int, error) {
|
||||||
|
return r.repo.countSeats(ctx, r.pool, networkID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) ListInvitationsForEmail(ctx context.Context, email string) ([]*Invitation, error) {
|
||||||
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||||
|
}
|
||||||
|
return r.repo.getInvitationsByEmail(ctx, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readerImpl) ListInvitationsForNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
|
||||||
|
return r.repo.getInvitationsByNetwork(ctx, networkID)
|
||||||
|
}
|
||||||
@@ -5,10 +5,19 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"go.jetify.com/typeid"
|
"go.jetify.com/typeid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx,
|
||||||
|
// so repository helpers can run standalone or inside a transaction.
|
||||||
|
type dbtx interface {
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
var errNotFound = errors.New("not found")
|
var errNotFound = errors.New("not found")
|
||||||
|
|
||||||
type networkIDPrefix struct{}
|
type networkIDPrefix struct{}
|
||||||
@@ -23,21 +32,35 @@ func newNetworkID() (networkID, error) {
|
|||||||
return typeid.New[networkID]()
|
return typeid.New[networkID]()
|
||||||
}
|
}
|
||||||
|
|
||||||
var errCapacityExceeded = errors.New("capacity exceeded")
|
|
||||||
|
|
||||||
type repository interface {
|
type repository interface {
|
||||||
create(ctx context.Context, name, adminEmail string) (*Network, error)
|
create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
||||||
getByID(ctx context.Context, id string) (*Network, error)
|
getByID(ctx context.Context, id string) (*Network, error)
|
||||||
updateName(ctx context.Context, id, name string) error
|
updateName(ctx context.Context, id, name string) error
|
||||||
delete(ctx context.Context, id string) error
|
delete(ctx context.Context, id string) error
|
||||||
addMember(ctx context.Context, networkID, email string) error
|
addMember(ctx context.Context, db dbtx, networkID, humanId string) error
|
||||||
removeMember(ctx context.Context, networkID, email string) error
|
removeMember(ctx context.Context, db dbtx, networkID, humanId string) error
|
||||||
getMemberEmails(ctx context.Context, networkID string) ([]string, error)
|
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
||||||
getNetworksForEmail(ctx context.Context, email string) ([]*Network, error)
|
countSeats(ctx context.Context, db dbtx, networkID string) (int, error)
|
||||||
isMember(ctx context.Context, networkID, email string) (bool, error)
|
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
setOpenStreamCapacity(ctx context.Context, id string, capacity int) error
|
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
incrementOpenStreamCount(ctx context.Context, id string) error
|
listAll(ctx context.Context) ([]*Network, error)
|
||||||
decrementOpenStreamCount(ctx context.Context, id string) error
|
|
||||||
|
// listAllMemberships returns humanId -> networkIds for every human with at
|
||||||
|
// least one membership. Humans with zero memberships are absent from the map.
|
||||||
|
listAllMemberships(ctx context.Context) (map[string][]string, error)
|
||||||
|
|
||||||
|
// Invitations
|
||||||
|
createInvitation(ctx context.Context, networkID, email string) error
|
||||||
|
getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error)
|
||||||
|
getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
|
||||||
|
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centralized so SELECTs and scanNetwork stay in sync.
|
||||||
|
const networkColumns = `id, name, admin_human_id, created_at`
|
||||||
|
|
||||||
|
func scanNetwork(row pgx.Row, n *Network) error {
|
||||||
|
return row.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
type repositoryImpl struct {
|
type repositoryImpl struct {
|
||||||
@@ -48,43 +71,44 @@ func newRepository(pool *pgxpool.Pool) repository {
|
|||||||
return &repositoryImpl{pool: pool}
|
return &repositoryImpl{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*Network, error) {
|
func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
||||||
id, err := newNetworkID()
|
id, err := newNetworkID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var n Network
|
var n Network
|
||||||
err = r.pool.QueryRow(ctx,
|
row := r.pool.QueryRow(ctx,
|
||||||
`INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3)
|
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
|
||||||
RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`,
|
RETURNING `+networkColumns,
|
||||||
id.String(), name, adminEmail,
|
id.String(), name, adminHumanId,
|
||||||
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
|
)
|
||||||
if err != nil {
|
if err := scanNetwork(row, &n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
n.MemberEmails = []string{}
|
n.MemberHumanIds = []string{}
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
||||||
var n Network
|
var n Network
|
||||||
err := r.pool.QueryRow(ctx,
|
row := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, name, admin_email, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`,
|
`SELECT `+networkColumns+` FROM networks WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
|
)
|
||||||
if err != nil {
|
if err := scanNetwork(row, &n); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
n.MemberEmails, err = r.getMemberEmails(ctx, id)
|
memberIds, err := r.getMemberHumanIds(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
n.MemberHumanIds = memberIds
|
||||||
|
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
@@ -114,26 +138,35 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) addMember(ctx context.Context, networkID, email string) error {
|
func (r *repositoryImpl) addMember(ctx context.Context, db dbtx, networkID, humanId string) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := db.Exec(ctx,
|
||||||
`INSERT INTO network_members (network_id, email) VALUES ($1, $2)
|
`INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
|
||||||
ON CONFLICT (network_id, email) DO NOTHING`,
|
ON CONFLICT (network_id, human_id) DO NOTHING`,
|
||||||
networkID, email,
|
networkID, humanId,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) removeMember(ctx context.Context, networkID, email string) error {
|
func (r *repositoryImpl) removeMember(ctx context.Context, db dbtx, networkID, humanId string) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := db.Exec(ctx,
|
||||||
`DELETE FROM network_members WHERE network_id = $1 AND email = $2`,
|
`DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
|
||||||
networkID, email,
|
networkID, humanId,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) ([]string, error) {
|
func (r *repositoryImpl) countSeats(ctx context.Context, db dbtx, networkID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM network_members WHERE network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT email FROM network_members WHERE network_id = $1`,
|
`SELECT human_id FROM network_members WHERE network_id = $1`,
|
||||||
networkID,
|
networkID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,24 +174,24 @@ func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string)
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var emails []string
|
var humanIds []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var email string
|
var humanId string
|
||||||
if err := rows.Scan(&email); err != nil {
|
if err := rows.Scan(&humanId); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
emails = append(emails, email)
|
humanIds = append(humanIds, humanId)
|
||||||
}
|
}
|
||||||
return emails, rows.Err()
|
return humanIds, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) {
|
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT n.id, n.name, n.admin_email, n.open_stream_capacity, n.open_stream_count, n.created_at
|
`SELECT `+networkColumns+`
|
||||||
FROM networks n
|
FROM networks
|
||||||
WHERE n.admin_email = $1
|
WHERE admin_human_id = $1
|
||||||
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`,
|
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = id AND nm.human_id = $1)`,
|
||||||
email,
|
humanId,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -168,7 +201,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
|
|||||||
var networks []*Network
|
var networks []*Network
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Network
|
var n Network
|
||||||
if err := rows.Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil {
|
if err := scanNetwork(rows, &n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
networks = append(networks, &n)
|
networks = append(networks, &n)
|
||||||
@@ -178,7 +211,7 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, n := range networks {
|
for _, n := range networks {
|
||||||
n.MemberEmails, err = r.getMemberEmails(ctx, n.ID)
|
n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -187,66 +220,128 @@ func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string)
|
|||||||
return networks, nil
|
return networks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) isMember(ctx context.Context, networkID, email string) (bool, error) {
|
func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string) (bool, error) {
|
||||||
var isMember bool
|
var isMember bool
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM networks n
|
SELECT 1 FROM networks n
|
||||||
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2
|
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2
|
||||||
WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL)
|
WHERE n.id = $1 AND (n.admin_human_id = $2 OR nm.human_id IS NOT NULL)
|
||||||
)
|
)
|
||||||
`, networkID, email).Scan(&isMember)
|
`, networkID, humanId).Scan(&isMember)
|
||||||
return isMember, err
|
return isMember, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error {
|
func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) {
|
||||||
result, err := r.pool.Exec(ctx,
|
rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`)
|
||||||
`UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`,
|
|
||||||
capacity, id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if result.RowsAffected() == 0 {
|
defer rows.Close()
|
||||||
return errNotFound
|
|
||||||
|
out := map[string][]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var humanId, networkId string
|
||||||
|
if err := rows.Scan(&humanId, &networkId); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[humanId] = append(out[humanId], networkId)
|
||||||
}
|
}
|
||||||
return nil
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error {
|
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
||||||
result, err := r.pool.Exec(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`UPDATE networks SET open_stream_count = open_stream_count + 1
|
`SELECT `+networkColumns+` FROM networks`,
|
||||||
WHERE id = $1 AND open_stream_count < open_stream_capacity`,
|
|
||||||
id,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if result.RowsAffected() == 0 {
|
defer rows.Close()
|
||||||
// Check if network exists vs capacity exceeded
|
|
||||||
var exists bool
|
var networks []*Network
|
||||||
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists)
|
for rows.Next() {
|
||||||
|
var n Network
|
||||||
|
if err := scanNetwork(rows, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
networks = append(networks, &n)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range networks {
|
||||||
|
n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
return errNotFound
|
|
||||||
}
|
|
||||||
return errCapacityExceeded
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return networks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error {
|
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
|
||||||
result, err := r.pool.Exec(ctx,
|
_, err := r.pool.Exec(ctx,
|
||||||
`UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`,
|
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
|
||||||
id,
|
ON CONFLICT (network_id, email) DO NOTHING`,
|
||||||
|
networkID, email,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT ni.network_id, n.name, ni.email, ni.created_at
|
||||||
|
FROM network_invitations ni
|
||||||
|
JOIN networks n ON n.id = ni.network_id
|
||||||
|
WHERE ni.email = $1`,
|
||||||
|
email,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if result.RowsAffected() == 0 {
|
defer rows.Close()
|
||||||
return errNotFound
|
|
||||||
|
var invitations []*Invitation
|
||||||
|
for rows.Next() {
|
||||||
|
var inv Invitation
|
||||||
|
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invitations = append(invitations, &inv)
|
||||||
}
|
}
|
||||||
return nil
|
return invitations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT ni.network_id, n.name, ni.email, ni.created_at
|
||||||
|
FROM network_invitations ni
|
||||||
|
JOIN networks n ON n.id = ni.network_id
|
||||||
|
WHERE ni.network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var invitations []*Invitation
|
||||||
|
for rows.Next() {
|
||||||
|
var inv Invitation
|
||||||
|
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
invitations = append(invitations, &inv)
|
||||||
|
}
|
||||||
|
return invitations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error {
|
||||||
|
_, err := db.Exec(ctx,
|
||||||
|
`DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`,
|
||||||
|
networkID, email,
|
||||||
|
)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+320
-72
@@ -4,76 +4,114 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
|
"github.com/flowy-live/llink/internal/constants"
|
||||||
|
"github.com/flowy-live/llink/internal/human"
|
||||||
|
"github.com/flowy-live/llink/internal/livestore"
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("network not found")
|
var ErrNotFound = errors.New("network not found")
|
||||||
var ErrInvalidName = errors.New("name cannot be empty")
|
var ErrInvalidName = errors.New("name cannot be empty")
|
||||||
|
var ErrInvalidEmail = errors.New("invalid email")
|
||||||
|
var ErrInvalidHumanId = errors.New("invalid humanId")
|
||||||
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
||||||
|
var ErrInvalidRetentionHours = errors.New("message retention hours must be between 24 and 336")
|
||||||
|
|
||||||
|
type humanLookup interface {
|
||||||
|
GetByID(ctx context.Context, id string) (*human.Human, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty.
|
Reader
|
||||||
Create(ctx context.Context, name, adminEmail string) (*Network, error)
|
|
||||||
// GetByID returns ErrNotFound if network doesn't exist.
|
// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
||||||
GetByID(ctx context.Context, id string) (*Network, error)
|
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
||||||
// SetName returns ErrNotFound or ErrInvalidName.
|
// SetName returns ErrNotFound or ErrInvalidName.
|
||||||
SetName(ctx context.Context, id, name string) error
|
SetName(ctx context.Context, id, name string) error
|
||||||
AddMembers(ctx context.Context, networkID string, emails []string) error
|
// AddMembers inserts members and syncs the new seat count to billing
|
||||||
RemoveMember(ctx context.Context, networkID, email string) error
|
// atomically; a Stripe failure rolls the insert back.
|
||||||
ListForEmail(ctx context.Context, email string) ([]*Network, error)
|
// Returns ErrInvalidHumanId if any humanId is empty.
|
||||||
IsMember(ctx context.Context, networkID, email string) (bool, error)
|
AddMembers(ctx context.Context, networkID string, humanIds []string) error
|
||||||
|
// RemoveMember returns ErrInvalidHumanId if humanId is empty.
|
||||||
|
RemoveMember(ctx context.Context, networkID, humanId string) error
|
||||||
|
|
||||||
// SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound.
|
// Invitations (email-based, for users who haven't registered yet)
|
||||||
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error
|
|
||||||
// IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded.
|
// InviteByEmail returns ErrNotFound if the network doesn't exist
|
||||||
IncrementOpenStreamCount(ctx context.Context, networkID string) error
|
// or ErrInvalidEmail if any email fails normalization.
|
||||||
// DecrementOpenStreamCount returns ErrNotFound.
|
InviteByEmail(ctx context.Context, networkID string, emails []string) error
|
||||||
DecrementOpenStreamCount(ctx context.Context, networkID string) error
|
// AcceptInvitation returns ErrInvalidEmail or ErrInvalidHumanId.
|
||||||
|
AcceptInvitation(ctx context.Context, networkID, email, humanId string) error
|
||||||
|
// RevokeInvitation returns ErrInvalidEmail.
|
||||||
|
RevokeInvitation(ctx context.Context, networkID, email string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
repo repository
|
*readerImpl
|
||||||
|
aeroSvc pbaero.PrimaryClient
|
||||||
|
billingSvc billing.Service
|
||||||
|
pub livestore.MembershipPublisher
|
||||||
|
humanLookup humanLookup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(pool *pgxpool.Pool) Service {
|
func NewService(
|
||||||
return &serviceImpl{repo: newRepository(pool)}
|
pool *pgxpool.Pool,
|
||||||
|
aeroSvc pbaero.PrimaryClient,
|
||||||
|
billingSvc billing.Service,
|
||||||
|
pub livestore.MembershipPublisher,
|
||||||
|
humanLookup humanLookup,
|
||||||
|
) Service {
|
||||||
|
return &serviceImpl{
|
||||||
|
readerImpl: newReader(pool),
|
||||||
|
aeroSvc: aeroSvc,
|
||||||
|
billingSvc: billingSvc,
|
||||||
|
pub: pub,
|
||||||
|
humanLookup: humanLookup,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) Create(ctx context.Context, name, adminEmail string) (*Network, error) {
|
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, ErrInvalidName
|
return nil, ErrInvalidName
|
||||||
}
|
}
|
||||||
|
|
||||||
adminEmail, err := utils.NormalizeEmail(adminEmail)
|
network, err := s.repo.create(ctx, name, adminHumanId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
network, err := s.repo.create(ctx, name, adminEmail)
|
if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.AddMembers(ctx, network.ID, []string{adminEmail})
|
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||||
|
ToEmails: []string{constants.FlowyAdminEmail},
|
||||||
|
Subject: fmt.Sprintf("New network created: %s", network.Name),
|
||||||
|
TemplateData: &pbaero.ShootEmailRequest_GenericFlowyAdminAlertData{
|
||||||
|
GenericFlowyAdminAlertData: &pbaero.GenericFlowyAdminAlertData{
|
||||||
|
Message: "This is a simple notification that a new network was created. Please attend to them.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
flog.Warn("unable to send admin update email", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return network, nil
|
return network, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Network, error) {
|
|
||||||
n, err := s.repo.getByID(ctx, id)
|
|
||||||
if errors.Is(err, errNotFound) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -87,69 +125,279 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error {
|
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
|
||||||
|
if slices.Contains(humanIds, "") {
|
||||||
|
return ErrInvalidHumanId
|
||||||
|
}
|
||||||
|
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
|
for _, humanId := range humanIds {
|
||||||
|
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, humanId := range humanIds {
|
||||||
|
s.mirrorAddMembership(ctx, humanId, networkID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
|
||||||
|
if humanId == "" {
|
||||||
|
return ErrInvalidHumanId
|
||||||
|
}
|
||||||
|
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
|
return s.repo.removeMember(ctx, tx, networkID, humanId)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mirrorRemoveMembership(ctx, humanId, networkID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the live store membership projection (humans/{humanId}.networks).
|
||||||
|
// Postgres is the source of truth: failures are logged and the reconciler heals drift.
|
||||||
|
func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) {
|
||||||
|
if err := s.pub.Add(ctx, humanId, networkID); err != nil {
|
||||||
|
flog.Error("membership publish add failed", "error", err, "humanId", humanId, "networkID", networkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) {
|
||||||
|
if err := s.pub.Remove(ctx, humanId, networkID); err != nil {
|
||||||
|
flog.Error("membership publish remove failed", "error", err, "humanId", humanId, "networkID", networkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs fn in a tx and syncs seats to billing atomically. Any error rolls back.
|
||||||
|
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
seats, err := s.repo.countSeats(ctx, tx, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("count seats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.billingSvc.SyncSeats(ctx, networkID, seats); err != nil {
|
||||||
|
return fmt.Errorf("sync billing seats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit tx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
||||||
|
network, err := s.repo.getByID(ctx, networkID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
normalized, err := utils.NormalizeEmail(email)
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid email: %w", err)
|
return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err)
|
||||||
}
|
}
|
||||||
if err := s.repo.addMember(ctx, networkID, normalized); err != nil {
|
if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||||
|
ToEmails: []string{email},
|
||||||
|
Subject: fmt.Sprintf("Invitation to Join %s on Flowy.llink", network.Name),
|
||||||
|
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
|
||||||
|
SimpleHtmlData: &pbaero.SimpleHtmlData{
|
||||||
|
Html: buildInvitationHTML(network.Name),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error {
|
func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
|
||||||
email, err := utils.NormalizeEmail(email)
|
network, err := s.repo.getByID(ctx, networkID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid email: %w", err)
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return s.repo.removeMember(ctx, networkID, email)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *serviceImpl) ListForEmail(ctx context.Context, email string) ([]*Network, error) {
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
email, err := utils.NormalizeEmail(email)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid email: %w", err)
|
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||||
}
|
}
|
||||||
return s.repo.getNetworksForEmail(ctx, email)
|
if humanId == "" {
|
||||||
|
return ErrInvalidHumanId
|
||||||
|
}
|
||||||
|
|
||||||
|
prevMembersHumanIds, membersErr := s.repo.getMemberHumanIds(ctx, networkID)
|
||||||
|
if membersErr != nil {
|
||||||
|
flog.Warn("unable to get member human ids", "error", membersErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
|
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.addMember(ctx, tx, networkID, humanId)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if membersErr == nil && len(prevMembersHumanIds) > 0 {
|
||||||
|
emailRecipients := make([]string, 0, len(prevMembersHumanIds))
|
||||||
|
for _, memberHumanId := range prevMembersHumanIds {
|
||||||
|
if memberHumanId != "" {
|
||||||
|
human, err := s.humanLookup.GetByID(ctx, memberHumanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Warn("unable to find human", "error", err, "humanId", memberHumanId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if human.Email == "" || !human.EmailNotificationsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emailRecipients = append(emailRecipients, human.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(emailRecipients) > 0 {
|
||||||
|
newMemberEmailPrefix := strings.Split(normalized, "@")[0]
|
||||||
|
_, err = s.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||||
|
ToEmails: emailRecipients,
|
||||||
|
Subject: fmt.Sprintf("A new member has joined %s", network.Name),
|
||||||
|
TemplateData: &pbaero.ShootEmailRequest_SimpleHtmlData{
|
||||||
|
SimpleHtmlData: &pbaero.SimpleHtmlData{
|
||||||
|
Html: buildNewMemberHTML(network.Name, newMemberEmailPrefix),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Warn("unable to send email notification", "email", email, "network", network.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mirrorAddMembership(ctx, humanId, networkID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) IsMember(ctx context.Context, networkID, email string) (bool, error) {
|
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
|
||||||
email, err := utils.NormalizeEmail(email)
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||||
}
|
}
|
||||||
return s.repo.isMember(ctx, networkID, email)
|
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error {
|
const (
|
||||||
if capacity < 0 {
|
emailWebAppURL = "https://llink.flowy.live"
|
||||||
capacity = 0
|
emailDesktopURL = "llink://"
|
||||||
}
|
emailDownloadURL = "https://flowylabs.ai/llink/download"
|
||||||
err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity)
|
)
|
||||||
if errors.Is(err, errNotFound) {
|
|
||||||
return ErrNotFound
|
// emailDesktopFooter is the shared secondary line offering the desktop app.
|
||||||
}
|
// The web app is always the primary CTA (no install required), so desktop is
|
||||||
return err
|
// kept quiet here and shared across templates so the two can't drift apart.
|
||||||
|
func emailDesktopFooter() string {
|
||||||
|
return fmt.Sprintf(`<tr>
|
||||||
|
<td style="font-size:13px;color:#888888;">
|
||||||
|
Prefer the desktop app? <a href="%s" style="color:#111111;">Open it</a> or <a href="%s" style="color:#111111;">download here</a>.
|
||||||
|
</td>
|
||||||
|
</tr>`, emailDesktopURL, emailDownloadURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) IncrementOpenStreamCount(ctx context.Context, networkID string) error {
|
func buildInvitationHTML(networkName string) string {
|
||||||
err := s.repo.incrementOpenStreamCount(ctx, networkID)
|
safeName := html.EscapeString(networkName)
|
||||||
if errors.Is(err, errNotFound) {
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
return ErrNotFound
|
<html>
|
||||||
}
|
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
if errors.Is(err, errCapacityExceeded) {
|
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
|
||||||
return ErrCapacityExceeded
|
<tr>
|
||||||
}
|
<td align="center">
|
||||||
return err
|
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
|
||||||
|
You've been invited to join %s on Flowy.llink
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
|
||||||
|
Open the app to accept your invitation. If you're new, you'll be prompted to create a free account first.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:24px;">
|
||||||
|
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
|
||||||
|
Open Flowy.llink (web)
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
%s
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`, safeName, emailWebAppURL, emailDesktopFooter())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) DecrementOpenStreamCount(ctx context.Context, networkID string) error {
|
// buildNewMemberHTML is an email template to notify other members that a new member has joined
|
||||||
err := s.repo.decrementOpenStreamCount(ctx, networkID)
|
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
|
||||||
if errors.Is(err, errNotFound) {
|
safeName := html.EscapeString(networkName)
|
||||||
return ErrNotFound
|
safeEmail := html.EscapeString(newMemberEmailPrefix)
|
||||||
}
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
return err
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f5f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f5f5f7;padding:48px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" border="0" style="max-width:480px;background-color:#ffffff;border-radius:12px;padding:40px;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:22px;font-weight:600;color:#111111;padding-bottom:16px;">
|
||||||
|
A new member joined %s
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:15px;line-height:1.5;color:#444444;padding-bottom:32px;">
|
||||||
|
<strong style="color:#111111;">%s</strong> just joined your network on Flowy.llink. Say hello and bring them up to speed.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:24px;">
|
||||||
|
<a href="%s" style="display:inline-block;background-color:#111111;color:#ffffff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:8px;">
|
||||||
|
Open Flowy.llink (web)
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
%s
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`, safeName, safeEmail, emailWebAppURL, emailDesktopFooter())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
mock_billing "github.com/flowy-live/llink/internal/billing/mocks"
|
||||||
|
"github.com/flowy-live/llink/internal/human"
|
||||||
|
mock_human "github.com/flowy-live/llink/internal/human/mocks"
|
||||||
|
mock_livestore "github.com/flowy-live/llink/internal/livestore/mocks"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/testhelper"
|
"github.com/flowy-live/llink/internal/testhelper"
|
||||||
|
"github.com/flowy-live/llink/internal/testhelper/mocks/aero"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dbPool *pgxpool.Pool
|
var dbPool *pgxpool.Pool
|
||||||
@@ -21,16 +28,47 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(ret)
|
os.Exit(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) network.Service {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockAero := aero.NewMockPrimaryClient(ctrl)
|
||||||
|
mockAero.EXPECT().
|
||||||
|
ShootEmail(gomock.Any(), gomock.Any()).
|
||||||
|
Return(&pbaero.ShootEmailResponse{}, nil).
|
||||||
|
AnyTimes()
|
||||||
|
|
||||||
|
mockBilling := mock_billing.NewMockService(ctrl)
|
||||||
|
mockBilling.EXPECT().SyncSeats(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||||
|
|
||||||
|
mockPub := mock_livestore.NewMockMembershipPublisher(ctrl)
|
||||||
|
mockPub.EXPECT().Add(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||||
|
mockPub.EXPECT().Remove(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
|
||||||
|
|
||||||
|
mockHuman := mock_human.NewMockService(ctrl)
|
||||||
|
mockHuman.EXPECT().GetByID(gomock.Any(), gomock.Any()).Return(&human.Human{
|
||||||
|
ID: "test_human",
|
||||||
|
Email: "test@gmail.com",
|
||||||
|
EmailPrefix: "test",
|
||||||
|
EmailNotificationsEnabled: false,
|
||||||
|
}, nil).AnyTimes()
|
||||||
|
|
||||||
|
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNetworkService(t *testing.T) {
|
func TestNetworkService(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc := network.NewService(dbPool)
|
svc := newTestService(t)
|
||||||
|
|
||||||
|
adminHumanId := "human_admin123"
|
||||||
|
member1HumanId := "human_member1abc"
|
||||||
|
member2HumanId := "human_member2def"
|
||||||
|
strangerHumanId := "human_stranger789"
|
||||||
|
|
||||||
// Test Create
|
// Test Create
|
||||||
createdNetwork, err := svc.Create(ctx, "Test Network", "admin@example.com")
|
createdNetwork, err := svc.Create(ctx, "Test Network", adminHumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, createdNetwork.ID)
|
assert.NotEmpty(t, createdNetwork.ID)
|
||||||
assert.Equal(t, "Test Network", createdNetwork.Name)
|
assert.Equal(t, "Test Network", createdNetwork.Name)
|
||||||
assert.Equal(t, "admin@example.com", createdNetwork.AdminEmail)
|
assert.Equal(t, adminHumanId, createdNetwork.AdminHumanId)
|
||||||
assert.NotZero(t, createdNetwork.CreatedAt)
|
assert.NotZero(t, createdNetwork.CreatedAt)
|
||||||
|
|
||||||
// Test GetByID
|
// Test GetByID
|
||||||
@@ -38,7 +76,7 @@ func TestNetworkService(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
|
assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
|
||||||
assert.Equal(t, createdNetwork.Name, foundNetwork.Name)
|
assert.Equal(t, createdNetwork.Name, foundNetwork.Name)
|
||||||
assert.Equal(t, createdNetwork.AdminEmail, foundNetwork.AdminEmail)
|
assert.Equal(t, createdNetwork.AdminHumanId, foundNetwork.AdminHumanId)
|
||||||
|
|
||||||
// Test GetByID with non-existent id
|
// Test GetByID with non-existent id
|
||||||
_, err = svc.GetByID(ctx, "network_nonexistent")
|
_, err = svc.GetByID(ctx, "network_nonexistent")
|
||||||
@@ -60,45 +98,45 @@ func TestNetworkService(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, network.ErrNotFound)
|
assert.ErrorIs(t, err, network.ErrNotFound)
|
||||||
|
|
||||||
// Test AddMembers
|
// Test AddMembers
|
||||||
err = svc.AddMembers(ctx, createdNetwork.ID, []string{"member1@example.com", "member2@example.com"})
|
err = svc.AddMembers(ctx, createdNetwork.ID, []string{member1HumanId, member2HumanId})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Test ListForEmail - should find network for admin
|
// Test ListForHuman - should find network for admin
|
||||||
networks, err := svc.ListForEmail(ctx, "admin@example.com")
|
networks, err := svc.ListForHuman(ctx, adminHumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, networks, 1)
|
assert.Len(t, networks, 1)
|
||||||
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
||||||
|
|
||||||
// Test ListForEmail - should find network for member
|
// Test ListForHuman - should find network for member
|
||||||
networks, err = svc.ListForEmail(ctx, "member1@example.com")
|
networks, err = svc.ListForHuman(ctx, member1HumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, networks, 1)
|
assert.Len(t, networks, 1)
|
||||||
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
||||||
|
|
||||||
// Test ListForEmail - should return empty for non-member
|
// Test ListForHuman - should return empty for non-member
|
||||||
networks, err = svc.ListForEmail(ctx, "stranger@example.com")
|
networks, err = svc.ListForHuman(ctx, strangerHumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, networks, 0)
|
assert.Len(t, networks, 0)
|
||||||
|
|
||||||
// Test RemoveMember
|
// Test RemoveMember
|
||||||
err = svc.RemoveMember(ctx, createdNetwork.ID, "member1@example.com")
|
err = svc.RemoveMember(ctx, createdNetwork.ID, member1HumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify member was removed
|
// Verify member was removed
|
||||||
networks, err = svc.ListForEmail(ctx, "member1@example.com")
|
networks, err = svc.ListForHuman(ctx, member1HumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, networks, 0)
|
assert.Len(t, networks, 0)
|
||||||
|
|
||||||
// member2 should still have access
|
// member2 should still have access
|
||||||
networks, err = svc.ListForEmail(ctx, "member2@example.com")
|
networks, err = svc.ListForHuman(ctx, member2HumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, networks, 1)
|
assert.Len(t, networks, 1)
|
||||||
|
|
||||||
// Create another network and verify ListForEmail returns multiple
|
// Create another network and verify ListForHuman returns multiple
|
||||||
network2, err := svc.Create(ctx, "Second Network", "member2@example.com")
|
network2, err := svc.Create(ctx, "Second Network", member2HumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
networks, err = svc.ListForEmail(ctx, "member2@example.com")
|
networks, err = svc.ListForHuman(ctx, member2HumanId)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, networks, 2)
|
assert.Len(t, networks, 2)
|
||||||
|
|
||||||
@@ -107,3 +145,65 @@ func TestNetworkService(t *testing.T) {
|
|||||||
assert.Contains(t, networkIDs, createdNetwork.ID)
|
assert.Contains(t, networkIDs, createdNetwork.ID)
|
||||||
assert.Contains(t, networkIDs, network2.ID)
|
assert.Contains(t, networkIDs, network2.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNetworkInvitations(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newTestService(t)
|
||||||
|
|
||||||
|
adminHumanId := "human_invtest_admin"
|
||||||
|
inviteeEmail := "invitee@example.com"
|
||||||
|
inviteeHumanId := "human_invitee123"
|
||||||
|
|
||||||
|
// Create a network
|
||||||
|
net, err := svc.Create(ctx, "Invitation Test Network", adminHumanId)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Invite by email
|
||||||
|
err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// List invitations for email
|
||||||
|
invitations, err := svc.ListInvitationsForEmail(ctx, inviteeEmail)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, invitations, 1)
|
||||||
|
assert.Equal(t, net.ID, invitations[0].NetworkID)
|
||||||
|
assert.Equal(t, inviteeEmail, invitations[0].Email)
|
||||||
|
|
||||||
|
// List invitations for network
|
||||||
|
invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, invitations, 1)
|
||||||
|
|
||||||
|
// Duplicate invite is idempotent
|
||||||
|
err = svc.InviteByEmail(ctx, net.ID, []string{inviteeEmail})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
invitations, err = svc.ListInvitationsForNetwork(ctx, net.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, invitations, 1)
|
||||||
|
|
||||||
|
// Accept invitation
|
||||||
|
err = svc.AcceptInvitation(ctx, net.ID, inviteeEmail, inviteeHumanId)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Invitation should be removed
|
||||||
|
invitations, err = svc.ListInvitationsForEmail(ctx, inviteeEmail)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, invitations, 0)
|
||||||
|
|
||||||
|
// Human should now be a member
|
||||||
|
isMember, err := svc.IsMember(ctx, net.ID, inviteeHumanId)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, isMember)
|
||||||
|
|
||||||
|
// Test revoke invitation
|
||||||
|
revokeEmail := "revokee@example.com"
|
||||||
|
err = svc.InviteByEmail(ctx, net.ID, []string{revokeEmail})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = svc.RevokeInvitation(ctx, net.ID, revokeEmail)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
invitations, err = svc.ListInvitationsForEmail(ctx, revokeEmail)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, invitations, 0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ResolveVisibility expands a stream particle's visible_to entries into the set
|
||||||
|
// of human IDs that should see (and thus be notified about) activity in that
|
||||||
|
// stream. Entries are formatted as `human:{id}` for a specific human or
|
||||||
|
// `network:{id}` to expand to every member of the surrounding network.
|
||||||
|
//
|
||||||
|
// networkMembers must contain every human currently in the network (members +
|
||||||
|
// admin). visible_to entries that point to humans no longer in the network are
|
||||||
|
// dropped — they may have been removed since the stream was created.
|
||||||
|
//
|
||||||
|
// Returns a deduped slice; ordering is not stable.
|
||||||
|
func ResolveVisibility(visibleTo []string, networkMembers []string) []string {
|
||||||
|
memberSet := make(map[string]bool, len(networkMembers))
|
||||||
|
for _, id := range networkMembers {
|
||||||
|
memberSet[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]bool)
|
||||||
|
for _, entry := range visibleTo {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(entry, "human:"):
|
||||||
|
id := strings.TrimPrefix(entry, "human:")
|
||||||
|
if memberSet[id] {
|
||||||
|
result[id] = true
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(entry, "network:"):
|
||||||
|
for id := range memberSet {
|
||||||
|
result[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(result))
|
||||||
|
for id := range result {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ var (
|
|||||||
ErrMembersRequired = errors.New("custom visibility requires at least one member")
|
ErrMembersRequired = errors.New("custom visibility requires at least one member")
|
||||||
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
|
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
|
||||||
ErrNotAContainer = errors.New("only streams can have members")
|
ErrNotAContainer = errors.New("only streams can have members")
|
||||||
|
ErrInvalidMember = errors.New("member is not in the network")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package particle
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type FirestoreMediaParticle struct {
|
||||||
|
CreatedByHumanId string `firestore:"created_by_human_id"`
|
||||||
|
Type string `firestore:"type"`
|
||||||
|
Properties FirestoreMediaParticleProperties `firestore:"properties"`
|
||||||
|
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
|
||||||
|
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreTranscriptWord struct {
|
||||||
|
Word string `firestore:"word"`
|
||||||
|
Start float64 `firestore:"start"`
|
||||||
|
End float64 `firestore:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreTranscriptSentence struct {
|
||||||
|
Text string `firestore:"text"`
|
||||||
|
Start float64 `firestore:"start"`
|
||||||
|
End float64 `firestore:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreTranscriptParagraph struct {
|
||||||
|
Sentences []FirestoreTranscriptSentence `firestore:"sentences"`
|
||||||
|
Start float64 `firestore:"start"`
|
||||||
|
End float64 `firestore:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreTranscript struct {
|
||||||
|
Transcript string `firestore:"transcript"`
|
||||||
|
Words []FirestoreTranscriptWord `firestore:"words"`
|
||||||
|
Paragraphs []FirestoreTranscriptParagraph `firestore:"paragraphs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreMediaParticleProperties struct {
|
||||||
|
ObjectId string `firestore:"object_id"`
|
||||||
|
MimeType string `firestore:"mime_type"`
|
||||||
|
DurationMs int `firestore:"duration_ms"`
|
||||||
|
SizeBytes int `firestore:"size_bytes"`
|
||||||
|
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
|
||||||
|
TranscodedObjectId string `firestore:"transcoded_object_id,omitempty"`
|
||||||
|
TranscodedMimeType string `firestore:"transcoded_mime_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirestoreStreamParticle struct {
|
||||||
|
CreatedByHumanId string `firestore:"created_by_human_id"`
|
||||||
|
Type string `firestore:"type"`
|
||||||
|
Status string `firestore:"status"`
|
||||||
|
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
|
||||||
|
LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
|
||||||
|
VisibleTo []string `firestore:"visible_to"`
|
||||||
|
PlaybackMarkers map[string]time.Time `firestore:"playback_markers,omitempty"`
|
||||||
|
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
|
||||||
|
HuddleActiveParticipants []string `firestore:"huddle_active_participants,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
|
// Source: ./networkmembershipchecker.go
|
||||||
|
//
|
||||||
|
// Generated by this command:
|
||||||
|
//
|
||||||
|
// mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
||||||
|
//
|
||||||
|
|
||||||
|
// Package mock_particle is a generated GoMock package.
|
||||||
|
package mock_particle
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
reflect "reflect"
|
||||||
|
|
||||||
|
gomock "go.uber.org/mock/gomock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockNetworkMembershipChecker is a mock of NetworkMembershipChecker interface.
|
||||||
|
type MockNetworkMembershipChecker struct {
|
||||||
|
ctrl *gomock.Controller
|
||||||
|
recorder *MockNetworkMembershipCheckerMockRecorder
|
||||||
|
isgomock struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockNetworkMembershipCheckerMockRecorder is the mock recorder for MockNetworkMembershipChecker.
|
||||||
|
type MockNetworkMembershipCheckerMockRecorder struct {
|
||||||
|
mock *MockNetworkMembershipChecker
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockNetworkMembershipChecker creates a new mock instance.
|
||||||
|
func NewMockNetworkMembershipChecker(ctrl *gomock.Controller) *MockNetworkMembershipChecker {
|
||||||
|
mock := &MockNetworkMembershipChecker{ctrl: ctrl}
|
||||||
|
mock.recorder = &MockNetworkMembershipCheckerMockRecorder{mock}
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||||
|
func (m *MockNetworkMembershipChecker) EXPECT() *MockNetworkMembershipCheckerMockRecorder {
|
||||||
|
return m.recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMember mocks base method.
|
||||||
|
func (m *MockNetworkMembershipChecker) IsMember(ctx context.Context, networkID, humanId string) (bool, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "IsMember", ctx, networkID, humanId)
|
||||||
|
ret0, _ := ret[0].(bool)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMember indicates an expected call of IsMember.
|
||||||
|
func (mr *MockNetworkMembershipCheckerMockRecorder) IsMember(ctx, networkID, humanId any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsMember", reflect.TypeOf((*MockNetworkMembershipChecker)(nil).IsMember), ctx, networkID, humanId)
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParticleType represents the type of particle
|
|
||||||
type ParticleType string
|
type ParticleType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +19,6 @@ const (
|
|||||||
// TypeThink ParticleType = "think"
|
// TypeThink ParticleType = "think"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VisibilityMode represents how access to a particle is determined
|
|
||||||
type VisibilityMode string
|
type VisibilityMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -32,7 +30,6 @@ const (
|
|||||||
var ErrInvalidParticleType = errors.New("invalid particle type")
|
var ErrInvalidParticleType = errors.New("invalid particle type")
|
||||||
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
||||||
|
|
||||||
// ParseParticleType parses a string into a ParticleType
|
|
||||||
func ParseParticleType(s string) (ParticleType, error) {
|
func ParseParticleType(s string) (ParticleType, error) {
|
||||||
switch s {
|
switch s {
|
||||||
case string(TypeStream):
|
case string(TypeStream):
|
||||||
@@ -54,7 +51,6 @@ func ParseParticleType(s string) (ParticleType, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseVisibilityMode parses a string into a VisibilityMode
|
|
||||||
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||||
switch s {
|
switch s {
|
||||||
case "", string(VisibilityNetworkAll):
|
case "", string(VisibilityNetworkAll):
|
||||||
@@ -68,7 +64,6 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream status values
|
|
||||||
type StreamStatus string
|
type StreamStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -76,7 +71,6 @@ const (
|
|||||||
StreamStatusClosed StreamStatus = "closed"
|
StreamStatusClosed StreamStatus = "closed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Particle represents a content particle in the system
|
|
||||||
type Particle struct {
|
type Particle struct {
|
||||||
ID string
|
ID string
|
||||||
Type ParticleType
|
Type ParticleType
|
||||||
@@ -89,7 +83,6 @@ type Particle struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInput represents the input for creating a new particle
|
|
||||||
type CreateInput struct {
|
type CreateInput struct {
|
||||||
Type ParticleType
|
Type ParticleType
|
||||||
NetworkID string
|
NetworkID string
|
||||||
@@ -99,18 +92,15 @@ type CreateInput struct {
|
|||||||
Visibility VisibilityMode
|
Visibility VisibilityMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFilter represents filtering options for listing particles
|
|
||||||
type ListFilter struct {
|
type ListFilter struct {
|
||||||
Types []ParticleType
|
Types []ParticleType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor represents a pagination cursor for bidirectional pagination
|
|
||||||
type Cursor struct {
|
type Cursor struct {
|
||||||
Position string // particle ID or timestamp
|
Position string // particle ID or timestamp
|
||||||
Direction string // "before" or "after"
|
Direction string // "before" or "after"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParticleList represents a paginated list of particles
|
|
||||||
type ParticleList struct {
|
type ParticleList struct {
|
||||||
Particles []*Particle
|
Particles []*Particle
|
||||||
HasMore bool
|
HasMore bool
|
||||||
@@ -118,57 +108,48 @@ type ParticleList struct {
|
|||||||
PrevCursor *Cursor
|
PrevCursor *Cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamData represents the data stored for stream particles
|
|
||||||
type StreamData struct {
|
type StreamData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"` // "open" or "closed"
|
Status string `json:"status"` // "open" or "closed"
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FolderData represents the data stored for folder particles
|
|
||||||
type FolderData struct {
|
type FolderData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaData represents the data stored for media particles
|
|
||||||
type MediaData struct {
|
type MediaData struct {
|
||||||
ObjectID string `json:"object_id"` // reference to storage object
|
ObjectID string `json:"object_id"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
DurationMs int `json:"duration_ms"`
|
DurationMs int `json:"duration_ms"`
|
||||||
// Caption *string `json:"caption"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileData represents the data stored for file particles
|
|
||||||
type FileData struct {
|
type FileData struct {
|
||||||
ObjectID string `json:"object_id"` // reference to storage object
|
ObjectID string `json:"object_id"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
Size int64 `json:"size"` // in bytes
|
Size int64 `json:"size"` // bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextData represents the data stored for text particles
|
|
||||||
type TextData struct {
|
type TextData struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuestData represents the data stored for quest particles
|
|
||||||
type QuestData struct {
|
type QuestData struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Done bool `json:"done"`
|
Done bool `json:"done"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
||||||
DueDate *string `json:"due_date,omitempty"` // ISO date string
|
DueDate *string `json:"due_date,omitempty"` // ISO date
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaperData represents the data stored for paper particles
|
|
||||||
type PaperData struct {
|
type PaperData struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"` // markdown
|
Content string `json:"content"` // markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
// AckInfo represents an acknowledgment record
|
|
||||||
type AckInfo struct {
|
type AckInfo struct {
|
||||||
Email string
|
Email string
|
||||||
AckedAt time.Time
|
AckedAt time.Time
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package particle
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
|
||||||
|
|
||||||
|
type NetworkMembershipChecker interface {
|
||||||
|
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package particle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessingRepository interface {
|
||||||
|
IsProcessed(ctx context.Context, particleID string) (bool, error)
|
||||||
|
MarkProcessed(ctx context.Context, particleID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type processingRepositoryImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProcessingRepository(pool *pgxpool.Pool) ProcessingRepository {
|
||||||
|
return &processingRepositoryImpl{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *processingRepositoryImpl) IsProcessed(ctx context.Context, particleID string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM processed_particles WHERE particle_id = $1)`,
|
||||||
|
particleID,
|
||||||
|
).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *processingRepositoryImpl) MarkProcessed(ctx context.Context, particleID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO processed_particles (particle_id) VALUES ($1) ON CONFLICT (particle_id) DO NOTHING`,
|
||||||
|
particleID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ type repository interface {
|
|||||||
getMembers(ctx context.Context, particleID string) ([]string, error)
|
getMembers(ctx context.Context, particleID string) ([]string, error)
|
||||||
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||||
|
|
||||||
// getAncestorChain returns the particle and all its ancestors (for access checks)
|
// getAncestorChain returns the particle followed by its ancestors, in order.
|
||||||
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
||||||
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
||||||
|
|
||||||
|
|||||||
+84
-146
@@ -4,75 +4,75 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/network"
|
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPageSize = 50
|
const defaultPageSize = 50
|
||||||
|
|
||||||
|
// Deprecated: particle data now lives in Firestore. The Postgres-backed
|
||||||
|
// service is retained only for legacy paths.
|
||||||
type Service interface {
|
type Service interface {
|
||||||
// Create creates a new particle. Caller must be a network member (verified by handler).
|
// Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired,
|
||||||
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
|
// ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network
|
||||||
|
// membership is verified by the handler.
|
||||||
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
|
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
|
||||||
// GetByID returns ErrNotFound or ErrAccessDenied.
|
// GetByID returns ErrNotFound or ErrAccessDenied.
|
||||||
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
|
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
|
||||||
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
// Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
||||||
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
|
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
|
||||||
// Delete returns ErrNotFound or ErrAccessDenied.
|
// Delete returns ErrNotFound or ErrAccessDenied.
|
||||||
Delete(ctx context.Context, id, requesterEmail string) error
|
Delete(ctx context.Context, id, requesterEmail string) error
|
||||||
|
|
||||||
// List returns particles in a network. Use parentID=nil for root particles.
|
// List uses parentID=nil for root particles. Returns ErrNotFound or
|
||||||
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
|
// ErrAccessDenied when parentID is given but inaccessible.
|
||||||
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
||||||
|
|
||||||
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
// OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream,
|
||||||
|
// ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
||||||
OpenStream(ctx context.Context, id, requesterEmail string) error
|
OpenStream(ctx context.Context, id, requesterEmail string) error
|
||||||
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
|
// CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or
|
||||||
|
// ErrStreamAlreadyClosed.
|
||||||
CloseStream(ctx context.Context, id, requesterEmail string) error
|
CloseStream(ctx context.Context, id, requesterEmail string) error
|
||||||
|
|
||||||
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
// SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
||||||
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
||||||
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
// AddMembers / RemoveMembers operate on custom-visibility streams only.
|
||||||
|
// Both return ErrNotFound or ErrAccessDenied.
|
||||||
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
||||||
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
|
||||||
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
||||||
|
|
||||||
// Seen tracking (private)
|
// Seen tracking is private per human; Ack is public and permanent.
|
||||||
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
||||||
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
||||||
|
|
||||||
// Ack tracking (public, permanent)
|
|
||||||
Ack(ctx context.Context, id, requesterEmail string) error
|
Ack(ctx context.Context, id, requesterEmail string) error
|
||||||
|
|
||||||
// Unseen counts for stream list view
|
|
||||||
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
|
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
|
||||||
|
|
||||||
// Bulk lookups for handler enrichment
|
// Bulk lookups for batch hydration.
|
||||||
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
|
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
|
||||||
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
||||||
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
repo repository
|
repo repository
|
||||||
networkSvc network.Service
|
networkMembershipChecker NetworkMembershipChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
|
func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Service {
|
||||||
return &serviceImpl{
|
return &serviceImpl{
|
||||||
repo: newRepository(pool),
|
repo: newRepository(pool),
|
||||||
networkSvc: networkSvc,
|
networkMembershipChecker: networkReader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAccess verifies that the email has access to the particle based on visibility.
|
// Walks the ancestor chain when visibility is inherited, stopping at the
|
||||||
// Assumes the caller is already verified as a network member (handler responsibility).
|
// first network_all or custom node. Assumes network membership is already
|
||||||
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
|
// verified by the handler.
|
||||||
// network_all or custom node.
|
|
||||||
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
|
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
|
||||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,13 +83,12 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
|||||||
return false, errNotFound
|
return false, errNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build lookup map by ID
|
|
||||||
byID := make(map[string]*Particle, len(ancestors))
|
byID := make(map[string]*Particle, len(ancestors))
|
||||||
for _, p := range ancestors {
|
for _, p := range ancestors {
|
||||||
byID[p.ID] = p
|
byID[p.ID] = p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start from the target particle (first in chain) and walk up on inherited
|
// ancestors[0] is the target; walk up only on inherited.
|
||||||
current := ancestors[0]
|
current := ancestors[0]
|
||||||
for {
|
for {
|
||||||
switch current.Visibility {
|
switch current.Visibility {
|
||||||
@@ -99,7 +98,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
|||||||
return s.repo.isMemberOf(ctx, current.ID, email)
|
return s.repo.isMemberOf(ctx, current.ID, email)
|
||||||
case VisibilityInherited:
|
case VisibilityInherited:
|
||||||
if current.ParentID == nil {
|
if current.ParentID == nil {
|
||||||
// inherited at root is invalid state, deny access
|
// inherited-at-root is invalid; deny.
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
parent, ok := byID[*current.ParentID]
|
parent, ok := byID[*current.ParentID]
|
||||||
@@ -119,30 +118,24 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate particle type
|
|
||||||
if !isValidParticleType(input.Type) {
|
if !isValidParticleType(input.Type) {
|
||||||
return nil, ErrInvalidType
|
return nil, ErrInvalidType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate data matches type requirements
|
|
||||||
if err := validateParticleData(input.Type, input.Data); err != nil {
|
if err := validateParticleData(input.Type, input.Data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MVP visibility rules:
|
// MVP visibility: children always inherit; roots cannot inherit and
|
||||||
// - Child particles (have parent) → always inherited
|
// default to network_all. Streams/folders are root-only.
|
||||||
// - Root particles (no parent) → cannot be inherited, default network_all
|
|
||||||
if input.ParentID != nil {
|
if input.ParentID != nil {
|
||||||
// Children always inherit from parent
|
|
||||||
input.Visibility = VisibilityInherited
|
input.Visibility = VisibilityInherited
|
||||||
input.Members = nil // no members on inherited particles
|
input.Members = nil
|
||||||
|
|
||||||
// Reject streams and folders as children (MVP: streams are root-level only)
|
|
||||||
if input.Type == TypeStream || input.Type == TypeFolder {
|
if input.Type == TypeStream || input.Type == TypeFolder {
|
||||||
return nil, ErrInvalidParent
|
return nil, ErrInvalidParent
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Root particles cannot be inherited
|
|
||||||
if input.Visibility == VisibilityInherited {
|
if input.Visibility == VisibilityInherited {
|
||||||
return nil, ErrInheritedAtRoot
|
return nil, ErrInheritedAtRoot
|
||||||
}
|
}
|
||||||
@@ -151,7 +144,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom visibility requires at least one member and must be a stream
|
var customMembers []string
|
||||||
if input.Visibility == VisibilityCustom {
|
if input.Visibility == VisibilityCustom {
|
||||||
if input.Type != TypeStream {
|
if input.Type != TypeStream {
|
||||||
return nil, ErrNotAContainer
|
return nil, ErrNotAContainer
|
||||||
@@ -159,10 +152,33 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
if len(input.Members) == 0 {
|
if len(input.Members) == 0 {
|
||||||
return nil, ErrMembersRequired
|
return nil, ErrMembersRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate every member upfront so DB writes are all-or-nothing.
|
||||||
|
customMembers = make([]string, 0, len(input.Members)+1)
|
||||||
|
customMembers = append(customMembers, requesterEmail)
|
||||||
|
seen := map[string]bool{requesterEmail: true}
|
||||||
|
for _, email := range input.Members {
|
||||||
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Join(ErrInvalidData, err)
|
||||||
|
}
|
||||||
|
if seen[normalized] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = true
|
||||||
|
|
||||||
|
isMember, err := s.networkMembershipChecker.IsMember(ctx, input.NetworkID, normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
|
||||||
|
}
|
||||||
|
customMembers = append(customMembers, normalized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network membership is verified by handler - we only check particle visibility
|
// Network membership is verified by the handler; only particle visibility is checked here.
|
||||||
// If parent specified, check parent access (visibility-based)
|
|
||||||
if input.ParentID != nil {
|
if input.ParentID != nil {
|
||||||
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,7 +192,6 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the particle
|
|
||||||
p := &Particle{
|
p := &Particle{
|
||||||
Type: input.Type,
|
Type: input.Type,
|
||||||
NetworkID: input.NetworkID,
|
NetworkID: input.NetworkID,
|
||||||
@@ -190,53 +205,22 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
|||||||
p.Data = json.RawMessage("{}")
|
p.Data = json.RawMessage("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// For streams, set initial status to open and check capacity
|
// New streams default to open.
|
||||||
if input.Type == TypeStream {
|
if input.Type == TypeStream {
|
||||||
// Set status to open in the data JSON
|
|
||||||
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.Data = data
|
p.Data = data
|
||||||
|
|
||||||
// Check and increment capacity
|
|
||||||
err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, network.ErrCapacityExceeded) {
|
|
||||||
return nil, ErrCapacityExceeded
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the particle
|
|
||||||
created, err := s.repo.create(ctx, p)
|
created, err := s.repo.create(ctx, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If we incremented the stream count but creation failed, decrement it
|
|
||||||
if input.Type == TypeStream {
|
|
||||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
|
|
||||||
slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add members if custom visibility (only streams for MVP)
|
if len(customMembers) > 0 {
|
||||||
if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
|
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
|
||||||
normalizedEmails := make([]string, 0, len(input.Members)+1)
|
|
||||||
// Always include the creator
|
|
||||||
normalizedEmails = append(normalizedEmails, requesterEmail)
|
|
||||||
for _, email := range input.Members {
|
|
||||||
normalized, err := utils.NormalizeEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
continue // Skip invalid emails
|
|
||||||
}
|
|
||||||
if normalized == requesterEmail {
|
|
||||||
continue // Already added
|
|
||||||
}
|
|
||||||
normalizedEmails = append(normalizedEmails, normalized)
|
|
||||||
}
|
|
||||||
if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +234,6 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -278,7 +261,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -290,7 +272,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
|||||||
return nil, ErrAccessDenied
|
return nil, ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to validate data against its type
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -299,7 +280,6 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate data matches type requirements
|
|
||||||
if err := validateParticleData(p.Type, data); err != nil {
|
if err := validateParticleData(p.Type, data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -321,7 +301,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -333,8 +312,7 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check if it's an open stream
|
_, err = s.repo.getByID(ctx, id)
|
||||||
p, err := s.repo.getByID(ctx, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
@@ -342,13 +320,6 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's an open stream, decrement the count
|
|
||||||
if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
|
|
||||||
if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.repo.delete(ctx, id)
|
err = s.repo.delete(ctx, id)
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
@@ -362,8 +333,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network membership is verified by handler - we only check particle visibility
|
// Network membership is verified by the handler; only particle visibility is checked here.
|
||||||
// If parentID specified, check access to parent (visibility-based)
|
|
||||||
if parentID != nil {
|
if parentID != nil {
|
||||||
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,8 +347,7 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch one extra to determine if there are more
|
// Fetch limit+1 to detect a next page; visibility filtering lives in the query.
|
||||||
// Access filtering is done in the query itself (network_all OR user is member)
|
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
limit = defaultPageSize
|
limit = defaultPageSize
|
||||||
}
|
}
|
||||||
@@ -425,7 +394,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -437,7 +405,6 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -454,30 +421,13 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
|||||||
return ErrStreamAlreadyOpen
|
return ErrStreamAlreadyOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check and increment capacity
|
|
||||||
err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, network.ErrCapacityExceeded) {
|
|
||||||
return ErrCapacityExceeded
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stream status in data
|
|
||||||
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
|
|
||||||
slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.repo.update(ctx, id, newData, time.Now())
|
err = s.repo.update(ctx, id, newData, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Rollback the capacity increment
|
|
||||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
|
|
||||||
slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
|
|
||||||
}
|
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -493,7 +443,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -505,7 +454,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -522,7 +470,6 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return ErrStreamAlreadyClosed
|
return ErrStreamAlreadyClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update stream status in data
|
|
||||||
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -536,8 +483,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement capacity
|
return nil
|
||||||
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
|
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
|
||||||
@@ -546,7 +492,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -558,7 +503,6 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check constraints
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -567,12 +511,11 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root particles cannot be inherited
|
|
||||||
if mode == VisibilityInherited && p.ParentID == nil {
|
if mode == VisibilityInherited && p.ParentID == nil {
|
||||||
return ErrInheritedAtRoot
|
return ErrInheritedAtRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
// If expanding to network_all, check that parent's effective visibility allows it
|
// Expanding to network_all is rejected if any ancestor restricts to custom.
|
||||||
if mode == VisibilityNetworkAll && p.ParentID != nil {
|
if mode == VisibilityNetworkAll && p.ParentID != nil {
|
||||||
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
|
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -590,7 +533,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
|
// Walks up the inherited chain to the concrete visibility node.
|
||||||
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
|
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
|
||||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -627,7 +570,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -639,7 +581,6 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check type and parent access
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -648,25 +589,30 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only streams can have members
|
|
||||||
if p.Type != TypeStream {
|
if p.Type != TypeStream {
|
||||||
return ErrNotAContainer
|
return ErrNotAContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and normalize emails, check network membership
|
// Validate every email upfront so any failure aborts before DB writes.
|
||||||
normalizedEmails := make([]string, 0, len(emails))
|
normalizedEmails := make([]string, 0, len(emails))
|
||||||
|
seen := make(map[string]bool, len(emails))
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
normalized, err := utils.NormalizeEmail(email)
|
normalized, err := utils.NormalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return errors.Join(ErrInvalidData, err)
|
||||||
|
}
|
||||||
|
if seen[normalized] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
seen[normalized] = true
|
||||||
|
|
||||||
// Root stream - check network membership
|
isMember, err := s.networkMembershipChecker.IsMember(ctx, p.NetworkID, normalized)
|
||||||
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
|
if err != nil {
|
||||||
if err != nil || !isMember {
|
return err
|
||||||
continue
|
}
|
||||||
|
if !isMember {
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedEmails = append(normalizedEmails, normalized)
|
normalizedEmails = append(normalizedEmails, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +629,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -695,7 +640,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the particle to check type
|
|
||||||
p, err := s.repo.getByID(ctx, id)
|
p, err := s.repo.getByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -704,7 +648,6 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only streams can have members
|
|
||||||
if p.Type != TypeStream {
|
if p.Type != TypeStream {
|
||||||
return ErrNotAContainer
|
return ErrNotAContainer
|
||||||
}
|
}
|
||||||
@@ -731,7 +674,6 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -752,17 +694,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access for each particle and mark seen
|
// Silently skip particles that are missing or inaccessible.
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
continue // Skip non-existent particles
|
continue
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !hasAccess {
|
if !hasAccess {
|
||||||
continue // Skip inaccessible particles
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||||
@@ -779,7 +721,6 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access
|
|
||||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errNotFound) {
|
if errors.Is(err, errNotFound) {
|
||||||
@@ -791,7 +732,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
|||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ack also marks as seen
|
// Ack implies seen.
|
||||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -834,7 +775,6 @@ func isValidParticleType(t ParticleType) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getStreamStatus extracts the status from a stream particle's data
|
|
||||||
func getStreamStatus(data json.RawMessage) string {
|
func getStreamStatus(data json.RawMessage) string {
|
||||||
var d StreamData
|
var d StreamData
|
||||||
if err := json.Unmarshal(data, &d); err != nil {
|
if err := json.Unmarshal(data, &d); err != nil {
|
||||||
@@ -843,7 +783,6 @@ func getStreamStatus(data json.RawMessage) string {
|
|||||||
return d.Status
|
return d.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// setStreamStatus updates the status in a stream particle's data
|
|
||||||
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
|
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
|
||||||
var d StreamData
|
var d StreamData
|
||||||
if err := json.Unmarshal(data, &d); err != nil {
|
if err := json.Unmarshal(data, &d); err != nil {
|
||||||
@@ -853,10 +792,9 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro
|
|||||||
return json.Marshal(d)
|
return json.Marshal(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateParticleData validates that the data field contains valid JSON
|
// Returns ErrInvalidData if data is not valid JSON or is missing required
|
||||||
// and has required fields for the given particle type.
|
// fields for pType. Empty/null data is allowed and treated as {}.
|
||||||
func validateParticleData(pType ParticleType, data json.RawMessage) error {
|
func validateParticleData(pType ParticleType, data json.RawMessage) error {
|
||||||
// Empty or null data is allowed - will default to {}
|
|
||||||
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
|
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ package particle_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/network"
|
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
|
mock_particle "github.com/flowy-live/llink/internal/particle/mocks"
|
||||||
"github.com/flowy-live/llink/internal/testhelper"
|
"github.com/flowy-live/llink/internal/testhelper"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dbPool *pgxpool.Pool
|
var dbPool *pgxpool.Pool
|
||||||
@@ -31,27 +33,46 @@ func getStreamStatus(data json.RawMessage) string {
|
|||||||
return d.Status
|
return d.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestService returns a particle.Service backed by the test DB and a fresh
|
||||||
|
// MockNetworkMembershipChecker. The mock is returned so each test can set its
|
||||||
|
// own EXPECT() calls for whatever membership behavior it needs. Tests that
|
||||||
|
// never trigger AddMembers (the only path that consults the checker) can
|
||||||
|
// discard the mock with _ — gomock will fail the test if it's called
|
||||||
|
// unexpectedly.
|
||||||
|
func newTestService(t *testing.T) (particle.Service, *mock_particle.MockNetworkMembershipChecker) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
mockChecker := mock_particle.NewMockNetworkMembershipChecker(ctrl)
|
||||||
|
return particle.NewService(dbPool, mockChecker), mockChecker
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectIsMember configures the mock so that IsMember(networkID, email) returns
|
||||||
|
// the given result. Each entry is matched once — the test fails if a configured
|
||||||
|
// email is never queried, or if any unconfigured email is.
|
||||||
|
func expectIsMember(mock *mock_particle.MockNetworkMembershipChecker, networkID string, results map[string]bool) {
|
||||||
|
for email, isMember := range results {
|
||||||
|
mock.EXPECT().
|
||||||
|
IsMember(gomock.Any(), networkID, email).
|
||||||
|
Return(isMember, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParticleService_CreateAndGet(t *testing.T) {
|
func TestParticleService_CreateAndGet(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, _ := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network first
|
|
||||||
net, err := networkSvc.Create(ctx, "Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
|
networkID := "net_123"
|
||||||
// Test Create stream particle
|
// Test Create stream particle
|
||||||
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
|
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
|
||||||
input := particle.CreateInput{
|
input := particle.CreateInput{
|
||||||
Type: particle.TypeStream,
|
Type: particle.TypeStream,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
created, err := svc.Create(ctx, input, "admin@example.com")
|
created, err := svc.Create(ctx, input, "admin@example.com")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, created.ID)
|
assert.NotEmpty(t, created.ID)
|
||||||
assert.Equal(t, particle.TypeStream, created.Type)
|
assert.Equal(t, particle.TypeStream, created.Type)
|
||||||
assert.Equal(t, net.ID, created.NetworkID)
|
assert.Equal(t, networkID, created.NetworkID)
|
||||||
assert.Nil(t, created.ParentID)
|
assert.Nil(t, created.ParentID)
|
||||||
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
|
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
|
||||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
|
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
|
||||||
@@ -70,71 +91,16 @@ func TestParticleService_CreateAndGet(t *testing.T) {
|
|||||||
// Service assumes caller is already verified as network member
|
// Service assumes caller is already verified as network member
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParticleService_StreamCapacity(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
networkSvc := network.NewService(dbPool)
|
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network with capacity 2
|
|
||||||
net, err := networkSvc.Create(ctx, "Capacity Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create first stream - should succeed
|
|
||||||
input := particle.CreateInput{
|
|
||||||
Type: particle.TypeStream,
|
|
||||||
NetworkID: net.ID,
|
|
||||||
Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`),
|
|
||||||
}
|
|
||||||
stream1, err := svc.Create(ctx, input, "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create second stream - should succeed
|
|
||||||
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
|
|
||||||
stream2, err := svc.Create(ctx, input, "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create third stream - should fail with capacity exceeded
|
|
||||||
input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`)
|
|
||||||
_, err = svc.Create(ctx, input, "admin@example.com")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
|
|
||||||
|
|
||||||
// Close a stream
|
|
||||||
err = svc.CloseStream(ctx, stream1.ID, "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Now we can create another stream
|
|
||||||
stream3, err := svc.Create(ctx, input, "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, stream3.ID)
|
|
||||||
|
|
||||||
// Verify stream2 is still open
|
|
||||||
found, err := svc.GetByID(ctx, stream2.ID, "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
|
|
||||||
|
|
||||||
// Verify stream1 is closed
|
|
||||||
found, err = svc.GetByID(ctx, stream1.ID, "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParticleService_NestedParticles(t *testing.T) {
|
func TestParticleService_NestedParticles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, _ := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "Nested Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a parent stream
|
// Create a parent stream
|
||||||
streamInput := particle.CreateInput{
|
streamInput := particle.CreateInput{
|
||||||
Type: particle.TypeStream,
|
Type: particle.TypeStream,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
|
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
|
||||||
}
|
}
|
||||||
stream, err := svc.Create(ctx, streamInput, "admin@example.com")
|
stream, err := svc.Create(ctx, streamInput, "admin@example.com")
|
||||||
@@ -143,7 +109,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
|||||||
// Create a text particle as child
|
// Create a text particle as child
|
||||||
textInput := particle.CreateInput{
|
textInput := particle.CreateInput{
|
||||||
Type: particle.TypeText,
|
Type: particle.TypeText,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
ParentID: &stream.ID,
|
ParentID: &stream.ID,
|
||||||
Data: json.RawMessage(`{"content":"Hello world"}`),
|
Data: json.RawMessage(`{"content":"Hello world"}`),
|
||||||
}
|
}
|
||||||
@@ -154,7 +120,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
|||||||
// Create a file as child of stream
|
// Create a file as child of stream
|
||||||
fileInput := particle.CreateInput{
|
fileInput := particle.CreateInput{
|
||||||
Type: particle.TypeFile,
|
Type: particle.TypeFile,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
ParentID: &stream.ID,
|
ParentID: &stream.ID,
|
||||||
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
|
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
|
||||||
}
|
}
|
||||||
@@ -163,27 +129,26 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
|||||||
assert.Equal(t, stream.ID, *file.ParentID)
|
assert.Equal(t, stream.ID, *file.ParentID)
|
||||||
|
|
||||||
// List children of stream
|
// List children of stream
|
||||||
children, err := svc.List(ctx, net.ID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50)
|
children, err := svc.List(ctx, networkID, &stream.ID, "admin@example.com", particle.ListFilter{}, nil, 50)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, children.Particles, 2)
|
assert.Len(t, children.Particles, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParticleService_CustomVisibility(t *testing.T) {
|
func TestParticleService_CustomVisibility(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, mockChecker := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network with a member
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "Visibility Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"})
|
// admin is the creator (exempt from the check); member is a network member.
|
||||||
assert.NoError(t, err)
|
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||||
|
"member@example.com": true,
|
||||||
|
})
|
||||||
|
|
||||||
// Create a stream with custom visibility including only admin and member
|
// Create a stream with custom visibility including only admin and member
|
||||||
streamInput := particle.CreateInput{
|
streamInput := particle.CreateInput{
|
||||||
Type: particle.TypeStream,
|
Type: particle.TypeStream,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Visibility: particle.VisibilityCustom,
|
Visibility: particle.VisibilityCustom,
|
||||||
Members: []string{"admin@example.com", "member@example.com"},
|
Members: []string{"admin@example.com", "member@example.com"},
|
||||||
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
||||||
@@ -212,17 +177,14 @@ func TestParticleService_CustomVisibility(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, _ := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "Update Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a text particle
|
// Create a text particle
|
||||||
input := particle.CreateInput{
|
input := particle.CreateInput{
|
||||||
Type: particle.TypeText,
|
Type: particle.TypeText,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Data: json.RawMessage(`{"content":"Original content"}`),
|
Data: json.RawMessage(`{"content":"Original content"}`),
|
||||||
}
|
}
|
||||||
created, err := svc.Create(ctx, input, "admin@example.com")
|
created, err := svc.Create(ctx, input, "admin@example.com")
|
||||||
@@ -250,18 +212,15 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_ListRootParticles(t *testing.T) {
|
func TestParticleService_ListRootParticles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, _ := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "List Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create multiple root particles
|
// Create multiple root particles
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
input := particle.CreateInput{
|
input := particle.CreateInput{
|
||||||
Type: particle.TypeStream,
|
Type: particle.TypeStream,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||||
}
|
}
|
||||||
_, err := svc.Create(ctx, input, "admin@example.com")
|
_, err := svc.Create(ctx, input, "admin@example.com")
|
||||||
@@ -269,24 +228,21 @@ func TestParticleService_ListRootParticles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List root particles (parentID = nil)
|
// List root particles (parentID = nil)
|
||||||
list, err := svc.List(ctx, net.ID, nil, "admin@example.com", particle.ListFilter{}, nil, 50)
|
list, err := svc.List(ctx, networkID, nil, "admin@example.com", particle.ListFilter{}, nil, 50)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, len(list.Particles), 3)
|
assert.GreaterOrEqual(t, len(list.Particles), 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParticleService_OpenCloseStream(t *testing.T) {
|
func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, _ := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "Open Close Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a stream
|
// Create a stream
|
||||||
input := particle.CreateInput{
|
input := particle.CreateInput{
|
||||||
Type: particle.TypeStream,
|
Type: particle.TypeStream,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
|
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
|
||||||
}
|
}
|
||||||
stream, err := svc.Create(ctx, input, "admin@example.com")
|
stream, err := svc.Create(ctx, input, "admin@example.com")
|
||||||
@@ -324,17 +280,14 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_NotAStream(t *testing.T) {
|
func TestParticleService_NotAStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, _ := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "Not Stream Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a text particle
|
// Create a text particle
|
||||||
input := particle.CreateInput{
|
input := particle.CreateInput{
|
||||||
Type: particle.TypeText,
|
Type: particle.TypeText,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Data: json.RawMessage(`{"content":"Hello"}`),
|
Data: json.RawMessage(`{"content":"Hello"}`),
|
||||||
}
|
}
|
||||||
text, err := svc.Create(ctx, input, "admin@example.com")
|
text, err := svc.Create(ctx, input, "admin@example.com")
|
||||||
@@ -353,20 +306,19 @@ func TestParticleService_NotAStream(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_AccessInheritance(t *testing.T) {
|
func TestParticleService_AccessInheritance(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool)
|
svc, mockChecker := newTestService(t)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
|
||||||
|
|
||||||
// Create a network with members
|
networkID := "net_123"
|
||||||
net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "admin@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "other@example.com"})
|
// admin is the creator (exempt); member is a network member.
|
||||||
assert.NoError(t, err)
|
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||||
|
"member@example.com": true,
|
||||||
|
})
|
||||||
|
|
||||||
// Create a stream with custom visibility (admin and member only)
|
// Create a stream with custom visibility (admin and member only)
|
||||||
streamInput := particle.CreateInput{
|
streamInput := particle.CreateInput{
|
||||||
Type: particle.TypeStream,
|
Type: particle.TypeStream,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
Visibility: particle.VisibilityCustom,
|
Visibility: particle.VisibilityCustom,
|
||||||
Members: []string{"admin@example.com", "member@example.com"},
|
Members: []string{"admin@example.com", "member@example.com"},
|
||||||
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
||||||
@@ -377,7 +329,7 @@ func TestParticleService_AccessInheritance(t *testing.T) {
|
|||||||
// Create a child text (network_all visibility)
|
// Create a child text (network_all visibility)
|
||||||
textInput := particle.CreateInput{
|
textInput := particle.CreateInput{
|
||||||
Type: particle.TypeText,
|
Type: particle.TypeText,
|
||||||
NetworkID: net.ID,
|
NetworkID: networkID,
|
||||||
ParentID: &stream.ID,
|
ParentID: &stream.ID,
|
||||||
Data: json.RawMessage(`{"content":"Child text"}`),
|
Data: json.RawMessage(`{"content":"Child text"}`),
|
||||||
}
|
}
|
||||||
@@ -397,3 +349,154 @@ func TestParticleService_AccessInheritance(t *testing.T) {
|
|||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.ErrorIs(t, err, particle.ErrAccessDenied)
|
assert.ErrorIs(t, err, particle.ErrAccessDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParticleService_AddMembers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, mockChecker := newTestService(t)
|
||||||
|
|
||||||
|
networkID := "net_addmembers"
|
||||||
|
|
||||||
|
// Create a custom-visibility stream owned by admin.
|
||||||
|
stream, err := svc.Create(ctx, particle.CreateInput{
|
||||||
|
Type: particle.TypeStream,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Visibility: particle.VisibilityCustom,
|
||||||
|
Members: []string{"admin@example.com"},
|
||||||
|
Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`),
|
||||||
|
}, "admin@example.com")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||||
|
"alice@example.com": true,
|
||||||
|
"bob@example.com": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
err = svc.AddMembers(ctx, stream.ID,
|
||||||
|
[]string{"alice@example.com", "bob@example.com"},
|
||||||
|
"admin@example.com")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, members[stream.ID], "alice@example.com")
|
||||||
|
assert.Contains(t, members[stream.ID], "bob@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticleService_AddMembers_RejectsNonNetworkMember(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, mockChecker := newTestService(t)
|
||||||
|
|
||||||
|
networkID := "net_addmembers_strict"
|
||||||
|
|
||||||
|
stream, err := svc.Create(ctx, particle.CreateInput{
|
||||||
|
Type: particle.TypeStream,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Visibility: particle.VisibilityCustom,
|
||||||
|
Members: []string{"admin@example.com"},
|
||||||
|
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
|
||||||
|
}, "admin@example.com")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// alice is in the network and is processed first; stranger is not, so the
|
||||||
|
// whole operation must abort and neither row should be persisted.
|
||||||
|
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||||
|
"alice@example.com": true,
|
||||||
|
"stranger@example.com": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
err = svc.AddMembers(ctx, stream.ID,
|
||||||
|
[]string{"alice@example.com", "stranger@example.com"},
|
||||||
|
"admin@example.com")
|
||||||
|
assert.ErrorIs(t, err, particle.ErrInvalidMember)
|
||||||
|
|
||||||
|
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotContains(t, members[stream.ID], "alice@example.com")
|
||||||
|
assert.NotContains(t, members[stream.ID], "stranger@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticleService_AddMembers_BubblesCheckerError(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, mockChecker := newTestService(t)
|
||||||
|
|
||||||
|
networkID := "net_addmembers_checker_err"
|
||||||
|
|
||||||
|
stream, err := svc.Create(ctx, particle.CreateInput{
|
||||||
|
Type: particle.TypeStream,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Visibility: particle.VisibilityCustom,
|
||||||
|
Members: []string{"admin@example.com"},
|
||||||
|
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||||
|
}, "admin@example.com")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
checkerErr := errors.New("checker unavailable")
|
||||||
|
mockChecker.EXPECT().
|
||||||
|
IsMember(gomock.Any(), networkID, "alice@example.com").
|
||||||
|
Return(false, checkerErr)
|
||||||
|
|
||||||
|
err = svc.AddMembers(ctx, stream.ID,
|
||||||
|
[]string{"alice@example.com"}, "admin@example.com")
|
||||||
|
assert.ErrorIs(t, err, checkerErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticleService_Create_RejectsNonNetworkMember(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, mockChecker := newTestService(t)
|
||||||
|
|
||||||
|
networkID := "net_create_strict"
|
||||||
|
|
||||||
|
// stranger is not in the network — Create must reject the call before
|
||||||
|
// writing anything to the DB.
|
||||||
|
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||||
|
"stranger@example.com": false,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := svc.Create(ctx, particle.CreateInput{
|
||||||
|
Type: particle.TypeStream,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Visibility: particle.VisibilityCustom,
|
||||||
|
Members: []string{"admin@example.com", "stranger@example.com"},
|
||||||
|
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
|
||||||
|
}, "admin@example.com")
|
||||||
|
assert.ErrorIs(t, err, particle.ErrInvalidMember)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticleService_Create_BubblesCheckerError(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, mockChecker := newTestService(t)
|
||||||
|
|
||||||
|
networkID := "net_create_checker_err"
|
||||||
|
|
||||||
|
checkerErr := errors.New("checker unavailable")
|
||||||
|
mockChecker.EXPECT().
|
||||||
|
IsMember(gomock.Any(), networkID, "alice@example.com").
|
||||||
|
Return(false, checkerErr)
|
||||||
|
|
||||||
|
_, err := svc.Create(ctx, particle.CreateInput{
|
||||||
|
Type: particle.TypeStream,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Visibility: particle.VisibilityCustom,
|
||||||
|
Members: []string{"alice@example.com"},
|
||||||
|
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||||
|
}, "admin@example.com")
|
||||||
|
assert.ErrorIs(t, err, checkerErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParticleService_AddMembers_NotAStream(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
// No EXPECT calls — AddMembers should fail on type check before hitting the checker.
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
networkID := "net_addmembers_notstream"
|
||||||
|
|
||||||
|
text, err := svc.Create(ctx, particle.CreateInput{
|
||||||
|
Type: particle.TypeText,
|
||||||
|
NetworkID: networkID,
|
||||||
|
Data: json.RawMessage(`{"content":"hi"}`),
|
||||||
|
}, "admin@example.com")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = svc.AddMembers(ctx, text.ID, []string{"alice@example.com"}, "admin@example.com")
|
||||||
|
assert.ErrorIs(t, err, particle.ErrNotAContainer)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package particle
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
|
"github.com/flowy-live/llink/internal/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SkipReasonNotMedia = "not_media"
|
||||||
|
SkipReasonAlreadyTranscoded = "already_transcoded"
|
||||||
|
SkipReasonIOSPlayable = "ios_playable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TranscodeResult reports the outcome of a single Transcode call. Skipped is
|
||||||
|
// true for any of the three idempotency short-circuits (with SkipReason set);
|
||||||
|
// Err is non-nil for real failures (download URL, ffmpeg, GCS upload, Firestore
|
||||||
|
// write). On success both flags are zero-value and TranscodedObjectID +
|
||||||
|
// OutputMimeType are populated.
|
||||||
|
type TranscodeResult struct {
|
||||||
|
Skipped bool
|
||||||
|
SkipReason string
|
||||||
|
TranscodedObjectID string
|
||||||
|
OutputMimeType string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode produces an iOS-playable MP4/m4a derivative for media particles
|
||||||
|
// whose original mime type AVPlayer can't decode (notably the WebM the desktop
|
||||||
|
// recorder emits today). Skips when the particle is not of type media, when a
|
||||||
|
// transcoded variant has already been written, or when the source is already
|
||||||
|
// in an iOS-playable family.
|
||||||
|
//
|
||||||
|
// ffmpeg reads directly from the GCS signed URL and writes to a local temp
|
||||||
|
// file — `+faststart` requires seekable output, so a stdout pipe wouldn't
|
||||||
|
// work. The temp file is then streamed to GCS via depotSvc.CreateFromReader.
|
||||||
|
func Transcode(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) TranscodeResult {
|
||||||
|
var mp FirestoreMediaParticle
|
||||||
|
if err := doc.DataTo(&mp); err != nil {
|
||||||
|
flog.Error("transcode: unable to marshal particle data", "error", err)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("unmarshal particle: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
particleType, err := ParseParticleType(mp.Type)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: invalid particle type", "error", err)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("parse type: %w", err)}
|
||||||
|
}
|
||||||
|
if particleType != TypeMedia {
|
||||||
|
flog.Info("transcode: particle is not of type media")
|
||||||
|
return TranscodeResult{Skipped: true, SkipReason: SkipReasonNotMedia}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mp.Properties.TranscodedObjectId != "" {
|
||||||
|
return TranscodeResult{Skipped: true, SkipReason: SkipReasonAlreadyTranscoded}
|
||||||
|
}
|
||||||
|
|
||||||
|
if media.IsIOSPlayableMime(mp.Properties.MimeType) {
|
||||||
|
flog.Info("transcode: skipping because already playable on ios")
|
||||||
|
return TranscodeResult{Skipped: true, SkipReason: SkipReasonIOSPlayable}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceURL, err := depotSvc.GetDownloadURL(ctx, mp.Properties.ObjectId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: failed to get download URL", "error", err, "object_id", mp.Properties.ObjectId)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("download URL: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID, err := NetworkIDFromParticlePath(doc.Ref.Path)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("network id: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
transcodeOutput, err := media.TranscodeToMp4(transcodeCtx, media.TranscodeInput{
|
||||||
|
SourceURL: sourceURL,
|
||||||
|
MimeType: mp.Properties.MimeType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: ffmpeg failed", "error", err)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("ffmpeg: %w", err)}
|
||||||
|
}
|
||||||
|
defer os.Remove(transcodeOutput.TempLocalFilePath)
|
||||||
|
|
||||||
|
f, err := os.Open(transcodeOutput.TempLocalFilePath)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: failed to open transcoded file", "error", err, "path", transcodeOutput.TempLocalFilePath)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("open temp file: %w", err)}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
|
||||||
|
Prefix: networkID,
|
||||||
|
Name: "transcoded" + transcodeOutput.OutputExt,
|
||||||
|
ContentType: transcodeOutput.OutputMimeType,
|
||||||
|
}, f)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("transcode: failed to upload transcoded object", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("upload: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := doc.Ref.Set(ctx, map[string]interface{}{
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"transcoded_object_id": newObj.ID,
|
||||||
|
"transcoded_mime_type": transcodeOutput.OutputMimeType,
|
||||||
|
},
|
||||||
|
}, firestore.MergeAll); err != nil {
|
||||||
|
flog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
|
||||||
|
return TranscodeResult{Err: fmt.Errorf("firestore update: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
flog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", transcodeOutput.OutputMimeType)
|
||||||
|
return TranscodeResult{
|
||||||
|
TranscodedObjectID: newObj.ID,
|
||||||
|
OutputMimeType: transcodeOutput.OutputMimeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkIDFromParticlePath extracts the network id from a Firestore particle
|
||||||
|
// document path. Particles live at `networks/{network_id}/children/.../children/{id}`
|
||||||
|
// at arbitrary nesting depth, so the network id is always the segment directly
|
||||||
|
// following the "networks" collection name in the full resource path.
|
||||||
|
func NetworkIDFromParticlePath(path string) (string, error) {
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
for i, seg := range segments {
|
||||||
|
if seg == "networks" && i+1 < len(segments) {
|
||||||
|
return segments[i+1], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no networks segment in path: %s", path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
|
type Authorizer struct {
|
||||||
|
networkReader network.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthorizer(networkReader network.Reader) *Authorizer {
|
||||||
|
return &Authorizer{networkReader: networkReader}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize accepts channel IDs of the form:
|
||||||
|
//
|
||||||
|
// network:{networkId}
|
||||||
|
// stream:{networkId}:{streamId}
|
||||||
|
// _presence:{humanId}
|
||||||
|
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
|
||||||
|
parts := strings.SplitN(channelID, ":", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
channelType := parts[0]
|
||||||
|
rest := parts[1]
|
||||||
|
|
||||||
|
switch channelType {
|
||||||
|
case "network":
|
||||||
|
return a.authorizeNetwork(ctx, rest, humanID)
|
||||||
|
case "stream":
|
||||||
|
return a.authorizeStream(ctx, rest, humanID)
|
||||||
|
case "_presence":
|
||||||
|
// Only the owning human may subscribe to their presence channel.
|
||||||
|
if rest != humanID {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID string) error {
|
||||||
|
isMember, err := a.networkReader.IsMember(ctx, networkID, humanID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access.
|
||||||
|
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
|
||||||
|
parts := strings.SplitN(rest, ":", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
networkID := parts[0]
|
||||||
|
return a.authorizeNetwork(ctx, networkID, humanID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
// Channel tracks the local connections subscribed on this pod.
|
||||||
|
// State is only mutated by the Hub goroutine, so no locks are needed.
|
||||||
|
type Channel struct {
|
||||||
|
id string
|
||||||
|
members map[*Conn]string // conn → humanID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChannel(id string) *Channel {
|
||||||
|
return &Channel{
|
||||||
|
id: id,
|
||||||
|
members: make(map[*Conn]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *Channel) addMember(conn *Conn, humanID string) {
|
||||||
|
ch.members[conn] = humanID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *Channel) removeMember(conn *Conn) {
|
||||||
|
delete(ch.members, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *Channel) isEmpty() bool {
|
||||||
|
return len(ch.members) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicated set; the same human may have multiple connections.
|
||||||
|
func (ch *Channel) localHumanIDs() []string {
|
||||||
|
seen := make(map[string]bool, len(ch.members))
|
||||||
|
ids := make([]string, 0, len(ch.members))
|
||||||
|
for _, hid := range ch.members {
|
||||||
|
if !seen[hid] {
|
||||||
|
seen[hid] = true
|
||||||
|
ids = append(ids, hid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *Channel) hasHumanID(humanID string) bool {
|
||||||
|
for _, hid := range ch.members {
|
||||||
|
if hid == humanID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
|
||||||
|
for conn := range ch.members {
|
||||||
|
if conn != exclude {
|
||||||
|
conn.Send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendBufferSize = 256
|
||||||
|
|
||||||
|
type Conn struct {
|
||||||
|
id string
|
||||||
|
humanID string
|
||||||
|
ws *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
once sync.Once // guards Close
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConn(id, humanID string, ws *websocket.Conn) *Conn {
|
||||||
|
return &Conn{
|
||||||
|
id: id,
|
||||||
|
humanID: humanID,
|
||||||
|
ws: ws,
|
||||||
|
send: make(chan []byte, sendBufferSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPump forwards inbound frames to the hub; blocks until the connection
|
||||||
|
// closes or ctx is cancelled.
|
||||||
|
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
||||||
|
defer hub.disconnect(c)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, data, err := c.ws.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
flog.Info("websocket context cancelled", "connId", c.id, "humanId", c.humanID, "error", ctx.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flog.Warn("websocket read error", "connId", c.id, "humanId", c.humanID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) == "ping" {
|
||||||
|
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
|
||||||
|
flog.Warn("websocket pong write error", "connId", c.id, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg ClientMessage
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
c.sendError("invalid message format")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case TypeSubscribe:
|
||||||
|
if msg.Channel == "" {
|
||||||
|
c.sendError("channel is required")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hub.subscribeCh <- &subscribeRequest{conn: c, channelID: msg.Channel}
|
||||||
|
case TypeUnsubscribe:
|
||||||
|
if msg.Channel == "" {
|
||||||
|
c.sendError("channel is required")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hub.unsubscribeCh <- &unsubscribeRequest{conn: c, channelID: msg.Channel}
|
||||||
|
case TypeMessage:
|
||||||
|
if msg.Channel == "" {
|
||||||
|
c.sendError("channel is required")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hub.broadcastCh <- &broadcastRequest{conn: c, channelID: msg.Channel, payload: msg.Payload}
|
||||||
|
default:
|
||||||
|
c.sendError("unknown message type: " + msg.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) WritePump(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case data, ok := <-c.send:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.ws.Write(ctx, websocket.MessageText, data); err != nil {
|
||||||
|
flog.Debug("websocket write error", "connId", c.id, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A full send buffer closes the connection (slow client policy).
|
||||||
|
func (c *Conn) Send(msg ServerMessage) {
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to marshal server message", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.send <- data:
|
||||||
|
default:
|
||||||
|
flog.Warn("slow client, closing connection", "connId", c.id, "humanId", c.humanID)
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) Close() {
|
||||||
|
c.once.Do(func() {
|
||||||
|
c.ws.Close(websocket.StatusNormalClosure, "closing")
|
||||||
|
close(c.send)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) sendError(msg string) {
|
||||||
|
c.Send(ServerMessage{Type: TypeError, Message: msg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type subscribeRequest struct {
|
||||||
|
conn *Conn
|
||||||
|
channelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type unsubscribeRequest struct {
|
||||||
|
conn *Conn
|
||||||
|
channelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type broadcastRequest struct {
|
||||||
|
conn *Conn
|
||||||
|
channelID string
|
||||||
|
payload json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type remoteEvent struct {
|
||||||
|
channelID string
|
||||||
|
event redisEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages all local WebSocket connections and channels on this pod.
|
||||||
|
// All state mutations happen in a single goroutine via Go channels — no locks.
|
||||||
|
type Hub struct {
|
||||||
|
channels map[string]*Channel
|
||||||
|
connChannels map[*Conn]map[string]bool // reverse index: conn → set of channel IDs
|
||||||
|
|
||||||
|
bridge *RedisBridge
|
||||||
|
authorizer *Authorizer
|
||||||
|
|
||||||
|
subscribeCh chan *subscribeRequest
|
||||||
|
unsubscribeCh chan *unsubscribeRequest
|
||||||
|
broadcastCh chan *broadcastRequest
|
||||||
|
disconnectCh chan *Conn
|
||||||
|
remoteEventCh chan *remoteEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
|
||||||
|
return &Hub{
|
||||||
|
channels: make(map[string]*Channel),
|
||||||
|
connChannels: make(map[*Conn]map[string]bool),
|
||||||
|
bridge: bridge,
|
||||||
|
authorizer: authorizer,
|
||||||
|
subscribeCh: make(chan *subscribeRequest, 256),
|
||||||
|
unsubscribeCh: make(chan *unsubscribeRequest, 256),
|
||||||
|
broadcastCh: make(chan *broadcastRequest, 256),
|
||||||
|
disconnectCh: make(chan *Conn, 256),
|
||||||
|
remoteEventCh: make(chan *remoteEvent, 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the hub event loop. Blocks until the context is cancelled.
|
||||||
|
func (h *Hub) Run(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case req := <-h.subscribeCh:
|
||||||
|
h.handleSubscribe(ctx, req)
|
||||||
|
|
||||||
|
case req := <-h.unsubscribeCh:
|
||||||
|
h.handleUnsubscribe(ctx, req)
|
||||||
|
|
||||||
|
case req := <-h.broadcastCh:
|
||||||
|
h.handleBroadcast(ctx, req)
|
||||||
|
|
||||||
|
case conn := <-h.disconnectCh:
|
||||||
|
h.handleDisconnect(ctx, conn)
|
||||||
|
|
||||||
|
case evt := <-h.remoteEventCh:
|
||||||
|
h.handleRemoteEvent(evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
||||||
|
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
|
||||||
|
req.conn.Send(ServerMessage{
|
||||||
|
Type: TypeError,
|
||||||
|
Channel: req.channelID,
|
||||||
|
Message: "unauthorized",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, ok := h.channels[req.channelID]
|
||||||
|
if !ok {
|
||||||
|
ch = newChannel(req.channelID)
|
||||||
|
h.channels[req.channelID] = ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture before addMember so multi-tab joins don't emit a spurious join.
|
||||||
|
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
|
||||||
|
|
||||||
|
ch.addMember(req.conn, req.conn.humanID)
|
||||||
|
|
||||||
|
if h.connChannels[req.conn] == nil {
|
||||||
|
h.connChannels[req.conn] = make(map[string]bool)
|
||||||
|
}
|
||||||
|
h.connChannels[req.conn][req.channelID] = true
|
||||||
|
|
||||||
|
presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("redis subscribe failed", "channelId", req.channelID, "error", err)
|
||||||
|
// Fall back to local-only presence.
|
||||||
|
presence = ch.localHumanIDs()
|
||||||
|
}
|
||||||
|
|
||||||
|
req.conn.Send(ServerMessage{
|
||||||
|
Type: TypeSubscribed,
|
||||||
|
Channel: req.channelID,
|
||||||
|
Presence: presence,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
|
||||||
|
if !wasPresentLocally {
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeJoin,
|
||||||
|
Channel: req.channelID,
|
||||||
|
HumanID: req.conn.humanID,
|
||||||
|
}, req.conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
|
||||||
|
ch, ok := h.channels[req.channelID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.removeMember(req.conn)
|
||||||
|
|
||||||
|
if chans, ok := h.connChannels[req.conn]; ok {
|
||||||
|
delete(chans, req.channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil {
|
||||||
|
flog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only emit leave once the humanID has no remaining tabs on this pod.
|
||||||
|
if !ch.hasHumanID(req.conn.humanID) {
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeLeave,
|
||||||
|
Channel: req.channelID,
|
||||||
|
HumanID: req.conn.humanID,
|
||||||
|
}, req.conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.isEmpty() {
|
||||||
|
delete(h.channels, req.channelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
|
||||||
|
ch, ok := h.channels[req.channelID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, isMember := ch.members[req.conn]; !isMember {
|
||||||
|
req.conn.sendError("not subscribed to channel: " + req.channelID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local fanout (excluding sender), then publish for other pods.
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeMessage,
|
||||||
|
Channel: req.channelID,
|
||||||
|
HumanID: req.conn.humanID,
|
||||||
|
Payload: req.payload,
|
||||||
|
}, req.conn)
|
||||||
|
|
||||||
|
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) {
|
||||||
|
chans, ok := h.connChannels[conn]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for channelID := range chans {
|
||||||
|
ch, ok := h.channels[channelID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.removeMember(conn)
|
||||||
|
|
||||||
|
if err := h.bridge.Unsubscribe(ctx, channelID, conn.id, conn.humanID); err != nil {
|
||||||
|
flog.Error("redis unsubscribe on disconnect failed", "channelId", channelID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ch.hasHumanID(conn.humanID) {
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeLeave,
|
||||||
|
Channel: channelID,
|
||||||
|
HumanID: conn.humanID,
|
||||||
|
}, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.isEmpty() {
|
||||||
|
delete(h.channels, channelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(h.connChannels, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
||||||
|
ch, ok := h.channels[evt.channelID]
|
||||||
|
if !ok {
|
||||||
|
// No local subscribers — drop the event.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch evt.event.Type {
|
||||||
|
case TypeJoin:
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeJoin,
|
||||||
|
Channel: evt.channelID,
|
||||||
|
HumanID: evt.event.HumanID,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
case TypeLeave:
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeLeave,
|
||||||
|
Channel: evt.channelID,
|
||||||
|
HumanID: evt.event.HumanID,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
case TypeMessage:
|
||||||
|
ch.broadcast(ServerMessage{
|
||||||
|
Type: TypeMessage,
|
||||||
|
Channel: evt.channelID,
|
||||||
|
HumanID: evt.event.HumanID,
|
||||||
|
Payload: evt.event.Payload,
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Subscribe(conn *Conn, channelID string) {
|
||||||
|
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) disconnect(conn *Conn) {
|
||||||
|
h.disconnectCh <- conn
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
podHeartbeatInterval = 30 * time.Second
|
||||||
|
podHeartbeatTTL = 60 * time.Second
|
||||||
|
cleanupInterval = 60 * time.Second
|
||||||
|
|
||||||
|
// Redis key prefixes
|
||||||
|
channelConnsPrefix = "pusher:ch:"
|
||||||
|
channelConnsSuffix = ":conns"
|
||||||
|
podKeyPrefix = "pusher:pod:"
|
||||||
|
pubsubPrefix = "pusher:events:"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wire format for cross-pod Pub/Sub.
|
||||||
|
type redisEvent struct {
|
||||||
|
Type string `json:"type"` // "join", "leave", "message"
|
||||||
|
HumanID string `json:"humanId,omitempty"` // who triggered the event
|
||||||
|
PodID string `json:"podId,omitempty"` // originating pod
|
||||||
|
Payload json.RawMessage `json:"payload,omitempty"` // message events only
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence
|
||||||
|
// tracking.
|
||||||
|
type RedisBridge struct {
|
||||||
|
client *redis.Client
|
||||||
|
podID string
|
||||||
|
hub *Hub // wired post-construction; see SetHub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
||||||
|
return &RedisBridge{
|
||||||
|
client: client,
|
||||||
|
podID: podID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHub resolves the circular dependency between Hub and RedisBridge.
|
||||||
|
func (rb *RedisBridge) SetHub(hub *Hub) {
|
||||||
|
rb.hub = hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Presence management ---
|
||||||
|
|
||||||
|
// Subscribe records the connection in Redis and returns the channel's
|
||||||
|
// current deduplicated presence set.
|
||||||
|
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
|
||||||
|
key := channelConnsKey(channelID)
|
||||||
|
field := rb.connField(connID)
|
||||||
|
|
||||||
|
// Snapshot before the insert so multi-tab joins don't double-emit.
|
||||||
|
existingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wasPresent := containsString(existingMembers, humanID)
|
||||||
|
|
||||||
|
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !wasPresent {
|
||||||
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
|
Type: TypeJoin,
|
||||||
|
HumanID: humanID,
|
||||||
|
PodID: rb.podID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
allMembers, err := rb.client.HVals(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||||
|
}
|
||||||
|
return deduplicateStrings(allMembers), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
|
||||||
|
key := channelConnsKey(channelID)
|
||||||
|
field := rb.connField(connID)
|
||||||
|
|
||||||
|
if err := rb.client.HDel(ctx, key, field).Err(); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove connection from channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only emit leave once this humanID has no tabs left in the channel.
|
||||||
|
remainingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
return fmt.Errorf("failed to get remaining members: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !containsString(remainingMembers, humanID) {
|
||||||
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
|
Type: TypeLeave,
|
||||||
|
HumanID: humanID,
|
||||||
|
PodID: rb.podID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remainingMembers) == 0 {
|
||||||
|
rb.client.Del(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
|
||||||
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
|
Type: TypeMessage,
|
||||||
|
HumanID: humanID,
|
||||||
|
PodID: rb.podID,
|
||||||
|
Payload: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
|
||||||
|
result := make(map[string][]string, len(channelIDs))
|
||||||
|
for _, chID := range channelIDs {
|
||||||
|
members, err := rb.client.HVals(ctx, channelConnsKey(chID)).Result()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
return nil, fmt.Errorf("failed to get presence for %s: %w", chID, err)
|
||||||
|
}
|
||||||
|
result[chID] = deduplicateStrings(members)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns every humanID with at least one active connection cluster-wide.
|
||||||
|
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
|
||||||
|
allHumanIDs := make(map[string]bool)
|
||||||
|
var cursor uint64
|
||||||
|
|
||||||
|
for {
|
||||||
|
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan channel keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
members, err := rb.client.HVals(ctx, key).Result()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, humanID := range members {
|
||||||
|
allHumanIDs[humanID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = nextCursor
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(allHumanIDs))
|
||||||
|
for id := range allHumanIDs {
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pub/Sub listener ---
|
||||||
|
|
||||||
|
// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
|
||||||
|
func (rb *RedisBridge) Listen(ctx context.Context) {
|
||||||
|
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
|
||||||
|
defer pubsub.Close()
|
||||||
|
|
||||||
|
ch := pubsub.Channel()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rb.handlePubSubMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||||
|
// Topic: "pusher:events:{channelID}".
|
||||||
|
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
|
||||||
|
if channelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var event redisEvent
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil {
|
||||||
|
flog.Error("failed to parse pub/sub event", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-pod events were already handled by the local hub.
|
||||||
|
if event.PodID == rb.podID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rb.hub == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rb.hub.remoteEventCh <- &remoteEvent{
|
||||||
|
channelID: channelID,
|
||||||
|
event: event,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Heartbeat + cleanup ---
|
||||||
|
|
||||||
|
// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick.
|
||||||
|
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||||
|
podKey := podKeyPrefix + rb.podID
|
||||||
|
|
||||||
|
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
|
||||||
|
|
||||||
|
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
|
||||||
|
cleanupTicker := time.NewTicker(cleanupInterval)
|
||||||
|
defer heartbeatTicker.Stop()
|
||||||
|
defer cleanupTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// On shutdown, drop our pod key and reclaim our connection slots.
|
||||||
|
rb.client.Del(context.Background(), podKey)
|
||||||
|
rb.cleanupPod(context.Background(), rb.podID)
|
||||||
|
return
|
||||||
|
case <-heartbeatTicker.C:
|
||||||
|
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
|
||||||
|
case <-cleanupTicker.C:
|
||||||
|
rb.cleanupStalePods(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||||
|
// Collect every pod referenced in channel-conn hashes, then drop those
|
||||||
|
// whose liveness key has expired.
|
||||||
|
var cursor uint64
|
||||||
|
knownPods := make(map[string]bool)
|
||||||
|
alivePods := make(map[string]bool)
|
||||||
|
|
||||||
|
for {
|
||||||
|
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to scan channel keys", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
fields, err := rb.client.HKeys(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
podID := extractPodID(field)
|
||||||
|
if podID != "" {
|
||||||
|
knownPods[podID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = nextCursor
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for podID := range knownPods {
|
||||||
|
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exists > 0 {
|
||||||
|
alivePods[podID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for podID := range knownPods {
|
||||||
|
if !alivePods[podID] {
|
||||||
|
flog.Info("cleaning up stale pod", "podId", podID)
|
||||||
|
rb.cleanupPod(ctx, podID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) {
|
||||||
|
var cursor uint64
|
||||||
|
for {
|
||||||
|
keys, nextCursor, err := rb.client.Scan(ctx, cursor, channelConnsPrefix+"*"+channelConnsSuffix, 100).Result()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
fields, err := rb.client.HGetAll(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
channelID := extractChannelID(key)
|
||||||
|
for field, humanID := range fields {
|
||||||
|
if extractPodID(field) == podID {
|
||||||
|
rb.client.HDel(ctx, key, field)
|
||||||
|
remaining, _ := rb.client.HVals(ctx, key).Result()
|
||||||
|
if !containsString(remaining, humanID) {
|
||||||
|
rb.publishEvent(ctx, channelID, redisEvent{
|
||||||
|
Type: TypeLeave,
|
||||||
|
HumanID: humanID,
|
||||||
|
PodID: rb.podID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = nextCursor
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func (rb *RedisBridge) connField(connID string) string {
|
||||||
|
return rb.podID + ":" + connID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rb *RedisBridge) publishEvent(ctx context.Context, channelID string, event redisEvent) {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to marshal event", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := rb.client.Publish(ctx, pubsubPrefix+channelID, data).Err(); err != nil {
|
||||||
|
flog.Error("failed to publish event", "channelId", channelID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelConnsKey(channelID string) string {
|
||||||
|
return channelConnsPrefix + channelID + channelConnsSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// "pusher:ch:{channelID}:conns" → channelID
|
||||||
|
func extractChannelID(redisKey string) string {
|
||||||
|
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
|
||||||
|
s = strings.TrimSuffix(s, channelConnsSuffix)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// "{podID}:{connID}" → podID
|
||||||
|
func extractPodID(field string) string {
|
||||||
|
parts := strings.SplitN(field, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(slice []string, s string) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicateStrings(slice []string) []string {
|
||||||
|
seen := make(map[string]bool, len(slice))
|
||||||
|
result := make([]string, 0, len(slice))
|
||||||
|
for _, s := range slice {
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
|
||||||
|
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
||||||
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server handles WebSocket upgrades and gRPC presence queries.
|
||||||
|
type Server struct {
|
||||||
|
pbpusher.UnimplementedPusherServiceServer
|
||||||
|
|
||||||
|
ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully
|
||||||
|
hub *Hub
|
||||||
|
bridge *RedisBridge
|
||||||
|
authSvc auth.SessionReader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
|
||||||
|
return &Server{
|
||||||
|
ctx: ctx,
|
||||||
|
hub: hub,
|
||||||
|
bridge: bridge,
|
||||||
|
authSvc: authSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Token rides in the query string — WebSocket upgrades can't carry custom headers.
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "token required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := s.authSvc.GetSession(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
// CORS is enforced at the gateway.
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("websocket accept failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connID := uuid.New().String()
|
||||||
|
conn := newConn(connID, session.HumanId, ws)
|
||||||
|
|
||||||
|
flog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
|
||||||
|
|
||||||
|
// Use the server context, not r.Context(): after upgrade the HTTP request
|
||||||
|
// context can be cancelled by load balancers and nhooyr/websocket would
|
||||||
|
// then permanently close the conn.
|
||||||
|
ctx, cancel := context.WithCancel(s.ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Auto-subscribe to the presence channel so this user appears online.
|
||||||
|
s.hub.Subscribe(conn, "_presence:"+session.HumanId)
|
||||||
|
|
||||||
|
go conn.WritePump(ctx)
|
||||||
|
conn.ReadPump(ctx, s.hub)
|
||||||
|
|
||||||
|
flog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
|
||||||
|
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
|
||||||
|
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
onlineSet := make(map[string]bool, len(allOnline))
|
||||||
|
for _, id := range allOnline {
|
||||||
|
onlineSet[id] = true
|
||||||
|
}
|
||||||
|
result := make(map[string]bool, len(req.HumanIds))
|
||||||
|
for _, id := range req.HumanIds {
|
||||||
|
result[id] = onlineSet[id]
|
||||||
|
}
|
||||||
|
return &pbpusher.IsOnlineResponse{Online: result}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
|
||||||
|
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := &pbpusher.GetChannelPresenceResponse{
|
||||||
|
Presences: make(map[string]*pbpusher.ChannelPresence, len(presence)),
|
||||||
|
}
|
||||||
|
for chID, humanIDs := range presence {
|
||||||
|
resp.Presences[chID] = &pbpusher.ChannelPresence{
|
||||||
|
HumanIds: humanIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pusher
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Client → Server message types
|
||||||
|
const (
|
||||||
|
TypeSubscribe = "subscribe"
|
||||||
|
TypeUnsubscribe = "unsubscribe"
|
||||||
|
TypeMessage = "message"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server → Client message types
|
||||||
|
const (
|
||||||
|
TypeSubscribed = "subscribed"
|
||||||
|
TypeJoin = "join"
|
||||||
|
TypeLeave = "leave"
|
||||||
|
// TypeMessage is reused for server → client messages
|
||||||
|
TypeError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
HumanID string `json:"humanId,omitempty"`
|
||||||
|
Presence []string `json:"presence,omitempty"`
|
||||||
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
@@ -3,23 +3,25 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConnectAndTestRedis(db int) *redis.Client {
|
func ConnectAndTestRedis(db int) *redis.Client {
|
||||||
redisHost := utils.MustGetEnv("REDIS_HOST")
|
redisHost := utils.MustGetEnv("REDIS_HOST")
|
||||||
if redisHost == "" {
|
if redisHost == "" {
|
||||||
slog.Error("must provide REDIS_HOST")
|
flog.Error("must provide REDIS_HOST")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
|
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: redisAddr,
|
Addr: redisAddr,
|
||||||
Password: "", // no password set
|
Password: "",
|
||||||
DB: db,
|
DB: db,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ func ConnectAndTestRedis(db int) *redis.Client {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
slog.Debug("redis test", "key", val)
|
flog.Debug("redis test", "key", val)
|
||||||
if val != "value" {
|
if val != "value" {
|
||||||
panic("unexpected value")
|
panic("unexpected value")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package speech
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/utils/flog"
|
||||||
|
|
||||||
|
dgapi "github.com/deepgram/deepgram-go-sdk/v3/pkg/api/listen/v1/rest"
|
||||||
|
interfaces "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/interfaces"
|
||||||
|
client "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/listen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TranscriptWord struct {
|
||||||
|
Word string
|
||||||
|
Start float64
|
||||||
|
End float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscriptSentence struct {
|
||||||
|
Text string
|
||||||
|
Start float64
|
||||||
|
End float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscriptParagraph struct {
|
||||||
|
Sentences []TranscriptSentence
|
||||||
|
Start float64
|
||||||
|
End float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscriptResult struct {
|
||||||
|
Transcript string
|
||||||
|
Words []TranscriptWord
|
||||||
|
Paragraphs []TranscriptParagraph
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeechService interface {
|
||||||
|
Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type speechServiceImpl struct {
|
||||||
|
deepgramClient *dgapi.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpeechService(ctx context.Context) SpeechService {
|
||||||
|
return &speechServiceImpl{
|
||||||
|
deepgramClient: dgapi.New(client.NewRESTWithDefaults()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) {
|
||||||
|
options := &interfaces.PreRecordedTranscriptionOptions{
|
||||||
|
Model: "nova-3",
|
||||||
|
SmartFormat: true,
|
||||||
|
Paragraphs: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to transcribe prerecorded media", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
alt := response.Results.Channels[0].Alternatives[0]
|
||||||
|
|
||||||
|
words := make([]TranscriptWord, len(alt.Words))
|
||||||
|
for i, w := range alt.Words {
|
||||||
|
words[i] = TranscriptWord{
|
||||||
|
Word: w.PunctuatedWord,
|
||||||
|
Start: w.Start,
|
||||||
|
End: w.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs []TranscriptParagraph
|
||||||
|
if alt.Paragraphs != nil {
|
||||||
|
paragraphs = make([]TranscriptParagraph, len(alt.Paragraphs.Paragraphs))
|
||||||
|
for i, p := range alt.Paragraphs.Paragraphs {
|
||||||
|
sentences := make([]TranscriptSentence, len(p.Sentences))
|
||||||
|
for j, s := range p.Sentences {
|
||||||
|
sentences[j] = TranscriptSentence{
|
||||||
|
Text: s.Text,
|
||||||
|
Start: s.Start,
|
||||||
|
End: s.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paragraphs[i] = TranscriptParagraph{
|
||||||
|
Sentences: sentences,
|
||||||
|
Start: p.Start,
|
||||||
|
End: p.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TranscriptResult{
|
||||||
|
Transcript: alt.Transcript,
|
||||||
|
Words: words,
|
||||||
|
Paragraphs: paragraphs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Package aero hosts a mock for the third-party aero gRPC client. The
|
||||||
|
// interface is generated protobuf code we don't own, so the //go:generate
|
||||||
|
// directive lives here rather than next to the source.
|
||||||
|
package aero
|
||||||
|
|
||||||
|
//go:generate go tool mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Code generated by MockGen. DO NOT EDIT.
|
||||||
|
// Source: github.com/flowy-live/llink/genproto/aero (interfaces: PrimaryClient)
|
||||||
|
//
|
||||||
|
// Generated by this command:
|
||||||
|
//
|
||||||
|
// mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
|
||||||
|
//
|
||||||
|
|
||||||
|
// Package aero is a generated GoMock package.
|
||||||
|
package aero
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
reflect "reflect"
|
||||||
|
|
||||||
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
gomock "go.uber.org/mock/gomock"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockPrimaryClient is a mock of PrimaryClient interface.
|
||||||
|
type MockPrimaryClient struct {
|
||||||
|
ctrl *gomock.Controller
|
||||||
|
recorder *MockPrimaryClientMockRecorder
|
||||||
|
isgomock struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockPrimaryClientMockRecorder is the mock recorder for MockPrimaryClient.
|
||||||
|
type MockPrimaryClientMockRecorder struct {
|
||||||
|
mock *MockPrimaryClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockPrimaryClient creates a new mock instance.
|
||||||
|
func NewMockPrimaryClient(ctrl *gomock.Controller) *MockPrimaryClient {
|
||||||
|
mock := &MockPrimaryClient{ctrl: ctrl}
|
||||||
|
mock.recorder = &MockPrimaryClientMockRecorder{mock}
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||||
|
func (m *MockPrimaryClient) EXPECT() *MockPrimaryClientMockRecorder {
|
||||||
|
return m.recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShootEmail mocks base method.
|
||||||
|
func (m *MockPrimaryClient) ShootEmail(ctx context.Context, in *pbaero.ShootEmailRequest, opts ...grpc.CallOption) (*pbaero.ShootEmailResponse, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
varargs := []any{ctx, in}
|
||||||
|
for _, a := range opts {
|
||||||
|
varargs = append(varargs, a)
|
||||||
|
}
|
||||||
|
ret := m.ctrl.Call(m, "ShootEmail", varargs...)
|
||||||
|
ret0, _ := ret[0].(*pbaero.ShootEmailResponse)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShootEmail indicates an expected call of ShootEmail.
|
||||||
|
func (mr *MockPrimaryClientMockRecorder) ShootEmail(ctx, in any, opts ...any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
varargs := append([]any{ctx, in}, opts...)
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShootEmail", reflect.TypeOf((*MockPrimaryClient)(nil).ShootEmail), varargs...)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user