mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 11:42:32 +09:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4d5137f8 | ||
|
|
376afd2ad6 | ||
|
|
e3fcedbec5 | ||
|
|
7c9564bcad | ||
|
|
cfed6cc4e0 | ||
|
|
c7bd1be3e4 | ||
|
|
2161d84701 |
@@ -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
+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",
|
||||
|
||||
@@ -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
|
||||
@@ -448,6 +448,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
|
||||
|
||||
@@ -353,6 +353,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,
|
||||
|
||||
@@ -13287,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",
|
||||
@@ -13295,6 +13295,7 @@
|
||||
"GUILD_VOICE",
|
||||
"GROUP_DM",
|
||||
"GUILD_CATEGORY",
|
||||
"GUILD_ANNOUNCEMENT",
|
||||
"GUILD_LINK",
|
||||
"DM_PERSONAL_NOTES"
|
||||
],
|
||||
@@ -13304,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"
|
||||
]
|
||||
@@ -13502,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",
|
||||
@@ -13536,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",
|
||||
@@ -13571,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"
|
||||
},
|
||||
@@ -13738,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": {
|
||||
@@ -13748,7 +13753,7 @@
|
||||
},
|
||||
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
|
||||
},
|
||||
"required": ["channel_id", "message_id", "type"],
|
||||
"required": ["channel_id", "type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"message_snapshots": {
|
||||
@@ -13883,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": {
|
||||
@@ -13893,7 +13899,7 @@
|
||||
},
|
||||
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
|
||||
},
|
||||
"required": ["channel_id", "message_id", "type"],
|
||||
"required": ["channel_id", "type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"message_snapshots": {
|
||||
@@ -14384,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",
|
||||
@@ -14400,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",
|
||||
@@ -14411,6 +14432,7 @@
|
||||
"CHANNEL_ICON_CHANGE",
|
||||
"CHANNEL_PINNED_MESSAGE",
|
||||
"USER_JOIN",
|
||||
"CHANNEL_FOLLOW_ADD",
|
||||
"REPLY"
|
||||
],
|
||||
"x-enumDescriptions": [
|
||||
@@ -14422,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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -450,6 +450,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, type GuildID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {mapGuildToPartialResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
||||
import type {IGuildDataRepository} from '@app/api/guild/repositories/IGuildDataRepository';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {MessageFlags, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import type {CrosspostSourceResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
const CROSSPOST_SOURCE_COUNTS_TTL_SECONDS = 60;
|
||||
|
||||
const CROSSPOST_SOURCE_PUBLIC_FEATURES = new Set<string>([
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.PARTNERED,
|
||||
GuildFeatures.DISCOVERABLE,
|
||||
]);
|
||||
|
||||
interface CrosspostSourceCounts {
|
||||
memberCount: number;
|
||||
presenceCount: number;
|
||||
}
|
||||
|
||||
export function crosspostSourceCountsCacheKey(guildId: GuildID): string {
|
||||
return `crosspost-source:counts:${guildId.toString()}`;
|
||||
}
|
||||
|
||||
export function getCrosspostSourceGuildId(message: MessageResponse): GuildID | null {
|
||||
const isCopy = (message.flags & MessageFlags.IS_CROSSPOST) !== 0;
|
||||
const isFollowNotice = message.type === MessageTypes.CHANNEL_FOLLOW_ADD;
|
||||
if (!isCopy && !isFollowNotice) {
|
||||
return null;
|
||||
}
|
||||
const guildId = message.message_reference?.guild_id;
|
||||
return guildId ? createGuildID(BigInt(guildId)) : null;
|
||||
}
|
||||
|
||||
export class CrosspostSourceService {
|
||||
constructor(
|
||||
private readonly guildRepository: IGuildDataRepository,
|
||||
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly cacheService: ICacheService,
|
||||
) {}
|
||||
|
||||
async getSource(message: MessageResponse): Promise<CrosspostSourceResponse> {
|
||||
const guildId = getCrosspostSourceGuildId(message);
|
||||
if (guildId === null) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
const partial = mapGuildToPartialResponse(guild);
|
||||
const isListed = Config.discovery.enabled && guild.features.has(GuildFeatures.DISCOVERABLE);
|
||||
const [counts, description] = await Promise.all([
|
||||
this.getCounts(guildId),
|
||||
isListed ? this.getListedDescription(guildId) : null,
|
||||
]);
|
||||
return {
|
||||
guild: {
|
||||
id: partial.id,
|
||||
name: partial.name,
|
||||
icon: partial.icon ?? null,
|
||||
banner: partial.banner ?? null,
|
||||
features: partial.features.filter((feature) => CROSSPOST_SOURCE_PUBLIC_FEATURES.has(feature)),
|
||||
approximate_member_count: counts?.memberCount ?? null,
|
||||
approximate_presence_count: counts?.presenceCount ?? null,
|
||||
description,
|
||||
discoverable: isListed && !guild.features.has(GuildFeatures.INVITES_DISABLED),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getListedDescription(guildId: GuildID): Promise<string | null> {
|
||||
const row = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (row?.status !== DiscoveryApplicationStatus.APPROVED) {
|
||||
return null;
|
||||
}
|
||||
return row.description || null;
|
||||
}
|
||||
|
||||
private async getCounts(guildId: GuildID): Promise<CrosspostSourceCounts | null> {
|
||||
try {
|
||||
return await this.cacheService.getOrSet<CrosspostSourceCounts>(
|
||||
crosspostSourceCountsCacheKey(guildId),
|
||||
() => this.gatewayService.getGuildCounts(guildId),
|
||||
CROSSPOST_SOURCE_COUNTS_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn({error, guildId: guildId.toString()}, 'Failed to load crosspost source community counts');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
|
||||
import {collectEmbedContentHashes} from '@app/api/channel/services/message/CrosspostEmbedObjects';
|
||||
import {
|
||||
type CrosspostPropagation,
|
||||
createCrosspostRateLimitError,
|
||||
isCrosspostedMessage,
|
||||
withPeekRetryAfter,
|
||||
} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
|
||||
import {isCrosspostCopy, isOperationDisabled} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {assertMessageWithinHistoryCutoff} from '@app/api/channel/services/message/MessageHistoryCutoff';
|
||||
import type {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
|
||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
import {CROSSPOST_CHANNEL_RATE_LIMIT} from '@fluxer/constants/src/AnnouncementConstants';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes, MessageFlags, MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {AnnouncementChannelRequiredError} from '@fluxer/errors/src/domains/channel/AnnouncementChannelRequiredError';
|
||||
import {MessageAlreadyCrosspostedError} from '@fluxer/errors/src/domains/channel/MessageAlreadyCrosspostedError';
|
||||
import {MessageNotCrosspostableError} from '@fluxer/errors/src/domains/channel/MessageNotCrosspostableError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/HttpErrors';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
|
||||
interface MessageCrosspostServiceDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
channelAuthService: MessageChannelAuthService;
|
||||
dispatchService: MessageDispatchService;
|
||||
rateLimitService: IRateLimitService;
|
||||
messageWriteLock: MessageWriteLock;
|
||||
crosspostPropagation: CrosspostPropagation;
|
||||
}
|
||||
|
||||
interface CrosspostMessageResult {
|
||||
message: Message;
|
||||
authChannel: AuthenticatedChannel;
|
||||
}
|
||||
|
||||
export function crosspostChannelRateLimitIdentifier(channelId: ChannelID): string {
|
||||
return `crosspost:channel:${channelId}`;
|
||||
}
|
||||
|
||||
export class MessageCrosspostService {
|
||||
constructor(private readonly deps: MessageCrosspostServiceDeps) {}
|
||||
|
||||
async crosspostMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<CrosspostMessageResult> {
|
||||
const authChannel = await this.deps.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
const {channel, guild, member, hasPermission, checkPermission} = authChannel;
|
||||
if (channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT) {
|
||||
throw new AnnouncementChannelRequiredError();
|
||||
}
|
||||
if (!guild) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
if (
|
||||
isOperationDisabled(guild, GuildOperations.SEND_MESSAGE) ||
|
||||
guild.features.includes(GuildFeatures.ANNOUNCEMENT_CHANNELS_DISABLED)
|
||||
) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
await checkPermission(Permissions.SEND_MESSAGES);
|
||||
if (message.authorId === userId) {
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
} else {
|
||||
await checkPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!(await hasPermission(Permissions.READ_MESSAGE_HISTORY))) {
|
||||
assertMessageWithinHistoryCutoff({message, guild});
|
||||
}
|
||||
}
|
||||
this.assertCrosspostable(message);
|
||||
this.assertNotBlocked(message, guild);
|
||||
await this.assertBudgetAvailable(channelId);
|
||||
const published = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
if (!fresh) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
this.assertCrosspostable(fresh);
|
||||
await this.assertBudgetAvailable(channelId);
|
||||
const updated = await this.deps.channelRepository.messages.upsertMessage(
|
||||
{...fresh.toRow(), flags: fresh.flags | MessageFlags.CROSSPOSTED},
|
||||
fresh.toRow(),
|
||||
);
|
||||
await this.deps.channelRepository.crossposts.addSource({sourceChannelId: channelId, sourceMessageId: messageId});
|
||||
return updated;
|
||||
});
|
||||
Logger.info(
|
||||
{
|
||||
actorId: userId.toString(),
|
||||
guildId: guild.id,
|
||||
channelId: channelId.toString(),
|
||||
messageId: messageId.toString(),
|
||||
},
|
||||
'message published',
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: published, requestCache});
|
||||
try {
|
||||
await this.deps.crosspostPropagation.enqueueCrosspostFanout({channelId, messageId});
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), messageId: messageId.toString()},
|
||||
'Failed to enqueue crosspost fan-out',
|
||||
);
|
||||
const reverted = await this.revertPublish({channelId, messageId});
|
||||
if (reverted) {
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: reverted, requestCache});
|
||||
}
|
||||
if (error instanceof WorkerQueueOverflowError) {
|
||||
throw new ServiceUnavailableError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.deps.rateLimitService.checkLimit(this.channelBudgetConfig(channelId));
|
||||
return {message: published, authChannel};
|
||||
}
|
||||
|
||||
private assertCrosspostable(message: Message): void {
|
||||
if (isCrosspostCopy(message) || message.type !== MessageTypes.DEFAULT || message.messageSnapshots.length > 0) {
|
||||
throw new MessageNotCrosspostableError();
|
||||
}
|
||||
if (isCrosspostedMessage(message)) {
|
||||
throw new MessageAlreadyCrosspostedError();
|
||||
}
|
||||
}
|
||||
|
||||
private assertNotBlocked(message: Message, guild: GuildResponse): void {
|
||||
const context = {
|
||||
userId: message.authorId,
|
||||
guildId: BigInt(guild.id),
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
};
|
||||
const textContext = {...context, surface: 'message_content' as const};
|
||||
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'});
|
||||
}
|
||||
}
|
||||
|
||||
private channelBudgetConfig(channelId: ChannelID) {
|
||||
return {identifier: crosspostChannelRateLimitIdentifier(channelId), ...CROSSPOST_CHANNEL_RATE_LIMIT};
|
||||
}
|
||||
|
||||
private async assertBudgetAvailable(channelId: ChannelID): Promise<void> {
|
||||
const config = this.channelBudgetConfig(channelId);
|
||||
const peek = await this.deps.rateLimitService.peekLimit(config);
|
||||
if (peek.remaining < 1) {
|
||||
throw createCrosspostRateLimitError(
|
||||
APIErrorCodes.MESSAGE_CROSSPOST_RATE_LIMITED,
|
||||
withPeekRetryAfter(peek, config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async revertPublish({
|
||||
channelId,
|
||||
messageId,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
}): Promise<Message | null> {
|
||||
return this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
await this.deps.channelRepository.crossposts.deleteSource({
|
||||
sourceChannelId: channelId,
|
||||
sourceMessageId: messageId,
|
||||
});
|
||||
if (!fresh || !isCrosspostedMessage(fresh)) {
|
||||
return null;
|
||||
}
|
||||
return this.deps.channelRepository.messages.upsertMessage(
|
||||
{...fresh.toRow(), flags: fresh.flags & ~MessageFlags.CROSSPOSTED},
|
||||
fresh.toRow(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createMessageID, createUserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
|
||||
import {isOperationDisabled, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
@@ -39,6 +40,7 @@ interface MessageDeleteServiceDeps {
|
||||
searchService: MessageSearchService;
|
||||
gatewayService: IGatewayService;
|
||||
guildAuditLogService: GuildAuditLogService;
|
||||
crosspostPropagation: CrosspostPropagation;
|
||||
}
|
||||
|
||||
export class MessageDeleteService {
|
||||
@@ -84,6 +86,11 @@ export class MessageDeleteService {
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageDelete({channel, messageId, message});
|
||||
await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({
|
||||
messages: [message],
|
||||
mode: 'source_deleted',
|
||||
channel,
|
||||
});
|
||||
if (message.pinnedTimestamp) {
|
||||
await this.deps.dispatchService.dispatchEvent({
|
||||
channel,
|
||||
@@ -134,6 +141,11 @@ export class MessageDeleteService {
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageDelete({channel, messageId, message});
|
||||
await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({
|
||||
messages: [message],
|
||||
mode: 'source_deleted',
|
||||
channel,
|
||||
});
|
||||
if (message.pinnedTimestamp) {
|
||||
await this.deps.dispatchService.dispatchEvent({
|
||||
channel,
|
||||
@@ -182,6 +194,11 @@ export class MessageDeleteService {
|
||||
);
|
||||
await this.deps.channelRepository.messages.bulkDeleteMessages(channelId, messageIds);
|
||||
await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds});
|
||||
await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({
|
||||
messages: existingMessages,
|
||||
mode: 'source_deleted',
|
||||
channel,
|
||||
});
|
||||
if (channel.guildId && existingMessages.length > 0) {
|
||||
await this.guildAuditLogService
|
||||
.createBuilder(channel.guildId, userId)
|
||||
@@ -260,6 +277,11 @@ export class MessageDeleteService {
|
||||
);
|
||||
await this.deps.channelRepository.messages.bulkDeleteMessages(channel.id, messageIds);
|
||||
await this.deps.dispatchService.dispatchMessageDeleteBulk({channel, messageIds});
|
||||
await this.deps.crosspostPropagation.enqueueCrosspostSourceRemoval({
|
||||
messages: userMessages,
|
||||
mode: 'source_deleted',
|
||||
channel,
|
||||
});
|
||||
await this.deps.searchService.deleteMessagesIndex(messageIds);
|
||||
}
|
||||
if (inWindow.length < messages.length || messages.length < batchSize) break;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {MessageUpdateRequest} from '@app/api/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
|
||||
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
|
||||
import type {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/MessageEmbedAttachmentResolver';
|
||||
@@ -13,25 +14,19 @@ import type {MessagePersistenceService} from '@app/api/channel/services/message/
|
||||
import type {MessageProcessingService} from '@app/api/channel/services/message/MessageProcessingService';
|
||||
import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService';
|
||||
import type {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService';
|
||||
import type {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
|
||||
import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
const MESSAGE_LOCK_TTL_SECONDS = 5;
|
||||
const MESSAGE_LOCK_ACQUIRE_ATTEMPTS = 6;
|
||||
const MESSAGE_LOCK_RETRY_DELAY_MS = 50;
|
||||
|
||||
interface EditMessageResult {
|
||||
message: Message;
|
||||
@@ -41,7 +36,6 @@ interface EditMessageResult {
|
||||
interface MessageEditServiceDeps {
|
||||
channelRepository: IChannelRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
cacheService: ICacheService;
|
||||
validationService: MessageValidationService;
|
||||
persistenceService: MessagePersistenceService;
|
||||
channelAuthService: MessageChannelAuthService;
|
||||
@@ -50,6 +44,8 @@ interface MessageEditServiceDeps {
|
||||
searchService: MessageSearchService;
|
||||
embedAttachmentResolver: MessageEmbedAttachmentResolver;
|
||||
mentionService: MessageMentionService;
|
||||
messageWriteLock: MessageWriteLock;
|
||||
crosspostPropagation: CrosspostPropagation;
|
||||
}
|
||||
|
||||
export class MessageEditService {
|
||||
@@ -113,7 +109,7 @@ export class MessageEditService {
|
||||
attachments: data.attachments,
|
||||
existingAttachments: message.attachments.map((att) => ({filename: att.filename})),
|
||||
});
|
||||
const referencedMessage = message.reference
|
||||
const referencedMessage = message.reference?.messageId
|
||||
? await this.deps.channelRepository.messages.getMessage(channelId, message.reference.messageId)
|
||||
: null;
|
||||
const effectiveAllowedMentions = this.getEffectiveAllowedMentionsForEdit({message, referencedMessage, data});
|
||||
@@ -138,9 +134,10 @@ export class MessageEditService {
|
||||
});
|
||||
}
|
||||
if (message.authorId !== userId) {
|
||||
const editedMessage = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.processingService.handleNonAuthorEdit({
|
||||
message,
|
||||
const editedMessage = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, (fresh) => {
|
||||
if (!fresh) throw new UnknownMessageError();
|
||||
return this.deps.processingService.handleNonAuthorEdit({
|
||||
message: fresh,
|
||||
messageId,
|
||||
data,
|
||||
guild,
|
||||
@@ -149,26 +146,30 @@ export class MessageEditService {
|
||||
requestCache,
|
||||
persistenceService: this.deps.persistenceService,
|
||||
dispatchService: this.deps.dispatchService,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
await this.deps.crosspostPropagation.propagateEdit(editedMessage);
|
||||
return {message: editedMessage, authChannel};
|
||||
}
|
||||
const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
|
||||
const updateResult = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.persistenceService.updateMessage({
|
||||
message,
|
||||
messageId,
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
attachmentUploadUserId: userId,
|
||||
allowEmbeds: canEmbedLinks,
|
||||
isBot: user?.isBot,
|
||||
isBugHunterBot,
|
||||
locale: user?.locale,
|
||||
}),
|
||||
);
|
||||
const updateResult = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
if (!fresh) throw new UnknownMessageError();
|
||||
return this.deps.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'author'}, () =>
|
||||
this.deps.persistenceService.updateMessage({
|
||||
message: fresh,
|
||||
messageId,
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
attachmentUploadUserId: userId,
|
||||
allowEmbeds: canEmbedLinks,
|
||||
isBot: user?.isBot,
|
||||
isBugHunterBot,
|
||||
locale: user?.locale,
|
||||
}),
|
||||
);
|
||||
});
|
||||
let updatedMessage = updateResult.message;
|
||||
if (data.content !== undefined || data.allowed_mentions !== undefined || data.embeds !== undefined) {
|
||||
const mentionResult = await this.deps.processingService.handleMentions({
|
||||
@@ -186,6 +187,7 @@ export class MessageEditService {
|
||||
}
|
||||
}
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
await this.deps.crosspostPropagation.propagateEdit(updatedMessage);
|
||||
void updateResult.enqueueDeferredEmbeds().catch((error) => {
|
||||
Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction after edit');
|
||||
});
|
||||
@@ -218,26 +220,4 @@ export class MessageEditService {
|
||||
}
|
||||
return {replied_user: false};
|
||||
}
|
||||
|
||||
private async withMessageLock<T>(channelId: ChannelID, messageId: MessageID, fn: () => Promise<T>): Promise<T> {
|
||||
const lockKey = `message:${channelId}:${messageId}:write`;
|
||||
let lockToken: string | null = null;
|
||||
for (let attempt = 0; attempt < MESSAGE_LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
||||
lockToken = await this.deps.cacheService.acquireLock(lockKey, MESSAGE_LOCK_TTL_SECONDS);
|
||||
if (lockToken) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, MESSAGE_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 this.deps.cacheService.releaseLock(lockKey, lockToken).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import type {AttachmentID, ChannelID} from '@app/api/BrandedTypes';
|
||||
import type {AttachmentRequestData} from '@app/api/channel/AttachmentDTOs';
|
||||
import type {RichEmbedMediaWithMetadata} from '@app/api/channel/EmbedTypes';
|
||||
import {getContentType, makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
EMBED_MEDIA_OWNED_ATTACHMENT_FLAG,
|
||||
getContentType,
|
||||
makeAttachmentCdnUrl,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
@@ -158,7 +162,7 @@ export class MessageEmbedAttachmentResolver {
|
||||
content_type: metadata.content_type,
|
||||
content_hash: metadata.content_hash,
|
||||
placeholder: metadata.placeholder,
|
||||
flags: metadata.flags,
|
||||
flags: metadata.flags | EMBED_MEDIA_OWNED_ATTACHMENT_FLAG,
|
||||
duration: metadata.duration,
|
||||
nsfw: metadata.nsfw,
|
||||
},
|
||||
@@ -176,7 +180,7 @@ export class MessageEmbedAttachmentResolver {
|
||||
content_type: metadata.content_type,
|
||||
content_hash: metadata.content_hash,
|
||||
placeholder: metadata.placeholder,
|
||||
flags: metadata.flags,
|
||||
flags: metadata.flags | EMBED_MEDIA_OWNED_ATTACHMENT_FLAG,
|
||||
duration: metadata.duration,
|
||||
nsfw: metadata.nsfw,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import {createAttachmentID, createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
EMBED_MEDIA_OWNED_ATTACHMENT_FLAG,
|
||||
purgeMessageAttachments,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import type {MessageEmbed} from '@app/api/database/types/MessageTypes';
|
||||
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
@@ -14,7 +17,7 @@ const CHANNEL_ID = createChannelID(10n);
|
||||
const ATTACHMENT_KEY = 'attachments/10/200/ação.png';
|
||||
const OTHER_MESSAGE_ATTACHMENT_KEY = 'attachments/11/300/photo.jpg';
|
||||
|
||||
function imageEmbed(key: string): MessageEmbed {
|
||||
function imageEmbed(key: string, flags = 0): MessageEmbed {
|
||||
return {
|
||||
type: 'image',
|
||||
title: null,
|
||||
@@ -33,7 +36,7 @@ function imageEmbed(key: string): MessageEmbed {
|
||||
content_type: 'image/png',
|
||||
content_hash: null,
|
||||
placeholder: null,
|
||||
flags: 0,
|
||||
flags,
|
||||
duration: null,
|
||||
},
|
||||
video: null,
|
||||
@@ -43,7 +46,9 @@ function imageEmbed(key: string): MessageEmbed {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessageWithMedia(): Message {
|
||||
function makeMessageWithMedia(
|
||||
embeds: Array<MessageEmbed> = [imageEmbed(ATTACHMENT_KEY), imageEmbed(OTHER_MESSAGE_ATTACHMENT_KEY)],
|
||||
): Message {
|
||||
return new Message({
|
||||
channel_id: CHANNEL_ID,
|
||||
bucket: 0,
|
||||
@@ -79,7 +84,7 @@ function makeMessageWithMedia(): Message {
|
||||
waveform: null,
|
||||
},
|
||||
],
|
||||
embeds: [imageEmbed(ATTACHMENT_KEY), imageEmbed(OTHER_MESSAGE_ATTACHMENT_KEY)],
|
||||
embeds,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
@@ -109,4 +114,27 @@ describe('purgeMessageAttachments', () => {
|
||||
expect(deletedObjects).toEqual([`${Config.s3.buckets.cdn}/${ATTACHMENT_KEY}`]);
|
||||
expect(queuedUrls).toEqual([`${Config.endpoints.media}/${ATTACHMENT_KEY}`]);
|
||||
});
|
||||
|
||||
it('purges embed files the message owns and leaves marked files under other channels alone', async () => {
|
||||
const deletedObjects: Array<string> = [];
|
||||
const storageService = {
|
||||
deleteObject: async (_bucket: string, key: string) => {
|
||||
deletedObjects.push(key);
|
||||
},
|
||||
} as unknown as IStorageService;
|
||||
const purgeQueue: IPurgeQueue = {addUrls: async () => {}};
|
||||
const ownedEmbedKey = 'attachments/10/201/embed.png';
|
||||
|
||||
await purgeMessageAttachments(
|
||||
makeMessageWithMedia([
|
||||
imageEmbed(ownedEmbedKey, EMBED_MEDIA_OWNED_ATTACHMENT_FLAG),
|
||||
imageEmbed(OTHER_MESSAGE_ATTACHMENT_KEY, EMBED_MEDIA_OWNED_ATTACHMENT_FLAG),
|
||||
imageEmbed('attachments/10/202/unmarked.png'),
|
||||
]),
|
||||
storageService,
|
||||
purgeQueue,
|
||||
);
|
||||
|
||||
expect(deletedObjects).toEqual([ATTACHMENT_KEY, ownedEmbedKey]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
import type {AttachmentID, ChannelID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createAttachmentID, userIdToChannelId} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {forEachEmbedMedia} from '@app/api/channel/services/message/CrosspostEmbedObjects';
|
||||
import type {
|
||||
MessageSnapshot as CassandraMessageSnapshot,
|
||||
MessageAttachment,
|
||||
MessageEmbed,
|
||||
MessageEmbedMedia,
|
||||
} from '@app/api/database/types/MessageTypes';
|
||||
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
@@ -15,14 +18,14 @@ import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import {Attachment} from '@app/api/models/Attachment';
|
||||
import type {Embed} from '@app/api/models/Embed';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import {MessageSnapshot as MessageSnapshotModel} from '@app/api/models/MessageSnapshot';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MessageFlags, SENDABLE_MESSAGE_FLAGS} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ATTACHMENT_MAX_SIZE_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
@@ -146,6 +149,17 @@ export function makeAttachmentCdnUrl(
|
||||
return `${Config.endpoints.media}/${makeAttachmentCdnKey(channelId, attachmentId, filename)}`;
|
||||
}
|
||||
|
||||
export function isCrosspostCopy(message: Pick<Message, 'flags'>): boolean {
|
||||
return (message.flags & MessageFlags.IS_CROSSPOST) !== 0;
|
||||
}
|
||||
|
||||
export function attachmentStorageChannelId(message: Pick<Message, 'flags' | 'channelId' | 'reference'>): ChannelID {
|
||||
if (isCrosspostCopy(message) && message.reference) {
|
||||
return message.reference.channelId;
|
||||
}
|
||||
return message.channelId;
|
||||
}
|
||||
|
||||
function isMissingStorageObjectError(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) ||
|
||||
@@ -153,6 +167,30 @@ function isMissingStorageObjectError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function copyCdnObject(
|
||||
storageService: IStorageService,
|
||||
sourceKey: string,
|
||||
destinationKey: string,
|
||||
contentType: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await storageService.copyObject({
|
||||
sourceBucket: Config.s3.buckets.cdn,
|
||||
sourceKey,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey,
|
||||
newContentType: contentType ?? undefined,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingStorageObjectError(error)) {
|
||||
Logger.warn({error, sourceKey, destinationKey}, 'Skipping missing attachment while cloning message');
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneAttachments(
|
||||
attachments: Array<Attachment>,
|
||||
sourceChannelId: ChannelID,
|
||||
@@ -163,33 +201,14 @@ async function cloneAttachments(
|
||||
const clonedAttachments: Array<MessageAttachment> = [];
|
||||
for (const attachment of attachments) {
|
||||
const newAttachmentId = createAttachmentID(await snowflakeService.generate());
|
||||
const sourceKey = makeAttachmentCdnKey(sourceChannelId, attachment.id, attachment.filename);
|
||||
const destinationKey = makeAttachmentCdnKey(destinationChannelId, newAttachmentId, attachment.filename);
|
||||
try {
|
||||
await storageService.copyObject({
|
||||
sourceBucket: Config.s3.buckets.cdn,
|
||||
sourceKey,
|
||||
destinationBucket: Config.s3.buckets.cdn,
|
||||
destinationKey,
|
||||
newContentType: attachment.contentType,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingStorageObjectError(error)) {
|
||||
Logger.warn(
|
||||
{
|
||||
error,
|
||||
sourceChannelId,
|
||||
destinationChannelId,
|
||||
sourceKey,
|
||||
destinationKey,
|
||||
attachmentId: attachment.id,
|
||||
filename: attachment.filename,
|
||||
},
|
||||
'Skipping missing attachment while cloning forwarded message',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
const copied = await copyCdnObject(
|
||||
storageService,
|
||||
makeAttachmentCdnKey(sourceChannelId, attachment.id, attachment.filename),
|
||||
makeAttachmentCdnKey(destinationChannelId, newAttachmentId, attachment.filename),
|
||||
attachment.contentType,
|
||||
);
|
||||
if (!copied) {
|
||||
continue;
|
||||
}
|
||||
clonedAttachments.push({
|
||||
attachment_id: newAttachmentId,
|
||||
@@ -220,6 +239,9 @@ export async function createMessageSnapshotsForForward(
|
||||
limitConfigService: LimitConfigService,
|
||||
selection?: ForwardMediaSelection,
|
||||
): Promise<Array<MessageSnapshotModel>> {
|
||||
if ((referencedMessage.flags & MessageFlags.SOURCE_MESSAGE_DELETED) !== 0) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const isMediaOnlyForward = hasForwardMediaSelection(selection);
|
||||
if (referencedMessage.messageSnapshots && referencedMessage.messageSnapshots.length > 0) {
|
||||
const snapshot = referencedMessage.messageSnapshots[0];
|
||||
@@ -239,7 +261,14 @@ export async function createMessageSnapshotsForForward(
|
||||
);
|
||||
const clonedAttachments = await cloneAttachments(
|
||||
attachmentsForClone,
|
||||
referencedMessage.channelId,
|
||||
attachmentStorageChannelId(referencedMessage),
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
);
|
||||
await cloneOwnedEmbedAttachments(
|
||||
snapshotEmbeds,
|
||||
attachmentStorageChannelId(referencedMessage),
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
@@ -255,7 +284,7 @@ export async function createMessageSnapshotsForForward(
|
||||
embeds: snapshotEmbeds.length > 0 ? snapshotEmbeds : null,
|
||||
sticker_items: isMediaOnlyForward ? null : snapshot.stickers.map((sticker) => sticker.toMessageStickerItem()),
|
||||
type: snapshot.type,
|
||||
flags: snapshot.flags,
|
||||
flags: snapshot.flags & SENDABLE_MESSAGE_FLAGS,
|
||||
};
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
@@ -263,7 +292,7 @@ export async function createMessageSnapshotsForForward(
|
||||
validateTotalAttachmentSize(selectedAttachments, user, limitConfigService);
|
||||
const clonedAttachments = await cloneAttachments(
|
||||
selectedAttachments,
|
||||
referencedMessage.channelId,
|
||||
attachmentStorageChannelId(referencedMessage),
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
@@ -277,6 +306,13 @@ export async function createMessageSnapshotsForForward(
|
||||
if (isMediaOnlyForward && selectedAttachments.length === 0 && referencedMessageEmbeds.length === 0) {
|
||||
throw InputValidationError.fromCode('message_reference', ValidationErrorCodes.NO_VALID_MEDIA_IN_MESSAGE);
|
||||
}
|
||||
await cloneOwnedEmbedAttachments(
|
||||
referencedMessageEmbeds,
|
||||
attachmentStorageChannelId(referencedMessage),
|
||||
destinationChannelId,
|
||||
storageService,
|
||||
snowflakeService,
|
||||
);
|
||||
const snapshotData: CassandraMessageSnapshot = {
|
||||
content: isMediaOnlyForward ? null : referencedMessage.content,
|
||||
timestamp: snowflakeToDate(referencedMessage.id),
|
||||
@@ -303,36 +339,87 @@ export async function createMessageSnapshotsForForward(
|
||||
? referencedMessage.stickers.map((s) => s.toMessageStickerItem())
|
||||
: null,
|
||||
type: referencedMessage.type,
|
||||
flags: referencedMessage.flags,
|
||||
flags: referencedMessage.flags & SENDABLE_MESSAGE_FLAGS,
|
||||
};
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message, ownKeys: ReadonlySet<string>): Array<string> {
|
||||
export const EMBED_MEDIA_OWNED_ATTACHMENT_FLAG = 1 << 30;
|
||||
|
||||
function isOwnedEmbedAttachment(media: Pick<MessageEmbedMedia, 'flags'>): boolean {
|
||||
return (media.flags & EMBED_MEDIA_OWNED_ATTACHMENT_FLAG) !== 0;
|
||||
}
|
||||
|
||||
export function keepOwnedEmbedAttachments(previous: Message, embeds: Array<MessageEmbed> | null): void {
|
||||
const ownedUrls = new Set<string>();
|
||||
forEachEmbedMedia(
|
||||
previous.embeds.map((embed) => embed.toMessageEmbed()),
|
||||
(media) => {
|
||||
if (media.url && isOwnedEmbedAttachment(media)) ownedUrls.add(media.url);
|
||||
},
|
||||
);
|
||||
if (ownedUrls.size === 0 || !embeds) return;
|
||||
forEachEmbedMedia(embeds, (media) => {
|
||||
if (media.url && ownedUrls.has(media.url)) {
|
||||
media.flags |= EMBED_MEDIA_OWNED_ATTACHMENT_FLAG;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cloneOwnedEmbedAttachments(
|
||||
embeds: Array<MessageEmbed>,
|
||||
sourceChannelId: ChannelID,
|
||||
destinationChannelId: ChannelID,
|
||||
storageService: IStorageService,
|
||||
snowflakeService: ISnowflakeService,
|
||||
): Promise<void> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
if (!url?.startsWith(mediaPrefix)) {
|
||||
return;
|
||||
const sourcePrefix = `${mediaPrefix}attachments/${sourceChannelId}/`;
|
||||
const owned: Array<MessageEmbedMedia> = [];
|
||||
forEachEmbedMedia(embeds, (media) => {
|
||||
if (isOwnedEmbedAttachment(media)) owned.push(media);
|
||||
});
|
||||
const clonedUrls = new Map<string, string | null>();
|
||||
for (const media of owned) {
|
||||
media.flags &= ~EMBED_MEDIA_OWNED_ATTACHMENT_FLAG;
|
||||
const url = media.url;
|
||||
if (!url?.startsWith(sourcePrefix)) continue;
|
||||
if (!clonedUrls.has(url)) {
|
||||
const filename = url.slice(sourcePrefix.length).split('/').slice(1).join('/');
|
||||
const clonedId = createAttachmentID(await snowflakeService.generate());
|
||||
const copied = await copyCdnObject(
|
||||
storageService,
|
||||
url.slice(mediaPrefix.length),
|
||||
makeAttachmentCdnKey(destinationChannelId, clonedId, filename),
|
||||
media.content_type,
|
||||
);
|
||||
clonedUrls.set(url, copied ? makeAttachmentCdnUrl(destinationChannelId, clonedId, filename) : null);
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
if (ownKeys.has(key)) {
|
||||
keys.add(key);
|
||||
const clonedUrl = clonedUrls.get(url);
|
||||
if (clonedUrl) {
|
||||
media.url = clonedUrl;
|
||||
media.flags |= EMBED_MEDIA_OWNED_ATTACHMENT_FLAG;
|
||||
}
|
||||
};
|
||||
const scanEmbeds = (embeds: Array<Embed>): void => {
|
||||
for (const embed of embeds) {
|
||||
consider(embed.image?.url);
|
||||
consider(embed.thumbnail?.url);
|
||||
consider(embed.video?.url);
|
||||
consider(embed.audio?.url);
|
||||
}
|
||||
};
|
||||
scanEmbeds(message.embeds);
|
||||
for (const snapshot of message.messageSnapshots) {
|
||||
scanEmbeds(snapshot.embeds);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function collectOwnedEmbedAttachments(message: Message): Array<{key: string; media: MessageEmbedMedia}> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const ownPrefix = `${mediaPrefix}attachments/${attachmentStorageChannelId(message)}/`;
|
||||
const seen = new Set<string>();
|
||||
const owned: Array<{key: string; media: MessageEmbedMedia}> = [];
|
||||
const embeds = [...message.embeds, ...message.messageSnapshots.flatMap((snapshot) => snapshot.embeds)];
|
||||
forEachEmbedMedia(
|
||||
embeds.map((embed) => embed.toMessageEmbed()),
|
||||
(media) => {
|
||||
if (!media.url?.startsWith(ownPrefix) || !isOwnedEmbedAttachment(media)) return;
|
||||
const key = media.url.slice(mediaPrefix.length);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
owned.push({key, media});
|
||||
},
|
||||
);
|
||||
return owned;
|
||||
}
|
||||
|
||||
export async function purgeMessageAttachments(
|
||||
@@ -340,13 +427,11 @@ export async function purgeMessageAttachments(
|
||||
storageService: IStorageService,
|
||||
purgeQueue: IPurgeQueue,
|
||||
): Promise<void> {
|
||||
if (isCrosspostCopy(message)) {
|
||||
return;
|
||||
}
|
||||
const cdnKeys = new Set<string>();
|
||||
const cdnUrls: Array<string> = [];
|
||||
const ownedCdnKeys = new Set<string>(
|
||||
collectMessageAttachments(message).map((attachment) =>
|
||||
makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
|
||||
),
|
||||
);
|
||||
for (const attachment of collectMessageAttachments(message)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
if (cdnKeys.has(cdnKey)) {
|
||||
@@ -355,7 +440,7 @@ export async function purgeMessageAttachments(
|
||||
cdnKeys.add(cdnKey);
|
||||
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
|
||||
}
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message, ownedCdnKeys)) {
|
||||
for (const {key: embedKey} of collectOwnedEmbedAttachments(message)) {
|
||||
if (cdnKeys.has(embedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
export function assertMessageWithinHistoryCutoff(params: {message: Message | null; guild: GuildResponse}): void {
|
||||
const {message, guild} = params;
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const cutoff = guild.message_history_cutoff;
|
||||
if (!cutoff) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const messageTimestamp = snowflakeToDate(message.id).getTime();
|
||||
const cutoffTimestamp = new Date(cutoff).getTime();
|
||||
if (messageTimestamp < cutoffTimestamp) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/
|
||||
import {
|
||||
assertAttachmentFileSizesWithinLimit,
|
||||
collectMessageAttachments,
|
||||
isCrosspostCopy,
|
||||
keepOwnedEmbedAttachments,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {MessageStickerService} from '@app/api/channel/services/message/MessageStickerService';
|
||||
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
|
||||
@@ -111,6 +113,9 @@ interface CreateMessageParams {
|
||||
};
|
||||
allowEmbeds?: boolean;
|
||||
dmNsfwContext?: DmNsfwContext;
|
||||
processedEmbeds?: Array<MessageEmbed>;
|
||||
processedStickerItems?: Array<MessageStickerItem>;
|
||||
skipDeferredEmbeds?: boolean;
|
||||
}
|
||||
|
||||
export class MessagePersistenceService {
|
||||
@@ -190,8 +195,12 @@ export class MessagePersistenceService {
|
||||
const allowEmbeds = params.allowEmbeds ?? true;
|
||||
let initialEmbeds: Array<MessageEmbed> | null = null;
|
||||
let hasUncachedUrls = false;
|
||||
const referencedFilenames = this.embedAttachmentResolver.collectReferencedAttachmentFilenames(params.embeds);
|
||||
if (allowEmbeds) {
|
||||
const referencedFilenames = params.processedEmbeds
|
||||
? new Set<string>()
|
||||
: this.embedAttachmentResolver.collectReferencedAttachmentFilenames(params.embeds);
|
||||
if (params.processedEmbeds) {
|
||||
initialEmbeds = params.processedEmbeds.length > 0 ? params.processedEmbeds : null;
|
||||
} else if (allowEmbeds) {
|
||||
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
|
||||
embeds: params.embeds,
|
||||
attachments: processedAttachments.map(mapAttachmentForEmbedResolution),
|
||||
@@ -299,6 +308,9 @@ export class MessagePersistenceService {
|
||||
params: CreateMessageParams,
|
||||
authorId: UserID | null,
|
||||
): Promise<Array<MessageStickerItem>> {
|
||||
if (params.processedStickerItems) {
|
||||
return params.processedStickerItems;
|
||||
}
|
||||
if (!params.stickerIds || params.stickerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -320,7 +332,7 @@ export class MessagePersistenceService {
|
||||
}): Promise<() => Promise<void>> {
|
||||
const {message, params, authorId, allowEmbeds, hasUncachedUrls, isNSFWAllowed} = context;
|
||||
const operations: Array<Promise<unknown>> = [];
|
||||
const trackedAttachments = collectMessageAttachments(message);
|
||||
const trackedAttachments = isCrosspostCopy(message) ? [] : collectMessageAttachments(message);
|
||||
if (trackedAttachments.length > 0) {
|
||||
const uploadedAt = snowflakeToDate(params.messageId);
|
||||
const decayPayloads = trackedAttachments.map((att) => ({
|
||||
@@ -334,7 +346,7 @@ export class MessagePersistenceService {
|
||||
operations.push(this.attachmentDecayService.upsertMany(decayPayloads));
|
||||
}
|
||||
let enqueueDeferredEmbeds: () => Promise<void> = () => Promise.resolve();
|
||||
if (allowEmbeds && hasUncachedUrls) {
|
||||
if (allowEmbeds && hasUncachedUrls && !params.skipDeferredEmbeds) {
|
||||
enqueueDeferredEmbeds = () =>
|
||||
this.embedService.enqueueUrlEmbedExtraction(
|
||||
params.channelId,
|
||||
@@ -524,6 +536,7 @@ export class MessagePersistenceService {
|
||||
isBugHunterBot: params.isBugHunterBot,
|
||||
});
|
||||
if (embedsExplicitlyProvided) {
|
||||
keepOwnedEmbedAttachments(message, initialEmbeds);
|
||||
updatedRowData.embeds = initialEmbeds;
|
||||
} else {
|
||||
const preservedEmbeds = message.embeds
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {Message} from '@app/api/models/Message';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ChannelTypes, MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CannotEditOtherUserMessageError} from '@fluxer/errors/src/domains/channel/CannotEditOtherUserMessageError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
|
||||
@@ -204,6 +204,9 @@ export class MessageProcessingService {
|
||||
}
|
||||
|
||||
async repairMentionsOnRead(message: Message, sourceChannel?: Channel): Promise<Message> {
|
||||
if ((message.flags & MessageFlags.IS_CROSSPOST) !== 0) {
|
||||
return message;
|
||||
}
|
||||
if (
|
||||
message.mentionedUserIds.size === 0 &&
|
||||
message.mentionedRoleIds.size === 0 &&
|
||||
@@ -216,7 +219,7 @@ export class MessageProcessingService {
|
||||
}
|
||||
}
|
||||
const referencedMessage =
|
||||
message.reference && message.mentionedUserIds.size > 0
|
||||
message.reference?.messageId && message.mentionedUserIds.size > 0
|
||||
? await this.channelRepository.messages.getMessage(message.reference.channelId, message.reference.messageId)
|
||||
: null;
|
||||
const repair = await this.mentionService.buildReadRepairMentionData({message, referencedMessage});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import type {CrosspostSourceService} from '@app/api/channel/services/message/CrosspostSourceService';
|
||||
import {isPersonalNotesChannel} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import type {MessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
@@ -10,6 +11,7 @@ import type {User} from '@app/api/models/User';
|
||||
import {mapWithConcurrency} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {UnclaimedAccountCannotSendMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendMessagesError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import type {CrosspostSourceResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas';
|
||||
import type {
|
||||
BulkMessageFetchResponse,
|
||||
MessageResponse,
|
||||
@@ -19,6 +21,7 @@ export class MessageRequestService {
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly responseDataService: MessageResponseDataService,
|
||||
private readonly crosspostSourceService: CrosspostSourceService,
|
||||
) {}
|
||||
|
||||
async listMessages(params: {
|
||||
@@ -95,6 +98,16 @@ export class MessageRequestService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async getCrosspostSource(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<CrosspostSourceResponse> {
|
||||
const message = await this.getMessage(params);
|
||||
return this.crosspostSourceService.getSource(message);
|
||||
}
|
||||
|
||||
async sendMessage(params: {
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
@@ -127,6 +140,26 @@ export class MessageRequestService {
|
||||
});
|
||||
}
|
||||
|
||||
async crosspostMessage(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<MessageResponse> {
|
||||
const {message, authChannel} = await this.channelService.messages.crosspost.crosspostMessage(params);
|
||||
const access = await this.channelService.messages.retrieval.getResponseAccessContext({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
messageId: message.id,
|
||||
authChannel,
|
||||
});
|
||||
return this.responseDataService.buildMessage({
|
||||
userId: params.userId,
|
||||
message,
|
||||
access,
|
||||
});
|
||||
}
|
||||
|
||||
async editMessage(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
|
||||
@@ -7,7 +7,11 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC
|
||||
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
|
||||
import {getDmChannelIdsForScope} from '@app/api/channel/services/message/DmScopeUtils';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import {collectMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
attachmentStorageChannelId,
|
||||
collectMessageAttachments,
|
||||
isCrosspostCopy,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import type {MessageProcessingService} from '@app/api/channel/services/message/MessageProcessingService';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
@@ -254,11 +258,13 @@ export class MessageRetrievalService {
|
||||
}> {
|
||||
const attachments = collectMessageAttachments(message);
|
||||
if (attachments.length === 0) return [];
|
||||
const uploadedAt = snowflakeToDate(message.id);
|
||||
const ownerMessageId = isCrosspostCopy(message) ? (message.reference?.messageId ?? message.id) : message.id;
|
||||
const uploadedAt = snowflakeToDate(ownerMessageId);
|
||||
const storageChannelId = attachmentStorageChannelId(message);
|
||||
return attachments.map((attachment) => ({
|
||||
attachmentId: attachment.id,
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
channelId: storageChannelId,
|
||||
messageId: ownerMessageId,
|
||||
filename: attachment.filename,
|
||||
sizeBytes: attachment.size,
|
||||
uploadedAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/Messag
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
|
||||
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
|
||||
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {emitMessageCreated} from '@app/api/channel/services/message/MessageActivity';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import type {DmNsfwContext} from '@app/api/channel/services/message/MessageContentService';
|
||||
@@ -26,12 +27,14 @@ import {
|
||||
isOperationDisabled,
|
||||
isPersonalNotesChannel,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {assertMessageWithinHistoryCutoff} from '@app/api/channel/services/message/MessageHistoryCutoff';
|
||||
import type {MessageMentionService} from '@app/api/channel/services/message/MessageMentionService';
|
||||
import type {MessageOperationsHelpers} from '@app/api/channel/services/message/MessageOperationsHelpers';
|
||||
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
|
||||
import type {MessageProcessingService} from '@app/api/channel/services/message/MessageProcessingService';
|
||||
import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService';
|
||||
import type {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService';
|
||||
import type {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {MessageAttachment, MessageReference} from '@app/api/database/types/MessageTypes';
|
||||
import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository';
|
||||
@@ -75,7 +78,6 @@ import {SlowmodeRateLimitError} from '@fluxer/errors/src/domains/core/SlowmodeRa
|
||||
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
|
||||
interface MessageSendServiceDeps {
|
||||
@@ -97,6 +99,8 @@ interface MessageSendServiceDeps {
|
||||
embedAttachmentResolver: MessageEmbedAttachmentResolver;
|
||||
attachmentUploadTraceRepository: AttachmentUploadTraceRepository;
|
||||
limitConfigService: LimitConfigService;
|
||||
messageWriteLock: MessageWriteLock;
|
||||
crosspostPropagation: CrosspostPropagation;
|
||||
}
|
||||
|
||||
interface SendMessageResult {
|
||||
@@ -360,8 +364,8 @@ export class MessageSendService {
|
||||
if (data.message_reference && guild && !isForwardMessage) {
|
||||
const hasReadHistory = await hasPermission(Permissions.READ_MESSAGE_HISTORY);
|
||||
if (!hasReadHistory) {
|
||||
this.assertReferencedMessageWithinCutoff({
|
||||
referencedMessage,
|
||||
assertMessageWithinHistoryCutoff({
|
||||
message: referencedMessage,
|
||||
guild,
|
||||
});
|
||||
}
|
||||
@@ -722,27 +726,6 @@ export class MessageSendService {
|
||||
return {attachmentsToProcess, favoriteMemeAttachment};
|
||||
}
|
||||
|
||||
private assertReferencedMessageWithinCutoff({
|
||||
referencedMessage,
|
||||
guild,
|
||||
}: {
|
||||
referencedMessage: Message | null;
|
||||
guild: GuildResponse;
|
||||
}): void {
|
||||
if (!referencedMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const cutoff = guild.message_history_cutoff;
|
||||
if (!cutoff) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const messageTimestamp = snowflakeToDate(referencedMessage.id).getTime();
|
||||
const cutoffTimestamp = new Date(cutoff).getTime();
|
||||
if (messageTimestamp < cutoffTimestamp) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageTypeForRequest(data: MessageRequest): number {
|
||||
if (!data.message_reference) {
|
||||
return MessageTypes.DEFAULT;
|
||||
@@ -859,8 +842,8 @@ export class MessageSendService {
|
||||
if (data.message_reference && guild && !isForwardMessage) {
|
||||
const hasReadHistory = await hasPermission(Permissions.READ_MESSAGE_HISTORY);
|
||||
if (!hasReadHistory) {
|
||||
this.assertReferencedMessageWithinCutoff({
|
||||
referencedMessage,
|
||||
assertMessageWithinHistoryCutoff({
|
||||
message: referencedMessage,
|
||||
guild,
|
||||
});
|
||||
}
|
||||
@@ -1276,16 +1259,30 @@ export class MessageSendService {
|
||||
existingAttachments: existingMessage.attachments.map((att) => ({filename: att.filename})),
|
||||
});
|
||||
}
|
||||
const {message: updatedMessage, enqueueDeferredEmbeds} = await this.deps.persistenceService.updateMessage({
|
||||
message: existingMessage,
|
||||
const attachmentUploadUserId = await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments);
|
||||
const {message: updatedMessage, enqueueDeferredEmbeds} = await this.deps.messageWriteLock.withFreshMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
attachmentUploadUserId: await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
allowEmbeds: true,
|
||||
});
|
||||
async (fresh) => {
|
||||
if (!fresh) throw new UnknownMessageError();
|
||||
if (fresh.webhookId !== webhook.id) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
return this.deps.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'webhook'}, () =>
|
||||
this.deps.persistenceService.updateMessage({
|
||||
message: fresh,
|
||||
messageId,
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
attachmentUploadUserId,
|
||||
allowEmbeds: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
await this.deps.crosspostPropagation.propagateEdit(updatedMessage);
|
||||
void enqueueDeferredEmbeds().catch((error) => {
|
||||
Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction after edit');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID} from '@app/api/BrandedTypes';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
const MESSAGE_WRITE_LOCK_TTL_SECONDS = 5;
|
||||
const MESSAGE_WRITE_LOCK_ACQUIRE_ATTEMPTS = 6;
|
||||
const MESSAGE_WRITE_LOCK_RETRY_DELAY_MS = 50;
|
||||
|
||||
export interface MessageWriteLockReader {
|
||||
getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
|
||||
}
|
||||
|
||||
export function messageWriteLockKey(channelId: ChannelID, messageId: MessageID): string {
|
||||
return `message:${channelId}:${messageId}:write`;
|
||||
}
|
||||
|
||||
export class MessageWriteLock {
|
||||
constructor(
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly messages: MessageWriteLockReader,
|
||||
) {}
|
||||
|
||||
async withLock<T>(channelId: ChannelID, messageId: MessageID, fn: () => Promise<T>): Promise<T> {
|
||||
const lockKey = messageWriteLockKey(channelId, messageId);
|
||||
let lockToken: string | null = null;
|
||||
for (let attempt = 0; attempt < MESSAGE_WRITE_LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
||||
lockToken = await this.cacheService.acquireLock(lockKey, MESSAGE_WRITE_LOCK_TTL_SECONDS);
|
||||
if (lockToken) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, MESSAGE_WRITE_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 this.cacheService.releaseLock(lockKey, lockToken).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async withFreshMessage<T>(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
fn: (fresh: Message | null) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.withLock(channelId, messageId, async () => {
|
||||
const fresh = await this.messages.getMessage(channelId, messageId);
|
||||
return fn(fresh);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {
|
||||
type CrosspostWorkerService,
|
||||
enqueueCrosspostSourceRemoval,
|
||||
} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
isChannelEligible,
|
||||
@@ -26,6 +30,7 @@ interface UserMessageDeletionServiceDeps {
|
||||
gatewayService: IGatewayService;
|
||||
storageService: IStorageService;
|
||||
purgeQueue: IPurgeQueue;
|
||||
workerService: CrosspostWorkerService;
|
||||
}
|
||||
|
||||
interface DeleteUserMessagesScope {
|
||||
@@ -232,6 +237,11 @@ export class UserMessageDeletionService {
|
||||
);
|
||||
await this.deps.channelRepository.bulkDeleteMessages(channelId, messageIds);
|
||||
await this.eventDispatcher.dispatchBulkDelete(channel, messageIds);
|
||||
await enqueueCrosspostSourceRemoval(this.deps.workerService, {
|
||||
messages: messageObjects,
|
||||
mode: 'source_deleted',
|
||||
channel,
|
||||
});
|
||||
await deleteMessageSearchDocuments(messageIds, {context: {source: 'bulk_user_message_delete'}});
|
||||
deleted += batch.length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannelID, createGuildID, createUserID, createWebhookID, createWebhookToken} from '@app/api/BrandedTypes';
|
||||
import {disableCrosspostWorker, followRequest, publishRequest} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
createGuild,
|
||||
deleteChannel,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
updateChannel,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
copiesOf,
|
||||
editRequest,
|
||||
type FanoutWorld,
|
||||
followInto,
|
||||
postAndPublish,
|
||||
sendMessage,
|
||||
setupFanoutWorld,
|
||||
} from '@app/api/channel/tests/CrosspostWorkerTestUtils';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {createWebhook, getChannelWebhooks} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import {WebhookRepository} from '@app/api/webhook/WebhookRepository';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ChannelTypes, WebhookTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import type {WorkerJobOptions, WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
interface RecordedJob {
|
||||
taskType: string;
|
||||
payload: WorkerJobPayload;
|
||||
options: WorkerJobOptions | undefined;
|
||||
}
|
||||
|
||||
class RecordingWorkerService implements IWorkerService {
|
||||
readonly jobs: Array<RecordedJob> = [];
|
||||
private nextJobId = 1n;
|
||||
|
||||
async addJob<TPayload extends WorkerJobPayload = WorkerJobPayload>(
|
||||
taskType: string,
|
||||
payload: TPayload,
|
||||
options?: WorkerJobOptions,
|
||||
): Promise<bigint> {
|
||||
this.jobs.push({taskType, payload, options});
|
||||
return this.nextJobId++;
|
||||
}
|
||||
|
||||
async cancelJob(_jobId: bigint): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async retryDeadLetterJob(_jobId: bigint): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
followerRemovals(): Array<RecordedJob> {
|
||||
return this.jobs.filter((job) => job.taskType === 'removeChannelFollowers');
|
||||
}
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
audit_log_entries: Array<{
|
||||
action_type: number;
|
||||
target_id: string | null;
|
||||
changes?: Array<{key: string; old_value?: unknown; new_value?: unknown}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function insertFollowerWebhook(params: {guildId: string; channelId: string; creatorId: string}): Promise<string> {
|
||||
const webhookId = createWebhookID(BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000)));
|
||||
await new WebhookRepository().create({
|
||||
webhookId,
|
||||
token: createWebhookToken('f'.repeat(64)),
|
||||
type: WebhookTypes.CHANNEL_FOLLOWER,
|
||||
guildId: createGuildID(BigInt(params.guildId)),
|
||||
channelId: createChannelID(BigInt(params.channelId)),
|
||||
creatorId: createUserID(BigInt(params.creatorId)),
|
||||
name: 'Source Guild #news',
|
||||
avatarHash: null,
|
||||
});
|
||||
return webhookId.toString();
|
||||
}
|
||||
|
||||
describe('Announcement channel conversion', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let worker: RecordingWorkerService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
worker = new RecordingWorkerService();
|
||||
setInjectedWorkerService(worker);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('converts a text channel to an announcement channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Convert Guild');
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_TEXT);
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const converted = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${text.id}`)
|
||||
.body({type: ChannelTypes.GUILD_ANNOUNCEMENT})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(converted.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect((await getChannel(harness, owner.token, text.id)).type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const channelUpdates = dispatchSpy.mock.calls
|
||||
.map(([params]) => params)
|
||||
.filter((params) => params.event === 'CHANNEL_UPDATE');
|
||||
expect(channelUpdates).toHaveLength(1);
|
||||
expect(channelUpdates[0]?.data).toMatchObject({type: ChannelTypes.GUILD_ANNOUNCEMENT});
|
||||
const auditLog = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.CHANNEL_UPDATE}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const entry = auditLog.audit_log_entries.find((candidate) => candidate.target_id === text.id);
|
||||
const typeChange = entry?.changes?.find((change) => change.key === 'type');
|
||||
expect(typeChange?.old_value).toBe(ChannelTypes.GUILD_TEXT);
|
||||
expect(typeChange?.new_value).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(worker.followerRemovals()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('converts an announcement channel back to text and schedules follower removal', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Revert Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const converted = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${announcement.id}`)
|
||||
.body({type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(converted.type).toBe(ChannelTypes.GUILD_TEXT);
|
||||
const removals = worker.followerRemovals();
|
||||
expect(removals).toHaveLength(1);
|
||||
expect(removals[0]?.payload).toEqual({sourceChannelId: announcement.id, reason: 'converted'});
|
||||
expect(removals[0]?.options?.jobKey).toMatch(new RegExp(`^remove-followers:${announcement.id}:converted:\\d+$`));
|
||||
expect(removals[0]?.options?.skipLedger).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps the type when the body has no type or repeats the current type', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Keep Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const renamed = await updateChannel(harness, owner.token, announcement.id, {topic: 'release notes'});
|
||||
expect(renamed.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(renamed.topic).toBe('release notes');
|
||||
const repeated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${announcement.id}`)
|
||||
.body({type: ChannelTypes.GUILD_ANNOUNCEMENT, topic: 'still news'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(repeated.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const nulled = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${announcement.id}`)
|
||||
.body({type: null, topic: 'null type'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(nulled.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(worker.followerRemovals()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects conversions outside text and announcement', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Reject Guild');
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'text', ChannelTypes.GUILD_TEXT);
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const attempts: Array<{channelId: string; type: number | string}> = [
|
||||
{channelId: text.id, type: ChannelTypes.GUILD_VOICE},
|
||||
{channelId: text.id, type: ChannelTypes.GUILD_CATEGORY},
|
||||
{channelId: text.id, type: 'announcement'},
|
||||
{channelId: announcement.id, type: ChannelTypes.GUILD_VOICE},
|
||||
{channelId: announcement.id, type: ChannelTypes.GROUP_DM},
|
||||
];
|
||||
for (const attempt of attempts) {
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/channels/${attempt.channelId}`)
|
||||
.body({type: attempt.type})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CHANNEL_TYPE_CONVERSION_NOT_SUPPORTED)
|
||||
.execute();
|
||||
}
|
||||
expect((await getChannel(harness, owner.token, text.id)).type).toBe(ChannelTypes.GUILD_TEXT);
|
||||
expect((await getChannel(harness, owner.token, announcement.id)).type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
});
|
||||
|
||||
test('ignores a body type on channels that cannot be converted', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
await createFriendship(harness, owner, friend);
|
||||
const guild = await createGuild(harness, owner.token, 'Ignore Guild');
|
||||
const voice = await createChannel(harness, owner.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE);
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'category', ChannelTypes.GUILD_CATEGORY);
|
||||
const link = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'link', type: ChannelTypes.GUILD_LINK, url: 'https://example.com'})
|
||||
.execute();
|
||||
const groupDm = await createGroupDmChannel(harness, owner.token, [friend.userId]);
|
||||
const attempts: Array<{channelId: string; type: number; expected: number}> = [
|
||||
{channelId: voice.id, type: ChannelTypes.GUILD_TEXT, expected: ChannelTypes.GUILD_VOICE},
|
||||
{channelId: voice.id, type: ChannelTypes.GUILD_ANNOUNCEMENT, expected: ChannelTypes.GUILD_VOICE},
|
||||
{channelId: category.id, type: ChannelTypes.GUILD_ANNOUNCEMENT, expected: ChannelTypes.GUILD_CATEGORY},
|
||||
{channelId: link.id, type: ChannelTypes.GUILD_TEXT, expected: ChannelTypes.GUILD_LINK},
|
||||
{channelId: groupDm.id, type: ChannelTypes.GUILD_ANNOUNCEMENT, expected: ChannelTypes.GROUP_DM},
|
||||
];
|
||||
for (const [index, attempt] of attempts.entries()) {
|
||||
const name = `renamed-${index}`;
|
||||
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${attempt.channelId}`)
|
||||
.body({type: attempt.type, name})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(updated.type).toBe(attempt.expected);
|
||||
expect(updated.name).toBe(name);
|
||||
const stored = await getChannel(harness, owner.token, attempt.channelId);
|
||||
expect(stored.type).toBe(attempt.expected);
|
||||
expect(stored.name).toBe(name);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires MANAGE_CHANNELS', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_TEXT);
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'ann', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/channels/${text.id}`)
|
||||
.body({type: ChannelTypes.GUILD_ANNOUNCEMENT})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/channels/${announcement.id}`)
|
||||
.body({type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
expect((await getChannel(harness, owner.token, text.id)).type).toBe(ChannelTypes.GUILD_TEXT);
|
||||
expect((await getChannel(harness, owner.token, announcement.id)).type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(worker.followerRemovals()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('refuses to convert a channel that receives follows until the follow is removed', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Followed Guild');
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'feed', ChannelTypes.GUILD_TEXT);
|
||||
await createWebhook(harness, text.id, owner.token, 'Incoming Bot');
|
||||
const followerWebhookId = await insertFollowerWebhook({
|
||||
guildId: guild.id,
|
||||
channelId: text.id,
|
||||
creatorId: owner.userId,
|
||||
});
|
||||
const channelWebhooks = await getChannelWebhooks(harness, text.id, owner.token);
|
||||
expect(channelWebhooks.some((webhook) => webhook.id === followerWebhookId)).toBe(true);
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/channels/${text.id}`)
|
||||
.body({type: ChannelTypes.GUILD_ANNOUNCEMENT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CHANNEL_HAS_FOLLOWED_CHANNELS)
|
||||
.execute();
|
||||
expect((await getChannel(harness, owner.token, text.id)).type).toBe(ChannelTypes.GUILD_TEXT);
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/webhooks/${followerWebhookId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const converted = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${text.id}`)
|
||||
.body({type: ChannelTypes.GUILD_ANNOUNCEMENT})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(converted.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
});
|
||||
|
||||
test('an incoming webhook does not block conversion', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Incoming Guild');
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'feed', ChannelTypes.GUILD_TEXT);
|
||||
await createWebhook(harness, text.id, owner.token, 'Incoming Bot');
|
||||
const converted = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${text.id}`)
|
||||
.body({type: ChannelTypes.GUILD_ANNOUNCEMENT})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(converted.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
});
|
||||
|
||||
test('records a distinct job for every conversion back to text', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Flip Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
for (const type of [ChannelTypes.GUILD_TEXT, ChannelTypes.GUILD_ANNOUNCEMENT, ChannelTypes.GUILD_TEXT]) {
|
||||
await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${announcement.id}`)
|
||||
.body({type})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
const removals = worker.followerRemovals();
|
||||
expect(removals).toHaveLength(2);
|
||||
const jobKeys = new Set(removals.map((job) => job.options?.jobKey));
|
||||
expect(jobKeys.size).toBe(2);
|
||||
});
|
||||
|
||||
test('deleting an announcement channel schedules follower removal with source-deleted copies', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Delete Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'text', ChannelTypes.GUILD_TEXT);
|
||||
await deleteChannel(harness, owner.token, text.id);
|
||||
expect(worker.followerRemovals()).toHaveLength(0);
|
||||
await deleteChannel(harness, owner.token, announcement.id);
|
||||
const removals = worker.followerRemovals();
|
||||
expect(removals).toHaveLength(1);
|
||||
expect(removals[0]?.payload).toEqual({
|
||||
sourceChannelId: announcement.id,
|
||||
reason: 'deleted',
|
||||
copyMode: 'source_deleted',
|
||||
});
|
||||
expect(removals[0]?.options?.jobKey).toMatch(new RegExp(`^remove-followers:${announcement.id}:deleted:\\d+$`));
|
||||
});
|
||||
|
||||
test('deleting a guild schedules follower removal for each announcement channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Owner Delete Guild');
|
||||
const first = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const second = await createChannel(harness, owner.token, guild.id, 'updates', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/delete`)
|
||||
.body({password: owner.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
const removals = worker.followerRemovals();
|
||||
expect(removals.map((job) => job.payload.sourceChannelId).sort()).toEqual([first.id, second.id].sort());
|
||||
for (const job of removals) {
|
||||
expect(job.payload.reason).toBe('deleted');
|
||||
expect(job.payload.copyMode).toBe('source_deleted');
|
||||
}
|
||||
});
|
||||
|
||||
test('admin guild deletion purges copies', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup', 'guild:delete']);
|
||||
const guild = await createGuild(harness, admin.token, 'Admin Delete Guild');
|
||||
const announcement = await createChannel(harness, admin.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/guilds/${guild.id}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const removals = worker.followerRemovals();
|
||||
expect(removals).toHaveLength(1);
|
||||
expect(removals[0]?.payload).toEqual({
|
||||
sourceChannelId: announcement.id,
|
||||
reason: 'deleted',
|
||||
copyMode: 'purge',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Announcement channel conversion with the crosspost worker', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let world: FanoutWorld;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
world = await setupFanoutWorld(harness);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disableCrosspostWorker();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function convert(type: number): Promise<void> {
|
||||
await createBuilder<ChannelResponse>(harness, world.a.owner.token)
|
||||
.patch(`/channels/${world.a.ann.id}`)
|
||||
.body({type})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function followerWebhooks(channelId: string) {
|
||||
const webhooks = await new WebhookRepository().listByChannel(createChannelID(BigInt(channelId)));
|
||||
return webhooks.filter((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER);
|
||||
}
|
||||
|
||||
test('converting to text removes every follower after the drain and leaves copies propagating', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await followInto(harness, world, world.b.t2.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'before conversion'});
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
await convert(ChannelTypes.GUILD_TEXT);
|
||||
expect(world.worker.byTask('removeChannelFollowers')).toHaveLength(1);
|
||||
expect(await followerWebhooks(world.b.t1.id)).toHaveLength(1);
|
||||
await world.worker.drain();
|
||||
expect(world.worker.deadLetters).toHaveLength(0);
|
||||
expect(await followerWebhooks(world.b.t1.id)).toHaveLength(0);
|
||||
expect(await followerWebhooks(world.b.t2.id)).toHaveLength(0);
|
||||
const updates = dispatchSpy.mock.calls
|
||||
.map(([params]) => params)
|
||||
.filter((params) => params.event === 'WEBHOOKS_UPDATE' && params.guildId.toString() === world.b.guild.id);
|
||||
expect(updates.map((params) => (params.data as {channel_id: string}).channel_id).sort()).toEqual(
|
||||
[world.b.t1.id, world.b.t2.id].sort(),
|
||||
);
|
||||
const audit = await createBuilder<AuditLogResponse>(harness, world.b.owner.token)
|
||||
.get(`/guilds/${world.b.guild.id}/audit-logs?action_type=${AuditLogActionType.WEBHOOK_DELETE}`)
|
||||
.execute();
|
||||
expect(audit.audit_log_entries).toHaveLength(0);
|
||||
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
||||
expect(copy!.content).toBe('before conversion');
|
||||
await editRequest(harness, world.a.owner.token, world.a.ann.id, source.id, {content: 'after conversion'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
for (const channelId of [world.b.t1.id, world.b.t2.id]) {
|
||||
const [updated] = await copiesOf(harness, world.b.owner.token, channelId, source.id);
|
||||
expect(updated!.content).toBe('after conversion');
|
||||
}
|
||||
});
|
||||
|
||||
test('converting back before the drain keeps the followers', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await convert(ChannelTypes.GUILD_TEXT);
|
||||
await convert(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await world.worker.drain();
|
||||
expect(await followerWebhooks(world.b.t1.id)).toHaveLength(1);
|
||||
const source = await postAndPublish(harness, world, {content: 'still followed'});
|
||||
expect(await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('flipping twice records two removals and ends with no followers', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await convert(ChannelTypes.GUILD_TEXT);
|
||||
await convert(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await convert(ChannelTypes.GUILD_TEXT);
|
||||
const removals = world.worker.byTask('removeChannelFollowers');
|
||||
expect(new Set(removals.map((job) => job.jobKey)).size).toBe(2);
|
||||
await world.worker.drain();
|
||||
expect(await followerWebhooks(world.b.t1.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('publishing or following after converting to text is refused', async () => {
|
||||
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'late'});
|
||||
await convert(ChannelTypes.GUILD_TEXT);
|
||||
await publishRequest(harness, world.a.owner.token, world.a.ann.id, message.id)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.ANNOUNCEMENT_CHANNEL_REQUIRED)
|
||||
.execute();
|
||||
await followRequest(harness, world.b.owner.token, world.a.ann.id, world.b.t1.id)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.ANNOUNCEMENT_CHANNEL_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {sendMessageWithAttachments} from '@app/api/channel/tests/AttachmentTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
getChannel,
|
||||
getGuild,
|
||||
sendChannelMessage,
|
||||
setupTestGuildWithMembers,
|
||||
updateChannel,
|
||||
updateGuild,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getGuildChannels, updateChannelPositions} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {deleteMessage, ensureSessionStarted, getMessages, pinMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
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 {createWebhook, executeWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {sortChannelsForOrdering} from '@fluxer/schema/src/domains/channel/GuildChannelOrdering';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Announcement channel type', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('creates a type 5 channel with the text name rules', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Announcement Guild');
|
||||
const text = await createChannel(harness, owner.token, guild.id, 'Big News', ChannelTypes.GUILD_TEXT);
|
||||
const announcement = await createChannel(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'Big News',
|
||||
ChannelTypes.GUILD_ANNOUNCEMENT,
|
||||
);
|
||||
expect(announcement.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(announcement.name).toBe(text.name);
|
||||
expect(announcement.name).not.toBe('Big News');
|
||||
const fetched = await getChannel(harness, owner.token, announcement.id);
|
||||
expect(fetched.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
});
|
||||
|
||||
test('keeps the name as typed when flexible names are enabled', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Flexible Guild');
|
||||
await updateGuild(harness, owner.token, guild.id, {
|
||||
features: [...guild.features, GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES],
|
||||
});
|
||||
const announcement = await createChannel(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'Big News',
|
||||
ChannelTypes.GUILD_ANNOUNCEMENT,
|
||||
);
|
||||
expect(announcement.name).toBe('Big News');
|
||||
const renamed = await updateChannel(harness, owner.token, announcement.id, {name: 'Even Bigger News'});
|
||||
expect(renamed.name).toBe('Even Bigger News');
|
||||
expect(renamed.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
});
|
||||
|
||||
test('normalises a rename like a text channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Rename Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const renamed = await updateChannel(harness, owner.token, announcement.id, {name: 'Release Notes'});
|
||||
expect(renamed.name).not.toBe('Release Notes');
|
||||
expect(renamed.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
});
|
||||
|
||||
test('lists the channel with text before voice in a category', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Ordering Guild');
|
||||
const category = await createChannel(harness, owner.token, guild.id, 'Mixed', ChannelTypes.GUILD_CATEGORY);
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const voice = await createChannel(harness, owner.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE);
|
||||
await updateChannelPositions(harness, owner.token, guild.id, [
|
||||
{id: voice.id, parent_id: category.id},
|
||||
{id: announcement.id, parent_id: category.id},
|
||||
]);
|
||||
const channels = await getGuildChannels(harness, owner.token, guild.id);
|
||||
const listed = channels.find((channel) => channel.id === announcement.id);
|
||||
expect(listed?.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const ordered = sortChannelsForOrdering(
|
||||
channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
parentId: channel.parent_id ?? null,
|
||||
type: channel.type,
|
||||
position: channel.position,
|
||||
})),
|
||||
)
|
||||
.filter((channel) => channel.parentId === category.id)
|
||||
.map((channel) => channel.id);
|
||||
expect(ordered.indexOf(announcement.id)).toBeLessThan(ordered.indexOf(voice.id));
|
||||
const response = await createBuilder<{code: string; errors: Array<{path: string; code: string}>}>(
|
||||
harness,
|
||||
owner.token,
|
||||
)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: announcement.id, parent_id: category.id, preceding_sibling_id: voice.id}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS);
|
||||
});
|
||||
|
||||
test('supports sending, editing, reacting, pinning, uploading and deleting', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Messaging Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
const message = await sendChannelMessage(harness, owner.token, announcement.id, 'first announcement');
|
||||
expect(message.channel_id).toBe(announcement.id);
|
||||
const edited = await createBuilder<MessageResponse>(harness, owner.token)
|
||||
.patch(`/channels/${announcement.id}/messages/${message.id}`)
|
||||
.body({content: 'edited announcement'})
|
||||
.execute();
|
||||
expect(edited.content).toBe('edited announcement');
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${announcement.id}/messages/${message.id}/reactions/%F0%9F%91%8D/@me`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
await pinMessage(harness, owner.token, announcement.id, message.id);
|
||||
const {response: uploadResponse, json: uploaded} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
owner.token,
|
||||
announcement.id,
|
||||
{content: 'with a file', attachments: [{id: 0, filename: 'notes.txt'}]},
|
||||
[{index: 0, filename: 'notes.txt', data: Buffer.from('release notes')}],
|
||||
);
|
||||
expect(uploadResponse.status).toBe(HTTP_STATUS.OK);
|
||||
expect(uploaded.attachments).toHaveLength(1);
|
||||
const messages = await getMessages(harness, owner.token, announcement.id);
|
||||
const reacted = messages.find((entry) => entry.id === message.id);
|
||||
expect(reacted?.reactions?.[0]?.count).toBe(1);
|
||||
expect(reacted?.pinned).toBe(true);
|
||||
await deleteMessage(harness, owner.token, announcement.id, uploaded.id);
|
||||
const remaining = await getMessages(harness, owner.token, announcement.id);
|
||||
expect(remaining.some((entry) => entry.id === uploaded.id)).toBe(false);
|
||||
});
|
||||
|
||||
test('enforces slowmode', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const updated = await updateChannel(harness, owner.token, announcement.id, {rate_limit_per_user: 30});
|
||||
expect(updated.rate_limit_per_user).toBe(30);
|
||||
await sendChannelMessage(harness, member.token, announcement.id, 'first');
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${announcement.id}/messages`)
|
||||
.body({content: 'second'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.SLOWMODE_RATE_LIMITED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('creates and executes an incoming webhook', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Webhook Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const webhook = await createWebhook(harness, announcement.id, owner.token, 'Release Bot');
|
||||
expect(webhook.channel_id).toBe(announcement.id);
|
||||
const {json} = await executeWebhook(harness, webhook.id, webhook.token, {content: 'shipped', wait: true}, 200);
|
||||
expect(json?.channel_id).toBe(announcement.id);
|
||||
expect(json?.webhook_id).toBe(webhook.id);
|
||||
});
|
||||
|
||||
test('applies the age gate to an age-restricted announcement channel', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const minor = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Gate Guild');
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, minor.token, invite.code);
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
await sendChannelMessage(harness, owner.token, announcement.id, 'before the gate');
|
||||
const minorView = await getMessages(harness, minor.token, announcement.id);
|
||||
expect(minorView).toHaveLength(1);
|
||||
const restricted = await updateChannel(harness, owner.token, announcement.id, {nsfw: true});
|
||||
expect(restricted.nsfw).toBe(true);
|
||||
await createBuilder(harness, minor.token)
|
||||
.get(`/channels/${announcement.id}/messages`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED)
|
||||
.execute();
|
||||
const adultView = await getMessages(harness, owner.token, announcement.id);
|
||||
expect(adultView).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('can be the system channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'System Guild');
|
||||
const announcement = await createChannel(harness, owner.token, guild.id, 'news', ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
const updated = await updateGuild(harness, owner.token, guild.id, {system_channel_id: announcement.id});
|
||||
expect(updated.system_channel_id).toBe(announcement.id);
|
||||
const fetched = await getGuild(harness, owner.token, guild.id);
|
||||
expect(fetched.system_channel_id).toBe(announcement.id);
|
||||
});
|
||||
|
||||
test('still rejects a voice channel as the system channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Voice System Guild');
|
||||
const voice = await createChannel(harness, owner.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE);
|
||||
const response = await createBuilder<{errors: Array<{path: string; code: string}>}>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({system_channel_id: voice.id})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_TEXT);
|
||||
});
|
||||
|
||||
test('imports a template announcement channel as type 5 and keeps it as the system channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createBuilder<{id: string; system_channel_id: string | null}>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Template Guild',
|
||||
template: {
|
||||
name: 'Template Source',
|
||||
description: null,
|
||||
verification_level: 0,
|
||||
default_message_notifications: 0,
|
||||
explicit_content_filter: 0,
|
||||
system_channel_id: '7002',
|
||||
afk_timeout: 300,
|
||||
system_channel_flags: 0,
|
||||
roles: [{id: '0', name: '@everyone', permissions: '0'}],
|
||||
channels: [
|
||||
{id: '7001', type: ChannelTypes.GUILD_VOICE, name: 'lounge', position: 0},
|
||||
{id: '7002', type: ChannelTypes.GUILD_ANNOUNCEMENT, name: 'news', position: 1},
|
||||
],
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
const channels = await getGuildChannels(harness, owner.token, guild.id);
|
||||
const news = channels.find((channel: ChannelResponse) => channel.name === 'news');
|
||||
expect(news?.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(guild.system_channel_id).toBe(news?.id);
|
||||
});
|
||||
|
||||
test('uses a template announcement channel as the fallback system channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createBuilder<{id: string; system_channel_id: string | null}>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Fallback Guild',
|
||||
template: {
|
||||
name: 'Template Source',
|
||||
description: null,
|
||||
verification_level: 0,
|
||||
default_message_notifications: 0,
|
||||
explicit_content_filter: 0,
|
||||
system_channel_id: null,
|
||||
afk_timeout: 300,
|
||||
system_channel_flags: 0,
|
||||
roles: [{id: '0', name: '@everyone', permissions: '0'}],
|
||||
channels: [{id: '8001', type: ChannelTypes.GUILD_ANNOUNCEMENT, name: 'news', position: 0}],
|
||||
},
|
||||
})
|
||||
.execute();
|
||||
const channels = await getGuildChannels(harness, owner.token, guild.id);
|
||||
expect(channels).toHaveLength(1);
|
||||
expect(channels[0]?.type).toBe(ChannelTypes.GUILD_ANNOUNCEMENT);
|
||||
expect(guild.system_channel_id).toBe(channels[0]?.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createWebhookID} from '@app/api/BrandedTypes';
|
||||
import {resetApiServicesForTesting} from '@app/api/CreateApiContext';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createPermissionOverwrite,
|
||||
createRole,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {createTestChannelService} from '@app/api/channel/tests/CrosspostTestUtils';
|
||||
import {getGatewayService, getSnowflakeService, setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
getAvatarService,
|
||||
getCacheService,
|
||||
getChannelRepository,
|
||||
getGuildRepository,
|
||||
getLimitConfigService,
|
||||
getPurgeQueue,
|
||||
getStorageService,
|
||||
getUserRepository,
|
||||
getWebhookRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {SyncTaskWorkerService} from '@app/api/test/SyncTaskWorkerService';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {WebhookRepository} from '@app/api/webhook/WebhookRepository';
|
||||
import crosspostMessage from '@app/api/worker/tasks/CrosspostMessage';
|
||||
import crosspostMessageChunk from '@app/api/worker/tasks/CrosspostMessageChunk';
|
||||
import removeChannelFollowers from '@app/api/worker/tasks/RemoveChannelFollowers';
|
||||
import syncCrosspostCopies from '@app/api/worker/tasks/SyncCrosspostCopies';
|
||||
import syncCrosspostedMessage from '@app/api/worker/tasks/SyncCrosspostedMessage';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import type {FollowedChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelFollowSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
export interface AnnouncementSourceGuild {
|
||||
owner: TestAccount;
|
||||
member: TestAccount;
|
||||
moderator: TestAccount;
|
||||
guild: GuildResponse;
|
||||
ann: ChannelResponse;
|
||||
}
|
||||
|
||||
export interface AnnouncementTargetGuild {
|
||||
owner: TestAccount;
|
||||
webhookManager: TestAccount;
|
||||
guild: GuildResponse;
|
||||
t1: ChannelResponse;
|
||||
t2: ChannelResponse;
|
||||
voice: ChannelResponse;
|
||||
ageRestricted: ChannelResponse;
|
||||
contentWarning: ChannelResponse;
|
||||
}
|
||||
|
||||
export interface AnnouncementWorld {
|
||||
a: AnnouncementSourceGuild;
|
||||
b: AnnouncementTargetGuild;
|
||||
}
|
||||
|
||||
export async function createGuildChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).post(`/guilds/${guildId}/channels`).body(body).execute();
|
||||
}
|
||||
|
||||
export async function addGuildMember(
|
||||
harness: ApiTestHarness,
|
||||
owner: TestAccount,
|
||||
guild: GuildResponse,
|
||||
member: TestAccount,
|
||||
): Promise<void> {
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
}
|
||||
|
||||
export async function grantGuildRole(
|
||||
harness: ApiTestHarness,
|
||||
owner: TestAccount,
|
||||
guildId: string,
|
||||
member: TestAccount,
|
||||
permissions: bigint,
|
||||
name = 'Announcement role',
|
||||
): Promise<string> {
|
||||
const role = await createRole(harness, owner.token, guildId, {
|
||||
name,
|
||||
permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES | permissions).toString(),
|
||||
});
|
||||
await addMemberRole(harness, owner.token, guildId, member.userId, role.id);
|
||||
return role.id;
|
||||
}
|
||||
|
||||
export async function createAnnouncementSourceGuild(
|
||||
harness: ApiTestHarness,
|
||||
name = 'Announcement Source',
|
||||
): Promise<AnnouncementSourceGuild> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
const ann = await createGuildChannel(harness, owner.token, guild.id, {
|
||||
name: 'news',
|
||||
type: ChannelTypes.GUILD_ANNOUNCEMENT,
|
||||
});
|
||||
await addGuildMember(harness, owner, guild, member);
|
||||
await addGuildMember(harness, owner, guild, moderator);
|
||||
await grantGuildRole(harness, owner, guild.id, moderator, Permissions.MANAGE_MESSAGES, 'Announcement moderator');
|
||||
return {owner, member, moderator, guild, ann};
|
||||
}
|
||||
|
||||
export async function createAnnouncementTargetGuild(
|
||||
harness: ApiTestHarness,
|
||||
name = 'Announcement Target',
|
||||
): Promise<AnnouncementTargetGuild> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const webhookManager = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
const t1 = await createGuildChannel(harness, owner.token, guild.id, {name: 't1', type: ChannelTypes.GUILD_TEXT});
|
||||
const t2 = await createGuildChannel(harness, owner.token, guild.id, {name: 't2', type: ChannelTypes.GUILD_TEXT});
|
||||
const voice = await createGuildChannel(harness, owner.token, guild.id, {
|
||||
name: 'voice',
|
||||
type: ChannelTypes.GUILD_VOICE,
|
||||
});
|
||||
const ageRestricted = await createGuildChannel(harness, owner.token, guild.id, {
|
||||
name: 'restricted',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
nsfw_override: true,
|
||||
});
|
||||
const contentWarning = await createGuildChannel(harness, owner.token, guild.id, {
|
||||
name: 'warned',
|
||||
type: ChannelTypes.GUILD_TEXT,
|
||||
content_warning_level: ContentWarningLevel.CONTENT_WARNING,
|
||||
});
|
||||
await addGuildMember(harness, owner, guild, webhookManager);
|
||||
await createPermissionOverwrite(harness, owner.token, t1.id, webhookManager.userId, {
|
||||
type: 1,
|
||||
allow: Permissions.MANAGE_WEBHOOKS.toString(),
|
||||
deny: '0',
|
||||
});
|
||||
return {owner, webhookManager, guild, t1, t2, voice, ageRestricted, contentWarning};
|
||||
}
|
||||
|
||||
export async function announcementWorld(harness: ApiTestHarness): Promise<AnnouncementWorld> {
|
||||
const a = await createAnnouncementSourceGuild(harness);
|
||||
const b = await createAnnouncementTargetGuild(harness);
|
||||
await addGuildMember(harness, a.owner, a.guild, b.owner);
|
||||
await addGuildMember(harness, a.owner, a.guild, b.webhookManager);
|
||||
return {a, b};
|
||||
}
|
||||
|
||||
export function followRequest(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
sourceChannelId: string,
|
||||
targetChannelId: string,
|
||||
) {
|
||||
return createBuilder<FollowedChannelResponse>(harness, token)
|
||||
.post(`/channels/${sourceChannelId}/followers`)
|
||||
.body({webhook_channel_id: targetChannelId});
|
||||
}
|
||||
|
||||
export async function follow(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
sourceChannelId: string,
|
||||
targetChannelId: string,
|
||||
): Promise<FollowedChannelResponse> {
|
||||
return followRequest(harness, token, sourceChannelId, targetChannelId).execute();
|
||||
}
|
||||
|
||||
export function publishRequest(harness: ApiTestHarness, token: string, channelId: string, messageId: string) {
|
||||
return createBuilder<MessageResponse>(harness, token).post(`/channels/${channelId}/messages/${messageId}/crosspost`);
|
||||
}
|
||||
|
||||
export async function publish(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
): Promise<MessageResponse> {
|
||||
return publishRequest(harness, token, channelId, messageId).execute();
|
||||
}
|
||||
|
||||
export async function findWebhookRow(webhookId: string) {
|
||||
return new WebhookRepository().findUnique(createWebhookID(BigInt(webhookId)));
|
||||
}
|
||||
|
||||
export async function setGuildFeatures(
|
||||
harness: ApiTestHarness,
|
||||
guildId: string,
|
||||
features: {add?: Array<string>; remove?: Array<string>},
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features.add ?? [], remove_features: features.remove ?? []})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function setLimitOverride(
|
||||
harness: ApiTestHarness,
|
||||
limits: Partial<Record<LimitKey, number>>,
|
||||
): Promise<() => Promise<void>> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
|
||||
]);
|
||||
const current = await createBuilder<{limit_config: LimitConfigSnapshot}>(harness, admin.token)
|
||||
.get('/admin/limit-config')
|
||||
.execute();
|
||||
const writeConfig = async (rules: LimitConfigSnapshot['rules']) => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/limit-config')
|
||||
.body({limit_config: {traitDefinitions: current.limit_config.traitDefinitions, rules}})
|
||||
.execute();
|
||||
};
|
||||
await writeConfig([...current.limit_config.rules, {id: 'announcement_test_override', limits}]);
|
||||
return async () => {
|
||||
await writeConfig(current.limit_config.rules);
|
||||
};
|
||||
}
|
||||
|
||||
export const crosspostTaskHandlers: Record<string, WorkerTaskHandler> = {
|
||||
crosspostMessage,
|
||||
crosspostMessageChunk,
|
||||
syncCrosspostedMessage,
|
||||
syncCrosspostCopies,
|
||||
removeChannelFollowers,
|
||||
};
|
||||
|
||||
export function enableCrosspostWorker(
|
||||
extraHandlers: Record<string, WorkerTaskHandler> = {},
|
||||
extraDependencies: Partial<WorkerDependencies> = {},
|
||||
): SyncTaskWorkerService {
|
||||
const worker = new SyncTaskWorkerService({...crosspostTaskHandlers, ...extraHandlers}, {deferred: true});
|
||||
setInjectedWorkerService(worker);
|
||||
resetApiServicesForTesting();
|
||||
setWorkerDependenciesForTest({
|
||||
channelRepository: getChannelRepository(),
|
||||
webhookRepository: getWebhookRepository(),
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
gatewayService: getGatewayService(),
|
||||
storageService: getStorageService(),
|
||||
avatarService: getAvatarService(),
|
||||
purgeQueue: getPurgeQueue(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
cacheService: getCacheService(),
|
||||
limitConfigService: getLimitConfigService(),
|
||||
channelService: createTestChannelService(),
|
||||
workerService: worker,
|
||||
...extraDependencies,
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
export function disableCrosspostWorker(): void {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
resetApiServicesForTesting();
|
||||
clearWorkerDependencies();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
|
||||
import {disableCrosspostWorker} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {pinMessage} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
type AnnouncementWorld,
|
||||
createAnnouncementWorld,
|
||||
createStallGate,
|
||||
editMessage,
|
||||
postMessage,
|
||||
publish,
|
||||
publishedEditBudgetRemaining,
|
||||
type RecordedJob,
|
||||
RecordingWorkerService,
|
||||
readMessageRow,
|
||||
} from '@app/api/channel/tests/CrosspostTestUtils';
|
||||
import {
|
||||
copiesOf,
|
||||
deleteMessageRequest,
|
||||
editRequest,
|
||||
type FanoutWorld,
|
||||
followInto,
|
||||
sendMessage,
|
||||
setupFanoutWorld,
|
||||
} from '@app/api/channel/tests/CrosspostWorkerTestUtils';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import {
|
||||
CROSSPOST_SOURCE_DELETED_CONTENT,
|
||||
CROSSPOST_SYNC_COALESCE_MS,
|
||||
} from '@fluxer/constants/src/AnnouncementConstants';
|
||||
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
function stallFirstReadOf(messageId: string) {
|
||||
const gate = createStallGate();
|
||||
const original = MessageRepository.prototype.getMessage;
|
||||
let armed = true;
|
||||
vi.spyOn(MessageRepository.prototype, 'getMessage').mockImplementation(async function (
|
||||
this: MessageRepository,
|
||||
channelId,
|
||||
id,
|
||||
) {
|
||||
const result = await original.call(this, channelId, id);
|
||||
if (armed && id.toString() === messageId) {
|
||||
armed = false;
|
||||
await gate.hit();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return gate;
|
||||
}
|
||||
|
||||
function expectUpdateSyncJob(job: RecordedJob | undefined, channelId: string, messageId: string): void {
|
||||
expect(job?.payload).toEqual({channelId, messageId, mode: 'update'});
|
||||
const runAt = job?.options?.runAt;
|
||||
expect(runAt).toBeInstanceOf(Date);
|
||||
const bucket = Math.floor((runAt!.getTime() - 1000) / CROSSPOST_SYNC_COALESCE_MS) - 1;
|
||||
expect(runAt!.getTime()).toBe((bucket + 1) * CROSSPOST_SYNC_COALESCE_MS + 1000);
|
||||
expect(job?.options?.jobKey).toBe(`crosspost-sync:${messageId}:update:${bucket}`);
|
||||
expect(job?.options?.skipLedger).toBe(true);
|
||||
}
|
||||
|
||||
describe('Publishing races with other message writers', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let worker: RecordingWorkerService;
|
||||
let world: AnnouncementWorld;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
worker = new RecordingWorkerService();
|
||||
setInjectedWorkerService(worker);
|
||||
world = await createAnnouncementWorld(harness);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('a stale author edit that sets flags keeps the published flag, counts toward the cap and propagates', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Draft');
|
||||
const gate = stallFirstReadOf(message.id);
|
||||
const pendingEdit = editMessage(harness, world.author.token, world.announcement.id, message.id, {
|
||||
content: 'Edited while publishing',
|
||||
flags: MessageFlags.SUPPRESS_EMBEDS,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await gate.reached;
|
||||
await publish(harness, world.author.token, world.announcement.id, message.id);
|
||||
gate.release();
|
||||
const edited = await pendingEdit;
|
||||
expect(edited.flags).toBe(MessageFlags.CROSSPOSTED | MessageFlags.SUPPRESS_EMBEDS);
|
||||
const row = await readMessageRow(world.announcement.id, message.id);
|
||||
expect(row?.flags).toBe(MessageFlags.CROSSPOSTED | MessageFlags.SUPPRESS_EMBEDS);
|
||||
expect(row?.content).toBe('Edited while publishing');
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(2);
|
||||
const syncs = worker.syncJobsFor(message.id);
|
||||
expect(syncs).toHaveLength(1);
|
||||
expectUpdateSyncJob(syncs[0], world.announcement.id, message.id);
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: 'Second edit'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(1);
|
||||
expect(worker.syncJobsFor(message.id)).toHaveLength(2);
|
||||
await createBuilder(harness, world.author.token)
|
||||
.delete(`/channels/${world.announcement.id}/messages/${message.id}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const removal = worker.syncJobsFor(message.id).find((job) => job.payload.mode === 'source_deleted');
|
||||
expect(removal?.payload).toEqual({
|
||||
channelId: world.announcement.id,
|
||||
messageId: message.id,
|
||||
mode: 'source_deleted',
|
||||
});
|
||||
expect(removal?.options?.jobKey).toBe(`crosspost-sync:${message.id}:source_deleted`);
|
||||
expect(removal?.options?.runAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a stale moderator suppress-embeds edit keeps the published flag without using the cap', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'See https://example.com');
|
||||
const gate = stallFirstReadOf(message.id);
|
||||
const pendingEdit = editMessage(harness, world.mod.token, world.announcement.id, message.id, {
|
||||
flags: MessageFlags.SUPPRESS_EMBEDS,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await gate.reached;
|
||||
await publish(harness, world.author.token, world.announcement.id, message.id);
|
||||
gate.release();
|
||||
const edited = await pendingEdit;
|
||||
expect(edited.flags).toBe(MessageFlags.CROSSPOSTED | MessageFlags.SUPPRESS_EMBEDS);
|
||||
expect((await readMessageRow(world.announcement.id, message.id))?.flags).toBe(
|
||||
MessageFlags.CROSSPOSTED | MessageFlags.SUPPRESS_EMBEDS,
|
||||
);
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(3);
|
||||
const syncs = worker.syncJobsFor(message.id);
|
||||
expect(syncs).toHaveLength(1);
|
||||
expectUpdateSyncJob(syncs[0], world.announcement.id, message.id);
|
||||
});
|
||||
|
||||
test('a stale webhook token edit keeps the published flag and counts toward the cap', async () => {
|
||||
const webhook = await createWebhook(harness, world.announcement.id, world.owner.token, 'Release Bot');
|
||||
const executed = await createBuilderWithoutAuth<MessageResponse>(harness)
|
||||
.post(`/webhooks/${webhook.id}/${webhook.token}?wait=true`)
|
||||
.body({content: 'Build 42 shipped'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const gate = stallFirstReadOf(executed.id);
|
||||
const pendingEdit = createBuilderWithoutAuth<MessageResponse>(harness)
|
||||
.patch(`/webhooks/${webhook.id}/${webhook.token}/messages/${executed.id}`)
|
||||
.body({content: 'Build 42 shipped (edited)', flags: MessageFlags.SUPPRESS_EMBEDS})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await gate.reached;
|
||||
await publish(harness, world.mod.token, world.announcement.id, executed.id);
|
||||
gate.release();
|
||||
await pendingEdit;
|
||||
const row = await readMessageRow(world.announcement.id, executed.id);
|
||||
expect(row?.flags).toBe(MessageFlags.CROSSPOSTED | MessageFlags.SUPPRESS_EMBEDS);
|
||||
expect(row?.content).toBe('Build 42 shipped (edited)');
|
||||
expect(await publishedEditBudgetRemaining(executed.id)).toBe(2);
|
||||
expect(worker.syncJobsFor(executed.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a pin concurrent with a publish keeps both', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Pin and publish');
|
||||
await Promise.all([
|
||||
pinMessage(harness, world.owner.token, world.announcement.id, message.id),
|
||||
publish(harness, world.author.token, world.announcement.id, message.id),
|
||||
]);
|
||||
const row = await readMessageRow(world.announcement.id, message.id);
|
||||
expect(row?.flags).toBe(MessageFlags.CROSSPOSTED);
|
||||
expect(row?.pinnedTimestamp).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Publishing races with a follower attached', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let world: FanoutWorld;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
world = await setupFanoutWorld(harness);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disableCrosspostWorker();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('a stale flag edit during a publish reaches the copy and a later delete marks it', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'Draft'});
|
||||
const gate = stallFirstReadOf(message.id);
|
||||
const pendingEdit = editRequest(harness, world.a.owner.token, world.a.ann.id, message.id, {
|
||||
content: 'Edited while publishing',
|
||||
flags: MessageFlags.SUPPRESS_EMBEDS,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await gate.reached;
|
||||
await publish(harness, world.a.owner.token, world.a.ann.id, message.id);
|
||||
gate.release();
|
||||
await pendingEdit;
|
||||
await world.worker.drain();
|
||||
let [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
||||
expect(copy!.content).toBe('Edited while publishing');
|
||||
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SUPPRESS_EMBEDS);
|
||||
await editRequest(harness, world.a.owner.token, world.a.ann.id, message.id, {content: 'Second edit'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
[copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
||||
expect(copy!.content).toBe('Second edit');
|
||||
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, message.id);
|
||||
await world.worker.drain();
|
||||
[copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, message.id);
|
||||
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED);
|
||||
expect(copy!.content).toBe(CROSSPOST_SOURCE_DELETED_CONTENT);
|
||||
expect(world.worker.deadLetters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,633 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
type ChannelID,
|
||||
createAttachmentID,
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {CrosspostTaskNames} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {
|
||||
type AnnouncementTargetGuild,
|
||||
type AnnouncementWorld,
|
||||
addGuildMember,
|
||||
announcementWorld,
|
||||
createAnnouncementTargetGuild,
|
||||
follow,
|
||||
publish,
|
||||
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {loadFixture, sendMessageWithAttachments} from '@app/api/channel/tests/AttachmentTestUtils';
|
||||
import {sendChannelMessage} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {readMessageRow, writeMessageRow} from '@app/api/channel/tests/CrosspostTestUtils';
|
||||
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import type {MessageEmbed} from '@app/api/database/types/MessageTypes';
|
||||
import type {EmbedService} from '@app/api/infrastructure/EmbedService';
|
||||
import {
|
||||
getGatewayService,
|
||||
getKVClient,
|
||||
getSnowflakeService,
|
||||
setInjectedWorkerService,
|
||||
} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
getCacheService,
|
||||
getChannelRepository,
|
||||
getNcmecSubmissionService,
|
||||
getPurgeQueue,
|
||||
getStorageService,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {createWebhook, executeWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import extractEmbeds from '@app/api/worker/tasks/ExtractEmbeds';
|
||||
import messageShred from '@app/api/worker/tasks/MessageShred';
|
||||
import {
|
||||
clearWorkerDependencies,
|
||||
setWorkerDependencies,
|
||||
setWorkerDependenciesForTest,
|
||||
} from '@app/api/worker/WorkerContext';
|
||||
import {initializeWorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||
import {workerTasks} from '@app/api/worker/WorkerTaskRegistry';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MessageFlags, MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {WorkerJobOptions, WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const SERVER_BITS = MessageFlags.CROSSPOSTED | MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED;
|
||||
const CROSSPOST_TASKS = new Set<string>(Object.values(CrosspostTaskNames));
|
||||
const BLOCKED_DOMAIN = 'crosspost-blocked.example';
|
||||
|
||||
interface QueuedJob {
|
||||
taskType: string;
|
||||
payload: WorkerJobPayload;
|
||||
options: WorkerJobOptions | undefined;
|
||||
}
|
||||
|
||||
class CrosspostQueueWorker implements IWorkerService {
|
||||
readonly recorded: Array<QueuedJob> = [];
|
||||
private queue: Array<QueuedJob> = [];
|
||||
private seenKeys = new Set<string>();
|
||||
private nextJobId = 1n;
|
||||
|
||||
clear(): void {
|
||||
this.recorded.length = 0;
|
||||
this.queue = [];
|
||||
this.seenKeys.clear();
|
||||
}
|
||||
|
||||
async addJob<TPayload extends WorkerJobPayload = WorkerJobPayload>(
|
||||
taskType: string,
|
||||
payload: TPayload,
|
||||
options?: WorkerJobOptions,
|
||||
): Promise<bigint> {
|
||||
const jobKey = options?.jobKey;
|
||||
if (jobKey && this.seenKeys.has(jobKey)) {
|
||||
return 0n;
|
||||
}
|
||||
if (jobKey) {
|
||||
this.seenKeys.add(jobKey);
|
||||
}
|
||||
const job = {taskType, payload, options};
|
||||
this.recorded.push(job);
|
||||
if (CROSSPOST_TASKS.has(taskType)) {
|
||||
this.queue.push(job);
|
||||
}
|
||||
return this.nextJobId++;
|
||||
}
|
||||
|
||||
async cancelJob(_jobId: bigint): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async retryDeadLetterJob(_jobId: bigint): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
syncJobs(): Array<QueuedJob> {
|
||||
return this.recorded.filter((job) => job.taskType === CrosspostTaskNames.SYNC_CROSSPOSTED_MESSAGE);
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
const handlers = workerTasks as Partial<Record<string, WorkerTaskHandler>>;
|
||||
const attempts = new Map<QueuedJob, number>();
|
||||
let processed = 0;
|
||||
while (this.queue.length > 0) {
|
||||
processed += 1;
|
||||
if (processed > 5_000) {
|
||||
throw new Error('crosspost queue did not settle');
|
||||
}
|
||||
const job = this.queue.shift()!;
|
||||
const handler = handlers[job.taskType];
|
||||
if (!handler) {
|
||||
throw new Error(`no worker handler registered for ${job.taskType}`);
|
||||
}
|
||||
const helpers: WorkerTaskHelpers = {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 0n,
|
||||
addJob: (taskType, payload, options) => this.addJob(taskType, payload, options),
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
try {
|
||||
await handler(job.payload, helpers);
|
||||
} catch (error) {
|
||||
const count = (attempts.get(job) ?? 0) + 1;
|
||||
attempts.set(job, count);
|
||||
if (count >= 3) {
|
||||
throw error;
|
||||
}
|
||||
this.queue.push(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FabricatedCopy {
|
||||
copyId: string;
|
||||
attachment: {id: string; filename: string} | null;
|
||||
}
|
||||
|
||||
async function fabricateCopy(params: {
|
||||
harness: ApiTestHarness;
|
||||
owner: TestAccount;
|
||||
target: string;
|
||||
world: AnnouncementWorld;
|
||||
sourceId: string;
|
||||
extraFlags?: number;
|
||||
withImage?: boolean;
|
||||
}): Promise<FabricatedCopy> {
|
||||
const webhook = await createWebhook(params.harness, params.target, params.owner.token, 'Follower');
|
||||
const {json: message} = await executeWebhook(
|
||||
params.harness,
|
||||
webhook.id,
|
||||
webhook.token,
|
||||
{content: 'copied update', wait: true},
|
||||
200,
|
||||
);
|
||||
const sourceAttachments = params.withImage
|
||||
? (await readMessageRow(params.world.a.ann.id, params.sourceId))!.attachments
|
||||
: [];
|
||||
const row = await readMessageRow(params.target, message!.id);
|
||||
await writeMessageRow(row!, {
|
||||
flags: MessageFlags.IS_CROSSPOST | (params.extraFlags ?? 0),
|
||||
attachments:
|
||||
sourceAttachments.length > 0 ? sourceAttachments.map((attachment) => attachment.toMessageAttachment()) : null,
|
||||
message_reference: {
|
||||
channel_id: createChannelID(BigInt(params.world.a.ann.id)),
|
||||
guild_id: createGuildID(BigInt(params.world.a.guild.id)),
|
||||
message_id: createMessageID(BigInt(params.sourceId)),
|
||||
type: MessageReferenceTypes.DEFAULT,
|
||||
},
|
||||
});
|
||||
const attachment = sourceAttachments[0];
|
||||
return {
|
||||
copyId: message!.id,
|
||||
attachment: attachment ? {id: attachment.id.toString(), filename: attachment.filename} : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
return setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.MESSAGE_DELETE,
|
||||
AdminACLs.MESSAGE_SHRED,
|
||||
AdminACLs.CSAM_SUBMIT_NCMEC,
|
||||
AdminACLs.USER_DELETE,
|
||||
AdminACLs.ARCHIVE_TRIGGER_USER,
|
||||
]);
|
||||
}
|
||||
|
||||
async function adminDeleteMessage(harness: ApiTestHarness, admin: TestAccount, channelId: string, messageId: string) {
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/channels/${channelId}/messages/${messageId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function deleteMessageSilently(channelId: string, messageId: string, fallbackUserId: string): Promise<void> {
|
||||
const service = getNcmecSubmissionService() as unknown as {
|
||||
deleteMessageSilently(channelId: ChannelID, messageId: MessageID, fallbackUserId: UserID): Promise<void>;
|
||||
};
|
||||
await service.deleteMessageSilently(
|
||||
createChannelID(BigInt(channelId)),
|
||||
createMessageID(BigInt(messageId)),
|
||||
createUserID(BigInt(fallbackUserId)),
|
||||
);
|
||||
}
|
||||
|
||||
function blockedEmbedService(): EmbedService {
|
||||
return {
|
||||
processUrlWithCachePolicy: async (url: string) => ({
|
||||
embeds: [{toMessageEmbed: (): MessageEmbed => ({type: 'link', url}) as MessageEmbed}],
|
||||
cacheTtlSeconds: 0,
|
||||
}),
|
||||
cacheEmbeds: async () => {},
|
||||
} as unknown as EmbedService;
|
||||
}
|
||||
|
||||
function helpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 1n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function listCopies(channelId: string, sourceId: string): Promise<Array<string>> {
|
||||
const messages = await new ChannelRepository().listMessages(createChannelID(BigInt(channelId)), undefined, 100);
|
||||
return messages
|
||||
.filter(
|
||||
(message) =>
|
||||
(message.flags & MessageFlags.IS_CROSSPOST) !== 0 && message.reference?.messageId?.toString() === sourceId,
|
||||
)
|
||||
.map((message) => message.id.toString());
|
||||
}
|
||||
|
||||
describe('Crosspost moderation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let worker: CrosspostQueueWorker;
|
||||
let world: AnnouncementWorld;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
worker = new CrosspostQueueWorker();
|
||||
setInjectedWorkerService(worker);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
harness.storageService.reset();
|
||||
worker.clear();
|
||||
world = await announcementWorld(harness);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearWorkerDependencies();
|
||||
urlBlocklistCache.removeDomain(BLOCKED_DOMAIN);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
function deletedKeys(): Array<string> {
|
||||
return harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
}
|
||||
|
||||
describe('takedown hooks', () => {
|
||||
test('an admin delete of a copy enqueues a purge of the whole family', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
const {copyId} = await fabricateCopy({
|
||||
harness,
|
||||
owner: world.b.owner,
|
||||
target: world.b.t1.id,
|
||||
world,
|
||||
sourceId: source.id,
|
||||
});
|
||||
const admin = await createAdmin(harness);
|
||||
await adminDeleteMessage(harness, admin, world.b.t1.id, copyId);
|
||||
expect(await readMessageRow(world.b.t1.id, copyId)).toBeNull();
|
||||
const jobs = worker.syncJobs();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.payload).toEqual({
|
||||
channelId: world.a.ann.id,
|
||||
messageId: source.id,
|
||||
mode: 'purge',
|
||||
deleteSource: true,
|
||||
});
|
||||
expect(jobs[0]!.options?.jobKey).toBe(`crosspost-sync:${source.id}:purge`);
|
||||
expect(jobs[0]!.options?.skipLedger).toBe(true);
|
||||
});
|
||||
|
||||
test('an admin delete of a published source purges its copies and leaves others alone', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
await publish(harness, world.a.member.token, world.a.ann.id, source.id);
|
||||
const plain = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'not published');
|
||||
const admin = await createAdmin(harness);
|
||||
await adminDeleteMessage(harness, admin, world.a.ann.id, plain.id);
|
||||
expect(worker.syncJobs()).toHaveLength(0);
|
||||
await adminDeleteMessage(harness, admin, world.a.ann.id, source.id);
|
||||
const jobs = worker.syncJobs();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.payload).toEqual({channelId: world.a.ann.id, messageId: source.id, mode: 'purge'});
|
||||
});
|
||||
|
||||
test('an NCMEC report on a copy records the source author and its takedown purges the family', async () => {
|
||||
const {json: source} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
world.a.member.token,
|
||||
world.a.ann.id,
|
||||
{content: 'update', attachments: [{id: 0, filename: 'yeah.png'}]},
|
||||
[{index: 0, filename: 'yeah.png', data: loadFixture('yeah.png')}],
|
||||
);
|
||||
const {copyId, attachment} = await fabricateCopy({
|
||||
harness,
|
||||
owner: world.b.owner,
|
||||
target: world.b.t1.id,
|
||||
world,
|
||||
sourceId: source.id,
|
||||
withImage: true,
|
||||
});
|
||||
expect(attachment).not.toBeNull();
|
||||
const admin = await createAdmin(harness);
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/messages/ncmec-reports')
|
||||
.body({
|
||||
channel_id: world.b.t1.id,
|
||||
message_id: copyId,
|
||||
attachment_id: attachment!.id,
|
||||
filename: attachment!.filename,
|
||||
reporter_full_name: 'Crosspost Reporter',
|
||||
confirmed_viewed: true,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const submission = await new NcmecRepository().getAttachmentSubmission(
|
||||
createAttachmentID(BigInt(attachment!.id)),
|
||||
);
|
||||
expect(submission?.user_id?.toString()).toBe(world.a.member.userId);
|
||||
expect(submission?.message_id.toString()).toBe(copyId);
|
||||
expect(submission?.channel_id.toString()).toBe(world.b.t1.id);
|
||||
worker.clear();
|
||||
const deletedBefore = deletedKeys().length;
|
||||
await deleteMessageSilently(world.b.t1.id, copyId, world.a.member.userId);
|
||||
expect(await readMessageRow(world.b.t1.id, copyId)).toBeNull();
|
||||
expect(deletedKeys().slice(deletedBefore)).toEqual([]);
|
||||
expect(
|
||||
harness.storageService.hasObject(
|
||||
Config.s3.buckets.cdn,
|
||||
`attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`,
|
||||
),
|
||||
).toBe(true);
|
||||
const jobs = worker.syncJobs();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.payload).toEqual({
|
||||
channelId: world.a.ann.id,
|
||||
messageId: source.id,
|
||||
mode: 'purge',
|
||||
deleteSource: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('an NCMEC takedown of a published source purges its copies', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
await publish(harness, world.a.member.token, world.a.ann.id, source.id);
|
||||
worker.clear();
|
||||
await deleteMessageSilently(world.a.ann.id, source.id, world.a.member.userId);
|
||||
const jobs = worker.syncJobs();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.payload).toEqual({channelId: world.a.ann.id, messageId: source.id, mode: 'purge'});
|
||||
});
|
||||
|
||||
test('a message shred of a published source purges its copies', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
await publish(harness, world.a.member.token, world.a.ann.id, source.id);
|
||||
const plain = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'plain');
|
||||
worker.clear();
|
||||
setWorkerDependenciesForTest({
|
||||
kvClient: getKVClient(),
|
||||
channelRepository: getChannelRepository(),
|
||||
gatewayService: getGatewayService(),
|
||||
storageService: getStorageService(),
|
||||
purgeQueue: getPurgeQueue(),
|
||||
workerService: worker,
|
||||
});
|
||||
await messageShred(
|
||||
{
|
||||
job_id: 'crosspost-shred',
|
||||
admin_user_id: '1',
|
||||
target_user_id: world.a.member.userId,
|
||||
entries: [
|
||||
{channel_id: world.a.ann.id, message_id: source.id},
|
||||
{channel_id: world.a.ann.id, message_id: plain.id},
|
||||
],
|
||||
},
|
||||
helpers(),
|
||||
);
|
||||
expect(await readMessageRow(world.a.ann.id, source.id)).toBeNull();
|
||||
const jobs = worker.syncJobs();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.payload).toEqual({channelId: world.a.ann.id, messageId: source.id, mode: 'purge'});
|
||||
});
|
||||
|
||||
test('a blocked unfurl on a published source deletes it and purges its copies', async () => {
|
||||
const source = await sendChannelMessage(
|
||||
harness,
|
||||
world.a.member.token,
|
||||
world.a.ann.id,
|
||||
`see https://${BLOCKED_DOMAIN}/page`,
|
||||
);
|
||||
await publish(harness, world.a.member.token, world.a.ann.id, source.id);
|
||||
urlBlocklistCache.addDomain(BLOCKED_DOMAIN);
|
||||
worker.clear();
|
||||
setWorkerDependenciesForTest({
|
||||
channelRepository: getChannelRepository(),
|
||||
gatewayService: getGatewayService(),
|
||||
embedService: blockedEmbedService(),
|
||||
cacheService: getCacheService(),
|
||||
workerService: worker,
|
||||
});
|
||||
await extractEmbeds(
|
||||
{channelId: world.a.ann.id, messageId: source.id, guildId: world.a.guild.id, nsfwMode: 'allow'},
|
||||
helpers(),
|
||||
);
|
||||
expect(await readMessageRow(world.a.ann.id, source.id)).toBeNull();
|
||||
const jobs = worker.syncJobs();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.payload).toEqual({channelId: world.a.ann.id, messageId: source.id, mode: 'purge'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('forwarding', () => {
|
||||
function forward(token: string, targetChannelId: string, channelId: string, guildId: string, messageId: string) {
|
||||
return createBuilder<MessageResponse>(harness, token)
|
||||
.post(`/channels/${targetChannelId}/messages`)
|
||||
.body({
|
||||
message_reference: {
|
||||
type: MessageReferenceTypes.FORWARD,
|
||||
message_id: messageId,
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('forwarding a published source carries none of the server bits', async () => {
|
||||
const source = await sendChannelMessage(harness, world.b.owner.token, world.a.ann.id, 'forward me');
|
||||
await publish(harness, world.b.owner.token, world.a.ann.id, source.id);
|
||||
const forwarded = await forward(world.b.owner.token, world.b.t2.id, world.a.ann.id, world.a.guild.id, source.id)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(forwarded.message_snapshots?.[0]?.flags ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
test('forwarding a copy carries none of the server bits', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
const {copyId} = await fabricateCopy({
|
||||
harness,
|
||||
owner: world.b.owner,
|
||||
target: world.b.t1.id,
|
||||
world,
|
||||
sourceId: source.id,
|
||||
extraFlags: MessageFlags.SUPPRESS_NOTIFICATIONS,
|
||||
});
|
||||
const forwarded = await forward(world.b.owner.token, world.b.t2.id, world.b.t1.id, world.b.guild.id, copyId)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const flags = forwarded.message_snapshots?.[0]?.flags ?? 0;
|
||||
expect(flags & SERVER_BITS).toBe(0);
|
||||
expect(flags & MessageFlags.SUPPRESS_NOTIFICATIONS).toBe(MessageFlags.SUPPRESS_NOTIFICATIONS);
|
||||
});
|
||||
|
||||
test('forwarding a source-deleted copy is refused', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
const {copyId} = await fabricateCopy({
|
||||
harness,
|
||||
owner: world.b.owner,
|
||||
target: world.b.t1.id,
|
||||
world,
|
||||
sourceId: source.id,
|
||||
extraFlags: MessageFlags.SOURCE_MESSAGE_DELETED,
|
||||
});
|
||||
await forward(world.b.owner.token, world.b.t2.id, world.b.t1.id, world.b.guild.id, copyId)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('takedown cascade after delivery', () => {
|
||||
let c: AnnouncementTargetGuild;
|
||||
|
||||
beforeEach(async () => {
|
||||
const deps = await initializeWorkerDependencies(getSnowflakeService());
|
||||
deps.workerService = worker;
|
||||
setWorkerDependencies(deps);
|
||||
c = await createAnnouncementTargetGuild(harness, 'Announcement Target C');
|
||||
await addGuildMember(harness, world.a.owner, world.a.guild, c.owner);
|
||||
await follow(harness, world.b.owner.token, world.a.ann.id, world.b.t1.id);
|
||||
await follow(harness, world.b.owner.token, world.a.ann.id, world.b.t2.id);
|
||||
await follow(harness, c.owner.token, world.a.ann.id, c.t1.id);
|
||||
});
|
||||
|
||||
async function publishAndDeliver(content: string, withImage = false): Promise<string> {
|
||||
let sourceId: string;
|
||||
if (withImage) {
|
||||
const filename = 'yeah.png';
|
||||
const {json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
world.a.member.token,
|
||||
world.a.ann.id,
|
||||
{content, attachments: [{id: 0, filename}]},
|
||||
[{index: 0, filename, data: loadFixture(filename)}],
|
||||
);
|
||||
sourceId = json.id;
|
||||
} else {
|
||||
sourceId = (await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, content)).id;
|
||||
}
|
||||
await publish(harness, world.a.member.token, world.a.ann.id, sourceId);
|
||||
await worker.drain();
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
async function sourceAttachmentKey(sourceId: string): Promise<string> {
|
||||
const [attachment] = (await readMessageRow(world.a.ann.id, sourceId))!.attachments;
|
||||
const key = `attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`;
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, key)).toBe(true);
|
||||
return key;
|
||||
}
|
||||
|
||||
function expectNoTargetObjectsDeleted(): void {
|
||||
for (const channelId of [world.b.t1.id, world.b.t2.id, c.t1.id]) {
|
||||
expect(deletedKeys().some((key) => key.startsWith(`attachments/${channelId}/`))).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectFamilyGone(sourceId: string): Promise<void> {
|
||||
expect(await readMessageRow(world.a.ann.id, sourceId)).toBeNull();
|
||||
expect(await listCopies(world.b.t1.id, sourceId)).toEqual([]);
|
||||
expect(await listCopies(world.b.t2.id, sourceId)).toEqual([]);
|
||||
expect(await listCopies(c.t1.id, sourceId)).toEqual([]);
|
||||
}
|
||||
|
||||
test('an admin delete of one copy removes the source and every sibling copy', async () => {
|
||||
const sourceId = await publishAndDeliver('family update', true);
|
||||
const sourceKey = await sourceAttachmentKey(sourceId);
|
||||
const [copyId] = await listCopies(world.b.t1.id, sourceId);
|
||||
expect(copyId).toBeDefined();
|
||||
expect(await listCopies(world.b.t2.id, sourceId)).toHaveLength(1);
|
||||
expect(await listCopies(c.t1.id, sourceId)).toHaveLength(1);
|
||||
const admin = await createAdmin(harness);
|
||||
await adminDeleteMessage(harness, admin, world.b.t1.id, copyId!);
|
||||
expect(deletedKeys()).not.toContain(sourceKey);
|
||||
await worker.drain();
|
||||
await expectFamilyGone(sourceId);
|
||||
expect(deletedKeys().filter((key) => key === sourceKey)).toHaveLength(1);
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, sourceKey)).toBe(false);
|
||||
expectNoTargetObjectsDeleted();
|
||||
});
|
||||
|
||||
test('an NCMEC takedown of a delivered copy records the source author and removes the family', async () => {
|
||||
const sourceId = await publishAndDeliver('image update', true);
|
||||
const sourceKey = await sourceAttachmentKey(sourceId);
|
||||
const [copyId] = await listCopies(c.t1.id, sourceId);
|
||||
const copy = await readMessageRow(c.t1.id, copyId!);
|
||||
const attachment = copy!.attachments[0]!;
|
||||
expect(`attachments/${world.a.ann.id}/${attachment.id}/${attachment.filename}`).toBe(sourceKey);
|
||||
const admin = await createAdmin(harness);
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/messages/ncmec-reports')
|
||||
.body({
|
||||
channel_id: c.t1.id,
|
||||
message_id: copyId,
|
||||
attachment_id: attachment.id.toString(),
|
||||
filename: attachment.filename,
|
||||
reporter_full_name: 'Crosspost Reporter',
|
||||
confirmed_viewed: true,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const submission = await new NcmecRepository().getAttachmentSubmission(attachment.id);
|
||||
expect(submission?.user_id?.toString()).toBe(world.a.member.userId);
|
||||
await deleteMessageSilently(c.t1.id, copyId!, world.a.member.userId);
|
||||
await worker.drain();
|
||||
await expectFamilyGone(sourceId);
|
||||
expect(deletedKeys().filter((key) => key === sourceKey)).toHaveLength(1);
|
||||
expectNoTargetObjectsDeleted();
|
||||
});
|
||||
|
||||
test('a blocked unfurl of a published source deletes every copy', async () => {
|
||||
const sourceId = await publishAndDeliver(`see https://${BLOCKED_DOMAIN}/page`);
|
||||
expect(await listCopies(c.t1.id, sourceId)).toHaveLength(1);
|
||||
urlBlocklistCache.addDomain(BLOCKED_DOMAIN);
|
||||
const deps = await initializeWorkerDependencies(getSnowflakeService());
|
||||
deps.workerService = worker;
|
||||
deps.embedService = blockedEmbedService();
|
||||
setWorkerDependencies(deps);
|
||||
await extractEmbeds(
|
||||
{channelId: world.a.ann.id, messageId: sourceId, guildId: world.a.guild.id, nsfwMode: 'allow'},
|
||||
helpers(),
|
||||
);
|
||||
await worker.drain();
|
||||
await expectFamilyGone(sourceId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,854 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AttachmentDecayRepository} from '@app/api/attachment/AttachmentDecayRepository';
|
||||
import {setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {crosspostSyncBucket, enqueueCrosspostSync} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {
|
||||
addGuildMember,
|
||||
createAnnouncementTargetGuild,
|
||||
disableCrosspostWorker,
|
||||
enableCrosspostWorker,
|
||||
} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {addGuildFeature, createTestChannelService} from '@app/api/channel/tests/CrosspostTestUtils';
|
||||
import {
|
||||
copiesOf,
|
||||
deleteMessageRequest,
|
||||
editRequest,
|
||||
type FanoutWorld,
|
||||
followInto,
|
||||
listCopies,
|
||||
mappingRow,
|
||||
mappingRows,
|
||||
patchChannel,
|
||||
postAndPublish,
|
||||
publishAndDrain,
|
||||
readRow,
|
||||
sendMessage,
|
||||
sendWithImage,
|
||||
setupFanoutWorld,
|
||||
sourceIndex,
|
||||
workerHelpers,
|
||||
writeRow,
|
||||
} from '@app/api/channel/tests/CrosspostWorkerTestUtils';
|
||||
import type {EmbedService} from '@app/api/infrastructure/EmbedService';
|
||||
import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue';
|
||||
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {Embed} from '@app/api/models/Embed';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {getExpiryBucket} from '@app/api/utils/AttachmentDecay';
|
||||
import {createWebhook, executeWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import deleteUserMessagesInGuildByTime from '@app/api/worker/tasks/DeleteUserMessagesInGuildByTime';
|
||||
import {processExpiredAttachments} from '@app/api/worker/tasks/ExpireAttachments';
|
||||
import extractEmbeds from '@app/api/worker/tasks/ExtractEmbeds';
|
||||
import messageShred from '@app/api/worker/tasks/MessageShred';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
CROSSPOST_SOURCE_DELETED_CONTENT,
|
||||
CROSSPOST_SYNC_COALESCE_MS,
|
||||
} from '@fluxer/constants/src/AnnouncementConstants';
|
||||
import {
|
||||
ChannelTypes,
|
||||
MessageAttachmentFlags,
|
||||
MessageFlags,
|
||||
MessageReferenceTypes,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const DELETED_COPY_FLAGS = MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED;
|
||||
|
||||
async function awaitFreshBucket(): Promise<void> {
|
||||
const intoBucket = Date.now() % CROSSPOST_SYNC_COALESCE_MS;
|
||||
if (intoBucket > CROSSPOST_SYNC_COALESCE_MS - 2000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, CROSSPOST_SYNC_COALESCE_MS - intoBucket + 50));
|
||||
}
|
||||
}
|
||||
|
||||
describe('Crosspost propagation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let world: FanoutWorld;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
harness.storageService.reset();
|
||||
world = await setupFanoutWorld(harness);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disableCrosspostWorker();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function onlyCopy(channelId: string, sourceId: string, token = world.b.owner.token): Promise<MessageResponse> {
|
||||
const copies = await copiesOf(harness, token, channelId, sourceId);
|
||||
expect(copies).toHaveLength(1);
|
||||
return copies[0]!;
|
||||
}
|
||||
|
||||
async function edit(messageId: string, body: Record<string, unknown>, token = world.a.owner.token) {
|
||||
await editRequest(harness, token, world.a.ann.id, messageId, body).expect(200).execute();
|
||||
}
|
||||
|
||||
async function expectDeletedCopy(channelId: string, copyId: string): Promise<void> {
|
||||
const row = (await readRow(channelId, copyId))!;
|
||||
expect(row.flags).toBe(DELETED_COPY_FLAGS);
|
||||
expect(row.content).toBe(CROSSPOST_SOURCE_DELETED_CONTENT);
|
||||
expect(row.attachments).toHaveLength(0);
|
||||
expect(row.embeds).toHaveLength(0);
|
||||
expect(row.stickers).toHaveLength(0);
|
||||
expect(row.editedTimestamp).not.toBeNull();
|
||||
}
|
||||
|
||||
test('a content edit updates every copy through one coalesced sync job', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await followInto(harness, world, world.b.t2.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'draft'});
|
||||
const copyT1 = await onlyCopy(world.b.t1.id, source.id);
|
||||
const copyT2 = await onlyCopy(world.b.t2.id, source.id);
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const before = crosspostSyncBucket(Date.now());
|
||||
await edit(source.id, {content: 'final'});
|
||||
const after = crosspostSyncBucket(Date.now());
|
||||
const [job] = world.worker
|
||||
.byTask('syncCrosspostedMessage')
|
||||
.filter((entry) => (entry.payload as {messageId: string}).messageId === source.id);
|
||||
const bucket = Number(job!.jobKey!.split(':').at(-1));
|
||||
expect(job!.jobKey).toBe(`crosspost-sync:${source.id}:update:${bucket}`);
|
||||
expect(bucket).toBeGreaterThanOrEqual(before);
|
||||
expect(bucket).toBeLessThanOrEqual(after);
|
||||
expect(job!.runAt!.getTime()).toBe((bucket + 1) * CROSSPOST_SYNC_COALESCE_MS + 1000);
|
||||
expect(job!.options?.skipLedger).toBe(true);
|
||||
await world.worker.drain();
|
||||
for (const [channelId, copyId] of [
|
||||
[world.b.t1.id, copyT1.id],
|
||||
[world.b.t2.id, copyT2.id],
|
||||
] as const) {
|
||||
const updated = await onlyCopy(channelId, source.id);
|
||||
expect(updated.content).toBe('final');
|
||||
expect(updated.edited_timestamp).toBeTruthy();
|
||||
const updates = dispatchSpy.mock.calls
|
||||
.map(([params]) => params)
|
||||
.filter(
|
||||
(params) =>
|
||||
params.event === 'MESSAGE_UPDATE' &&
|
||||
params.guildId.toString() === world.b.guild.id &&
|
||||
(params.data as {id: string}).id === copyId,
|
||||
);
|
||||
expect(updates).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('two edits in one bucket run a single sync and the copy ends with the second', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'first'});
|
||||
await awaitFreshBucket();
|
||||
await edit(source.id, {content: 'second'});
|
||||
await edit(source.id, {content: 'third'});
|
||||
const syncJobs = world.worker
|
||||
.byTask('syncCrosspostedMessage')
|
||||
.filter((entry) => (entry.payload as {messageId: string}).messageId === source.id);
|
||||
expect(syncJobs).toHaveLength(1);
|
||||
expect(world.worker.deduped.filter((entry) => entry.taskType === 'syncCrosspostedMessage')).toHaveLength(1);
|
||||
await world.worker.drain();
|
||||
expect((await onlyCopy(world.b.t1.id, source.id)).content).toBe('third');
|
||||
});
|
||||
|
||||
test('embed edits and suppress-embeds toggles propagate, including flag-only edits', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const embedded = await postAndPublish(harness, world, {content: 'card', embeds: [{title: 'Old title'}]});
|
||||
await edit(embedded.id, {embeds: [{title: 'New title', description: 'Details'}]});
|
||||
await world.worker.drain();
|
||||
let copyRow = (await readRow(world.b.t1.id, (await onlyCopy(world.b.t1.id, embedded.id)).id))!;
|
||||
expect(copyRow.embeds.map((embed) => [embed.title, embed.description])).toEqual([['New title', 'Details']]);
|
||||
await edit(embedded.id, {flags: MessageFlags.SUPPRESS_EMBEDS});
|
||||
await world.worker.drain();
|
||||
copyRow = (await readRow(world.b.t1.id, copyRow.id.toString()))!;
|
||||
expect(copyRow.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SUPPRESS_EMBEDS);
|
||||
await edit(embedded.id, {flags: 0}, world.a.moderator.token);
|
||||
await world.worker.drain();
|
||||
copyRow = (await readRow(world.b.t1.id, copyRow.id.toString()))!;
|
||||
expect(copyRow.flags).toBe(MessageFlags.IS_CROSSPOST);
|
||||
const plain = await postAndPublish(harness, world, {content: 'plain'});
|
||||
await awaitFreshBucket();
|
||||
await edit(plain.id, {content: 'plain edited'});
|
||||
await world.worker.drain();
|
||||
const plainCopy = await onlyCopy(world.b.t1.id, plain.id);
|
||||
const editedAt = (await readRow(world.a.ann.id, plain.id))!.editedTimestamp;
|
||||
await edit(plain.id, {flags: MessageFlags.SUPPRESS_EMBEDS});
|
||||
expect((await readRow(world.a.ann.id, plain.id))!.editedTimestamp).toEqual(editedAt);
|
||||
await world.worker.drain();
|
||||
const plainRow = (await readRow(world.b.t1.id, plainCopy.id))!;
|
||||
expect(plainRow.content).toBe('plain edited');
|
||||
expect(plainRow.flags).toBe(MessageFlags.IS_CROSSPOST | MessageFlags.SUPPRESS_EMBEDS);
|
||||
});
|
||||
|
||||
test('removing one attachment by an edit drops it from the copy and deletes no copy objects', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'two files'}, [
|
||||
'keep.png',
|
||||
'drop.gif',
|
||||
]);
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const before = (await readRow(world.b.t1.id, copy.id))!.attachments;
|
||||
expect(before).toHaveLength(2);
|
||||
const sourceRow = (await readRow(world.a.ann.id, source.id))!;
|
||||
await edit(source.id, {attachments: [{id: sourceRow.attachments[0]!.id.toString()}]});
|
||||
await world.worker.drain();
|
||||
expect(before.map((attachment) => attachment.id)).toEqual(sourceRow.attachments.map((attachment) => attachment.id));
|
||||
const after = (await readRow(world.b.t1.id, copy.id))!.attachments;
|
||||
expect(after.map((attachment) => attachment.id)).toEqual([before[0]!.id]);
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
expect(deleted.some((key) => key.startsWith(`attachments/${world.b.t1.id}/`))).toBe(false);
|
||||
expect(deleted).not.toContain(`attachments/${world.a.ann.id}/${before[0]!.id}/${before[0]!.filename}`);
|
||||
const [rendered] = (await listCopies(harness, world.b.owner.token, world.b.t1.id)).filter(
|
||||
(message) => message.id === copy.id,
|
||||
);
|
||||
expect(rendered!.attachments?.map((attachment) => attachment.url?.split('?')[0])).toEqual([
|
||||
expect.stringContaining(`/attachments/${world.a.ann.id}/${before[0]!.id}/${before[0]!.filename}`),
|
||||
]);
|
||||
});
|
||||
|
||||
test('forwarding a copy clones the shared file into the forward channel', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'forward the file'});
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const [attachment] = (await readRow(world.a.ann.id, source.id))!.attachments;
|
||||
const sourceKey = `attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`;
|
||||
const copiedBefore = harness.storageService.getCopiedObjects().length;
|
||||
const forwarded = await createBuilder<MessageResponse>(harness, world.b.owner.token)
|
||||
.post(`/channels/${world.b.t2.id}/messages`)
|
||||
.body({
|
||||
message_reference: {
|
||||
type: MessageReferenceTypes.FORWARD,
|
||||
message_id: copy.id,
|
||||
channel_id: world.b.t1.id,
|
||||
guild_id: world.b.guild.id,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
const clones = harness.storageService.getCopiedObjects().slice(copiedBefore);
|
||||
expect(clones).toHaveLength(1);
|
||||
expect(clones[0]!.sourceKey).toBe(sourceKey);
|
||||
expect(clones[0]!.destinationKey.startsWith(`attachments/${world.b.t2.id}/`)).toBe(true);
|
||||
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, source.id);
|
||||
await world.worker.drain();
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
expect(deleted).toContain(sourceKey);
|
||||
expect(deleted).not.toContain(clones[0]!.destinationKey);
|
||||
expect(forwarded.message_snapshots?.[0]?.attachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('attachment metadata edits reach the copy under the same attachment id', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'alt text'});
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const sourceAttachment = (await readRow(world.a.ann.id, source.id))!.attachments[0]!;
|
||||
const attachmentId = (await readRow(world.b.t1.id, copy.id))!.attachments[0]!.id;
|
||||
expect(attachmentId).toBe(sourceAttachment.id);
|
||||
const copiedBefore = harness.storageService.getCopiedObjects().length;
|
||||
await edit(source.id, {attachments: [{id: sourceAttachment.id.toString(), description: 'A cat'}]});
|
||||
await world.worker.drain();
|
||||
let copied = (await readRow(world.b.t1.id, copy.id))!.attachments[0]!;
|
||||
expect(copied.id).toBe(attachmentId);
|
||||
expect(copied.description).toBe('A cat');
|
||||
const edited = (await readRow(world.a.ann.id, source.id))!;
|
||||
await writeRow(edited, {
|
||||
attachments: edited.attachments.map((attachment) => ({
|
||||
...attachment.toMessageAttachment(),
|
||||
flags: attachment.flags | MessageAttachmentFlags.IS_SPOILER,
|
||||
})),
|
||||
});
|
||||
await enqueueCrosspostSync(world.worker, {
|
||||
channelId: createChannelID(BigInt(world.a.ann.id)),
|
||||
messageId: createMessageID(BigInt(source.id)),
|
||||
mode: 'update',
|
||||
});
|
||||
await world.worker.drain();
|
||||
copied = (await readRow(world.b.t1.id, copy.id))!.attachments[0]!;
|
||||
expect(copied.id).toBe(attachmentId);
|
||||
expect(copied.flags & MessageAttachmentFlags.IS_SPOILER).toBe(MessageAttachmentFlags.IS_SPOILER);
|
||||
expect(harness.storageService.getCopiedObjects()).toHaveLength(copiedBefore);
|
||||
});
|
||||
|
||||
test('a late unfurl on a published source propagates its embeds', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'read https://example.com/post'});
|
||||
const embedService = {
|
||||
processUrlWithCachePolicy: async (url: string) => ({
|
||||
embeds: [
|
||||
{
|
||||
toMessageEmbed: () => ({
|
||||
type: 'link',
|
||||
url,
|
||||
title: 'Example post',
|
||||
description: null,
|
||||
timestamp: null,
|
||||
color: null,
|
||||
author: null,
|
||||
provider: null,
|
||||
thumbnail: null,
|
||||
image: null,
|
||||
video: null,
|
||||
footer: null,
|
||||
fields: null,
|
||||
nsfw: null,
|
||||
}),
|
||||
} as unknown as Embed,
|
||||
],
|
||||
cacheTtlSeconds: 0,
|
||||
}),
|
||||
cacheEmbeds: async () => {},
|
||||
} as unknown as EmbedService;
|
||||
disableCrosspostWorker();
|
||||
world.worker = enableCrosspostWorker({}, {embedService});
|
||||
await extractEmbeds(
|
||||
{channelId: world.a.ann.id, messageId: source.id, guildId: world.a.guild.id, nsfwMode: 'allow'},
|
||||
workerHelpers(world.worker),
|
||||
);
|
||||
expect(world.worker.byTask('syncCrosspostedMessage')).toHaveLength(1);
|
||||
await world.worker.drain();
|
||||
const copyRow = (await readRow(world.b.t1.id, (await onlyCopy(world.b.t1.id, source.id)).id))!;
|
||||
expect(copyRow.embeds.map((embed) => embed.title)).toEqual(['Example post']);
|
||||
});
|
||||
|
||||
test('a sync with nothing new writes nothing', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'steady'});
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const before = await readRow(world.b.t1.id, copy.id);
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
await enqueueCrosspostSync(world.worker, {
|
||||
channelId: createChannelID(BigInt(world.a.ann.id)),
|
||||
messageId: createMessageID(BigInt(source.id)),
|
||||
mode: 'update',
|
||||
});
|
||||
await world.worker.drain();
|
||||
expect((await readRow(world.b.t1.id, copy.id))?.version).toBe(before?.version);
|
||||
expect(dispatchSpy.mock.calls.filter(([params]) => params.event === 'MESSAGE_UPDATE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('deleting the source marks every copy and cleans up the mapping and index rows', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await followInto(harness, world, world.b.t2.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {
|
||||
content: 'goodbye',
|
||||
embeds: [{title: 'Card'}],
|
||||
});
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const copyT1 = await onlyCopy(world.b.t1.id, source.id);
|
||||
const copyT2 = await onlyCopy(world.b.t2.id, source.id);
|
||||
const sourceKeys = (await readRow(world.a.ann.id, source.id))!.attachments.map(
|
||||
(attachment) => `attachments/${world.a.ann.id}/${attachment.id}/${attachment.filename}`,
|
||||
);
|
||||
expect(sourceKeys).toHaveLength(1);
|
||||
for (const [channelId, copyId] of [
|
||||
[world.b.t1.id, copyT1.id],
|
||||
[world.b.t2.id, copyT2.id],
|
||||
] as const) {
|
||||
const copyKeys = (await readRow(channelId, copyId))!.attachments.map(
|
||||
(attachment) => `attachments/${world.a.ann.id}/${attachment.id}/${attachment.filename}`,
|
||||
);
|
||||
expect(copyKeys).toEqual(sourceKeys);
|
||||
}
|
||||
expect(await sourceIndex(world.a.ann.id)).toContain(source.id);
|
||||
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, source.id);
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.t1.id, copyT1.id);
|
||||
await expectDeletedCopy(world.b.t2.id, copyT2.id);
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
for (const key of sourceKeys) {
|
||||
expect(deleted.filter((entry) => entry === key)).toHaveLength(1);
|
||||
}
|
||||
expect(deleted.some((key) => key.startsWith(`attachments/${world.b.t1.id}/`))).toBe(false);
|
||||
expect(deleted.some((key) => key.startsWith(`attachments/${world.b.t2.id}/`))).toBe(false);
|
||||
expect(await mappingRows(source.id)).toHaveLength(0);
|
||||
expect(await sourceIndex(world.a.ann.id)).not.toContain(source.id);
|
||||
});
|
||||
|
||||
describe('other delete paths mark copies too', () => {
|
||||
test('bulk delete', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const first = await postAndPublish(harness, world, {content: 'one'});
|
||||
const second = await postAndPublish(harness, world, {content: 'two'});
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.post(`/channels/${world.a.ann.id}/messages/bulk-delete`)
|
||||
.body({message_ids: [first.id, second.id]})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
for (const source of [first, second]) {
|
||||
await expectDeletedCopy(world.b.t1.id, (await onlyCopy(world.b.t1.id, source.id)).id);
|
||||
}
|
||||
});
|
||||
|
||||
test('a ban that deletes recent messages', async () => {
|
||||
disableCrosspostWorker();
|
||||
world.worker = enableCrosspostWorker({deleteUserMessagesInGuildByTime});
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendMessage(harness, world.a.member.token, world.a.ann.id, {content: 'soon banned'});
|
||||
await publishAndDrain(harness, world, source.id, world.a.member.token);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.put(`/guilds/${world.a.guild.id}/bans/${world.a.member.userId}`)
|
||||
.body({delete_message_seconds: 3600})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
expect(await readRow(world.a.ann.id, source.id)).toBeNull();
|
||||
await expectDeletedCopy(world.b.t1.id, copy.id);
|
||||
});
|
||||
|
||||
test('a self bulk delete', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendMessage(harness, world.a.member.token, world.a.ann.id, {content: 'mine'});
|
||||
await publishAndDrain(harness, world, source.id, world.a.member.token);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
await createTestChannelService().userMessageDeletion.deleteUserMessagesInScope(
|
||||
createUserID(BigInt(world.a.member.userId)),
|
||||
{channelIds: [createChannelID(BigInt(world.a.ann.id))]},
|
||||
);
|
||||
await world.worker.drain();
|
||||
expect(await readRow(world.a.ann.id, source.id)).toBeNull();
|
||||
await expectDeletedCopy(world.b.t1.id, copy.id);
|
||||
});
|
||||
|
||||
test('a webhook token delete of a published webhook message', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const incoming = await createWebhook(harness, world.a.ann.id, world.a.owner.token, 'Releases');
|
||||
const {json} = await executeWebhook(harness, incoming.id, incoming.token, {content: 'bot post', wait: true}, 200);
|
||||
await publishAndDrain(harness, world, json!.id);
|
||||
const copy = await onlyCopy(world.b.t1.id, json!.id);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.delete(`/webhooks/${incoming.id}/${incoming.token}/messages/${json!.id}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.t1.id, copy.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('takedowns delete copies', () => {
|
||||
test('an admin delete of the source', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'illegal'});
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const admin = await setUserACLs(harness, world.a.member, [AdminACLs.AUTHENTICATE, AdminACLs.MESSAGE_DELETE]);
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/channels/${world.a.ann.id}/messages/${source.id}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
expect(await readRow(world.b.t1.id, copy.id)).toBeNull();
|
||||
expect(await listCopies(harness, world.b.owner.token, world.b.t1.id)).toHaveLength(0);
|
||||
expect(
|
||||
dispatchSpy.mock.calls.some(
|
||||
([params]) =>
|
||||
params.event === 'MESSAGE_DELETE' &&
|
||||
params.guildId.toString() === world.b.guild.id &&
|
||||
(params.data as {id: string}).id === copy.id,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await mappingRows(source.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a message shred of the source', async () => {
|
||||
disableCrosspostWorker();
|
||||
world.worker = enableCrosspostWorker({}, {kvClient: getKVClient()});
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendMessage(harness, world.a.member.token, world.a.ann.id, {content: 'shred me'});
|
||||
await publishAndDrain(harness, world, source.id, world.a.member.token);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
await messageShred(
|
||||
{
|
||||
job_id: 'crosspost-propagation-shred',
|
||||
admin_user_id: '1',
|
||||
target_user_id: world.a.member.userId,
|
||||
entries: [{channel_id: world.a.ann.id, message_id: source.id}],
|
||||
},
|
||||
workerHelpers(world.worker),
|
||||
);
|
||||
await world.worker.drain();
|
||||
expect(await readRow(world.a.ann.id, source.id)).toBeNull();
|
||||
expect(await readRow(world.b.t1.id, copy.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing a copy keeps the source files', () => {
|
||||
async function publishedCopyWithFile(): Promise<{sourceId: string; copyId: string; sourceKey: string}> {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await followInto(harness, world, world.b.t2.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'shared file'});
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const [attachment] = (await readRow(world.a.ann.id, source.id))!.attachments;
|
||||
const sourceKey = `attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`;
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
expect((await readRow(world.b.t1.id, copy.id))!.attachments.map((entry) => entry.id)).toEqual([attachment!.id]);
|
||||
return {sourceId: source.id, copyId: copy.id, sourceKey};
|
||||
}
|
||||
|
||||
async function expectSourceFileKept(sourceId: string, sourceKey: string): Promise<void> {
|
||||
await world.worker.drain();
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
expect(deleted).not.toContain(sourceKey);
|
||||
expect(deleted.some((key) => key.startsWith('attachments/'))).toBe(false);
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, sourceKey)).toBe(true);
|
||||
expect((await readRow(world.a.ann.id, sourceId))!.attachments).toHaveLength(1);
|
||||
const sibling = await onlyCopy(world.b.t2.id, sourceId);
|
||||
expect(sibling.attachments?.[0]?.url).toContain(`/${sourceKey}`);
|
||||
}
|
||||
|
||||
test('a moderator delete in the following channel', async () => {
|
||||
const {sourceId, copyId, sourceKey} = await publishedCopyWithFile();
|
||||
await deleteMessageRequest(harness, world.b.owner.token, world.b.t1.id, copyId);
|
||||
expect(await readRow(world.b.t1.id, copyId)).toBeNull();
|
||||
await expectSourceFileKept(sourceId, sourceKey);
|
||||
});
|
||||
|
||||
test('a bulk delete in the following channel', async () => {
|
||||
const {sourceId, copyId, sourceKey} = await publishedCopyWithFile();
|
||||
const other = await sendMessage(harness, world.b.owner.token, world.b.t1.id, {content: 'local'});
|
||||
await createBuilder(harness, world.b.owner.token)
|
||||
.post(`/channels/${world.b.t1.id}/messages/bulk-delete`)
|
||||
.body({message_ids: [copyId, other.id]})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await readRow(world.b.t1.id, copyId)).toBeNull();
|
||||
await expectSourceFileKept(sourceId, sourceKey);
|
||||
});
|
||||
|
||||
test('deleting the following channel', async () => {
|
||||
const {sourceId, sourceKey} = await publishedCopyWithFile();
|
||||
await createBuilder(harness, world.b.owner.token).delete(`/channels/${world.b.t1.id}`).expect(204).execute();
|
||||
await expectSourceFileKept(sourceId, sourceKey);
|
||||
});
|
||||
|
||||
test('deleting the following guild', async () => {
|
||||
const {sourceId, sourceKey} = await publishedCopyWithFile();
|
||||
await createBuilder(harness, world.b.owner.token)
|
||||
.post(`/guilds/${world.b.guild.id}/delete`)
|
||||
.body({password: world.b.owner.password})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
expect(deleted).not.toContain(sourceKey);
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, sourceKey)).toBe(true);
|
||||
expect((await readRow(world.a.ann.id, sourceId))!.attachments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting the source channel after it published', () => {
|
||||
async function publishedWithFile(): Promise<{sourceId: string; copyId: string; sourceKey: string}> {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'channel file'});
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const [attachment] = (await readRow(world.a.ann.id, source.id))!.attachments;
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
return {
|
||||
sourceId: source.id,
|
||||
copyId: copy.id,
|
||||
sourceKey: `attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function convertToText(): Promise<void> {
|
||||
await patchChannel(harness, world.a.owner.token, world.a.ann.id, {type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
}
|
||||
|
||||
async function expectCopyMarkedAndSourcePurged(sourceId: string, copyId: string, sourceKey: string) {
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.t1.id, copyId);
|
||||
expect(await mappingRows(sourceId)).toHaveLength(0);
|
||||
expect(await sourceIndex(world.a.ann.id)).toEqual([]);
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
expect(deleted.filter((key) => key === sourceKey)).toHaveLength(1);
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, sourceKey)).toBe(false);
|
||||
}
|
||||
|
||||
test('a channel converted to text and then deleted marks its copies', async () => {
|
||||
const {sourceId, copyId, sourceKey} = await publishedWithFile();
|
||||
await convertToText();
|
||||
expect((await readRow(world.b.t1.id, copyId))!.content).toBe('channel file');
|
||||
await createBuilder(harness, world.a.owner.token).delete(`/channels/${world.a.ann.id}`).expect(204).execute();
|
||||
await expectCopyMarkedAndSourcePurged(sourceId, copyId, sourceKey);
|
||||
});
|
||||
|
||||
test('a guild deleted after its channel was converted to text marks the copies and purges the files', async () => {
|
||||
const {sourceId, copyId, sourceKey} = await publishedWithFile();
|
||||
await convertToText();
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.post(`/guilds/${world.a.guild.id}/delete`)
|
||||
.body({password: world.a.owner.password})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await expectCopyMarkedAndSourcePurged(sourceId, copyId, sourceKey);
|
||||
});
|
||||
|
||||
test('deleting the source guild purges the source files', async () => {
|
||||
const {sourceId, copyId, sourceKey} = await publishedWithFile();
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.post(`/guilds/${world.a.guild.id}/delete`)
|
||||
.body({password: world.a.owner.password})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await expectCopyMarkedAndSourcePurged(sourceId, copyId, sourceKey);
|
||||
});
|
||||
|
||||
test('a failed follower removal enqueue fails the delete before anything is purged', async () => {
|
||||
const {sourceId, copyId, sourceKey} = await publishedWithFile();
|
||||
world.worker.failure = (taskType) => (taskType === 'removeChannelFollowers' ? new Error('queue down') : null);
|
||||
try {
|
||||
await createBuilder(harness, world.a.owner.token).delete(`/channels/${world.a.ann.id}`).expect(500).execute();
|
||||
} finally {
|
||||
world.worker.failure = null;
|
||||
}
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, sourceKey)).toBe(true);
|
||||
expect(await readRow(world.a.ann.id, sourceId)).not.toBeNull();
|
||||
expect((await readRow(world.b.t1.id, copyId))!.content).toBe('channel file');
|
||||
await createBuilder(harness, world.a.owner.token).delete(`/channels/${world.a.ann.id}`).expect(204).execute();
|
||||
await expectCopyMarkedAndSourcePurged(sourceId, copyId, sourceKey);
|
||||
});
|
||||
});
|
||||
|
||||
test('a delete that read the source before it was flagged still marks the copies', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'raced'});
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const row = (await readRow(world.a.ann.id, source.id))!;
|
||||
await writeRow(row, {flags: row.flags & ~MessageFlags.CROSSPOSTED});
|
||||
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, source.id);
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.t1.id, copy.id);
|
||||
expect(await mappingRows(source.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('deleting unpublished messages in a text channel enqueues no sync jobs', async () => {
|
||||
const message = await sendMessage(harness, world.b.owner.token, world.b.t1.id, {content: 'local only'});
|
||||
await deleteMessageRequest(harness, world.b.owner.token, world.b.t1.id, message.id);
|
||||
expect(world.worker.recorded.filter((job) => job.taskType === 'syncCrosspostedMessage')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('removing a source attachment while publishing is disabled drops it from the copies', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'frozen'}, [
|
||||
'keep.png',
|
||||
'drop.gif',
|
||||
]);
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
const [kept, dropped] = (await readRow(world.a.ann.id, source.id))!.attachments;
|
||||
await addGuildFeature(world.a.guild.id, GuildFeatures.ANNOUNCEMENT_CHANNELS_DISABLED);
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.delete(`/channels/${world.a.ann.id}/messages/${source.id}/attachments/${dropped!.id}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
await edit(source.id, {content: 'frozen, edited'});
|
||||
await world.worker.drain();
|
||||
const copyRow = (await readRow(world.b.t1.id, copy.id))!;
|
||||
expect(copyRow.attachments.map((attachment) => attachment.id)).toEqual([kept!.id]);
|
||||
expect(copyRow.content).toBe('frozen');
|
||||
const droppedKey = `attachments/${world.a.ann.id}/${dropped!.id}/${dropped!.filename}`;
|
||||
expect(harness.storageService.hasObject(Config.s3.buckets.cdn, droppedKey)).toBe(false);
|
||||
});
|
||||
|
||||
test('a copy owns no decay record and its expiry purges only the source file', async () => {
|
||||
const queued: Array<{s3Key: string}> = [];
|
||||
disableCrosspostWorker();
|
||||
world.worker = enableCrosspostWorker(
|
||||
{},
|
||||
{
|
||||
assetDeletionQueue: {
|
||||
async queueDeletion(item: {s3Key: string}) {
|
||||
queued.push(item);
|
||||
},
|
||||
} as unknown as IAssetDeletionQueue,
|
||||
instanceConfigRepository: {
|
||||
async getEffectiveAttachmentDecayConfig() {
|
||||
return {enabled: true};
|
||||
},
|
||||
} as unknown as InstanceConfigRepository,
|
||||
},
|
||||
);
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'decaying'});
|
||||
await publishAndDrain(harness, world, source.id);
|
||||
const [attachment] = (await readRow(world.a.ann.id, source.id))!.attachments;
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
await createBuilder(harness, world.b.owner.token).get(`/channels/${world.b.t1.id}/messages/${copy.id}`).execute();
|
||||
const repository = new AttachmentDecayRepository();
|
||||
const record = await repository.fetchById(attachment!.id);
|
||||
expect(record?.channel_id.toString()).toBe(world.a.ann.id);
|
||||
expect(record?.message_id.toString()).toBe(source.id);
|
||||
const expiresAt = new Date(Date.now() - 60_000);
|
||||
await repository.upsert({...record!, expires_at: expiresAt, expiry_bucket: getExpiryBucket(expiresAt)});
|
||||
await processExpiredAttachments();
|
||||
expect(queued.map((item) => item.s3Key)).toEqual([
|
||||
`attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a copy deleted in the target is cleaned up lazily on the next edit', async () => {
|
||||
const webhookId = await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'moderated'});
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
await deleteMessageRequest(harness, world.b.owner.token, world.b.t1.id, copy.id);
|
||||
expect(await mappingRow(source.id, webhookId)).not.toBeNull();
|
||||
await edit(source.id, {content: 'edited after removal'});
|
||||
await world.worker.drain();
|
||||
expect(world.worker.deadLetters).toHaveLength(0);
|
||||
expect(await mappingRow(source.id, webhookId)).toBeNull();
|
||||
});
|
||||
|
||||
test('syncs after the source is gone are no-ops', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'twice'});
|
||||
const copy = await onlyCopy(world.b.t1.id, source.id);
|
||||
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, source.id);
|
||||
await world.worker.drain();
|
||||
const marked = await readRow(world.b.t1.id, copy.id);
|
||||
for (const mode of ['update', 'source_deleted'] as const) {
|
||||
await enqueueCrosspostSync(world.worker, {
|
||||
channelId: createChannelID(BigInt(world.a.ann.id)),
|
||||
messageId: createMessageID(BigInt(source.id)),
|
||||
mode,
|
||||
});
|
||||
await world.worker.drain();
|
||||
}
|
||||
expect(world.worker.deadLetters).toHaveLength(0);
|
||||
expect((await readRow(world.b.t1.id, copy.id))?.version).toBe(marked?.version);
|
||||
});
|
||||
|
||||
test('a copy rewritten as source deleted never takes later updates', async () => {
|
||||
await followInto(harness, world, world.b.ageRestricted.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'rule change'});
|
||||
const copy = await onlyCopy(world.b.ageRestricted.id, source.id);
|
||||
await patchChannel(harness, world.a.owner.token, world.a.ann.id, {nsfw_override: true}).expect(200).execute();
|
||||
await patchChannel(harness, world.b.owner.token, world.b.ageRestricted.id, {nsfw_override: false})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await edit(source.id, {content: 'second version'});
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.ageRestricted.id, copy.id);
|
||||
await patchChannel(harness, world.b.owner.token, world.b.ageRestricted.id, {nsfw_override: true})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await edit(source.id, {content: 'third version'});
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.ageRestricted.id, copy.id);
|
||||
});
|
||||
|
||||
test('a delivered copy keeps updating after an unfollow', async () => {
|
||||
const webhookId = await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'before unfollow'});
|
||||
await createBuilder(harness, world.b.owner.token).delete(`/webhooks/${webhookId}`).expect(204).execute();
|
||||
await edit(source.id, {content: 'after unfollow'});
|
||||
await world.worker.drain();
|
||||
expect((await onlyCopy(world.b.t1.id, source.id)).content).toBe('after unfollow');
|
||||
});
|
||||
|
||||
test('old copies keep updating after the follower webhook moves', async () => {
|
||||
const webhookId = await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'in t1'});
|
||||
await createBuilder(harness, world.b.owner.token)
|
||||
.patch(`/webhooks/${webhookId}`)
|
||||
.body({channel_id: world.b.t2.id})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await edit(source.id, {content: 'still in t1'});
|
||||
await world.worker.drain();
|
||||
expect((await onlyCopy(world.b.t1.id, source.id)).content).toBe('still in t1');
|
||||
expect(await copiesOf(harness, world.b.owner.token, world.b.t2.id, source.id)).toHaveLength(0);
|
||||
const next = await postAndPublish(harness, world, {content: 'lands in t2'});
|
||||
expect(await copiesOf(harness, world.b.owner.token, world.b.t2.id, next.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('unpublished messages enqueue no sync jobs on edit and a no-op one on delete', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'private note'});
|
||||
await edit(message.id, {content: 'edited'});
|
||||
expect(world.worker.byTask('syncCrosspostedMessage')).toHaveLength(0);
|
||||
await deleteMessageRequest(harness, world.a.owner.token, world.a.ann.id, message.id);
|
||||
expect(world.worker.byTask('syncCrosspostedMessage')).toHaveLength(1);
|
||||
await world.worker.drain();
|
||||
expect(world.worker.byTask('syncCrosspostCopies')).toHaveLength(0);
|
||||
expect(world.worker.deadLetters).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a target that loses its age restriction gets the copy rewritten on the next edit', async () => {
|
||||
await patchChannel(harness, world.a.owner.token, world.a.ann.id, {nsfw_override: true}).expect(200).execute();
|
||||
await followInto(harness, world, world.b.ageRestricted.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'restricted news'});
|
||||
const copy = await onlyCopy(world.b.ageRestricted.id, source.id);
|
||||
await patchChannel(harness, world.b.owner.token, world.b.ageRestricted.id, {nsfw_override: false})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await edit(source.id, {content: 'restricted news, edited'});
|
||||
await world.worker.drain();
|
||||
await expectDeletedCopy(world.b.ageRestricted.id, copy.id);
|
||||
});
|
||||
|
||||
test('webhook token edits and attachment deletes propagate', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const incoming = await createWebhook(harness, world.a.ann.id, world.a.owner.token, 'Releases');
|
||||
const {json} = await executeWebhook(harness, incoming.id, incoming.token, {content: 'v1', wait: true}, 200);
|
||||
await publishAndDrain(harness, world, json!.id);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch(`/webhooks/${incoming.id}/${incoming.token}/messages/${json!.id}`)
|
||||
.body({content: 'v2'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
expect((await onlyCopy(world.b.t1.id, json!.id)).content).toBe('v2');
|
||||
const withFile = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'file'});
|
||||
await publishAndDrain(harness, world, withFile.id);
|
||||
const copy = await onlyCopy(world.b.t1.id, withFile.id);
|
||||
const sourceAttachment = (await readRow(world.a.ann.id, withFile.id))!.attachments[0]!;
|
||||
const attachmentId = sourceAttachment.id;
|
||||
expect((await readRow(world.b.t1.id, copy.id))!.attachments[0]!.id).toBe(attachmentId);
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.delete(`/channels/${world.a.ann.id}/messages/${withFile.id}/attachments/${attachmentId}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
await world.worker.drain();
|
||||
expect((await readRow(world.b.t1.id, copy.id))!.attachments).toHaveLength(0);
|
||||
const deleted = harness.storageService.getDeletedObjects().map((entry) => entry.key);
|
||||
expect(
|
||||
deleted.filter((key) => key === `attachments/${world.a.ann.id}/${attachmentId}/${sourceAttachment.filename}`),
|
||||
).toHaveLength(1);
|
||||
expect(deleted.some((key) => key.startsWith(`attachments/${world.b.t1.id}/`))).toBe(false);
|
||||
});
|
||||
|
||||
test('copies in several guilds follow edits of the source', async () => {
|
||||
const c = await createAnnouncementTargetGuild(harness, 'Third Community');
|
||||
await addGuildMember(harness, world.a.owner, world.a.guild, c.owner);
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
await followInto(harness, world, c.t1.id, c.owner.token);
|
||||
const source = await postAndPublish(harness, world, {content: 'shared'});
|
||||
await edit(source.id, {content: 'shared, edited'});
|
||||
await world.worker.drain();
|
||||
expect((await onlyCopy(world.b.t1.id, source.id)).content).toBe('shared, edited');
|
||||
expect((await onlyCopy(c.t1.id, source.id, c.owner.token)).content).toBe('shared, edited');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {loadFixture, sendMessageWithAttachments} from '@app/api/channel/tests/AttachmentTestUtils';
|
||||
import {
|
||||
type AnnouncementWorld,
|
||||
channelBudgetRemaining,
|
||||
createAnnouncementChannel,
|
||||
createAnnouncementWorld,
|
||||
crosspostRequest,
|
||||
editMessage,
|
||||
postMessage,
|
||||
publish,
|
||||
publishedEditBudgetRemaining,
|
||||
RecordingWorkerService,
|
||||
} from '@app/api/channel/tests/CrosspostTestUtils';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import {
|
||||
CROSSPOST_CHANNEL_RATE_LIMIT,
|
||||
PUBLISHED_MESSAGE_EDIT_RATE_LIMIT,
|
||||
} from '@fluxer/constants/src/AnnouncementConstants';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface RateLimitBody {
|
||||
code: string;
|
||||
retry_after: number;
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
describe('Publishing and published-edit limits', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let worker: RecordingWorkerService;
|
||||
let world: AnnouncementWorld;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
worker = new RecordingWorkerService();
|
||||
setInjectedWorkerService(worker);
|
||||
world = await createAnnouncementWorld(harness);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function publishMany(channelId: string, count: number, token = world.author.token): Promise<void> {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const message = await postMessage(harness, token, channelId, `Update ${channelId} ${index}`);
|
||||
await publish(harness, token, channelId, message.id);
|
||||
}
|
||||
}
|
||||
|
||||
test('a channel publishes 10 in a row, then the 11th is limited with a shared scope', async () => {
|
||||
await publishMany(world.announcement.id, CROSSPOST_CHANNEL_RATE_LIMIT.maxAttempts);
|
||||
const extra = await postMessage(harness, world.author.token, world.announcement.id, 'One too many');
|
||||
const {response, json} = await crosspostRequest(harness, world.author.token, world.announcement.id, extra.id)
|
||||
.expect(429, APIErrorCodes.MESSAGE_CROSSPOST_RATE_LIMITED)
|
||||
.executeWithResponse();
|
||||
const body = json as unknown as RateLimitBody;
|
||||
expect(body.retry_after).toBeGreaterThan(300);
|
||||
expect(body.retry_after).toBeLessThanOrEqual(360);
|
||||
expect(body.global).toBe(false);
|
||||
expect(response.headers.get('X-RateLimit-Scope')).toBe('shared');
|
||||
expect(Number(response.headers.get('Retry-After'))).toBeGreaterThan(300);
|
||||
expect(worker.byTask('crosspostMessage')).toHaveLength(CROSSPOST_CHANNEL_RATE_LIMIT.maxAttempts);
|
||||
});
|
||||
|
||||
test('the channel limit is per channel and shared across members', async () => {
|
||||
await publishMany(world.announcement.id, CROSSPOST_CHANNEL_RATE_LIMIT.maxAttempts);
|
||||
const byMod = await postMessage(harness, world.mod.token, world.announcement.id, 'Moderator news');
|
||||
await crosspostRequest(harness, world.mod.token, world.announcement.id, byMod.id)
|
||||
.expect(429, APIErrorCodes.MESSAGE_CROSSPOST_RATE_LIMITED)
|
||||
.execute();
|
||||
const second = await createAnnouncementChannel(harness, world.owner.token, world.guild.id, 'news-two');
|
||||
const elsewhere = await postMessage(harness, world.author.token, second.id, 'Different channel');
|
||||
await publish(harness, world.author.token, second.id, elsewhere.id);
|
||||
});
|
||||
|
||||
test('a community has no shared publish allowance across its announcement channels', async () => {
|
||||
const channels = [
|
||||
world.announcement,
|
||||
await createAnnouncementChannel(harness, world.owner.token, world.guild.id, 'news-b'),
|
||||
await createAnnouncementChannel(harness, world.owner.token, world.guild.id, 'news-c'),
|
||||
await createAnnouncementChannel(harness, world.owner.token, world.guild.id, 'news-d'),
|
||||
];
|
||||
for (const channel of channels) {
|
||||
await publishMany(channel.id, CROSSPOST_CHANNEL_RATE_LIMIT.maxAttempts);
|
||||
expect(await channelBudgetRemaining(channel.id)).toBe(0);
|
||||
}
|
||||
expect(worker.byTask('crosspostMessage')).toHaveLength(channels.length * CROSSPOST_CHANNEL_RATE_LIMIT.maxAttempts);
|
||||
const last = channels[3]!;
|
||||
const extra = await postMessage(harness, world.author.token, last.id, 'One past the channel allowance');
|
||||
const {response} = await crosspostRequest(harness, world.author.token, last.id, extra.id)
|
||||
.expect(429, APIErrorCodes.MESSAGE_CROSSPOST_RATE_LIMITED)
|
||||
.executeWithResponse();
|
||||
expect(response.headers.get('X-RateLimit-Scope')).toBe('shared');
|
||||
});
|
||||
|
||||
test('failed publishes do not consume budget', async () => {
|
||||
const published = await postMessage(harness, world.author.token, world.announcement.id, 'Published once');
|
||||
await publish(harness, world.author.token, world.announcement.id, published.id);
|
||||
const reply = await createBuilder<MessageResponse>(harness, world.author.token)
|
||||
.post(`/channels/${world.announcement.id}/messages`)
|
||||
.body({content: 'Reply', message_reference: {message_id: published.id}})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await crosspostRequest(harness, world.author.token, world.announcement.id, published.id)
|
||||
.expect(400, APIErrorCodes.MESSAGE_ALREADY_CROSSPOSTED)
|
||||
.execute();
|
||||
await crosspostRequest(harness, world.author.token, world.announcement.id, reply.id)
|
||||
.expect(400, APIErrorCodes.MESSAGE_NOT_CROSSPOSTABLE)
|
||||
.execute();
|
||||
await crosspostRequest(harness, world.member.token, world.announcement.id, reply.id)
|
||||
.expect(403, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
await crosspostRequest(harness, world.author.token, world.announcement.id, '123456789012345678')
|
||||
.expect(404, APIErrorCodes.UNKNOWN_MESSAGE)
|
||||
.execute();
|
||||
expect(await channelBudgetRemaining(world.announcement.id)).toBe(CROSSPOST_CHANNEL_RATE_LIMIT.maxAttempts - 1);
|
||||
});
|
||||
|
||||
test('a published message can be edited 3 times in a row, then the 4th is limited', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Edit me');
|
||||
const other = await postMessage(harness, world.author.token, world.announcement.id, 'Edit me too');
|
||||
await publish(harness, world.author.token, world.announcement.id, message.id);
|
||||
await publish(harness, world.author.token, world.announcement.id, other.id);
|
||||
for (let index = 0; index < PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts; index++) {
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: `Edit ${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
const {response, json} = await editMessage(harness, world.author.token, world.announcement.id, message.id, {
|
||||
content: 'Edit 4',
|
||||
})
|
||||
.expect(429, APIErrorCodes.PUBLISHED_MESSAGE_EDIT_RATE_LIMITED)
|
||||
.executeWithResponse();
|
||||
const body = json as unknown as RateLimitBody;
|
||||
expect(body.retry_after).toBeGreaterThan(1100);
|
||||
expect(body.retry_after).toBeLessThanOrEqual(1200);
|
||||
expect(response.headers.get('X-RateLimit-Scope')).toBe('shared');
|
||||
expect(worker.syncJobsFor(message.id)).toHaveLength(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts);
|
||||
await editMessage(harness, world.author.token, world.announcement.id, other.id, {content: 'Other edit'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('edits before publishing do not count toward the published-edit cap', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Draft');
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: `Draft ${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
expect(worker.syncJobsFor(message.id)).toHaveLength(0);
|
||||
await publish(harness, world.author.token, world.announcement.id, message.id);
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts);
|
||||
for (let index = 0; index < PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts; index++) {
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: `Final ${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
test('unpublished messages are never limited by the published-edit cap', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Never published');
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: `Version ${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts);
|
||||
expect(worker.byTask('syncCrosspostedMessage')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a moderator suppress-embeds edit is not limited and still propagates', async () => {
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Link https://example.com');
|
||||
await publish(harness, world.author.token, world.announcement.id, message.id);
|
||||
for (let index = 0; index < PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts; index++) {
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: `Link ${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: 'Blocked'})
|
||||
.expect(429, APIErrorCodes.PUBLISHED_MESSAGE_EDIT_RATE_LIMITED)
|
||||
.execute();
|
||||
const syncsBefore = worker.syncJobsFor(message.id).length;
|
||||
const suppressed = await editMessage(harness, world.mod.token, world.announcement.id, message.id, {
|
||||
flags: MessageFlags.SUPPRESS_EMBEDS,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(suppressed.flags).toBe(MessageFlags.CROSSPOSTED | MessageFlags.SUPPRESS_EMBEDS);
|
||||
const restored = await editMessage(harness, world.mod.token, world.announcement.id, message.id, {flags: 0})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(restored.flags).toBe(MessageFlags.CROSSPOSTED);
|
||||
expect(worker.syncJobsFor(message.id).length).toBe(syncsBefore + 2);
|
||||
});
|
||||
|
||||
test('webhook token edits count toward the published-edit cap', async () => {
|
||||
const webhook = await createWebhook(harness, world.announcement.id, world.owner.token, 'Changelog');
|
||||
const executed = await createBuilderWithoutAuth<MessageResponse>(harness)
|
||||
.post(`/webhooks/${webhook.id}/${webhook.token}?wait=true`)
|
||||
.body({content: 'v1'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await publish(harness, world.mod.token, world.announcement.id, executed.id);
|
||||
const editPath = `/webhooks/${webhook.id}/${webhook.token}/messages/${executed.id}`;
|
||||
for (let index = 0; index < PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts; index++) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch(editPath)
|
||||
.body({content: `v1.${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch(editPath)
|
||||
.body({content: 'v1.9'})
|
||||
.expect(429, APIErrorCodes.PUBLISHED_MESSAGE_EDIT_RATE_LIMITED)
|
||||
.execute();
|
||||
expect(worker.syncJobsFor(executed.id)).toHaveLength(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts);
|
||||
});
|
||||
|
||||
test('published edits that fail validation do not use the edit budget', async () => {
|
||||
const missingUpload = {
|
||||
attachments: [
|
||||
{
|
||||
id: 0,
|
||||
filename: 'upload.png',
|
||||
upload_filename: 'missing-upload-key',
|
||||
file_size: 2048,
|
||||
content_type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
const message = await postMessage(harness, world.author.token, world.announcement.id, 'Edit me');
|
||||
await publish(harness, world.author.token, world.announcement.id, message.id);
|
||||
for (let index = 0; index < PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts; index++) {
|
||||
const {json} = await editMessage(harness, world.author.token, world.announcement.id, message.id, {
|
||||
content: `Broken ${index}`,
|
||||
...missingUpload,
|
||||
})
|
||||
.expect(400)
|
||||
.executeWithResponse();
|
||||
expect(JSON.stringify(json)).toContain('FILE_NOT_FOUND');
|
||||
}
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts);
|
||||
await editMessage(harness, world.author.token, world.announcement.id, message.id, {content: 'Valid edit'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(await publishedEditBudgetRemaining(message.id)).toBe(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts - 1);
|
||||
});
|
||||
|
||||
test('removing an attachment from a published message counts toward the cap and propagates', async () => {
|
||||
const {json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
world.author.token,
|
||||
world.announcement.id,
|
||||
{
|
||||
content: 'Two pictures',
|
||||
attachments: [
|
||||
{id: 0, filename: 'first.png'},
|
||||
{id: 1, filename: 'second.png'},
|
||||
],
|
||||
},
|
||||
[
|
||||
{index: 0, filename: 'first.png', data: loadFixture('yeah.png')},
|
||||
{index: 1, filename: 'second.png', data: loadFixture('yeah.png')},
|
||||
],
|
||||
);
|
||||
expect(json.attachments?.length).toBe(2);
|
||||
await publish(harness, world.author.token, world.announcement.id, json.id);
|
||||
await createBuilder(harness, world.author.token)
|
||||
.delete(`/channels/${world.announcement.id}/messages/${json.id}/attachments/${json.attachments![0]!.id}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await publishedEditBudgetRemaining(json.id)).toBe(PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts - 1);
|
||||
expect(worker.syncJobsFor(json.id)).toHaveLength(1);
|
||||
for (let index = 0; index < PUBLISHED_MESSAGE_EDIT_RATE_LIMIT.maxAttempts - 1; index++) {
|
||||
await editMessage(harness, world.author.token, world.announcement.id, json.id, {content: `Caption ${index}`})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
await createBuilder(harness, world.author.token)
|
||||
.delete(`/channels/${world.announcement.id}/messages/${json.id}/attachments/${json.attachments![1]!.id}`)
|
||||
.expect(429, APIErrorCodes.PUBLISHED_MESSAGE_EDIT_RATE_LIMITED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('the publish and follow route buckets are enforced when route limits are on', async () => {
|
||||
expect(RateLimitConfigs.CHANNEL_MESSAGE_CROSSPOST).toEqual({
|
||||
bucket: 'channel:message:crosspost::channel_id',
|
||||
config: {limit: 5, windowMs: ms('5 seconds')},
|
||||
});
|
||||
expect(RateLimitConfigs.CHANNEL_FOLLOW).toEqual({
|
||||
bucket: 'channel:follow::channel_id',
|
||||
config: {limit: 5, windowMs: ms('10 seconds')},
|
||||
});
|
||||
expect(RateLimitConfigs.CHANNEL_FOLLOWER_STATS).toEqual({
|
||||
bucket: 'channel:follower_stats::channel_id',
|
||||
config: {limit: 10, windowMs: ms('10 seconds')},
|
||||
});
|
||||
const statuses: Array<number> = [];
|
||||
for (let index = 0; index < 6; index++) {
|
||||
const {response} = await crosspostRequest(
|
||||
harness,
|
||||
world.author.token,
|
||||
world.announcement.id,
|
||||
`12345678901234567${index}`,
|
||||
)
|
||||
.header('x-fluxer-test-enable-rate-limits', 'true')
|
||||
.executeRaw();
|
||||
statuses.push(response.status);
|
||||
}
|
||||
expect(statuses.slice(0, 5)).toEqual([404, 404, 404, 404, 404]);
|
||||
expect(statuses[5]).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {addGuildMember, disableCrosspostWorker, setGuildFeatures} from '@app/api/channel/tests/AnnouncementTestUtils';
|
||||
import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
copiesOf,
|
||||
type FanoutWorld,
|
||||
followInto,
|
||||
postAndPublish,
|
||||
sendMessage,
|
||||
setupFanoutWorld,
|
||||
} from '@app/api/channel/tests/CrosspostWorkerTestUtils';
|
||||
import {ensureSessionStarted, getMessages} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getGuildDiscoveryRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DiscoveryApplicationStatus, DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {CrosspostSourceResponse} from '@fluxer/schema/src/domains/message/CrosspostSourceSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
describe('Crosspost source', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let world: FanoutWorld;
|
||||
let outsider: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
world = await setupFanoutWorld(harness);
|
||||
outsider = await createTestAccount(harness);
|
||||
await addGuildMember(harness, world.b.owner, world.b.guild, outsider);
|
||||
await ensureSessionStarted(harness, outsider.token);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disableCrosspostWorker();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
function sourcePath(channelId: string, messageId: string): string {
|
||||
return `/channels/${channelId}/messages/${messageId}/crosspost-source`;
|
||||
}
|
||||
|
||||
function getSource(token: string, channelId: string, messageId: string) {
|
||||
return createBuilder<CrosspostSourceResponse>(harness, token).get(sourcePath(channelId, messageId));
|
||||
}
|
||||
|
||||
async function publishedCopy(): Promise<MessageResponse> {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'Version 2 is out'});
|
||||
const [copy] = await copiesOf(harness, world.b.owner.token, world.b.t1.id, source.id);
|
||||
expect(copy).toBeDefined();
|
||||
return copy!;
|
||||
}
|
||||
|
||||
async function followNotice(): Promise<MessageResponse> {
|
||||
const messages = await getMessages(harness, world.b.owner.token, world.b.t1.id);
|
||||
const notice = messages.find((message) => message.type === MessageTypes.CHANNEL_FOLLOW_ADD);
|
||||
expect(notice).toBeDefined();
|
||||
return notice!;
|
||||
}
|
||||
|
||||
test('returns the source community card to a viewer who is not a member of it', async () => {
|
||||
const copy = await publishedCopy();
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getGuildCounts').mockResolvedValue({
|
||||
memberCount: 1234,
|
||||
presenceCount: 56,
|
||||
});
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response).toEqual({
|
||||
guild: {
|
||||
id: world.a.guild.id,
|
||||
name: world.a.guild.name,
|
||||
icon: null,
|
||||
banner: null,
|
||||
description: null,
|
||||
features: [],
|
||||
approximate_member_count: 1234,
|
||||
approximate_presence_count: 56,
|
||||
discoverable: false,
|
||||
},
|
||||
});
|
||||
expect(Object.keys(response.guild).sort()).toEqual(
|
||||
[
|
||||
'approximate_member_count',
|
||||
'approximate_presence_count',
|
||||
'banner',
|
||||
'description',
|
||||
'discoverable',
|
||||
'features',
|
||||
'icon',
|
||||
'id',
|
||||
'name',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns the same card to a viewer who is a member of the source community', async () => {
|
||||
const copy = await publishedCopy();
|
||||
const asMember = await getSource(world.b.owner.token, world.b.t1.id, copy.id).execute();
|
||||
const asOutsider = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(asMember).toEqual(asOutsider);
|
||||
expect(asMember.guild.id).toBe(world.a.guild.id);
|
||||
});
|
||||
|
||||
async function writeDiscoveryApplication(status: string, description: string): Promise<void> {
|
||||
await getGuildDiscoveryRepository().upsert({
|
||||
guild_id: createGuildID(BigInt(world.a.guild.id)),
|
||||
status,
|
||||
category_type: DiscoveryCategories.OTHER,
|
||||
description,
|
||||
primary_language: null,
|
||||
custom_tags: null,
|
||||
applied_at: new Date(),
|
||||
reviewed_at: null,
|
||||
reviewed_by: null,
|
||||
review_reason: null,
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removal_reason: null,
|
||||
});
|
||||
}
|
||||
|
||||
test('returns the community description while it is listed in discovery', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await writeDiscoveryApplication(DiscoveryApplicationStatus.APPROVED, 'Release notes and outage reports');
|
||||
await setGuildFeatures(harness, world.a.guild.id, {add: [GuildFeatures.DISCOVERABLE]});
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response.guild.description).toBe('Release notes and outage reports');
|
||||
});
|
||||
|
||||
test('hides the description of an application that is not approved', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await writeDiscoveryApplication(DiscoveryApplicationStatus.PENDING, 'Pending application text');
|
||||
await setGuildFeatures(harness, world.a.guild.id, {add: [GuildFeatures.DISCOVERABLE]});
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response.guild.description).toBeNull();
|
||||
});
|
||||
|
||||
test('hides an approved description once the community is no longer listed', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await writeDiscoveryApplication(DiscoveryApplicationStatus.APPROVED, 'Delisted community text');
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response.guild.description).toBeNull();
|
||||
});
|
||||
|
||||
test('reports a discoverable source community as joinable', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await setGuildFeatures(harness, world.a.guild.id, {add: [GuildFeatures.DISCOVERABLE]});
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response.guild.discoverable).toBe(true);
|
||||
expect(response.guild.features).toEqual([GuildFeatures.DISCOVERABLE]);
|
||||
});
|
||||
|
||||
test('does not report a discoverable community with invites disabled as joinable', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await setGuildFeatures(harness, world.a.guild.id, {
|
||||
add: [GuildFeatures.DISCOVERABLE, GuildFeatures.INVITES_DISABLED],
|
||||
});
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response.guild.discoverable).toBe(false);
|
||||
});
|
||||
|
||||
test('only exposes the badge features of the source community', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await setGuildFeatures(harness, world.a.guild.id, {
|
||||
add: [
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.PARTNERED,
|
||||
GuildFeatures.INVITES_DISABLED,
|
||||
GuildFeatures.VIP_VOICE,
|
||||
GuildFeatures.VANITY_URL,
|
||||
],
|
||||
});
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect([...response.guild.features].sort()).toEqual([GuildFeatures.PARTNERED, GuildFeatures.VERIFIED].sort());
|
||||
});
|
||||
|
||||
test('returns null counts when the gateway cannot provide them', async () => {
|
||||
const copy = await publishedCopy();
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getGuildCounts').mockRejectedValue(new Error('gateway down'));
|
||||
const response = await getSource(outsider.token, world.b.t1.id, copy.id).execute();
|
||||
expect(response.guild.approximate_member_count).toBeNull();
|
||||
expect(response.guild.approximate_presence_count).toBeNull();
|
||||
expect(response.guild.name).toBe(world.a.guild.name);
|
||||
});
|
||||
|
||||
test('accepts the follow system message', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const notice = await followNotice();
|
||||
const response = await getSource(outsider.token, world.b.t1.id, notice.id).execute();
|
||||
expect(response.guild.id).toBe(world.a.guild.id);
|
||||
});
|
||||
|
||||
test('rejects a message that is not a copy', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendMessage(harness, world.b.owner.token, world.b.t1.id, {content: 'hello'});
|
||||
await getSource(outsider.token, world.b.t1.id, message.id)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('rejects the source message itself', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const source = await postAndPublish(harness, world, {content: 'published'});
|
||||
await getSource(world.b.owner.token, world.a.ann.id, source.id)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_MESSAGE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('rejects a viewer who cannot view the channel of the copy', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await createPermissionOverwrite(harness, world.b.owner.token, world.b.t1.id, outsider.userId, {
|
||||
type: 1,
|
||||
allow: '0',
|
||||
deny: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
const message = await createBuilder(harness, outsider.token)
|
||||
.get(`/channels/${world.b.t1.id}/messages/${copy.id}`)
|
||||
.executeRaw();
|
||||
expect(message.response.status).not.toBe(HTTP_STATUS.OK);
|
||||
const source = await getSource(outsider.token, world.b.t1.id, copy.id).executeRaw();
|
||||
expect(source.response.status).toBe(message.response.status);
|
||||
expect((source.json as {code?: string}).code).toBe((message.json as {code?: string}).code);
|
||||
});
|
||||
|
||||
test('rejects a viewer who is not in the community of the copy', async () => {
|
||||
const copy = await publishedCopy();
|
||||
const stranger = await createTestAccount(harness);
|
||||
const message = await createBuilder(harness, stranger.token)
|
||||
.get(`/channels/${world.b.t1.id}/messages/${copy.id}`)
|
||||
.executeRaw();
|
||||
expect(message.response.status).not.toBe(HTTP_STATUS.OK);
|
||||
const source = await getSource(stranger.token, world.b.t1.id, copy.id).executeRaw();
|
||||
expect(source.response.status).toBe(message.response.status);
|
||||
expect((source.json as {code?: string}).code).toBe((message.json as {code?: string}).code);
|
||||
});
|
||||
|
||||
test('returns unknown guild once the source community is deleted', async () => {
|
||||
const copy = await publishedCopy();
|
||||
await createBuilder(harness, world.a.owner.token)
|
||||
.post(`/guilds/${world.a.guild.id}/delete`)
|
||||
.body({password: world.a.owner.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
await world.worker.drain();
|
||||
await getSource(outsider.token, world.b.t1.id, copy.id)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_GUILD)
|
||||
.execute();
|
||||
const notice = await followNotice();
|
||||
await getSource(outsider.token, world.b.t1.id, notice.id)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_GUILD)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user