mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2df82b2b5e | ||
|
|
d691047884 | ||
|
|
c2e7fde5bc | ||
|
|
7e4d5137f8 | ||
|
|
376afd2ad6 | ||
|
|
e3fcedbec5 | ||
|
|
7c9564bcad | ||
|
|
cfed6cc4e0 | ||
|
|
c7bd1be3e4 | ||
|
|
2161d84701 | ||
|
|
eaeeb3b502 | ||
|
|
dc32a7c70e | ||
|
|
ab0b483fbe | ||
|
|
6e2f90b03c | ||
|
|
5e0806f479 | ||
|
|
dfdfffe5de | ||
|
|
f5e32aed31 | ||
|
|
710c1aeaa8 | ||
|
|
af49cd6cc4 |
@@ -2,3 +2,5 @@
|
||||
fluxer_static/** -text -diff
|
||||
fluxer_static/**/*.md text diff
|
||||
packages/fonts/files/** -text -diff
|
||||
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.wasm -text -diff
|
||||
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.tar.gz -text -diff
|
||||
|
||||
Generated
+4
-2
@@ -1823,6 +1823,7 @@ dependencies = [
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"flate2",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -1850,6 +1851,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -5830,9 +5832,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
||||
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
],
|
||||
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["fluxer_app/src/**/*.worklet.js"],
|
||||
"javascript": {"globals": ["AudioWorkletProcessor", "registerProcessor", "sampleRate", "currentTime"]}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-api
|
||||
description: Fluxer HTTP API and background job workers
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,244 @@
|
||||
{{- define "fluxer-api.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.labels" -}}
|
||||
{{ include "fluxer-api.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-api.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.image" -}}
|
||||
{{- $g := .root.Values.image | default dict -}}
|
||||
{{- $i := .w.image | default dict -}}
|
||||
{{- $repo := $i.repository -}}
|
||||
{{- if not $repo -}}
|
||||
{{- $repo = printf "%s/%s" (required "image.registry is required" $g.registry) ($i.name | default "fluxer-api") -}}
|
||||
{{- end -}}
|
||||
{{- $tag := required "image.tag is required" ($i.tag | default $g.tag) -}}
|
||||
{{- if $i.digest -}}
|
||||
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" $repo $tag | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.pick" -}}
|
||||
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
|
||||
{{- if $v }}
|
||||
{{- toYaml $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.str" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- int64 . | toString | quote -}}
|
||||
{{- else -}}
|
||||
{{- toString . | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.env" -}}
|
||||
{{- $env := dict -}}
|
||||
{{- range $k, $val := .root.Values.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := .w.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := $env }}
|
||||
{{- if not (kindIs "invalid" $val) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-api.str" $val }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .w.buildVersion }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-api.str" . }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.topologySpread" -}}
|
||||
{{- $tscs := ternary .w.topologySpreadConstraints .root.Values.topologySpreadConstraints (hasKey .w "topologySpreadConstraints") -}}
|
||||
{{- range $tscs }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not $c.labelSelector }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-api.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
- {{- toYaml $c | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.pdb" -}}
|
||||
{{- with .w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $.name }}-pdb
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-api.selectorLabels" $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.hpa" -}}
|
||||
{{- with .w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $.name }}
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $.name }}
|
||||
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $.name) .minReplicas }}
|
||||
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $.name) .maxReplicas }}
|
||||
{{- with .targetCPUUtilizationPercentage }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.deployment" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $v := $root.Values -}}
|
||||
{{- $w := .w -}}
|
||||
{{- $envFrom := concat ($v.envFrom | default list) ($w.envFrom | default list) -}}
|
||||
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($v.podAnnotations | default dict) -}}
|
||||
{{- $wProbes := $w.probes | default dict -}}
|
||||
{{- $gProbes := .probes | default dict -}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .name }}
|
||||
namespace: {{ $root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ if kindIs "invalid" $w.replicas }}1{{ else }}{{ int $w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ int $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-api.selectorLabels" . | nindent 6 }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" . | nindent 8 }}
|
||||
{{- with $podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ int $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.topologySpread" . | trim }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .name }}
|
||||
image: {{ include "fluxer-api.image" . }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($v.image | default dict).pullPolicy | default "IfNotPresent" }}
|
||||
{{- with .command }}
|
||||
command:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.env" . | trim }}
|
||||
env:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- with hasKey $wProbes $probe | ternary (get $wProbes $probe) (get $gProbes $probe) }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{- range $name, $w := .Values.api }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w "component" "api" "probes" ($.Values.probes | default dict) }}
|
||||
{{ include "fluxer-api.deployment" $ctx }}
|
||||
{{ include "fluxer-api.hpa" $ctx }}
|
||||
{{ include "fluxer-api.pdb" $ctx }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-api.selectorLabels" $ctx | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{- range $name, $w := .Values.workers }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w "component" "worker" "command" (list "node" "dist/WorkerEntrypoint.js") "probes" (dict) }}
|
||||
{{ include "fluxer-api.deployment" $ctx }}
|
||||
{{ include "fluxer-api.hpa" $ctx }}
|
||||
{{ include "fluxer-api.pdb" $ctx }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,86 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
FLUXER_ENV: production
|
||||
FLUXER_PUBLIC_ORIGIN: https://web.example.com
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_GATEWAY_ENDPOINT: wss://gateway.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: https://uploads.example.com
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
api:
|
||||
api:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2560Mi
|
||||
|
||||
workers:
|
||||
worker:
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2560Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-gateway
|
||||
description: A Helm chart for the Fluxer realtime gateway.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,280 @@
|
||||
{{- define "gateway.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.labels" -}}
|
||||
{{ include "gateway.selectorLabels" . }}
|
||||
{{- with .component }}
|
||||
app.kubernetes.io/component: {{ . }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.headlessName" -}}
|
||||
{{ printf "%s-headless" .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.pick" -}}
|
||||
{{- $v := get .root.Values .key }}
|
||||
{{- if hasKey .w .key }}
|
||||
{{- $v = get .w .key }}
|
||||
{{- end }}
|
||||
{{- with $v }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) }}
|
||||
{{- int64 . | toString }}
|
||||
{{- else }}
|
||||
{{- toString . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.envList" -}}
|
||||
{{- $env := deepCopy (.root.Values.env | default dict) }}
|
||||
{{- range $k, $v := .w.env | default dict }}
|
||||
{{- if kindIs "invalid" $v }}
|
||||
{{- $_ := unset $env $k }}
|
||||
{{- else }}
|
||||
{{- $_ := set $env $k $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := $env }}
|
||||
{{- if not (kindIs "invalid" $v) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "gateway.string" $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.envFrom" -}}
|
||||
{{- with concat (.root.Values.envFrom | default list) (.w.envFrom | default list) }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.podAnnotations" -}}
|
||||
{{- with merge (deepCopy (.w.podAnnotations | default dict)) (deepCopy (.root.Values.podAnnotations | default dict)) }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.probes" -}}
|
||||
{{- $global := .root.Values.probes | default dict }}
|
||||
{{- $own := .w.probes | default dict }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- $p := get $global $probe }}
|
||||
{{- if hasKey $own $probe }}
|
||||
{{- $p = get $own $probe }}
|
||||
{{- end }}
|
||||
{{- with $p }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.topologySpreadConstraints" -}}
|
||||
{{- $out := list }}
|
||||
{{- range include "gateway.pick" (dict "root" .root "w" .w "key" "topologySpreadConstraints") | fromYamlArray }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not (hasKey $c "labelSelector") }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "gateway.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
{{- $out = append $out $c }}
|
||||
{{- end }}
|
||||
{{- with $out }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.image" -}}
|
||||
{{- $img := .w.image | default dict }}
|
||||
{{- $v := .root.Values.image }}
|
||||
{{- $repo := $img.repository | default (printf "%s/%s" $v.registry ($img.name | default "fluxer-gateway")) }}
|
||||
{{- $ref := printf "%s:%s" $repo ($img.tag | default $v.tag) }}
|
||||
{{- with $img.digest }}
|
||||
{{- $ref = printf "%s@%s" $ref . }}
|
||||
{{- end }}
|
||||
{{- $ref | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.replicas" -}}
|
||||
{{- if kindIs "invalid" .w.replicas }}1{{ else }}{{ .w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.env" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w -}}
|
||||
{{- with $w.role }}
|
||||
- name: FLUXER_GATEWAY_ROLE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "gateway.string" $w.buildVersion | quote }}
|
||||
{{- end }}
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
apiVersion: v1
|
||||
fieldPath: status.podIP
|
||||
- name: FLUXER_ERLANG_NODE_NAME
|
||||
value: fluxer_gateway@$(POD_IP)
|
||||
- name: FLUXER_ERLANG_DIST_PORT
|
||||
value: "8081"
|
||||
- name: FLUXER_GATEWAY_CLUSTER_ENABLED
|
||||
value: "true"
|
||||
- name: FLUXER_GATEWAY_CLUSTER_DISCOVERY_DNS_NAME
|
||||
value: {{ printf "%s.%s.svc.%s" (include "gateway.headlessName" $root) $root.Release.Namespace $root.Values.clusterDomain | quote }}
|
||||
- name: FLUXER_GATEWAY_CLUSTER_DISCOVERY_NODE_BASENAME
|
||||
value: fluxer_gateway
|
||||
{{- include "gateway.envList" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.pod" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w -}}
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "gateway.labels" . | nindent 4 }}
|
||||
{{- with include "gateway.podAnnotations" . }}
|
||||
annotations:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.topologySpreadConstraints" . }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: gateway
|
||||
image: {{ include "gateway.image" . }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default $root.Values.image.pullPolicy }}
|
||||
env:
|
||||
{{- include "gateway.env" . | trim | nindent 6 }}
|
||||
{{- with include "gateway.envFrom" . }}
|
||||
envFrom:
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: epmd
|
||||
containerPort: 4369
|
||||
protocol: TCP
|
||||
- name: erl-dist
|
||||
containerPort: 8081
|
||||
protocol: TCP
|
||||
{{- with include "gateway.probes" . | trim }}
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.pdb" -}}
|
||||
{{- with .w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $.name }}-pdb
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
{{- if not (kindIs "invalid" .minAvailable) }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" .maxUnavailable) }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gateway.selectorLabels" $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.hpa" -}}
|
||||
{{- with .w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $.name }}
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $.name }}
|
||||
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $.name) .minReplicas }}
|
||||
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $.name) .maxReplicas }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,48 @@
|
||||
{{- range $name, $w := .Values.deployments }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "component" $w.role "w" $w }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ include "gateway.replicas" $ctx }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gateway.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with include "gateway.pick" (dict "root" $ "w" $w "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "gateway.pod" $ctx | nindent 4 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "gateway.selectorLabels" $ctx | nindent 4 }}
|
||||
{{- include "gateway.hpa" $ctx }}
|
||||
{{- include "gateway.pdb" $ctx }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "gateway.headlessName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" (dict "root" . "name" "gateway" "component" "discovery") | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
- name: epmd
|
||||
port: 4369
|
||||
protocol: TCP
|
||||
targetPort: epmd
|
||||
- name: erl-dist
|
||||
port: 8081
|
||||
protocol: TCP
|
||||
targetPort: erl-dist
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
@@ -0,0 +1,53 @@
|
||||
{{- $np := .Values.networkPolicy | default dict }}
|
||||
{{- if $np.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" (dict "root" . "name" "gateway") | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
egress:
|
||||
- {}
|
||||
ingress:
|
||||
{{- with $np.ingressNamespace }}
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: {{ . }}
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- with $np.clients }}
|
||||
- from:
|
||||
{{- range . }}
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 4369
|
||||
protocol: TCP
|
||||
- port: 8081
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- range $name, $w := .Values.statefulsets }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "component" $w.role "w" $w }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ include "gateway.replicas" $ctx }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
serviceName: {{ include "gateway.headlessName" $ }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gateway.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with include "gateway.pick" (dict "root" $ "w" $w "key" "updateStrategy") }}
|
||||
updateStrategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "gateway.pod" $ctx | nindent 4 }}
|
||||
{{- include "gateway.pdb" $ctx }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,86 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
clusterDomain: cluster.local
|
||||
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_GATEWAY_PORT: "8080"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: https://media.example.com
|
||||
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
failureThreshold: 30
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
exec:
|
||||
command:
|
||||
- curl
|
||||
- -fsS
|
||||
- -o
|
||||
- /dev/null
|
||||
- --max-time
|
||||
- "2"
|
||||
- http://127.0.0.1:8080/_health/ready
|
||||
timeoutSeconds: 3
|
||||
|
||||
strategy: {}
|
||||
updateStrategy: {}
|
||||
|
||||
topologySpreadConstraints: []
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
ingressNamespace: ingress-nginx
|
||||
clients:
|
||||
- app.kubernetes.io/part-of: fluxer
|
||||
|
||||
deployments:
|
||||
gateway:
|
||||
role: all
|
||||
replicas: 1
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- curl -fsS -o /dev/null --max-time 2 http://127.0.0.1:8080/_health/drain; sleep 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 384Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
|
||||
statefulsets: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-infra
|
||||
description: NATS and Valkey for a Fluxer installation.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,282 @@
|
||||
{{- define "fluxer-infra.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.labels" -}}
|
||||
{{ include "fluxer-infra.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-infra.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.pick" -}}
|
||||
{{- $v := get .root.Values .key }}
|
||||
{{- if hasKey .w .key }}
|
||||
{{- $v = get .w .key }}
|
||||
{{- end }}
|
||||
{{- with $v }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) }}
|
||||
{{- int64 . | toString }}
|
||||
{{- else }}
|
||||
{{- toString . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.envList" -}}
|
||||
{{- $env := deepCopy (.root.Values.env | default dict) }}
|
||||
{{- range $k, $v := .w.env | default dict }}
|
||||
{{- if kindIs "invalid" $v }}
|
||||
{{- $_ := unset $env $k }}
|
||||
{{- else }}
|
||||
{{- $_ := set $env $k $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := $env }}
|
||||
{{- if not (kindIs "invalid" $v) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-infra.string" $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.envFrom" -}}
|
||||
{{- with concat (.root.Values.envFrom | default list) (.w.envFrom | default list) }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.probes" -}}
|
||||
{{- $global := .root.Values.probes | default dict }}
|
||||
{{- $own := .w.probes | default dict }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- $p := get $global $probe }}
|
||||
{{- if hasKey $own $probe }}
|
||||
{{- $p = get $own $probe }}
|
||||
{{- end }}
|
||||
{{- with $p }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.topologySpreadConstraints" -}}
|
||||
{{- $out := list }}
|
||||
{{- range include "fluxer-infra.pick" (dict "root" .root "w" .w "key" "topologySpreadConstraints") | fromYamlArray }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not (hasKey $c "labelSelector") }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-infra.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
{{- $out = append $out $c }}
|
||||
{{- end }}
|
||||
{{- with $out }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.replicas" -}}
|
||||
{{- if kindIs "invalid" .w.replicas }}1{{ else }}{{ .w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.image" -}}
|
||||
{{- $ref := printf "%s:%s" .repository .tag }}
|
||||
{{- with .digest }}
|
||||
{{- $ref = printf "%s@%s" $ref . }}
|
||||
{{- end }}
|
||||
{{- $ref | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.podAnnotations" -}}
|
||||
{{- with merge (deepCopy (.extra | default dict)) (deepCopy (.w.podAnnotations | default dict)) (deepCopy (.root.Values.podAnnotations | default dict)) }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.podSpec" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.topologySpreadConstraints" . }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.containerCommon" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w }}
|
||||
{{- $img := $w.image | default dict }}
|
||||
image: {{ include "fluxer-infra.image" $img }}
|
||||
imagePullPolicy: {{ $img.pullPolicy }}
|
||||
{{- $env := include "fluxer-infra.envList" . | trim }}
|
||||
{{- if or .env $env }}
|
||||
env:
|
||||
{{- with .env }}
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with $env }}
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.envFrom" . }}
|
||||
envFrom:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- include "fluxer-infra.probes" . }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with concat .mounts ($w.extraVolumeMounts | default list) }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.statefulSetSpec" -}}
|
||||
{{- $w := .w }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" .root "w" $w "key" "updateStrategy") }}
|
||||
updateStrategy:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.volumeClaim" -}}
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .size }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.pdb" -}}
|
||||
{{- with .w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $.name }}-pdb
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
{{- if not (kindIs "invalid" .minAvailable) }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" .maxUnavailable) }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-infra.selectorLabels" $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.service" }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .svcName }}
|
||||
namespace: {{ .root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if .headless }}
|
||||
clusterIP: None
|
||||
{{- end }}
|
||||
{{- if .publishNotReady }}
|
||||
publishNotReadyAddresses: true
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "fluxer-infra.selectorLabels" . | nindent 4 }}
|
||||
ports:
|
||||
{{- range .ports }}
|
||||
- name: {{ index . 0 }}
|
||||
port: {{ index . 1 }}
|
||||
targetPort: {{ index . 0 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.natsConf" -}}
|
||||
{{- $w := .Values.nats -}}
|
||||
{{- with $w.config -}}
|
||||
listen: 0.0.0.0:4222
|
||||
http: 0.0.0.0:8222
|
||||
max_payload: {{ .maxPayload }}
|
||||
max_pending: {{ .maxPending }}
|
||||
max_connections: {{ .maxConnections }}
|
||||
{{- if $w.jetstream.enabled }}
|
||||
server_name: $POD_NAME
|
||||
|
||||
jetstream {
|
||||
store_dir: /data
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
cluster {
|
||||
name: {{ .clusterName }}
|
||||
listen: 0.0.0.0:6222
|
||||
|
||||
routes = [
|
||||
{{- range $i := until (int (include "fluxer-infra.replicas" (dict "w" $w))) }}
|
||||
nats-route://nats-{{ $i }}.nats-headless.{{ $.Release.Namespace }}.svc.{{ $.Values.clusterDomain }}:6222
|
||||
{{- end }}
|
||||
]
|
||||
}
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{- with .Values.nats }}
|
||||
{{- $ctx := dict "root" $ "w" . "name" "nats" "component" "messaging" }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nats-config
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
|
||||
data:
|
||||
nats.conf: {{ include "fluxer-infra.natsConf" $ | toJson }}
|
||||
{{- include "fluxer-infra.pdb" $ctx }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "nats" "ports" (list (list "client" 4222))) $ctx) }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "nats-headless" "headless" true "ports" (list (list "client" 4222) (list "cluster" 6222) (list "monitor" 8222))) $ctx) }}
|
||||
{{- $mounts := list (dict "name" "config" "mountPath" "/etc/nats") }}
|
||||
{{- $env := list }}
|
||||
{{- if .jetstream.enabled }}
|
||||
{{- $mounts = append $mounts (dict "name" "data" "mountPath" "/data") }}
|
||||
{{- $env = append $env (dict "name" "POD_NAME" "valueFrom" (dict "fieldRef" (dict "fieldPath" "metadata.name"))) }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: nats
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ include "fluxer-infra.replicas" $ctx }}
|
||||
serviceName: nats-headless
|
||||
{{- with include "fluxer-infra.statefulSetSpec" $ctx | trim }}
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-infra.selectorLabels" $ctx | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 8 }}
|
||||
{{- with include "fluxer-infra.podAnnotations" (merge (dict "extra" (dict "checksum/config" (include "fluxer-infra.natsConf" $ | sha256sum))) $ctx) | trim }}
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- include "fluxer-infra.podSpec" $ctx | trim | nindent 6 }}
|
||||
containers:
|
||||
- name: nats
|
||||
{{- include "fluxer-infra.containerCommon" (merge (dict "env" $env "mounts" $mounts) $ctx) | trim | nindent 10 }}
|
||||
args:
|
||||
- -c
|
||||
- /etc/nats/nats.conf
|
||||
ports:
|
||||
- name: client
|
||||
containerPort: 4222
|
||||
- name: cluster
|
||||
containerPort: 6222
|
||||
- name: monitor
|
||||
containerPort: 8222
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: nats-config
|
||||
{{- with .extraVolumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .jetstream.enabled }}
|
||||
volumeClaimTemplates:
|
||||
{{- include "fluxer-infra.volumeClaim" .jetstream.storage | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,67 @@
|
||||
{{- with .Values.valkey }}
|
||||
{{- $ctx := dict "root" $ "w" . "name" "valkey" "component" "cache" }}
|
||||
{{- include "fluxer-infra.pdb" $ctx }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "valkey" "ports" (list (list "valkey" 6379))) $ctx) }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "valkey-headless" "headless" true "publishNotReady" true "ports" (list (list "valkey" 6379))) $ctx) }}
|
||||
{{- $mounts := list }}
|
||||
{{- if .persistence.enabled }}
|
||||
{{- $mounts = append $mounts (dict "name" "data" "mountPath" "/data") }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: valkey
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
serviceName: valkey-headless
|
||||
{{- with include "fluxer-infra.statefulSetSpec" $ctx | trim }}
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-infra.selectorLabels" $ctx | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 8 }}
|
||||
{{- with include "fluxer-infra.podAnnotations" $ctx | trim }}
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- include "fluxer-infra.podSpec" $ctx | trim | nindent 6 }}
|
||||
containers:
|
||||
- name: valkey
|
||||
{{- include "fluxer-infra.containerCommon" (merge (dict "env" list "mounts" $mounts) $ctx) | trim | nindent 10 }}
|
||||
command:
|
||||
- valkey-server
|
||||
{{- if .persistence.enabled }}
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --dir
|
||||
- /data
|
||||
{{- else }}
|
||||
- --save
|
||||
- ""
|
||||
- --appendonly
|
||||
- "no"
|
||||
{{- end }}
|
||||
- --maxmemory
|
||||
- {{ .maxmemory | quote }}
|
||||
- --maxmemory-policy
|
||||
- {{ .maxmemoryPolicy | quote }}
|
||||
ports:
|
||||
- name: valkey
|
||||
containerPort: 6379
|
||||
{{- with .extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
{{- include "fluxer-infra.volumeClaim" .persistence | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,108 @@
|
||||
imagePullSecrets: []
|
||||
|
||||
clusterDomain: cluster.local
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom: []
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes: {}
|
||||
|
||||
updateStrategy: {}
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
nats:
|
||||
image:
|
||||
repository: nats
|
||||
tag: 2.14-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
replicas: 3
|
||||
config:
|
||||
clusterName: nats
|
||||
maxPayload: 1MB
|
||||
maxPending: 64MB
|
||||
maxConnections: 65536
|
||||
jetstream:
|
||||
enabled: true
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClassName: ""
|
||||
podSecurityContext:
|
||||
fsGroup: 65534
|
||||
runAsGroup: 65534
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: monitor
|
||||
initialDelaySeconds: 10
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /healthz?js-enabled-only=true
|
||||
port: monitor
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
|
||||
valkey:
|
||||
image:
|
||||
repository: valkey/valkey
|
||||
tag: 9.1-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
maxmemory: 192mb
|
||||
maxmemoryPolicy: noeviction
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
podSecurityContext:
|
||||
fsGroup: 999
|
||||
runAsGroup: 999
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
probes:
|
||||
liveness:
|
||||
exec:
|
||||
command:
|
||||
- valkey-cli
|
||||
- ping
|
||||
initialDelaySeconds: 10
|
||||
readiness:
|
||||
exec:
|
||||
command:
|
||||
- valkey-cli
|
||||
- ping
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-ingress
|
||||
description: Ingress routing for the public Fluxer endpoints.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- define "fluxer-ingress.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-ingress.labels" -}}
|
||||
app.kubernetes.io/name: {{ .Chart.Name }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-ingress.chart" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-ingress.annotationKey" -}}
|
||||
{{- if or (contains "/" .key) (not .prefix) -}}
|
||||
{{- .key -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s/%s" .prefix .key -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-ingress.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- . | int64 | toString -}}
|
||||
{{- else -}}
|
||||
{{- . | toString -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- with .Values.clusterIssuer }}
|
||||
{{- if .enabled }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: {{ required "clusterIssuer.name is required" .name }}
|
||||
labels:
|
||||
{{- include "fluxer-ingress.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
acme:
|
||||
email: {{ required "clusterIssuer.email is required" .email | quote }}
|
||||
privateKeySecretRef:
|
||||
name: {{ required "clusterIssuer.privateKeySecretName is required" .privateKeySecretName }}
|
||||
server: {{ required "clusterIssuer.server is required" .server }}
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: {{ required "clusterIssuer.solverIngressClass is required" .solverIngressClass }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{- $v := .Values }}
|
||||
{{- $presets := $v.annotationPresets | default dict }}
|
||||
{{- $issuer := $v.clusterIssuer | default dict }}
|
||||
{{- range $name, $spec := ($v.ingresses | default dict) }}
|
||||
{{- if not (kindIs "invalid" $spec) }}
|
||||
{{- $ann := deepCopy ($v.commonAnnotations | default dict) }}
|
||||
{{- range ($spec.presets | default list) }}
|
||||
{{- $ann = mergeOverwrite $ann (deepCopy (required (printf "unknown annotation preset %s" .) (index $presets .))) }}
|
||||
{{- end }}
|
||||
{{- if and $spec.tls $issuer.enabled }}
|
||||
{{- $_ := set $ann "cert-manager.io/cluster-issuer" (required "clusterIssuer.name is required" $issuer.name) }}
|
||||
{{- end }}
|
||||
{{- $ann = mergeOverwrite $ann (deepCopy ($spec.annotations | default dict)) }}
|
||||
{{- range $k, $val := $ann }}
|
||||
{{- if kindIs "invalid" $val }}
|
||||
{{- $_ := unset $ann $k }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-ingress.labels" $ | nindent 4 }}
|
||||
{{- with $ann }}
|
||||
annotations:
|
||||
{{- range $k, $val := . }}
|
||||
{{ include "fluxer-ingress.annotationKey" (dict "key" $k "prefix" $v.annotationPrefix) }}: {{ include "fluxer-ingress.string" $val | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with $spec.ingressClassName | default $v.ingressClassName }}
|
||||
ingressClassName: {{ . }}
|
||||
{{- end }}
|
||||
{{- with $spec.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range $rule := required (printf "ingress %s needs rules" $name) $spec.rules }}
|
||||
- host: {{ required (printf "ingress %s has a rule without a host" $name) $rule.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range $p := $rule.paths | default (list dict) }}
|
||||
{{- $p = $p | default dict }}
|
||||
- path: {{ $p.path | default "/" | quote }}
|
||||
pathType: {{ $p.pathType | default "Prefix" }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ required (printf "ingress %s host %s needs a service" $name $rule.host) ($p.service | default $rule.service) }}
|
||||
port:
|
||||
number: {{ required (printf "ingress %s host %s needs a port or servicePort" $name $rule.host) ($p.port | default $rule.port | default $v.servicePort) | int64 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,53 @@
|
||||
ingressClassName: nginx
|
||||
annotationPrefix: nginx.ingress.kubernetes.io
|
||||
servicePort: 8080
|
||||
|
||||
commonAnnotations: {}
|
||||
|
||||
annotationPresets:
|
||||
websocket:
|
||||
proxy-read-timeout: "3600"
|
||||
proxy-send-timeout: "3600"
|
||||
stripPrefix:
|
||||
use-regex: "true"
|
||||
rewrite-target: /$2
|
||||
|
||||
ingresses:
|
||||
fluxer:
|
||||
rules:
|
||||
- host: web.example.com
|
||||
service: app-proxy
|
||||
- host: api.example.com
|
||||
service: api
|
||||
- host: admin.example.com
|
||||
service: admin
|
||||
- host: media.example.com
|
||||
service: media-proxy
|
||||
fluxer-web-api:
|
||||
presets: [stripPrefix]
|
||||
rules:
|
||||
- host: web.example.com
|
||||
service: api
|
||||
paths:
|
||||
- path: /api(/(.*))?$
|
||||
pathType: ImplementationSpecific
|
||||
fluxer-gateway:
|
||||
presets: [websocket]
|
||||
rules:
|
||||
- host: gateway.example.com
|
||||
service: gateway
|
||||
fluxer-uploads:
|
||||
annotations:
|
||||
proxy-body-size: 100m
|
||||
proxy-request-buffering: "off"
|
||||
rules:
|
||||
- host: uploads.example.com
|
||||
service: uploads
|
||||
|
||||
clusterIssuer:
|
||||
enabled: false
|
||||
name: letsencrypt
|
||||
email: ""
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
privateKeySecretName: letsencrypt-account-key
|
||||
solverIngressClass: nginx
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-media-proxy
|
||||
description: Fluxer media proxy and upload relay workloads.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,87 @@
|
||||
{{- define "fluxer-media-proxy.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.labels" -}}
|
||||
{{ include "fluxer-media-proxy.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ include "fluxer-media-proxy.mode" . }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-media-proxy.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.image" -}}
|
||||
{{- $g := .root.Values.image -}}
|
||||
{{- $i := .w.image | default dict -}}
|
||||
{{- $repo := $i.repository | default (printf "%s/%s" $g.registry ($i.name | default "fluxer-media-proxy")) -}}
|
||||
{{- $tag := $i.tag | default $g.tag -}}
|
||||
{{- if $i.digest -}}
|
||||
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" $repo $tag | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.pick" -}}
|
||||
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
|
||||
{{- if $v }}
|
||||
{{- toYaml $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.mode" -}}
|
||||
{{- $mode := required (printf "workloads.%s.mode is required" .name) .w.mode -}}
|
||||
{{- if not (has $mode (list "mp" "static" "upload" "relay")) -}}
|
||||
{{- fail (printf "workloads.%s.mode must be mp, static, upload or relay" .name) -}}
|
||||
{{- end -}}
|
||||
{{- $mode -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.envValue" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) -}}
|
||||
{{- int64 . | toString -}}
|
||||
{{- else -}}
|
||||
{{- toString . -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.mergeEnv" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $layer := . -}}
|
||||
{{- range $k, $v := ($layer | default dict) -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $_ := unset $out $k -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $k $v -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.topologySpreadConstraints" -}}
|
||||
{{- $out := list -}}
|
||||
{{- range .constraints -}}
|
||||
{{- if .labelSelector -}}
|
||||
{{- $out = append $out . -}}
|
||||
{{- else -}}
|
||||
{{- $out = append $out (merge (dict "labelSelector" (dict "matchLabels" $.selector)) .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.pdb" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := list "minAvailable" "maxUnavailable" -}}
|
||||
{{- if and (hasKey $ $k) (not (kindIs "invalid" (index $ $k))) -}}
|
||||
{{- $_ := set $out $k (index $ $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,191 @@
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w }}
|
||||
{{- $mode := include "fluxer-media-proxy.mode" $ctx }}
|
||||
{{- $sel := include "fluxer-media-proxy.selectorLabels" $ctx | fromYaml }}
|
||||
{{- $env := include "fluxer-media-proxy.mergeEnv" (list $.Values.env $w.env) | fromYaml }}
|
||||
{{- $extraEnv := concat ($.Values.extraEnv | default list) ($w.extraEnv | default list) }}
|
||||
{{- $envFrom := concat ($.Values.envFrom | default list) ($w.envFrom | default list) }}
|
||||
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($.Values.podAnnotations | default dict) }}
|
||||
{{- $probes := dict }}
|
||||
{{- range $k, $v := ($.Values.probes | default dict) }}
|
||||
{{- $_ := set $probes $k $v }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := ($w.probes | default dict) }}
|
||||
{{- $_ := set $probes $k $v }}
|
||||
{{- end }}
|
||||
{{- $pick := dict "root" $ "w" $w }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ ternary $w.replicas 1 (hasKey $w "replicas") | int64 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds | int64 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- toYaml $sel | nindent 6 }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with $podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 8 }}
|
||||
spec:
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds | int64 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "topologySpreadConstraints") | fromYamlArray }}
|
||||
topologySpreadConstraints:
|
||||
{{- include "fluxer-media-proxy.topologySpreadConstraints" (dict "constraints" . "selector" $sel) | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "fluxer-media-proxy.image" $ctx }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
|
||||
env:
|
||||
{{- if not (kindIs "invalid" $w.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-media-proxy.envValue" $w.buildVersion | quote }}
|
||||
{{- end }}
|
||||
- name: FLUXER_MEDIA_PROXY_MODE
|
||||
value: {{ $mode | quote }}
|
||||
{{- range $k, $v := $env }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-media-proxy.envValue" $v | quote }}
|
||||
{{- end }}
|
||||
{{- with $extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- range $k := list "startup" "liveness" "readiness" }}
|
||||
{{- with get $probes $k }}
|
||||
{{ $k }}Probe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- toYaml $sel | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
{{- with include "fluxer-media-proxy.pdb" ($w.pdb | default dict) | fromYaml }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- toYaml $sel | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $name }}
|
||||
minReplicas: {{ required (printf "workloads.%s.hpa.minReplicas is required" $name) .minReplicas | int64 }}
|
||||
maxReplicas: {{ required (printf "workloads.%s.hpa.maxReplicas is required" $name) .maxReplicas | int64 }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage | int64 }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,72 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
workloads:
|
||||
media-proxy:
|
||||
mode: mp
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
|
||||
uploads:
|
||||
mode: relay
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-push
|
||||
description: Fluxer push notification delivery service
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,71 @@
|
||||
{{- define "fluxer-push.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.labels" -}}
|
||||
{{ include "fluxer-push.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ include "fluxer-push.mode" . }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.mode" -}}
|
||||
{{- $mode := .w.mode | default "delivery" -}}
|
||||
{{- if not (has $mode (list "delivery" "relay")) -}}
|
||||
{{- fail (printf "workloads.%s.mode must be delivery or relay" .name) -}}
|
||||
{{- end -}}
|
||||
{{- $mode -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.port" -}}
|
||||
{{- .w.port | default (ternary 8127 8126 (eq (include "fluxer-push.mode" .) "relay")) -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.image" -}}
|
||||
{{- $global := .root.Values.image | default dict -}}
|
||||
{{- $img := .w.image | default dict -}}
|
||||
{{- $repo := $img.repository -}}
|
||||
{{- if not $repo -}}
|
||||
{{- $repo = printf "%s/%s" (required "image.registry is required" $global.registry) ($img.name | default "fluxer-push") -}}
|
||||
{{- end -}}
|
||||
{{- $ref := printf "%s:%s" $repo (include "fluxer-push.string" (required "image.tag is required" ($img.tag | default $global.tag))) -}}
|
||||
{{- with $img.digest }}{{ $ref = printf "%s@%s" $ref . }}{{ end -}}
|
||||
{{- $ref -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- . | int64 | toString -}}
|
||||
{{- else -}}
|
||||
{{- . | toString -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.env" -}}
|
||||
{{- $env := deepCopy (.root.Values.env | default dict) -}}
|
||||
{{- range $k, $v := (.w.env | default dict) -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $_ := unset $env $k -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $env $k $v -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "invalid" .w.port) -}}
|
||||
{{- $_ := set $env "FLUXER_PUSH_SERVICE_PORT" .w.port -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "invalid" .w.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-push.string" .w.buildVersion | quote }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := $env }}
|
||||
{{- if not (kindIs "invalid" $v) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-push.string" $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,205 @@
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w }}
|
||||
{{- $mode := include "fluxer-push.mode" $ctx }}
|
||||
{{- $port := include "fluxer-push.port" $ctx | int }}
|
||||
{{- $globalProbes := $.Values.probes | default dict }}
|
||||
{{- $workloadProbes := $w.probes | default dict }}
|
||||
{{- $probes := dict }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- $_ := set $probes $probe (ternary (index $workloadProbes $probe) (index $globalProbes $probe) (hasKey $workloadProbes $probe)) }}
|
||||
{{- end }}
|
||||
{{- $annotations := mergeOverwrite (deepCopy ($.Values.podAnnotations | default dict)) (deepCopy ($w.podAnnotations | default dict)) }}
|
||||
{{- $pullSecrets := ternary $w.imagePullSecrets $.Values.imagePullSecrets (hasKey $w "imagePullSecrets") }}
|
||||
{{- $podSecurityContext := ternary $w.podSecurityContext $.Values.podSecurityContext (hasKey $w "podSecurityContext") }}
|
||||
{{- $securityContext := ternary $w.securityContext $.Values.securityContext (hasKey $w "securityContext") }}
|
||||
{{- $strategy := ternary $w.strategy $.Values.strategy (hasKey $w "strategy") }}
|
||||
{{- $tsc := ternary $w.topologySpreadConstraints $.Values.topologySpreadConstraints (hasKey $w "topologySpreadConstraints") }}
|
||||
{{- $nodeSelector := ternary $w.nodeSelector $.Values.nodeSelector (hasKey $w "nodeSelector") }}
|
||||
{{- $tolerations := ternary $w.tolerations $.Values.tolerations (hasKey $w "tolerations") }}
|
||||
{{- $affinity := ternary $w.affinity $.Values.affinity (hasKey $w "affinity") }}
|
||||
{{- $envFrom := concat ($.Values.envFrom | default list) ($w.envFrom | default list) }}
|
||||
{{- $env := include "fluxer-push.env" $ctx }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ ternary $w.replicas 1 (hasKey $w "replicas") | int }}
|
||||
{{- end }}
|
||||
{{- if hasKey $w "minReadySeconds" }}
|
||||
minReadySeconds: {{ $w.minReadySeconds | int }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-push.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with $strategy }}
|
||||
strategy:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with $annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 8 }}
|
||||
spec:
|
||||
{{- with $pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if hasKey $w "terminationGracePeriodSeconds" }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds | int }}
|
||||
{{- end }}
|
||||
{{- with $nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $tsc }}
|
||||
topologySpreadConstraints:
|
||||
{{- range . }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not $c.labelSelector }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-push.selectorLabels" $ctx | fromYaml)) }}
|
||||
{{- end }}
|
||||
{{- toYaml (list $c) | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "fluxer-push.image" $ctx | quote }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($.Values.image | default dict).pullPolicy | default "IfNotPresent" }}
|
||||
command:
|
||||
- /usr/local/bin/fluxer-push
|
||||
{{- if eq $mode "relay" }}
|
||||
args:
|
||||
- --mode
|
||||
- relay
|
||||
{{- end }}
|
||||
{{- with trim $env }}
|
||||
env:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $port }}
|
||||
protocol: TCP
|
||||
{{- with $probes.startup }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $probes.liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $probes.readiness }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-push.selectorLabels" $ctx | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $port }}
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
{{- with $w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-push.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $name }}
|
||||
minReplicas: {{ required (printf "workloads.%s.hpa.minReplicas is required" $name) .minReplicas | int }}
|
||||
maxReplicas: {{ required (printf "workloads.%s.hpa.maxReplicas is required" $name) .maxReplicas | int }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage | int }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,65 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
workloads:
|
||||
push:
|
||||
mode: delivery
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-svc
|
||||
description: Fluxer internal services, each a router Deployment and a shard StatefulSet
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: v1
|
||||
@@ -0,0 +1,203 @@
|
||||
{{- define "fluxer-svc.chart" -}}
|
||||
{{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.labels" -}}
|
||||
{{ include "fluxer-svc.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .mode }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-svc.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.envValue" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) -}}
|
||||
{{- int64 . | toString -}}
|
||||
{{- else -}}
|
||||
{{- toString . -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.mergeEnv" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $layer := . -}}
|
||||
{{- range $k, $v := ($layer | default dict) -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $_ := unset $out $k -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $k $v -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.topologySpreadConstraints" -}}
|
||||
{{- $out := list -}}
|
||||
{{- range .constraints -}}
|
||||
{{- if .labelSelector -}}
|
||||
{{- $out = append $out . -}}
|
||||
{{- else -}}
|
||||
{{- $out = append $out (merge (dict "labelSelector" (dict "matchLabels" $.selector)) .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.pdb" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := list "minAvailable" "maxUnavailable" -}}
|
||||
{{- if and (hasKey $ $k) (not (kindIs "invalid" (index $ $k))) -}}
|
||||
{{- $_ := set $out $k (index $ $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.config" -}}
|
||||
{{- $v := .root.Values -}}
|
||||
{{- $levels := list (index $v .mode) (index .svc .mode) -}}
|
||||
{{- $c := dict "extraEnv" ($v.extraEnv | default list) "envFrom" ($v.envFrom | default list) "podAnnotations" (deepCopy ($v.podAnnotations | default dict)) "probes" (deepCopy ($v.probes | default dict)) "image" (deepCopy (.svc.image | default dict)) -}}
|
||||
{{- range $k := list "imagePullSecrets" "podSecurityContext" "securityContext" "topologySpreadConstraints" "nodeSelector" "tolerations" "affinity" (ternary "updateStrategy" "strategy" (eq .mode "shard")) -}}
|
||||
{{- $_ := set $c $k (index $v $k) -}}
|
||||
{{- end -}}
|
||||
{{- $envLayers := list $v.env -}}
|
||||
{{- range $level := $levels -}}
|
||||
{{- range $k, $x := ($level | default dict) -}}
|
||||
{{- if eq $k "env" -}}
|
||||
{{- $envLayers = append $envLayers $x -}}
|
||||
{{- else if has $k (list "podAnnotations" "image") -}}
|
||||
{{- $_ := set $c $k (mergeOverwrite (index $c $k) (deepCopy ($x | default dict))) -}}
|
||||
{{- else if has $k (list "extraEnv" "envFrom") -}}
|
||||
{{- $_ := set $c $k (concat (index $c $k) ($x | default list)) -}}
|
||||
{{- else if eq $k "probes" -}}
|
||||
{{- range $name, $p := ($x | default dict) -}}
|
||||
{{- $_ := set $c.probes $name $p -}}
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $c $k $x -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $_ := set $c "env" (include "fluxer-svc.mergeEnv" $envLayers | fromYaml) -}}
|
||||
{{- toYaml $c }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.image" -}}
|
||||
{{- $g := .root.Values.image -}}
|
||||
{{- $i := .c.image -}}
|
||||
{{- $repo := $i.repository | default (printf "%s/%s" $g.registry ($i.name | default (printf "fluxer-%s" .service))) -}}
|
||||
{{- $ref := printf "%s:%s" $repo ($i.tag | default $g.tag) -}}
|
||||
{{- with $i.digest }}{{ $ref = printf "%s@%s" $ref . }}{{ end -}}
|
||||
{{- $ref -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.pod" -}}
|
||||
{{- $v := .root.Values -}}
|
||||
{{- $c := .c -}}
|
||||
metadata:
|
||||
{{- with $c.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- with $c.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $c.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $c.terminationGracePeriodSeconds | int64 }}
|
||||
{{- end }}
|
||||
{{- with $c.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- include "fluxer-svc.topologySpreadConstraints" (dict "constraints" . "selector" (include "fluxer-svc.selectorLabels" $ | fromYaml)) | nindent 4 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .mode }}
|
||||
image: {{ include "fluxer-svc.image" . | quote }}
|
||||
imagePullPolicy: {{ $c.image.pullPolicy | default $v.image.pullPolicy }}
|
||||
env:
|
||||
- name: FLUXER_SVC_MODE
|
||||
value: {{ .mode | quote }}
|
||||
- name: FLUXER_SVC_NAME
|
||||
value: {{ .service | quote }}
|
||||
- name: FLUXER_SVC_SHARD_COUNT
|
||||
value: {{ .shardCount | quote }}
|
||||
- name: FLUXER_SVC_PORT
|
||||
value: {{ include "fluxer-svc.envValue" $v.port | quote }}
|
||||
{{- if not (kindIs "invalid" $c.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-svc.envValue" $c.buildVersion | quote }}
|
||||
{{- end }}
|
||||
{{- if eq .mode "shard" }}
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
apiVersion: v1
|
||||
fieldPath: metadata.name
|
||||
{{- end }}
|
||||
{{- range $name, $value := $c.env }}
|
||||
- name: {{ $name }}
|
||||
value: {{ include "fluxer-svc.envValue" $value | quote }}
|
||||
{{- end }}
|
||||
{{- with $c.extraEnv }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $v.port }}
|
||||
protocol: TCP
|
||||
{{- with $c.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- range $name := list "startup" "liveness" "readiness" }}
|
||||
{{- with index $c.probes $name }}
|
||||
{{ $name }}Probe:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $c.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,145 @@
|
||||
{{- range $service, $svc := .Values.services }}
|
||||
{{- if not (kindIs "invalid" $svc) }}
|
||||
{{- $svc = $svc | default dict }}
|
||||
{{- $rc := fromYaml (include "fluxer-svc.config" (dict "root" $ "svc" $svc "mode" "router")) }}
|
||||
{{- $sc := fromYaml (include "fluxer-svc.config" (dict "root" $ "svc" $svc "mode" "shard")) }}
|
||||
{{- $routerReplicas := ternary $rc.replicas 1 (hasKey $rc "replicas") | int64 }}
|
||||
{{- $shardCount := ternary $sc.replicas 1 (hasKey $sc "replicas") | int64 }}
|
||||
{{- if lt $shardCount 1 }}
|
||||
{{- fail (printf "services.%s shard replicas must be at least 1" $service) }}
|
||||
{{- end }}
|
||||
{{- $router := dict "root" $ "service" $service "svc" $svc "mode" "router" "name" $service "c" $rc "shardCount" (toString $shardCount) }}
|
||||
{{- $shard := dict "root" $ "service" $service "svc" $svc "mode" "shard" "name" (printf "%s-shard" $service) "c" $sc "shardCount" (toString $shardCount) }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $service }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $router | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $rc.hpa }}
|
||||
replicas: {{ $routerReplicas }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $rc.minReadySeconds) }}
|
||||
minReadySeconds: {{ $rc.minReadySeconds | int64 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-svc.selectorLabels" $router | nindent 6 }}
|
||||
{{- with $rc.strategy }}
|
||||
strategy:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "fluxer-svc.pod" $router | nindent 4 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ $service }}-shard
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $shard | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ $shardCount }}
|
||||
{{- if not (kindIs "invalid" $sc.minReadySeconds) }}
|
||||
minReadySeconds: {{ $sc.minReadySeconds | int64 }}
|
||||
{{- end }}
|
||||
podManagementPolicy: Parallel
|
||||
serviceName: {{ $service }}-shard-headless
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-svc.selectorLabels" $shard | nindent 6 }}
|
||||
{{- with $sc.updateStrategy }}
|
||||
updateStrategy:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "fluxer-svc.pod" $shard | nindent 4 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $service }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $router | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-svc.selectorLabels" $router | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $.Values.port }}
|
||||
targetPort: {{ $.Values.port }}
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $service }}-shard-headless
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $shard | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
publishNotReadyAddresses: true
|
||||
selector:
|
||||
{{- include "fluxer-svc.selectorLabels" $shard | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $.Values.port }}
|
||||
targetPort: {{ $.Values.port }}
|
||||
protocol: TCP
|
||||
{{- with $rc.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $service }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $router | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $service }}
|
||||
minReplicas: {{ required (printf "services.%s router hpa.minReplicas is required" $service) .minReplicas | int64 }}
|
||||
maxReplicas: {{ required (printf "services.%s router hpa.maxReplicas is required" $service) .maxReplicas | int64 }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage | int64 }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $ctx := list $router $shard }}
|
||||
{{- with include "fluxer-svc.pdb" ($ctx.c.pdb | default dict) | fromYaml }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $ctx.name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-svc.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,89 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env:
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
|
||||
topologySpreadConstraints: []
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
port: 8090
|
||||
|
||||
router:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 192Mi
|
||||
|
||||
shard:
|
||||
replicas: 2
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
|
||||
services:
|
||||
gifs:
|
||||
shard:
|
||||
env:
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://media.example.com
|
||||
messages: {}
|
||||
snowflakes: {}
|
||||
unfurl:
|
||||
shard:
|
||||
env:
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
users: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-web
|
||||
description: Fluxer web app proxy and admin dashboard.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,80 @@
|
||||
{{- define "fluxer-web.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.labels" -}}
|
||||
{{ include "fluxer-web.selectorLabels" . }}
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-web.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.image" -}}
|
||||
{{- $g := .root.Values.image | default dict -}}
|
||||
{{- $i := .w.image | default dict -}}
|
||||
{{- $repo := $i.repository -}}
|
||||
{{- if not $repo -}}
|
||||
{{- $repo = printf "%s/%s" (required "image.registry is required" $g.registry) ($i.name | default (printf "fluxer-%s" .name)) -}}
|
||||
{{- end -}}
|
||||
{{- $tag := required "image.tag is required" ($i.tag | default $g.tag) -}}
|
||||
{{- if $i.digest -}}
|
||||
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" $repo $tag | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.pick" -}}
|
||||
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
|
||||
{{- if $v }}
|
||||
{{- toYaml $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.str" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- int64 . | toString | quote -}}
|
||||
{{- else -}}
|
||||
{{- toString . | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.env" -}}
|
||||
{{- $env := dict -}}
|
||||
{{- range $k, $val := .root.Values.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := .w.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := $env }}
|
||||
{{- if not (kindIs "invalid" $val) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-web.str" $val }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .w.buildVersion }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-web.str" . }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.topologySpread" -}}
|
||||
{{- $tscs := ternary .w.topologySpreadConstraints .root.Values.topologySpreadConstraints (hasKey .w "topologySpreadConstraints") -}}
|
||||
{{- range $tscs }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not $c.labelSelector }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-web.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
- {{- toYaml $c | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,172 @@
|
||||
{{- $v := .Values }}
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w }}
|
||||
{{- $envFrom := concat ($v.envFrom | default list) ($w.envFrom | default list) }}
|
||||
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($v.podAnnotations | default dict) }}
|
||||
{{- $wProbes := $w.probes | default dict }}
|
||||
{{- $gProbes := $v.probes | default dict }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ if kindIs "invalid" $w.replicas }}1{{ else }}{{ int $w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ int $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-web.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 8 }}
|
||||
{{- with $podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ int $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.topologySpread" $ctx | trim }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "fluxer-web.image" $ctx }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($v.image | default dict).pullPolicy | default "IfNotPresent" }}
|
||||
{{- with include "fluxer-web.env" $ctx | trim }}
|
||||
env:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- with hasKey $wProbes $probe | ternary (get $wProbes $probe) (get $gProbes $probe) }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-web.selectorLabels" $ctx | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
{{- with $w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $name }}
|
||||
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $name) .minReplicas }}
|
||||
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $name) .maxReplicas }}
|
||||
{{- with .targetCPUUtilizationPercentage }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-web.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,83 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
workloads:
|
||||
admin:
|
||||
image:
|
||||
name: fluxer-admin
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
FLUXER_APP_ENDPOINT: https://web.example.com
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
app-proxy:
|
||||
image:
|
||||
name: fluxer-app-proxy-self-hosted
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
@@ -160,7 +160,9 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
|
||||
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
|
||||
#FLUXER_IPINFO_API_KEY=
|
||||
# Phone verification needs your own responder on the rpc.phone.v1 NATS
|
||||
# subjects. Off unless turned on.
|
||||
#FLUXER_PHONE_VERIFICATION_ENABLED=false
|
||||
# A local path, or an s3:// URL read with the S3 credentials of this file.
|
||||
#FLUXER_GEOIP_DB_PATH=
|
||||
|
||||
@@ -449,6 +451,11 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
#FLUXER_SEAWEEDFS_TELEMETRY=false
|
||||
|
||||
# Volumes SeaweedFS creates at once when a bucket needs space. Each reserves 1 GB
|
||||
# of free disk from the start, and SeaweedFS's own default of 7 fills a small
|
||||
# disk before every bucket has one, so uploads fail with no free volumes left.
|
||||
#FLUXER_SEAWEEDFS_VOLUME_GROWTH=1
|
||||
|
||||
# Node sizes its heap from the container limit by default. Leave these unset
|
||||
# unless you need to pin it. A heap ceiling above the container limit gets the
|
||||
# container OOM-killed instead of reporting a heap error. The values below are
|
||||
|
||||
@@ -33,7 +33,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
|
||||
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
|
||||
FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-}
|
||||
FLUXER_PHONE_VERIFICATION_ENABLED: ${FLUXER_PHONE_VERIFICATION_ENABLED:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
|
||||
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
|
||||
@@ -354,6 +354,7 @@ services:
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
WEED_MASTER_VOLUME_GROWTH_COPY_1: ${FLUXER_SEAWEEDFS_VOLUME_GROWTH:-1}
|
||||
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
|
||||
@@ -40,6 +40,7 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
adapt_progenitor_throttled_errors(&mut spec);
|
||||
relax_guild_audit_log_schemas(&mut spec);
|
||||
relax_progenitor_schema_strictness(&mut spec);
|
||||
relax_integer_enums(&mut spec);
|
||||
|
||||
let mut settings = progenitor::GenerationSettings::new();
|
||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||
@@ -174,6 +175,23 @@ fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
const OPEN_INTEGER_ENUMS: &[&str] = &["ChannelType", "MessageType", "WebhookType"];
|
||||
|
||||
fn relax_integer_enums(spec: &mut openapiv3::OpenAPI) {
|
||||
let components = spec.components.as_mut().expect("missing API components");
|
||||
for name in OPEN_INTEGER_ENUMS {
|
||||
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(*name) else {
|
||||
panic!("missing inline {name} schema");
|
||||
};
|
||||
let openapiv3::SchemaKind::Type(openapiv3::Type::Integer(integer)) =
|
||||
&mut schema.schema_kind
|
||||
else {
|
||||
panic!("{name} must be an integer schema");
|
||||
};
|
||||
integer.enumeration.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema_mut<'a>(
|
||||
components: &'a mut openapiv3::Components,
|
||||
name: &str,
|
||||
|
||||
@@ -1251,7 +1251,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
|
||||
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5435,7 +5435,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
|
||||
"description": "Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -10150,20 +10150,25 @@
|
||||
"description": "Message content to send to each recipient"
|
||||
},
|
||||
"user_ids": {
|
||||
"description": "Recipient user IDs. Each receives the same content as a system DM.",
|
||||
"minItems": 1,
|
||||
"maxItems": 10000,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "Recipient user IDs. Each receives the same content as a system DM."
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"}
|
||||
},
|
||||
"all_users": {
|
||||
"description": "Send to every user account, skipping bots, system accounts, and deleted or disabled accounts",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["content", "user_ids"]
|
||||
"required": ["content"]
|
||||
},
|
||||
"SendSystemDmResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_count": {
|
||||
"description": "Number of recipients the worker job was queued to deliver to",
|
||||
"nullable": true,
|
||||
"description": "Number of recipients the worker job was queued to deliver to, or null when sending to all users",
|
||||
"allOf": [{"$ref": "#/components/schemas/Int32Type"}]
|
||||
}
|
||||
},
|
||||
@@ -13282,7 +13287,7 @@
|
||||
"ChannelType": {
|
||||
"description": "The type of the channel",
|
||||
"type": "integer",
|
||||
"enum": [0, 1, 2, 3, 4, 998, 999],
|
||||
"enum": [0, 1, 2, 3, 4, 5, 998, 999],
|
||||
"format": "int32",
|
||||
"x-enumNames": [
|
||||
"GUILD_TEXT",
|
||||
@@ -13290,6 +13295,7 @@
|
||||
"GUILD_VOICE",
|
||||
"GROUP_DM",
|
||||
"GUILD_CATEGORY",
|
||||
"GUILD_ANNOUNCEMENT",
|
||||
"GUILD_LINK",
|
||||
"DM_PERSONAL_NOTES"
|
||||
],
|
||||
@@ -13299,6 +13305,7 @@
|
||||
"A voice channel within a guild",
|
||||
"A group direct message between users",
|
||||
"A category that contains channels",
|
||||
"A guild channel whose messages can be published to channels that follow it",
|
||||
"A link channel for external resources",
|
||||
"Personal notes DM channel"
|
||||
]
|
||||
@@ -13497,7 +13504,7 @@
|
||||
"enum": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
|
||||
},
|
||||
"GuildFeatureSchema": {
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_EMOJI_ENABLED, CLONE_STICKER_DISABLED, CLONE_STICKER_ENABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)",
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_EMOJI_ENABLED, CLONE_STICKER_DISABLED, CLONE_STICKER_ENABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD, ANNOUNCEMENT_CHANNELS_DISABLED (other values allowed)",
|
||||
"x-enumNames": [
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
@@ -13531,7 +13538,8 @@
|
||||
"UNAVAILABLE_HIDDEN",
|
||||
"VISIONARY",
|
||||
"LARGE_GUILD_OVERRIDE",
|
||||
"VERY_LARGE_GUILD"
|
||||
"VERY_LARGE_GUILD",
|
||||
"ANNOUNCEMENT_CHANNELS_DISABLED"
|
||||
],
|
||||
"x-enumDescriptions": [
|
||||
"Guild can have an animated icon",
|
||||
@@ -13566,7 +13574,8 @@
|
||||
"Guild is hidden when it is force unavailable",
|
||||
"Guild is a visionary guild",
|
||||
"Guild has large guild overrides enabled",
|
||||
"Guild has increased member capacity enabled"
|
||||
"Guild has increased member capacity enabled",
|
||||
"Guild cannot publish announcement messages or gain new followers"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -13733,7 +13742,8 @@
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
|
||||
},
|
||||
"message_id": {
|
||||
"description": "The ID of the referenced message",
|
||||
"description": "The ID of the referenced message, absent on a channel follow system message",
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
|
||||
},
|
||||
"guild_id": {
|
||||
@@ -13743,7 +13753,7 @@
|
||||
},
|
||||
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
|
||||
},
|
||||
"required": ["channel_id", "message_id", "type"],
|
||||
"required": ["channel_id", "type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"message_snapshots": {
|
||||
@@ -13878,7 +13888,8 @@
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
|
||||
},
|
||||
"message_id": {
|
||||
"description": "The ID of the referenced message",
|
||||
"description": "The ID of the referenced message, absent on a channel follow system message",
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]
|
||||
},
|
||||
"guild_id": {
|
||||
@@ -13888,7 +13899,7 @@
|
||||
},
|
||||
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
|
||||
},
|
||||
"required": ["channel_id", "message_id", "type"],
|
||||
"required": ["channel_id", "type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"message_snapshots": {
|
||||
@@ -14379,11 +14390,26 @@
|
||||
"description": "The bitwise flags of the original message",
|
||||
"format": "int32",
|
||||
"x-bitflagValues": [
|
||||
{
|
||||
"name": "CROSSPOSTED",
|
||||
"value": "1",
|
||||
"description": "This message has been published to channels that follow this announcement channel"
|
||||
},
|
||||
{
|
||||
"name": "IS_CROSSPOST",
|
||||
"value": "2",
|
||||
"description": "This message was delivered from an announcement channel this channel follows"
|
||||
},
|
||||
{
|
||||
"name": "SUPPRESS_EMBEDS",
|
||||
"value": "4",
|
||||
"description": "Do not include embeds when serialising this message"
|
||||
},
|
||||
{
|
||||
"name": "SOURCE_MESSAGE_DELETED",
|
||||
"value": "8",
|
||||
"description": "The published message this copy came from has been deleted"
|
||||
},
|
||||
{
|
||||
"name": "SUPPRESS_NOTIFICATIONS",
|
||||
"value": "4096",
|
||||
@@ -14395,7 +14421,7 @@
|
||||
"MessageType": {
|
||||
"description": "The type of message",
|
||||
"type": "integer",
|
||||
"enum": [0, 1, 2, 3, 4, 5, 6, 7, 19],
|
||||
"enum": [0, 1, 2, 3, 4, 5, 6, 7, 12, 19],
|
||||
"format": "int32",
|
||||
"x-enumNames": [
|
||||
"DEFAULT",
|
||||
@@ -14406,6 +14432,7 @@
|
||||
"CHANNEL_ICON_CHANGE",
|
||||
"CHANNEL_PINNED_MESSAGE",
|
||||
"USER_JOIN",
|
||||
"CHANNEL_FOLLOW_ADD",
|
||||
"REPLY"
|
||||
],
|
||||
"x-enumDescriptions": [
|
||||
@@ -14417,6 +14444,7 @@
|
||||
"A system message indicating the channel icon changed",
|
||||
"A system message indicating a message was pinned",
|
||||
"A system message indicating a user joined",
|
||||
"System message posted when a channel starts following an announcement channel",
|
||||
"A reply message"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -8,13 +8,18 @@ use super::types::SendSystemDmResponse;
|
||||
impl AdminApiClient {
|
||||
pub async fn send_system_dm(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
user_ids: Option<&[String]>,
|
||||
content: &str,
|
||||
) -> ApiResult<SendSystemDmResponse> {
|
||||
let body = generated_types::SendSystemDmRequest {
|
||||
content: generated_types::SendSystemDmRequestContent::try_from(content)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
user_ids: user_ids
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|id| snowflake(id))
|
||||
.collect(),
|
||||
all_users: user_ids.is_none().then_some(true),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
|
||||
@@ -4,5 +4,5 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SendSystemDmResponse {
|
||||
pub recipient_count: i64,
|
||||
pub recipient_count: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -171,7 +171,8 @@ pub(crate) async fn system_dms_post(
|
||||
let flash = if let Some(content) = content.as_deref()
|
||||
&& !user_ids.is_empty()
|
||||
{
|
||||
match client.send_system_dm(&user_ids, content).await {
|
||||
let recipients = (user_ids != ["*"]).then_some(user_ids.as_slice());
|
||||
match client.send_system_dm(recipients, content).await {
|
||||
Ok(_) => FlashData::success("System DM sent"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: send system DM");
|
||||
|
||||
@@ -179,6 +179,7 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"VISIONARY",
|
||||
"LARGE_GUILD_OVERRIDE",
|
||||
"VERY_LARGE_GUILD",
|
||||
"ANNOUNCEMENT_CHANNELS_DISABLED",
|
||||
];
|
||||
|
||||
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||
|
||||
@@ -45,6 +45,7 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"VISIONARY",
|
||||
"LARGE_GUILD_OVERRIDE",
|
||||
"VERY_LARGE_GUILD",
|
||||
"ANNOUNCEMENT_CHANNELS_DISABLED",
|
||||
];
|
||||
|
||||
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
|
||||
|
||||
@@ -32,6 +32,7 @@ fn channel_type_label(channel_type: i32) -> &'static str {
|
||||
0 => "Text",
|
||||
2 => "Voice",
|
||||
4 => "Category",
|
||||
5 => "Announcement",
|
||||
13 => "Link",
|
||||
_ => "Unknown",
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn system_dm_page(
|
||||
(form_field_group(
|
||||
"Recipient user IDs", "system-dm-user-ids",
|
||||
true, None,
|
||||
Some("One per line. Snowflake IDs only."),
|
||||
Some("One per line. Snowflake IDs only, or a single * to send to every user."),
|
||||
html! {
|
||||
textarea id="system-dm-user-ids" name="user_ids"
|
||||
required rows="10"
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "catalog:",
|
||||
"@pkgs/cassandra": "workspace:*",
|
||||
"@fluxer/geo_utils": "workspace:*",
|
||||
"@fluxer/instance_bootstrap": "workspace:*",
|
||||
"@fluxer/ip_utils": "workspace:*",
|
||||
"@pkgs/postgres": "workspace:*",
|
||||
"maxmind": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"maxmind": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const TABLE = 'ipinfo_cache';
|
||||
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
|
||||
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
|
||||
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
|
||||
|
||||
interface CassandraIpInfoCacheOptions {
|
||||
client?: ICassandraClient;
|
||||
getClient?: () => ICassandraClient;
|
||||
}
|
||||
|
||||
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
|
||||
const row = result.first();
|
||||
if (!row) return null;
|
||||
const payload = row.get('payload');
|
||||
if (typeof payload !== 'string') return null;
|
||||
return JSON.parse(payload) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
|
||||
await client.execute({
|
||||
cql: INSERT_WITH_TTL_CQL,
|
||||
params: {cache_key: key, payload, ttl: ttlSeconds},
|
||||
});
|
||||
} else {
|
||||
await client.execute({
|
||||
cql: INSERT_DEFAULT_TTL_CQL,
|
||||
params: {cache_key: key, payload},
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const TABLE = 'ipinfo_requests_by_hour';
|
||||
const INSERT_CQL = `INSERT INTO ${TABLE} (
|
||||
bucket_date,
|
||||
bucket_hour,
|
||||
requested_at,
|
||||
event_id,
|
||||
source,
|
||||
reason,
|
||||
ip,
|
||||
cache_key,
|
||||
request_url,
|
||||
http_status,
|
||||
outcome,
|
||||
available,
|
||||
risk_note,
|
||||
latency_ms,
|
||||
response_ip,
|
||||
country_code,
|
||||
asn,
|
||||
is_anonymous,
|
||||
is_tor,
|
||||
is_vpn,
|
||||
is_proxy,
|
||||
is_residential_proxy,
|
||||
metadata_json
|
||||
) VALUES (
|
||||
:bucket_date,
|
||||
:bucket_hour,
|
||||
:requested_at,
|
||||
:event_id,
|
||||
:source,
|
||||
:reason,
|
||||
:ip,
|
||||
:cache_key,
|
||||
:request_url,
|
||||
:http_status,
|
||||
:outcome,
|
||||
:available,
|
||||
:risk_note,
|
||||
:latency_ms,
|
||||
:response_ip,
|
||||
:country_code,
|
||||
:asn,
|
||||
:is_anonymous,
|
||||
:is_tor,
|
||||
:is_vpn,
|
||||
:is_proxy,
|
||||
:is_residential_proxy,
|
||||
:metadata_json
|
||||
);`;
|
||||
|
||||
interface CassandraIpInfoRequestAuditOptions {
|
||||
client?: ICassandraClient;
|
||||
getClient?: () => ICassandraClient;
|
||||
}
|
||||
|
||||
export function createCassandraIpInfoRequestAuditLogger(
|
||||
options: CassandraIpInfoRequestAuditOptions,
|
||||
): IpInfoRequestAuditLogger {
|
||||
return {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.execute({
|
||||
cql: INSERT_CQL,
|
||||
params: {
|
||||
bucket_date: formatUtcDate(event.requestedAt),
|
||||
bucket_hour: event.requestedAt.getUTCHours(),
|
||||
requested_at: event.requestedAt,
|
||||
event_id: randomUUID(),
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
ip: event.ip,
|
||||
cache_key: event.cacheKey,
|
||||
request_url: event.requestUrl,
|
||||
http_status: event.httpStatus,
|
||||
outcome: event.outcome,
|
||||
available: event.available,
|
||||
risk_note: event.note,
|
||||
latency_ms: event.latencyMs,
|
||||
response_ip: event.responseIp,
|
||||
country_code: event.countryCode,
|
||||
asn: event.asnNumber,
|
||||
is_anonymous: event.isAnonymous,
|
||||
is_tor: event.isTor,
|
||||
is_vpn: event.isVpn,
|
||||
is_proxy: event.isProxy,
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatUtcDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {z} from 'zod';
|
||||
|
||||
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
const CACHE_KEY_PREFIX = 'ipinfo:max:';
|
||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
|
||||
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
|
||||
const FAILURE_TTL_QUOTA_SECONDS = 900;
|
||||
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
|
||||
|
||||
export interface IpInfoGeoBlock {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
continent: string | null;
|
||||
continentCode: string | null;
|
||||
region: string | null;
|
||||
regionCode: string | null;
|
||||
city: string | null;
|
||||
postalCode: string | null;
|
||||
timezone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadiusKm: number | null;
|
||||
}
|
||||
|
||||
export interface IpInfoAsnBlock {
|
||||
asn: string | null;
|
||||
number: number | null;
|
||||
name: string | null;
|
||||
domain: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoMobileBlock {
|
||||
name: string | null;
|
||||
mcc: string | null;
|
||||
mnc: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoAnonymousBlock {
|
||||
isAnonymous: boolean;
|
||||
providerName: string | null;
|
||||
isVpn: boolean;
|
||||
isProxy: boolean;
|
||||
isResidentialProxy: boolean;
|
||||
isTor: boolean;
|
||||
isRelay: boolean;
|
||||
percentDaysSeen: number | null;
|
||||
}
|
||||
|
||||
export interface IpInfoFlags {
|
||||
isAnycast: boolean;
|
||||
isHosting: boolean;
|
||||
isMobile: boolean;
|
||||
isSatellite: boolean;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupResult {
|
||||
ip: string;
|
||||
available: boolean;
|
||||
note: string;
|
||||
geo: IpInfoGeoBlock;
|
||||
asn: IpInfoAsnBlock;
|
||||
mobile: IpInfoMobileBlock;
|
||||
anonymous: IpInfoAnonymousBlock;
|
||||
flags: IpInfoFlags;
|
||||
}
|
||||
|
||||
export interface IpInfoCache {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CachedIpInfoFailure extends IpInfoLookupResult {
|
||||
cachedFailure: true;
|
||||
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
failureHttpStatus: number | null;
|
||||
cachedAtMs: number;
|
||||
}
|
||||
|
||||
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
|
||||
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
|
||||
}
|
||||
|
||||
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
|
||||
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
|
||||
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
|
||||
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
|
||||
return FAILURE_TTL_HTTP_ERROR_SECONDS;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupContext {
|
||||
source?: string;
|
||||
reason?: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export interface IpInfoRequestAuditEvent {
|
||||
requestedAt: Date;
|
||||
ip: string;
|
||||
cacheKey: string;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
httpStatus: number | null;
|
||||
available: boolean;
|
||||
note: string;
|
||||
latencyMs: number;
|
||||
requestUrl: string;
|
||||
responseIp: string | null;
|
||||
countryCode: string | null;
|
||||
asnNumber: number | null;
|
||||
isAnonymous: boolean;
|
||||
isTor: boolean;
|
||||
isVpn: boolean;
|
||||
isProxy: boolean;
|
||||
isResidentialProxy: boolean;
|
||||
}
|
||||
|
||||
export interface IpInfoRequestAuditLogger {
|
||||
record(event: IpInfoRequestAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
interface IpInfoServiceContext {
|
||||
apiKey: string;
|
||||
cache: IpInfoCache;
|
||||
auditLogger?: IpInfoRequestAuditLogger;
|
||||
}
|
||||
|
||||
export interface IpInfoService {
|
||||
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
|
||||
}
|
||||
|
||||
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
|
||||
const RawIpInfoGeoSchema = z.object({
|
||||
city: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
region_code: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
country_code: z.string().optional(),
|
||||
continent: z.string().optional(),
|
||||
continent_code: z.string().optional(),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
dma_code: z.string().optional(),
|
||||
geoname_id: z.string().optional(),
|
||||
radius: z.number().int().optional(),
|
||||
last_changed: IpInfoDateSchema.optional(),
|
||||
});
|
||||
const RawIpInfoAsSchema = z.object({
|
||||
asn: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
last_changed: IpInfoDateSchema.optional(),
|
||||
});
|
||||
const RawIpInfoMobileSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
mcc: z.string().optional(),
|
||||
mnc: z.string().optional(),
|
||||
});
|
||||
const RawIpInfoAnonymousSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
last_seen: IpInfoDateSchema.optional(),
|
||||
percent_days_seen: z.number().int().optional(),
|
||||
is_proxy: z.boolean().optional(),
|
||||
is_relay: z.boolean().optional(),
|
||||
is_tor: z.boolean().optional(),
|
||||
is_vpn: z.boolean().optional(),
|
||||
is_res_proxy: z.boolean().optional(),
|
||||
});
|
||||
const RawIpInfoResponseSchema = z.object({
|
||||
ip: z.string(),
|
||||
hostname: z.string().optional(),
|
||||
geo: RawIpInfoGeoSchema,
|
||||
as: RawIpInfoAsSchema,
|
||||
mobile: RawIpInfoMobileSchema.optional(),
|
||||
anonymous: RawIpInfoAnonymousSchema,
|
||||
is_anonymous: z.boolean().optional(),
|
||||
is_anycast: z.boolean().optional(),
|
||||
is_hosting: z.boolean().optional(),
|
||||
is_mobile: z.boolean().optional(),
|
||||
is_satellite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
|
||||
|
||||
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
|
||||
return {
|
||||
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
|
||||
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
|
||||
if (cached !== null) {
|
||||
if (isCachedIpInfoFailure(cached)) {
|
||||
return unavailable(ip, cached.note);
|
||||
}
|
||||
return {...cached, ip};
|
||||
}
|
||||
const existing = inflight.get(cacheKey);
|
||||
if (existing) {
|
||||
const result = await existing;
|
||||
return {...result, ip};
|
||||
}
|
||||
const requestedAt = new Date();
|
||||
const startedAt = Date.now();
|
||||
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
|
||||
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
|
||||
const finalize = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: IpInfoRequestAuditEvent['outcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
await ctx.auditLogger
|
||||
?.record({
|
||||
requestedAt,
|
||||
ip,
|
||||
cacheKey,
|
||||
source: context?.source ?? 'unknown',
|
||||
reason: context?.reason ?? null,
|
||||
metadata: context?.metadata,
|
||||
outcome: params.outcome,
|
||||
httpStatus: params.httpStatus,
|
||||
available: params.result.available,
|
||||
note: params.result.note,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
requestUrl,
|
||||
responseIp: params.result.available ? params.result.ip : null,
|
||||
countryCode: params.result.geo.countryCode,
|
||||
asnNumber: params.result.asn.number,
|
||||
isAnonymous: params.result.anonymous.isAnonymous,
|
||||
isTor: params.result.anonymous.isTor,
|
||||
isVpn: params.result.anonymous.isVpn,
|
||||
isProxy: params.result.anonymous.isProxy,
|
||||
isResidentialProxy: params.result.anonymous.isResidentialProxy,
|
||||
})
|
||||
.catch(() => {});
|
||||
return params.result;
|
||||
};
|
||||
const performLookup = async (): Promise<IpInfoLookupResult> => {
|
||||
const finalizeFailure = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: CachedIpInfoFailure['failureOutcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
const entry: CachedIpInfoFailure = {
|
||||
...params.result,
|
||||
cachedFailure: true,
|
||||
failureOutcome: params.outcome,
|
||||
failureHttpStatus: params.httpStatus,
|
||||
cachedAtMs: Date.now(),
|
||||
};
|
||||
await ctx.cache
|
||||
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
|
||||
.catch(() => {});
|
||||
return finalize(params);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
let payload: unknown;
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
|
||||
outcome: 'http_error',
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo request failed: ${detail}`),
|
||||
outcome: 'request_failed',
|
||||
httpStatus: null,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
}
|
||||
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
|
||||
if (!parsedResponse.success) {
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
|
||||
outcome: 'schema_mismatch',
|
||||
httpStatus: 200,
|
||||
});
|
||||
}
|
||||
const result = parseIpInfoResponse(parsedResponse.data);
|
||||
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
|
||||
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
|
||||
return finalize({
|
||||
result,
|
||||
outcome: 'http_success',
|
||||
httpStatus: 200,
|
||||
});
|
||||
};
|
||||
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
|
||||
if (inflight.get(cacheKey) === promise) {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
|
||||
return {
|
||||
async lookup(ip: string): Promise<IpInfoLookupResult> {
|
||||
return unavailable(ip, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable(ip: string, reason: string): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
available: false,
|
||||
note: reason,
|
||||
geo: emptyGeo(),
|
||||
asn: emptyAsn(),
|
||||
mobile: emptyMobile(),
|
||||
anonymous: emptyAnonymous(),
|
||||
flags: emptyFlags(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyGeo(): IpInfoGeoBlock {
|
||||
return {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
continent: null,
|
||||
continentCode: null,
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAsn(): IpInfoAsnBlock {
|
||||
return {asn: null, number: null, name: null, domain: null, type: null};
|
||||
}
|
||||
|
||||
function emptyMobile(): IpInfoMobileBlock {
|
||||
return {name: null, mcc: null, mnc: null};
|
||||
}
|
||||
|
||||
function emptyAnonymous(): IpInfoAnonymousBlock {
|
||||
return {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyFlags(): IpInfoFlags {
|
||||
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
|
||||
}
|
||||
|
||||
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
|
||||
const geo = raw.geo;
|
||||
const anon = raw.anonymous;
|
||||
const isAnonymous =
|
||||
raw.is_anonymous === true ||
|
||||
anon.is_res_proxy === true ||
|
||||
anon.is_vpn === true ||
|
||||
anon.is_proxy === true ||
|
||||
anon.is_tor === true ||
|
||||
anon.is_relay === true;
|
||||
return {
|
||||
ip: raw.ip,
|
||||
available: true,
|
||||
note: describeAnonymity(isAnonymous, anon),
|
||||
geo: {
|
||||
countryCode: normalizeCountryCode(geo.country_code),
|
||||
countryName: geo.country ?? null,
|
||||
continent: geo?.continent ?? null,
|
||||
continentCode: normalizeContinentCode(geo.continent_code),
|
||||
region: geo.region ?? null,
|
||||
regionCode: normalizeRegionCode(geo.region_code),
|
||||
city: geo.city ?? null,
|
||||
postalCode: geo.postal_code ?? null,
|
||||
timezone: geo.timezone ?? null,
|
||||
latitude: normalizeCoordinate(geo.latitude),
|
||||
longitude: normalizeCoordinate(geo.longitude),
|
||||
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
|
||||
},
|
||||
asn: parseAsnBlock(raw.as),
|
||||
mobile: {
|
||||
name: raw.mobile?.name ?? null,
|
||||
mcc: raw.mobile?.mcc ?? null,
|
||||
mnc: raw.mobile?.mnc ?? null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous,
|
||||
providerName: anon?.name ?? null,
|
||||
isVpn: anon?.is_vpn === true,
|
||||
isProxy: anon?.is_proxy === true,
|
||||
isResidentialProxy: anon?.is_res_proxy === true,
|
||||
isTor: anon?.is_tor === true,
|
||||
isRelay: anon?.is_relay === true,
|
||||
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: raw.is_anycast === true,
|
||||
isHosting: raw.is_hosting === true,
|
||||
isMobile: raw.is_mobile === true,
|
||||
isSatellite: raw.is_satellite === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatSchemaMismatch(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) {
|
||||
return 'IPInfo response schema mismatch';
|
||||
}
|
||||
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
|
||||
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
|
||||
}
|
||||
|
||||
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
|
||||
const raw = as?.asn ?? null;
|
||||
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
|
||||
return {
|
||||
asn: raw,
|
||||
number: Number.isFinite(numeric) ? numeric : null,
|
||||
name: as?.name ?? null,
|
||||
domain: as?.domain ?? null,
|
||||
type: as?.type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
|
||||
if (!isAnonymous) {
|
||||
return 'IPInfo: IP is not anonymous';
|
||||
}
|
||||
if (!anon) {
|
||||
return 'IPInfo: anonymous IP';
|
||||
}
|
||||
const flags: Array<string> = [];
|
||||
if (anon.is_res_proxy) flags.push('residential proxy');
|
||||
if (anon.is_vpn) flags.push('VPN');
|
||||
if (anon.is_proxy) flags.push('proxy');
|
||||
if (anon.is_tor) flags.push('Tor');
|
||||
if (anon.is_relay) flags.push('relay');
|
||||
const provider = anon.name ? ` (provider: ${anon.name})` : '';
|
||||
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
|
||||
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
|
||||
}
|
||||
|
||||
function normalizeCountryCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeContinentCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeRegionCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
|
||||
interface PostgresIpInfoOptions {
|
||||
client?: IPostgresClient;
|
||||
getClient?: () => IPostgresClient;
|
||||
onError?: (error: unknown, operation: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
|
||||
return options.client ?? options.getClient?.() ?? null;
|
||||
}
|
||||
|
||||
function valueKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function rowKey(values: ReadonlyArray<unknown>): string {
|
||||
return values.map(valueKey).join(VALUE_SEPARATOR);
|
||||
}
|
||||
|
||||
function table(client: IPostgresClient): string {
|
||||
return quoteIdentifier(client.kvTable());
|
||||
}
|
||||
|
||||
async function upsertKvRow(
|
||||
client: IPostgresClient,
|
||||
tableName: string,
|
||||
partitionKey: string,
|
||||
key: string,
|
||||
row: Record<string, unknown>,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
|
||||
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return null;
|
||||
const result = await client.query<{row_data: {payload?: string}}>(
|
||||
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
['ipinfo_cache', rowKey([key])],
|
||||
);
|
||||
const payload = result.rows[0]?.row_data?.payload;
|
||||
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_get');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(value);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_serialize');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_cache',
|
||||
rowKey([key]),
|
||||
rowKey([key]),
|
||||
{cache_key: key, payload},
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_set');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
|
||||
return {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
const bucketDate = formatUtcDate(event.requestedAt);
|
||||
const bucketHour = event.requestedAt.getUTCHours();
|
||||
const eventId = randomUUID();
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_requests_by_hour',
|
||||
rowKey([bucketDate, bucketHour]),
|
||||
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
|
||||
{
|
||||
bucket_date: bucketDate,
|
||||
bucket_hour: bucketHour,
|
||||
requested_at: event.requestedAt.toISOString(),
|
||||
event_id: eventId,
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
ip: event.ip,
|
||||
cache_key: event.cacheKey,
|
||||
request_url: event.requestUrl,
|
||||
http_status: event.httpStatus,
|
||||
outcome: event.outcome,
|
||||
available: event.available,
|
||||
risk_note: event.note,
|
||||
latency_ms: event.latencyMs,
|
||||
response_ip: event.responseIp,
|
||||
country_code: event.countryCode,
|
||||
asn: event.asnNumber,
|
||||
is_anonymous: event.isAnonymous,
|
||||
is_tor: event.isTor,
|
||||
is_vpn: event.isVpn,
|
||||
is_proxy: event.isProxy,
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_request_audit_record');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatUtcDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
|
||||
if (!metadata || Object.keys(metadata).length === 0) return null;
|
||||
try {
|
||||
return JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
|
||||
|
||||
interface TieredIpInfoCacheOptions {
|
||||
hot: IpInfoCache;
|
||||
cold: IpInfoCache;
|
||||
hotTtlSeconds?: number;
|
||||
skipColdWrite?: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
|
||||
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const hit = await opts.hot.get<T>(key).catch(() => null);
|
||||
if (hit !== null) return hit;
|
||||
const cold = await opts.cold.get<T>(key).catch(() => null);
|
||||
if (cold === null) return null;
|
||||
if (opts.skipColdWrite?.(cold) === true) return cold;
|
||||
void opts.hot.set(key, cold, hotTtl).catch(() => {});
|
||||
return cold;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
|
||||
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
|
||||
if (opts.skipColdWrite?.(value) !== true) {
|
||||
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -211,3 +211,41 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function withPhoneVerification(master: MasterConfig, selfHosted: boolean, enabled?: boolean): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
instance: {
|
||||
...master.instance,
|
||||
self_hosted: selfHosted,
|
||||
phone_verification_enabled: enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster phone verification', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('is on by default when the instance is not self-hosted', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, false)).instance.phoneVerificationEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is off by default on a self-hosted instance', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true)).instance.phoneVerificationEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a self-hosted operator switch it on', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true, true)).instance.phoneVerificationEnabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets an operator switch it off when the instance is not self-hosted', () => {
|
||||
expect(
|
||||
buildAPIConfigFromMaster(withPhoneVerification(master, false, false)).instance.phoneVerificationEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,9 +259,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
ipinfo: {
|
||||
apiKey: master.integrations.ipinfo.api_key || undefined,
|
||||
},
|
||||
blocklistFeeds: {
|
||||
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
@@ -370,6 +367,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
instance: {
|
||||
selfHosted: master.instance.self_hosted,
|
||||
phoneVerificationEnabled: master.instance.phone_verification_enabled ?? !master.instance.self_hosted,
|
||||
autoJoinInviteCode: master.instance.auto_join_invite_code,
|
||||
visionariesGuildId: master.instance.visionaries_guild_id,
|
||||
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
|
||||
@@ -453,6 +451,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||
lifecycle: apiWorkerConfig?.lane_concurrency_overrides?.lifecycle,
|
||||
batch: apiWorkerConfig?.lane_concurrency_overrides?.batch,
|
||||
crosspost: apiWorkerConfig?.lane_concurrency_overrides?.crosspost,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,6 +129,10 @@ import {
|
||||
CHANNELS_BY_GUILD_COLUMNS,
|
||||
type ChannelRow,
|
||||
type ChannelsByGuildRow,
|
||||
CROSSPOST_SOURCE_BY_CHANNEL_COLUMNS,
|
||||
CROSSPOSTED_MESSAGE_COLUMNS,
|
||||
type CrosspostedMessageRow,
|
||||
type CrosspostSourceByChannelRow,
|
||||
DM_STATE_COLUMNS,
|
||||
type DmStateRow,
|
||||
INVITE_COLUMNS,
|
||||
@@ -136,7 +140,9 @@ import {
|
||||
PRIVATE_CHANNEL_COLUMNS,
|
||||
type PrivateChannelRow,
|
||||
WEBHOOK_COLUMNS,
|
||||
WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
|
||||
type WebhookRow,
|
||||
type WebhooksBySourceChannelRow,
|
||||
} from '@app/api/database/types/ChannelTypes';
|
||||
import {USER_CONNECTION_STORAGE_COLUMNS, type UserConnectionStorageRow} from '@app/api/database/types/ConnectionTypes';
|
||||
import {
|
||||
@@ -1089,6 +1095,36 @@ export const WebhooksByGuild = defineTable<WebhooksByGuildRow, 'guild_id' | 'web
|
||||
columns: WEBHOOKS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'webhook_id'],
|
||||
});
|
||||
export const WebhooksBySourceChannel = defineTable<
|
||||
WebhooksBySourceChannelRow,
|
||||
'source_channel_id' | 'webhook_id',
|
||||
'source_channel_id'
|
||||
>({
|
||||
name: 'webhooks_by_source_channel_id',
|
||||
columns: WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
|
||||
primaryKey: ['source_channel_id', 'webhook_id'],
|
||||
partitionKey: ['source_channel_id'],
|
||||
});
|
||||
export const CrosspostedMessages = defineTable<
|
||||
CrosspostedMessageRow,
|
||||
'source_message_id' | 'webhook_id',
|
||||
'source_message_id'
|
||||
>({
|
||||
name: 'crossposted_messages',
|
||||
columns: CROSSPOSTED_MESSAGE_COLUMNS,
|
||||
primaryKey: ['source_message_id', 'webhook_id'],
|
||||
partitionKey: ['source_message_id'],
|
||||
});
|
||||
export const CrosspostSourcesByChannel = defineTable<
|
||||
CrosspostSourceByChannelRow,
|
||||
'source_channel_id' | 'source_message_id',
|
||||
'source_channel_id'
|
||||
>({
|
||||
name: 'crosspost_sources_by_channel',
|
||||
columns: CROSSPOST_SOURCE_BY_CHANNEL_COLUMNS,
|
||||
primaryKey: ['source_channel_id', 'source_message_id'],
|
||||
partitionKey: ['source_channel_id'],
|
||||
});
|
||||
export const InstanceConfiguration = defineTable<InstanceConfigurationRow, 'key'>({
|
||||
name: 'instance_configuration',
|
||||
columns: INSTANCE_CONFIGURATION_COLUMNS,
|
||||
|
||||
@@ -41,7 +41,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
|
||||
import type {UserService} from '@app/api/user/services/UserService';
|
||||
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
|
||||
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export class AdminService {
|
||||
@@ -81,7 +80,6 @@ export class AdminService {
|
||||
private readonly applicationRepository: IApplicationRepository,
|
||||
private readonly stripe: Stripe | null = null,
|
||||
private readonly jobLedger: IJobLedgerRepository,
|
||||
private readonly ipInfoService: IpInfoService,
|
||||
private readonly storeEntitlementService: StoreEntitlementService,
|
||||
) {
|
||||
const {users, gateway, worker, snowflake} = this.apiContext.services;
|
||||
@@ -94,7 +92,6 @@ export class AdminService {
|
||||
apiContext: this.apiContext,
|
||||
adminRepository: this.adminRepository,
|
||||
auditService: this.auditService,
|
||||
ipInfoService: this.ipInfoService,
|
||||
});
|
||||
this.userService = new AdminUserService({
|
||||
apiContext: this.apiContext,
|
||||
@@ -181,20 +178,20 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async sendSystemDm(
|
||||
data: {content: string; userIds: Array<string>},
|
||||
data: {content: string; recipients: {kind: 'all'} | {kind: 'list'; userIds: Array<string>}},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SendSystemDmResponse> {
|
||||
const recipientCount = data.recipients.kind === 'all' ? null : data.recipients.userIds.length;
|
||||
await this.apiContext.services.worker.addJob(
|
||||
'sendSystemDm',
|
||||
{
|
||||
content: data.content,
|
||||
user_ids: data.userIds,
|
||||
},
|
||||
data.recipients.kind === 'all'
|
||||
? {content: data.content, all_users: true}
|
||||
: {content: data.content, user_ids: data.recipients.userIds},
|
||||
{requireLedger: true},
|
||||
);
|
||||
const metadata = new Map<string, string>([
|
||||
['recipient_count', data.userIds.length.toString()],
|
||||
['recipient_count', recipientCount === null ? 'all' : recipientCount.toString()],
|
||||
['content_length', data.content.length.toString()],
|
||||
]);
|
||||
await this.auditService.createAuditLog({
|
||||
@@ -205,6 +202,6 @@ export class AdminService {
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
return {recipient_count: data.userIds.length};
|
||||
return {recipient_count: recipientCount};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
tags: ['Admin'],
|
||||
requestSchema: AdminBlocklistEntryCreateRequest,
|
||||
description:
|
||||
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
|
||||
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SystemDmAdminController(app: HonoApp) {
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
|
||||
'Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
@@ -31,7 +31,12 @@ export function SystemDmAdminController(app: HonoApp) {
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const payload = ctx.req.valid('json');
|
||||
const result = await adminService.sendSystemDm(
|
||||
{content: payload.content, userIds: payload.user_ids.map((id) => id.toString())},
|
||||
{
|
||||
content: payload.content,
|
||||
recipients: payload.all_users
|
||||
? {kind: 'all'}
|
||||
: {kind: 'list', userIds: (payload.user_ids ?? []).map((id) => id.toString())},
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
|
||||
import {
|
||||
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
} from '@app/api/constants/ContentModeration';
|
||||
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
|
||||
import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache';
|
||||
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
@@ -34,13 +32,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
interface AdminBanManagementServiceDeps {
|
||||
apiContext: ApiContext;
|
||||
adminRepository: IAdminRepository;
|
||||
auditService: AdminAuditService;
|
||||
ipInfoService: IpInfoService;
|
||||
}
|
||||
|
||||
interface AdminBlocklistEntry {
|
||||
@@ -146,20 +142,6 @@ export class AdminBanManagementService {
|
||||
message: 'This IP address is on the instance exemption list',
|
||||
});
|
||||
}
|
||||
if (await this.shouldSkipIpBanForCgnat(data.ip)) {
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_ip_skipped_cgnat',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.IP_BAN_DECLINED,
|
||||
message: 'This IP address is a high blast-radius carrier network',
|
||||
});
|
||||
}
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
@@ -200,25 +182,6 @@ export class AdminBanManagementService {
|
||||
return {banned};
|
||||
}
|
||||
|
||||
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
|
||||
if (!isSingleIpBanCandidate(ip)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
|
||||
source: 'admin.ip_ban',
|
||||
reason: 'pre_write_cgnat_guard',
|
||||
});
|
||||
if (highRisk) {
|
||||
Logger.warn({ip}, 'Skipping IP ban because IPInfo indicates high CGNAT blast-radius risk');
|
||||
}
|
||||
return highRisk;
|
||||
} catch (error) {
|
||||
Logger.warn({error, ip}, 'IPInfo CGNAT guard failed while adding IP ban');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async banEmail(
|
||||
data: {
|
||||
email: string;
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {
|
||||
enqueueCrosspostFamilyPurgeFromCopies,
|
||||
enqueueCrosspostSourceRemoval,
|
||||
} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
@@ -127,15 +131,13 @@ export class AdminMessageService {
|
||||
|
||||
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {channelRepository, auditService} = this.deps;
|
||||
const {gateway: gatewayService} = this.deps.apiContext.services;
|
||||
const {gateway: gatewayService, worker: workerService} = this.deps.apiContext.services;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
const channel = await channelRepository.findUnique(channelId);
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
if (message) {
|
||||
if (message.attachments.length > 0) {
|
||||
await purgeMessageAttachments(message, getStorageService(), getPurgeQueue());
|
||||
}
|
||||
await purgeMessageAttachments(message, getStorageService(), getPurgeQueue());
|
||||
await channelRepository.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
@@ -166,6 +168,8 @@ export class AdminMessageService {
|
||||
}
|
||||
}
|
||||
await deleteMessageSearchDocuments([messageId], {context: {source: 'admin_message_delete'}});
|
||||
await enqueueCrosspostSourceRemoval(workerService, {messages: [message], mode: 'purge'});
|
||||
await enqueueCrosspostFamilyPurgeFromCopies(workerService, {messages: [message]});
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const ADMIN_ID = createUserID(42n);
|
||||
const EXEMPT_IP = '10.0.0.1';
|
||||
const CARRIER_IP = '198.51.100.7';
|
||||
const LOOKUP_FAILURE_IP = '203.0.113.9';
|
||||
|
||||
interface AuditCall {
|
||||
action: string;
|
||||
metadata: Map<string, string> | undefined;
|
||||
}
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip: CARRIER_IP,
|
||||
available: true,
|
||||
note: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test Carrier',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookupResult>) {
|
||||
const bannedIps: Array<string> = [];
|
||||
const auditCalls: Array<AuditCall> = [];
|
||||
const adminRepository = {
|
||||
banIp: async (ip: string) => {
|
||||
bannedIps.push(ip);
|
||||
},
|
||||
};
|
||||
const auditService = {
|
||||
createAuditLog: async ({action, metadata}: AuditCall) => {
|
||||
auditCalls.push({action, metadata});
|
||||
},
|
||||
};
|
||||
const ipInfoService = {lookup: (ip: string) => lookup(ip)};
|
||||
const apiContext = {
|
||||
services: {
|
||||
cache: {
|
||||
publish: async () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const service = new AdminBanManagementService({
|
||||
apiContext: apiContext as unknown as ApiContext,
|
||||
adminRepository: adminRepository as unknown as IAdminRepository,
|
||||
auditService: auditService as unknown as AdminAuditService,
|
||||
ipInfoService: ipInfoService as unknown as IpInfoService,
|
||||
});
|
||||
return {service, bannedIps, auditCalls};
|
||||
}
|
||||
|
||||
describe('AdminBanManagementService banIp guards', () => {
|
||||
let originalExemptIps: Array<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig();
|
||||
originalExemptIps = config.ipBanExemptIps;
|
||||
config.ipBanExemptIps = [EXEMPT_IP];
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ipBanCache.unban(LOOKUP_FAILURE_IP);
|
||||
getConfig().ipBanExemptIps = originalExemptIps;
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
it('refuses an exempt address with IP_BAN_DECLINED and writes no ban row', async () => {
|
||||
const {service, bannedIps, auditCalls} = createBanManagementService(async () => ipInfoResult());
|
||||
|
||||
const error = await service.banIp({ip: EXEMPT_IP}, ADMIN_ID, null).then(
|
||||
() => null,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
|
||||
expect((error as BadRequestError).status).toBe(400);
|
||||
expect(bannedIps).toEqual([]);
|
||||
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_exempt']);
|
||||
expect(auditCalls[0].metadata?.get('ip')).toBe(EXEMPT_IP);
|
||||
});
|
||||
|
||||
it('refuses a high blast-radius carrier address with IP_BAN_DECLINED and writes no ban row', async () => {
|
||||
const {service, bannedIps, auditCalls} = createBanManagementService(async () =>
|
||||
ipInfoResult({
|
||||
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await service.banIp({ip: CARRIER_IP}, ADMIN_ID, null).then(
|
||||
() => null,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
|
||||
expect((error as BadRequestError).status).toBe(400);
|
||||
expect(bannedIps).toEqual([]);
|
||||
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_cgnat']);
|
||||
expect(auditCalls[0].metadata?.get('ip')).toBe(CARRIER_IP);
|
||||
});
|
||||
|
||||
it('still writes the ban when the IPInfo lookup fails', async () => {
|
||||
const {service, bannedIps, auditCalls} = createBanManagementService(async () => {
|
||||
throw new Error('ipinfo is unreachable');
|
||||
});
|
||||
|
||||
await expect(service.banIp({ip: LOOKUP_FAILURE_IP}, ADMIN_ID, null)).resolves.toBeUndefined();
|
||||
|
||||
expect(bannedIps).toEqual([LOOKUP_FAILURE_IP]);
|
||||
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip']);
|
||||
});
|
||||
});
|
||||
@@ -10,83 +10,24 @@ import {
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
function createUniqueTestIp(): string {
|
||||
return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`;
|
||||
}
|
||||
|
||||
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
available: true,
|
||||
note: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test ISP',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Admin Deletion Queue', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
setInjectedIpInfoService({
|
||||
async lookup(ip: string) {
|
||||
return ipInfoResult(ip);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
setInjectedIpInfoService(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import type {CrosspostWorkerService} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {GuildMemberOperationsService} from '@app/api/guild/services/member/GuildMemberOperationsService';
|
||||
@@ -37,6 +38,7 @@ function createMessageDeletionService(): UserMessageDeletionService {
|
||||
gatewayService: unusable as IGatewayService,
|
||||
storageService: unusable as IStorageService,
|
||||
purgeQueue: unusable as IPurgeQueue,
|
||||
workerService: unusable as CrosspostWorkerService,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -168,12 +168,17 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
|
||||
return false;
|
||||
}
|
||||
|
||||
type CredentialTransport = 'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid';
|
||||
|
||||
const ALL_CREDENTIAL_TRANSPORTS: Array<CredentialTransport> = ['internal', 'hybrid', 'usb', 'nfc', 'ble'];
|
||||
|
||||
function toCredentialDescriptor(credential: WebAuthnCredential) {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
transports:
|
||||
credential.transports && credential.transports.size > 0
|
||||
? (Array.from(credential.transports) as Array<CredentialTransport>)
|
||||
: ALL_CREDENTIAL_TRANSPORTS,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('WebAuthn MFA login', () => {
|
||||
expect(mfaOptions.userVerification).toBe('discouraged');
|
||||
expect(mfaOptions.allowCredentials).toBeTruthy();
|
||||
expect(mfaOptions.allowCredentials!.length).toBeGreaterThan(0);
|
||||
expect(mfaOptions.allowCredentials![0]!.transports).toEqual(['internal']);
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
@@ -87,6 +88,48 @@ describe('WebAuthn MFA login', () => {
|
||||
.execute();
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
it('offers every transport for a passkey registered without transports', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
device.transports = null;
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
const loginResp = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: loginResp.ticket})
|
||||
.execute();
|
||||
expect(mfaOptions.allowCredentials).toEqual([
|
||||
{
|
||||
id: device.credentialId.toString('base64url'),
|
||||
type: 'public-key',
|
||||
transports: ['internal', 'hybrid', 'usb', 'nfc', 'ble'],
|
||||
},
|
||||
]);
|
||||
const webauthnMfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: loginResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(webauthnMfaLogin.token).toBeTruthy();
|
||||
});
|
||||
it('issues a session token instead of an MFA ticket when passkey two-factor is left off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface WebAuthnDevice {
|
||||
rpId: string;
|
||||
origin: string;
|
||||
signCount: number;
|
||||
transports?: Array<string> | null;
|
||||
}
|
||||
|
||||
export interface WebAuthnRegistrationOptions {
|
||||
@@ -47,6 +48,7 @@ export interface WebAuthnAuthenticationOptions {
|
||||
allowCredentials?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
transports?: Array<string>;
|
||||
}>;
|
||||
userVerification: string;
|
||||
}
|
||||
@@ -75,7 +77,7 @@ export interface WebAuthnTwoFactorResult {
|
||||
interface AuthenticatorAttestationResponse {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
transports: Array<string>;
|
||||
transports?: Array<string>;
|
||||
}
|
||||
|
||||
interface AuthenticatorAssertionResponse {
|
||||
@@ -363,7 +365,7 @@ export function createRegistrationResponse(
|
||||
response: {
|
||||
clientDataJSON: encodeBase64URL(clientDataJSON),
|
||||
attestationObject: encodeBase64URL(attestationObject),
|
||||
transports: ['internal'],
|
||||
...(device.transports === null ? {} : {transports: device.transports ?? ['internal']}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
interface IpBanBlastRadiusVerdict {
|
||||
cgnat: boolean;
|
||||
sharedAccess: boolean;
|
||||
}
|
||||
|
||||
interface CachedVerdict {
|
||||
expiresAtMs: number;
|
||||
verdict: IpBanBlastRadiusVerdict;
|
||||
}
|
||||
|
||||
const verdictCache = new Map<string, CachedVerdict>();
|
||||
|
||||
function isAnonymousAccess(result: IpInfoLookupResult): boolean {
|
||||
return (
|
||||
result.anonymous.isAnonymous ||
|
||||
result.anonymous.isVpn ||
|
||||
result.anonymous.isProxy ||
|
||||
result.anonymous.isResidentialProxy ||
|
||||
result.anonymous.isTor ||
|
||||
result.anonymous.isRelay
|
||||
);
|
||||
}
|
||||
|
||||
export function isHighCgnatBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
if (!result.available || result.flags.isHosting || isAnonymousAccess(result)) {
|
||||
return false;
|
||||
}
|
||||
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
|
||||
return result.flags.isMobile || result.mobile.name !== null || asnType === 'mobile';
|
||||
}
|
||||
|
||||
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
if (result.flags.isHosting || isAnonymousAccess(result)) {
|
||||
return false;
|
||||
}
|
||||
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
|
||||
return result.flags.isAnycast || result.flags.isSatellite || asnType === 'education';
|
||||
}
|
||||
|
||||
export function isSingleIpBanCandidate(value: string): boolean {
|
||||
return parseIpBanEntry(value)?.type === 'single';
|
||||
}
|
||||
|
||||
export async function getIpBanBlastRadiusVerdict(
|
||||
ip: string,
|
||||
ipInfoService: IpInfoService,
|
||||
context: {
|
||||
source: string;
|
||||
reason: string;
|
||||
},
|
||||
): Promise<IpBanBlastRadiusVerdict> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
|
||||
const cached = verdictCache.get(cacheKey);
|
||||
if (cached && cached.expiresAtMs > now) {
|
||||
return cached.verdict;
|
||||
}
|
||||
const result = await ipInfoService.lookup(ip, {
|
||||
source: context.source,
|
||||
reason: context.reason,
|
||||
metadata: {policy: 'ip_ban_cgnat_guard'},
|
||||
});
|
||||
const verdict: IpBanBlastRadiusVerdict = {
|
||||
cgnat: isHighCgnatBlastRadiusRisk(result),
|
||||
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
|
||||
};
|
||||
verdictCache.set(cacheKey, {
|
||||
verdict,
|
||||
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
|
||||
});
|
||||
return verdict;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getDefaultCassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import {createCassandraIpInfoCache} from '@pkgs/geoip/src/CassandraIpInfoCache';
|
||||
import {createCassandraIpInfoRequestAuditLogger} from '@pkgs/geoip/src/CassandraIpInfoRequestAudit';
|
||||
import {type IpInfoCache, type IpInfoRequestAuditLogger, isCachedIpInfoFailure} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createPostgresIpInfoCache, createPostgresIpInfoRequestAuditLogger} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
|
||||
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
|
||||
|
||||
interface BuildIpInfoCacheOptions {
|
||||
hot: IpInfoCache;
|
||||
}
|
||||
|
||||
export function buildIpInfoCache(options: BuildIpInfoCacheOptions): IpInfoCache {
|
||||
if (Config.database.backend === 'postgres') {
|
||||
return createTieredIpInfoCache({
|
||||
hot: options.hot,
|
||||
cold: createPostgresIpInfoCache({
|
||||
getClient: getDefaultPostgresClient,
|
||||
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo cache operation failed'),
|
||||
}),
|
||||
skipColdWrite: isCachedIpInfoFailure,
|
||||
});
|
||||
}
|
||||
return createTieredIpInfoCache({
|
||||
hot: options.hot,
|
||||
cold: createCassandraIpInfoCache({getClient: getDefaultCassandraClient}),
|
||||
skipColdWrite: isCachedIpInfoFailure,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildIpInfoRequestAuditLogger(): IpInfoRequestAuditLogger {
|
||||
if (Config.database.backend === 'postgres') {
|
||||
return createPostgresIpInfoRequestAuditLogger({
|
||||
getClient: getDefaultPostgresClient,
|
||||
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo audit operation failed'),
|
||||
});
|
||||
}
|
||||
return createCassandraIpInfoRequestAuditLogger({
|
||||
getClient: getDefaultCassandraClient,
|
||||
});
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
isHighCgnatBlastRadiusRisk,
|
||||
isHighSharedAccessBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '@app/api/ban/IpBanCgnatGuard';
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip: '198.51.100.1',
|
||||
available: true,
|
||||
note: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test ISP',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('IpBanCgnatGuard', () => {
|
||||
it('only treats single IP ban entries as CGNAT guard candidates', () => {
|
||||
expect(isSingleIpBanCandidate('198.51.100.10')).toBe(true);
|
||||
expect(isSingleIpBanCandidate('198.51.100.0/24')).toBe(false);
|
||||
});
|
||||
it('flags mobile carrier IPs as high blast-radius risk', () => {
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not exempt hosting or anonymous infrastructure', () => {
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: true, isMobile: true, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
anonymous: {
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not flag ordinary residential networks as shared-access risk', () => {
|
||||
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
|
||||
});
|
||||
it('does not treat shared-access networks as CGNAT risk', () => {
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
anonymous: {
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import type {
|
||||
CachedIpInfoFailure,
|
||||
IpInfoCache,
|
||||
IpInfoRequestAuditEvent,
|
||||
IpInfoRequestAuditLogger,
|
||||
} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createIpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {delay, HttpResponse, http} from 'msw';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface RecordedSet {
|
||||
key: string;
|
||||
value: unknown;
|
||||
ttlSeconds: number | undefined;
|
||||
}
|
||||
|
||||
interface RecordingCache {
|
||||
cache: IpInfoCache;
|
||||
sets: Array<RecordedSet>;
|
||||
}
|
||||
|
||||
function createRecordingCache(): RecordingCache {
|
||||
const store = new Map<string, unknown>();
|
||||
const sets: Array<RecordedSet> = [];
|
||||
return {
|
||||
sets,
|
||||
cache: {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return (store.get(key) as T | undefined) ?? null;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
store.set(key, value);
|
||||
sets.push({key, value, ttlSeconds});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRecordingAuditLogger(): {logger: IpInfoRequestAuditLogger; events: Array<IpInfoRequestAuditEvent>} {
|
||||
const events: Array<IpInfoRequestAuditEvent> = [];
|
||||
return {
|
||||
events,
|
||||
logger: {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
events.push(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function useLookupHandler(handler: () => Response | Promise<Response>): {count: () => number} {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('https://api.ipinfo.io/lookup/:ip', async () => {
|
||||
calls += 1;
|
||||
return await handler();
|
||||
}),
|
||||
);
|
||||
return {count: () => calls};
|
||||
}
|
||||
|
||||
function successPayload(ip: string, anonymous: Record<string, boolean> = {}): Response {
|
||||
return HttpResponse.json({
|
||||
ip,
|
||||
geo: {country_code: 'US', country: 'United States'},
|
||||
as: {asn: 'AS64500', name: 'Test ISP'},
|
||||
anonymous,
|
||||
});
|
||||
}
|
||||
|
||||
describe('IpInfoService caching', () => {
|
||||
it('negative-caches an HTTP error and serves the second lookup without a request', async () => {
|
||||
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const first = await service.lookup('203.0.113.1');
|
||||
const second = await service.lookup('203.0.113.1');
|
||||
|
||||
expect(first.available).toBe(false);
|
||||
expect(second.available).toBe(false);
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(sets[0]?.ttlSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it('negative-caches a request failure for a short window', async () => {
|
||||
useLookupHandler(async () => {
|
||||
await delay(5000);
|
||||
return successPayload('203.0.113.2');
|
||||
});
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const result = await service.lookup('203.0.113.2');
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(sets[0]?.ttlSeconds).toBe(60);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('request_failed');
|
||||
});
|
||||
|
||||
it('negative-caches a schema mismatch', async () => {
|
||||
useLookupHandler(() => HttpResponse.json({}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const result = await service.lookup('203.0.113.3');
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(sets[0]?.ttlSeconds).toBe(600);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('schema_mismatch');
|
||||
expect((sets[0]?.value as CachedIpInfoFailure)?.failureHttpStatus).toBe(200);
|
||||
});
|
||||
|
||||
it('negative-caches a quota rejection for longer', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 429}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.4');
|
||||
|
||||
expect(sets[0]?.ttlSeconds).toBe(900);
|
||||
});
|
||||
|
||||
it('returns a cached failure as a clean unavailable result', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.6');
|
||||
const cached = await service.lookup('203.0.113.6');
|
||||
|
||||
expect(cached).not.toHaveProperty('cachedFailure');
|
||||
expect(cached).not.toHaveProperty('failureOutcome');
|
||||
expect(cached).not.toHaveProperty('failureHttpStatus');
|
||||
expect(cached).not.toHaveProperty('cachedAtMs');
|
||||
expect(cached.ip).toBe('203.0.113.6');
|
||||
expect(cached.note).toBe('IPInfo HTTP 500');
|
||||
});
|
||||
|
||||
it('writes a cached failure that older readers can still consume', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.7');
|
||||
|
||||
const entry = sets[0]?.value as CachedIpInfoFailure;
|
||||
expect(entry.cachedFailure).toBe(true);
|
||||
expect(entry.failureOutcome).toBe('http_error');
|
||||
expect(entry.failureHttpStatus).toBe(500);
|
||||
expect(typeof entry.cachedAtMs).toBe('number');
|
||||
const legacyView = {...entry, ip: '203.0.113.7'};
|
||||
expect(legacyView.available).toBe(false);
|
||||
expect(legacyView.geo.countryCode).toBeNull();
|
||||
expect(legacyView.asn.number).toBeNull();
|
||||
expect(legacyView.mobile.name).toBeNull();
|
||||
expect(legacyView.anonymous.isAnonymous).toBe(false);
|
||||
expect(legacyView.flags.isMobile).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the existing success TTL selection', async () => {
|
||||
useLookupHandler(() => successPayload('203.0.113.8'));
|
||||
const plain = createRecordingCache();
|
||||
await createIpInfoService({apiKey: 'token', cache: plain.cache}).lookup('203.0.113.8');
|
||||
|
||||
useLookupHandler(() => successPayload('203.0.113.9', {is_vpn: true}));
|
||||
const anonymous = createRecordingCache();
|
||||
await createIpInfoService({apiKey: 'token', cache: anonymous.cache}).lookup('203.0.113.9');
|
||||
|
||||
expect(plain.sets[0]?.ttlSeconds).toBe(14 * 24 * 60 * 60);
|
||||
expect(anonymous.sets[0]?.ttlSeconds).toBe(7 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('coalesces concurrent lookups across a failure', async () => {
|
||||
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const [first, second] = await Promise.all([service.lookup('203.0.113.10'), service.lookup('203.0.113.10')]);
|
||||
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(first.available).toBe(false);
|
||||
expect(second.available).toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces concurrent lookups from different sources into one audited request', async () => {
|
||||
const requests = useLookupHandler(() => successPayload('203.0.113.13'));
|
||||
const {cache} = createRecordingCache();
|
||||
const {logger, events} = createRecordingAuditLogger();
|
||||
const service = createIpInfoService({apiKey: 'token', cache, auditLogger: logger});
|
||||
|
||||
const results = await Promise.all([
|
||||
service.lookup('203.0.113.13', {source: 'admin.ip_ban', reason: 'ban'}),
|
||||
service.lookup('203.0.113.13', {source: 'test.b'}),
|
||||
service.lookup('203.0.113.13', {source: 'test.c'}),
|
||||
]);
|
||||
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(results.every((result) => result.available)).toBe(true);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.source).toBe('admin.ip_ban');
|
||||
expect(events[0]?.outcome).toBe('http_success');
|
||||
expect(events[0]?.note).toBe('IPInfo: IP is not anonymous');
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {
|
||||
createPostgresIpInfoCache,
|
||||
createPostgresIpInfoRequestAuditLogger,
|
||||
IPINFO_CACHE_TTL_SECONDS,
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function recordingClient(writes: Array<Array<unknown>>): IPostgresClient {
|
||||
return {
|
||||
async query(_text: string, values?: Array<unknown>) {
|
||||
writes.push(values ?? []);
|
||||
return {rows: [], rowCount: 1};
|
||||
},
|
||||
kvTable() {
|
||||
return 'kv';
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function expectExpiresIn(values: Array<unknown> | undefined, ttlSeconds: number): void {
|
||||
const expiresAt = values?.[4];
|
||||
expect(expiresAt).toBeInstanceOf(Date);
|
||||
const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000;
|
||||
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10);
|
||||
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
|
||||
}
|
||||
|
||||
const EVENT: IpInfoRequestAuditEvent = {
|
||||
requestedAt: new Date('2026-09-21T12:00:00.000Z'),
|
||||
ip: '192.0.2.1',
|
||||
cacheKey: 'ip:192.0.2.1',
|
||||
source: 'test',
|
||||
reason: null,
|
||||
outcome: 'http_success',
|
||||
httpStatus: 200,
|
||||
available: true,
|
||||
note: 'none',
|
||||
latencyMs: 12,
|
||||
requestUrl: 'https://ipinfo.test/192.0.2.1',
|
||||
responseIp: '192.0.2.1',
|
||||
countryCode: 'SE',
|
||||
asnNumber: 64500,
|
||||
isAnonymous: false,
|
||||
isTor: false,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
};
|
||||
|
||||
describe('Postgres ipinfo KV expiry', () => {
|
||||
it('expires request audit rows after 90 days', async () => {
|
||||
const writes: Array<Array<unknown>> = [];
|
||||
await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour');
|
||||
expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it('falls back to the 14-day cache default', async () => {
|
||||
const writes: Array<Array<unknown>> = [];
|
||||
const cache = createPostgresIpInfoCache({client: recordingClient(writes)});
|
||||
await cache.set('fallback', {ok: true});
|
||||
await cache.set('zero', {ok: true}, 0);
|
||||
await cache.set('short', {ok: true}, 60);
|
||||
expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']);
|
||||
expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS);
|
||||
expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS);
|
||||
expectExpiresIn(writes[2], 60);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface RecordedSet {
|
||||
key: string;
|
||||
value: unknown;
|
||||
ttlSeconds: number | undefined;
|
||||
}
|
||||
|
||||
interface RecordingCache {
|
||||
cache: IpInfoCache;
|
||||
store: Map<string, unknown>;
|
||||
sets: Array<RecordedSet>;
|
||||
}
|
||||
|
||||
function createRecordingCache(): RecordingCache {
|
||||
const store = new Map<string, unknown>();
|
||||
const sets: Array<RecordedSet> = [];
|
||||
return {
|
||||
store,
|
||||
sets,
|
||||
cache: {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return (store.get(key) as T | undefined) ?? null;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
store.set(key, value);
|
||||
sets.push({key, value, ttlSeconds});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TieredIpInfoCache', () => {
|
||||
it('clamps the hot TTL to the requested TTL and passes the raw TTL to the cold tier', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: false}, 60);
|
||||
|
||||
expect(hot.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
|
||||
expect(cold.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
|
||||
});
|
||||
|
||||
it('caps the hot TTL at the configured hot window', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: true}, 100000);
|
||||
|
||||
expect(hot.sets[0]?.ttlSeconds).toBe(600);
|
||||
expect(cold.sets[0]?.ttlSeconds).toBe(100000);
|
||||
});
|
||||
|
||||
it('uses the hot window when no TTL is supplied', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: true});
|
||||
|
||||
expect(hot.sets[0]?.ttlSeconds).toBe(600);
|
||||
expect(cold.sets[0]?.ttlSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips the cold write when skipColdWrite matches', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({
|
||||
hot: hot.cache,
|
||||
cold: cold.cache,
|
||||
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
|
||||
});
|
||||
|
||||
await tiered.set('a', {available: false}, 60);
|
||||
await tiered.set('b', {available: true}, 60);
|
||||
|
||||
expect(hot.sets.map((entry) => entry.key)).toEqual(['a', 'b']);
|
||||
expect(cold.sets.map((entry) => entry.key)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('promotes a cold hit into the hot tier', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
cold.store.set('a', {available: true});
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
const hit = await tiered.get('a');
|
||||
|
||||
expect(hit).toEqual({available: true});
|
||||
expect(hot.sets).toEqual([{key: 'a', value: {available: true}, ttlSeconds: 600}]);
|
||||
});
|
||||
|
||||
it('never promotes a cold hit that skipColdWrite matches', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
cold.store.set('a', {available: false});
|
||||
const tiered = createTieredIpInfoCache({
|
||||
hot: hot.cache,
|
||||
cold: cold.cache,
|
||||
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
|
||||
});
|
||||
|
||||
const hit = await tiered.get('a');
|
||||
|
||||
expect(hit).toEqual({available: false});
|
||||
expect(hot.sets).toEqual([]);
|
||||
});
|
||||
|
||||
it('never writes a zero TTL', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: false}, 60);
|
||||
await tiered.set('b', {available: true}, 100000);
|
||||
await tiered.set('c', {available: true});
|
||||
cold.store.set('d', {available: true});
|
||||
await tiered.get('d');
|
||||
|
||||
for (const entry of [...hot.sets, ...cold.sets]) {
|
||||
expect(entry.ttlSeconds === undefined || entry.ttlSeconds > 0).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -196,6 +196,7 @@ export async function mapChannelToResponse(params: MapChannelToResponseParams):
|
||||
let response: ChannelResponse;
|
||||
switch (channel.type) {
|
||||
case ChannelTypes.GUILD_TEXT:
|
||||
case ChannelTypes.GUILD_ANNOUNCEMENT:
|
||||
response = serializeGuildTextChannel(channel, ctx);
|
||||
break;
|
||||
case ChannelTypes.GUILD_VOICE:
|
||||
|
||||
@@ -30,6 +30,10 @@ export class ChannelRepository extends IChannelRepository {
|
||||
return this.repository.messageInteractions;
|
||||
}
|
||||
|
||||
get crossposts() {
|
||||
return this.repository.crossposts;
|
||||
}
|
||||
|
||||
async findUnique(channelId: ChannelID): Promise<Channel | null> {
|
||||
return this.repository.channelData.findUnique(channelId);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {CLIENT_FEATURES_HEADER, parseClientFeaturesHeader} from '@app/api/utils/featureUtils';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {
|
||||
@@ -136,7 +138,19 @@ export function ChannelController(app: HonoApp) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
const body = isPlainObject(raw) ? raw : {};
|
||||
return {...body, type: channelType};
|
||||
const requestedType = body.type;
|
||||
if (
|
||||
requestedType === undefined ||
|
||||
requestedType === null ||
|
||||
requestedType === channelType ||
|
||||
!ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES.has(channelType)
|
||||
) {
|
||||
return {...body, type: channelType};
|
||||
}
|
||||
if (typeof requestedType !== 'number' || !ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES.has(requestedType)) {
|
||||
throw new ChannelTypeConversionNotSupportedError();
|
||||
}
|
||||
return {...body, type: requestedType};
|
||||
},
|
||||
}),
|
||||
OpenAPI({
|
||||
@@ -154,6 +168,9 @@ export function ChannelController(app: HonoApp) {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const existingType = ctx.get('channelUpdateType');
|
||||
const typeConversion =
|
||||
existingType !== undefined && data.type !== existingType ? {from: existingType, to: data.type} : null;
|
||||
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
@@ -166,6 +183,7 @@ export function ChannelController(app: HonoApp) {
|
||||
clientFeatures,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createChannelID} from '@app/api/BrandedTypes';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
ChannelFollowerStatsResponse,
|
||||
ChannelFollowRequest,
|
||||
FollowedChannelResponse,
|
||||
} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
|
||||
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function ChannelFollowController(app: HonoApp) {
|
||||
app.post(
|
||||
'/channels/:channel_id/followers',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_FOLLOW),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('json', ChannelFollowRequest),
|
||||
OpenAPI({
|
||||
operationId: 'follow_channel',
|
||||
summary: 'Follow an announcement channel',
|
||||
description:
|
||||
'Follows an announcement channel into a text channel. Creates a channel follower webhook in the target channel that receives every message published in the announcement channel. Requires Manage Webhooks in the target channel and View Channel on the announcement channel.',
|
||||
requestSchema: ChannelFollowRequest,
|
||||
responseSchema: FollowedChannelResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const followed = await ctx.get('channelFollowService').followChannel({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(ctx.req.valid('param').channel_id),
|
||||
webhookChannelId: createChannelID(ctx.req.valid('json').webhook_channel_id),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
auditLogReason: ctx.get('auditLogReason') ?? null,
|
||||
});
|
||||
return ctx.json({
|
||||
channel_id: followed.channelId.toString(),
|
||||
webhook_id: followed.webhookId.toString(),
|
||||
} satisfies FollowedChannelResponse);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/channels/:channel_id/follower-stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_FOLLOWER_STATS),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_channel_follower_stats',
|
||||
summary: 'Get announcement channel follower stats',
|
||||
description:
|
||||
'Returns how many channels and distinct guilds follow an announcement channel. Requires View Channel on the announcement channel.',
|
||||
responseSchema: ChannelFollowerStatsResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const stats = await ctx.get('channelFollowService').getFollowerStats({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(ctx.req.valid('param').channel_id),
|
||||
});
|
||||
return ctx.json(stats);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
PresignedAttachmentUploadRequest,
|
||||
PresignedAttachmentUploadResponse,
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentUploadSchemas';
|
||||
import {CrosspostSourceResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas';
|
||||
import {
|
||||
BulkDeleteMessagesRequest,
|
||||
BulkMessageFetchRequest,
|
||||
@@ -503,6 +504,60 @@ export function MessageController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/:message_id/crosspost',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CROSSPOST),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'crosspost_message',
|
||||
summary: 'Publish a message to following channels',
|
||||
responseSchema: MessageResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Publishes a message in an announcement channel to every channel that follows it. The author needs Send Messages. Anyone else needs Send Messages and Manage Messages. Only default messages that are not replies, forwards or copies can be published, and each message can be published once. Copies are delivered asynchronously. Publishing is limited per channel (10 in a row, then one every 6 minutes) and per community (30 in a row, then one every 2 minutes). Returns the updated message with the CROSSPOSTED flag set.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await ctx.get('messageRequestService').crosspostMessage({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(channel_id),
|
||||
messageId: createMessageID(message_id),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/channels/:channel_id/messages/:message_id/crosspost-source',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CROSSPOST_SOURCE),
|
||||
LoginRequired,
|
||||
Validator('param', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_message_crosspost_source',
|
||||
summary: 'Get the source community of a published message copy',
|
||||
responseSchema: CrosspostSourceResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Channels', 'Messages'],
|
||||
description:
|
||||
'Returns the public profile of the community a message copy was published from. Works on copies delivered to a following channel and on the system message posted when a channel starts following. Needs the same access as fetching the message. The response holds the community name, icon, banner, badge features, approximate counts and whether it can be joined through discovery.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await ctx.get('messageRequestService').getCrosspostSource({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(channel_id),
|
||||
messageId: createMessageID(message_id),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/:message_id/ack',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_ACK),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {CallController} from '@app/api/channel/controllers/CallController';
|
||||
import {ChannelController} from '@app/api/channel/controllers/ChannelController';
|
||||
import {ChannelFollowController} from '@app/api/channel/controllers/ChannelFollowController';
|
||||
import {MessageController} from '@app/api/channel/controllers/MessageController';
|
||||
import {MessageInteractionController} from '@app/api/channel/controllers/MessageInteractionController';
|
||||
import {StreamController} from '@app/api/channel/controllers/StreamController';
|
||||
@@ -9,6 +10,7 @@ import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
|
||||
export function registerChannelControllers(app: HonoApp) {
|
||||
ChannelController(app);
|
||||
ChannelFollowController(app);
|
||||
MessageInteractionController(app);
|
||||
MessageController(app);
|
||||
CallController(app);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
|
||||
import {CrosspostedMessageRepository} from '@app/api/channel/repositories/CrosspostedMessageRepository';
|
||||
import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {MessageInteractionRepository} from '@app/api/channel/repositories/MessageInteractionRepository';
|
||||
import {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
|
||||
@@ -10,11 +11,13 @@ export class ChannelRepository extends IChannelRepositoryAggregate {
|
||||
readonly channelData: ChannelDataRepository;
|
||||
readonly messages: MessageRepository;
|
||||
readonly messageInteractions: MessageInteractionRepository;
|
||||
readonly crossposts: CrosspostedMessageRepository;
|
||||
|
||||
constructor(requestCache?: RequestCache) {
|
||||
super();
|
||||
this.channelData = new ChannelDataRepository(requestCache);
|
||||
this.messages = new MessageRepository(this.channelData);
|
||||
this.messageInteractions = new MessageInteractionRepository(this.messages);
|
||||
this.crossposts = new CrosspostedMessageRepository();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createChannelID, createGuildID, createMessageID, createWebhookID, type MessageID} from '@app/api/BrandedTypes';
|
||||
import {ChannelRepository} from '@app/api/channel/repositories/ChannelRepository';
|
||||
import {CrosspostedMessageRepository} from '@app/api/channel/repositories/CrosspostedMessageRepository';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {CrosspostedMessageRow} from '@app/api/database/types/ChannelTypes';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const SOURCE_CHANNEL = createChannelID(10n);
|
||||
const SOURCE_MESSAGE = createMessageID(100n);
|
||||
const WEBHOOK = createWebhookID(500n);
|
||||
const KEY = {sourceMessageId: SOURCE_MESSAGE, webhookId: WEBHOOK};
|
||||
|
||||
let executor: InMemoryCassandraQueryExecutor;
|
||||
let repository: CrosspostedMessageRepository;
|
||||
|
||||
function pendingRow(overrides: Partial<CrosspostedMessageRow> = {}): CrosspostedMessageRow {
|
||||
return {
|
||||
source_message_id: SOURCE_MESSAGE,
|
||||
webhook_id: WEBHOOK,
|
||||
source_channel_id: SOURCE_CHANNEL,
|
||||
target_guild_id: createGuildID(20n),
|
||||
target_channel_id: createChannelID(30n),
|
||||
target_message_id: createMessageID(1000n),
|
||||
state: 'pending',
|
||||
reserved_at: new Date('2026-09-30T12:00:00.000Z'),
|
||||
source_fingerprint: null,
|
||||
created_at: new Date('2026-09-30T12:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CrosspostedMessageRepository', () => {
|
||||
beforeEach(() => {
|
||||
executor = new InMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
repository = new CrosspostedMessageRepository();
|
||||
});
|
||||
afterEach(() => {
|
||||
executor.reset();
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
it('is exposed on the channel repository aggregate', () => {
|
||||
expect(new ChannelRepository().crossposts).toBeInstanceOf(CrosspostedMessageRepository);
|
||||
});
|
||||
|
||||
it('returns null for a pair that was never reserved', async () => {
|
||||
expect(await repository.get(SOURCE_MESSAGE, WEBHOOK)).toBeNull();
|
||||
});
|
||||
|
||||
it('inserts a pending row once and refuses a second reservation', async () => {
|
||||
expect(await repository.insertPending(pendingRow())).toBe(true);
|
||||
expect(await repository.insertPending(pendingRow({target_message_id: createMessageID(2000n)}))).toBe(false);
|
||||
const row = await repository.get(SOURCE_MESSAGE, WEBHOOK);
|
||||
expect(row?.state).toBe('pending');
|
||||
expect(row?.target_message_id).toBe(1000n);
|
||||
expect(row?.target_guild_id).toBe(20n);
|
||||
expect(row?.target_channel_id).toBe(30n);
|
||||
expect(row?.source_channel_id).toBe(10n);
|
||||
});
|
||||
|
||||
it('always stores a reservation as pending', async () => {
|
||||
expect(await repository.insertPending(pendingRow({state: 'delivered'}))).toBe(true);
|
||||
expect((await repository.get(SOURCE_MESSAGE, WEBHOOK))?.state).toBe('pending');
|
||||
});
|
||||
|
||||
it('reclaims a pending row only from the expected target id', async () => {
|
||||
await repository.insertPending(pendingRow());
|
||||
const reservedAt = new Date('2026-09-30T12:05:00.000Z');
|
||||
expect(
|
||||
await repository.reclaimPending(KEY, {
|
||||
fromTargetMessageId: createMessageID(999n),
|
||||
toTargetMessageId: createMessageID(2000n),
|
||||
reservedAt,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await repository.reclaimPending(KEY, {
|
||||
fromTargetMessageId: createMessageID(1000n),
|
||||
toTargetMessageId: createMessageID(2000n),
|
||||
reservedAt,
|
||||
}),
|
||||
).toBe(true);
|
||||
const row = await repository.get(SOURCE_MESSAGE, WEBHOOK);
|
||||
expect(row?.target_message_id).toBe(2000n);
|
||||
expect(row?.reserved_at.getTime()).toBe(reservedAt.getTime());
|
||||
expect(row?.state).toBe('pending');
|
||||
});
|
||||
|
||||
it('does not reclaim a delivered row', async () => {
|
||||
await repository.insertPending(pendingRow());
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'fp0',
|
||||
});
|
||||
expect(
|
||||
await repository.reclaimPending(KEY, {
|
||||
fromTargetMessageId: createMessageID(1000n),
|
||||
toTargetMessageId: createMessageID(2000n),
|
||||
reservedAt: new Date(),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect((await repository.get(SOURCE_MESSAGE, WEBHOOK))?.target_message_id).toBe(1000n);
|
||||
});
|
||||
|
||||
it('marks delivered only when the target id still matches', async () => {
|
||||
await repository.insertPending(pendingRow());
|
||||
await repository.reclaimPending(KEY, {
|
||||
fromTargetMessageId: createMessageID(1000n),
|
||||
toTargetMessageId: createMessageID(2000n),
|
||||
reservedAt: new Date(),
|
||||
});
|
||||
expect(
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'stale',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(2000n),
|
||||
sourceFingerprint: 'fp0',
|
||||
}),
|
||||
).toBe(true);
|
||||
const row = await repository.get(SOURCE_MESSAGE, WEBHOOK);
|
||||
expect(row?.state).toBe('delivered');
|
||||
expect(row?.source_fingerprint).toBe('fp0');
|
||||
});
|
||||
|
||||
it('marks delivered with a null fingerprint for a recovered pending copy', async () => {
|
||||
await repository.insertPending(pendingRow({source_fingerprint: 'reserved'}));
|
||||
expect(
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
const row = await repository.get(SOURCE_MESSAGE, WEBHOOK);
|
||||
expect(row?.state).toBe('delivered');
|
||||
expect(row?.source_fingerprint ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mark a missing row delivered', async () => {
|
||||
expect(
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'fp0',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(await repository.get(SOURCE_MESSAGE, WEBHOOK)).toBeNull();
|
||||
});
|
||||
|
||||
it('updates sync state only on a delivered row with the same target id', async () => {
|
||||
await repository.insertPending(pendingRow());
|
||||
expect(
|
||||
await repository.updateSynced(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'fp1',
|
||||
}),
|
||||
).toBe(false);
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'fp0',
|
||||
});
|
||||
expect(
|
||||
await repository.updateSynced(KEY, {
|
||||
targetMessageId: createMessageID(1001n),
|
||||
sourceFingerprint: 'fp1',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await repository.updateSynced(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'fp1',
|
||||
}),
|
||||
).toBe(true);
|
||||
const row = await repository.get(SOURCE_MESSAGE, WEBHOOK);
|
||||
expect(row?.source_fingerprint).toBe('fp1');
|
||||
expect(row?.state).toBe('delivered');
|
||||
});
|
||||
|
||||
it('pages rows for a source message in webhook id order', async () => {
|
||||
const webhookIds = [505n, 501n, 503n, 502n, 504n];
|
||||
for (const id of webhookIds) {
|
||||
await repository.insertPending(pendingRow({webhook_id: createWebhookID(id)}));
|
||||
}
|
||||
await repository.insertPending(
|
||||
pendingRow({source_message_id: createMessageID(101n), webhook_id: createWebhookID(506n)}),
|
||||
);
|
||||
const first = await repository.listBySourceMessage(SOURCE_MESSAGE, {limit: 2});
|
||||
expect(first.map((row) => row.webhook_id)).toEqual([501n, 502n]);
|
||||
const second = await repository.listBySourceMessage(SOURCE_MESSAGE, {
|
||||
afterWebhookId: first[first.length - 1]!.webhook_id,
|
||||
limit: 2,
|
||||
});
|
||||
expect(second.map((row) => row.webhook_id)).toEqual([503n, 504n]);
|
||||
const third = await repository.listBySourceMessage(SOURCE_MESSAGE, {
|
||||
afterWebhookId: second[second.length - 1]!.webhook_id,
|
||||
limit: 2,
|
||||
});
|
||||
expect(third.map((row) => row.webhook_id)).toEqual([505n]);
|
||||
});
|
||||
|
||||
it('deletes unconditionally without expected values', async () => {
|
||||
await repository.insertPending(pendingRow());
|
||||
expect(await repository.delete(KEY)).toBe(true);
|
||||
expect(await repository.get(SOURCE_MESSAGE, WEBHOOK)).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes conditionally only when the expected state and target match', async () => {
|
||||
await repository.insertPending(pendingRow());
|
||||
expect(await repository.delete(KEY, {state: 'delivered', target_message_id: createMessageID(1000n)})).toBe(false);
|
||||
expect(await repository.get(SOURCE_MESSAGE, WEBHOOK)).not.toBeNull();
|
||||
await repository.markDelivered(KEY, {
|
||||
targetMessageId: createMessageID(1000n),
|
||||
sourceFingerprint: 'fp0',
|
||||
});
|
||||
expect(await repository.delete(KEY, {state: 'delivered', target_message_id: createMessageID(999n)})).toBe(false);
|
||||
expect(await repository.delete(KEY, {state: 'delivered', target_message_id: createMessageID(1000n)})).toBe(true);
|
||||
expect(await repository.get(SOURCE_MESSAGE, WEBHOOK)).toBeNull();
|
||||
});
|
||||
|
||||
it('pages published sources by channel and removes them', async () => {
|
||||
const messageIds: Array<MessageID> = [103n, 101n, 102n].map((id) => createMessageID(id));
|
||||
for (const sourceMessageId of messageIds) {
|
||||
await repository.addSource({sourceChannelId: SOURCE_CHANNEL, sourceMessageId});
|
||||
}
|
||||
await repository.addSource({sourceChannelId: SOURCE_CHANNEL, sourceMessageId: createMessageID(101n)});
|
||||
await repository.addSource({sourceChannelId: createChannelID(11n), sourceMessageId: createMessageID(104n)});
|
||||
expect(await repository.listSourcesByChannel(SOURCE_CHANNEL, {limit: 2})).toEqual([101n, 102n]);
|
||||
expect(
|
||||
await repository.listSourcesByChannel(SOURCE_CHANNEL, {afterMessageId: createMessageID(102n), limit: 2}),
|
||||
).toEqual([103n]);
|
||||
await repository.deleteSource({sourceChannelId: SOURCE_CHANNEL, sourceMessageId: createMessageID(102n)});
|
||||
expect(await repository.listSourcesByChannel(SOURCE_CHANNEL, {limit: 10})).toEqual([101n, 103n]);
|
||||
expect(await repository.listSourcesByChannel(createChannelID(11n), {limit: 10})).toEqual([104n]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID, WebhookID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
type CrosspostedMessageKey,
|
||||
type CrosspostSource,
|
||||
type CrosspostSyncState,
|
||||
ICrosspostedMessageRepository,
|
||||
} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
|
||||
import {
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import type {CrosspostedMessageRow, CrosspostSourceByChannelRow} from '@app/api/database/types/ChannelTypes';
|
||||
import {CrosspostedMessages, CrosspostSourcesByChannel} from '@app/api/Tables';
|
||||
|
||||
const FETCH_CROSSPOSTED_MESSAGE_CQL = CrosspostedMessages.selectCql({
|
||||
where: [CrosspostedMessages.where.eq('source_message_id'), CrosspostedMessages.where.eq('webhook_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
function createBySourceMessageFirstPageQuery(limit: number) {
|
||||
return CrosspostedMessages.select({
|
||||
where: CrosspostedMessages.where.eq('source_message_id'),
|
||||
orderBy: {col: 'webhook_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
function createBySourceMessagePageQuery(limit: number) {
|
||||
return CrosspostedMessages.select({
|
||||
where: [CrosspostedMessages.where.eq('source_message_id'), CrosspostedMessages.where.gt('webhook_id')],
|
||||
orderBy: {col: 'webhook_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
function createSourcesByChannelFirstPageQuery(limit: number) {
|
||||
return CrosspostSourcesByChannel.select({
|
||||
columns: ['source_message_id'],
|
||||
where: CrosspostSourcesByChannel.where.eq('source_channel_id'),
|
||||
orderBy: {col: 'source_message_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
function createSourcesByChannelPageQuery(limit: number) {
|
||||
return CrosspostSourcesByChannel.select({
|
||||
columns: ['source_message_id'],
|
||||
where: [
|
||||
CrosspostSourcesByChannel.where.eq('source_channel_id'),
|
||||
CrosspostSourcesByChannel.where.gt('source_message_id'),
|
||||
],
|
||||
orderBy: {col: 'source_message_id', direction: 'ASC'},
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
function toPk(key: CrosspostedMessageKey): Pick<CrosspostedMessageRow, 'source_message_id' | 'webhook_id'> {
|
||||
return {source_message_id: key.sourceMessageId, webhook_id: key.webhookId};
|
||||
}
|
||||
|
||||
export class CrosspostedMessageRepository extends ICrosspostedMessageRepository {
|
||||
async get(sourceMessageId: MessageID, webhookId: WebhookID): Promise<CrosspostedMessageRow | null> {
|
||||
return fetchOne<CrosspostedMessageRow>(FETCH_CROSSPOSTED_MESSAGE_CQL, {
|
||||
source_message_id: sourceMessageId,
|
||||
webhook_id: webhookId,
|
||||
});
|
||||
}
|
||||
|
||||
async insertPending(row: CrosspostedMessageRow): Promise<boolean> {
|
||||
return executeConditional(CrosspostedMessages.insertIfNotExists({...row, state: 'pending'}));
|
||||
}
|
||||
|
||||
async reclaimPending(
|
||||
key: CrosspostedMessageKey,
|
||||
data: {
|
||||
fromTargetMessageId: MessageID;
|
||||
toTargetMessageId: MessageID;
|
||||
reservedAt: Date;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
return executeConditional(
|
||||
CrosspostedMessages.conditionalPatchByPk(
|
||||
toPk(key),
|
||||
{
|
||||
target_message_id: Db.set(data.toTargetMessageId),
|
||||
reserved_at: Db.set(data.reservedAt),
|
||||
},
|
||||
{state: 'pending', target_message_id: data.fromTargetMessageId},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async markDelivered(key: CrosspostedMessageKey, data: CrosspostSyncState): Promise<boolean> {
|
||||
return executeConditional(
|
||||
CrosspostedMessages.conditionalPatchByPk(
|
||||
toPk(key),
|
||||
{
|
||||
state: Db.set('delivered'),
|
||||
source_fingerprint: Db.set(data.sourceFingerprint),
|
||||
},
|
||||
{target_message_id: data.targetMessageId},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateSynced(key: CrosspostedMessageKey, data: CrosspostSyncState): Promise<boolean> {
|
||||
return executeConditional(
|
||||
CrosspostedMessages.conditionalPatchByPk(
|
||||
toPk(key),
|
||||
{source_fingerprint: Db.set(data.sourceFingerprint)},
|
||||
{state: 'delivered', target_message_id: data.targetMessageId},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listBySourceMessage(
|
||||
sourceMessageId: MessageID,
|
||||
options: {afterWebhookId?: WebhookID; limit: number},
|
||||
): Promise<Array<CrosspostedMessageRow>> {
|
||||
if (options.afterWebhookId !== undefined) {
|
||||
return fetchMany<CrosspostedMessageRow>(
|
||||
createBySourceMessagePageQuery(options.limit).bind({
|
||||
source_message_id: sourceMessageId,
|
||||
webhook_id: options.afterWebhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fetchMany<CrosspostedMessageRow>(
|
||||
createBySourceMessageFirstPageQuery(options.limit).bind({source_message_id: sourceMessageId}),
|
||||
);
|
||||
}
|
||||
|
||||
async delete(
|
||||
key: CrosspostedMessageKey,
|
||||
expected?: Partial<Pick<CrosspostedMessageRow, 'state' | 'target_message_id'>>,
|
||||
): Promise<boolean> {
|
||||
if (expected && Object.keys(expected).length > 0) {
|
||||
return executeConditional(CrosspostedMessages.conditionalDeleteByPk(toPk(key), expected));
|
||||
}
|
||||
await deleteOneOrMany(CrosspostedMessages.deleteByPk(toPk(key)));
|
||||
return true;
|
||||
}
|
||||
|
||||
async addSource(source: CrosspostSource): Promise<void> {
|
||||
await upsertOne(
|
||||
CrosspostSourcesByChannel.upsertAll({
|
||||
source_channel_id: source.sourceChannelId,
|
||||
source_message_id: source.sourceMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listSourcesByChannel(
|
||||
sourceChannelId: ChannelID,
|
||||
options: {afterMessageId?: MessageID; limit: number},
|
||||
): Promise<Array<MessageID>> {
|
||||
const rows =
|
||||
options.afterMessageId !== undefined
|
||||
? await fetchMany<Pick<CrosspostSourceByChannelRow, 'source_message_id'>>(
|
||||
createSourcesByChannelPageQuery(options.limit).bind({
|
||||
source_channel_id: sourceChannelId,
|
||||
source_message_id: options.afterMessageId,
|
||||
}),
|
||||
)
|
||||
: await fetchMany<Pick<CrosspostSourceByChannelRow, 'source_message_id'>>(
|
||||
createSourcesByChannelFirstPageQuery(options.limit).bind({source_channel_id: sourceChannelId}),
|
||||
);
|
||||
return rows.map((row) => row.source_message_id);
|
||||
}
|
||||
|
||||
async deleteSource(source: CrosspostSource): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
CrosspostSourcesByChannel.deleteByPk({
|
||||
source_channel_id: source.sourceChannelId,
|
||||
source_message_id: source.sourceMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
|
||||
import type {ICrosspostedMessageRepository} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
|
||||
import type {IMessageInteractionRepository} from '@app/api/channel/repositories/IMessageInteractionRepository';
|
||||
import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository';
|
||||
|
||||
@@ -8,4 +9,5 @@ export abstract class IChannelRepositoryAggregate {
|
||||
abstract readonly channelData: IChannelDataRepository;
|
||||
abstract readonly messages: IMessageRepository;
|
||||
abstract readonly messageInteractions: IMessageInteractionRepository;
|
||||
abstract readonly crossposts: ICrosspostedMessageRepository;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID, WebhookID} from '@app/api/BrandedTypes';
|
||||
import type {CrosspostedMessageRow} from '@app/api/database/types/ChannelTypes';
|
||||
|
||||
export interface CrosspostedMessageKey {
|
||||
sourceMessageId: MessageID;
|
||||
webhookId: WebhookID;
|
||||
}
|
||||
|
||||
export interface CrosspostSyncState {
|
||||
targetMessageId: MessageID;
|
||||
sourceFingerprint: string | null;
|
||||
}
|
||||
|
||||
export interface CrosspostSource {
|
||||
sourceChannelId: ChannelID;
|
||||
sourceMessageId: MessageID;
|
||||
}
|
||||
|
||||
export abstract class ICrosspostedMessageRepository {
|
||||
abstract get(sourceMessageId: MessageID, webhookId: WebhookID): Promise<CrosspostedMessageRow | null>;
|
||||
|
||||
abstract insertPending(row: CrosspostedMessageRow): Promise<boolean>;
|
||||
|
||||
abstract reclaimPending(
|
||||
key: CrosspostedMessageKey,
|
||||
data: {
|
||||
fromTargetMessageId: MessageID;
|
||||
toTargetMessageId: MessageID;
|
||||
reservedAt: Date;
|
||||
},
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract markDelivered(key: CrosspostedMessageKey, data: CrosspostSyncState): Promise<boolean>;
|
||||
|
||||
abstract updateSynced(key: CrosspostedMessageKey, data: CrosspostSyncState): Promise<boolean>;
|
||||
|
||||
abstract listBySourceMessage(
|
||||
sourceMessageId: MessageID,
|
||||
options: {afterWebhookId?: WebhookID; limit: number},
|
||||
): Promise<Array<CrosspostedMessageRow>>;
|
||||
|
||||
abstract delete(
|
||||
key: CrosspostedMessageKey,
|
||||
expected?: Partial<Pick<CrosspostedMessageRow, 'state' | 'target_message_id'>>,
|
||||
): Promise<boolean>;
|
||||
|
||||
abstract addSource(source: CrosspostSource): Promise<void>;
|
||||
|
||||
abstract listSourcesByChannel(
|
||||
sourceChannelId: ChannelID,
|
||||
options: {afterMessageId?: MessageID; limit: number},
|
||||
): Promise<Array<MessageID>>;
|
||||
|
||||
abstract deleteSource(source: CrosspostSource): Promise<void>;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {MessageService} from '@app/api/channel/services/MessageService';
|
||||
import {
|
||||
assertAttachmentFileSizesWithinLimit,
|
||||
getContentType,
|
||||
isCrosspostCopy,
|
||||
isMessageEmpty,
|
||||
isOperationDisabled,
|
||||
makeAttachmentCdnKey,
|
||||
@@ -385,19 +386,45 @@ export class AttachmentUploadService {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
await this.purgeQueue.addUrls([cdnUrl]);
|
||||
const updatedAttachments = message.attachments.filter((a: Attachment) => a.id !== attachmentId);
|
||||
const updatedRowData = {
|
||||
...message.toRow(),
|
||||
edited_timestamp: new Date(),
|
||||
attachments:
|
||||
updatedAttachments.length > 0 ? updatedAttachments.map((a: Attachment) => a.toMessageAttachment()) : null,
|
||||
};
|
||||
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedRowData, message.toRow());
|
||||
const updatedMessage = await this.messageService.writeLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
if (!fresh || fresh.authorId !== userId) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const freshAttachment = fresh.attachments.find((a: Attachment) => a.id === attachmentId);
|
||||
if (!freshAttachment) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const updatedAttachments = fresh.attachments.filter((a: Attachment) => a.id !== attachmentId);
|
||||
if (updatedAttachments.length === 0 && isMessageEmpty(fresh, true)) {
|
||||
return null;
|
||||
}
|
||||
const updatedRowData = {
|
||||
...fresh.toRow(),
|
||||
edited_timestamp: new Date(),
|
||||
attachments:
|
||||
updatedAttachments.length > 0 ? updatedAttachments.map((a: Attachment) => a.toMessageAttachment()) : null,
|
||||
};
|
||||
return this.messageService.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'author'}, () =>
|
||||
this.channelRepository.messages.upsertMessage(updatedRowData, fresh.toRow()),
|
||||
);
|
||||
});
|
||||
if (!updatedMessage) {
|
||||
await this.messageService.deletion.deleteMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isCrosspostCopy(updatedMessage)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
await this.purgeQueue.addUrls([cdnUrl]);
|
||||
}
|
||||
await this.messageInteractionService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
await this.messageService.crosspostPropagation.propagateEdit(updatedMessage);
|
||||
}
|
||||
|
||||
async purgeChannelAttachments(channel: Channel): Promise<void> {
|
||||
|
||||
@@ -224,6 +224,7 @@ export abstract class BaseChannelAuthService {
|
||||
this.options.validateNsfw &&
|
||||
!skipNsfwValidation &&
|
||||
(channel.type === ChannelTypes.GUILD_TEXT ||
|
||||
channel.type === ChannelTypes.GUILD_ANNOUNCEMENT ||
|
||||
channel.type === ChannelTypes.GUILD_VOICE ||
|
||||
channel.type === ChannelTypes.GUILD_LINK) &&
|
||||
requiresAgeVerification
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {ChannelID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
|
||||
import type {ChannelUpdateData} from '@app/api/channel/services/channel_data/ChannelOperationsService';
|
||||
import type {
|
||||
ChannelTypeConversion,
|
||||
ChannelUpdateData,
|
||||
} from '@app/api/channel/services/channel_data/ChannelOperationsService';
|
||||
import {ChannelOperationsService} from '@app/api/channel/services/channel_data/ChannelOperationsService';
|
||||
import {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
|
||||
import {GroupDmUpdateService} from '@app/api/channel/services/channel_data/GroupDmUpdateService';
|
||||
@@ -28,6 +31,7 @@ import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilitySer
|
||||
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
|
||||
type GuildChannelUpdateRequest = Exclude<
|
||||
@@ -63,6 +67,7 @@ export class ChannelDataService {
|
||||
webhookRepository: IWebhookRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
rateLimitService: IRateLimitService,
|
||||
cacheService: ICacheService,
|
||||
) {
|
||||
this.utils = new ChannelUtilsService(
|
||||
channelRepository,
|
||||
@@ -87,6 +92,7 @@ export class ChannelDataService {
|
||||
guildRepository,
|
||||
limitConfigService,
|
||||
rateLimitService,
|
||||
cacheService,
|
||||
);
|
||||
this.groupDmUpdate = new GroupDmUpdateService(
|
||||
channelRepository,
|
||||
@@ -105,6 +111,7 @@ export class ChannelDataService {
|
||||
clientFeatures,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -112,6 +119,7 @@ export class ChannelDataService {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
@@ -185,6 +193,7 @@ export class ChannelDataService {
|
||||
clientFeatures,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID} from '@app/api/BrandedTypes';
|
||||
import type {ICrosspostedMessageRepository} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getSnowflakeService, getWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
const CHANNEL_FOLLOW_LOCK_TTL_SECONDS = 5;
|
||||
const CHANNEL_FOLLOW_LOCK_ACQUIRE_ATTEMPTS = 6;
|
||||
const CHANNEL_FOLLOW_LOCK_RETRY_DELAY_MS = 50;
|
||||
|
||||
export type ChannelFollowerRemovalReason = 'deleted' | 'converted';
|
||||
export type ChannelFollowerRemovalCopyMode = 'source_deleted' | 'purge';
|
||||
|
||||
export async function withChannelFollowLock<T>(
|
||||
cacheService: ICacheService,
|
||||
channelId: ChannelID,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const lockKey = `channel-follow:${channelId}`;
|
||||
let lockToken: string | null = null;
|
||||
for (let attempt = 0; attempt < CHANNEL_FOLLOW_LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
||||
lockToken = await cacheService.acquireLock(lockKey, CHANNEL_FOLLOW_LOCK_TTL_SECONDS);
|
||||
if (lockToken) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, CHANNEL_FOLLOW_LOCK_RETRY_DELAY_MS * (attempt + 1)));
|
||||
}
|
||||
if (!lockToken) {
|
||||
throw new ThrottledError({
|
||||
code: APIErrorCodes.RESOURCE_LOCKED,
|
||||
retryAfterSeconds: 1,
|
||||
data: {retry_after: 1},
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await cacheService.releaseLock(lockKey, lockToken).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelFollowerRemovalParams {
|
||||
sourceChannelId: ChannelID;
|
||||
reason: ChannelFollowerRemovalReason;
|
||||
copyMode?: ChannelFollowerRemovalCopyMode;
|
||||
}
|
||||
|
||||
export async function addChannelFollowerRemovalJob(params: ChannelFollowerRemovalParams): Promise<void> {
|
||||
const {sourceChannelId, reason, copyMode} = params;
|
||||
const uniqueSuffix = await getSnowflakeService().generate();
|
||||
await getWorkerService().addJob(
|
||||
'removeChannelFollowers',
|
||||
{
|
||||
sourceChannelId: sourceChannelId.toString(),
|
||||
reason,
|
||||
...(copyMode ? {copyMode} : {}),
|
||||
},
|
||||
{jobKey: `remove-followers:${sourceChannelId}:${reason}:${uniqueSuffix}`},
|
||||
);
|
||||
}
|
||||
|
||||
export async function enqueueChannelFollowerRemoval(params: ChannelFollowerRemovalParams): Promise<void> {
|
||||
try {
|
||||
await addChannelFollowerRemovalJob(params);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, sourceChannelId: params.sourceChannelId.toString(), reason: params.reason, copyMode: params.copyMode},
|
||||
'Failed to enqueue channel follower removal',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelMayHaveFollowerCopies(
|
||||
channel: Pick<Channel, 'id' | 'type'>,
|
||||
crossposts: Pick<ICrosspostedMessageRepository, 'listSourcesByChannel'>,
|
||||
): Promise<boolean> {
|
||||
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT) return true;
|
||||
const sources = await crossposts.listSourcesByChannel(channel.id, {limit: 1});
|
||||
return sources.length > 0;
|
||||
}
|
||||
|
||||
export async function scheduleDeletedChannelFollowerRemoval(params: {
|
||||
channel: Pick<Channel, 'id' | 'type'>;
|
||||
crossposts: Pick<ICrosspostedMessageRepository, 'listSourcesByChannel'>;
|
||||
copyMode: ChannelFollowerRemovalCopyMode;
|
||||
}): Promise<void> {
|
||||
if (!(await channelMayHaveFollowerCopies(params.channel, params.crossposts))) return;
|
||||
await addChannelFollowerRemovalJob({
|
||||
sourceChannelId: params.channel.id,
|
||||
reason: 'deleted',
|
||||
copyMode: params.copyMode,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {ChannelID, UserID} from '@app/api/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import type {ChannelTypeConversion} from '@app/api/channel/services/channel_data/ChannelOperationsService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
@@ -62,6 +63,7 @@ export class ChannelRequestService {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.channelData.editChannel({
|
||||
userId: params.userId,
|
||||
@@ -70,6 +72,7 @@ export class ChannelRequestService {
|
||||
clientFeatures: params.clientFeatures,
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: params.auditLogReason,
|
||||
typeConversion: params.typeConversion,
|
||||
});
|
||||
return mapChannelToResponse({
|
||||
channel,
|
||||
|
||||
@@ -86,6 +86,7 @@ export class ChannelService {
|
||||
gatewayService,
|
||||
storageService,
|
||||
purgeQueue,
|
||||
workerService,
|
||||
});
|
||||
const messagePersistenceService = new MessagePersistenceService(
|
||||
channelRepository,
|
||||
@@ -119,6 +120,7 @@ export class ChannelService {
|
||||
webhookRepository,
|
||||
limitConfigService,
|
||||
rateLimitService,
|
||||
cacheService,
|
||||
);
|
||||
this.messages = new MessageService(
|
||||
channelRepository,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
|
||||
import {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {MessageAnonymizationService} from '@app/api/channel/services/message/MessageAnonymizationService';
|
||||
import {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import {MessageCrosspostService} from '@app/api/channel/services/message/MessageCrosspostService';
|
||||
import {MessageDeleteService} from '@app/api/channel/services/message/MessageDeleteService';
|
||||
import {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
|
||||
import {MessageEditService} from '@app/api/channel/services/message/MessageEditService';
|
||||
@@ -17,6 +19,7 @@ import {MessageSearchService} from '@app/api/channel/services/message/MessageSea
|
||||
import {MessageSendService} from '@app/api/channel/services/message/MessageSendService';
|
||||
import {MessageSystemService} from '@app/api/channel/services/message/MessageSystemService';
|
||||
import {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService';
|
||||
import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
|
||||
import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
@@ -48,6 +51,9 @@ export class MessageService {
|
||||
public readonly deletion: MessageDeleteService;
|
||||
public readonly retrieval: MessageRetrievalService;
|
||||
public readonly anonymization: MessageAnonymizationService;
|
||||
public readonly writeLock: MessageWriteLock;
|
||||
public readonly crosspostPropagation: CrosspostPropagation;
|
||||
public readonly crosspost: MessageCrosspostService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepositoryAggregate,
|
||||
@@ -70,6 +76,8 @@ export class MessageService {
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.validation = new MessageValidationService(cacheService, limitConfigService);
|
||||
this.writeLock = new MessageWriteLock(cacheService, channelRepository.messages);
|
||||
this.crosspostPropagation = new CrosspostPropagation({rateLimitService, workerService});
|
||||
this.mention = new MessageMentionService(
|
||||
userRepository,
|
||||
guildRepository,
|
||||
@@ -128,11 +136,12 @@ export class MessageService {
|
||||
attachmentUploadTraceRepository,
|
||||
operationsHelpers,
|
||||
limitConfigService,
|
||||
messageWriteLock: this.writeLock,
|
||||
crosspostPropagation: this.crosspostPropagation,
|
||||
});
|
||||
this.edit = new MessageEditService({
|
||||
channelRepository,
|
||||
userRepository,
|
||||
cacheService,
|
||||
validationService: this.validation,
|
||||
persistenceService: this.persistence,
|
||||
channelAuthService: this.channelAuth,
|
||||
@@ -141,6 +150,8 @@ export class MessageService {
|
||||
searchService: this.search,
|
||||
embedAttachmentResolver: this.persistence.getEmbedAttachmentResolver(),
|
||||
mentionService: this.mention,
|
||||
messageWriteLock: this.writeLock,
|
||||
crosspostPropagation: this.crosspostPropagation,
|
||||
});
|
||||
this.deletion = new MessageDeleteService({
|
||||
channelRepository,
|
||||
@@ -152,6 +163,15 @@ export class MessageService {
|
||||
searchService: this.search,
|
||||
gatewayService,
|
||||
guildAuditLogService,
|
||||
crosspostPropagation: this.crosspostPropagation,
|
||||
});
|
||||
this.crosspost = new MessageCrosspostService({
|
||||
channelRepository,
|
||||
channelAuthService: this.channelAuth,
|
||||
dispatchService: this.dispatch,
|
||||
rateLimitService,
|
||||
messageWriteLock: this.writeLock,
|
||||
crosspostPropagation: this.crosspostPropagation,
|
||||
});
|
||||
this.retrieval = new MessageRetrievalService(
|
||||
channelRepository,
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import type {ChannelID, GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, createUserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import {
|
||||
enqueueChannelFollowerRemoval,
|
||||
scheduleDeletedChannelFollowerRemoval,
|
||||
withChannelFollowLock,
|
||||
} from '@app/api/channel/services/ChannelFollowers';
|
||||
import type {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
|
||||
import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
@@ -31,13 +36,17 @@ import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES,
|
||||
ChannelTypes,
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
Permissions,
|
||||
WebhookTypes,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ChannelHasFollowedChannelsError} from '@fluxer/errors/src/domains/channel/ChannelHasFollowedChannelsError';
|
||||
import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {MaxCategoryChannelsError} from '@fluxer/errors/src/domains/channel/MaxCategoryChannelsError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
@@ -46,6 +55,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
|
||||
export interface ChannelUpdateData {
|
||||
@@ -89,6 +99,7 @@ export class ChannelOperationsService {
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
private limitConfigService: LimitConfigService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private cacheService: ICacheService,
|
||||
) {}
|
||||
|
||||
async getChannel({
|
||||
@@ -131,6 +142,7 @@ export class ChannelOperationsService {
|
||||
clientFeatures,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -138,6 +150,7 @@ export class ChannelOperationsService {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
}): Promise<Channel> {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
@@ -149,6 +162,7 @@ export class ChannelOperationsService {
|
||||
}
|
||||
if (!guild) throw new MissingPermissionsError();
|
||||
await checkPermission(Permissions.MANAGE_CHANNELS);
|
||||
const nextType = resolveNextChannelType(channel, typeConversion ?? null);
|
||||
const guildIdValue = createGuildID(BigInt(guild.id));
|
||||
contentModerationService.scanText(data.name ?? null, {
|
||||
userId,
|
||||
@@ -165,7 +179,7 @@ export class ChannelOperationsService {
|
||||
surface: 'profile_field',
|
||||
});
|
||||
let channelName = data.name ?? channel.name;
|
||||
if (data.name !== undefined && channel.type === ChannelTypes.GUILD_TEXT) {
|
||||
if (data.name !== undefined && isTextNamedChannelType(channel.type)) {
|
||||
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(data.name);
|
||||
@@ -262,6 +276,7 @@ export class ChannelOperationsService {
|
||||
}
|
||||
const updatedChannelData = {
|
||||
...channel.toRow(),
|
||||
type: nextType,
|
||||
name: channelName,
|
||||
topic: data.topic !== undefined ? data.topic : channel.topic,
|
||||
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
||||
@@ -293,7 +308,19 @@ export class ChannelOperationsService {
|
||||
]),
|
||||
),
|
||||
};
|
||||
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
const updatedChannel =
|
||||
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT
|
||||
? await withChannelFollowLock(this.cacheService, channelId, async () => {
|
||||
const webhooks = await this.webhookRepository.listByChannel(channelId);
|
||||
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
|
||||
throw new ChannelHasFollowedChannelsError();
|
||||
}
|
||||
return await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
})
|
||||
: await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && nextType !== ChannelTypes.GUILD_ANNOUNCEMENT) {
|
||||
await enqueueChannelFollowerRemoval({sourceChannelId: channelId, reason: 'converted'});
|
||||
}
|
||||
if (
|
||||
data.rate_limit_per_user !== undefined &&
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
|
||||
@@ -401,6 +428,11 @@ export class ChannelOperationsService {
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
||||
}
|
||||
}
|
||||
await scheduleDeletedChannelFollowerRemoval({
|
||||
channel,
|
||||
crossposts: this.channelRepository.crossposts,
|
||||
copyMode: 'source_deleted',
|
||||
});
|
||||
const [channelInvites, channelWebhooks] = await Promise.all([
|
||||
this.inviteRepository.listChannelInvites(channelId),
|
||||
this.webhookRepository.listByChannel(channelId),
|
||||
@@ -736,9 +768,33 @@ export class ChannelOperationsService {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChannelTypeConversion {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
function resolveNextChannelType(channel: Channel, typeConversion: ChannelTypeConversion | null): number {
|
||||
if (typeConversion === null || typeConversion.to === channel.type) {
|
||||
return channel.type;
|
||||
}
|
||||
if (
|
||||
typeConversion.from !== channel.type ||
|
||||
!ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES.has(channel.type) ||
|
||||
!ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES.has(typeConversion.to)
|
||||
) {
|
||||
throw new ChannelTypeConversionNotSupportedError();
|
||||
}
|
||||
return typeConversion.to;
|
||||
}
|
||||
|
||||
function isTextNamedChannelType(type: number): boolean {
|
||||
return type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT;
|
||||
}
|
||||
|
||||
function isWritableGuildChannel(type: number): boolean {
|
||||
return (
|
||||
type === ChannelTypes.GUILD_TEXT ||
|
||||
type === ChannelTypes.GUILD_ANNOUNCEMENT ||
|
||||
type === ChannelTypes.GUILD_VOICE ||
|
||||
type === ChannelTypes.GUILD_LINK ||
|
||||
type === ChannelTypes.GUILD_CATEGORY
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {ChannelID, GuildID, MessageID, UserID, WebhookID} from '@app/api/BrandedTypes';
|
||||
import {createMessageID, createUserID} from '@app/api/BrandedTypes';
|
||||
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import type {CrosspostedMessageKey} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {
|
||||
collectEmbedContentHashes,
|
||||
type EmbedMediaField,
|
||||
forEachEmbedMedia,
|
||||
parseAttachmentUrl,
|
||||
} from '@app/api/channel/services/message/CrosspostEmbedObjects';
|
||||
import {isCrosspostedMessage, isCrosspostSourcePurged} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {MessageContentService} from '@app/api/channel/services/message/MessageContentService';
|
||||
import {
|
||||
dispatchMessageCreateBroadcast,
|
||||
dispatchMessageUpdateBroadcast,
|
||||
} from '@app/api/channel/services/message/MessageGatewayDispatch';
|
||||
import {isOperationDisabled, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
|
||||
import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService';
|
||||
import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
|
||||
import {checkCrosspostContentRules} from '@app/api/channel/utils/CrosspostContentRules';
|
||||
import {
|
||||
type ContentWarningChannelLike,
|
||||
channelToContentWarningView,
|
||||
computeEffectiveChannelNsfw,
|
||||
guildResponseToContentWarningView,
|
||||
} from '@app/api/channel/utils/EffectiveContentWarning';
|
||||
import type {CrosspostedMessageRow} from '@app/api/database/types/ChannelTypes';
|
||||
import type {
|
||||
MessageAttachment,
|
||||
MessageEmbed,
|
||||
MessageEmbedChild,
|
||||
MessageStickerItem,
|
||||
} from '@app/api/database/types/MessageTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
|
||||
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
|
||||
import {contentModerationService, type ModerationContext} from '@app/api/infrastructure/ContentModerationService';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {Webhook} from '@app/api/models/Webhook';
|
||||
import {deleteMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
||||
import {
|
||||
CROSSPOST_PENDING_RECLAIM_AFTER_MS,
|
||||
CROSSPOST_SOURCE_DELETED_CONTENT,
|
||||
} from '@fluxer/constants/src/AnnouncementConstants';
|
||||
import {
|
||||
CHANNEL_FOLLOW_TARGET_TYPES,
|
||||
ChannelTypes,
|
||||
MessageFlags,
|
||||
MessageReferenceTypes,
|
||||
MessageTypes,
|
||||
Permissions,
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
WebhookTypes,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
export type CrosspostCopySyncMode = 'update' | 'source_deleted' | 'purge';
|
||||
|
||||
export type CrosspostDeliveryOutcome = 'delivered' | 'skipped';
|
||||
|
||||
export interface CrosspostSourceContext {
|
||||
message: Message;
|
||||
channel: Channel;
|
||||
parent: Channel | null;
|
||||
guild: GuildResponse;
|
||||
fingerprint: string;
|
||||
accessCache: Map<string, Promise<boolean>>;
|
||||
authorAvatarCache: Map<string, Promise<string | null>>;
|
||||
}
|
||||
|
||||
export type CrosspostSourceLoadResult =
|
||||
| {kind: 'missing'}
|
||||
| {kind: 'inactive'}
|
||||
| {kind: 'ready'; context: CrosspostSourceContext};
|
||||
|
||||
interface CrosspostTarget {
|
||||
channel: Channel;
|
||||
parent: Channel | null;
|
||||
guild: GuildResponse;
|
||||
}
|
||||
|
||||
interface CrosspostCopyPayload {
|
||||
content: string | null;
|
||||
flags: number;
|
||||
attachments: Array<MessageAttachment>;
|
||||
embeds: Array<MessageEmbed>;
|
||||
stickerItems: Array<MessageStickerItem>;
|
||||
}
|
||||
|
||||
type CrosspostCopyPayloadResult = {kind: 'blocked'} | {kind: 'ready'; payload: CrosspostCopyPayload};
|
||||
|
||||
export interface CrosspostDeliveryDeps {
|
||||
channelRepository: ChannelRepository;
|
||||
webhookRepository: IWebhookRepository;
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
gatewayService: IGatewayService;
|
||||
storageService: IStorageService;
|
||||
avatarService: AvatarService;
|
||||
purgeQueue: IPurgeQueue;
|
||||
snowflakeService: ISnowflakeService;
|
||||
cacheService: ICacheService;
|
||||
limitConfigService: LimitConfigService;
|
||||
persistenceService: MessagePersistenceService;
|
||||
searchService: MessageSearchService;
|
||||
}
|
||||
|
||||
const UNAVAILABLE_GUILD_FEATURES: ReadonlyArray<string> = [
|
||||
GuildFeatures.UNAVAILABLE_FOR_EVERYONE,
|
||||
GuildFeatures.UNAVAILABLE_FOR_EVERYONE_BUT_STAFF,
|
||||
GuildFeatures.UNAVAILABLE_HIDDEN,
|
||||
];
|
||||
|
||||
export class CrosspostDeliveryPendingError extends Error {
|
||||
constructor(sourceMessageId: MessageID, webhookId: WebhookID) {
|
||||
super(`Crosspost delivery for ${sourceMessageId} to webhook ${webhookId} is still pending`);
|
||||
this.name = 'CrosspostDeliveryPendingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CrosspostSyncConflictError extends Error {
|
||||
constructor(sourceMessageId: MessageID, webhookId: WebhookID) {
|
||||
super(`Crosspost copy for ${sourceMessageId} via webhook ${webhookId} changed during sync`);
|
||||
this.name = 'CrosspostSyncConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function toStableValue(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value instanceof Set) return [...value].map(toStableValue).sort();
|
||||
if (value instanceof Map) {
|
||||
return [...value.entries()]
|
||||
.map(([key, entry]) => [String(key), toStableValue(entry)] as const)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(toStableValue);
|
||||
if (typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
const entry = (value as Record<string, unknown>)[key];
|
||||
if (entry === undefined || entry === null) continue;
|
||||
result[key] = toStableValue(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function crosspostSourceFingerprint(source: Message): string {
|
||||
const state = {
|
||||
content: source.content ?? null,
|
||||
flags: source.flags & SENDABLE_MESSAGE_FLAGS,
|
||||
stickers: source.stickers.map((sticker) => sticker.id.toString()),
|
||||
embeds: source.embeds.map((embed) => embed.toMessageEmbed()),
|
||||
attachments: source.attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
title: attachment.title,
|
||||
description: attachment.description,
|
||||
flags: attachment.flags,
|
||||
nsfw: attachment.nsfw,
|
||||
})),
|
||||
};
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(toStableValue(state)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function isGuildUnavailable(guild: GuildResponse): boolean {
|
||||
return guild.features.some((feature) => UNAVAILABLE_GUILD_FEATURES.includes(feature));
|
||||
}
|
||||
|
||||
function withoutNsfwChildren(embed: MessageEmbed): MessageEmbed {
|
||||
if (!embed.children || embed.children.length === 0) return embed;
|
||||
const children = embed.children.filter((child) => !child.nsfw);
|
||||
return {...embed, children: children.length > 0 ? children : null};
|
||||
}
|
||||
|
||||
function cloneEmbed(embed: MessageEmbed): MessageEmbed {
|
||||
const cloneChild = (child: MessageEmbedChild): MessageEmbedChild => ({
|
||||
...child,
|
||||
thumbnail: child.thumbnail ? {...child.thumbnail} : child.thumbnail,
|
||||
image: child.image ? {...child.image} : child.image,
|
||||
video: child.video ? {...child.video} : child.video,
|
||||
audio: child.audio ? {...child.audio} : child.audio,
|
||||
});
|
||||
return {
|
||||
...cloneChild(embed),
|
||||
children: embed.children ? embed.children.map(cloneChild) : embed.children,
|
||||
};
|
||||
}
|
||||
|
||||
export class CrosspostDeliveryService {
|
||||
private readonly writeLock: MessageWriteLock;
|
||||
private readonly contentService: MessageContentService;
|
||||
|
||||
constructor(private readonly deps: CrosspostDeliveryDeps) {
|
||||
this.writeLock = new MessageWriteLock(deps.cacheService, deps.channelRepository.messages);
|
||||
this.contentService = new MessageContentService(deps.userRepository, deps.guildRepository, deps.limitConfigService);
|
||||
}
|
||||
|
||||
async loadSource(channelId: ChannelID, messageId: MessageID): Promise<CrosspostSourceContext | null> {
|
||||
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) return null;
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
if (!channel?.guildId) return null;
|
||||
const guild = await this.loadGuild(channel.guildId);
|
||||
if (!guild) return null;
|
||||
const parent = await this.loadParent(channel);
|
||||
return {
|
||||
message,
|
||||
channel,
|
||||
parent,
|
||||
guild,
|
||||
fingerprint: crosspostSourceFingerprint(message),
|
||||
accessCache: new Map(),
|
||||
authorAvatarCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async loadSourceForDelivery(channelId: ChannelID, messageId: MessageID): Promise<CrosspostSourceLoadResult> {
|
||||
const context = await this.loadSource(channelId, messageId);
|
||||
if (!context) return {kind: 'missing'};
|
||||
if (
|
||||
!isCrosspostedMessage(context.message) ||
|
||||
context.channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT ||
|
||||
!this.isSourceGuildActive(context.guild)
|
||||
) {
|
||||
return {kind: 'inactive'};
|
||||
}
|
||||
return {kind: 'ready', context};
|
||||
}
|
||||
|
||||
isSourceGuildActive(guild: GuildResponse): boolean {
|
||||
return (
|
||||
!isGuildUnavailable(guild) &&
|
||||
!isOperationDisabled(guild, GuildOperations.SEND_MESSAGE) &&
|
||||
!guild.features.includes(GuildFeatures.ANNOUNCEMENT_CHANNELS_DISABLED)
|
||||
);
|
||||
}
|
||||
|
||||
async deliverToWebhook(context: CrosspostSourceContext, webhookId: WebhookID): Promise<CrosspostDeliveryOutcome> {
|
||||
const logContext = {
|
||||
sourceMessageId: context.message.id.toString(),
|
||||
webhookId: webhookId.toString(),
|
||||
};
|
||||
const webhook = await this.deps.webhookRepository.findUnique(webhookId);
|
||||
if (
|
||||
!webhook ||
|
||||
webhook.type !== WebhookTypes.CHANNEL_FOLLOWER ||
|
||||
webhook.sourceChannelId !== context.channel.id ||
|
||||
!webhook.channelId
|
||||
) {
|
||||
return 'skipped';
|
||||
}
|
||||
if (!(await this.creatorCanViewSource(context, webhook.creatorId))) {
|
||||
Logger.info(logContext, 'Skipping crosspost delivery: follower creator cannot view the source channel');
|
||||
return 'skipped';
|
||||
}
|
||||
const target = await this.loadDeliveryTarget(webhook.channelId);
|
||||
if (!target) {
|
||||
Logger.info(logContext, 'Skipping crosspost delivery: target channel cannot receive copies');
|
||||
return 'skipped';
|
||||
}
|
||||
if (this.checkContentRules(context, target) !== 'ok') {
|
||||
Logger.info(logContext, 'Skipping crosspost delivery: target does not meet the content rules');
|
||||
return 'skipped';
|
||||
}
|
||||
return this.deliverToTarget(context, webhook, target, true);
|
||||
}
|
||||
|
||||
private async deliverToTarget(
|
||||
context: CrosspostSourceContext,
|
||||
webhook: Webhook,
|
||||
target: CrosspostTarget,
|
||||
allowRestart: boolean,
|
||||
): Promise<CrosspostDeliveryOutcome> {
|
||||
const {crossposts} = this.deps.channelRepository;
|
||||
const key: CrosspostedMessageKey = {sourceMessageId: context.message.id, webhookId: webhook.id};
|
||||
const existing = await crossposts.get(key.sourceMessageId, key.webhookId);
|
||||
if (existing?.state === 'delivered') {
|
||||
await this.closeDeliveryRace(context, key);
|
||||
return 'skipped';
|
||||
}
|
||||
if (existing?.state === 'pending') {
|
||||
const copy = await this.deps.channelRepository.messages.getMessage(
|
||||
existing.target_channel_id,
|
||||
existing.target_message_id,
|
||||
);
|
||||
if (copy) {
|
||||
const marked = await crossposts.markDelivered(key, {
|
||||
targetMessageId: existing.target_message_id,
|
||||
sourceFingerprint: null,
|
||||
});
|
||||
if (!marked) return 'skipped';
|
||||
const copyChannel =
|
||||
existing.target_channel_id === target.channel.id
|
||||
? target.channel
|
||||
: await this.deps.channelRepository.findUnique(existing.target_channel_id);
|
||||
if (copyChannel) {
|
||||
await this.announceCopy(copyChannel, copy);
|
||||
}
|
||||
await this.closeDeliveryRace(context, key);
|
||||
return 'delivered';
|
||||
}
|
||||
if (Date.now() - existing.reserved_at.getTime() < CROSSPOST_PENDING_RECLAIM_AFTER_MS) {
|
||||
throw new CrosspostDeliveryPendingError(key.sourceMessageId, key.webhookId);
|
||||
}
|
||||
}
|
||||
const authorAvatar = await this.resolveCopyAuthorAvatar(context, webhook);
|
||||
const built = this.buildCopyPayload(context, target);
|
||||
if (built.kind === 'blocked') {
|
||||
Logger.warn(
|
||||
{sourceMessageId: context.message.id.toString(), webhookId: webhook.id.toString()},
|
||||
'Skipping crosspost delivery: blocked content',
|
||||
);
|
||||
return 'skipped';
|
||||
}
|
||||
const {payload} = built;
|
||||
const messageId = createMessageID(await this.deps.snowflakeService.generateForChannel(target.channel.id));
|
||||
const now = new Date();
|
||||
const reserved =
|
||||
existing?.state === 'pending'
|
||||
? await crossposts.reclaimPending(key, {
|
||||
fromTargetMessageId: existing.target_message_id,
|
||||
toTargetMessageId: messageId,
|
||||
reservedAt: now,
|
||||
})
|
||||
: await crossposts.insertPending({
|
||||
source_message_id: key.sourceMessageId,
|
||||
webhook_id: key.webhookId,
|
||||
source_channel_id: context.channel.id,
|
||||
target_guild_id: target.channel.guildId!,
|
||||
target_channel_id: target.channel.id,
|
||||
target_message_id: messageId,
|
||||
state: 'pending',
|
||||
reserved_at: now,
|
||||
source_fingerprint: null,
|
||||
created_at: now,
|
||||
});
|
||||
if (!reserved) {
|
||||
if (allowRestart) {
|
||||
return this.deliverToTarget(context, webhook, target, false);
|
||||
}
|
||||
throw new CrosspostDeliveryPendingError(key.sourceMessageId, key.webhookId);
|
||||
}
|
||||
const copy = await this.createCopy(context, webhook, target, messageId, payload, authorAvatar);
|
||||
const marked = await crossposts.markDelivered(key, {
|
||||
targetMessageId: messageId,
|
||||
sourceFingerprint: context.fingerprint,
|
||||
});
|
||||
if (!marked) {
|
||||
await this.deps.channelRepository.deleteMessage(target.channel.id, messageId, createUserID(0n));
|
||||
return 'skipped';
|
||||
}
|
||||
await this.announceCopy(target.channel, copy);
|
||||
await this.closeDeliveryRace(context, key);
|
||||
return 'delivered';
|
||||
}
|
||||
|
||||
private async createCopy(
|
||||
context: CrosspostSourceContext,
|
||||
webhook: Webhook,
|
||||
target: CrosspostTarget,
|
||||
messageId: MessageID,
|
||||
payload: CrosspostCopyPayload,
|
||||
authorAvatar: string | null,
|
||||
): Promise<Message> {
|
||||
try {
|
||||
const {message} = await this.deps.persistenceService.createMessage({
|
||||
messageId,
|
||||
channelId: target.channel.id,
|
||||
webhookId: webhook.id,
|
||||
webhookName: webhook.name,
|
||||
webhookAvatar: authorAvatar,
|
||||
type: MessageTypes.DEFAULT,
|
||||
content: payload.content,
|
||||
flags: payload.flags,
|
||||
processedAttachments: payload.attachments,
|
||||
processedEmbeds: payload.embeds,
|
||||
processedStickerItems: payload.stickerItems,
|
||||
messageReference: {
|
||||
guild_id: context.channel.guildId,
|
||||
channel_id: context.channel.id,
|
||||
message_id: context.message.id,
|
||||
type: MessageReferenceTypes.DEFAULT,
|
||||
},
|
||||
mentionData: {
|
||||
flags: payload.flags,
|
||||
mentionUserIds: [],
|
||||
mentionRoleIds: [],
|
||||
mentionChannelIds: [],
|
||||
mentionEveryone: false,
|
||||
},
|
||||
guildId: target.channel.guildId,
|
||||
skipDeferredEmbeds: true,
|
||||
});
|
||||
return message;
|
||||
} catch (error) {
|
||||
await this.deps.channelRepository.deleteMessage(target.channel.id, messageId, createUserID(0n));
|
||||
await this.deps.channelRepository.crossposts.delete(
|
||||
{sourceMessageId: context.message.id, webhookId: webhook.id},
|
||||
{state: 'pending', target_message_id: messageId},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCopyAuthorAvatar(context: CrosspostSourceContext, webhook: Webhook): Promise<string | null> {
|
||||
const iconHash = context.guild.icon ?? null;
|
||||
if (!iconHash) return Promise.resolve(null);
|
||||
if (iconHash === webhook.avatarHash) return Promise.resolve(iconHash);
|
||||
const cacheKey = `${webhook.id}:${iconHash}`;
|
||||
const cached = context.authorAvatarCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const pending = this.deps.avatarService.ensureWebhookAvatarFromGuildIcon({
|
||||
guildId: context.channel.guildId!,
|
||||
iconHash,
|
||||
webhookId: webhook.id,
|
||||
});
|
||||
context.authorAvatarCache.set(cacheKey, pending);
|
||||
pending.catch(() => context.authorAvatarCache.delete(cacheKey));
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async announceCopy(channel: Channel, copy: Message): Promise<void> {
|
||||
await dispatchMessageCreateBroadcast({gatewayService: this.deps.gatewayService, channel, message: copy});
|
||||
if (channel.indexedAt != null) {
|
||||
void this.deps.searchService.indexMessage(copy, false, {includeDefault: true});
|
||||
}
|
||||
}
|
||||
|
||||
private async closeDeliveryRace(context: CrosspostSourceContext, key: CrosspostedMessageKey): Promise<void> {
|
||||
const row = await this.deps.channelRepository.crossposts.get(key.sourceMessageId, key.webhookId);
|
||||
if (!row) return;
|
||||
const latest = await this.deps.channelRepository.messages.getMessage(context.channel.id, context.message.id);
|
||||
if (!latest) {
|
||||
await this.syncCopy(row, await this.removalModeFor(context.message.id), null);
|
||||
return;
|
||||
}
|
||||
if (crosspostSourceFingerprint(latest) !== row.source_fingerprint) {
|
||||
await this.syncCopy(row, 'update', await this.loadSource(context.channel.id, context.message.id));
|
||||
}
|
||||
}
|
||||
|
||||
async removalModeFor(sourceMessageId: MessageID): Promise<'source_deleted' | 'purge'> {
|
||||
return (await isCrosspostSourcePurged(sourceMessageId)) ? 'purge' : 'source_deleted';
|
||||
}
|
||||
|
||||
async syncCopy(
|
||||
row: CrosspostedMessageRow,
|
||||
mode: CrosspostCopySyncMode,
|
||||
source: CrosspostSourceContext | null,
|
||||
): Promise<void> {
|
||||
if (mode === 'update') {
|
||||
await this.syncUpdate(row, source);
|
||||
return;
|
||||
}
|
||||
if (mode === 'source_deleted') {
|
||||
await this.markCopySourceDeleted(row);
|
||||
return;
|
||||
}
|
||||
await this.purgeCopy(row);
|
||||
}
|
||||
|
||||
private keyOf(row: CrosspostedMessageRow): CrosspostedMessageKey {
|
||||
return {sourceMessageId: row.source_message_id, webhookId: row.webhook_id};
|
||||
}
|
||||
|
||||
private async syncUpdate(initialRow: CrosspostedMessageRow, source: CrosspostSourceContext | null): Promise<void> {
|
||||
const {crossposts} = this.deps.channelRepository;
|
||||
const key = this.keyOf(initialRow);
|
||||
const row = await crossposts.get(key.sourceMessageId, key.webhookId);
|
||||
if (row?.state !== 'delivered') return;
|
||||
if (!source) return;
|
||||
if (source.guild.features.includes(GuildFeatures.ANNOUNCEMENT_CHANNELS_DISABLED)) {
|
||||
await this.dropRemovedSourceAttachments(row, source);
|
||||
return;
|
||||
}
|
||||
const fingerprint = source.fingerprint;
|
||||
if (fingerprint === row.source_fingerprint) return;
|
||||
const copy = await this.deps.channelRepository.messages.getMessage(row.target_channel_id, row.target_message_id);
|
||||
if (!copy) {
|
||||
await crossposts.delete(key, {state: 'delivered', target_message_id: row.target_message_id});
|
||||
return;
|
||||
}
|
||||
if ((copy.flags & MessageFlags.SOURCE_MESSAGE_DELETED) !== 0) return;
|
||||
const target = await this.loadSyncTarget(row.target_channel_id);
|
||||
if (!target) return;
|
||||
if (!(await this.syncStillAllowed(source, row, target))) {
|
||||
await this.markCopySourceDeleted(row);
|
||||
return;
|
||||
}
|
||||
const built = this.buildCopyPayload(source, target);
|
||||
if (built.kind === 'blocked') {
|
||||
Logger.warn(
|
||||
{sourceMessageId: row.source_message_id.toString(), webhookId: row.webhook_id.toString()},
|
||||
'Crosspost sync found blocked content, marking the copy as source deleted',
|
||||
);
|
||||
await this.markCopySourceDeleted(row);
|
||||
return;
|
||||
}
|
||||
const {payload} = built;
|
||||
const result = await this.writeLock.withFreshMessage(
|
||||
row.target_channel_id,
|
||||
row.target_message_id,
|
||||
async (fresh) => {
|
||||
const current = await crossposts.get(key.sourceMessageId, key.webhookId);
|
||||
const latestSource = await this.deps.channelRepository.messages.getMessage(
|
||||
source.channel.id,
|
||||
source.message.id,
|
||||
);
|
||||
if (current?.state === 'delivered' && current.source_fingerprint === fingerprint) {
|
||||
return {kind: 'current' as const};
|
||||
}
|
||||
if (
|
||||
current?.state !== 'delivered' ||
|
||||
current.target_message_id !== row.target_message_id ||
|
||||
!fresh ||
|
||||
(fresh.flags & MessageFlags.SOURCE_MESSAGE_DELETED) !== 0 ||
|
||||
!latestSource ||
|
||||
crosspostSourceFingerprint(latestSource) !== fingerprint
|
||||
) {
|
||||
return {kind: 'conflict' as const};
|
||||
}
|
||||
const updated = await this.deps.channelRepository.messages.upsertMessage(
|
||||
{
|
||||
...fresh.toRow(),
|
||||
content: payload.content,
|
||||
embeds: payload.embeds.length > 0 ? payload.embeds : null,
|
||||
attachments: payload.attachments.length > 0 ? payload.attachments : null,
|
||||
sticker_items: payload.stickerItems.length > 0 ? payload.stickerItems : null,
|
||||
flags: (fresh.flags & ~SENDABLE_MESSAGE_FLAGS) | payload.flags,
|
||||
edited_timestamp: new Date(),
|
||||
},
|
||||
fresh.toRow(),
|
||||
);
|
||||
await crossposts.updateSynced(key, {
|
||||
targetMessageId: row.target_message_id,
|
||||
sourceFingerprint: fingerprint,
|
||||
});
|
||||
return {kind: 'synced' as const, updated};
|
||||
},
|
||||
);
|
||||
if (result.kind === 'current') return;
|
||||
if (result.kind === 'conflict') {
|
||||
throw new CrosspostSyncConflictError(key.sourceMessageId, key.webhookId);
|
||||
}
|
||||
await dispatchMessageUpdateBroadcast({
|
||||
gatewayService: this.deps.gatewayService,
|
||||
channel: target.channel,
|
||||
message: result.updated,
|
||||
});
|
||||
if (target.channel.indexedAt != null) {
|
||||
void this.deps.searchService.updateMessageIndex(result.updated, {includeDefault: true});
|
||||
}
|
||||
}
|
||||
|
||||
private async dropRemovedSourceAttachments(
|
||||
row: CrosspostedMessageRow,
|
||||
source: CrosspostSourceContext,
|
||||
): Promise<void> {
|
||||
const updated = await this.writeLock.withFreshMessage(
|
||||
row.target_channel_id,
|
||||
row.target_message_id,
|
||||
async (fresh) => {
|
||||
if (!fresh || (fresh.flags & MessageFlags.SOURCE_MESSAGE_DELETED) !== 0) return null;
|
||||
const latestSource = await this.deps.channelRepository.messages.getMessage(
|
||||
source.channel.id,
|
||||
source.message.id,
|
||||
);
|
||||
if (!latestSource) return null;
|
||||
const liveIds = new Set(latestSource.attachments.map((attachment) => attachment.id));
|
||||
const kept = fresh.attachments.filter((attachment) => liveIds.has(attachment.id));
|
||||
if (kept.length === fresh.attachments.length) return null;
|
||||
return this.deps.channelRepository.messages.upsertMessage(
|
||||
{
|
||||
...fresh.toRow(),
|
||||
attachments: kept.length > 0 ? kept.map((attachment) => attachment.toMessageAttachment()) : null,
|
||||
edited_timestamp: new Date(),
|
||||
},
|
||||
fresh.toRow(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!updated) return;
|
||||
const channel = await this.deps.channelRepository.findUnique(row.target_channel_id);
|
||||
if (!channel) return;
|
||||
await dispatchMessageUpdateBroadcast({gatewayService: this.deps.gatewayService, channel, message: updated});
|
||||
if (channel.indexedAt != null) {
|
||||
void this.deps.searchService.updateMessageIndex(updated, {includeDefault: true});
|
||||
}
|
||||
}
|
||||
|
||||
private async syncStillAllowed(
|
||||
source: CrosspostSourceContext,
|
||||
row: CrosspostedMessageRow,
|
||||
target: CrosspostTarget,
|
||||
): Promise<boolean> {
|
||||
if (this.checkContentRules(source, target) !== 'ok') return false;
|
||||
const webhook = await this.deps.webhookRepository.findUnique(row.webhook_id);
|
||||
if (!webhook || webhook.sourceChannelId !== source.channel.id) return true;
|
||||
return this.creatorCanViewSource(source, webhook.creatorId);
|
||||
}
|
||||
|
||||
private isStaleRow(row: CrosspostedMessageRow): boolean {
|
||||
return row.state === 'delivered' || Date.now() - row.reserved_at.getTime() >= CROSSPOST_PENDING_RECLAIM_AFTER_MS;
|
||||
}
|
||||
|
||||
private async markCopySourceDeleted(row: CrosspostedMessageRow): Promise<void> {
|
||||
const {crossposts} = this.deps.channelRepository;
|
||||
const key = this.keyOf(row);
|
||||
const outcome = await this.writeLock.withFreshMessage(
|
||||
row.target_channel_id,
|
||||
row.target_message_id,
|
||||
async (fresh) => {
|
||||
if (!fresh) return {kind: 'missing' as const};
|
||||
if ((fresh.flags & MessageFlags.SOURCE_MESSAGE_DELETED) !== 0) {
|
||||
return {kind: 'already' as const};
|
||||
}
|
||||
const updated = await this.deps.channelRepository.messages.upsertMessage(
|
||||
{
|
||||
...fresh.toRow(),
|
||||
content: CROSSPOST_SOURCE_DELETED_CONTENT,
|
||||
attachments: null,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
flags: MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED,
|
||||
edited_timestamp: new Date(),
|
||||
},
|
||||
fresh.toRow(),
|
||||
);
|
||||
return {kind: 'marked' as const, updated};
|
||||
},
|
||||
);
|
||||
if (outcome.kind === 'missing') {
|
||||
if (this.isStaleRow(row)) {
|
||||
await crossposts.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'marked') {
|
||||
const channel = await this.deps.channelRepository.findUnique(row.target_channel_id);
|
||||
if (channel) {
|
||||
await dispatchMessageUpdateBroadcast({
|
||||
gatewayService: this.deps.gatewayService,
|
||||
channel,
|
||||
message: outcome.updated,
|
||||
});
|
||||
if (channel.indexedAt != null) {
|
||||
void this.deps.searchService.updateMessageIndex(outcome.updated, {includeDefault: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
await crossposts.delete(key);
|
||||
}
|
||||
|
||||
private async purgeCopy(row: CrosspostedMessageRow): Promise<void> {
|
||||
const {crossposts} = this.deps.channelRepository;
|
||||
const key = this.keyOf(row);
|
||||
const removed = await this.writeLock.withFreshMessage(
|
||||
row.target_channel_id,
|
||||
row.target_message_id,
|
||||
async (fresh) => {
|
||||
if (!fresh) return null;
|
||||
await this.deps.channelRepository.deleteMessage(
|
||||
fresh.channelId,
|
||||
fresh.id,
|
||||
fresh.authorId ?? createUserID(0n),
|
||||
fresh.pinnedTimestamp ?? undefined,
|
||||
);
|
||||
return fresh;
|
||||
},
|
||||
);
|
||||
if (!removed) {
|
||||
if (this.isStaleRow(row)) {
|
||||
await crossposts.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const channel = await this.deps.channelRepository.findUnique(row.target_channel_id);
|
||||
if (channel) {
|
||||
await dispatchChannelEvent({
|
||||
gatewayService: this.deps.gatewayService,
|
||||
channel,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {channel_id: channel.id.toString(), id: removed.id.toString()},
|
||||
});
|
||||
}
|
||||
await deleteMessageSearchDocuments([removed.id], {context: {source: 'crosspost_purge'}});
|
||||
await crossposts.delete(key);
|
||||
}
|
||||
|
||||
async deleteSourceMessage(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
const removed = await this.writeLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
if (!fresh) return null;
|
||||
await this.deps.channelRepository.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
fresh.authorId ?? createUserID(0n),
|
||||
fresh.pinnedTimestamp ?? undefined,
|
||||
);
|
||||
return fresh;
|
||||
});
|
||||
if (!removed) return;
|
||||
await purgeMessageAttachments(removed, this.deps.storageService, this.deps.purgeQueue);
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
if (channel) {
|
||||
await dispatchChannelEvent({
|
||||
gatewayService: this.deps.gatewayService,
|
||||
channel,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {channel_id: channelId.toString(), id: messageId.toString()},
|
||||
});
|
||||
}
|
||||
await deleteMessageSearchDocuments([messageId], {context: {source: 'crosspost_family_purge'}});
|
||||
}
|
||||
|
||||
private buildCopyPayload(source: CrosspostSourceContext, target: CrosspostTarget): CrosspostCopyPayloadResult {
|
||||
const message = source.message;
|
||||
if (this.isBlocked(source)) {
|
||||
return {kind: 'blocked'};
|
||||
}
|
||||
const nsfwAllowed = this.targetAllowsNsfw(target);
|
||||
const excludedAttachmentIds = new Set<string>();
|
||||
const attachments: Array<MessageAttachment> = [];
|
||||
for (const attachment of message.attachments) {
|
||||
if (!nsfwAllowed && attachment.nsfw) {
|
||||
excludedAttachmentIds.add(attachment.id.toString());
|
||||
continue;
|
||||
}
|
||||
attachments.push(attachment.toMessageAttachment());
|
||||
}
|
||||
let embeds = message.embeds.map((embed) => cloneEmbed(embed.toMessageEmbed()));
|
||||
if (!nsfwAllowed) {
|
||||
embeds = embeds.filter((embed) => !embed.nsfw).map(withoutNsfwChildren);
|
||||
}
|
||||
const removedMedia: Array<{owner: MessageEmbedChild; field: EmbedMediaField}> = [];
|
||||
forEachEmbedMedia(embeds, (media, owner, field) => {
|
||||
const parsed = parseAttachmentUrl(media.url, source.channel.id);
|
||||
if (parsed && excludedAttachmentIds.has(parsed.id)) {
|
||||
removedMedia.push({owner, field});
|
||||
}
|
||||
});
|
||||
for (const {owner, field} of removedMedia) {
|
||||
owner[field] = null;
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
payload: {
|
||||
content: message.content,
|
||||
flags: MessageFlags.IS_CROSSPOST | (message.flags & SENDABLE_MESSAGE_FLAGS),
|
||||
attachments,
|
||||
embeds,
|
||||
stickerItems: message.stickers.map((sticker) => sticker.toMessageStickerItem()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private isBlocked(source: CrosspostSourceContext): boolean {
|
||||
const message = source.message;
|
||||
const context: Omit<ModerationContext, 'surface'> = {
|
||||
userId: message.authorId,
|
||||
guildId: source.channel.guildId,
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
};
|
||||
try {
|
||||
const textContext: ModerationContext = {...context, surface: 'message_content'};
|
||||
contentModerationService.scanText(message.content, textContext);
|
||||
for (const embed of message.embeds) {
|
||||
if (embed.type !== 'rich') continue;
|
||||
contentModerationService.scanText(embed.title, textContext);
|
||||
contentModerationService.scanText(embed.description, textContext);
|
||||
for (const field of embed.fields) {
|
||||
contentModerationService.scanText(field.name, textContext);
|
||||
contentModerationService.scanText(field.value, textContext);
|
||||
}
|
||||
contentModerationService.scanText(embed.footer?.text, textContext);
|
||||
contentModerationService.scanText(embed.author?.name, textContext);
|
||||
}
|
||||
for (const attachment of message.attachments) {
|
||||
if (attachment.contentHash) {
|
||||
contentModerationService.scanSha256(attachment.contentHash, {...context, surface: 'message_attachment'});
|
||||
}
|
||||
}
|
||||
for (const contentHash of collectEmbedContentHashes(message)) {
|
||||
contentModerationService.scanSha256(contentHash, {...context, surface: 'message_attachment'});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ContentBlockedError) return true;
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private targetAllowsNsfw(target: CrosspostTarget): boolean {
|
||||
if (
|
||||
computeEffectiveChannelNsfw(
|
||||
channelToContentWarningView(target.channel),
|
||||
target.parent ? channelToContentWarningView(target.parent) : null,
|
||||
guildResponseToContentWarningView(target.guild),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return this.contentService.isNSFWContentAllowed({
|
||||
channel: target.channel,
|
||||
guild: target.guild,
|
||||
member: null,
|
||||
isBot: false,
|
||||
});
|
||||
}
|
||||
|
||||
private checkContentRules(source: CrosspostSourceContext, target: CrosspostTarget) {
|
||||
return checkCrosspostContentRules({
|
||||
source: channelToContentWarningView(source.channel),
|
||||
sourceParent: this.parentView(source.parent),
|
||||
sourceGuild: guildResponseToContentWarningView(source.guild),
|
||||
target: channelToContentWarningView(target.channel),
|
||||
targetParent: this.parentView(target.parent),
|
||||
targetGuild: guildResponseToContentWarningView(target.guild),
|
||||
});
|
||||
}
|
||||
|
||||
private parentView(parent: Channel | null): ContentWarningChannelLike | null {
|
||||
return parent ? channelToContentWarningView(parent) : null;
|
||||
}
|
||||
|
||||
private async creatorCanViewSource(source: CrosspostSourceContext, creatorId: UserID | null): Promise<boolean> {
|
||||
if (!creatorId || !source.channel.guildId) return false;
|
||||
const cacheKey = creatorId.toString();
|
||||
let cached = source.accessCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = this.deps.gatewayService.checkPermission({
|
||||
guildId: source.channel.guildId,
|
||||
userId: creatorId,
|
||||
permission: Permissions.VIEW_CHANNEL,
|
||||
channelId: source.channel.id,
|
||||
});
|
||||
source.accessCache.set(cacheKey, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
private async loadDeliveryTarget(channelId: ChannelID): Promise<CrosspostTarget | null> {
|
||||
const target = await this.loadSyncTarget(channelId);
|
||||
if (!target) return null;
|
||||
if (!CHANNEL_FOLLOW_TARGET_TYPES.has(target.channel.type)) return null;
|
||||
if (isGuildUnavailable(target.guild) || isOperationDisabled(target.guild, GuildOperations.SEND_MESSAGE)) {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private async loadSyncTarget(channelId: ChannelID): Promise<CrosspostTarget | null> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
if (!channel?.guildId) return null;
|
||||
const guild = await this.loadGuild(channel.guildId);
|
||||
if (!guild) return null;
|
||||
return {channel, parent: await this.loadParent(channel), guild};
|
||||
}
|
||||
|
||||
private async loadParent(channel: Channel): Promise<Channel | null> {
|
||||
if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) return null;
|
||||
return this.deps.channelRepository.findUnique(channel.parentId);
|
||||
}
|
||||
|
||||
private async loadGuild(guildId: GuildID): Promise<GuildResponse | null> {
|
||||
try {
|
||||
return await this.deps.gatewayService.getGuildData({
|
||||
guildId,
|
||||
userId: createUserID(0n),
|
||||
skipMembershipCheck: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownGuildError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {MessageEmbed, MessageEmbedChild, MessageEmbedMedia} from '@app/api/database/types/MessageTypes';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
|
||||
const EMBED_MEDIA_FIELDS = ['image', 'thumbnail', 'video', 'audio'] as const;
|
||||
|
||||
export type EmbedMediaField = (typeof EMBED_MEDIA_FIELDS)[number];
|
||||
|
||||
function attachmentPrefix(channelId: ChannelID): string {
|
||||
return `${Config.endpoints.media}/attachments/${channelId}/`;
|
||||
}
|
||||
|
||||
export function parseAttachmentUrl(
|
||||
url: string | null | undefined,
|
||||
channelId: ChannelID,
|
||||
): {id: string; filename: string} | null {
|
||||
const prefix = attachmentPrefix(channelId);
|
||||
if (!url?.startsWith(prefix)) return null;
|
||||
const rest = url.slice(prefix.length);
|
||||
const separator = rest.indexOf('/');
|
||||
if (separator <= 0) return null;
|
||||
const id = rest.slice(0, separator);
|
||||
const filename = rest.slice(separator + 1).split('?')[0] ?? '';
|
||||
if (!/^\d+$/.test(id) || filename.length === 0) return null;
|
||||
return {id, filename};
|
||||
}
|
||||
|
||||
export function forEachEmbedMedia(
|
||||
embeds: ReadonlyArray<MessageEmbed>,
|
||||
visit: (media: MessageEmbedMedia, owner: MessageEmbedChild, field: EmbedMediaField) => void,
|
||||
): void {
|
||||
const visitOwner = (owner: MessageEmbedChild) => {
|
||||
for (const field of EMBED_MEDIA_FIELDS) {
|
||||
const media = owner[field];
|
||||
if (media) visit(media, owner, field);
|
||||
}
|
||||
};
|
||||
for (const embed of embeds) {
|
||||
visitOwner(embed);
|
||||
for (const child of embed.children ?? []) {
|
||||
visitOwner(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function collectEmbedContentHashes(message: Message): Array<string> {
|
||||
const hashes: Array<string> = [];
|
||||
forEachEmbedMedia(
|
||||
message.embeds.map((embed) => embed.toMessageEmbed()),
|
||||
(media) => {
|
||||
if (media.content_hash) hashes.push(media.content_hash);
|
||||
},
|
||||
);
|
||||
return hashes;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID} from '@app/api/BrandedTypes';
|
||||
import {isCrosspostCopy} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {
|
||||
CROSSPOST_SYNC_COALESCE_MS,
|
||||
PUBLISHED_MESSAGE_EDIT_RATE_LIMIT,
|
||||
} from '@fluxer/constants/src/AnnouncementConstants';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes, MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import type {IRateLimitService, RateLimitConfig, RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const CrosspostTaskNames = {
|
||||
CROSSPOST_MESSAGE: 'crosspostMessage',
|
||||
CROSSPOST_MESSAGE_CHUNK: 'crosspostMessageChunk',
|
||||
SYNC_CROSSPOSTED_MESSAGE: 'syncCrosspostedMessage',
|
||||
SYNC_CROSSPOST_COPIES: 'syncCrosspostCopies',
|
||||
REMOVE_CHANNEL_FOLLOWERS: 'removeChannelFollowers',
|
||||
} as const;
|
||||
|
||||
export type CrosspostTaskName = (typeof CrosspostTaskNames)[keyof typeof CrosspostTaskNames];
|
||||
|
||||
export type CrosspostWorkerService = IWorkerService<WorkerTaskName | CrosspostTaskName>;
|
||||
|
||||
export const CrosspostSyncModeSchema = z.enum(['update', 'source_deleted', 'purge']);
|
||||
export type CrosspostSyncMode = z.infer<typeof CrosspostSyncModeSchema>;
|
||||
export type CrosspostRemovalMode = Exclude<CrosspostSyncMode, 'update'>;
|
||||
|
||||
export const CrosspostMessagePayloadSchema = z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string(),
|
||||
afterWebhookId: z.string().optional(),
|
||||
});
|
||||
export type CrosspostMessagePayload = z.infer<typeof CrosspostMessagePayloadSchema>;
|
||||
|
||||
export const CrosspostMessageChunkPayloadSchema = z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string(),
|
||||
webhookIds: z.array(z.string()).min(1),
|
||||
attempt: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const SyncCrosspostedMessagePayloadSchema = z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string(),
|
||||
mode: CrosspostSyncModeSchema,
|
||||
deleteSource: z.boolean().optional(),
|
||||
});
|
||||
export type SyncCrosspostedMessagePayload = z.infer<typeof SyncCrosspostedMessagePayloadSchema>;
|
||||
|
||||
export const SyncCrosspostCopiesPayloadSchema = z.object({
|
||||
channelId: z.string(),
|
||||
messageId: z.string(),
|
||||
mode: CrosspostSyncModeSchema,
|
||||
webhookIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export const RemoveChannelFollowersPayloadSchema = z.object({
|
||||
sourceChannelId: z.string(),
|
||||
reason: z.enum(['deleted', 'converted']),
|
||||
copyMode: z.enum(['source_deleted', 'purge']).optional(),
|
||||
});
|
||||
|
||||
export type PublishedEditActor = 'author' | 'webhook' | 'moderator';
|
||||
|
||||
const CROSSPOST_PURGE_MARKER_TTL_SECONDS = 86_400;
|
||||
|
||||
type CrosspostMessageLike = Pick<Message, 'id' | 'channelId' | 'flags' | 'reference'>;
|
||||
|
||||
export function isCrosspostedMessage(message: Pick<Message, 'flags'>): boolean {
|
||||
return (message.flags & MessageFlags.CROSSPOSTED) !== 0;
|
||||
}
|
||||
|
||||
export function crosspostFanoutJobKey(messageId: string, afterWebhookId?: string): string {
|
||||
return afterWebhookId ? `crosspost:${messageId}:${afterWebhookId}` : `crosspost:${messageId}`;
|
||||
}
|
||||
|
||||
export function crosspostChunkJobKey(messageId: string, firstWebhookId: string, attempt: number): string {
|
||||
return `crosspost-chunk:${messageId}:${firstWebhookId}:${attempt}`;
|
||||
}
|
||||
|
||||
export function crosspostSyncBucket(nowMs: number): number {
|
||||
return Math.floor(nowMs / CROSSPOST_SYNC_COALESCE_MS);
|
||||
}
|
||||
|
||||
export function crosspostSyncJobKey(messageId: string, mode: CrosspostSyncMode, bucket?: number): string {
|
||||
return mode === 'update' ? `crosspost-sync:${messageId}:update:${bucket}` : `crosspost-sync:${messageId}:${mode}`;
|
||||
}
|
||||
|
||||
export function crosspostSyncChunkJobKey(params: {
|
||||
messageId: string;
|
||||
mode: CrosspostSyncMode;
|
||||
bucketOrMode: string;
|
||||
firstWebhookId: string;
|
||||
}): string {
|
||||
return `crosspost-sync-chunk:${params.messageId}:${params.mode}:${params.bucketOrMode}:${params.firstWebhookId}`;
|
||||
}
|
||||
|
||||
export function publishedEditRateLimitIdentifier(messageId: MessageID): string {
|
||||
return `crosspost:edit:${messageId}`;
|
||||
}
|
||||
|
||||
function crosspostPurgeMarkerKey(messageId: MessageID | string): string {
|
||||
return `crosspost:purged:${messageId}`;
|
||||
}
|
||||
|
||||
export async function isCrosspostSourcePurged(messageId: MessageID): Promise<boolean> {
|
||||
return (await getKVClient().exists(crosspostPurgeMarkerKey(messageId))) > 0;
|
||||
}
|
||||
|
||||
export function withPeekRetryAfter(result: RateLimitResult, config: RateLimitConfig): RateLimitResult {
|
||||
const leakPerMs = config.maxAttempts / config.windowMs;
|
||||
const retryAfterMs = Math.max(1, Math.ceil(result.resetAfterDecimal * 1000 - (config.maxAttempts - 1) / leakPerMs));
|
||||
return {
|
||||
...result,
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
||||
retryAfterDecimal: retryAfterMs / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCrosspostRateLimitError(code: string, result: RateLimitResult): RateLimitError {
|
||||
return new RateLimitError({
|
||||
code,
|
||||
scope: 'shared',
|
||||
retryAfter: result.retryAfter,
|
||||
retryAfterDecimal: result.retryAfterDecimal,
|
||||
limit: result.limit,
|
||||
resetTime: result.resetTime,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueCrosspostSync(
|
||||
workerService: CrosspostWorkerService,
|
||||
{
|
||||
channelId,
|
||||
messageId,
|
||||
mode,
|
||||
deleteSource,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
mode: CrosspostSyncMode;
|
||||
deleteSource?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const payload: SyncCrosspostedMessagePayload = {
|
||||
channelId: channelId.toString(),
|
||||
messageId: messageId.toString(),
|
||||
mode,
|
||||
...(deleteSource ? {deleteSource: true} : {}),
|
||||
};
|
||||
if (mode === 'purge') {
|
||||
try {
|
||||
await getKVClient().setex(crosspostPurgeMarkerKey(messageId), CROSSPOST_PURGE_MARKER_TTL_SECONDS, '1');
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: payload.channelId, messageId: payload.messageId},
|
||||
'Failed to mark crosspost purge',
|
||||
);
|
||||
}
|
||||
}
|
||||
let jobKey: string;
|
||||
let runAt: Date | undefined;
|
||||
if (mode === 'update') {
|
||||
const bucket = crosspostSyncBucket(Date.now());
|
||||
jobKey = crosspostSyncJobKey(payload.messageId, mode, bucket);
|
||||
runAt = new Date((bucket + 1) * CROSSPOST_SYNC_COALESCE_MS + 1000);
|
||||
} else {
|
||||
jobKey = crosspostSyncJobKey(payload.messageId, mode);
|
||||
}
|
||||
try {
|
||||
await workerService.addJob(CrosspostTaskNames.SYNC_CROSSPOSTED_MESSAGE, payload, {
|
||||
jobKey,
|
||||
runAt,
|
||||
skipLedger: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: payload.channelId, messageId: payload.messageId, mode},
|
||||
'Failed to enqueue crosspost sync',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CrosspostSourceRemovalParams {
|
||||
messages: ReadonlyArray<CrosspostMessageLike>;
|
||||
mode: CrosspostRemovalMode;
|
||||
channel?: Pick<Channel, 'type'> | null;
|
||||
}
|
||||
|
||||
function mayHaveCrosspostCopies(message: CrosspostMessageLike, channel: Pick<Channel, 'type'> | null): boolean {
|
||||
if (isCrosspostedMessage(message)) return true;
|
||||
return channel?.type === ChannelTypes.GUILD_ANNOUNCEMENT && !isCrosspostCopy(message);
|
||||
}
|
||||
|
||||
export async function enqueueCrosspostSourceRemoval(
|
||||
workerService: CrosspostWorkerService,
|
||||
{messages, mode, channel = null}: CrosspostSourceRemovalParams,
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
if (!mayHaveCrosspostCopies(message, channel)) continue;
|
||||
await enqueueCrosspostSync(workerService, {channelId: message.channelId, messageId: message.id, mode});
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueCrosspostFamilyPurgeFromCopies(
|
||||
workerService: CrosspostWorkerService,
|
||||
{messages}: {messages: ReadonlyArray<CrosspostMessageLike>},
|
||||
): Promise<void> {
|
||||
const seen = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!isCrosspostCopy(message)) continue;
|
||||
const reference = message.reference;
|
||||
if (!reference?.messageId) continue;
|
||||
const key = `${reference.channelId}:${reference.messageId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await enqueueCrosspostSync(workerService, {
|
||||
channelId: reference.channelId,
|
||||
messageId: reference.messageId,
|
||||
mode: 'purge',
|
||||
deleteSource: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface CrosspostPropagationDeps {
|
||||
rateLimitService: IRateLimitService;
|
||||
workerService: CrosspostWorkerService;
|
||||
}
|
||||
|
||||
export class CrosspostPropagation {
|
||||
constructor(private readonly deps: CrosspostPropagationDeps) {}
|
||||
|
||||
async withPublishedEditBudget<T>(
|
||||
{fresh, actor}: {fresh: Message; actor: PublishedEditActor},
|
||||
write: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!isCrosspostedMessage(fresh) || actor === 'moderator') {
|
||||
return write();
|
||||
}
|
||||
const config = {identifier: publishedEditRateLimitIdentifier(fresh.id), ...PUBLISHED_MESSAGE_EDIT_RATE_LIMIT};
|
||||
const peek = await this.deps.rateLimitService.peekLimit(config);
|
||||
if (peek.remaining < 1) {
|
||||
throw createCrosspostRateLimitError(
|
||||
APIErrorCodes.PUBLISHED_MESSAGE_EDIT_RATE_LIMITED,
|
||||
withPeekRetryAfter(peek, config),
|
||||
);
|
||||
}
|
||||
const result = await write();
|
||||
await this.deps.rateLimitService.checkLimit(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
async enqueueCrosspostFanout({channelId, messageId}: {channelId: ChannelID; messageId: MessageID}): Promise<void> {
|
||||
const payload: CrosspostMessagePayload = {channelId: channelId.toString(), messageId: messageId.toString()};
|
||||
await this.deps.workerService.addJob(CrosspostTaskNames.CROSSPOST_MESSAGE, payload, {
|
||||
jobKey: crosspostFanoutJobKey(payload.messageId),
|
||||
skipLedger: true,
|
||||
});
|
||||
}
|
||||
|
||||
async propagateEdit(message: Message): Promise<void> {
|
||||
if (!isCrosspostedMessage(message)) {
|
||||
return;
|
||||
}
|
||||
await enqueueCrosspostSync(this.deps.workerService, {
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
mode: 'update',
|
||||
});
|
||||
}
|
||||
|
||||
async enqueueCrosspostSourceRemoval(params: CrosspostSourceRemovalParams): Promise<void> {
|
||||
await enqueueCrosspostSourceRemoval(this.deps.workerService, params);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user