Compare commits

...
Author SHA1 Message Date
HampusandGitHub 98cce4815d feat(users): add temporary new conversation limits (#3100) 2026-10-02 01:28:35 +02:00
HampusandGitHub b375abc20a feat(desktop): live-reload linked css theme files (#3099) 2026-10-02 01:02:30 +02:00
HampusandGitHub 21cb7ba69c feat(premium): show App Store and Google Play subs on web (#3098) 2026-10-01 22:51:14 +02:00
HampusandGitHub be69333eaf feat(premium): add the Plutonium page behind an experiment (#3097) 2026-10-01 21:45:44 +02:00
HampusandGitHub 9a074adb11 fix(app): make disabling built-in shortcuts take effect live (#3096) 2026-10-01 20:37:28 +02:00
HampusandGitHub 2df82b2b5e fix(guild): treat very high as high without phone verification (#3095) 2026-10-01 20:33:02 +02:00
HampusandGitHub d691047884 feat(desktop): add start minimized option for launch at login (#3094) 2026-10-01 19:51:43 +02:00
HampusandGitHub c2e7fde5bc test(api): isolate crosspost tests that mock constants (#3091) 2026-10-01 17:13:49 +02:00
HampusandGitHub 7e4d5137f8 feat: add announcement channels, publishing and following (#3090) 2026-10-01 16:57:21 +02:00
HampusandGitHub 376afd2ad6 fix(voice): keep mic publish state in sync with voice state (#3088) 2026-10-01 14:03:04 +02:00
HampusandGitHub e3fcedbec5 fix(voice): stabilize voice input and noise suppression (#3087) 2026-10-01 14:02:12 +02:00
HampusandGitHub 7c9564bcad feat(deploy): add helm charts for the fluxer services (#3082) 2026-10-01 04:11:30 +02:00
HampusandGitHub cfed6cc4e0 perf(media-proxy): gzip static assets on the fly (#3079) 2026-09-30 23:41:16 +02:00
HampusandGitHub c7bd1be3e4 fix(auth): offer every transport for passkeys stored without any (#3077) 2026-09-30 23:01:37 +02:00
HampusandGitHub 2161d84701 fix(self-hosting): grow seaweedfs one volume at a time (#3076) 2026-09-30 22:55:12 +02:00
HampusandGitHub eaeeb3b502 fix(api): report final system DM progress (#3074) 2026-09-30 22:11:34 +02:00
661 changed files with 86931 additions and 27132 deletions
+2
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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"}}},
+6
View File
@@ -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 }}
+24
View File
@@ -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 }}
+86
View File
@@ -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
+6
View File
@@ -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 }}
+86
View File
@@ -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: {}
+6
View File
@@ -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 }}
+108
View File
@@ -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
+6
View File
@@ -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 }}
+53
View File
@@ -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
+6
View File
@@ -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 }}
+65
View File
@@ -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
+6
View File
@@ -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 }}
+89
View File
@@ -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: {}
+6
View File
@@ -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 }}
+83
View File
@@ -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
+8
View File
@@ -160,6 +160,9 @@ MEILI_MASTER_KEY=CHANGE_ME
# api.pwnedpasswords.com.
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
# Phone verification needs your own responder on the rpc.phone.v1 NATS
# subjects. Off unless turned on.
#FLUXER_PHONE_VERIFICATION_ENABLED=false
# A local path, or an s3:// URL read with the S3 credentials of this file.
#FLUXER_GEOIP_DB_PATH=
@@ -448,6 +451,11 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
#FLUXER_SEAWEEDFS_TELEMETRY=false
# Volumes SeaweedFS creates at once when a bucket needs space. Each reserves 1 GB
# of free disk from the start, and SeaweedFS's own default of 7 fills a small
# disk before every bucket has one, so uploads fail with no free volumes left.
#FLUXER_SEAWEEDFS_VOLUME_GROWTH=1
# Node sizes its heap from the container limit by default. Leave these unset
# unless you need to pin it. A heap ceiling above the container limit gets the
# container OOM-killed instead of reporting a heap error. The values below are
+2
View File
@@ -33,6 +33,7 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
FLUXER_PHONE_VERIFICATION_ENABLED: ${FLUXER_PHONE_VERIFICATION_ENABLED:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
@@ -353,6 +354,7 @@ services:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
WEED_MASTER_VOLUME_GROWTH_COPY_1: ${FLUXER_SEAWEEDFS_VOLUME_GROWTH:-1}
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
volumes:
- seaweedfs-data:/data
+18
View File
@@ -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,
+107 -9
View File
@@ -10809,6 +10809,7 @@
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageConfigResponse"},
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
@@ -11209,6 +11210,7 @@
"gateway_rollout",
"push_relay",
"domain_migration",
"plutonium_page",
"captcha",
"experiment_delivery",
"registration",
@@ -11346,6 +11348,10 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"plutonium_page": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PlutoniumPageConfigUpdateRequest"}]
},
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
"experiment_delivery": {
"nullable": true,
@@ -13287,7 +13293,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 +13301,7 @@
"GUILD_VOICE",
"GROUP_DM",
"GUILD_CATEGORY",
"GUILD_ANNOUNCEMENT",
"GUILD_LINK",
"DM_PERSONAL_NOTES"
],
@@ -13304,6 +13311,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 +13510,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 +13544,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 +13580,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 +13748,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 +13759,7 @@
},
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
},
"required": ["channel_id", "message_id", "type"],
"required": ["channel_id", "type"],
"additionalProperties": false
},
"message_snapshots": {
@@ -13883,7 +13894,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 +13905,7 @@
},
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
},
"required": ["channel_id", "message_id", "type"],
"required": ["channel_id", "type"],
"additionalProperties": false
},
"message_snapshots": {
@@ -14384,11 +14396,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 +14427,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 +14438,7 @@
"CHANNEL_ICON_CHANGE",
"CHANNEL_PINNED_MESSAGE",
"USER_JOIN",
"CHANNEL_FOLLOW_ADD",
"REPLY"
],
"x-enumDescriptions": [
@@ -14422,6 +14450,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"
]
},
@@ -15428,6 +15457,30 @@
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
}
},
"PlutoniumPageConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15567,6 +15620,51 @@
"required": ["enabled", "cost", "max_counter"],
"additionalProperties": false
},
"PlutoniumPageConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "plutonium-page-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
@@ -25,6 +25,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub plutonium_page: PlutoniumPageConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
@@ -430,6 +432,7 @@ impl VoiceE2eeScope {
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
@@ -501,6 +504,52 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PlutoniumPageConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for PlutoniumPageConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PlutoniumPageConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
@@ -647,6 +696,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
@@ -949,17 +1000,24 @@ mod tests {
.expect("admin schema");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
.expect("default plutonium page config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let plutonium_page =
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse =
serde_json::from_value(plutonium_page.clone())
.expect("generated plutonium page config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
@@ -969,6 +1027,11 @@ mod tests {
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_plutonium_page)
.expect("serializable generated plutonium page config"),
plutonium_page
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
@@ -980,6 +1043,7 @@ mod tests {
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("PlutoniumPageConfigResponse", plutonium_page),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
@@ -1014,4 +1078,25 @@ mod tests {
json!({})
);
}
#[test]
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
let update = PlutoniumPageConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::PlutoniumPageConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+107 -2
View File
@@ -18,8 +18,8 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -220,6 +220,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_plutonium_page" => match build_plutonium_page_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -609,6 +613,41 @@ fn build_domain_migration_update(
})
}
fn build_plutonium_page_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
enabled: Some(form.bool_value("plutonium_page_enabled")),
rollout_basis_points: parse_form_number(
form,
"plutonium_page_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
captcha: Some(CaptchaConfigUpdateRequest {
@@ -1444,6 +1483,72 @@ mod tests {
);
}
#[test]
fn build_plutonium_page_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
);
let update = build_plutonium_page_update(&form)
.expect("valid form")
.plutonium_page
.expect("plutonium page update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_plutonium_page_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"plutonium_page": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"plutonium_page_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_plutonium_page_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -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",
}
@@ -7,8 +7,9 @@ use crate::{
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse,
LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse,
PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse,
SsoConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -181,6 +182,7 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1207,6 +1209,147 @@ fn domain_migration_section(
)
}
fn plutonium_page_section(
base: &str,
csrf_token: &str,
plutonium_page: &PlutoniumPageConfigResponse,
) -> Markup {
let status = if plutonium_page.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = plutonium_page.included_user_ids.join("\n");
let excluded_user_ids = plutonium_page.excluded_user_ids.join("\n");
section_card_with_description(
"Plutonium page",
"Replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable \
in chat, and uses a minimal gift purchase modal.",
html! {
form method="post" action={(base) "/instance-config?action=update_plutonium_page"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (plutonium_page.config_version)
}
}
(checkbox(
"plutonium_page_enabled",
"true",
"Serve the Plutonium page to the selected users",
plutonium_page.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
client keeps the Plutonium settings tab, so the rollout and targeting \
fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"plutonium_page_rollout_basis_points",
"Rollout (basis points)",
&plutonium_page.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the Plutonium page, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"plutonium_page_rollout_salt",
"Rollout Salt",
&plutonium_page.rollout_salt,
PLUTONIUM_PAGE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
plutonium_page.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"plutonium_page_include_premium_users",
"true",
"Include premium users",
plutonium_page.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&plutonium_page.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
plutonium_page.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
plutonium_page.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage."
}
}
(form_actions(html! {
(submit_button("Save Plutonium Page Configuration"))
}))
}
}
},
)
}
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
}
@@ -1928,6 +2071,34 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
let plutonium_page = PlutoniumPageConfigResponse {
enabled: true,
config_version: 3,
rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..PlutoniumPageConfigResponse::default()
};
let markup = plutonium_page_section("/admin", "csrf", &plutonium_page).into_string();
assert!(markup.contains("Plutonium page"));
assert!(markup.contains("action=update_plutonium_page"));
assert!(markup.contains("name=\"plutonium_page_enabled\""));
assert!(markup.contains("name=\"plutonium_page_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"plutonium_page_include_premium_users\""));
assert!(markup.contains("name=\"plutonium_page_included_guild_ids\""));
assert!(markup.contains("Config version 3"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("anonymous_rollout_basis_points"));
assert!(!markup.contains("standalone_forwarding"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
+19
View File
@@ -422,6 +422,17 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"plutonium_page": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "plutonium-page-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": ["1500000000000000002"],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_plutonium_page_knob": true
},
"captcha": {
"enabled": true,
"cost": 5000,
@@ -578,6 +589,14 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.plutonium_page.enabled);
assert_eq!(resp.plutonium_page.config_version, 3);
assert_eq!(resp.plutonium_page.rollout_basis_points, 500);
assert_eq!(*resp.plutonium_page.rollout_salt, "plutonium-page-v1");
assert_eq!(resp.plutonium_page.included_user_ids.len(), 1);
assert_eq!(resp.plutonium_page.excluded_user_ids.len(), 1);
assert_eq!(resp.plutonium_page.included_guild_ids.len(), 1);
assert!(resp.plutonium_page.include_premium_users);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
+9
View File
@@ -467,6 +467,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_plutonium_page",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -1193,6 +1194,14 @@ fn instance_config() -> Value {
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"plutonium_page": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "plutonium-page-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+38
View File
@@ -211,3 +211,41 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
function withPhoneVerification(master: MasterConfig, selfHosted: boolean, enabled?: boolean): MasterConfig {
return {
...master,
instance: {
...master.instance,
self_hosted: selfHosted,
phone_verification_enabled: enabled,
},
};
}
describe('buildAPIConfigFromMaster phone verification', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('is on by default when the instance is not self-hosted', () => {
expect(buildAPIConfigFromMaster(withPhoneVerification(master, false)).instance.phoneVerificationEnabled).toBe(true);
});
it('is off by default on a self-hosted instance', () => {
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true)).instance.phoneVerificationEnabled).toBe(false);
});
it('lets a self-hosted operator switch it on', () => {
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true, true)).instance.phoneVerificationEnabled).toBe(
true,
);
});
it('lets an operator switch it off when the instance is not self-hosted', () => {
expect(
buildAPIConfigFromMaster(withPhoneVerification(master, false, false)).instance.phoneVerificationEnabled,
).toBe(false);
});
});
+2
View File
@@ -367,6 +367,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
instance: {
selfHosted: master.instance.self_hosted,
phoneVerificationEnabled: master.instance.phone_verification_enabled ?? !master.instance.self_hosted,
autoJoinInviteCode: master.instance.auto_join_invite_code,
visionariesGuildId: master.instance.visionaries_guild_id,
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
@@ -450,6 +451,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
lifecycle: apiWorkerConfig?.lane_concurrency_overrides?.lifecycle,
batch: apiWorkerConfig?.lane_concurrency_overrides?.batch,
crosspost: apiWorkerConfig?.lane_concurrency_overrides?.crosspost,
},
},
};
+36
View File
@@ -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,
@@ -37,6 +37,7 @@ import {
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -66,6 +67,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
pushRelay,
domainMigration,
plutoniumPage,
captcha,
experimentDelivery,
registrationConfig,
@@ -76,6 +78,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
@@ -110,6 +113,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
push_relay: pushRelay,
domain_migration: domainMigration,
plutonium_page: plutoniumPage,
captcha,
experiment_delivery: experimentDelivery,
registration: {
@@ -388,6 +392,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.plutonium_page) {
const patch = omitUndefinedFields(data.plutonium_page);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updatePlutoniumPageConfig((current) =>
PlutoniumPageConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
@@ -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,
@@ -8,6 +8,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
import * as AuthSession from '@app/api/auth/AuthSession';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
import {isAccountClosed, isTemporarilyBanned} from '@app/api/user/UserHelpers';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
@@ -174,6 +175,7 @@ export class AdminUserBanService {
['public_reason', data.public_reason ?? 'null'],
]),
});
await clearNewConversationLimit(userId, {cache: cacheService});
await emitAdminAction(adminUserId, userId, 'unban');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
@@ -18,6 +18,7 @@ import {ReportStatus} from '@app/api/report/IReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DeletionReasons} from '@fluxer/constants/src/Core';
@@ -326,6 +327,7 @@ export class AdminUserDeletionService {
['notification_sent', notificationSent ? 'true' : 'false'],
]),
});
await clearNewConversationLimit(userId, {cache: cacheService});
await emitAdminAction(adminUserId, userId, 'cancel_deletion');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
@@ -15,6 +15,7 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
@@ -143,6 +144,10 @@ export class AdminUserSecurityService {
},
user.toRow(),
);
const trusted = (newFlags & UserFlags.NOT_SUSPICIOUS) !== 0n && (user.flags & UserFlags.NOT_SUSPICIOUS) === 0n;
if (trusted || (user.flags & ~newFlags) !== 0n) {
await clearNewConversationLimit(userId, {cache: cacheService});
}
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
@@ -470,6 +475,9 @@ export class AdminUserSecurityService {
},
user.toRow(),
);
if ((currentFlags & ~newFlags) !== 0) {
await clearNewConversationLimit(userId, {cache: cacheService});
}
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
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,
});
}
+8 -3
View File
@@ -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
@@ -15,6 +15,7 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import {AUTOMATIC_VOICE_REGION_ID, ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {IncomingCallFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
@@ -168,6 +169,16 @@ export class CallService {
const dmRecipientIds = recipientIds.filter((id) => id !== userId);
if (dmRecipientIds.length === 1) {
await this.dmPermissionValidator.validate({senderId: userId, recipientId: dmRecipientIds[0]});
const caller = await this.userRepository.findUnique(userId);
if (caller) {
await assertMayStartConversation({
user: caller,
targetId: dmRecipientIds[0]!,
users: this.userRepository,
messages: this.channelRepository,
channel,
});
}
}
}
const existingCall = await this.gatewayService.getCall(channelId);
@@ -336,6 +347,16 @@ export class CallService {
const dmRecipientIds = Array.from(channel.recipientIds).filter((id) => id !== userId);
if (dmRecipientIds.length === 1) {
await this.dmPermissionValidator.validate({senderId: userId, recipientId: dmRecipientIds[0]});
const caller = await this.userRepository.findUnique(userId);
if (caller) {
await assertMayStartConversation({
user: caller,
targetId: dmRecipientIds[0]!,
users: this.userRepository,
messages: this.channelRepository,
channel,
});
}
}
}
const callerRequestedNoRing = recipients !== undefined && recipients.length === 0;
@@ -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,
@@ -19,8 +19,14 @@ import type {Channel} from '@app/api/models/Channel';
import type {Message} from '@app/api/models/Message';
import type {MessageReaction} from '@app/api/models/MessageReaction';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {
assertMayStartConversation,
getNewConversationLimit,
oneToOneDmRecipient,
} from '@app/api/user/NewConversationLimit';
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {NewConversationsLimitedError} from '@fluxer/errors/src/domains/user/NewConversationsLimitedError';
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -32,8 +38,8 @@ export class MessageInteractionService {
private reactionService: MessageReactionService;
constructor(
channelRepository: IChannelRepository,
userRepository: IUserRepository,
private channelRepository: IChannelRepository,
private userRepository: IUserRepository,
guildRepository: IGuildRepositoryAggregate,
private gatewayService: IGatewayService,
snowflakeService: ISnowflakeService,
@@ -69,6 +75,7 @@ export class MessageInteractionService {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
await authChannel.checkPermission(Permissions.SEND_MESSAGES);
assertGuildMemberCanCommunicate(authChannel.member);
if (!authChannel.guild && (await this.startsNewConversation(authChannel.channel, userId))) return;
await this.readStateService.startTyping({authChannel, userId});
}
@@ -108,6 +115,7 @@ export class MessageInteractionService {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
await this.assertConversationAllowed(authChannel.channel, userId);
}
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache, auditLogReason});
}
@@ -170,9 +178,36 @@ export class MessageInteractionService {
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild) {
await this.assertConversationAllowed(authChannel.channel, userId);
}
await this.reactionService.addReaction({authChannel, messageId, emoji, userId, sessionId});
}
private async startsNewConversation(channel: Channel, userId: UserID): Promise<boolean> {
try {
await this.assertConversationAllowed(channel, userId);
return false;
} catch (error) {
if (error instanceof NewConversationsLimitedError) return true;
throw error;
}
}
private async assertConversationAllowed(channel: Channel, userId: UserID): Promise<void> {
const targetId = oneToOneDmRecipient(channel, userId);
if (targetId === null || !(await getNewConversationLimit(userId))) return;
const user = await this.userRepository.findUnique(userId);
if (!user) return;
await assertMayStartConversation({
user,
targetId,
users: this.userRepository,
messages: this.channelRepository.messages,
channel,
});
}
async removeReaction({
userId,
sessionId,
@@ -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,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAttachmentID, createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
import {emitMessageCreated, emitMessageUpdated} from '@app/api/channel/services/message/MessageActivity';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
import type {Channel} from '@app/api/models/Channel';
import {Message} from '@app/api/models/Message';
import type {User} from '@app/api/models/User';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, describe, expect, it, vi} from 'vitest';
const HASH = '3F'.repeat(32);
function attachment(id: bigint, hash: string | null): MessageAttachment {
return {
attachment_id: createAttachmentID(id),
filename: `${id}.png`,
size: 10n * id,
title: null,
description: null,
width: 1,
height: 1,
content_type: 'image/png',
content_hash: hash,
placeholder: null,
flags: 0,
duration: null,
nsfw: null,
waveform: null,
};
}
function message(attachments: Array<MessageAttachment>): Message {
return new Message({
channel_id: createChannelID(10n),
bucket: 0,
message_id: createMessageID(100n),
author_id: createUserID(3n),
type: MessageTypes.DEFAULT,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: '',
edited_timestamp: null,
pinned_timestamp: null,
flags: 0,
mention_everyone: false,
mention_users: null,
mention_roles: null,
mention_channels: null,
attachments,
embeds: null,
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: null,
has_reaction: null,
version: 1,
});
}
class CapturingPublisher implements ActivityPublisher {
readonly payloads: Array<string> = [];
async publish(_subject: string, payload: string): Promise<void> {
this.payloads.push(payload);
}
}
describe('message activity', () => {
afterEach(() => {
resetActivityEventsForTests();
});
function params(attachments: Array<MessageAttachment>) {
return {
user: {id: createUserID(3n), isBot: false} as unknown as User,
message: message(attachments),
channel: {id: createChannelID(10n), type: ChannelTypes.DM} as unknown as Channel,
guildId: null,
guildOwnerId: null,
dmRecipientId: createUserID(4n),
channelHadMessages: true,
delivered: true,
userRepository: {getRelationship: async () => null},
};
}
it('serializes attachment metadata', async () => {
const publisher = new CapturingPublisher();
await startActivityEvents({publisher, kv: new MockKVProvider()});
emitMessageCreated(params([attachment(1n, HASH), attachment(2n, null)]));
await vi.waitFor(() => expect(publisher.payloads).toHaveLength(1));
const event = JSON.parse(publisher.payloads[0]!);
expect([event.kind, event.key]).toEqual(['message_created', '3']);
expect(event.data).toMatchObject({
attachment_count: 2,
attachments: [
{size: 10, content_type: 'image/png', hash: HASH.toLowerCase()},
{size: 20, content_type: 'image/png', hash: null},
],
});
});
it('serializes message updates', async () => {
const publisher = new CapturingPublisher();
await startActivityEvents({publisher, kv: new MockKVProvider()});
emitMessageCreated(params([]));
emitMessageUpdated(params([attachment(1n, HASH)]));
await vi.waitFor(() => expect(publisher.payloads).toHaveLength(2));
const [created, updated] = publisher.payloads.map((payload) => JSON.parse(payload));
expect(created.kind).toBe('message_created');
expect(updated.kind).toBe('message_updated');
expect(updated.data).toMatchObject({message_id: '100', attachments: [{hash: HASH.toLowerCase()}]});
expect(updated.id).not.toBe(created.id);
});
});
@@ -2,6 +2,7 @@
import {createInviteCode, type GuildID, type UserID} from '@app/api/BrandedTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import type {AttachmentMeta} from '@app/api/infrastructure/activity/Contract.generated';
import {Logger} from '@app/api/Logger';
import {getGuildRepository, getInviteRepository} from '@app/api/middleware/ServiceSingletons';
import type {Channel} from '@app/api/models/Channel';
@@ -71,7 +72,18 @@ export interface MessageCreatedActivity {
userRepository: Pick<IUserRepository, 'getRelationship'>;
}
async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
function attachmentMeta(message: Message): Array<AttachmentMeta> {
return message.attachments.slice(0, LIST_MAX).map((attachment) => ({
size: Number(attachment.size),
content_type: attachment.contentType || null,
hash: attachment.contentHash ? attachment.contentHash.toLowerCase() : null,
}));
}
async function buildAndEmit(
kind: 'message_created' | 'message_updated',
params: MessageCreatedActivity,
): Promise<void> {
const {user, message, channel, guildId, dmRecipientId} = params;
const content = Array.from(message.content ?? '')
.slice(0, CONTENT_MAX_CHARS)
@@ -87,7 +99,7 @@ async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
? (await params.userRepository.getRelationship(user.id, dmRecipientId, RelationshipTypes.FRIEND)) !== null
: false;
await emitActivity(
'message_created',
kind,
user.id.toString(),
{
user_id: user.id.toString(),
@@ -102,6 +114,7 @@ async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
content,
attachment_count: message.attachments.length,
attachment_names: message.attachments.slice(0, LIST_MAX).map((attachment) => attachment.filename),
attachments: attachmentMeta(message),
link_domains: domains,
invite_codes: inviteCodes,
invite_guild_ids: targets.map((target) => target.guildId),
@@ -114,12 +127,20 @@ async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
delivered: params.delivered,
},
null,
message.id.toString(),
kind === 'message_created'
? message.id.toString()
: `${message.id}:${message.editedTimestamp?.getTime() ?? Date.now()}`,
);
}
export function emitMessageCreated(params: MessageCreatedActivity): void {
void buildAndEmit(params).catch((error: unknown) => {
void buildAndEmit('message_created', params).catch((error: unknown) => {
Logger.debug({error}, 'Message activity event could not be built');
});
}
export function emitMessageUpdated(params: MessageCreatedActivity): void {
void buildAndEmit('message_updated', params).catch((error: unknown) => {
Logger.debug({error}, 'Message activity event could not be built');
});
}
@@ -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;
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import {type ChannelID, createGuildID, createUserID, type MessageID, type 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 {emitMessageUpdated} from '@app/api/channel/services/message/MessageActivity';
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 +15,21 @@ 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 {assertMayStartConversation, oneToOneDmRecipient} from '@app/api/user/NewConversationLimit';
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
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 +39,6 @@ interface EditMessageResult {
interface MessageEditServiceDeps {
channelRepository: IChannelRepositoryAggregate;
userRepository: IUserRepository;
cacheService: ICacheService;
validationService: MessageValidationService;
persistenceService: MessagePersistenceService;
channelAuthService: MessageChannelAuthService;
@@ -50,6 +47,8 @@ interface MessageEditServiceDeps {
searchService: MessageSearchService;
embedAttachmentResolver: MessageEmbedAttachmentResolver;
mentionService: MessageMentionService;
messageWriteLock: MessageWriteLock;
crosspostPropagation: CrosspostPropagation;
}
export class MessageEditService {
@@ -113,7 +112,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 +137,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 +149,40 @@ 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,
const dmRecipientId = oneToOneDmRecipient(channel, userId);
if (user && dmRecipientId !== null) {
await assertMayStartConversation({
user,
targetId: dmRecipientId,
users: this.deps.userRepository,
messages: this.deps.channelRepository.messages,
channel,
guild,
member,
attachmentUploadUserId: userId,
allowEmbeds: canEmbedLinks,
isBot: user?.isBot,
isBugHunterBot,
locale: user?.locale,
}),
);
});
}
const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
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 +200,20 @@ export class MessageEditService {
}
}
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
await this.deps.crosspostPropagation.propagateEdit(updatedMessage);
if (user && ((data.content !== undefined && data.content !== message.content) || hasNewAttachments)) {
emitMessageUpdated({
user,
message: updatedMessage,
channel,
guildId: guild?.id ? createGuildID(BigInt(guild.id)) : null,
guildOwnerId: guild?.owner_id ? createUserID(BigInt(guild.owner_id)) : null,
dmRecipientId,
channelHadMessages: true,
delivered: !(dmRecipientId !== null && isDirectDeliverySuppressed(user)),
userRepository: this.deps.userRepository,
});
}
void updateResult.enqueueDeferredEmbeds().catch((error) => {
Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction after edit');
});
@@ -218,26 +246,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

Some files were not shown because too many files have changed in this diff Show More