Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590edcf00f |
@@ -1,85 +0,0 @@
|
||||
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 --tail
|
||||
else
|
||||
skaffold run -p ${{ inputs.environment }} -m ${{ inputs.module }}
|
||||
fi
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,72 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
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 ./...
|
||||
@@ -1,71 +0,0 @@
|
||||
name: PR Quality Gate (client applications)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "js/mobile/**"
|
||||
- "js/desktop/**"
|
||||
branches: [ main ]
|
||||
types: [ opened, synchronize, reopened ]
|
||||
|
||||
jobs:
|
||||
mobile:
|
||||
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
|
||||
|
||||
desktop:
|
||||
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
|
||||
|
||||
|
||||
@@ -4,4 +4,3 @@ build/
|
||||
.cache/
|
||||
compile_commands.json
|
||||
CMakeLists.txt.user
|
||||
tags
|
||||
|
||||
@@ -11,9 +11,7 @@ Whenever implementing anything, make sure to take into account best practices wi
|
||||
### Quality
|
||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Package Manager
|
||||
- Use **yarn** (not npm) for all dependency management
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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"]
|
||||
+24
-32
@@ -8,13 +8,19 @@ generate:
|
||||
test:
|
||||
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"
|
||||
create-migration:
|
||||
echo "Creating migration with name $(ARG)"
|
||||
docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations $(ARG)
|
||||
|
||||
# ---- Database access ----
|
||||
|
||||
dev-database:
|
||||
kubectl run postgres-client \
|
||||
--rm -it --image=postgres:latest \
|
||||
@@ -22,37 +28,23 @@ dev-database:
|
||||
--context dev \
|
||||
--command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
|
||||
|
||||
prod-database:
|
||||
kubectl run postgres-client \
|
||||
--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)" \
|
||||
--context prod \
|
||||
--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 --tail
|
||||
|
||||
.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 --tail
|
||||
|
||||
# ---- 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
|
||||
# prod-database:
|
||||
# kubectl run postgres-client \
|
||||
# --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)" \
|
||||
# --context prod \
|
||||
# --command -- /bin/bash -c "psql \$$LLINK_POSTGRES_CONNECTION_URL"
|
||||
|
||||
.PHONY: deploy-dev
|
||||
deploy-dev: generate test
|
||||
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p dev $(if $(MODULE),-m $(MODULE)) --kube-context dev --port-forward --tail
|
||||
./deploy_dev.sh
|
||||
|
||||
.PHONY: deploy-prod
|
||||
deploy-prod: generate test
|
||||
SKAFFOLD_DEFAULT_REPO=$(PROD_REPO) skaffold run -p prod $(if $(MODULE),-m $(MODULE)) --kube-context prod --port-forward --tail
|
||||
# .PHONY: release
|
||||
# deploy-prod: generate test
|
||||
# # Check for any changes (both staged and unstaged)
|
||||
# @if [ -n "$(git status --porcelain)" ]; then \
|
||||
# echo "Error: You have uncommitted changes in your Git repository."; \
|
||||
# exit 1; \
|
||||
# fi
|
||||
# ./deploy_prod.sh
|
||||
#
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,99 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
DEV_REPO: us-west2-docker.pkg.dev/flowy-dev-440017/deployments
|
||||
PROD_REPO: us-west2-docker.pkg.dev/flowy-prod-440017/deployments
|
||||
|
||||
tasks:
|
||||
generate:
|
||||
desc: Generate protobuf code and run go generate
|
||||
cmds:
|
||||
- task: genproto
|
||||
- go generate ./...
|
||||
|
||||
format:
|
||||
desc: Format go files
|
||||
cmds:
|
||||
- go fmt ./...
|
||||
|
||||
genproto:
|
||||
desc: Generate Go code from .proto files via docker
|
||||
vars:
|
||||
PROTO_DIR: '{{.PROTO_DIR | default "./protocol"}}'
|
||||
OUT_DIR: '{{.OUT_DIR | default "./genproto"}}'
|
||||
cmds:
|
||||
- 'echo "Proto directory: {{.PROTO_DIR}}"'
|
||||
- 'echo "Output directory: {{.OUT_DIR}}"'
|
||||
- rm -rf {{.OUT_DIR}}
|
||||
- mkdir -p {{.OUT_DIR}}
|
||||
- |
|
||||
docker run --rm \
|
||||
-v "{{.PROTO_DIR}}/":/protocol \
|
||||
-v "{{.OUT_DIR}}":/genproto \
|
||||
--workdir / \
|
||||
talksik/golang-protoc:latest \
|
||||
sh -c 'protoc --proto_path=./protocol \
|
||||
--go_out=./genproto \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-grpc_out=./genproto \
|
||||
--go-grpc_opt=paths=source_relative \
|
||||
$(find ./protocol -name "*.proto")'
|
||||
|
||||
test:
|
||||
desc: Run tests with pretty output via gotestfmt
|
||||
cmds:
|
||||
- go test -json ./... | docker run -i ghcr.io/gotesttools/gotestfmt:latest
|
||||
|
||||
create-migration:
|
||||
desc: 'Create new migration (usage: task create-migration ARG=create_users_table)'
|
||||
cmds:
|
||||
- 'echo "Creating migration with name {{.ARG}}"'
|
||||
- docker run -v ./migrations:/migrations --network host migrate/migrate create -ext sql -seq -dir /migrations {{.ARG}}
|
||||
|
||||
dev-database:
|
||||
desc: Open psql shell against the dev database
|
||||
interactive: true
|
||||
cmds:
|
||||
- |
|
||||
kubectl run postgres-client \
|
||||
--rm -it --image=postgres:latest \
|
||||
--env="LLINK_POSTGRES_CONNECTION_URL=$(kubectl get secret shared-secrets -o jsonpath='{.data.LLINK_POSTGRES_CONNECTION_URL}' --context dev | base64 --decode)" \
|
||||
--context dev \
|
||||
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
|
||||
|
||||
prod-database:
|
||||
desc: Open psql shell against the prod database
|
||||
interactive: true
|
||||
cmds:
|
||||
- |
|
||||
kubectl run postgres-client \
|
||||
--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)" \
|
||||
--context prod \
|
||||
--command -- /bin/bash -c "psql \$LLINK_POSTGRES_CONNECTION_URL"
|
||||
|
||||
migrate-dev:
|
||||
desc: Run migrations against dev cluster
|
||||
cmds:
|
||||
- kubectl delete job migrations --ignore-not-found --context=dev
|
||||
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p migrations --kube-context dev --tail
|
||||
|
||||
migrate-prod:
|
||||
desc: Run migrations against prod cluster
|
||||
cmds:
|
||||
- kubectl delete job migrations --ignore-not-found --context=prod
|
||||
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p migrations --kube-context prod --tail
|
||||
|
||||
deploy-dev:
|
||||
desc: 'Deploy to dev (optionally MODULE=orion to deploy a single service)'
|
||||
cmds:
|
||||
- task: generate
|
||||
- task: test
|
||||
- SKAFFOLD_DEFAULT_REPO={{.DEV_REPO}} skaffold run -p dev {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context dev --port-forward --tail
|
||||
|
||||
deploy-prod:
|
||||
desc: 'Deploy to prod (optionally MODULE=orion to deploy a single service)'
|
||||
cmds:
|
||||
- task: generate
|
||||
- task: test
|
||||
- SKAFFOLD_DEFAULT_REPO={{.PROD_REPO}} skaffold run -p prod {{if .MODULE}}-m {{.MODULE}}{{end}} --kube-context prod --port-forward --tail
|
||||
@@ -1,247 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
+47
-108
@@ -3,118 +3,83 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"cloud.google.com/go/firestore"
|
||||
"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"
|
||||
"github.com/flowy-live/llink/internal"
|
||||
"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/depot"
|
||||
"github.com/flowy-live/llink/internal/handler"
|
||||
"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/network"
|
||||
"github.com/flowy-live/llink/internal/particle"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/flowy-live/llink/internal/waitlist"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"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 {
|
||||
return internal.ConnectAndTestRedis(db.RedisDBAuth)
|
||||
return internal.ConnectAndTestRedis(REDIS_DATABASE_FOR_AUTH)
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := utils.MustGetEnv("PORT")
|
||||
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
||||
|
||||
// Initialize database
|
||||
db.Init()
|
||||
defer db.Cleanup()
|
||||
|
||||
// Initialize Redis for auth
|
||||
redisClient := redisForAuth()
|
||||
|
||||
// Initialize GCS client
|
||||
ctx := context.Background()
|
||||
storageClient, err := storage.NewClient(ctx)
|
||||
if err != nil {
|
||||
flog.Error("failed to create GCS client", "error", err)
|
||||
slog.Error("failed to create GCS client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer storageClient.Close()
|
||||
|
||||
aeroAddr := utils.MustGetEnv("AERO_ADDR")
|
||||
if aeroAddr == "" {
|
||||
flog.Error("must provide AERO_ADDR")
|
||||
slog.Error("must provide AERO_ADDR")
|
||||
os.Exit(1)
|
||||
}
|
||||
aeroServer, err := grpc.NewClient(aeroAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
flog.Error("connection to aero server invalid", "error", err)
|
||||
slog.Error("connection to aero server invalid", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer aeroServer.Close()
|
||||
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
||||
|
||||
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||
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)
|
||||
// Initialize services
|
||||
authSvc := auth.NewAuthService(redisClient, aeroSvc)
|
||||
humanSvc := human.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)
|
||||
networkSvc := network.NewService(db.Pool())
|
||||
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
||||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||
BucketName: gcsBucket,
|
||||
})
|
||||
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||
pushTokenSvc := pushnotify.NewService(db.Pool())
|
||||
livekitClient := livekit.NewClient()
|
||||
|
||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, pushTokenSvc, livekitClient, firestoreClient)
|
||||
// Initialize handler
|
||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc)
|
||||
|
||||
// Helper to wrap handlers with auth middleware
|
||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
||||
}
|
||||
@@ -132,9 +97,6 @@ func main() {
|
||||
})
|
||||
mux.HandleFunc("POST /auth/request-code", h.RequestSignInCode)
|
||||
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)
|
||||
@@ -143,70 +105,47 @@ func main() {
|
||||
// Auth
|
||||
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
|
||||
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
|
||||
mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken))
|
||||
|
||||
// Settings
|
||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||
|
||||
// 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))
|
||||
// Bootstrap startup data
|
||||
mux.Handle("GET /startup", withAuth(h.StartupData))
|
||||
|
||||
// Networks
|
||||
mux.Handle("POST /networks", withAuth(h.CreateNetwork))
|
||||
mux.Handle("GET /networks", withAuth(h.ListNetworks))
|
||||
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
||||
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
||||
mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
||||
// TODO: what about members who are part of streams visibility within this network?
|
||||
mux.Handle("DELETE /networks/{id}/members/{email}", withAuth(h.RemoveMemberFromNetwork))
|
||||
mux.Handle("PUT /networks/{id}/capacity", withAuth(h.SetOpenStreamCapacity))
|
||||
|
||||
// Billing (network admin only; admin check happens inside each handler)
|
||||
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
|
||||
mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession))
|
||||
mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession))
|
||||
|
||||
// Freemium usage (any network member)
|
||||
mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage))
|
||||
|
||||
// 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))
|
||||
// Streams
|
||||
mux.Handle("POST /networks/{network_id}/streams", withAuth(h.CreateStream))
|
||||
mux.Handle("GET /streams/{id}", withAuth(h.GetStream))
|
||||
mux.Handle("PATCH /streams/{id}", withAuth(h.UpdateStream))
|
||||
mux.Handle("POST /streams/{id}/particles", withAuth(h.CreateStreamParticle))
|
||||
mux.Handle("POST /streams/{id}/open", withAuth(h.OpenStream))
|
||||
mux.Handle("POST /streams/{id}/close", withAuth(h.CloseStream))
|
||||
mux.Handle("POST /streams/{id}/members", withAuth(h.AddMembers))
|
||||
mux.Handle("DELETE /streams/{id}/members", withAuth(h.RemoveMembers))
|
||||
|
||||
// Particles
|
||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
||||
|
||||
// Link metadata
|
||||
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||
mux.Handle("GET /networks/{network_id}/particles", withAuth(h.ListParticles))
|
||||
mux.Handle("GET /particles/{id}", withAuth(h.GetParticle))
|
||||
mux.Handle("PATCH /particles/{id}", withAuth(h.UpdateParticle))
|
||||
mux.Handle("DELETE /particles/{id}", withAuth(h.DeleteParticle))
|
||||
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
|
||||
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
|
||||
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)
|
||||
flog.Info("running server", "addr", addr)
|
||||
if err := http.ListenAndServe(addr, muxWithCors); err != nil {
|
||||
flog.Error("server failed", "error", err)
|
||||
slog.Info("running server", "addr", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
slog.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
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] + "…"
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/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
|
||||
+311
-3
@@ -51,6 +51,54 @@ 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": ["[email protected]"],
|
||||
"particles": [
|
||||
{
|
||||
"id": "p-002",
|
||||
"type": "text",
|
||||
"data": { "content": "Hello" },
|
||||
"created_by_email": "[email protected]",
|
||||
"seen": true,
|
||||
"acks": [],
|
||||
"updated_at": "...",
|
||||
"created_at": "..."
|
||||
}
|
||||
],
|
||||
"unseen_count": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Networks
|
||||
|
||||
### Create Network
|
||||
@@ -88,15 +136,183 @@ Returns a specific network by ID.
|
||||
|
||||
Removes a member by email from the network.
|
||||
|
||||
### Set Open Stream Capacity
|
||||
`PUT /networks/{id}/capacity` (Protected, Admin only)
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"capacity": 10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Download Particle Object
|
||||
## Streams
|
||||
|
||||
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": ["[email protected]"]
|
||||
}
|
||||
```
|
||||
|
||||
- `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": ["[email protected]"]
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Members from Stream
|
||||
`DELETE /streams/{id}/members` (Protected)
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"emails": ["[email protected]"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
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.
|
||||
|
||||
### 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)
|
||||
@@ -179,6 +395,8 @@ Confirms that an upload has been completed.
|
||||
| `name` | `string` | Display name of the network. |
|
||||
| `admin_human` | `Human` | The network administrator. |
|
||||
| `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. |
|
||||
|
||||
```json
|
||||
@@ -189,10 +407,86 @@ Confirms that an upload has been completed.
|
||||
"humans": [
|
||||
{ "id": "abc-123", "email": "[email protected]", "email_prefix": "alice", "created_at": "..." }
|
||||
],
|
||||
"open_stream_count": 2,
|
||||
"open_stream_capacity": 5,
|
||||
"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": ["[email protected]"],
|
||||
"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
|
||||
|
||||
Returned by `POST /depot/objects/{id}/confirm`.
|
||||
@@ -297,6 +591,20 @@ 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
|
||||
|
||||
All endpoints return standard HTTP status codes with plain text error bodies:
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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,189 +1,108 @@
|
||||
module github.com/flowy-live/llink
|
||||
|
||||
go 1.25.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.21.0
|
||||
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/google/uuid v1.6.0
|
||||
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/sirupsen/logrus v1.9.3
|
||||
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/modules/postgres v0.40.0
|
||||
go.jetify.com/typeid v1.3.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/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.123.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/compute/metadata v0.9.0 // 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
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // 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/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/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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v1.0.0-rc.2 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // 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/dennwc/iters v1.2.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.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/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // 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-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // 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/googleapis/enterprise-certificate-proxy v0.3.7 // 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/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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // 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/klauspost/compress v1.18.0 // 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/magefile/mage v1.15.0 // 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/go-archive v0.1.0 // indirect
|
||||
github.com/moby/patternmatcher 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/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/moby/term v0.5.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/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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/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/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // 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/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.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/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/atomic v1.11.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
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/api v0.256.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.110.1 // indirect
|
||||
)
|
||||
|
||||
tool go.uber.org/mock/mockgen
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ=
|
||||
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=
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
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/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
@@ -14,8 +8,6 @@ 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/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
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/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
@@ -30,12 +22,10 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4
|
||||
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/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/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/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/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
@@ -44,149 +34,88 @@ 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/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/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/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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
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/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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
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/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0=
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
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/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
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/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4=
|
||||
github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
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.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/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/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
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/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/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/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/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/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
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.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs=
|
||||
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/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
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/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
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/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-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-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/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/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/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.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
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/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/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/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/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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -195,51 +124,24 @@ 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/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/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||
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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
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/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/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/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/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/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/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
@@ -250,60 +152,14 @@ 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/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
@@ -313,26 +169,12 @@ 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/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/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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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/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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
@@ -344,8 +186,6 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||
@@ -354,149 +194,71 @@ 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/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
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/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/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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||
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/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/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
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/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
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=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
|
||||
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
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/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/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-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-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-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-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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
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/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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
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/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/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
|
||||
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-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
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/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
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=
|
||||
@@ -509,7 +271,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/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=
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
}
|
||||
+36
-54
@@ -2,20 +2,16 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
firebaseauth "firebase.google.com/go/v4/auth"
|
||||
"github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.jetify.com/typeid"
|
||||
|
||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -41,54 +37,34 @@ func newSessionToken() (sessionToken, error) {
|
||||
return typeid.New[sessionToken]()
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Email string `json:"email"`
|
||||
HumanId string `json:"human_id"`
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
SessionReader
|
||||
|
||||
// RequestSignInCode emails a one-time code; the client redeems it via VerifySignInCode.
|
||||
// RequestSignInCode generates a code and emails it to the provided email.
|
||||
// To retrieve a session, client must verify with VerifySignInCode.
|
||||
RequestSignInCode(ctx context.Context, email string) error
|
||||
// VerifySignInCode returns ErrInvalidCode on a wrong code, otherwise creates
|
||||
// a session keyed to (email, humanId).
|
||||
VerifySignInCode(ctx context.Context, email, code, humanId string) (sessionToken string, err error)
|
||||
// ExtendSession returns ErrSessionNotFound if no valid session.
|
||||
// VerifySignInCode returns ErrInvalidCode if incorrect code
|
||||
VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error)
|
||||
// GetSession returns ErrSessionNotFound if no valid session
|
||||
GetSession(ctx context.Context, sessionToken string) (email string, err error)
|
||||
// ExtendSession returns ErrSessionNotFound if no valid session
|
||||
ExtendSession(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
|
||||
}
|
||||
|
||||
type authServiceImpl struct {
|
||||
*sessionReaderImpl
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
fbAuth *firebaseauth.Client
|
||||
redisClient *redis.Client
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
}
|
||||
|
||||
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
|
||||
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 NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
|
||||
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
flog.Error("problem validating email", "error", err)
|
||||
slog.Error("problem validating email", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -112,12 +88,12 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
|
||||
|
||||
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
|
||||
if err != nil {
|
||||
flog.Error("error setting code in redis", "error", err)
|
||||
slog.Error("error setting code in redis", "error", 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)
|
||||
subject := fmt.Sprint("Sign In - Your One-Time Code for Flowy.llink")
|
||||
subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink")
|
||||
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||
ToEmails: []string{formattedEmail},
|
||||
Subject: subject,
|
||||
@@ -131,11 +107,11 @@ func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) e
|
||||
return fmt.Errorf("an error occurred while sending the email: %w", err)
|
||||
}
|
||||
|
||||
flog.Info("sent sign in code", "email", formattedEmail)
|
||||
slog.Info("sent sign in code", "email", formattedEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, humanId string) (string, error) {
|
||||
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) {
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid email: %w", err)
|
||||
@@ -146,7 +122,7 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, hum
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
flog.Error("error getting code from redis", "error", err)
|
||||
slog.Error("error getting code from redis", "error", err)
|
||||
return "", fmt.Errorf("error verifying code: %w", err)
|
||||
}
|
||||
|
||||
@@ -155,10 +131,10 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, hum
|
||||
}
|
||||
|
||||
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
|
||||
flog.Error("error deleting code from redis", "error", err)
|
||||
slog.Error("error deleting code from redis", "error", err)
|
||||
}
|
||||
|
||||
token, err := a.createSession(ctx, formattedEmail, humanId)
|
||||
token, err := a.createSession(ctx, formattedEmail)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -166,6 +142,18 @@ func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code, hum
|
||||
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 {
|
||||
ttl, err := a.redisClient.TTL(ctx, token).Result()
|
||||
if err != nil {
|
||||
@@ -192,7 +180,7 @@ func (a *authServiceImpl) SignOut(ctx context.Context, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) createSession(ctx context.Context, email, humanId string) (string, error) {
|
||||
func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) {
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid email: %w", err)
|
||||
@@ -203,13 +191,7 @@ func (a *authServiceImpl) createSession(ctx context.Context, email, humanId stri
|
||||
return "", fmt.Errorf("error generating session token: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil {
|
||||
return "", fmt.Errorf("error storing session: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package constants
|
||||
|
||||
const FlowyAdminEmail = "[email protected]"
|
||||
@@ -2,10 +2,9 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -22,24 +21,24 @@ func Pool() *pgxpool.Pool {
|
||||
func Init() {
|
||||
connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL")
|
||||
if connString == "" {
|
||||
flog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
|
||||
slog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbpool, err := pgxpool.New(context.Background(), connString)
|
||||
if err != nil {
|
||||
flog.Error("unable to create connection pool", "error", err)
|
||||
slog.Error("unable to create connection pool", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var greeting string
|
||||
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
|
||||
if err != nil {
|
||||
flog.Error("queryRow failed", "error", err)
|
||||
slog.Error("queryRow failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
flog.Info("successfully connected to database", "greeting", greeting)
|
||||
slog.Info("successfully connected to database", "greeting", greeting)
|
||||
|
||||
db = dbpool
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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,6 +2,7 @@ package depot
|
||||
|
||||
import "time"
|
||||
|
||||
// Object represents a stored object in the depot
|
||||
type Object struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -13,26 +14,22 @@ type Object struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PrepareUploadInput represents the input for preparing an upload
|
||||
type PrepareUploadInput struct {
|
||||
Prefix string // optional, e.g. network_id
|
||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
||||
Name string
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
// PrepareUploadResult represents the result of preparing an upload
|
||||
type PrepareUploadResult struct {
|
||||
ObjectID string
|
||||
UploadURL string
|
||||
UploadHeaders map[string]string
|
||||
}
|
||||
|
||||
// For server-side direct uploads (no presigned URL).
|
||||
type CreateFromReaderInput struct {
|
||||
Prefix string // optional, e.g. network_id
|
||||
Name string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Config holds configuration for the depot service
|
||||
type Config struct {
|
||||
GoogleServiceAccountEmail string
|
||||
BucketName string
|
||||
|
||||
@@ -4,11 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"cloud.google.com/go/storage"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -22,7 +20,6 @@ const (
|
||||
type Service interface {
|
||||
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, 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)
|
||||
GetDownloadURL(ctx context.Context, objectID string) (string, error)
|
||||
Delete(ctx context.Context, objectID string) error
|
||||
@@ -50,7 +47,7 @@ func NewService(pool *pgxpool.Pool, storageClient *storage.Client, config Config
|
||||
}
|
||||
|
||||
if config.GoogleServiceAccountEmail == "" {
|
||||
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.")
|
||||
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.")
|
||||
panic("GoogleServiceAccountEmail is required for signed URL generation")
|
||||
}
|
||||
|
||||
@@ -75,10 +72,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
||||
}
|
||||
|
||||
// {prefix}/{uuid}/{filename}
|
||||
// Generate object key: {prefix}/{uuid}/{filename}
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||
|
||||
// Row is written first with contains_content=false; ConfirmUpload flips it.
|
||||
// Create the database record (contains_content = false initially)
|
||||
obj := &Object{
|
||||
Name: input.Name,
|
||||
ContentType: input.ContentType,
|
||||
@@ -93,7 +90,8 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Content-Length is part of the signature, so the client must send it verbatim.
|
||||
// Generate a signed URL for uploading with Content-Length enforcement
|
||||
// The Headers field specifies headers that MUST be included in the upload request
|
||||
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
|
||||
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
|
||||
GoogleAccessID: s.googleServiceAccountEmail,
|
||||
@@ -103,10 +101,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
||||
Headers: []string{contentLengthHeader},
|
||||
})
|
||||
if err != nil {
|
||||
flog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||
// Roll back the placeholder row.
|
||||
slog.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
|
||||
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
||||
flog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
||||
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,19 +128,22 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrObjectNotExist) {
|
||||
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
|
||||
}
|
||||
flog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
slog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify content length matches what was declared
|
||||
if attrs.Size != obj.ContentLength {
|
||||
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 errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
@@ -150,58 +151,10 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch and return the updated object
|
||||
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) {
|
||||
obj, err := s.repo.getByID(ctx, objectID)
|
||||
if err != nil {
|
||||
@@ -222,13 +175,14 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate a signed URL for downloading
|
||||
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
||||
GoogleAccessID: s.googleServiceAccountEmail,
|
||||
Method: "GET",
|
||||
Expires: time.Now().Add(s.downloadURLExpiry),
|
||||
})
|
||||
if err != nil {
|
||||
flog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
slog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -244,13 +198,14 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GCS first so we don't strand an object after the row vanishes; missing object is fine.
|
||||
// Delete from GCS (ignore not found errors)
|
||||
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
|
||||
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
|
||||
flog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return gcsErr
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if err := s.repo.delete(ctx, objectID); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1480
-575
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// 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,10 +3,8 @@ package human
|
||||
import "time"
|
||||
|
||||
type Human struct {
|
||||
ID string
|
||||
Email string
|
||||
EmailPrefix string
|
||||
EmailNotificationsEnabled bool
|
||||
LastEmailNotificationSentAt *time.Time
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Email string
|
||||
EmailPrefix string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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] + "…"
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -34,9 +33,6 @@ type repository interface {
|
||||
getByID(ctx context.Context, id string) (*Human, error)
|
||||
create(ctx context.Context, email string) (*Human, 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
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
@@ -50,9 +46,9 @@ func newRepository(pool *pgxpool.Pool) repository {
|
||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
||||
`SELECT id, email, created_at FROM humans WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -66,9 +62,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
||||
`SELECT id, email, created_at FROM humans WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
@@ -88,9 +84,9 @@ func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, erro
|
||||
var h Human
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO humans (id, email) VALUES ($1, $2)
|
||||
RETURNING id, email, email_notifications_enabled, last_email_notification_sent_at, created_at`,
|
||||
RETURNING id, email, created_at`,
|
||||
id.String(), email,
|
||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -110,52 +106,3 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
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 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); 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
|
||||
}
|
||||
|
||||
@@ -3,25 +3,17 @@ package human
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||
|
||||
var ErrNotFound = errors.New("human not found")
|
||||
|
||||
type Service interface {
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
@@ -60,31 +52,3 @@ func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, 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
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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,51 +2,29 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"github.com/flowy-live/llink/internal/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
emailContextKey contextKey = "email"
|
||||
humanIdContextKey contextKey = "humanId"
|
||||
isAdminContextKey contextKey = "isAdmin"
|
||||
)
|
||||
const emailContextKey contextKey = "email"
|
||||
|
||||
// WithEmail adds the email to the context
|
||||
func WithEmail(ctx context.Context, email string) context.Context {
|
||||
return context.WithValue(ctx, emailContextKey, email)
|
||||
}
|
||||
|
||||
// EmailFromContext extracts the email from the context
|
||||
func EmailFromContext(ctx context.Context) (string, bool) {
|
||||
email, ok := ctx.Value(emailContextKey).(string)
|
||||
return email, ok
|
||||
}
|
||||
|
||||
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.
|
||||
// Auth returns a middleware that validates the session token and adds the email to the context
|
||||
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -56,24 +34,24 @@ func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := authSvc.GetSession(r.Context(), token)
|
||||
email, err := authSvc.GetSession(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-extend session
|
||||
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
|
||||
flog.Warn("failed to extend session", "error", err)
|
||||
slog.Warn("failed to extend session", "error", err)
|
||||
}
|
||||
|
||||
ctx := WithEmail(r.Context(), session.Email)
|
||||
ctx = WithHumanId(ctx, session.HumanId)
|
||||
ctx = WithIsAdmin(ctx, authSvc.IsSystemAdmin(r.Context(), session.Email))
|
||||
ctx := WithEmail(r.Context(), email)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractBearerToken extracts the token from the Authorization header
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,16 +3,11 @@ package network
|
||||
import "time"
|
||||
|
||||
type Network struct {
|
||||
ID string
|
||||
Name string
|
||||
AdminHumanId string
|
||||
MemberHumanIds []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
NetworkID string
|
||||
NetworkName string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Name string
|
||||
AdminEmail string
|
||||
MemberEmails []string
|
||||
OpenStreamCapacity int
|
||||
OpenStreamCount int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+131
-226
@@ -5,19 +5,10 @@ import (
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"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")
|
||||
|
||||
type networkIDPrefix struct{}
|
||||
@@ -32,35 +23,21 @@ func newNetworkID() (networkID, error) {
|
||||
return typeid.New[networkID]()
|
||||
}
|
||||
|
||||
var errCapacityExceeded = errors.New("capacity exceeded")
|
||||
|
||||
type repository interface {
|
||||
create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
||||
create(ctx context.Context, name, adminEmail string) (*Network, error)
|
||||
getByID(ctx context.Context, id string) (*Network, error)
|
||||
updateName(ctx context.Context, id, name string) error
|
||||
delete(ctx context.Context, id string) error
|
||||
addMember(ctx context.Context, db dbtx, networkID, humanId string) error
|
||||
removeMember(ctx context.Context, db dbtx, networkID, humanId string) error
|
||||
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
||||
countSeats(ctx context.Context, db dbtx, networkID string) (int, error)
|
||||
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||
isMember(ctx context.Context, networkID, humanId string) (bool, 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)
|
||||
|
||||
// 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)
|
||||
addMember(ctx context.Context, networkID, email string) error
|
||||
removeMember(ctx context.Context, networkID, email string) error
|
||||
getMemberEmails(ctx context.Context, networkID string) ([]string, error)
|
||||
getNetworksForEmail(ctx context.Context, email string) ([]*Network, error)
|
||||
isMember(ctx context.Context, networkID, email string) (bool, error)
|
||||
setOpenStreamCapacity(ctx context.Context, id string, capacity int) error
|
||||
incrementOpenStreamCount(ctx context.Context, id string) error
|
||||
decrementOpenStreamCount(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
@@ -71,44 +48,43 @@ func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
||||
func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*Network, error) {
|
||||
id, err := newNetworkID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var n Network
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
|
||||
RETURNING `+networkColumns,
|
||||
id.String(), name, adminHumanId,
|
||||
)
|
||||
if err := scanNetwork(row, &n); err != nil {
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3)
|
||||
RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`,
|
||||
id.String(), name, adminEmail,
|
||||
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.MemberHumanIds = []string{}
|
||||
n.MemberEmails = []string{}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
||||
var n Network
|
||||
row := r.pool.QueryRow(ctx,
|
||||
`SELECT `+networkColumns+` FROM networks WHERE id = $1`,
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, name, admin_email, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err := scanNetwork(row, &n); err != nil {
|
||||
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memberIds, err := r.getMemberHumanIds(ctx, id)
|
||||
n.MemberEmails, err = r.getMemberEmails(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.MemberHumanIds = memberIds
|
||||
|
||||
return &n, nil
|
||||
}
|
||||
@@ -138,164 +114,50 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) addMember(ctx context.Context, db dbtx, networkID, humanId string) error {
|
||||
_, err := db.Exec(ctx,
|
||||
`INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
|
||||
ON CONFLICT (network_id, human_id) DO NOTHING`,
|
||||
networkID, humanId,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) removeMember(ctx context.Context, db dbtx, networkID, humanId string) error {
|
||||
_, err := db.Exec(ctx,
|
||||
`DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
|
||||
networkID, humanId,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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,
|
||||
`SELECT human_id FROM network_members WHERE network_id = $1`,
|
||||
networkID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var humanIds []string
|
||||
for rows.Next() {
|
||||
var humanId string
|
||||
if err := rows.Scan(&humanId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
humanIds = append(humanIds, humanId)
|
||||
}
|
||||
return humanIds, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT `+networkColumns+`
|
||||
FROM networks
|
||||
WHERE admin_human_id = $1
|
||||
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = id AND nm.human_id = $1)`,
|
||||
humanId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var networks []*Network
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string) (bool, error) {
|
||||
var isMember bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM networks n
|
||||
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2
|
||||
WHERE n.id = $1 AND (n.admin_human_id = $2 OR nm.human_id IS NOT NULL)
|
||||
)
|
||||
`, networkID, humanId).Scan(&isMember)
|
||||
return isMember, err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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 out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT `+networkColumns+` FROM networks`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var networks []*Network
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error {
|
||||
func (r *repositoryImpl) addMember(ctx context.Context, networkID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO network_invitations (network_id, email) VALUES ($1, $2)
|
||||
`INSERT INTO network_members (network_id, email) VALUES ($1, $2)
|
||||
ON CONFLICT (network_id, email) DO NOTHING`,
|
||||
networkID, email,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) {
|
||||
func (r *repositoryImpl) removeMember(ctx context.Context, networkID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM network_members WHERE network_id = $1 AND email = $2`,
|
||||
networkID, email,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) ([]string, 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`,
|
||||
`SELECT email FROM network_members WHERE network_id = $1`,
|
||||
networkID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var emails []string
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
return emails, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) {
|
||||
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
|
||||
FROM networks n
|
||||
WHERE n.admin_email = $1
|
||||
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`,
|
||||
email,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -303,45 +165,88 @@ func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var invitations []*Invitation
|
||||
var networks []*Network
|
||||
for rows.Next() {
|
||||
var inv Invitation
|
||||
if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil {
|
||||
var n Network
|
||||
if err := rows.Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invitations = append(invitations, &inv)
|
||||
networks = append(networks, &n)
|
||||
}
|
||||
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 {
|
||||
if err := rows.Err(); 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 {
|
||||
for _, n := range networks {
|
||||
n.MemberEmails, err = r.getMemberEmails(ctx, n.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invitations = append(invitations, &inv)
|
||||
}
|
||||
return invitations, rows.Err()
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
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,
|
||||
func (r *repositoryImpl) isMember(ctx context.Context, networkID, email string) (bool, error) {
|
||||
var isMember bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM networks n
|
||||
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2
|
||||
WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL)
|
||||
)
|
||||
`, networkID, email).Scan(&isMember)
|
||||
return isMember, err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`,
|
||||
capacity, id,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET open_stream_count = open_stream_count + 1
|
||||
WHERE id = $1 AND open_stream_count < open_stream_capacity`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
// Check if network exists vs capacity exceeded
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errNotFound
|
||||
}
|
||||
return errCapacityExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+72
-320
@@ -4,114 +4,76 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("network not found")
|
||||
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 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 {
|
||||
Reader
|
||||
|
||||
// Create adds adminHumanId as the first member. Returns ErrInvalidName if name is empty.
|
||||
Create(ctx context.Context, name, adminHumanId string) (*Network, error)
|
||||
// Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty.
|
||||
Create(ctx context.Context, name, adminEmail string) (*Network, error)
|
||||
// GetByID returns ErrNotFound if network doesn't exist.
|
||||
GetByID(ctx context.Context, id string) (*Network, error)
|
||||
// SetName returns ErrNotFound or ErrInvalidName.
|
||||
SetName(ctx context.Context, id, name string) error
|
||||
// AddMembers inserts members and syncs the new seat count to billing
|
||||
// atomically; a Stripe failure rolls the insert back.
|
||||
// Returns ErrInvalidHumanId if any humanId is empty.
|
||||
AddMembers(ctx context.Context, networkID string, humanIds []string) error
|
||||
// RemoveMember returns ErrInvalidHumanId if humanId is empty.
|
||||
RemoveMember(ctx context.Context, networkID, humanId string) error
|
||||
AddMembers(ctx context.Context, networkID string, emails []string) error
|
||||
RemoveMember(ctx context.Context, networkID, email string) error
|
||||
ListForEmail(ctx context.Context, email string) ([]*Network, error)
|
||||
IsMember(ctx context.Context, networkID, email string) (bool, error)
|
||||
|
||||
// Invitations (email-based, for users who haven't registered yet)
|
||||
|
||||
// InviteByEmail returns ErrNotFound if the network doesn't exist
|
||||
// or ErrInvalidEmail if any email fails normalization.
|
||||
InviteByEmail(ctx context.Context, networkID string, emails []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
|
||||
// SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound.
|
||||
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error
|
||||
// IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded.
|
||||
IncrementOpenStreamCount(ctx context.Context, networkID string) error
|
||||
// DecrementOpenStreamCount returns ErrNotFound.
|
||||
DecrementOpenStreamCount(ctx context.Context, networkID string) error
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
*readerImpl
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
billingSvc billing.Service
|
||||
pub livestore.MembershipPublisher
|
||||
humanLookup humanLookup
|
||||
repo repository
|
||||
}
|
||||
|
||||
func NewService(
|
||||
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 NewService(pool *pgxpool.Pool) Service {
|
||||
return &serviceImpl{repo: newRepository(pool)}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
||||
func (s *serviceImpl) Create(ctx context.Context, name, adminEmail string) (*Network, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
|
||||
network, err := s.repo.create(ctx, name, adminHumanId)
|
||||
adminEmail, err := utils.NormalizeEmail(adminEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
|
||||
network, err := s.repo.create(ctx, name, adminEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, 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.",
|
||||
},
|
||||
},
|
||||
})
|
||||
err = s.AddMembers(ctx, network.ID, []string{adminEmail})
|
||||
if err != nil {
|
||||
flog.Warn("unable to send admin update email", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
@@ -125,279 +87,69 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error {
|
||||
for _, email := range emails {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q: %w: %w", email, ErrInvalidEmail, err)
|
||||
return fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
if err := s.repo.createInvitation(ctx, networkID, normalized); err != nil {
|
||||
if err := s.repo.addMember(ctx, networkID, normalized); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, humanId string) error {
|
||||
network, err := s.repo.getByID(ctx, networkID)
|
||||
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
return s.repo.removeMember(ctx, networkID, email)
|
||||
}
|
||||
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
func (s *serviceImpl) ListForEmail(ctx context.Context, email string) ([]*Network, error) {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||
return nil, fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
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
|
||||
return s.repo.getNetworksForEmail(ctx, email)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
func (s *serviceImpl) IsMember(ctx context.Context, networkID, email string) (bool, error) {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidEmail, err)
|
||||
return false, err
|
||||
}
|
||||
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
|
||||
return s.repo.isMember(ctx, networkID, email)
|
||||
}
|
||||
|
||||
const (
|
||||
emailWebAppURL = "https://llink.flowy.live"
|
||||
emailDesktopURL = "llink://"
|
||||
emailDownloadURL = "https://flowylabs.ai/llink/download"
|
||||
)
|
||||
|
||||
// emailDesktopFooter is the shared secondary line offering the desktop app.
|
||||
// The web app is always the primary CTA (no install required), so desktop is
|
||||
// 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) SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error {
|
||||
if capacity < 0 {
|
||||
capacity = 0
|
||||
}
|
||||
err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func buildInvitationHTML(networkName string) string {
|
||||
safeName := html.EscapeString(networkName)
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<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;">
|
||||
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) IncrementOpenStreamCount(ctx context.Context, networkID string) error {
|
||||
err := s.repo.incrementOpenStreamCount(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if errors.Is(err, errCapacityExceeded) {
|
||||
return ErrCapacityExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildNewMemberHTML is an email template to notify other members that a new member has joined
|
||||
func buildNewMemberHTML(networkName, newMemberEmailPrefix string) string {
|
||||
safeName := html.EscapeString(networkName)
|
||||
safeEmail := html.EscapeString(newMemberEmailPrefix)
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<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())
|
||||
func (s *serviceImpl) DecrementOpenStreamCount(ctx context.Context, networkID string) error {
|
||||
err := s.repo.decrementOpenStreamCount(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,17 +5,10 @@ import (
|
||||
"os"
|
||||
"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/testhelper"
|
||||
"github.com/flowy-live/llink/internal/testhelper/mocks/aero"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
var dbPool *pgxpool.Pool
|
||||
@@ -28,47 +21,16 @@ func TestMain(m *testing.M) {
|
||||
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: "[email protected]",
|
||||
EmailPrefix: "test",
|
||||
EmailNotificationsEnabled: false,
|
||||
}, nil).AnyTimes()
|
||||
|
||||
return network.NewService(dbPool, mockAero, mockBilling, mockPub, mockHuman)
|
||||
}
|
||||
|
||||
func TestNetworkService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestService(t)
|
||||
|
||||
adminHumanId := "human_admin123"
|
||||
member1HumanId := "human_member1abc"
|
||||
member2HumanId := "human_member2def"
|
||||
strangerHumanId := "human_stranger789"
|
||||
svc := network.NewService(dbPool)
|
||||
|
||||
// Test Create
|
||||
createdNetwork, err := svc.Create(ctx, "Test Network", adminHumanId)
|
||||
createdNetwork, err := svc.Create(ctx, "Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, createdNetwork.ID)
|
||||
assert.Equal(t, "Test Network", createdNetwork.Name)
|
||||
assert.Equal(t, adminHumanId, createdNetwork.AdminHumanId)
|
||||
assert.Equal(t, "[email protected]", createdNetwork.AdminEmail)
|
||||
assert.NotZero(t, createdNetwork.CreatedAt)
|
||||
|
||||
// Test GetByID
|
||||
@@ -76,7 +38,7 @@ func TestNetworkService(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
|
||||
assert.Equal(t, createdNetwork.Name, foundNetwork.Name)
|
||||
assert.Equal(t, createdNetwork.AdminHumanId, foundNetwork.AdminHumanId)
|
||||
assert.Equal(t, createdNetwork.AdminEmail, foundNetwork.AdminEmail)
|
||||
|
||||
// Test GetByID with non-existent id
|
||||
_, err = svc.GetByID(ctx, "network_nonexistent")
|
||||
@@ -98,45 +60,45 @@ func TestNetworkService(t *testing.T) {
|
||||
assert.ErrorIs(t, err, network.ErrNotFound)
|
||||
|
||||
// Test AddMembers
|
||||
err = svc.AddMembers(ctx, createdNetwork.ID, []string{member1HumanId, member2HumanId})
|
||||
err = svc.AddMembers(ctx, createdNetwork.ID, []string{"[email protected]", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test ListForHuman - should find network for admin
|
||||
networks, err := svc.ListForHuman(ctx, adminHumanId)
|
||||
// Test ListForEmail - should find network for admin
|
||||
networks, err := svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 1)
|
||||
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
||||
|
||||
// Test ListForHuman - should find network for member
|
||||
networks, err = svc.ListForHuman(ctx, member1HumanId)
|
||||
// Test ListForEmail - should find network for member
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 1)
|
||||
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
||||
|
||||
// Test ListForHuman - should return empty for non-member
|
||||
networks, err = svc.ListForHuman(ctx, strangerHumanId)
|
||||
// Test ListForEmail - should return empty for non-member
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 0)
|
||||
|
||||
// Test RemoveMember
|
||||
err = svc.RemoveMember(ctx, createdNetwork.ID, member1HumanId)
|
||||
err = svc.RemoveMember(ctx, createdNetwork.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify member was removed
|
||||
networks, err = svc.ListForHuman(ctx, member1HumanId)
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 0)
|
||||
|
||||
// member2 should still have access
|
||||
networks, err = svc.ListForHuman(ctx, member2HumanId)
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 1)
|
||||
|
||||
// Create another network and verify ListForHuman returns multiple
|
||||
network2, err := svc.Create(ctx, "Second Network", member2HumanId)
|
||||
// Create another network and verify ListForEmail returns multiple
|
||||
network2, err := svc.Create(ctx, "Second Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
networks, err = svc.ListForHuman(ctx, member2HumanId)
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 2)
|
||||
|
||||
@@ -145,65 +107,3 @@ func TestNetworkService(t *testing.T) {
|
||||
assert.Contains(t, networkIDs, createdNetwork.ID)
|
||||
assert.Contains(t, networkIDs, network2.ID)
|
||||
}
|
||||
|
||||
func TestNetworkInvitations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newTestService(t)
|
||||
|
||||
adminHumanId := "human_invtest_admin"
|
||||
inviteeEmail := "[email protected]"
|
||||
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 := "[email protected]"
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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,5 +16,4 @@ var (
|
||||
ErrMembersRequired = errors.New("custom visibility requires at least one member")
|
||||
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
|
||||
ErrNotAContainer = errors.New("only streams can have members")
|
||||
ErrInvalidMember = errors.New("member is not in the network")
|
||||
)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// 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,6 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParticleType represents the type of particle
|
||||
type ParticleType string
|
||||
|
||||
const (
|
||||
@@ -19,6 +20,7 @@ const (
|
||||
// TypeThink ParticleType = "think"
|
||||
)
|
||||
|
||||
// VisibilityMode represents how access to a particle is determined
|
||||
type VisibilityMode string
|
||||
|
||||
const (
|
||||
@@ -30,6 +32,7 @@ const (
|
||||
var ErrInvalidParticleType = errors.New("invalid particle type")
|
||||
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
||||
|
||||
// ParseParticleType parses a string into a ParticleType
|
||||
func ParseParticleType(s string) (ParticleType, error) {
|
||||
switch s {
|
||||
case string(TypeStream):
|
||||
@@ -51,6 +54,7 @@ func ParseParticleType(s string) (ParticleType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseVisibilityMode parses a string into a VisibilityMode
|
||||
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||
switch s {
|
||||
case "", string(VisibilityNetworkAll):
|
||||
@@ -64,6 +68,7 @@ func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stream status values
|
||||
type StreamStatus string
|
||||
|
||||
const (
|
||||
@@ -71,6 +76,7 @@ const (
|
||||
StreamStatusClosed StreamStatus = "closed"
|
||||
)
|
||||
|
||||
// Particle represents a content particle in the system
|
||||
type Particle struct {
|
||||
ID string
|
||||
Type ParticleType
|
||||
@@ -83,6 +89,7 @@ type Particle struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateInput represents the input for creating a new particle
|
||||
type CreateInput struct {
|
||||
Type ParticleType
|
||||
NetworkID string
|
||||
@@ -92,15 +99,18 @@ type CreateInput struct {
|
||||
Visibility VisibilityMode
|
||||
}
|
||||
|
||||
// ListFilter represents filtering options for listing particles
|
||||
type ListFilter struct {
|
||||
Types []ParticleType
|
||||
}
|
||||
|
||||
// Cursor represents a pagination cursor for bidirectional pagination
|
||||
type Cursor struct {
|
||||
Position string // particle ID or timestamp
|
||||
Direction string // "before" or "after"
|
||||
}
|
||||
|
||||
// ParticleList represents a paginated list of particles
|
||||
type ParticleList struct {
|
||||
Particles []*Particle
|
||||
HasMore bool
|
||||
@@ -108,48 +118,57 @@ type ParticleList struct {
|
||||
PrevCursor *Cursor
|
||||
}
|
||||
|
||||
// StreamData represents the data stored for stream particles
|
||||
type StreamData struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "open" or "closed"
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// FolderData represents the data stored for folder particles
|
||||
type FolderData struct {
|
||||
Name string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
// MediaData represents the data stored for media particles
|
||||
type MediaData struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
ObjectID string `json:"object_id"` // reference to storage object
|
||||
MimeType string `json:"mime_type"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
// Caption *string `json:"caption"`
|
||||
}
|
||||
|
||||
// FileData represents the data stored for file particles
|
||||
type FileData struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
ObjectID string `json:"object_id"` // reference to storage object
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"` // bytes
|
||||
Size int64 `json:"size"` // in bytes
|
||||
}
|
||||
|
||||
// TextData represents the data stored for text particles
|
||||
type TextData struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// QuestData represents the data stored for quest particles
|
||||
type QuestData struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Done bool `json:"done"`
|
||||
Status *string `json:"status"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
||||
DueDate *string `json:"due_date,omitempty"` // ISO date
|
||||
DueDate *string `json:"due_date,omitempty"` // ISO date string
|
||||
}
|
||||
|
||||
// PaperData represents the data stored for paper particles
|
||||
type PaperData struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // markdown
|
||||
}
|
||||
|
||||
// AckInfo represents an acknowledgment record
|
||||
type AckInfo struct {
|
||||
Email string
|
||||
AckedAt time.Time
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||
|
||||
// getAncestorChain returns the particle followed by its ancestors, in order.
|
||||
// getAncestorChain returns the particle and all its ancestors (for access checks)
|
||||
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
||||
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
||||
|
||||
|
||||
+146
-84
@@ -4,75 +4,75 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const defaultPageSize = 50
|
||||
|
||||
// Deprecated: particle data now lives in Firestore. The Postgres-backed
|
||||
// service is retained only for legacy paths.
|
||||
type Service interface {
|
||||
// Create returns ErrInvalidType, ErrInvalidData, ErrMembersRequired,
|
||||
// ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded. Network
|
||||
// membership is verified by the handler.
|
||||
// Create creates a new particle. Caller must be a network member (verified by handler).
|
||||
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
|
||||
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
|
||||
// GetByID returns ErrNotFound or ErrAccessDenied.
|
||||
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
|
||||
// Update returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
||||
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
||||
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
|
||||
// Delete returns ErrNotFound or ErrAccessDenied.
|
||||
Delete(ctx context.Context, id, requesterEmail string) error
|
||||
|
||||
// List uses parentID=nil for root particles. Returns ErrNotFound or
|
||||
// ErrAccessDenied when parentID is given but inaccessible.
|
||||
// List returns particles in a network. Use parentID=nil for root particles.
|
||||
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
|
||||
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
||||
|
||||
// OpenStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream,
|
||||
// ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
||||
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
||||
OpenStream(ctx context.Context, id, requesterEmail string) error
|
||||
// CloseStream returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or
|
||||
// ErrStreamAlreadyClosed.
|
||||
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
|
||||
CloseStream(ctx context.Context, id, requesterEmail string) error
|
||||
|
||||
// SetVisibility returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
||||
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
||||
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
||||
// AddMembers / RemoveMembers operate on custom-visibility streams only.
|
||||
// Both return ErrNotFound or ErrAccessDenied.
|
||||
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
||||
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
|
||||
|
||||
// Seen tracking is private per human; Ack is public and permanent.
|
||||
// Seen tracking (private)
|
||||
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
||||
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
||||
|
||||
// Ack tracking (public, permanent)
|
||||
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)
|
||||
|
||||
// Bulk lookups for batch hydration.
|
||||
// Bulk lookups for handler enrichment
|
||||
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
|
||||
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
||||
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
networkMembershipChecker NetworkMembershipChecker
|
||||
repo repository
|
||||
networkSvc network.Service
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, networkReader NetworkMembershipChecker) Service {
|
||||
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
|
||||
return &serviceImpl{
|
||||
repo: newRepository(pool),
|
||||
networkMembershipChecker: networkReader,
|
||||
repo: newRepository(pool),
|
||||
networkSvc: networkSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the ancestor chain when visibility is inherited, stopping at the
|
||||
// first network_all or custom node. Assumes network membership is already
|
||||
// verified by the handler.
|
||||
// checkAccess verifies that the email has access to the particle based on visibility.
|
||||
// Assumes the caller is already verified as a network member (handler responsibility).
|
||||
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
|
||||
// network_all or custom node.
|
||||
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
|
||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||
if err != nil {
|
||||
@@ -83,12 +83,13 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
||||
return false, errNotFound
|
||||
}
|
||||
|
||||
// Build lookup map by ID
|
||||
byID := make(map[string]*Particle, len(ancestors))
|
||||
for _, p := range ancestors {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
|
||||
// ancestors[0] is the target; walk up only on inherited.
|
||||
// Start from the target particle (first in chain) and walk up on inherited
|
||||
current := ancestors[0]
|
||||
for {
|
||||
switch current.Visibility {
|
||||
@@ -98,7 +99,7 @@ func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string)
|
||||
return s.repo.isMemberOf(ctx, current.ID, email)
|
||||
case VisibilityInherited:
|
||||
if current.ParentID == nil {
|
||||
// inherited-at-root is invalid; deny.
|
||||
// inherited at root is invalid state, deny access
|
||||
return false, nil
|
||||
}
|
||||
parent, ok := byID[*current.ParentID]
|
||||
@@ -118,24 +119,30 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate particle type
|
||||
if !isValidParticleType(input.Type) {
|
||||
return nil, ErrInvalidType
|
||||
}
|
||||
|
||||
// Validate data matches type requirements
|
||||
if err := validateParticleData(input.Type, input.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// MVP visibility: children always inherit; roots cannot inherit and
|
||||
// default to network_all. Streams/folders are root-only.
|
||||
// MVP visibility rules:
|
||||
// - Child particles (have parent) → always inherited
|
||||
// - Root particles (no parent) → cannot be inherited, default network_all
|
||||
if input.ParentID != nil {
|
||||
// Children always inherit from parent
|
||||
input.Visibility = VisibilityInherited
|
||||
input.Members = nil
|
||||
input.Members = nil // no members on inherited particles
|
||||
|
||||
// Reject streams and folders as children (MVP: streams are root-level only)
|
||||
if input.Type == TypeStream || input.Type == TypeFolder {
|
||||
return nil, ErrInvalidParent
|
||||
}
|
||||
} else {
|
||||
// Root particles cannot be inherited
|
||||
if input.Visibility == VisibilityInherited {
|
||||
return nil, ErrInheritedAtRoot
|
||||
}
|
||||
@@ -144,7 +151,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
}
|
||||
}
|
||||
|
||||
var customMembers []string
|
||||
// Custom visibility requires at least one member and must be a stream
|
||||
if input.Visibility == VisibilityCustom {
|
||||
if input.Type != TypeStream {
|
||||
return nil, ErrNotAContainer
|
||||
@@ -152,33 +159,10 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
if len(input.Members) == 0 {
|
||||
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 the handler; only particle visibility is checked here.
|
||||
// Network membership is verified by handler - we only check particle visibility
|
||||
// If parent specified, check parent access (visibility-based)
|
||||
if input.ParentID != nil {
|
||||
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
|
||||
if err != nil {
|
||||
@@ -192,6 +176,7 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
}
|
||||
}
|
||||
|
||||
// Build the particle
|
||||
p := &Particle{
|
||||
Type: input.Type,
|
||||
NetworkID: input.NetworkID,
|
||||
@@ -205,22 +190,53 @@ func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEm
|
||||
p.Data = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
// New streams default to open.
|
||||
// For streams, set initial status to open and check capacity
|
||||
if input.Type == TypeStream {
|
||||
// Set status to open in the data JSON
|
||||
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
if len(customMembers) > 0 {
|
||||
if err := s.repo.addMembers(ctx, created.ID, customMembers); err != nil {
|
||||
// Add members if custom visibility (only streams for MVP)
|
||||
if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -234,6 +250,7 @@ func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -261,6 +278,7 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -272,6 +290,7 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to validate data against its type
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -280,6 +299,7 @@ func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessag
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate data matches type requirements
|
||||
if err := validateParticleData(p.Type, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -301,6 +321,7 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -312,7 +333,8 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) err
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
_, err = s.repo.getByID(ctx, id)
|
||||
// Get the particle to check if it's an open stream
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
@@ -320,6 +342,13 @@ func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) 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)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
@@ -333,7 +362,8 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Network membership is verified by the handler; only particle visibility is checked here.
|
||||
// Network membership is verified by handler - we only check particle visibility
|
||||
// If parentID specified, check access to parent (visibility-based)
|
||||
if parentID != nil {
|
||||
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
|
||||
if err != nil {
|
||||
@@ -347,7 +377,8 @@ func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *stri
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch limit+1 to detect a next page; visibility filtering lives in the query.
|
||||
// Fetch one extra to determine if there are more
|
||||
// Access filtering is done in the query itself (network_all OR user is member)
|
||||
if limit == 0 {
|
||||
limit = defaultPageSize
|
||||
}
|
||||
@@ -394,6 +425,7 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -405,6 +437,7 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -421,13 +454,30 @@ func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string)
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
err = s.repo.update(ctx, id, newData, time.Now())
|
||||
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) {
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -443,6 +493,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -454,6 +505,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -470,6 +522,7 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return ErrStreamAlreadyClosed
|
||||
}
|
||||
|
||||
// Update stream status in data
|
||||
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -483,7 +536,8 @@ func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
// Decrement capacity
|
||||
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
|
||||
@@ -492,6 +546,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -503,6 +558,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check constraints
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -511,11 +567,12 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return err
|
||||
}
|
||||
|
||||
// Root particles cannot be inherited
|
||||
if mode == VisibilityInherited && p.ParentID == nil {
|
||||
return ErrInheritedAtRoot
|
||||
}
|
||||
|
||||
// Expanding to network_all is rejected if any ancestor restricts to custom.
|
||||
// If expanding to network_all, check that parent's effective visibility allows it
|
||||
if mode == VisibilityNetworkAll && p.ParentID != nil {
|
||||
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
|
||||
if err != nil {
|
||||
@@ -533,7 +590,7 @@ func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode Visibil
|
||||
return err
|
||||
}
|
||||
|
||||
// Walks up the inherited chain to the concrete visibility node.
|
||||
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
|
||||
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
|
||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||
if err != nil {
|
||||
@@ -570,6 +627,7 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -581,6 +639,7 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check type and parent access
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -589,30 +648,25 @@ func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string
|
||||
return err
|
||||
}
|
||||
|
||||
// Only streams can have members
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAContainer
|
||||
}
|
||||
|
||||
// Validate every email upfront so any failure aborts before DB writes.
|
||||
// Validate and normalize emails, check network membership
|
||||
normalizedEmails := make([]string, 0, len(emails))
|
||||
seen := make(map[string]bool, len(emails))
|
||||
for _, email := range emails {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if seen[normalized] {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = true
|
||||
|
||||
isMember, err := s.networkMembershipChecker.IsMember(ctx, p.NetworkID, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isMember {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidMember, normalized)
|
||||
// Root stream - check network membership
|
||||
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
|
||||
if err != nil || !isMember {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedEmails = append(normalizedEmails, normalized)
|
||||
}
|
||||
|
||||
@@ -629,6 +683,7 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -640,6 +695,7 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check type
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -648,6 +704,7 @@ func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []str
|
||||
return err
|
||||
}
|
||||
|
||||
// Only streams can have members
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAContainer
|
||||
}
|
||||
@@ -674,6 +731,7 @@ func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -694,17 +752,17 @@ func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requester
|
||||
return err
|
||||
}
|
||||
|
||||
// Silently skip particles that are missing or inaccessible.
|
||||
// Check access for each particle and mark seen
|
||||
for _, id := range ids {
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
continue
|
||||
continue // Skip non-existent particles
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
continue
|
||||
continue // Skip inaccessible particles
|
||||
}
|
||||
|
||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||
@@ -721,6 +779,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
@@ -732,7 +791,7 @@ func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Ack implies seen.
|
||||
// Ack also marks as seen
|
||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -775,6 +834,7 @@ func isValidParticleType(t ParticleType) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// getStreamStatus extracts the status from a stream particle's data
|
||||
func getStreamStatus(data json.RawMessage) string {
|
||||
var d StreamData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
@@ -783,6 +843,7 @@ func getStreamStatus(data json.RawMessage) string {
|
||||
return d.Status
|
||||
}
|
||||
|
||||
// setStreamStatus updates the status in a stream particle's data
|
||||
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
|
||||
var d StreamData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
@@ -792,9 +853,10 @@ func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, erro
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
// Returns ErrInvalidData if data is not valid JSON or is missing required
|
||||
// fields for pType. Empty/null data is allowed and treated as {}.
|
||||
// validateParticleData validates that the data field contains valid JSON
|
||||
// and has required fields for the given particle type.
|
||||
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) == "{}" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,16 +3,14 @@ package particle_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"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/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
var dbPool *pgxpool.Pool
|
||||
@@ -33,46 +31,27 @@ func getStreamStatus(data json.RawMessage) string {
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network first
|
||||
net, err := networkSvc.Create(ctx, "Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
networkID := "net_123"
|
||||
// Test Create stream particle
|
||||
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Data: data,
|
||||
}
|
||||
created, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
assert.Equal(t, particle.TypeStream, created.Type)
|
||||
assert.Equal(t, networkID, created.NetworkID)
|
||||
assert.Equal(t, net.ID, created.NetworkID)
|
||||
assert.Nil(t, created.ParentID)
|
||||
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
|
||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
|
||||
@@ -91,16 +70,71 @@ func TestParticleService_CreateAndGet(t *testing.T) {
|
||||
// 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", "[email protected]")
|
||||
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, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create second stream - should succeed
|
||||
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
|
||||
stream2, err := svc.Create(ctx, input, "[email protected]")
|
||||
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, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
|
||||
|
||||
// Close a stream
|
||||
err = svc.CloseStream(ctx, stream1.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Now we can create another stream
|
||||
stream3, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, stream3.ID)
|
||||
|
||||
// Verify stream2 is still open
|
||||
found, err := svc.GetByID(ctx, stream2.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
|
||||
|
||||
// Verify stream1 is closed
|
||||
found, err = svc.GetByID(ctx, stream1.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
|
||||
}
|
||||
|
||||
func TestParticleService_NestedParticles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Nested Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a parent stream
|
||||
streamInput := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
|
||||
}
|
||||
stream, err := svc.Create(ctx, streamInput, "[email protected]")
|
||||
@@ -109,7 +143,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
||||
// Create a text particle as child
|
||||
textInput := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
ParentID: &stream.ID,
|
||||
Data: json.RawMessage(`{"content":"Hello world"}`),
|
||||
}
|
||||
@@ -120,7 +154,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
||||
// Create a file as child of stream
|
||||
fileInput := particle.CreateInput{
|
||||
Type: particle.TypeFile,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
ParentID: &stream.ID,
|
||||
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
|
||||
}
|
||||
@@ -129,26 +163,27 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
||||
assert.Equal(t, stream.ID, *file.ParentID)
|
||||
|
||||
// List children of stream
|
||||
children, err := svc.List(ctx, networkID, &stream.ID, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
children, err := svc.List(ctx, net.ID, &stream.ID, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, children.Particles, 2)
|
||||
}
|
||||
|
||||
func TestParticleService_CustomVisibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, mockChecker := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network with a member
|
||||
net, err := networkSvc.Create(ctx, "Visibility Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// admin is the creator (exempt from the check); member is a network member.
|
||||
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||
"[email protected]": true,
|
||||
})
|
||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a stream with custom visibility including only admin and member
|
||||
streamInput := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]", "[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
||||
@@ -177,14 +212,17 @@ func TestParticleService_CustomVisibility(t *testing.T) {
|
||||
|
||||
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Update Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a text particle
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"content":"Original content"}`),
|
||||
}
|
||||
created, err := svc.Create(ctx, input, "[email protected]")
|
||||
@@ -212,15 +250,18 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||
|
||||
func TestParticleService_ListRootParticles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "List Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create multiple root particles
|
||||
for i := 0; i < 3; i++ {
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||
}
|
||||
_, err := svc.Create(ctx, input, "[email protected]")
|
||||
@@ -228,21 +269,24 @@ func TestParticleService_ListRootParticles(t *testing.T) {
|
||||
}
|
||||
|
||||
// List root particles (parentID = nil)
|
||||
list, err := svc.List(ctx, networkID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
list, err := svc.List(ctx, net.ID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(list.Particles), 3)
|
||||
}
|
||||
|
||||
func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Open Close Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a stream
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
|
||||
}
|
||||
stream, err := svc.Create(ctx, input, "[email protected]")
|
||||
@@ -280,14 +324,17 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||
|
||||
func TestParticleService_NotAStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _ := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Not Stream Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a text particle
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"content":"Hello"}`),
|
||||
}
|
||||
text, err := svc.Create(ctx, input, "[email protected]")
|
||||
@@ -306,19 +353,20 @@ func TestParticleService_NotAStream(t *testing.T) {
|
||||
|
||||
func TestParticleService_AccessInheritance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, mockChecker := newTestService(t)
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
networkID := "net_123"
|
||||
// Create a network with members
|
||||
net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// admin is the creator (exempt); member is a network member.
|
||||
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||
"[email protected]": true,
|
||||
})
|
||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"member@example.com", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a stream with custom visibility (admin and member only)
|
||||
streamInput := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]", "[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
||||
@@ -329,7 +377,7 @@ func TestParticleService_AccessInheritance(t *testing.T) {
|
||||
// Create a child text (network_all visibility)
|
||||
textInput := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: networkID,
|
||||
NetworkID: net.ID,
|
||||
ParentID: &stream.ID,
|
||||
Data: json.RawMessage(`{"content":"Child text"}`),
|
||||
}
|
||||
@@ -349,154 +397,3 @@ func TestParticleService_AccessInheritance(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
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{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Members Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectIsMember(mockChecker, networkID, map[string]bool{
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
})
|
||||
|
||||
err = svc.AddMembers(ctx, stream.ID,
|
||||
[]string{"[email protected]", "[email protected]"},
|
||||
"[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, members[stream.ID], "[email protected]")
|
||||
assert.Contains(t, members[stream.ID], "[email protected]")
|
||||
}
|
||||
|
||||
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{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
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{
|
||||
"[email protected]": true,
|
||||
"[email protected]": false,
|
||||
})
|
||||
|
||||
err = svc.AddMembers(ctx, stream.ID,
|
||||
[]string{"[email protected]", "[email protected]"},
|
||||
"[email protected]")
|
||||
assert.ErrorIs(t, err, particle.ErrInvalidMember)
|
||||
|
||||
members, err := svc.GetMembersMap(ctx, []string{stream.ID})
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, members[stream.ID], "[email protected]")
|
||||
assert.NotContains(t, members[stream.ID], "[email protected]")
|
||||
}
|
||||
|
||||
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{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
checkerErr := errors.New("checker unavailable")
|
||||
mockChecker.EXPECT().
|
||||
IsMember(gomock.Any(), networkID, "[email protected]").
|
||||
Return(false, checkerErr)
|
||||
|
||||
err = svc.AddMembers(ctx, stream.ID,
|
||||
[]string{"[email protected]"}, "[email protected]")
|
||||
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{
|
||||
"[email protected]": false,
|
||||
})
|
||||
|
||||
_, err := svc.Create(ctx, particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]", "[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Strict Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
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, "[email protected]").
|
||||
Return(false, checkerErr)
|
||||
|
||||
_, err := svc.Create(ctx, particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: networkID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||
}, "[email protected]")
|
||||
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"}`),
|
||||
}, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = svc.AddMembers(ctx, text.ID, []string{"[email protected]"}, "[email protected]")
|
||||
assert.ErrorIs(t, err, particle.ErrNotAContainer)
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,25 +3,23 @@ package internal
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func ConnectAndTestRedis(db int) *redis.Client {
|
||||
redisHost := utils.MustGetEnv("REDIS_HOST")
|
||||
if redisHost == "" {
|
||||
flog.Error("must provide REDIS_HOST")
|
||||
slog.Error("must provide REDIS_HOST")
|
||||
os.Exit(1)
|
||||
}
|
||||
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Password: "",
|
||||
Password: "", // no password set
|
||||
DB: db,
|
||||
})
|
||||
|
||||
@@ -34,7 +32,7 @@ func ConnectAndTestRedis(db int) *redis.Client {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
flog.Debug("redis test", "key", val)
|
||||
slog.Debug("redis test", "key", val)
|
||||
if val != "value" {
|
||||
panic("unexpected value")
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// 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
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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...)
|
||||
}
|
||||
@@ -3,11 +3,10 @@ package testhelper
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
@@ -40,20 +39,20 @@ func SetupTestDB() *pgxpool.Pool {
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
flog.Error("failed to start postgres container", "error", err)
|
||||
slog.Error("failed to start postgres container", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get connection URL
|
||||
host, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
flog.Error("failed to get container host", "error", err)
|
||||
slog.Error("failed to get container host", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
port, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
flog.Error("failed to get container port", "error", err)
|
||||
slog.Error("failed to get container port", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -63,20 +62,20 @@ func SetupTestDB() *pgxpool.Pool {
|
||||
// Run migrations
|
||||
m, err := migrate.New("file://../../migrations", connectionURL)
|
||||
if err != nil {
|
||||
flog.Error("failed to create migrate instance", "error", err)
|
||||
slog.Error("failed to create migrate instance", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
flog.Error("failed to run migrations", "error", err)
|
||||
slog.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create connection pool
|
||||
dbPool, err = pgxpool.New(ctx, connectionURL)
|
||||
if err != nil {
|
||||
flog.Error("failed to create connection pool", "error", err)
|
||||
slog.Error("failed to create connection pool", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -90,7 +89,7 @@ func TeardownTestDB() {
|
||||
}
|
||||
if container != nil {
|
||||
if err := container.Terminate(ctx); err != nil {
|
||||
flog.Error("failed to terminate container", "error", err)
|
||||
slog.Error("failed to terminate container", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,33 @@ package utils
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils/flog"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// EnvVar enumerates the env vars referenced via this package.
|
||||
// enum of environment variables
|
||||
type EnvVar string
|
||||
|
||||
const ()
|
||||
|
||||
// MustGetEnv panics if the variable is unset.
|
||||
// MustGetEnv returns the value of the environment variable with the given key.
|
||||
// panics if the variable is not set.
|
||||
func MustGetEnv[T string | EnvVar](key T) string {
|
||||
keyString := string(key)
|
||||
value := os.Getenv(keyString)
|
||||
if value == "" {
|
||||
flog.Error("missing required environment variable", "key", key)
|
||||
logrus.Errorf("Missing required environment variable %s", key)
|
||||
panic("Missing required environment variable")
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// GetEnv returns "" if the variable is unset (and logs a warning).
|
||||
// GetEnv returns the value of the environment variable with the given key.
|
||||
// returns an empty string if the variable is not set.
|
||||
func GetEnv(key string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
flog.Warn("missing optional environment variable", "key", key)
|
||||
logrus.Warnf("Missing optional environment variable %s", key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Package flog is a structured logger formatted for GCP Cloud Logging.
|
||||
//
|
||||
// The API mirrors log/slog: a message string followed by alternating key/value
|
||||
// pairs. Each kv pair becomes a JSON field in the emitted record, which Cloud
|
||||
// Logging promotes to a queryable jsonPayload field.
|
||||
//
|
||||
// flog.Info("particle processed", "particleID", id, "elapsed", dt)
|
||||
package flog
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Prepares global logger for GCP Cloud Logging's quirks
|
||||
var log = func() *logrus.Logger {
|
||||
logger := logrus.New()
|
||||
logger.SetFormatter(&logrus.JSONFormatter{
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyLevel: "severity",
|
||||
logrus.FieldKeyMsg: "message",
|
||||
logrus.FieldKeyTime: "timestamp",
|
||||
},
|
||||
})
|
||||
logger.SetOutput(os.Stderr)
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
return logger
|
||||
}()
|
||||
|
||||
// fields converts alternating key/value args into a logrus.Fields map. A
|
||||
// trailing odd arg or non-string key is stored under "!BADKEY" to match
|
||||
// slog's behavior.
|
||||
func fields(args []any) logrus.Fields {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
f := make(logrus.Fields, len(args)/2+1)
|
||||
for i := 0; i < len(args); i += 2 {
|
||||
if i+1 >= len(args) {
|
||||
f["!BADKEY"] = args[i]
|
||||
break
|
||||
}
|
||||
key, ok := args[i].(string)
|
||||
if !ok {
|
||||
f["!BADKEY"] = args[i]
|
||||
continue
|
||||
}
|
||||
f[key] = args[i+1]
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func Debug(msg string, args ...any) { log.WithFields(fields(args)).Debug(msg) }
|
||||
func Info(msg string, args ...any) { log.WithFields(fields(args)).Info(msg) }
|
||||
func Warn(msg string, args ...any) { log.WithFields(fields(args)).Warn(msg) }
|
||||
func Error(msg string, args ...any) { log.WithFields(fields(args)).Error(msg) }
|
||||
func Fatal(msg string, args ...any) { log.WithFields(fields(args)).Fatal(msg) }
|
||||
@@ -21,6 +21,7 @@ func CreateOptionalBool(input bool) *bool {
|
||||
return &input
|
||||
}
|
||||
|
||||
// OptionalString converts a non-nil *string to the respective string or returns "".
|
||||
func OptionalString(input *string) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
@@ -29,6 +30,7 @@ func OptionalString(input *string) string {
|
||||
return *input
|
||||
}
|
||||
|
||||
// OptionalInt converts a non-nil *int to the respective int, otherwise returns 0.
|
||||
func OptionalInt(input *int) int {
|
||||
if input == nil {
|
||||
return 0
|
||||
@@ -37,7 +39,8 @@ func OptionalInt(input *int) int {
|
||||
return *input
|
||||
}
|
||||
|
||||
// Zero values become nil; the inverse of OptionalInt.
|
||||
// CreateOptionalInt when given a zero value int (0), it returns a nil *int.
|
||||
// Otherwise, it gives a proper *int with valid value.
|
||||
func CreateOptionalInt(input int) *int {
|
||||
if input == 0 {
|
||||
return nil
|
||||
@@ -46,7 +49,8 @@ func CreateOptionalInt(input int) *int {
|
||||
return &input
|
||||
}
|
||||
|
||||
// Empty string becomes nil; the inverse of OptionalString.
|
||||
// CreateOptionalString when given an empty string, it returns a nil *string.
|
||||
// Otherwise, it gives a proper *string with valid value.
|
||||
func CreateOptionalString(input string) *string {
|
||||
if input == "" {
|
||||
return nil
|
||||
@@ -55,7 +59,8 @@ func CreateOptionalString(input string) *string {
|
||||
return &input
|
||||
}
|
||||
|
||||
// Returns an error if input contains non-digit characters or parses to <= 0.
|
||||
// GetNumberFromString converts a string to a number.
|
||||
// Returns error if the query is not a number.
|
||||
func GetNumberFromString(input string) (int, error) {
|
||||
for _, c := range input {
|
||||
if c < '0' || c > '9' {
|
||||
@@ -75,6 +80,7 @@ type Number interface {
|
||||
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
|
||||
}
|
||||
|
||||
// OptionalNumber converts a non-nil *NUMBER to the respective number value or returns 0.
|
||||
func OptionalNumber[T Number](input *T) T {
|
||||
if input == nil {
|
||||
return 0
|
||||
@@ -83,7 +89,7 @@ func OptionalNumber[T Number](input *T) T {
|
||||
return *input
|
||||
}
|
||||
|
||||
// Zero values become nil; the inverse of OptionalNumber.
|
||||
// CreateOptionalNumber when given an zero value NUMBER (0), it returns a nil *NUMBER, otherwise, it gives a proper *NUMBER with valid value.
|
||||
func CreateOptionalNumber[T Number](input T) *T {
|
||||
if input == 0 {
|
||||
return nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user