Compare commits

...
Author SHA1 Message Date
HampusandGitHub 237aff666d perf(app-proxy): skip disk reads for absent static prefixes (#3115) 2026-10-02 16:09:34 +02:00
HampusandGitHub 11645cbf28 fix(ci): keep published source maps when a rebuild differs (#3113) 2026-10-02 15:25:05 +02:00
HampusandGitHub e297a6a653 fix(app-proxy): make the SPA shell identical for every visitor (#3112) 2026-10-02 15:13:36 +02:00
HampusandGitHub 1eed347ffb fix(premium): follow the light theme on the Plutonium page (#3111) 2026-10-02 14:15:24 +02:00
HampusandGitHub 4aa7a3e181 fix(voice): allow stereo mics at 64 kbps and in the mic test (#3110) 2026-10-02 14:11:19 +02:00
HampusandGitHub 69786d3b49 fix(voice): prefer vp8 for automatic screen shares in firefox (#3109) 2026-10-02 14:09:55 +02:00
HampusandGitHub 2fb5fb1abb fix(voice): allow av1 and vp9 screen shares in firefox (#3108) 2026-10-02 14:08:41 +02:00
HampusandGitHub a9265cbb39 perf(gateway): speed up reconnects and pin guilds to nodes (#3107) 2026-10-02 14:02:22 +02:00
HampusandGitHub ee2d11ee0a fix(voice): prefer vp9 over software h264 for screen shares (#3106) 2026-10-02 13:29:29 +02:00
TarekandGitHub 632067b552 feat(gateway,admin): Expand stats for metrics (#3064) 2026-10-02 12:58:16 +02:00
HampusandGitHub 840dc3dfa5 feat(premium): match the Plutonium page to the new site look (#3104) 2026-10-02 12:20:44 +02:00
HampusandGitHub 4e6f9b539c fix(voice): make RNNoise the default noise suppression (#3103) 2026-10-02 11:31:40 +02:00
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
HampusandGitHub dc32a7c70e feat(admin): allow system DMs to all users (#3073) 2026-09-30 21:29:05 +02:00
HampusandGitHub ab0b483fbe perf(gateway): speed up presence and harden guild queries (#3072) 2026-09-30 21:12:13 +02:00
HampusandGitHub 6e2f90b03c fix(premium): drop the grace period after a voluntary cancel (#3071) 2026-09-30 21:03:25 +02:00
HampusandGitHub 5e0806f479 fix(voice): preserve microphone channels during screen sharing (#3070) 2026-09-30 20:47:30 +02:00
HampusandGitHub dfdfffe5de feat(premium): give failed renewals a billing-cycle grace period (#3066) 2026-09-30 18:48:01 +02:00
HampusandGitHub f5e32aed31 fix(ci): correct TTL fixtures and unused dependencies (#3065) 2026-09-30 17:45:07 +02:00
HampusandGitHub 710c1aeaa8 fix(deps): bump yanked yoke-derive to 0.8.4 (#3063) 2026-09-30 16:59:59 +02:00
HampusandGitHub af49cd6cc4 refactor(ban): drop ipinfo cgnat blast-radius guard (#3062) 2026-09-30 16:54:43 +02:00
omsterandGitHub ca719e7b5e feat(admin,api): restrict community creation on self-hosted (#3055) 2026-09-30 16:32:45 +02:00
HampusandGitHub ab4069ed0e fix(app): let hidden sidebar buttons be shown again (#3061) 2026-09-30 15:03:44 +02:00
HampusandGitHub 12bfaa83ba fix(sso): route mobile sign-in through the web callback (#3060) 2026-09-30 14:48:24 +02:00
HampusandGitHub 1076728241 perf(gateway): keep large guilds responsive under floods (#3058) 2026-09-30 12:26:21 +02:00
HampusandGitHub 360b984adc fix(ci): repair admin test config and a ttl race in api tests (#3054) 2026-09-30 02:39:46 +02:00
870 changed files with 104588 additions and 33999 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
+5 -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",
@@ -2038,6 +2040,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tokio-util",
"tower",
@@ -5830,9 +5833,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
+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 -1
View File
@@ -160,7 +160,9 @@ MEILI_MASTER_KEY=CHANGE_ME
# api.pwnedpasswords.com.
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
#FLUXER_IPINFO_API_KEY=
# Phone verification needs your own responder on the rpc.phone.v1 NATS
# subjects. Off unless turned on.
#FLUXER_PHONE_VERIFICATION_ENABLED=false
# A local path, or an s3:// URL read with the S3 credentials of this file.
#FLUXER_GEOIP_DB_PATH=
@@ -449,6 +451,11 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
#FLUXER_SEAWEEDFS_TELEMETRY=false
# Volumes SeaweedFS creates at once when a bucket needs space. Each reserves 1 GB
# of free disk from the start, and SeaweedFS's own default of 7 fills a small
# disk before every bucket has one, so uploads fail with no free volumes left.
#FLUXER_SEAWEEDFS_VOLUME_GROWTH=1
# Node sizes its heap from the container limit by default. Leave these unset
# unless you need to pin it. A heap ceiling above the container limit gets the
# container OOM-killed instead of reporting a heap error. The values below are
+2 -1
View File
@@ -33,7 +33,7 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-}
FLUXER_PHONE_VERIFICATION_ENABLED: ${FLUXER_PHONE_VERIFICATION_ENABLED:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
@@ -354,6 +354,7 @@ services:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
WEED_MASTER_VOLUME_GROWTH_COPY_1: ${FLUXER_SEAWEEDFS_VOLUME_GROWTH:-1}
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
volumes:
- seaweedfs-data:/data
+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,
+149 -15
View File
@@ -1251,7 +1251,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
"description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5435,7 +5435,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
"description": "Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
@@ -10150,20 +10150,25 @@
"description": "Message content to send to each recipient"
},
"user_ids": {
"description": "Recipient user IDs. Each receives the same content as a system DM.",
"minItems": 1,
"maxItems": 10000,
"type": "array",
"items": {"$ref": "#/components/schemas/SnowflakeType"},
"description": "Recipient user IDs. Each receives the same content as a system DM."
"items": {"$ref": "#/components/schemas/SnowflakeType"}
},
"all_users": {
"description": "Send to every user account, skipping bots, system accounts, and deleted or disabled accounts",
"type": "boolean"
}
},
"required": ["content", "user_ids"]
"required": ["content"]
},
"SendSystemDmResponse": {
"type": "object",
"properties": {
"recipient_count": {
"description": "Number of recipients the worker job was queued to deliver to",
"nullable": true,
"description": "Number of recipients the worker job was queued to deliver to, or null when sending to all users",
"allOf": [{"$ref": "#/components/schemas/Int32Type"}]
}
},
@@ -10659,6 +10664,7 @@
"feature_custom_notification_sounds",
"feature_early_access",
"feature_global_expressions",
"feature_guild_create",
"feature_higher_video_quality",
"feature_per_guild_profiles",
"feature_voice_entrance_sounds",
@@ -10803,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": {
@@ -10966,6 +10973,7 @@
"single_community_guild_id": {"nullable": true, "type": "string"},
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean"},
"guild_create_access": {"type": "boolean"},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"services": {
"type": "object",
@@ -11003,6 +11011,7 @@
"single_community_guild_id",
"direct_messages_disabled",
"direct_messages_locked",
"guild_create_access",
"premium_mode",
"services",
"services_resolved",
@@ -11201,6 +11210,7 @@
"gateway_rollout",
"push_relay",
"domain_migration",
"plutonium_page",
"captcha",
"experiment_delivery",
"registration",
@@ -11338,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,
@@ -11523,6 +11537,7 @@
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean", "enum": [false]},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"guild_create_access": {"type": "boolean"},
"services": {
"nullable": true,
"type": "object",
@@ -12044,6 +12059,9 @@
"properties": {
"status": {"type": "string", "minLength": 1, "maxLength": 256},
"sessions": {"$ref": "#/components/schemas/Int32Type"},
"session_resumes_total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
"websocket_dispatches_total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
"websocket_dispatch_drops_total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
"guilds": {"$ref": "#/components/schemas/Int32Type"},
"presences": {"$ref": "#/components/schemas/Int32Type"},
"calls": {"$ref": "#/components/schemas/Int32Type"},
@@ -12070,6 +12088,24 @@
"node_id": {"type": "string", "minLength": 1, "maxLength": 256},
"status": {"type": "string", "minLength": 1, "maxLength": 256},
"sessions": {"$ref": "#/components/schemas/Int32Type"},
"session_resumes_total": {
"nullable": true,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"websocket_dispatches_total": {
"nullable": true,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"websocket_dispatch_drops_total": {
"nullable": true,
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"guilds": {"$ref": "#/components/schemas/Int32Type"},
"presences": {"$ref": "#/components/schemas/Int32Type"},
"calls": {"$ref": "#/components/schemas/Int32Type"},
@@ -12123,6 +12159,9 @@
"node_id",
"status",
"sessions",
"session_resumes_total",
"websocket_dispatches_total",
"websocket_dispatch_drops_total",
"guilds",
"presences",
"calls",
@@ -12138,6 +12177,9 @@
"required": [
"status",
"sessions",
"session_resumes_total",
"websocket_dispatches_total",
"websocket_dispatch_drops_total",
"guilds",
"presences",
"calls",
@@ -13278,7 +13320,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",
@@ -13286,6 +13328,7 @@
"GUILD_VOICE",
"GROUP_DM",
"GUILD_CATEGORY",
"GUILD_ANNOUNCEMENT",
"GUILD_LINK",
"DM_PERSONAL_NOTES"
],
@@ -13295,6 +13338,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"
]
@@ -13493,7 +13537,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",
@@ -13527,7 +13571,8 @@
"UNAVAILABLE_HIDDEN",
"VISIONARY",
"LARGE_GUILD_OVERRIDE",
"VERY_LARGE_GUILD"
"VERY_LARGE_GUILD",
"ANNOUNCEMENT_CHANNELS_DISABLED"
],
"x-enumDescriptions": [
"Guild can have an animated icon",
@@ -13562,7 +13607,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"
},
@@ -13729,7 +13775,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": {
@@ -13739,7 +13786,7 @@
},
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
},
"required": ["channel_id", "message_id", "type"],
"required": ["channel_id", "type"],
"additionalProperties": false
},
"message_snapshots": {
@@ -13874,7 +13921,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": {
@@ -13884,7 +13932,7 @@
},
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
},
"required": ["channel_id", "message_id", "type"],
"required": ["channel_id", "type"],
"additionalProperties": false
},
"message_snapshots": {
@@ -14375,11 +14423,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",
@@ -14391,7 +14454,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",
@@ -14402,6 +14465,7 @@
"CHANNEL_ICON_CHANGE",
"CHANNEL_PINNED_MESSAGE",
"USER_JOIN",
"CHANNEL_FOLLOW_ADD",
"REPLY"
],
"x-enumDescriptions": [
@@ -14413,6 +14477,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"
]
},
@@ -15419,6 +15484,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": {
@@ -15558,6 +15647,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": {
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::tooltip::{Hint, HintLink};
pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
match key {
"feature_guild_create" => Some(Hint {
name: Some("Community Creation Access"),
body: "Admins with the wildcard ACL can always create communities.",
link: Some(HintLink::new(
"/instance-config#community-creation",
"Community creation policy",
)),
}),
_ => None,
}
}
+7 -2
View File
@@ -8,13 +8,18 @@ use super::types::SendSystemDmResponse;
impl AdminApiClient {
pub async fn send_system_dm(
&self,
user_ids: &[String],
user_ids: Option<&[String]>,
content: &str,
) -> ApiResult<SendSystemDmResponse> {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
user_ids: user_ids
.unwrap_or_default()
.iter()
.map(|id| snowflake(id))
.collect(),
all_users: user_ids.is_none().then_some(true),
};
let response = self
.generated()
@@ -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,
@@ -43,6 +45,8 @@ pub struct InstancePolicyResponse {
pub direct_messages_locked: bool,
#[serde(default)]
pub premium_mode: PremiumMode,
#[serde(default = "default_guild_create_access")]
pub guild_create_access: bool,
#[serde(default)]
pub services: InstanceServicesOverrides,
#[serde(default)]
@@ -51,6 +55,10 @@ pub struct InstancePolicyResponse {
pub services_available: InstanceServicesAvailable,
}
fn default_guild_create_access() -> bool {
true
}
impl Default for InstancePolicyResponse {
fn default() -> Self {
Self {
@@ -59,6 +67,7 @@ impl Default for InstancePolicyResponse {
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: PremiumMode::Everyone,
guild_create_access: default_guild_create_access(),
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
@@ -423,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;
@@ -494,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 {
@@ -640,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>,
@@ -656,6 +714,8 @@ pub struct InstancePolicyUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_messages_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_create_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
@@ -940,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 =
@@ -960,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
@@ -971,6 +1043,7 @@ mod tests {
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("PlutoniumPageConfigResponse", plutonium_page),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
@@ -1005,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!({})
);
}
}
+1 -1
View File
@@ -4,5 +4,5 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SendSystemDmResponse {
pub recipient_count: i64,
pub recipient_count: Option<i64>,
}
+1
View File
@@ -2,6 +2,7 @@
pub mod acl;
pub mod admin_flags;
pub mod admin_hints;
pub mod api;
pub mod config;
pub mod fonts;
+2 -1
View File
@@ -171,7 +171,8 @@ pub(crate) async fn system_dms_post(
let flash = if let Some(content) = content.as_deref()
&& !user_ids.is_empty()
{
match client.send_system_dm(&user_ids, content).await {
let recipients = (user_ids != ["*"]).then_some(user_ids.as_slice());
match client.send_system_dm(recipients, content).await {
Ok(_) => FlashData::success("System DM sent"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: send system DM");
+112 -6
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 {
@@ -734,6 +773,9 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let direct_messages_disabled = form
.first("policy_direct_messages_disabled")
.map(|value| value == "true");
let guild_create_access = form
.first("policy_guild_create_access")
.map(|value| value == "true");
let premium_mode = match form.first("policy_premium_mode") {
Some("mirror") => Some(PremiumMode::Mirror),
Some("everyone") => Some(PremiumMode::Everyone),
@@ -745,6 +787,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
single_community_enabled: None,
single_community_name: None,
direct_messages_disabled,
guild_create_access,
premium_mode,
services,
}),
@@ -875,10 +918,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
services: None,
..Default::default()
}),
..Default::default()
}
@@ -1443,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");
+25
View File
@@ -238,3 +238,28 @@ input:disabled + .checkbox-custom {
border: 2px solid transparent;
background-clip: content-box;
}
:target {
padding: 0.5rem;
border-radius: 0.25rem;
scroll-margin-top: 6rem;
animation: target-pulse 700ms ease-in-out 3;
}
@keyframes target-pulse {
0%,
100% {
background-color: transparent;
}
50% {
background-color: hsl(242 70% 55% / 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
:target {
background-color: hsl(242 70% 55% / 0.12);
animation: none;
}
}
@@ -21,6 +21,7 @@ pub mod resource_link;
pub mod section_card;
pub mod stack;
pub mod table;
pub mod tooltip;
pub mod typography;
pub mod user_display;
pub mod user_profile_badges;
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::icons::paperclip_icon;
use maud::{Markup, html};
use std::sync::atomic::{AtomicUsize, Ordering};
static HINT_TOGGLE_ID: AtomicUsize = AtomicUsize::new(0);
pub struct HintLink<'a> {
href: &'a str,
label: &'a str,
}
impl<'a> HintLink<'a> {
pub fn new(href: &'a str, label: &'a str) -> Self {
debug_assert!(
href.starts_with('/'),
"hint link href must be admin-absolute: {href:?}"
);
debug_assert!(
href.contains('#'),
"hint link href should point at an anchor: {href:?}"
);
debug_assert!(!label.trim().is_empty(), "hint link needs a label");
Self { href, label }
}
}
pub struct Hint<'a> {
pub name: Option<&'a str>,
pub body: &'a str,
pub link: Option<HintLink<'a>>,
}
pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
let aria_label = match hint.name {
Some(name) => format!("About {name}"),
None => "More information".to_owned(),
};
let toggle_id = format!(
"hint-toggle-{}",
HINT_TOGGLE_ID.fetch_add(1, Ordering::Relaxed)
);
html! {
span class="group relative inline-flex items-center" {
input type="checkbox" id=(toggle_id) class="peer sr-only";
label for=(toggle_id) tabindex="0" aria-label=(aria_label)
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full \
font-semibold text-brand-primary leading-none active:scale-97 \
hover:text-brand-primary-dark" {
"?"
}
label for=(toggle_id) aria-hidden="true"
class="invisible fixed inset-0 z-20 cursor-default peer-checked:visible" {}
div class="invisible absolute bottom-full left-2 z-30 w-64 pb-3 pl-2 opacity-0 \
transition-[opacity,visibility] duration-200 ease-out motion-reduce:transition-none \
group-hover:visible group-hover:opacity-100 \
group-focus-within:visible group-focus-within:opacity-100 \
peer-checked:visible peer-checked:opacity-100" {
div class="rounded-lg border border-neutral-200 bg-white p-3 text-neutral-600 \
text-xs shadow-lg" {
@if let Some(name) = hint.name {
p class="font-semibold text-neutral-900" { (name) }
}
p class=[hint.name.is_some().then_some("mt-1")] { (hint.body) }
@if let Some(link) = &hint.link {
a href={(base) (link.href)} hx-boost="false"
class="mt-2 inline-flex items-center gap-1 text-blue-600 hover:underline" {
(paperclip_icon(""))(link.label)
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::HintLink;
#[test]
#[should_panic(expected = "anchor")]
fn rejects_a_link_that_points_at_no_anchor() {
let _ = HintLink::new("/instance-config", "Instance policy");
}
#[test]
#[should_panic(expected = "label")]
fn rejects_a_link_with_no_label() {
let _ = HintLink::new("/instance-config#community-creation", " ");
}
}
@@ -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"];
@@ -25,6 +25,10 @@ pub(crate) fn stat_card(label: &str, value: &str) -> Markup {
pub(crate) fn node_stats_section(data: &serde_json::Value, expanded: bool, base: &str) -> Markup {
let sessions = data.get("sessions").and_then(|v| v.as_u64()).unwrap_or(0);
let reconnects: u64 = data
.get("session_resumes_total")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let guilds = data.get("guilds").and_then(|v| v.as_u64()).unwrap_or(0);
let presences = data.get("presences").and_then(|v| v.as_u64()).unwrap_or(0);
let calls = data.get("calls").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -64,6 +68,7 @@ pub(crate) fn node_stats_section(data: &serde_json::Value, expanded: bool, base:
div class="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-6" {
(stat_card("Nodes", &node_count.to_string()))
(stat_card("Sessions", &sessions.to_string()))
(stat_card("Reconnects", &reconnects.to_string()))
(stat_card("Guilds", &guilds.to_string()))
(stat_card("Presences", &presences.to_string()))
(stat_card("Calls", &calls.to_string()))
@@ -83,6 +88,7 @@ pub(crate) fn node_stats_table(nodes: &[serde_json::Value]) -> Markup {
tr {
th class="px-6 py-3 text-left text-neutral-600 text-xs uppercase" { "Node" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Sessions" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Session Resumes" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Guilds" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Presences" }
th class="px-6 py-3 text-right text-neutral-600 text-xs uppercase" { "Calls" }
@@ -95,6 +101,7 @@ pub(crate) fn node_stats_table(nodes: &[serde_json::Value]) -> Markup {
@let label = format_node_id(node_id, i);
@let status = node.get("status").and_then(|v| v.as_str()).unwrap_or("-");
@let ns = node.get("sessions").and_then(|v| v.as_u64()).unwrap_or(0);
@let nsr = node.get("session_resumes_total").and_then(|v| v.as_u64()).unwrap_or(0);
@let ng = node.get("guilds").and_then(|v| v.as_u64()).unwrap_or(0);
@let np = node.get("presences").and_then(|v| v.as_u64()).unwrap_or(0);
@let nc = node.get("calls").and_then(|v| v.as_u64()).unwrap_or(0);
@@ -105,6 +112,7 @@ pub(crate) fn node_stats_table(nodes: &[serde_json::Value]) -> Markup {
div class="text-neutral-500 text-xs" { (status) }
}
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (ns) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (nsr) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (ng) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (np) }
td class="whitespace-nowrap px-6 py-4 text-right text-sm" { (nc) }
@@ -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))
@@ -245,6 +247,7 @@ fn policy_config_section(
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(community_creation_form(base, csrf_token, policy))
(services_form(base, csrf_token, policy))
}
},
@@ -364,6 +367,37 @@ fn premium_mode_form(
}
}
fn community_creation_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
) -> Markup {
html! {
div id="community-creation" class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Community creation" }
form method="post" action={(base) "/instance-config?action=update_policy"} {
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_guild_create_access", "Who can create communities", &[
("true", "Everyone"),
("false", "Restricted"),
], if policy.guild_create_access { "true" } else { "false" }))
p class="text-xs text-neutral-500" {
"When restricted, only admins with the wildcard ACL and users matched by a "
a href={(base) "/limit-config"} class="text-blue-600 hover:underline" {
"limit rule"
}
" that grants Community Creation Access can create communities."
}
(form_actions(html! {
(submit_button("Save community creation policy"))
}))
}
}
}
}
}
fn service_select(name: &str, label: &str, override_value: Option<bool>, resolved: bool) -> Markup {
let selected = match override_value {
None => "inherit",
@@ -1175,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
}
@@ -1896,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 {
@@ -2,6 +2,7 @@
use crate::{
acl::{self, INSTANCE_LIMIT_CONFIG_UPDATE},
admin_hints,
api::types::{LimitConfigResponse, LimitKeyMetadata, LimitRule},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -9,6 +10,7 @@ use crate::{
components::{
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, page_header},
tooltip,
},
layout::admin_layout,
},
@@ -208,7 +210,7 @@ fn rule_editor(
@for category in CATEGORY_ORDER {
@let keys = keys_for_category(response, category);
@if !keys.is_empty() {
(category_section(response, rule, category, &keys, can_update))
(category_section(&config.base_path, response, rule, category, &keys, can_update))
}
}
@if can_update {
@@ -274,6 +276,7 @@ fn keys_for_category(response: &LimitConfigResponse, category: &str) -> Vec<Stri
}
fn category_section(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
category: &str,
@@ -292,7 +295,7 @@ fn category_section(
div class="space-y-4" {
@for key in keys {
@if let Some(metadata) = response.metadata.get(key) {
(limit_field(response, rule, key, metadata, can_update))
(limit_field(base, response, rule, key, metadata, can_update))
}
}
}
@@ -301,6 +304,7 @@ fn category_section(
}
fn limit_field(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
key: &str,
@@ -319,9 +323,10 @@ fn limit_field(
.as_ref()
.is_some_and(|fields| fields.iter().any(|field| field == key));
if metadata.is_toggle {
toggle_field(key, metadata, current_value, modified, can_update)
toggle_field(base, key, metadata, current_value, modified, can_update)
} else {
numeric_field(
base,
key,
metadata,
current_value,
@@ -333,6 +338,7 @@ fn limit_field(
}
fn toggle_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -343,7 +349,7 @@ fn toggle_field(
html! {
div class={(field_class(modified, false))} {
div class="flex-1 space-y-1" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
p class="text-xs text-neutral-500" { (metadata.description) }
}
div class="shrink-0" {
@@ -369,6 +375,7 @@ fn toggle_field(
}
fn numeric_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -385,7 +392,7 @@ fn numeric_field(
html! {
div class={(field_class(modified, true))} {
div class="flex flex-wrap items-center justify-between gap-2" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
}
p class="text-xs text-neutral-500" {
(metadata.description)
@@ -417,10 +424,13 @@ fn numeric_field(
}
}
fn field_label_row(key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
fn field_label_row(base: &str, key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
html! {
div class="flex flex-wrap items-center gap-2" {
label for=(key) class="font-medium text-neutral-900 text-sm" { (metadata.label) }
@if let Some(hint) = admin_hints::limit_key_hint(key) {
(tooltip::info(base, &hint))
}
span class=(scope_class(&metadata.scope)) { (scope_label(&metadata.scope)) }
@if modified {
span class="rounded bg-neutral-100 px-1.5 py-0.5 text-neutral-700 text-xs" { "Modified" }
@@ -58,7 +58,7 @@ pub fn system_dm_page(
(form_field_group(
"Recipient user IDs", "system-dm-user-ids",
true, None,
Some("One per line. Snowflake IDs only."),
Some("One per line. Snowflake IDs only, or a single * to send to every user."),
html! {
textarea id="system-dm-user-ids" name="user_ids"
required rows="10"
+20
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,
@@ -461,6 +472,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"guild_create_access": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,
@@ -577,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
@@ -354,12 +354,10 @@ fn test_config(api_endpoint: String) -> AdminConfig {
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
+1 -4
View File
@@ -11,13 +11,10 @@
},
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@pkgs/cassandra": "workspace:*",
"@fluxer/geo_utils": "workspace:*",
"@fluxer/instance_bootstrap": "workspace:*",
"@fluxer/ip_utils": "workspace:*",
"@pkgs/postgres": "workspace:*",
"maxmind": "catalog:",
"zod": "catalog:"
"maxmind": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -1,60 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_cache';
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
interface CassandraIpInfoCacheOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return null;
}
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
const row = result.first();
if (!row) return null;
const payload = row.get('payload');
if (typeof payload !== 'string') return null;
return JSON.parse(payload) as T;
} catch {
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch {
return;
}
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
await client.execute({
cql: INSERT_WITH_TTL_CQL,
params: {cache_key: key, payload, ttl: ttlSeconds},
});
} else {
await client.execute({
cql: INSERT_DEFAULT_TTL_CQL,
params: {cache_key: key, payload},
});
}
} catch {}
},
};
}
@@ -1,119 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_requests_by_hour';
const INSERT_CQL = `INSERT INTO ${TABLE} (
bucket_date,
bucket_hour,
requested_at,
event_id,
source,
reason,
ip,
cache_key,
request_url,
http_status,
outcome,
available,
risk_note,
latency_ms,
response_ip,
country_code,
asn,
is_anonymous,
is_tor,
is_vpn,
is_proxy,
is_residential_proxy,
metadata_json
) VALUES (
:bucket_date,
:bucket_hour,
:requested_at,
:event_id,
:source,
:reason,
:ip,
:cache_key,
:request_url,
:http_status,
:outcome,
:available,
:risk_note,
:latency_ms,
:response_ip,
:country_code,
:asn,
:is_anonymous,
:is_tor,
:is_vpn,
:is_proxy,
:is_residential_proxy,
:metadata_json
);`;
interface CassandraIpInfoRequestAuditOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoRequestAuditLogger(
options: CassandraIpInfoRequestAuditOptions,
): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
await client.execute({
cql: INSERT_CQL,
params: {
bucket_date: formatUtcDate(event.requestedAt),
bucket_hour: event.requestedAt.getUTCHours(),
requested_at: event.requestedAt,
event_id: randomUUID(),
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
});
} catch {}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
-506
View File
@@ -1,506 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {z} from 'zod';
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
const FETCH_TIMEOUT_MS = 3000;
const CACHE_KEY_PREFIX = 'ipinfo:max:';
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
export interface IpInfoGeoBlock {
countryCode: string | null;
countryName: string | null;
continent: string | null;
continentCode: string | null;
region: string | null;
regionCode: string | null;
city: string | null;
postalCode: string | null;
timezone: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadiusKm: number | null;
}
export interface IpInfoAsnBlock {
asn: string | null;
number: number | null;
name: string | null;
domain: string | null;
type: string | null;
}
export interface IpInfoMobileBlock {
name: string | null;
mcc: string | null;
mnc: string | null;
}
export interface IpInfoAnonymousBlock {
isAnonymous: boolean;
providerName: string | null;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
isTor: boolean;
isRelay: boolean;
percentDaysSeen: number | null;
}
export interface IpInfoFlags {
isAnycast: boolean;
isHosting: boolean;
isMobile: boolean;
isSatellite: boolean;
}
export interface IpInfoLookupResult {
ip: string;
available: boolean;
note: string;
geo: IpInfoGeoBlock;
asn: IpInfoAsnBlock;
mobile: IpInfoMobileBlock;
anonymous: IpInfoAnonymousBlock;
flags: IpInfoFlags;
}
export interface IpInfoCache {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
failureHttpStatus: number | null;
cachedAtMs: number;
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
return FAILURE_TTL_HTTP_ERROR_SECONDS;
}
export interface IpInfoLookupContext {
source?: string;
reason?: string;
metadata?: Record<string, string | number | boolean | null>;
}
export interface IpInfoRequestAuditEvent {
requestedAt: Date;
ip: string;
cacheKey: string;
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
httpStatus: number | null;
available: boolean;
note: string;
latencyMs: number;
requestUrl: string;
responseIp: string | null;
countryCode: string | null;
asnNumber: number | null;
isAnonymous: boolean;
isTor: boolean;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
}
export interface IpInfoRequestAuditLogger {
record(event: IpInfoRequestAuditEvent): Promise<void>;
}
interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
}
export interface IpInfoService {
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
}
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
const RawIpInfoGeoSchema = z.object({
city: z.string().optional(),
region: z.string().optional(),
region_code: z.string().optional(),
country: z.string().optional(),
country_code: z.string().optional(),
continent: z.string().optional(),
continent_code: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
timezone: z.string().optional(),
postal_code: z.string().optional(),
dma_code: z.string().optional(),
geoname_id: z.string().optional(),
radius: z.number().int().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoAsSchema = z.object({
asn: z.string().optional(),
name: z.string().optional(),
domain: z.string().optional(),
type: z.string().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoMobileSchema = z.object({
name: z.string().optional(),
mcc: z.string().optional(),
mnc: z.string().optional(),
});
const RawIpInfoAnonymousSchema = z.object({
name: z.string().optional(),
last_seen: IpInfoDateSchema.optional(),
percent_days_seen: z.number().int().optional(),
is_proxy: z.boolean().optional(),
is_relay: z.boolean().optional(),
is_tor: z.boolean().optional(),
is_vpn: z.boolean().optional(),
is_res_proxy: z.boolean().optional(),
});
const RawIpInfoResponseSchema = z.object({
ip: z.string(),
hostname: z.string().optional(),
geo: RawIpInfoGeoSchema,
as: RawIpInfoAsSchema,
mobile: RawIpInfoMobileSchema.optional(),
anonymous: RawIpInfoAnonymousSchema,
is_anonymous: z.boolean().optional(),
is_anycast: z.boolean().optional(),
is_hosting: z.boolean().optional(),
is_mobile: z.boolean().optional(),
is_satellite: z.boolean().optional(),
});
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.note);
}
return {...cached, ip};
}
const existing = inflight.get(cacheKey);
if (existing) {
const result = await existing;
return {...result, ip};
}
const requestedAt = new Date();
const startedAt = Date.now();
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
const finalize = async (params: {
result: IpInfoLookupResult;
outcome: IpInfoRequestAuditEvent['outcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
await ctx.auditLogger
?.record({
requestedAt,
ip,
cacheKey,
source: context?.source ?? 'unknown',
reason: context?.reason ?? null,
metadata: context?.metadata,
outcome: params.outcome,
httpStatus: params.httpStatus,
available: params.result.available,
note: params.result.note,
latencyMs: Date.now() - startedAt,
requestUrl,
responseIp: params.result.available ? params.result.ip : null,
countryCode: params.result.geo.countryCode,
asnNumber: params.result.asn.number,
isAnonymous: params.result.anonymous.isAnonymous,
isTor: params.result.anonymous.isTor,
isVpn: params.result.anonymous.isVpn,
isProxy: params.result.anonymous.isProxy,
isResidentialProxy: params.result.anonymous.isResidentialProxy,
})
.catch(() => {});
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
const entry: CachedIpInfoFailure = {
...params.result,
cachedFailure: true,
failureOutcome: params.outcome,
failureHttpStatus: params.httpStatus,
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
.catch(() => {});
return finalize(params);
};
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
}, FETCH_TIMEOUT_MS);
timer.unref();
let payload: unknown;
try {
const res = await fetch(fetchUrl, {
signal: controller.signal,
headers: {Accept: 'application/json'},
});
if (!res.ok) {
return finalizeFailure({
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
outcome: 'http_error',
httpStatus: res.status,
});
}
payload = await res.json();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return finalizeFailure({
result: unavailable(ip, `IPInfo request failed: ${detail}`),
outcome: 'request_failed',
httpStatus: null,
});
} finally {
clearTimeout(timer);
controller.abort();
}
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
if (!parsedResponse.success) {
return finalizeFailure({
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
outcome: 'schema_mismatch',
httpStatus: 200,
});
}
const result = parseIpInfoResponse(parsedResponse.data);
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
return finalize({
result,
outcome: 'http_success',
httpStatus: 200,
});
};
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
if (inflight.get(cacheKey) === promise) {
inflight.delete(cacheKey);
}
});
inflight.set(cacheKey, promise);
return promise;
},
};
}
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
return {
async lookup(ip: string): Promise<IpInfoLookupResult> {
return unavailable(ip, reason);
},
};
}
function unavailable(ip: string, reason: string): IpInfoLookupResult {
return {
ip,
available: false,
note: reason,
geo: emptyGeo(),
asn: emptyAsn(),
mobile: emptyMobile(),
anonymous: emptyAnonymous(),
flags: emptyFlags(),
};
}
function emptyGeo(): IpInfoGeoBlock {
return {
countryCode: null,
countryName: null,
continent: null,
continentCode: null,
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
};
}
function emptyAsn(): IpInfoAsnBlock {
return {asn: null, number: null, name: null, domain: null, type: null};
}
function emptyMobile(): IpInfoMobileBlock {
return {name: null, mcc: null, mnc: null};
}
function emptyAnonymous(): IpInfoAnonymousBlock {
return {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
};
}
function emptyFlags(): IpInfoFlags {
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
}
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
const geo = raw.geo;
const anon = raw.anonymous;
const isAnonymous =
raw.is_anonymous === true ||
anon.is_res_proxy === true ||
anon.is_vpn === true ||
anon.is_proxy === true ||
anon.is_tor === true ||
anon.is_relay === true;
return {
ip: raw.ip,
available: true,
note: describeAnonymity(isAnonymous, anon),
geo: {
countryCode: normalizeCountryCode(geo.country_code),
countryName: geo.country ?? null,
continent: geo?.continent ?? null,
continentCode: normalizeContinentCode(geo.continent_code),
region: geo.region ?? null,
regionCode: normalizeRegionCode(geo.region_code),
city: geo.city ?? null,
postalCode: geo.postal_code ?? null,
timezone: geo.timezone ?? null,
latitude: normalizeCoordinate(geo.latitude),
longitude: normalizeCoordinate(geo.longitude),
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
},
asn: parseAsnBlock(raw.as),
mobile: {
name: raw.mobile?.name ?? null,
mcc: raw.mobile?.mcc ?? null,
mnc: raw.mobile?.mnc ?? null,
},
anonymous: {
isAnonymous,
providerName: anon?.name ?? null,
isVpn: anon?.is_vpn === true,
isProxy: anon?.is_proxy === true,
isResidentialProxy: anon?.is_res_proxy === true,
isTor: anon?.is_tor === true,
isRelay: anon?.is_relay === true,
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
},
flags: {
isAnycast: raw.is_anycast === true,
isHosting: raw.is_hosting === true,
isMobile: raw.is_mobile === true,
isSatellite: raw.is_satellite === true,
},
};
}
function formatSchemaMismatch(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return 'IPInfo response schema mismatch';
}
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
}
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
const raw = as?.asn ?? null;
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
return {
asn: raw,
number: Number.isFinite(numeric) ? numeric : null,
name: as?.name ?? null,
domain: as?.domain ?? null,
type: as?.type ?? null,
};
}
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
if (!isAnonymous) {
return 'IPInfo: IP is not anonymous';
}
if (!anon) {
return 'IPInfo: anonymous IP';
}
const flags: Array<string> = [];
if (anon.is_res_proxy) flags.push('residential proxy');
if (anon.is_vpn) flags.push('VPN');
if (anon.is_proxy) flags.push('proxy');
if (anon.is_tor) flags.push('Tor');
if (anon.is_relay) flags.push('relay');
const provider = anon.name ? ` (provider: ${anon.name})` : '';
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
}
function normalizeCountryCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeContinentCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeRegionCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return normalized.length > 0 ? normalized : null;
}
function normalizeCoordinate(value: number | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
@@ -1,153 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
interface PostgresIpInfoOptions {
client?: IPostgresClient;
getClient?: () => IPostgresClient;
onError?: (error: unknown, operation: string) => void;
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
}
function valueKey(value: unknown): string {
return JSON.stringify(value);
}
function rowKey(values: ReadonlyArray<unknown>): string {
return values.map(valueKey).join(VALUE_SEPARATOR);
}
function table(client: IPostgresClient): string {
return quoteIdentifier(client.kvTable());
}
async function upsertKvRow(
client: IPostgresClient,
tableName: string,
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds: number,
): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
ON CONFLICT (table_name, row_key)
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
);
}
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = getClient(options);
if (!client) return null;
const result = await client.query<{row_data: {payload?: string}}>(
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
['ipinfo_cache', rowKey([key])],
);
const payload = result.rows[0]?.row_data?.payload;
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
} catch (error) {
options.onError?.(error, 'ipinfo_cache_get');
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_serialize');
return;
}
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
},
};
}
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = getClient(options);
if (!client) return;
const bucketDate = formatUtcDate(event.requestedAt);
const bucketHour = event.requestedAt.getUTCHours();
const eventId = randomUUID();
await upsertKvRow(
client,
'ipinfo_requests_by_hour',
rowKey([bucketDate, bucketHour]),
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
{
bucket_date: bucketDate,
bucket_hour: bucketHour,
requested_at: event.requestedAt.toISOString(),
event_id: eventId,
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) return null;
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
interface TieredIpInfoCacheOptions {
hot: IpInfoCache;
cold: IpInfoCache;
hotTtlSeconds?: number;
skipColdWrite?: (value: unknown) => boolean;
}
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
return {
async get<T>(key: string): Promise<T | null> {
const hit = await opts.hot.get<T>(key).catch(() => null);
if (hit !== null) return hit;
const cold = await opts.cold.get<T>(key).catch(() => null);
if (cold === null) return null;
if (opts.skipColdWrite?.(cold) === true) return cold;
void opts.hot.set(key, cold, hotTtl).catch(() => {});
return cold;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
if (opts.skipColdWrite?.(value) !== true) {
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
}
await Promise.all(writes);
},
};
}
+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 -3
View File
@@ -259,9 +259,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
}
: undefined,
},
ipinfo: {
apiKey: master.integrations.ipinfo.api_key || undefined,
},
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
@@ -370,6 +367,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
instance: {
selfHosted: master.instance.self_hosted,
phoneVerificationEnabled: master.instance.phone_verification_enabled ?? !master.instance.self_hosted,
autoJoinInviteCode: master.instance.auto_join_invite_code,
visionariesGuildId: master.instance.visionaries_guild_id,
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
@@ -453,6 +451,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
lifecycle: apiWorkerConfig?.lane_concurrency_overrides?.lifecycle,
batch: apiWorkerConfig?.lane_concurrency_overrides?.batch,
crosspost: apiWorkerConfig?.lane_concurrency_overrides?.crosspost,
},
},
};
+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,
+7 -10
View File
@@ -41,7 +41,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
import type {UserService} from '@app/api/user/services/UserService';
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type Stripe from 'stripe';
export class AdminService {
@@ -81,7 +80,6 @@ export class AdminService {
private readonly applicationRepository: IApplicationRepository,
private readonly stripe: Stripe | null = null,
private readonly jobLedger: IJobLedgerRepository,
private readonly ipInfoService: IpInfoService,
private readonly storeEntitlementService: StoreEntitlementService,
) {
const {users, gateway, worker, snowflake} = this.apiContext.services;
@@ -94,7 +92,6 @@ export class AdminService {
apiContext: this.apiContext,
adminRepository: this.adminRepository,
auditService: this.auditService,
ipInfoService: this.ipInfoService,
});
this.userService = new AdminUserService({
apiContext: this.apiContext,
@@ -181,20 +178,20 @@ export class AdminService {
}
async sendSystemDm(
data: {content: string; userIds: Array<string>},
data: {content: string; recipients: {kind: 'all'} | {kind: 'list'; userIds: Array<string>}},
adminUserId: UserID,
auditLogReason: string | null,
): Promise<SendSystemDmResponse> {
const recipientCount = data.recipients.kind === 'all' ? null : data.recipients.userIds.length;
await this.apiContext.services.worker.addJob(
'sendSystemDm',
{
content: data.content,
user_ids: data.userIds,
},
data.recipients.kind === 'all'
? {content: data.content, all_users: true}
: {content: data.content, user_ids: data.recipients.userIds},
{requireLedger: true},
);
const metadata = new Map<string, string>([
['recipient_count', data.userIds.length.toString()],
['recipient_count', recipientCount === null ? 'all' : recipientCount.toString()],
['content_length', data.content.length.toString()],
]);
await this.auditService.createAuditLog({
@@ -205,6 +202,6 @@ export class AdminService {
auditLogReason,
metadata,
});
return {recipient_count: data.userIds.length};
return {recipient_count: recipientCount};
}
}
@@ -338,7 +338,7 @@ export function BanAdminController(app: HonoApp) {
tags: ['Admin'],
requestSchema: AdminBlocklistEntryCreateRequest,
description:
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -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: {
@@ -124,6 +128,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
single_community_guild_id: policy.single_community_guild_id,
direct_messages_disabled: policy.direct_messages_disabled,
direct_messages_locked: policy.direct_messages_locked,
guild_create_access: policy.guild_create_access,
premium_mode: policy.premium_mode,
services: {
gif_enabled: policy.gif_enabled,
@@ -387,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) {
@@ -831,6 +848,9 @@ function planInstancePolicyPatch(
patch.direct_messages_locked = true;
}
}
if (policy.guild_create_access !== undefined && policy.guild_create_access !== current.guild_create_access) {
patch.guild_create_access = policy.guild_create_access;
}
if (policy.services) {
if (policy.services.gif_enabled !== undefined) {
patch.gif_enabled = policy.services.gif_enabled ?? null;
@@ -23,7 +23,7 @@ export function SystemDmAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
'Queue a worker job that delivers the same content to every listed user, or to every user when all_users is set, as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -31,7 +31,12 @@ export function SystemDmAdminController(app: HonoApp) {
const auditLogReason = ctx.get('auditLogReason');
const payload = ctx.req.valid('json');
const result = await adminService.sendSystemDm(
{content: payload.content, userIds: payload.user_ids.map((id) => id.toString())},
{
content: payload.content,
recipients: payload.all_users
? {kind: 'all'}
: {kind: 'list', userIds: (payload.user_ids ?? []).map((id) => id.toString())},
},
adminUserId,
auditLogReason,
);
@@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
import {
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
@@ -18,7 +17,6 @@ import {
} from '@app/api/constants/ContentModeration';
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes';
import {Logger} from '@app/api/Logger';
import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache';
import {fileShaCache} from '@app/api/middleware/FileShaCache';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
@@ -34,13 +32,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface AdminBanManagementServiceDeps {
apiContext: ApiContext;
adminRepository: IAdminRepository;
auditService: AdminAuditService;
ipInfoService: IpInfoService;
}
interface AdminBlocklistEntry {
@@ -146,20 +142,6 @@ export class AdminBanManagementService {
message: 'This IP address is on the instance exemption list',
});
}
if (await this.shouldSkipIpBanForCgnat(data.ip)) {
await auditService.createAuditLog({
adminUserId,
targetType: 'ip',
targetId: BigInt(0),
action: 'ban_ip_skipped_cgnat',
auditLogReason,
metadata: new Map([['ip', data.ip]]),
});
throw new BadRequestError({
code: APIErrorCodes.IP_BAN_DECLINED,
message: 'This IP address is a high blast-radius carrier network',
});
}
await adminRepository.banIp(data.ip);
ipBanCache.ban(data.ip);
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
@@ -200,25 +182,6 @@ export class AdminBanManagementService {
return {banned};
}
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
if (!isSingleIpBanCandidate(ip)) {
return false;
}
try {
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
source: 'admin.ip_ban',
reason: 'pre_write_cgnat_guard',
});
if (highRisk) {
Logger.warn({ip}, 'Skipping IP ban because IPInfo indicates high CGNAT blast-radius risk');
}
return highRisk;
} catch (error) {
Logger.warn({error, ip}, 'IPInfo CGNAT guard failed while adding IP ban');
return false;
}
}
async banEmail(
data: {
email: string;
@@ -13,6 +13,10 @@ import {
type UserID,
} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {
enqueueCrosspostFamilyPurgeFromCopies,
enqueueCrosspostSourceRemoval,
} from '@app/api/channel/services/message/CrosspostPropagation';
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
import {
createMessageResponseDataService,
@@ -127,15 +131,13 @@ export class AdminMessageService {
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
const {channelRepository, auditService} = this.deps;
const {gateway: gatewayService} = this.deps.apiContext.services;
const {gateway: gatewayService, worker: workerService} = this.deps.apiContext.services;
const channelId = createChannelID(data.channel_id);
const messageId = createMessageID(data.message_id);
const channel = await channelRepository.findUnique(channelId);
const message = await channelRepository.getMessage(channelId, messageId);
if (message) {
if (message.attachments.length > 0) {
await purgeMessageAttachments(message, getStorageService(), getPurgeQueue());
}
await purgeMessageAttachments(message, getStorageService(), getPurgeQueue());
await channelRepository.deleteMessage(
channelId,
messageId,
@@ -166,6 +168,8 @@ export class AdminMessageService {
}
}
await deleteMessageSearchDocuments([messageId], {context: {source: 'admin_message_delete'}});
await enqueueCrosspostSourceRemoval(workerService, {messages: [message], mode: 'purge'});
await enqueueCrosspostFamilyPurgeFromCopies(workerService, {messages: [message]});
}
await auditService.createAuditLog({
adminUserId,
@@ -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,
@@ -1,170 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {createUserID} from '@app/api/BrandedTypes';
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
import {getConfig} from '@app/api/Config';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const ADMIN_ID = createUserID(42n);
const EXEMPT_IP = '10.0.0.1';
const CARRIER_IP = '198.51.100.7';
const LOOKUP_FAILURE_IP = '203.0.113.9';
interface AuditCall {
action: string;
metadata: Map<string, string> | undefined;
}
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip: CARRIER_IP,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test Carrier',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookupResult>) {
const bannedIps: Array<string> = [];
const auditCalls: Array<AuditCall> = [];
const adminRepository = {
banIp: async (ip: string) => {
bannedIps.push(ip);
},
};
const auditService = {
createAuditLog: async ({action, metadata}: AuditCall) => {
auditCalls.push({action, metadata});
},
};
const ipInfoService = {lookup: (ip: string) => lookup(ip)};
const apiContext = {
services: {
cache: {
publish: async () => {},
},
},
};
const service = new AdminBanManagementService({
apiContext: apiContext as unknown as ApiContext,
adminRepository: adminRepository as unknown as IAdminRepository,
auditService: auditService as unknown as AdminAuditService,
ipInfoService: ipInfoService as unknown as IpInfoService,
});
return {service, bannedIps, auditCalls};
}
describe('AdminBanManagementService banIp guards', () => {
let originalExemptIps: Array<string>;
beforeEach(() => {
const config = getConfig();
originalExemptIps = config.ipBanExemptIps;
config.ipBanExemptIps = [EXEMPT_IP];
resetIpBanExemptionsForTesting();
});
afterEach(() => {
ipBanCache.unban(LOOKUP_FAILURE_IP);
getConfig().ipBanExemptIps = originalExemptIps;
resetIpBanExemptionsForTesting();
});
it('refuses an exempt address with IP_BAN_DECLINED and writes no ban row', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () => ipInfoResult());
const error = await service.banIp({ip: EXEMPT_IP}, ADMIN_ID, null).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
expect((error as BadRequestError).status).toBe(400);
expect(bannedIps).toEqual([]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_exempt']);
expect(auditCalls[0].metadata?.get('ip')).toBe(EXEMPT_IP);
});
it('refuses a high blast-radius carrier address with IP_BAN_DECLINED and writes no ban row', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () =>
ipInfoResult({
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
);
const error = await service.banIp({ip: CARRIER_IP}, ADMIN_ID, null).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(BadRequestError);
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
expect((error as BadRequestError).status).toBe(400);
expect(bannedIps).toEqual([]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_cgnat']);
expect(auditCalls[0].metadata?.get('ip')).toBe(CARRIER_IP);
});
it('still writes the ban when the IPInfo lookup fails', async () => {
const {service, bannedIps, auditCalls} = createBanManagementService(async () => {
throw new Error('ipinfo is unreachable');
});
await expect(service.banIp({ip: LOOKUP_FAILURE_IP}, ADMIN_ID, null)).resolves.toBeUndefined();
expect(bannedIps).toEqual([LOOKUP_FAILURE_IP]);
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip']);
});
});
@@ -10,83 +10,24 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function createUniqueTestIp(): string {
return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`;
}
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('Admin Deletion Queue', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip);
},
});
});
afterEach(async () => {
setInjectedIpInfoService(undefined);
await harness?.shutdown();
});
test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => {
@@ -2,6 +2,7 @@
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {CrosspostWorkerService} from '@app/api/channel/services/message/CrosspostPropagation';
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildMemberOperationsService} from '@app/api/guild/services/member/GuildMemberOperationsService';
@@ -37,6 +38,7 @@ function createMessageDeletionService(): UserMessageDeletionService {
gatewayService: unusable as IGatewayService,
storageService: unusable as IStorageService,
purgeQueue: unusable as IPurgeQueue,
workerService: unusable as CrosspostWorkerService,
});
}
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const COMMUNITY_CREATOR_TRAIT = 'community_creator';
interface LimitConfigReadResponse {
limit_config: LimitConfigSnapshot;
}
describe('guild creation access on a self-hosted instance', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
const asSelfHosted = async <T>(run: () => Promise<T>): Promise<T> => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
return await run();
} finally {
config.instance.selfHosted = originalSelfHosted;
}
};
const createAdmin = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW,
AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE,
AdminACLs.USER_UPDATE_TRAITS,
]);
const createMember = async (): Promise<TestAccount> =>
await setUserACLs(harness, await createTestAccount(harness), []);
const setGuildCreateAccess = async (admin: TestAccount, enabled: boolean): Promise<void> => {
const updated = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch('/admin/instance/config')
.body({policy: {guild_create_access: enabled}})
.execute();
expect(updated.policy.guild_create_access).toBe(enabled);
};
const readInstanceConfig = async (admin: TestAccount): Promise<InstanceConfigResponse> =>
await createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const createGuild = (account: TestAccount, name: string) =>
createBuilder<GuildResponse>(harness, account.token).post('/guilds').body({name});
const grantGuildCreateToTrait = async (admin: TestAccount, trait: string): Promise<void> => {
const current = await createBuilder<LimitConfigReadResponse>(harness, admin.token)
.get('/admin/limit-config')
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, admin.token)
.put('/admin/limit-config')
.body({
limit_config: {
traitDefinitions: [...current.limit_config.traitDefinitions, trait],
rules: [
...current.limit_config.rules,
{id: `grant_${trait}`, filters: {traits: [trait]}, limits: {feature_guild_create: 1}},
],
},
})
.expect(HTTP_STATUS.OK)
.execute();
};
const grantTrait = async (admin: TestAccount, account: TestAccount, trait: string): Promise<void> => {
await createBuilder(harness, admin.token)
.put(`/admin/users/${account.userId}/traits`)
.body({traits: [trait]})
.expect(HTTP_STATUS.OK)
.execute();
};
it('allows guild creation while the community creation policy is at its default', async () => {
const admin = await createAdmin();
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(true);
const member = await createMember();
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Default policy community').execute();
expect(guild.id).toBeTruthy();
});
});
it('stores a disabled policy and rejects guild creation for a member without a grant', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
expect((await readInstanceConfig(admin)).policy.guild_create_access).toBe(false);
const member = await createMember();
await asSelfHosted(async () => {
await createGuild(member, 'Denied community')
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
.execute();
});
});
it('allows guild creation only once a member holds the trait the grant rule targets', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
await grantGuildCreateToTrait(admin, COMMUNITY_CREATOR_TRAIT);
const member = await createMember();
await asSelfHosted(async () => {
await createGuild(member, 'Ungranted community')
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.GUILD_CREATION_PERMISSION_REQUIRED)
.execute();
});
await grantTrait(admin, member, COMMUNITY_CREATOR_TRAIT);
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Granted community').execute();
expect(guild.id).toBeTruthy();
});
});
it('allows guild creation for a member holding a wildcard ACL while the policy is disabled', async () => {
const admin = await createAdmin();
await setGuildCreateAccess(admin, false);
const member = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.WILDCARD]);
await asSelfHosted(async () => {
const guild = await createGuild(member, 'Wildcard community').execute();
expect(guild.id).toBeTruthy();
});
});
});
+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,
};
}
+10 -11
View File
@@ -35,6 +35,7 @@ import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {generateRandomUsername} from '@app/api/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@app/api/utils/UsernameSuggestionUtils';
import {SSO_MOBILE_CALLBACK_URI, SSO_MOBILE_STATE_PREFIX} from '@fluxer/constants/src/SsoConstants';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
@@ -106,7 +107,6 @@ interface JwksCacheEntry {
const CODE_VERIFIER_BYTE_LENGTH = 32;
const STATE_BYTE_LENGTH = 16;
const NONCE_BYTE_LENGTH = 16;
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
let ssoLogger: ILogger | undefined;
@@ -136,11 +136,10 @@ function buildDiscoveryCacheKey(issuer: string): string {
return `sso:oidc-discovery:${key}`;
}
function resolveSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): string {
if (!requestedRedirectUri) return defaultRedirectUri;
const trimmed = requestedRedirectUri.trim();
if (!trimmed) return defaultRedirectUri;
if (trimmed === defaultRedirectUri || trimmed === MOBILE_SSO_REDIRECT_URI) return trimmed;
function isMobileSsoRedirectUri(requestedRedirectUri: string | undefined, defaultRedirectUri: string): boolean {
const trimmed = requestedRedirectUri?.trim();
if (!trimmed || trimmed === defaultRedirectUri) return false;
if (trimmed === SSO_MOBILE_CALLBACK_URI) return true;
throw InputValidationError.fromCode('redirect_uri', ValidationErrorCodes.INVALID_URL_FORMAT);
}
@@ -285,16 +284,16 @@ export class SsoService {
redirect_uri: string;
}> {
const config = await this.requireReadyConfig();
const state = randomHexToken(STATE_BYTE_LENGTH);
const isMobile = isMobileSsoRedirectUri(redirectUri, config.redirectUri);
const state = `${isMobile ? SSO_MOBILE_STATE_PREFIX : ''}${randomHexToken(STATE_BYTE_LENGTH)}`;
const codeVerifier = randomBase64UrlToken(CODE_VERIFIER_BYTE_LENGTH);
const codeChallenge = buildCodeChallenge(codeVerifier);
const nonce = randomBase64UrlToken(NONCE_BYTE_LENGTH);
const ssoRedirectUri = resolveSsoRedirectUri(redirectUri, config.redirectUri);
const statePayload: SsoStatePayload = {
codeVerifier,
nonce,
redirectTo: sanitizeSsoRedirectTo(redirectTo),
redirectUri: ssoRedirectUri,
redirectUri: config.redirectUri,
createdAt: Date.now(),
};
const {cache} = this.apiContext.services;
@@ -302,7 +301,7 @@ export class SsoService {
const searchParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId ?? '',
redirect_uri: ssoRedirectUri,
redirect_uri: config.redirectUri,
scope: config.scope,
state,
code_challenge: codeChallenge,
@@ -324,7 +323,7 @@ export class SsoService {
throw new FeatureTemporarilyDisabledError();
}
}
return {authorization_url: authorizationUrlString, state, redirect_uri: ssoRedirectUri};
return {authorization_url: authorizationUrlString, state, redirect_uri: config.redirectUri};
}
async completeLogin({code, state, request}: {code: string; state: string; request: Request}): Promise<{
@@ -131,6 +131,7 @@ describe('Auth SSO flow', () => {
.body({redirect_to: '/me'})
.execute();
expect(startData.state).toBeTruthy();
expect(startData.state.startsWith('m.')).toBe(false);
expect(startData.authorization_url).toBeTruthy();
const authUrlString = startData.authorization_url;
expect(authUrlString).toContain(`state=${startData.state}`);
@@ -179,7 +180,8 @@ describe('Auth SSO flow', () => {
expect(startData.redirect_uri).not.toContain('evil.example');
expect(startData.authorization_url).toContain(encodeURIComponent(startData.redirect_uri));
});
it('uses the requested mobile SSO redirect URI without changing the post-login redirect', async () => {
it('routes mobile SSO through the default redirect URI without changing the post-login redirect', async () => {
const status = await createBuilderWithoutAuth<{redirect_uri: string}>(harness).get('/auth/sso/status').execute();
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({
@@ -187,8 +189,10 @@ describe('Auth SSO flow', () => {
redirect_uri: 'fluxer://auth/sso/callback',
})
.execute();
expect(startData.redirect_uri).toBe('fluxer://auth/sso/callback');
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe('fluxer://auth/sso/callback');
expect(startData.redirect_uri).toBe(status.redirect_uri);
expect(getAuthorizationUrlParam(startData.authorization_url, 'redirect_uri')).toBe(status.redirect_uri);
expect(startData.state.startsWith('m.')).toBe(true);
expect(getAuthorizationUrlParam(startData.authorization_url, 'state')).toBe(startData.state);
const email = `sso-mobile-redirect-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
@@ -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']}),
},
};
}
-80
View File
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
interface IpBanBlastRadiusVerdict {
cgnat: boolean;
sharedAccess: boolean;
}
interface CachedVerdict {
expiresAtMs: number;
verdict: IpBanBlastRadiusVerdict;
}
const verdictCache = new Map<string, CachedVerdict>();
function isAnonymousAccess(result: IpInfoLookupResult): boolean {
return (
result.anonymous.isAnonymous ||
result.anonymous.isVpn ||
result.anonymous.isProxy ||
result.anonymous.isResidentialProxy ||
result.anonymous.isTor ||
result.anonymous.isRelay
);
}
export function isHighCgnatBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (!result.available || result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
return result.flags.isMobile || result.mobile.name !== null || asnType === 'mobile';
}
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
const asnType = result.asn.type?.trim().toLowerCase() ?? null;
return result.flags.isAnycast || result.flags.isSatellite || asnType === 'education';
}
export function isSingleIpBanCandidate(value: string): boolean {
return parseIpBanEntry(value)?.type === 'single';
}
export async function getIpBanBlastRadiusVerdict(
ip: string,
ipInfoService: IpInfoService,
context: {
source: string;
reason: string;
},
): Promise<IpBanBlastRadiusVerdict> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
const cached = verdictCache.get(cacheKey);
if (cached && cached.expiresAtMs > now) {
return cached.verdict;
}
const result = await ipInfoService.lookup(ip, {
source: context.source,
reason: context.reason,
metadata: {policy: 'ip_ban_cgnat_guard'},
});
const verdict: IpBanBlastRadiusVerdict = {
cgnat: isHighCgnatBlastRadiusRisk(result),
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
};
verdictCache.set(cacheKey, {
verdict,
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
});
return verdict;
}
@@ -1,45 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {getDefaultCassandraClient} from '@pkgs/cassandra/src/Client';
import {createCassandraIpInfoCache} from '@pkgs/geoip/src/CassandraIpInfoCache';
import {createCassandraIpInfoRequestAuditLogger} from '@pkgs/geoip/src/CassandraIpInfoRequestAudit';
import {type IpInfoCache, type IpInfoRequestAuditLogger, isCachedIpInfoFailure} from '@pkgs/geoip/src/IpInfoService';
import {createPostgresIpInfoCache, createPostgresIpInfoRequestAuditLogger} from '@pkgs/geoip/src/PostgresIpInfoKv';
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
interface BuildIpInfoCacheOptions {
hot: IpInfoCache;
}
export function buildIpInfoCache(options: BuildIpInfoCacheOptions): IpInfoCache {
if (Config.database.backend === 'postgres') {
return createTieredIpInfoCache({
hot: options.hot,
cold: createPostgresIpInfoCache({
getClient: getDefaultPostgresClient,
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo cache operation failed'),
}),
skipColdWrite: isCachedIpInfoFailure,
});
}
return createTieredIpInfoCache({
hot: options.hot,
cold: createCassandraIpInfoCache({getClient: getDefaultCassandraClient}),
skipColdWrite: isCachedIpInfoFailure,
});
}
export function buildIpInfoRequestAuditLogger(): IpInfoRequestAuditLogger {
if (Config.database.backend === 'postgres') {
return createPostgresIpInfoRequestAuditLogger({
getClient: getDefaultPostgresClient,
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo audit operation failed'),
});
}
return createCassandraIpInfoRequestAuditLogger({
getClient: getDefaultCassandraClient,
});
}
@@ -1,162 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
isHighCgnatBlastRadiusRisk,
isHighSharedAccessBlastRadiusRisk,
isSingleIpBanCandidate,
} from '@app/api/ban/IpBanCgnatGuard';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {describe, expect, it} from 'vitest';
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip: '198.51.100.1',
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('IpBanCgnatGuard', () => {
it('only treats single IP ban entries as CGNAT guard candidates', () => {
expect(isSingleIpBanCandidate('198.51.100.10')).toBe(true);
expect(isSingleIpBanCandidate('198.51.100.0/24')).toBe(false);
});
it('flags mobile carrier IPs as high blast-radius risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
),
).toBe(true);
});
it('does not exempt hosting or anonymous infrastructure', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: true, isMobile: true, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
}),
),
).toBe(false);
});
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
),
).toBe(true);
});
it('does not flag ordinary residential networks as shared-access risk', () => {
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
});
it('does not treat shared-access networks as CGNAT risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
});
@@ -1,210 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {server} from '@app/api/test/msw/server';
import type {
CachedIpInfoFailure,
IpInfoCache,
IpInfoRequestAuditEvent,
IpInfoRequestAuditLogger,
} from '@pkgs/geoip/src/IpInfoService';
import {createIpInfoService} from '@pkgs/geoip/src/IpInfoService';
import {delay, HttpResponse, http} from 'msw';
import {describe, expect, it} from 'vitest';
interface RecordedSet {
key: string;
value: unknown;
ttlSeconds: number | undefined;
}
interface RecordingCache {
cache: IpInfoCache;
sets: Array<RecordedSet>;
}
function createRecordingCache(): RecordingCache {
const store = new Map<string, unknown>();
const sets: Array<RecordedSet> = [];
return {
sets,
cache: {
async get<T>(key: string): Promise<T | null> {
return (store.get(key) as T | undefined) ?? null;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
store.set(key, value);
sets.push({key, value, ttlSeconds});
},
},
};
}
function createRecordingAuditLogger(): {logger: IpInfoRequestAuditLogger; events: Array<IpInfoRequestAuditEvent>} {
const events: Array<IpInfoRequestAuditEvent> = [];
return {
events,
logger: {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
events.push(event);
},
},
};
}
function useLookupHandler(handler: () => Response | Promise<Response>): {count: () => number} {
let calls = 0;
server.use(
http.get('https://api.ipinfo.io/lookup/:ip', async () => {
calls += 1;
return await handler();
}),
);
return {count: () => calls};
}
function successPayload(ip: string, anonymous: Record<string, boolean> = {}): Response {
return HttpResponse.json({
ip,
geo: {country_code: 'US', country: 'United States'},
as: {asn: 'AS64500', name: 'Test ISP'},
anonymous,
});
}
describe('IpInfoService caching', () => {
it('negative-caches an HTTP error and serves the second lookup without a request', async () => {
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const first = await service.lookup('203.0.113.1');
const second = await service.lookup('203.0.113.1');
expect(first.available).toBe(false);
expect(second.available).toBe(false);
expect(requests.count()).toBe(1);
expect(sets).toHaveLength(1);
expect(sets[0]?.ttlSeconds).toBe(300);
});
it('negative-caches a request failure for a short window', async () => {
useLookupHandler(async () => {
await delay(5000);
return successPayload('203.0.113.2');
});
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const result = await service.lookup('203.0.113.2');
expect(result.available).toBe(false);
expect(sets[0]?.ttlSeconds).toBe(60);
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('request_failed');
});
it('negative-caches a schema mismatch', async () => {
useLookupHandler(() => HttpResponse.json({}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const result = await service.lookup('203.0.113.3');
expect(result.available).toBe(false);
expect(sets[0]?.ttlSeconds).toBe(600);
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('schema_mismatch');
expect((sets[0]?.value as CachedIpInfoFailure)?.failureHttpStatus).toBe(200);
});
it('negative-caches a quota rejection for longer', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 429}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.4');
expect(sets[0]?.ttlSeconds).toBe(900);
});
it('returns a cached failure as a clean unavailable result', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.6');
const cached = await service.lookup('203.0.113.6');
expect(cached).not.toHaveProperty('cachedFailure');
expect(cached).not.toHaveProperty('failureOutcome');
expect(cached).not.toHaveProperty('failureHttpStatus');
expect(cached).not.toHaveProperty('cachedAtMs');
expect(cached.ip).toBe('203.0.113.6');
expect(cached.note).toBe('IPInfo HTTP 500');
});
it('writes a cached failure that older readers can still consume', async () => {
useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
await service.lookup('203.0.113.7');
const entry = sets[0]?.value as CachedIpInfoFailure;
expect(entry.cachedFailure).toBe(true);
expect(entry.failureOutcome).toBe('http_error');
expect(entry.failureHttpStatus).toBe(500);
expect(typeof entry.cachedAtMs).toBe('number');
const legacyView = {...entry, ip: '203.0.113.7'};
expect(legacyView.available).toBe(false);
expect(legacyView.geo.countryCode).toBeNull();
expect(legacyView.asn.number).toBeNull();
expect(legacyView.mobile.name).toBeNull();
expect(legacyView.anonymous.isAnonymous).toBe(false);
expect(legacyView.flags.isMobile).toBe(false);
});
it('keeps the existing success TTL selection', async () => {
useLookupHandler(() => successPayload('203.0.113.8'));
const plain = createRecordingCache();
await createIpInfoService({apiKey: 'token', cache: plain.cache}).lookup('203.0.113.8');
useLookupHandler(() => successPayload('203.0.113.9', {is_vpn: true}));
const anonymous = createRecordingCache();
await createIpInfoService({apiKey: 'token', cache: anonymous.cache}).lookup('203.0.113.9');
expect(plain.sets[0]?.ttlSeconds).toBe(14 * 24 * 60 * 60);
expect(anonymous.sets[0]?.ttlSeconds).toBe(7 * 24 * 60 * 60);
});
it('coalesces concurrent lookups across a failure', async () => {
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
const {cache, sets} = createRecordingCache();
const service = createIpInfoService({apiKey: 'token', cache});
const [first, second] = await Promise.all([service.lookup('203.0.113.10'), service.lookup('203.0.113.10')]);
expect(requests.count()).toBe(1);
expect(sets).toHaveLength(1);
expect(first.available).toBe(false);
expect(second.available).toBe(false);
});
it('coalesces concurrent lookups from different sources into one audited request', async () => {
const requests = useLookupHandler(() => successPayload('203.0.113.13'));
const {cache} = createRecordingCache();
const {logger, events} = createRecordingAuditLogger();
const service = createIpInfoService({apiKey: 'token', cache, auditLogger: logger});
const results = await Promise.all([
service.lookup('203.0.113.13', {source: 'admin.ip_ban', reason: 'ban'}),
service.lookup('203.0.113.13', {source: 'test.b'}),
service.lookup('203.0.113.13', {source: 'test.c'}),
]);
expect(requests.count()).toBe(1);
expect(results.every((result) => result.available)).toBe(true);
expect(events).toHaveLength(1);
expect(events[0]?.source).toBe('admin.ip_ban');
expect(events[0]?.outcome).toBe('http_success');
expect(events[0]?.note).toBe('IPInfo: IP is not anonymous');
});
});
@@ -1,75 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoRequestAuditEvent} from '@pkgs/geoip/src/IpInfoService';
import {
createPostgresIpInfoCache,
createPostgresIpInfoRequestAuditLogger,
IPINFO_CACHE_TTL_SECONDS,
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
} from '@pkgs/geoip/src/PostgresIpInfoKv';
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
import {describe, expect, it} from 'vitest';
function recordingClient(writes: Array<Array<unknown>>): IPostgresClient {
return {
async query(_text: string, values?: Array<unknown>) {
writes.push(values ?? []);
return {rows: [], rowCount: 1};
},
kvTable() {
return 'kv';
},
} as never;
}
function expectExpiresIn(values: Array<unknown> | undefined, ttlSeconds: number): void {
const expiresAt = values?.[4];
expect(expiresAt).toBeInstanceOf(Date);
const remainingSeconds = ((expiresAt as Date).getTime() - Date.now()) / 1000;
expect(remainingSeconds).toBeGreaterThan(ttlSeconds - 10);
expect(remainingSeconds).toBeLessThanOrEqual(ttlSeconds);
}
const EVENT: IpInfoRequestAuditEvent = {
requestedAt: new Date('2026-09-21T12:00:00.000Z'),
ip: '192.0.2.1',
cacheKey: 'ip:192.0.2.1',
source: 'test',
reason: null,
outcome: 'http_success',
httpStatus: 200,
available: true,
note: 'none',
latencyMs: 12,
requestUrl: 'https://ipinfo.test/192.0.2.1',
responseIp: '192.0.2.1',
countryCode: 'SE',
asnNumber: 64500,
isAnonymous: false,
isTor: false,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
};
describe('Postgres ipinfo KV expiry', () => {
it('expires request audit rows after 90 days', async () => {
const writes: Array<Array<unknown>> = [];
await createPostgresIpInfoRequestAuditLogger({client: recordingClient(writes)}).record(EVENT);
expect(writes).toHaveLength(1);
expect(writes[0]?.[0]).toBe('ipinfo_requests_by_hour');
expectExpiresIn(writes[0], IPINFO_REQUEST_AUDIT_TTL_SECONDS);
});
it('falls back to the 14-day cache default', async () => {
const writes: Array<Array<unknown>> = [];
const cache = createPostgresIpInfoCache({client: recordingClient(writes)});
await cache.set('fallback', {ok: true});
await cache.set('zero', {ok: true}, 0);
await cache.set('short', {ok: true}, 60);
expect(writes.map((values) => values[0])).toEqual(['ipinfo_cache', 'ipinfo_cache', 'ipinfo_cache']);
expectExpiresIn(writes[0], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[1], IPINFO_CACHE_TTL_SECONDS);
expectExpiresIn(writes[2], 60);
});
});
@@ -1,130 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
import {describe, expect, it} from 'vitest';
interface RecordedSet {
key: string;
value: unknown;
ttlSeconds: number | undefined;
}
interface RecordingCache {
cache: IpInfoCache;
store: Map<string, unknown>;
sets: Array<RecordedSet>;
}
function createRecordingCache(): RecordingCache {
const store = new Map<string, unknown>();
const sets: Array<RecordedSet> = [];
return {
store,
sets,
cache: {
async get<T>(key: string): Promise<T | null> {
return (store.get(key) as T | undefined) ?? null;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
store.set(key, value);
sets.push({key, value, ttlSeconds});
},
},
};
}
describe('TieredIpInfoCache', () => {
it('clamps the hot TTL to the requested TTL and passes the raw TTL to the cold tier', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: false}, 60);
expect(hot.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
expect(cold.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
});
it('caps the hot TTL at the configured hot window', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: true}, 100000);
expect(hot.sets[0]?.ttlSeconds).toBe(600);
expect(cold.sets[0]?.ttlSeconds).toBe(100000);
});
it('uses the hot window when no TTL is supplied', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: true});
expect(hot.sets[0]?.ttlSeconds).toBe(600);
expect(cold.sets[0]?.ttlSeconds).toBeUndefined();
});
it('skips the cold write when skipColdWrite matches', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({
hot: hot.cache,
cold: cold.cache,
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
});
await tiered.set('a', {available: false}, 60);
await tiered.set('b', {available: true}, 60);
expect(hot.sets.map((entry) => entry.key)).toEqual(['a', 'b']);
expect(cold.sets.map((entry) => entry.key)).toEqual(['b']);
});
it('promotes a cold hit into the hot tier', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
cold.store.set('a', {available: true});
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
const hit = await tiered.get('a');
expect(hit).toEqual({available: true});
expect(hot.sets).toEqual([{key: 'a', value: {available: true}, ttlSeconds: 600}]);
});
it('never promotes a cold hit that skipColdWrite matches', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
cold.store.set('a', {available: false});
const tiered = createTieredIpInfoCache({
hot: hot.cache,
cold: cold.cache,
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
});
const hit = await tiered.get('a');
expect(hit).toEqual({available: false});
expect(hot.sets).toEqual([]);
});
it('never writes a zero TTL', async () => {
const hot = createRecordingCache();
const cold = createRecordingCache();
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
await tiered.set('a', {available: false}, 60);
await tiered.set('b', {available: true}, 100000);
await tiered.set('c', {available: true});
cold.store.set('d', {available: true});
await tiered.get('d');
for (const entry of [...hot.sets, ...cold.sets]) {
expect(entry.ttlSeconds === undefined || entry.ttlSeconds > 0).toBe(true);
}
});
});
@@ -196,6 +196,7 @@ export async function mapChannelToResponse(params: MapChannelToResponseParams):
let response: ChannelResponse;
switch (channel.type) {
case ChannelTypes.GUILD_TEXT:
case ChannelTypes.GUILD_ANNOUNCEMENT:
response = serializeGuildTextChannel(channel, ctx);
break;
case ChannelTypes.GUILD_VOICE:
@@ -30,6 +30,10 @@ export class ChannelRepository extends IChannelRepository {
return this.repository.messageInteractions;
}
get crossposts() {
return this.repository.crossposts;
}
async findUnique(channelId: ChannelID): Promise<Channel | null> {
return this.repository.channelData.findUnique(channelId);
}

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