mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abde06824 | ||
|
|
b54016653b | ||
|
|
ee74d61f27 | ||
|
|
1f18d3262d | ||
|
|
8ea7707b37 | ||
|
|
7b40df5d6c | ||
|
|
6f98de33f7 | ||
|
|
efe94ed094 | ||
|
|
603b936536 | ||
|
|
d87351eefe | ||
|
|
76e6891f5b | ||
|
|
5040ae2c10 | ||
|
|
87df92e2c2 | ||
|
|
237aff666d | ||
|
|
11645cbf28 | ||
|
|
e297a6a653 | ||
|
|
1eed347ffb | ||
|
|
4aa7a3e181 | ||
|
|
69786d3b49 | ||
|
|
2fb5fb1abb | ||
|
|
a9265cbb39 | ||
|
|
ee2d11ee0a | ||
|
|
632067b552 | ||
|
|
840dc3dfa5 | ||
|
|
4e6f9b539c | ||
|
|
98cce4815d | ||
|
|
b375abc20a | ||
|
|
21cb7ba69c | ||
|
|
be69333eaf | ||
|
|
9a074adb11 | ||
|
|
2df82b2b5e | ||
|
|
d691047884 | ||
|
|
c2e7fde5bc | ||
|
|
7e4d5137f8 | ||
|
|
376afd2ad6 | ||
|
|
e3fcedbec5 | ||
|
|
7c9564bcad | ||
|
|
cfed6cc4e0 | ||
|
|
c7bd1be3e4 | ||
|
|
2161d84701 | ||
|
|
eaeeb3b502 | ||
|
|
dc32a7c70e | ||
|
|
ab0b483fbe | ||
|
|
6e2f90b03c | ||
|
|
5e0806f479 | ||
|
|
dfdfffe5de | ||
|
|
f5e32aed31 | ||
|
|
710c1aeaa8 | ||
|
|
af49cd6cc4 | ||
|
|
ca719e7b5e | ||
|
|
ab4069ed0e | ||
|
|
12bfaa83ba | ||
|
|
1076728241 | ||
|
|
360b984adc |
@@ -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
+7
-2
@@ -1785,8 +1785,10 @@ name = "fluxer-gifs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
@@ -1823,6 +1825,7 @@ dependencies = [
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"flate2",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
"hex",
|
||||
@@ -1850,6 +1853,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -2038,6 +2042,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -5830,9 +5835,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
||||
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
],
|
||||
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["fluxer_app/src/**/*.worklet.js"],
|
||||
"javascript": {"globals": ["AudioWorkletProcessor", "registerProcessor", "sampleRate", "currentTime"]}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-api
|
||||
description: Fluxer HTTP API and background job workers
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,244 @@
|
||||
{{- define "fluxer-api.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.labels" -}}
|
||||
{{ include "fluxer-api.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-api.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.image" -}}
|
||||
{{- $g := .root.Values.image | default dict -}}
|
||||
{{- $i := .w.image | default dict -}}
|
||||
{{- $repo := $i.repository -}}
|
||||
{{- if not $repo -}}
|
||||
{{- $repo = printf "%s/%s" (required "image.registry is required" $g.registry) ($i.name | default "fluxer-api") -}}
|
||||
{{- end -}}
|
||||
{{- $tag := required "image.tag is required" ($i.tag | default $g.tag) -}}
|
||||
{{- if $i.digest -}}
|
||||
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" $repo $tag | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.pick" -}}
|
||||
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
|
||||
{{- if $v }}
|
||||
{{- toYaml $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.str" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- int64 . | toString | quote -}}
|
||||
{{- else -}}
|
||||
{{- toString . | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.env" -}}
|
||||
{{- $env := dict -}}
|
||||
{{- range $k, $val := .root.Values.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := .w.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := $env }}
|
||||
{{- if not (kindIs "invalid" $val) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-api.str" $val }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .w.buildVersion }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-api.str" . }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.topologySpread" -}}
|
||||
{{- $tscs := ternary .w.topologySpreadConstraints .root.Values.topologySpreadConstraints (hasKey .w "topologySpreadConstraints") -}}
|
||||
{{- range $tscs }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not $c.labelSelector }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-api.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
- {{- toYaml $c | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.pdb" -}}
|
||||
{{- with .w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $.name }}-pdb
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-api.selectorLabels" $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.hpa" -}}
|
||||
{{- with .w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $.name }}
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $.name }}
|
||||
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $.name) .minReplicas }}
|
||||
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $.name) .maxReplicas }}
|
||||
{{- with .targetCPUUtilizationPercentage }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-api.deployment" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $v := $root.Values -}}
|
||||
{{- $w := .w -}}
|
||||
{{- $envFrom := concat ($v.envFrom | default list) ($w.envFrom | default list) -}}
|
||||
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($v.podAnnotations | default dict) -}}
|
||||
{{- $wProbes := $w.probes | default dict -}}
|
||||
{{- $gProbes := .probes | default dict -}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .name }}
|
||||
namespace: {{ $root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ if kindIs "invalid" $w.replicas }}1{{ else }}{{ int $w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ int $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-api.selectorLabels" . | nindent 6 }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" . | nindent 8 }}
|
||||
{{- with $podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ int $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.topologySpread" . | trim }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .name }}
|
||||
image: {{ include "fluxer-api.image" . }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($v.image | default dict).pullPolicy | default "IfNotPresent" }}
|
||||
{{- with .command }}
|
||||
command:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.env" . | trim }}
|
||||
env:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- with hasKey $wProbes $probe | ternary (get $wProbes $probe) (get $gProbes $probe) }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{- range $name, $w := .Values.api }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w "component" "api" "probes" ($.Values.probes | default dict) }}
|
||||
{{ include "fluxer-api.deployment" $ctx }}
|
||||
{{ include "fluxer-api.hpa" $ctx }}
|
||||
{{ include "fluxer-api.pdb" $ctx }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-api.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-api.selectorLabels" $ctx | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{- range $name, $w := .Values.workers }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w "component" "worker" "command" (list "node" "dist/WorkerEntrypoint.js") "probes" (dict) }}
|
||||
{{ include "fluxer-api.deployment" $ctx }}
|
||||
{{ include "fluxer-api.hpa" $ctx }}
|
||||
{{ include "fluxer-api.pdb" $ctx }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,86 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
FLUXER_ENV: production
|
||||
FLUXER_PUBLIC_ORIGIN: https://web.example.com
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_GATEWAY_ENDPOINT: wss://gateway.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: https://uploads.example.com
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
api:
|
||||
api:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2560Mi
|
||||
|
||||
workers:
|
||||
worker:
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2560Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-gateway
|
||||
description: A Helm chart for the Fluxer realtime gateway.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,280 @@
|
||||
{{- define "gateway.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.labels" -}}
|
||||
{{ include "gateway.selectorLabels" . }}
|
||||
{{- with .component }}
|
||||
app.kubernetes.io/component: {{ . }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.headlessName" -}}
|
||||
{{ printf "%s-headless" .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.pick" -}}
|
||||
{{- $v := get .root.Values .key }}
|
||||
{{- if hasKey .w .key }}
|
||||
{{- $v = get .w .key }}
|
||||
{{- end }}
|
||||
{{- with $v }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) }}
|
||||
{{- int64 . | toString }}
|
||||
{{- else }}
|
||||
{{- toString . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.envList" -}}
|
||||
{{- $env := deepCopy (.root.Values.env | default dict) }}
|
||||
{{- range $k, $v := .w.env | default dict }}
|
||||
{{- if kindIs "invalid" $v }}
|
||||
{{- $_ := unset $env $k }}
|
||||
{{- else }}
|
||||
{{- $_ := set $env $k $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := $env }}
|
||||
{{- if not (kindIs "invalid" $v) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "gateway.string" $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.envFrom" -}}
|
||||
{{- with concat (.root.Values.envFrom | default list) (.w.envFrom | default list) }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.podAnnotations" -}}
|
||||
{{- with merge (deepCopy (.w.podAnnotations | default dict)) (deepCopy (.root.Values.podAnnotations | default dict)) }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.probes" -}}
|
||||
{{- $global := .root.Values.probes | default dict }}
|
||||
{{- $own := .w.probes | default dict }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- $p := get $global $probe }}
|
||||
{{- if hasKey $own $probe }}
|
||||
{{- $p = get $own $probe }}
|
||||
{{- end }}
|
||||
{{- with $p }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.topologySpreadConstraints" -}}
|
||||
{{- $out := list }}
|
||||
{{- range include "gateway.pick" (dict "root" .root "w" .w "key" "topologySpreadConstraints") | fromYamlArray }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not (hasKey $c "labelSelector") }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "gateway.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
{{- $out = append $out $c }}
|
||||
{{- end }}
|
||||
{{- with $out }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.image" -}}
|
||||
{{- $img := .w.image | default dict }}
|
||||
{{- $v := .root.Values.image }}
|
||||
{{- $repo := $img.repository | default (printf "%s/%s" $v.registry ($img.name | default "fluxer-gateway")) }}
|
||||
{{- $ref := printf "%s:%s" $repo ($img.tag | default $v.tag) }}
|
||||
{{- with $img.digest }}
|
||||
{{- $ref = printf "%s@%s" $ref . }}
|
||||
{{- end }}
|
||||
{{- $ref | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.replicas" -}}
|
||||
{{- if kindIs "invalid" .w.replicas }}1{{ else }}{{ .w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.env" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w -}}
|
||||
{{- with $w.role }}
|
||||
- name: FLUXER_GATEWAY_ROLE
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "gateway.string" $w.buildVersion | quote }}
|
||||
{{- end }}
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
apiVersion: v1
|
||||
fieldPath: status.podIP
|
||||
- name: FLUXER_ERLANG_NODE_NAME
|
||||
value: fluxer_gateway@$(POD_IP)
|
||||
- name: FLUXER_ERLANG_DIST_PORT
|
||||
value: "8081"
|
||||
- name: FLUXER_GATEWAY_CLUSTER_ENABLED
|
||||
value: "true"
|
||||
- name: FLUXER_GATEWAY_CLUSTER_DISCOVERY_DNS_NAME
|
||||
value: {{ printf "%s.%s.svc.%s" (include "gateway.headlessName" $root) $root.Release.Namespace $root.Values.clusterDomain | quote }}
|
||||
- name: FLUXER_GATEWAY_CLUSTER_DISCOVERY_NODE_BASENAME
|
||||
value: fluxer_gateway
|
||||
{{- include "gateway.envList" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.pod" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w -}}
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "gateway.labels" . | nindent 4 }}
|
||||
{{- with include "gateway.podAnnotations" . }}
|
||||
annotations:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.topologySpreadConstraints" . }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: gateway
|
||||
image: {{ include "gateway.image" . }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default $root.Values.image.pullPolicy }}
|
||||
env:
|
||||
{{- include "gateway.env" . | trim | nindent 6 }}
|
||||
{{- with include "gateway.envFrom" . }}
|
||||
envFrom:
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: epmd
|
||||
containerPort: 4369
|
||||
protocol: TCP
|
||||
- name: erl-dist
|
||||
containerPort: 8081
|
||||
protocol: TCP
|
||||
{{- with include "gateway.probes" . | trim }}
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.pdb" -}}
|
||||
{{- with .w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $.name }}-pdb
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
{{- if not (kindIs "invalid" .minAvailable) }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" .maxUnavailable) }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gateway.selectorLabels" $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "gateway.hpa" -}}
|
||||
{{- with .w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $.name }}
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $.name }}
|
||||
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $.name) .minReplicas }}
|
||||
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $.name) .maxReplicas }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,48 @@
|
||||
{{- range $name, $w := .Values.deployments }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "component" $w.role "w" $w }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ include "gateway.replicas" $ctx }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gateway.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with include "gateway.pick" (dict "root" $ "w" $w "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "gateway.pod" $ctx | nindent 4 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "gateway.selectorLabels" $ctx | nindent 4 }}
|
||||
{{- include "gateway.hpa" $ctx }}
|
||||
{{- include "gateway.pdb" $ctx }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "gateway.headlessName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" (dict "root" . "name" "gateway" "component" "discovery") | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
- name: epmd
|
||||
port: 4369
|
||||
protocol: TCP
|
||||
targetPort: epmd
|
||||
- name: erl-dist
|
||||
port: 8081
|
||||
protocol: TCP
|
||||
targetPort: erl-dist
|
||||
selector:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
@@ -0,0 +1,53 @@
|
||||
{{- $np := .Values.networkPolicy | default dict }}
|
||||
{{- if $np.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: gateway
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" (dict "root" . "name" "gateway") | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
egress:
|
||||
- {}
|
||||
ingress:
|
||||
{{- with $np.ingressNamespace }}
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: {{ . }}
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- with $np.clients }}
|
||||
- from:
|
||||
{{- range . }}
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
- port: 4369
|
||||
protocol: TCP
|
||||
- port: 8081
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- range $name, $w := .Values.statefulsets }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "component" $w.role "w" $w }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "gateway.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ include "gateway.replicas" $ctx }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
serviceName: {{ include "gateway.headlessName" $ }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gateway.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with include "gateway.pick" (dict "root" $ "w" $w "key" "updateStrategy") }}
|
||||
updateStrategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "gateway.pod" $ctx | nindent 4 }}
|
||||
{{- include "gateway.pdb" $ctx }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,86 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
clusterDomain: cluster.local
|
||||
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_GATEWAY_PORT: "8080"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: https://media.example.com
|
||||
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
failureThreshold: 30
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
exec:
|
||||
command:
|
||||
- curl
|
||||
- -fsS
|
||||
- -o
|
||||
- /dev/null
|
||||
- --max-time
|
||||
- "2"
|
||||
- http://127.0.0.1:8080/_health/ready
|
||||
timeoutSeconds: 3
|
||||
|
||||
strategy: {}
|
||||
updateStrategy: {}
|
||||
|
||||
topologySpreadConstraints: []
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
ingressNamespace: ingress-nginx
|
||||
clients:
|
||||
- app.kubernetes.io/part-of: fluxer
|
||||
|
||||
deployments:
|
||||
gateway:
|
||||
role: all
|
||||
replicas: 1
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- curl -fsS -o /dev/null --max-time 2 http://127.0.0.1:8080/_health/drain; sleep 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 384Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
|
||||
statefulsets: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-infra
|
||||
description: NATS and Valkey for a Fluxer installation.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,282 @@
|
||||
{{- define "fluxer-infra.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.labels" -}}
|
||||
{{ include "fluxer-infra.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-infra.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.pick" -}}
|
||||
{{- $v := get .root.Values .key }}
|
||||
{{- if hasKey .w .key }}
|
||||
{{- $v = get .w .key }}
|
||||
{{- end }}
|
||||
{{- with $v }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) }}
|
||||
{{- int64 . | toString }}
|
||||
{{- else }}
|
||||
{{- toString . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.envList" -}}
|
||||
{{- $env := deepCopy (.root.Values.env | default dict) }}
|
||||
{{- range $k, $v := .w.env | default dict }}
|
||||
{{- if kindIs "invalid" $v }}
|
||||
{{- $_ := unset $env $k }}
|
||||
{{- else }}
|
||||
{{- $_ := set $env $k $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := $env }}
|
||||
{{- if not (kindIs "invalid" $v) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-infra.string" $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.envFrom" -}}
|
||||
{{- with concat (.root.Values.envFrom | default list) (.w.envFrom | default list) }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.probes" -}}
|
||||
{{- $global := .root.Values.probes | default dict }}
|
||||
{{- $own := .w.probes | default dict }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- $p := get $global $probe }}
|
||||
{{- if hasKey $own $probe }}
|
||||
{{- $p = get $own $probe }}
|
||||
{{- end }}
|
||||
{{- with $p }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.topologySpreadConstraints" -}}
|
||||
{{- $out := list }}
|
||||
{{- range include "fluxer-infra.pick" (dict "root" .root "w" .w "key" "topologySpreadConstraints") | fromYamlArray }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not (hasKey $c "labelSelector") }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-infra.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
{{- $out = append $out $c }}
|
||||
{{- end }}
|
||||
{{- with $out }}
|
||||
{{- toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.replicas" -}}
|
||||
{{- if kindIs "invalid" .w.replicas }}1{{ else }}{{ .w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.image" -}}
|
||||
{{- $ref := printf "%s:%s" .repository .tag }}
|
||||
{{- with .digest }}
|
||||
{{- $ref = printf "%s@%s" $ref . }}
|
||||
{{- end }}
|
||||
{{- $ref | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.podAnnotations" -}}
|
||||
{{- with merge (deepCopy (.extra | default dict)) (deepCopy (.w.podAnnotations | default dict)) (deepCopy (.root.Values.podAnnotations | default dict)) }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.podSpec" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.topologySpreadConstraints" . }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.containerCommon" -}}
|
||||
{{- $root := .root }}
|
||||
{{- $w := .w }}
|
||||
{{- $img := $w.image | default dict }}
|
||||
image: {{ include "fluxer-infra.image" $img }}
|
||||
imagePullPolicy: {{ $img.pullPolicy }}
|
||||
{{- $env := include "fluxer-infra.envList" . | trim }}
|
||||
{{- if or .env $env }}
|
||||
env:
|
||||
{{- with .env }}
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with $env }}
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.envFrom" . }}
|
||||
envFrom:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- include "fluxer-infra.probes" . }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- with concat .mounts ($w.extraVolumeMounts | default list) }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.statefulSetSpec" -}}
|
||||
{{- $w := .w }}
|
||||
{{- with include "fluxer-infra.pick" (dict "root" .root "w" $w "key" "updateStrategy") }}
|
||||
updateStrategy:
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.volumeClaim" -}}
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .size }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.pdb" -}}
|
||||
{{- with .w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $.name }}-pdb
|
||||
namespace: {{ $.root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
{{- if not (kindIs "invalid" .minAvailable) }}
|
||||
minAvailable: {{ .minAvailable }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" .maxUnavailable) }}
|
||||
maxUnavailable: {{ .maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-infra.selectorLabels" $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.service" }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .svcName }}
|
||||
namespace: {{ .root.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if .headless }}
|
||||
clusterIP: None
|
||||
{{- end }}
|
||||
{{- if .publishNotReady }}
|
||||
publishNotReadyAddresses: true
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "fluxer-infra.selectorLabels" . | nindent 4 }}
|
||||
ports:
|
||||
{{- range .ports }}
|
||||
- name: {{ index . 0 }}
|
||||
port: {{ index . 1 }}
|
||||
targetPort: {{ index . 0 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-infra.natsConf" -}}
|
||||
{{- $w := .Values.nats -}}
|
||||
{{- with $w.config -}}
|
||||
listen: 0.0.0.0:4222
|
||||
http: 0.0.0.0:8222
|
||||
max_payload: {{ .maxPayload }}
|
||||
max_pending: {{ .maxPending }}
|
||||
max_connections: {{ .maxConnections }}
|
||||
{{- if $w.jetstream.enabled }}
|
||||
server_name: $POD_NAME
|
||||
|
||||
jetstream {
|
||||
store_dir: /data
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
cluster {
|
||||
name: {{ .clusterName }}
|
||||
listen: 0.0.0.0:6222
|
||||
|
||||
routes = [
|
||||
{{- range $i := until (int (include "fluxer-infra.replicas" (dict "w" $w))) }}
|
||||
nats-route://nats-{{ $i }}.nats-headless.{{ $.Release.Namespace }}.svc.{{ $.Values.clusterDomain }}:6222
|
||||
{{- end }}
|
||||
]
|
||||
}
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{- with .Values.nats }}
|
||||
{{- $ctx := dict "root" $ "w" . "name" "nats" "component" "messaging" }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: nats-config
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
|
||||
data:
|
||||
nats.conf: {{ include "fluxer-infra.natsConf" $ | toJson }}
|
||||
{{- include "fluxer-infra.pdb" $ctx }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "nats" "ports" (list (list "client" 4222))) $ctx) }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "nats-headless" "headless" true "ports" (list (list "client" 4222) (list "cluster" 6222) (list "monitor" 8222))) $ctx) }}
|
||||
{{- $mounts := list (dict "name" "config" "mountPath" "/etc/nats") }}
|
||||
{{- $env := list }}
|
||||
{{- if .jetstream.enabled }}
|
||||
{{- $mounts = append $mounts (dict "name" "data" "mountPath" "/data") }}
|
||||
{{- $env = append $env (dict "name" "POD_NAME" "valueFrom" (dict "fieldRef" (dict "fieldPath" "metadata.name"))) }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: nats
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ include "fluxer-infra.replicas" $ctx }}
|
||||
serviceName: nats-headless
|
||||
{{- with include "fluxer-infra.statefulSetSpec" $ctx | trim }}
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-infra.selectorLabels" $ctx | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 8 }}
|
||||
{{- with include "fluxer-infra.podAnnotations" (merge (dict "extra" (dict "checksum/config" (include "fluxer-infra.natsConf" $ | sha256sum))) $ctx) | trim }}
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- include "fluxer-infra.podSpec" $ctx | trim | nindent 6 }}
|
||||
containers:
|
||||
- name: nats
|
||||
{{- include "fluxer-infra.containerCommon" (merge (dict "env" $env "mounts" $mounts) $ctx) | trim | nindent 10 }}
|
||||
args:
|
||||
- -c
|
||||
- /etc/nats/nats.conf
|
||||
ports:
|
||||
- name: client
|
||||
containerPort: 4222
|
||||
- name: cluster
|
||||
containerPort: 6222
|
||||
- name: monitor
|
||||
containerPort: 8222
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: nats-config
|
||||
{{- with .extraVolumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .jetstream.enabled }}
|
||||
volumeClaimTemplates:
|
||||
{{- include "fluxer-infra.volumeClaim" .jetstream.storage | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,67 @@
|
||||
{{- with .Values.valkey }}
|
||||
{{- $ctx := dict "root" $ "w" . "name" "valkey" "component" "cache" }}
|
||||
{{- include "fluxer-infra.pdb" $ctx }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "valkey" "ports" (list (list "valkey" 6379))) $ctx) }}
|
||||
{{- include "fluxer-infra.service" (merge (dict "svcName" "valkey-headless" "headless" true "publishNotReady" true "ports" (list (list "valkey" 6379))) $ctx) }}
|
||||
{{- $mounts := list }}
|
||||
{{- if .persistence.enabled }}
|
||||
{{- $mounts = append $mounts (dict "name" "data" "mountPath" "/data") }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: valkey
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
serviceName: valkey-headless
|
||||
{{- with include "fluxer-infra.statefulSetSpec" $ctx | trim }}
|
||||
{{- . | nindent 2 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-infra.selectorLabels" $ctx | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-infra.labels" $ctx | nindent 8 }}
|
||||
{{- with include "fluxer-infra.podAnnotations" $ctx | trim }}
|
||||
{{- . | nindent 6 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- include "fluxer-infra.podSpec" $ctx | trim | nindent 6 }}
|
||||
containers:
|
||||
- name: valkey
|
||||
{{- include "fluxer-infra.containerCommon" (merge (dict "env" list "mounts" $mounts) $ctx) | trim | nindent 10 }}
|
||||
command:
|
||||
- valkey-server
|
||||
{{- if .persistence.enabled }}
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --dir
|
||||
- /data
|
||||
{{- else }}
|
||||
- --save
|
||||
- ""
|
||||
- --appendonly
|
||||
- "no"
|
||||
{{- end }}
|
||||
- --maxmemory
|
||||
- {{ .maxmemory | quote }}
|
||||
- --maxmemory-policy
|
||||
- {{ .maxmemoryPolicy | quote }}
|
||||
ports:
|
||||
- name: valkey
|
||||
containerPort: 6379
|
||||
{{- with .extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
{{- include "fluxer-infra.volumeClaim" .persistence | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,108 @@
|
||||
imagePullSecrets: []
|
||||
|
||||
clusterDomain: cluster.local
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom: []
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes: {}
|
||||
|
||||
updateStrategy: {}
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
nats:
|
||||
image:
|
||||
repository: nats
|
||||
tag: 2.14-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
replicas: 3
|
||||
config:
|
||||
clusterName: nats
|
||||
maxPayload: 1MB
|
||||
maxPending: 64MB
|
||||
maxConnections: 65536
|
||||
jetstream:
|
||||
enabled: true
|
||||
storage:
|
||||
size: 10Gi
|
||||
storageClassName: ""
|
||||
podSecurityContext:
|
||||
fsGroup: 65534
|
||||
runAsGroup: 65534
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: monitor
|
||||
initialDelaySeconds: 10
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /healthz?js-enabled-only=true
|
||||
port: monitor
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
|
||||
valkey:
|
||||
image:
|
||||
repository: valkey/valkey
|
||||
tag: 9.1-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
maxmemory: 192mb
|
||||
maxmemoryPolicy: noeviction
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
podSecurityContext:
|
||||
fsGroup: 999
|
||||
runAsGroup: 999
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
probes:
|
||||
liveness:
|
||||
exec:
|
||||
command:
|
||||
- valkey-cli
|
||||
- ping
|
||||
initialDelaySeconds: 10
|
||||
readiness:
|
||||
exec:
|
||||
command:
|
||||
- valkey-cli
|
||||
- ping
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-ingress
|
||||
description: Ingress routing for the public Fluxer endpoints.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- define "fluxer-ingress.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-ingress.labels" -}}
|
||||
app.kubernetes.io/name: {{ .Chart.Name }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-ingress.chart" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-ingress.annotationKey" -}}
|
||||
{{- if or (contains "/" .key) (not .prefix) -}}
|
||||
{{- .key -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s/%s" .prefix .key -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-ingress.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- . | int64 | toString -}}
|
||||
{{- else -}}
|
||||
{{- . | toString -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- with .Values.clusterIssuer }}
|
||||
{{- if .enabled }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: {{ required "clusterIssuer.name is required" .name }}
|
||||
labels:
|
||||
{{- include "fluxer-ingress.labels" $ | nindent 4 }}
|
||||
spec:
|
||||
acme:
|
||||
email: {{ required "clusterIssuer.email is required" .email | quote }}
|
||||
privateKeySecretRef:
|
||||
name: {{ required "clusterIssuer.privateKeySecretName is required" .privateKeySecretName }}
|
||||
server: {{ required "clusterIssuer.server is required" .server }}
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: {{ required "clusterIssuer.solverIngressClass is required" .solverIngressClass }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{- $v := .Values }}
|
||||
{{- $presets := $v.annotationPresets | default dict }}
|
||||
{{- $issuer := $v.clusterIssuer | default dict }}
|
||||
{{- range $name, $spec := ($v.ingresses | default dict) }}
|
||||
{{- if not (kindIs "invalid" $spec) }}
|
||||
{{- $ann := deepCopy ($v.commonAnnotations | default dict) }}
|
||||
{{- range ($spec.presets | default list) }}
|
||||
{{- $ann = mergeOverwrite $ann (deepCopy (required (printf "unknown annotation preset %s" .) (index $presets .))) }}
|
||||
{{- end }}
|
||||
{{- if and $spec.tls $issuer.enabled }}
|
||||
{{- $_ := set $ann "cert-manager.io/cluster-issuer" (required "clusterIssuer.name is required" $issuer.name) }}
|
||||
{{- end }}
|
||||
{{- $ann = mergeOverwrite $ann (deepCopy ($spec.annotations | default dict)) }}
|
||||
{{- range $k, $val := $ann }}
|
||||
{{- if kindIs "invalid" $val }}
|
||||
{{- $_ := unset $ann $k }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-ingress.labels" $ | nindent 4 }}
|
||||
{{- with $ann }}
|
||||
annotations:
|
||||
{{- range $k, $val := . }}
|
||||
{{ include "fluxer-ingress.annotationKey" (dict "key" $k "prefix" $v.annotationPrefix) }}: {{ include "fluxer-ingress.string" $val | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with $spec.ingressClassName | default $v.ingressClassName }}
|
||||
ingressClassName: {{ . }}
|
||||
{{- end }}
|
||||
{{- with $spec.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range $rule := required (printf "ingress %s needs rules" $name) $spec.rules }}
|
||||
- host: {{ required (printf "ingress %s has a rule without a host" $name) $rule.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range $p := $rule.paths | default (list dict) }}
|
||||
{{- $p = $p | default dict }}
|
||||
- path: {{ $p.path | default "/" | quote }}
|
||||
pathType: {{ $p.pathType | default "Prefix" }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ required (printf "ingress %s host %s needs a service" $name $rule.host) ($p.service | default $rule.service) }}
|
||||
port:
|
||||
number: {{ required (printf "ingress %s host %s needs a port or servicePort" $name $rule.host) ($p.port | default $rule.port | default $v.servicePort) | int64 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,53 @@
|
||||
ingressClassName: nginx
|
||||
annotationPrefix: nginx.ingress.kubernetes.io
|
||||
servicePort: 8080
|
||||
|
||||
commonAnnotations: {}
|
||||
|
||||
annotationPresets:
|
||||
websocket:
|
||||
proxy-read-timeout: "3600"
|
||||
proxy-send-timeout: "3600"
|
||||
stripPrefix:
|
||||
use-regex: "true"
|
||||
rewrite-target: /$2
|
||||
|
||||
ingresses:
|
||||
fluxer:
|
||||
rules:
|
||||
- host: web.example.com
|
||||
service: app-proxy
|
||||
- host: api.example.com
|
||||
service: api
|
||||
- host: admin.example.com
|
||||
service: admin
|
||||
- host: media.example.com
|
||||
service: media-proxy
|
||||
fluxer-web-api:
|
||||
presets: [stripPrefix]
|
||||
rules:
|
||||
- host: web.example.com
|
||||
service: api
|
||||
paths:
|
||||
- path: /api(/(.*))?$
|
||||
pathType: ImplementationSpecific
|
||||
fluxer-gateway:
|
||||
presets: [websocket]
|
||||
rules:
|
||||
- host: gateway.example.com
|
||||
service: gateway
|
||||
fluxer-uploads:
|
||||
annotations:
|
||||
proxy-body-size: 100m
|
||||
proxy-request-buffering: "off"
|
||||
rules:
|
||||
- host: uploads.example.com
|
||||
service: uploads
|
||||
|
||||
clusterIssuer:
|
||||
enabled: false
|
||||
name: letsencrypt
|
||||
email: ""
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
privateKeySecretName: letsencrypt-account-key
|
||||
solverIngressClass: nginx
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-media-proxy
|
||||
description: Fluxer media proxy and upload relay workloads.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,87 @@
|
||||
{{- define "fluxer-media-proxy.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.labels" -}}
|
||||
{{ include "fluxer-media-proxy.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ include "fluxer-media-proxy.mode" . }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-media-proxy.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.image" -}}
|
||||
{{- $g := .root.Values.image -}}
|
||||
{{- $i := .w.image | default dict -}}
|
||||
{{- $repo := $i.repository | default (printf "%s/%s" $g.registry ($i.name | default "fluxer-media-proxy")) -}}
|
||||
{{- $tag := $i.tag | default $g.tag -}}
|
||||
{{- if $i.digest -}}
|
||||
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" $repo $tag | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.pick" -}}
|
||||
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
|
||||
{{- if $v }}
|
||||
{{- toYaml $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.mode" -}}
|
||||
{{- $mode := required (printf "workloads.%s.mode is required" .name) .w.mode -}}
|
||||
{{- if not (has $mode (list "mp" "static" "upload" "relay")) -}}
|
||||
{{- fail (printf "workloads.%s.mode must be mp, static, upload or relay" .name) -}}
|
||||
{{- end -}}
|
||||
{{- $mode -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.envValue" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) -}}
|
||||
{{- int64 . | toString -}}
|
||||
{{- else -}}
|
||||
{{- toString . -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.mergeEnv" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $layer := . -}}
|
||||
{{- range $k, $v := ($layer | default dict) -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $_ := unset $out $k -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $k $v -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.topologySpreadConstraints" -}}
|
||||
{{- $out := list -}}
|
||||
{{- range .constraints -}}
|
||||
{{- if .labelSelector -}}
|
||||
{{- $out = append $out . -}}
|
||||
{{- else -}}
|
||||
{{- $out = append $out (merge (dict "labelSelector" (dict "matchLabels" $.selector)) .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-media-proxy.pdb" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := list "minAvailable" "maxUnavailable" -}}
|
||||
{{- if and (hasKey $ $k) (not (kindIs "invalid" (index $ $k))) -}}
|
||||
{{- $_ := set $out $k (index $ $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,191 @@
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w }}
|
||||
{{- $mode := include "fluxer-media-proxy.mode" $ctx }}
|
||||
{{- $sel := include "fluxer-media-proxy.selectorLabels" $ctx | fromYaml }}
|
||||
{{- $env := include "fluxer-media-proxy.mergeEnv" (list $.Values.env $w.env) | fromYaml }}
|
||||
{{- $extraEnv := concat ($.Values.extraEnv | default list) ($w.extraEnv | default list) }}
|
||||
{{- $envFrom := concat ($.Values.envFrom | default list) ($w.envFrom | default list) }}
|
||||
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($.Values.podAnnotations | default dict) }}
|
||||
{{- $probes := dict }}
|
||||
{{- range $k, $v := ($.Values.probes | default dict) }}
|
||||
{{- $_ := set $probes $k $v }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := ($w.probes | default dict) }}
|
||||
{{- $_ := set $probes $k $v }}
|
||||
{{- end }}
|
||||
{{- $pick := dict "root" $ "w" $w }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ ternary $w.replicas 1 (hasKey $w "replicas") | int64 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ $w.minReadySeconds | int64 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- toYaml $sel | nindent 6 }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with $podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 8 }}
|
||||
spec:
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds | int64 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "topologySpreadConstraints") | fromYamlArray }}
|
||||
topologySpreadConstraints:
|
||||
{{- include "fluxer-media-proxy.topologySpreadConstraints" (dict "constraints" . "selector" $sel) | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "fluxer-media-proxy.image" $ctx }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
|
||||
env:
|
||||
{{- if not (kindIs "invalid" $w.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-media-proxy.envValue" $w.buildVersion | quote }}
|
||||
{{- end }}
|
||||
- name: FLUXER_MEDIA_PROXY_MODE
|
||||
value: {{ $mode | quote }}
|
||||
{{- range $k, $v := $env }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-media-proxy.envValue" $v | quote }}
|
||||
{{- end }}
|
||||
{{- with $extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- range $k := list "startup" "liveness" "readiness" }}
|
||||
{{- with get $probes $k }}
|
||||
{{ $k }}Probe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- toYaml $sel | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
{{- with include "fluxer-media-proxy.pdb" ($w.pdb | default dict) | fromYaml }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- toYaml $sel | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $name }}
|
||||
minReplicas: {{ required (printf "workloads.%s.hpa.minReplicas is required" $name) .minReplicas | int64 }}
|
||||
maxReplicas: {{ required (printf "workloads.%s.hpa.maxReplicas is required" $name) .maxReplicas | int64 }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage | int64 }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,72 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
workloads:
|
||||
media-proxy:
|
||||
mode: mp
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
|
||||
uploads:
|
||||
mode: relay
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-push
|
||||
description: Fluxer push notification delivery service
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,71 @@
|
||||
{{- define "fluxer-push.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.labels" -}}
|
||||
{{ include "fluxer-push.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ include "fluxer-push.mode" . }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.mode" -}}
|
||||
{{- $mode := .w.mode | default "delivery" -}}
|
||||
{{- if not (has $mode (list "delivery" "relay")) -}}
|
||||
{{- fail (printf "workloads.%s.mode must be delivery or relay" .name) -}}
|
||||
{{- end -}}
|
||||
{{- $mode -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.port" -}}
|
||||
{{- .w.port | default (ternary 8127 8126 (eq (include "fluxer-push.mode" .) "relay")) -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.image" -}}
|
||||
{{- $global := .root.Values.image | default dict -}}
|
||||
{{- $img := .w.image | default dict -}}
|
||||
{{- $repo := $img.repository -}}
|
||||
{{- if not $repo -}}
|
||||
{{- $repo = printf "%s/%s" (required "image.registry is required" $global.registry) ($img.name | default "fluxer-push") -}}
|
||||
{{- end -}}
|
||||
{{- $ref := printf "%s:%s" $repo (include "fluxer-push.string" (required "image.tag is required" ($img.tag | default $global.tag))) -}}
|
||||
{{- with $img.digest }}{{ $ref = printf "%s@%s" $ref . }}{{ end -}}
|
||||
{{- $ref -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.string" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- . | int64 | toString -}}
|
||||
{{- else -}}
|
||||
{{- . | toString -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-push.env" -}}
|
||||
{{- $env := deepCopy (.root.Values.env | default dict) -}}
|
||||
{{- range $k, $v := (.w.env | default dict) -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $_ := unset $env $k -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $env $k $v -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "invalid" .w.port) -}}
|
||||
{{- $_ := set $env "FLUXER_PUSH_SERVICE_PORT" .w.port -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "invalid" .w.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-push.string" .w.buildVersion | quote }}
|
||||
{{- end }}
|
||||
{{- range $k, $v := $env }}
|
||||
{{- if not (kindIs "invalid" $v) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-push.string" $v | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,205 @@
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w }}
|
||||
{{- $mode := include "fluxer-push.mode" $ctx }}
|
||||
{{- $port := include "fluxer-push.port" $ctx | int }}
|
||||
{{- $globalProbes := $.Values.probes | default dict }}
|
||||
{{- $workloadProbes := $w.probes | default dict }}
|
||||
{{- $probes := dict }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- $_ := set $probes $probe (ternary (index $workloadProbes $probe) (index $globalProbes $probe) (hasKey $workloadProbes $probe)) }}
|
||||
{{- end }}
|
||||
{{- $annotations := mergeOverwrite (deepCopy ($.Values.podAnnotations | default dict)) (deepCopy ($w.podAnnotations | default dict)) }}
|
||||
{{- $pullSecrets := ternary $w.imagePullSecrets $.Values.imagePullSecrets (hasKey $w "imagePullSecrets") }}
|
||||
{{- $podSecurityContext := ternary $w.podSecurityContext $.Values.podSecurityContext (hasKey $w "podSecurityContext") }}
|
||||
{{- $securityContext := ternary $w.securityContext $.Values.securityContext (hasKey $w "securityContext") }}
|
||||
{{- $strategy := ternary $w.strategy $.Values.strategy (hasKey $w "strategy") }}
|
||||
{{- $tsc := ternary $w.topologySpreadConstraints $.Values.topologySpreadConstraints (hasKey $w "topologySpreadConstraints") }}
|
||||
{{- $nodeSelector := ternary $w.nodeSelector $.Values.nodeSelector (hasKey $w "nodeSelector") }}
|
||||
{{- $tolerations := ternary $w.tolerations $.Values.tolerations (hasKey $w "tolerations") }}
|
||||
{{- $affinity := ternary $w.affinity $.Values.affinity (hasKey $w "affinity") }}
|
||||
{{- $envFrom := concat ($.Values.envFrom | default list) ($w.envFrom | default list) }}
|
||||
{{- $env := include "fluxer-push.env" $ctx }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ ternary $w.replicas 1 (hasKey $w "replicas") | int }}
|
||||
{{- end }}
|
||||
{{- if hasKey $w "minReadySeconds" }}
|
||||
minReadySeconds: {{ $w.minReadySeconds | int }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-push.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with $strategy }}
|
||||
strategy:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with $annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 8 }}
|
||||
spec:
|
||||
{{- with $pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if hasKey $w "terminationGracePeriodSeconds" }}
|
||||
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds | int }}
|
||||
{{- end }}
|
||||
{{- with $nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $tsc }}
|
||||
topologySpreadConstraints:
|
||||
{{- range . }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not $c.labelSelector }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-push.selectorLabels" $ctx | fromYaml)) }}
|
||||
{{- end }}
|
||||
{{- toYaml (list $c) | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "fluxer-push.image" $ctx | quote }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($.Values.image | default dict).pullPolicy | default "IfNotPresent" }}
|
||||
command:
|
||||
- /usr/local/bin/fluxer-push
|
||||
{{- if eq $mode "relay" }}
|
||||
args:
|
||||
- --mode
|
||||
- relay
|
||||
{{- end }}
|
||||
{{- with trim $env }}
|
||||
env:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $port }}
|
||||
protocol: TCP
|
||||
{{- with $probes.startup }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $probes.liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $probes.readiness }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-push.selectorLabels" $ctx | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $port }}
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
{{- with $w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-push.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- with $w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $name }}
|
||||
minReplicas: {{ required (printf "workloads.%s.hpa.minReplicas is required" $name) .minReplicas | int }}
|
||||
maxReplicas: {{ required (printf "workloads.%s.hpa.maxReplicas is required" $name) .maxReplicas | int }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage | int }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,65 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
workloads:
|
||||
push:
|
||||
mode: delivery
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-svc
|
||||
description: Fluxer internal services, each a router Deployment and a shard StatefulSet
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: v1
|
||||
@@ -0,0 +1,203 @@
|
||||
{{- define "fluxer-svc.chart" -}}
|
||||
{{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.labels" -}}
|
||||
{{ include "fluxer-svc.selectorLabels" . }}
|
||||
app.kubernetes.io/component: {{ .mode }}
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-svc.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.envValue" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) -}}
|
||||
{{- int64 . | toString -}}
|
||||
{{- else -}}
|
||||
{{- toString . -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.mergeEnv" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $layer := . -}}
|
||||
{{- range $k, $v := ($layer | default dict) -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $_ := unset $out $k -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $k $v -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.topologySpreadConstraints" -}}
|
||||
{{- $out := list -}}
|
||||
{{- range .constraints -}}
|
||||
{{- if .labelSelector -}}
|
||||
{{- $out = append $out . -}}
|
||||
{{- else -}}
|
||||
{{- $out = append $out (merge (dict "labelSelector" (dict "matchLabels" $.selector)) .) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.pdb" -}}
|
||||
{{- $out := dict -}}
|
||||
{{- range $k := list "minAvailable" "maxUnavailable" -}}
|
||||
{{- if and (hasKey $ $k) (not (kindIs "invalid" (index $ $k))) -}}
|
||||
{{- $_ := set $out $k (index $ $k) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.config" -}}
|
||||
{{- $v := .root.Values -}}
|
||||
{{- $levels := list (index $v .mode) (index .svc .mode) -}}
|
||||
{{- $c := dict "extraEnv" ($v.extraEnv | default list) "envFrom" ($v.envFrom | default list) "podAnnotations" (deepCopy ($v.podAnnotations | default dict)) "probes" (deepCopy ($v.probes | default dict)) "image" (deepCopy (.svc.image | default dict)) -}}
|
||||
{{- range $k := list "imagePullSecrets" "podSecurityContext" "securityContext" "topologySpreadConstraints" "nodeSelector" "tolerations" "affinity" (ternary "updateStrategy" "strategy" (eq .mode "shard")) -}}
|
||||
{{- $_ := set $c $k (index $v $k) -}}
|
||||
{{- end -}}
|
||||
{{- $envLayers := list $v.env -}}
|
||||
{{- range $level := $levels -}}
|
||||
{{- range $k, $x := ($level | default dict) -}}
|
||||
{{- if eq $k "env" -}}
|
||||
{{- $envLayers = append $envLayers $x -}}
|
||||
{{- else if has $k (list "podAnnotations" "image") -}}
|
||||
{{- $_ := set $c $k (mergeOverwrite (index $c $k) (deepCopy ($x | default dict))) -}}
|
||||
{{- else if has $k (list "extraEnv" "envFrom") -}}
|
||||
{{- $_ := set $c $k (concat (index $c $k) ($x | default list)) -}}
|
||||
{{- else if eq $k "probes" -}}
|
||||
{{- range $name, $p := ($x | default dict) -}}
|
||||
{{- $_ := set $c.probes $name $p -}}
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $c $k $x -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $_ := set $c "env" (include "fluxer-svc.mergeEnv" $envLayers | fromYaml) -}}
|
||||
{{- toYaml $c }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.image" -}}
|
||||
{{- $g := .root.Values.image -}}
|
||||
{{- $i := .c.image -}}
|
||||
{{- $repo := $i.repository | default (printf "%s/%s" $g.registry ($i.name | default (printf "fluxer-%s" .service))) -}}
|
||||
{{- $ref := printf "%s:%s" $repo ($i.tag | default $g.tag) -}}
|
||||
{{- with $i.digest }}{{ $ref = printf "%s@%s" $ref . }}{{ end -}}
|
||||
{{- $ref -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-svc.pod" -}}
|
||||
{{- $v := .root.Values -}}
|
||||
{{- $c := .c -}}
|
||||
metadata:
|
||||
{{- with $c.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- with $c.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $c.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ $c.terminationGracePeriodSeconds | int64 }}
|
||||
{{- end }}
|
||||
{{- with $c.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with $c.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- include "fluxer-svc.topologySpreadConstraints" (dict "constraints" . "selector" (include "fluxer-svc.selectorLabels" $ | fromYaml)) | nindent 4 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .mode }}
|
||||
image: {{ include "fluxer-svc.image" . | quote }}
|
||||
imagePullPolicy: {{ $c.image.pullPolicy | default $v.image.pullPolicy }}
|
||||
env:
|
||||
- name: FLUXER_SVC_MODE
|
||||
value: {{ .mode | quote }}
|
||||
- name: FLUXER_SVC_NAME
|
||||
value: {{ .service | quote }}
|
||||
- name: FLUXER_SVC_SHARD_COUNT
|
||||
value: {{ .shardCount | quote }}
|
||||
- name: FLUXER_SVC_PORT
|
||||
value: {{ include "fluxer-svc.envValue" $v.port | quote }}
|
||||
{{- if not (kindIs "invalid" $c.buildVersion) }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-svc.envValue" $c.buildVersion | quote }}
|
||||
{{- end }}
|
||||
{{- if eq .mode "shard" }}
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
apiVersion: v1
|
||||
fieldPath: metadata.name
|
||||
{{- end }}
|
||||
{{- range $name, $value := $c.env }}
|
||||
- name: {{ $name }}
|
||||
value: {{ include "fluxer-svc.envValue" $value | quote }}
|
||||
{{- end }}
|
||||
{{- with $c.extraEnv }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $v.port }}
|
||||
protocol: TCP
|
||||
{{- with $c.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- range $name := list "startup" "liveness" "readiness" }}
|
||||
{{- with index $c.probes $name }}
|
||||
{{ $name }}Probe:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $c.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $c.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,145 @@
|
||||
{{- range $service, $svc := .Values.services }}
|
||||
{{- if not (kindIs "invalid" $svc) }}
|
||||
{{- $svc = $svc | default dict }}
|
||||
{{- $rc := fromYaml (include "fluxer-svc.config" (dict "root" $ "svc" $svc "mode" "router")) }}
|
||||
{{- $sc := fromYaml (include "fluxer-svc.config" (dict "root" $ "svc" $svc "mode" "shard")) }}
|
||||
{{- $routerReplicas := ternary $rc.replicas 1 (hasKey $rc "replicas") | int64 }}
|
||||
{{- $shardCount := ternary $sc.replicas 1 (hasKey $sc "replicas") | int64 }}
|
||||
{{- if lt $shardCount 1 }}
|
||||
{{- fail (printf "services.%s shard replicas must be at least 1" $service) }}
|
||||
{{- end }}
|
||||
{{- $router := dict "root" $ "service" $service "svc" $svc "mode" "router" "name" $service "c" $rc "shardCount" (toString $shardCount) }}
|
||||
{{- $shard := dict "root" $ "service" $service "svc" $svc "mode" "shard" "name" (printf "%s-shard" $service) "c" $sc "shardCount" (toString $shardCount) }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $service }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $router | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $rc.hpa }}
|
||||
replicas: {{ $routerReplicas }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $rc.minReadySeconds) }}
|
||||
minReadySeconds: {{ $rc.minReadySeconds | int64 }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-svc.selectorLabels" $router | nindent 6 }}
|
||||
{{- with $rc.strategy }}
|
||||
strategy:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "fluxer-svc.pod" $router | nindent 4 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ $service }}-shard
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $shard | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ $shardCount }}
|
||||
{{- if not (kindIs "invalid" $sc.minReadySeconds) }}
|
||||
minReadySeconds: {{ $sc.minReadySeconds | int64 }}
|
||||
{{- end }}
|
||||
podManagementPolicy: Parallel
|
||||
serviceName: {{ $service }}-shard-headless
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-svc.selectorLabels" $shard | nindent 6 }}
|
||||
{{- with $sc.updateStrategy }}
|
||||
updateStrategy:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
{{- include "fluxer-svc.pod" $shard | nindent 4 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $service }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $router | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-svc.selectorLabels" $router | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $.Values.port }}
|
||||
targetPort: {{ $.Values.port }}
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $service }}-shard-headless
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $shard | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
publishNotReadyAddresses: true
|
||||
selector:
|
||||
{{- include "fluxer-svc.selectorLabels" $shard | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $.Values.port }}
|
||||
targetPort: {{ $.Values.port }}
|
||||
protocol: TCP
|
||||
{{- with $rc.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $service }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $router | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $service }}
|
||||
minReplicas: {{ required (printf "services.%s router hpa.minReplicas is required" $service) .minReplicas | int64 }}
|
||||
maxReplicas: {{ required (printf "services.%s router hpa.maxReplicas is required" $service) .maxReplicas | int64 }}
|
||||
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .targetCPUUtilizationPercentage | int64 }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range $ctx := list $router $shard }}
|
||||
{{- with include "fluxer-svc.pdb" ($ctx.c.pdb | default dict) | fromYaml }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $ctx.name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-svc.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-svc.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,89 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env:
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
|
||||
topologySpreadConstraints: []
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
port: 8090
|
||||
|
||||
router:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 192Mi
|
||||
|
||||
shard:
|
||||
replicas: 2
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_healthz
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
|
||||
services:
|
||||
gifs:
|
||||
shard:
|
||||
env:
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://media.example.com
|
||||
messages: {}
|
||||
snowflakes: {}
|
||||
unfurl:
|
||||
shard:
|
||||
env:
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
users: {}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: fluxer-web
|
||||
description: Fluxer web app proxy and admin dashboard.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "v1"
|
||||
@@ -0,0 +1,80 @@
|
||||
{{- define "fluxer-web.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ .name }}
|
||||
app.kubernetes.io/instance: {{ .root.Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.labels" -}}
|
||||
{{ include "fluxer-web.selectorLabels" . }}
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: fluxer
|
||||
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
|
||||
helm.sh/chart: {{ include "fluxer-web.chart" .root }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.image" -}}
|
||||
{{- $g := .root.Values.image | default dict -}}
|
||||
{{- $i := .w.image | default dict -}}
|
||||
{{- $repo := $i.repository -}}
|
||||
{{- if not $repo -}}
|
||||
{{- $repo = printf "%s/%s" (required "image.registry is required" $g.registry) ($i.name | default (printf "fluxer-%s" .name)) -}}
|
||||
{{- end -}}
|
||||
{{- $tag := required "image.tag is required" ($i.tag | default $g.tag) -}}
|
||||
{{- if $i.digest -}}
|
||||
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" $repo $tag | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.pick" -}}
|
||||
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
|
||||
{{- if $v }}
|
||||
{{- toYaml $v }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.str" -}}
|
||||
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
|
||||
{{- int64 . | toString | quote -}}
|
||||
{{- else -}}
|
||||
{{- toString . | quote -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.env" -}}
|
||||
{{- $env := dict -}}
|
||||
{{- range $k, $val := .root.Values.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := .w.env | default dict }}
|
||||
{{- $_ := set $env $k $val }}
|
||||
{{- end }}
|
||||
{{- range $k, $val := $env }}
|
||||
{{- if not (kindIs "invalid" $val) }}
|
||||
- name: {{ $k }}
|
||||
value: {{ include "fluxer-web.str" $val }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .w.buildVersion }}
|
||||
- name: BUILD_VERSION
|
||||
value: {{ include "fluxer-web.str" . }}
|
||||
{{- end }}
|
||||
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
|
||||
{{ toYaml . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "fluxer-web.topologySpread" -}}
|
||||
{{- $tscs := ternary .w.topologySpreadConstraints .root.Values.topologySpreadConstraints (hasKey .w "topologySpreadConstraints") -}}
|
||||
{{- range $tscs }}
|
||||
{{- $c := deepCopy . }}
|
||||
{{- if not $c.labelSelector }}
|
||||
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-web.selectorLabels" $ | fromYaml)) }}
|
||||
{{- end }}
|
||||
- {{- toYaml $c | nindent 2 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,172 @@
|
||||
{{- $v := .Values }}
|
||||
{{- range $name, $w := .Values.workloads }}
|
||||
{{- if not (kindIs "invalid" $w) }}
|
||||
{{- $ctx := dict "root" $ "name" $name "w" $w }}
|
||||
{{- $envFrom := concat ($v.envFrom | default list) ($w.envFrom | default list) }}
|
||||
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($v.podAnnotations | default dict) }}
|
||||
{{- $wProbes := $w.probes | default dict }}
|
||||
{{- $gProbes := $v.probes | default dict }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- if not $w.hpa }}
|
||||
replicas: {{ if kindIs "invalid" $w.replicas }}1{{ else }}{{ int $w.replicas }}{{ end }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
|
||||
minReadySeconds: {{ int $w.minReadySeconds }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-web.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "strategy") }}
|
||||
strategy:
|
||||
{{- . | nindent 4 }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 8 }}
|
||||
{{- with $podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "imagePullSecrets") }}
|
||||
imagePullSecrets:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "podSecurityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
|
||||
terminationGracePeriodSeconds: {{ int $w.terminationGracePeriodSeconds }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "nodeSelector") }}
|
||||
nodeSelector:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "affinity") }}
|
||||
affinity:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "tolerations") }}
|
||||
tolerations:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.topologySpread" $ctx | trim }}
|
||||
topologySpreadConstraints:
|
||||
{{- . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ $name }}
|
||||
image: {{ include "fluxer-web.image" $ctx }}
|
||||
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($v.image | default dict).pullPolicy | default "IfNotPresent" }}
|
||||
{{- with include "fluxer-web.env" $ctx | trim }}
|
||||
env:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
{{- with $w.lifecycle }}
|
||||
lifecycle:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- range $probe := list "startup" "liveness" "readiness" }}
|
||||
{{- with hasKey $wProbes $probe | ternary (get $wProbes $probe) (get $gProbes $probe) }}
|
||||
{{ $probe }}Probe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "securityContext") }}
|
||||
securityContext:
|
||||
{{- . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $w.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
{{- include "fluxer-web.selectorLabels" $ctx | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
{{- with $w.hpa }}
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ $name }}
|
||||
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $name) .minReplicas }}
|
||||
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $name) .maxReplicas }}
|
||||
{{- with .targetCPUUtilizationPercentage }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ . }}
|
||||
{{- end }}
|
||||
{{- with .behavior }}
|
||||
behavior:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with $w.pdb }}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ $name }}-pdb
|
||||
namespace: {{ $.Release.Namespace }}
|
||||
labels:
|
||||
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
|
||||
spec:
|
||||
{{- toYaml . | nindent 2 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "fluxer-web.selectorLabels" $ctx | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,83 @@
|
||||
image:
|
||||
registry: ghcr.io/fluxerapp
|
||||
tag: v1
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
env: {}
|
||||
|
||||
extraEnv: []
|
||||
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: fluxer-env
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
probes:
|
||||
startup:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 30
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /_health
|
||||
port: http
|
||||
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
|
||||
topologySpreadConstraints: []
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
workloads:
|
||||
admin:
|
||||
image:
|
||||
name: fluxer-admin
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
FLUXER_APP_ENDPOINT: https://web.example.com
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
app-proxy:
|
||||
image:
|
||||
name: fluxer-app-proxy-self-hosted
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
memory: 384Mi
|
||||
@@ -160,7 +160,9 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
|
||||
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
|
||||
#FLUXER_IPINFO_API_KEY=
|
||||
# Phone verification needs your own responder on the rpc.phone.v1 NATS
|
||||
# subjects. Off unless turned on.
|
||||
#FLUXER_PHONE_VERIFICATION_ENABLED=false
|
||||
# A local path, or an s3:// URL read with the S3 credentials of this file.
|
||||
#FLUXER_GEOIP_DB_PATH=
|
||||
|
||||
@@ -449,6 +451,11 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
#FLUXER_SEAWEEDFS_TELEMETRY=false
|
||||
|
||||
# Volumes SeaweedFS creates at once when a bucket needs space. Each reserves 1 GB
|
||||
# of free disk from the start, and SeaweedFS's own default of 7 fills a small
|
||||
# disk before every bucket has one, so uploads fail with no free volumes left.
|
||||
#FLUXER_SEAWEEDFS_VOLUME_GROWTH=1
|
||||
|
||||
# Node sizes its heap from the container limit by default. Leave these unset
|
||||
# unless you need to pin it. A heap ceiling above the container limit gets the
|
||||
# container OOM-killed instead of reporting a heap error. The values below are
|
||||
|
||||
@@ -33,7 +33,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
|
||||
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
|
||||
FLUXER_IPINFO_API_KEY: ${FLUXER_IPINFO_API_KEY:-}
|
||||
FLUXER_PHONE_VERIFICATION_ENABLED: ${FLUXER_PHONE_VERIFICATION_ENABLED:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
|
||||
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
|
||||
@@ -354,6 +354,7 @@ services:
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
WEED_MASTER_VOLUME_GROWTH_COPY_1: ${FLUXER_SEAWEEDFS_VOLUME_GROWTH:-1}
|
||||
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
|
||||
@@ -40,6 +40,7 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
adapt_progenitor_throttled_errors(&mut spec);
|
||||
relax_guild_audit_log_schemas(&mut spec);
|
||||
relax_progenitor_schema_strictness(&mut spec);
|
||||
relax_integer_enums(&mut spec);
|
||||
|
||||
let mut settings = progenitor::GenerationSettings::new();
|
||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||
@@ -174,6 +175,23 @@ fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
const OPEN_INTEGER_ENUMS: &[&str] = &["ChannelType", "MessageType", "WebhookType"];
|
||||
|
||||
fn relax_integer_enums(spec: &mut openapiv3::OpenAPI) {
|
||||
let components = spec.components.as_mut().expect("missing API components");
|
||||
for name in OPEN_INTEGER_ENUMS {
|
||||
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(*name) else {
|
||||
panic!("missing inline {name} schema");
|
||||
};
|
||||
let openapiv3::SchemaKind::Type(openapiv3::Type::Integer(integer)) =
|
||||
&mut schema.schema_kind
|
||||
else {
|
||||
panic!("{name} must be an integer schema");
|
||||
};
|
||||
integer.enumeration.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema_mut<'a>(
|
||||
components: &'a mut openapiv3::Components,
|
||||
name: &str,
|
||||
|
||||
+155
-17
@@ -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"
|
||||
]
|
||||
@@ -13383,7 +13427,10 @@
|
||||
"BanEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"description": "Email address to ban", "allOf": [{"$ref": "#/components/schemas/EmailType"}]}
|
||||
"email": {
|
||||
"description": "Email address to ban, or a domain written as @example.com to ban every address at it and its subdomains",
|
||||
"allOf": [{"$ref": "#/components/schemas/EmailBlocklistEntryType"}]
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
},
|
||||
@@ -13392,7 +13439,7 @@
|
||||
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
|
||||
"required": ["ip"]
|
||||
},
|
||||
"EmailType": {"type": "string"},
|
||||
"EmailBlocklistEntryType": {"type": "string"},
|
||||
"CheckAvatarHashRequest": {
|
||||
"type": "object",
|
||||
"properties": {"hashes": {"minItems": 1, "maxItems": 1000, "type": "array", "items": {"type": "string"}}},
|
||||
@@ -13493,7 +13540,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 +13574,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 +13610,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 +13778,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 +13789,7 @@
|
||||
},
|
||||
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
|
||||
},
|
||||
"required": ["channel_id", "message_id", "type"],
|
||||
"required": ["channel_id", "type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"message_snapshots": {
|
||||
@@ -13874,7 +13924,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 +13935,7 @@
|
||||
},
|
||||
"type": {"allOf": [{"$ref": "#/components/schemas/MessageReferenceType"}]}
|
||||
},
|
||||
"required": ["channel_id", "message_id", "type"],
|
||||
"required": ["channel_id", "type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"message_snapshots": {
|
||||
@@ -14375,11 +14426,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 +14457,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 +14468,7 @@
|
||||
"CHANNEL_ICON_CHANGE",
|
||||
"CHANNEL_PINNED_MESSAGE",
|
||||
"USER_JOIN",
|
||||
"CHANNEL_FOLLOW_ADD",
|
||||
"REPLY"
|
||||
],
|
||||
"x-enumDescriptions": [
|
||||
@@ -14413,6 +14480,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 +15487,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 +15650,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": {
|
||||
@@ -15902,6 +16039,7 @@
|
||||
{"name": "PERKS_DISABLED", "value": "256", "description": "User has temporarily disabled premium perks"}
|
||||
]
|
||||
},
|
||||
"EmailType": {"type": "string"},
|
||||
"AdminRelationshipEntrySchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ impl AdminApiClient {
|
||||
"email",
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
email: generated_types::EmailBlocklistEntryType::from(email.to_owned()),
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
|
||||
@@ -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!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod acl;
|
||||
pub mod admin_flags;
|
||||
pub mod admin_hints;
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod fonts;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", " ");
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,10 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
route: "/email-bans",
|
||||
input_label: "Email Address",
|
||||
input_label: "Email Address or Domain",
|
||||
input_name: "email",
|
||||
input_type: "email",
|
||||
placeholder: "[email protected]",
|
||||
input_type: "text",
|
||||
placeholder: "[email protected] or @example.com",
|
||||
entity_name: "Email",
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,13 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "catalog:",
|
||||
"@pkgs/cassandra": "workspace:*",
|
||||
"@fluxer/geo_utils": "workspace:*",
|
||||
"@fluxer/instance_bootstrap": "workspace:*",
|
||||
"@fluxer/ip_utils": "workspace:*",
|
||||
"@pkgs/postgres": "workspace:*",
|
||||
"maxmind": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"maxmind": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const TABLE = 'ipinfo_cache';
|
||||
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
|
||||
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
|
||||
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
|
||||
|
||||
interface CassandraIpInfoCacheOptions {
|
||||
client?: ICassandraClient;
|
||||
getClient?: () => ICassandraClient;
|
||||
}
|
||||
|
||||
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
|
||||
const row = result.first();
|
||||
if (!row) return null;
|
||||
const payload = row.get('payload');
|
||||
if (typeof payload !== 'string') return null;
|
||||
return JSON.parse(payload) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
|
||||
await client.execute({
|
||||
cql: INSERT_WITH_TTL_CQL,
|
||||
params: {cache_key: key, payload, ttl: ttlSeconds},
|
||||
});
|
||||
} else {
|
||||
await client.execute({
|
||||
cql: INSERT_DEFAULT_TTL_CQL,
|
||||
params: {cache_key: key, payload},
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const TABLE = 'ipinfo_requests_by_hour';
|
||||
const INSERT_CQL = `INSERT INTO ${TABLE} (
|
||||
bucket_date,
|
||||
bucket_hour,
|
||||
requested_at,
|
||||
event_id,
|
||||
source,
|
||||
reason,
|
||||
ip,
|
||||
cache_key,
|
||||
request_url,
|
||||
http_status,
|
||||
outcome,
|
||||
available,
|
||||
risk_note,
|
||||
latency_ms,
|
||||
response_ip,
|
||||
country_code,
|
||||
asn,
|
||||
is_anonymous,
|
||||
is_tor,
|
||||
is_vpn,
|
||||
is_proxy,
|
||||
is_residential_proxy,
|
||||
metadata_json
|
||||
) VALUES (
|
||||
:bucket_date,
|
||||
:bucket_hour,
|
||||
:requested_at,
|
||||
:event_id,
|
||||
:source,
|
||||
:reason,
|
||||
:ip,
|
||||
:cache_key,
|
||||
:request_url,
|
||||
:http_status,
|
||||
:outcome,
|
||||
:available,
|
||||
:risk_note,
|
||||
:latency_ms,
|
||||
:response_ip,
|
||||
:country_code,
|
||||
:asn,
|
||||
:is_anonymous,
|
||||
:is_tor,
|
||||
:is_vpn,
|
||||
:is_proxy,
|
||||
:is_residential_proxy,
|
||||
:metadata_json
|
||||
);`;
|
||||
|
||||
interface CassandraIpInfoRequestAuditOptions {
|
||||
client?: ICassandraClient;
|
||||
getClient?: () => ICassandraClient;
|
||||
}
|
||||
|
||||
export function createCassandraIpInfoRequestAuditLogger(
|
||||
options: CassandraIpInfoRequestAuditOptions,
|
||||
): IpInfoRequestAuditLogger {
|
||||
return {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
try {
|
||||
const client = options.client ?? options.getClient?.();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.execute({
|
||||
cql: INSERT_CQL,
|
||||
params: {
|
||||
bucket_date: formatUtcDate(event.requestedAt),
|
||||
bucket_hour: event.requestedAt.getUTCHours(),
|
||||
requested_at: event.requestedAt,
|
||||
event_id: randomUUID(),
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
ip: event.ip,
|
||||
cache_key: event.cacheKey,
|
||||
request_url: event.requestUrl,
|
||||
http_status: event.httpStatus,
|
||||
outcome: event.outcome,
|
||||
available: event.available,
|
||||
risk_note: event.note,
|
||||
latency_ms: event.latencyMs,
|
||||
response_ip: event.responseIp,
|
||||
country_code: event.countryCode,
|
||||
asn: event.asnNumber,
|
||||
is_anonymous: event.isAnonymous,
|
||||
is_tor: event.isTor,
|
||||
is_vpn: event.isVpn,
|
||||
is_proxy: event.isProxy,
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatUtcDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {z} from 'zod';
|
||||
|
||||
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
const CACHE_KEY_PREFIX = 'ipinfo:max:';
|
||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
|
||||
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
|
||||
const FAILURE_TTL_QUOTA_SECONDS = 900;
|
||||
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
|
||||
|
||||
export interface IpInfoGeoBlock {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
continent: string | null;
|
||||
continentCode: string | null;
|
||||
region: string | null;
|
||||
regionCode: string | null;
|
||||
city: string | null;
|
||||
postalCode: string | null;
|
||||
timezone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyRadiusKm: number | null;
|
||||
}
|
||||
|
||||
export interface IpInfoAsnBlock {
|
||||
asn: string | null;
|
||||
number: number | null;
|
||||
name: string | null;
|
||||
domain: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoMobileBlock {
|
||||
name: string | null;
|
||||
mcc: string | null;
|
||||
mnc: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoAnonymousBlock {
|
||||
isAnonymous: boolean;
|
||||
providerName: string | null;
|
||||
isVpn: boolean;
|
||||
isProxy: boolean;
|
||||
isResidentialProxy: boolean;
|
||||
isTor: boolean;
|
||||
isRelay: boolean;
|
||||
percentDaysSeen: number | null;
|
||||
}
|
||||
|
||||
export interface IpInfoFlags {
|
||||
isAnycast: boolean;
|
||||
isHosting: boolean;
|
||||
isMobile: boolean;
|
||||
isSatellite: boolean;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupResult {
|
||||
ip: string;
|
||||
available: boolean;
|
||||
note: string;
|
||||
geo: IpInfoGeoBlock;
|
||||
asn: IpInfoAsnBlock;
|
||||
mobile: IpInfoMobileBlock;
|
||||
anonymous: IpInfoAnonymousBlock;
|
||||
flags: IpInfoFlags;
|
||||
}
|
||||
|
||||
export interface IpInfoCache {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CachedIpInfoFailure extends IpInfoLookupResult {
|
||||
cachedFailure: true;
|
||||
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
failureHttpStatus: number | null;
|
||||
cachedAtMs: number;
|
||||
}
|
||||
|
||||
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
|
||||
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
|
||||
}
|
||||
|
||||
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
|
||||
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
|
||||
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
|
||||
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
|
||||
return FAILURE_TTL_HTTP_ERROR_SECONDS;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupContext {
|
||||
source?: string;
|
||||
reason?: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export interface IpInfoRequestAuditEvent {
|
||||
requestedAt: Date;
|
||||
ip: string;
|
||||
cacheKey: string;
|
||||
source: string;
|
||||
reason: string | null;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
httpStatus: number | null;
|
||||
available: boolean;
|
||||
note: string;
|
||||
latencyMs: number;
|
||||
requestUrl: string;
|
||||
responseIp: string | null;
|
||||
countryCode: string | null;
|
||||
asnNumber: number | null;
|
||||
isAnonymous: boolean;
|
||||
isTor: boolean;
|
||||
isVpn: boolean;
|
||||
isProxy: boolean;
|
||||
isResidentialProxy: boolean;
|
||||
}
|
||||
|
||||
export interface IpInfoRequestAuditLogger {
|
||||
record(event: IpInfoRequestAuditEvent): Promise<void>;
|
||||
}
|
||||
|
||||
interface IpInfoServiceContext {
|
||||
apiKey: string;
|
||||
cache: IpInfoCache;
|
||||
auditLogger?: IpInfoRequestAuditLogger;
|
||||
}
|
||||
|
||||
export interface IpInfoService {
|
||||
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
|
||||
}
|
||||
|
||||
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
|
||||
const RawIpInfoGeoSchema = z.object({
|
||||
city: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
region_code: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
country_code: z.string().optional(),
|
||||
continent: z.string().optional(),
|
||||
continent_code: z.string().optional(),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
timezone: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
dma_code: z.string().optional(),
|
||||
geoname_id: z.string().optional(),
|
||||
radius: z.number().int().optional(),
|
||||
last_changed: IpInfoDateSchema.optional(),
|
||||
});
|
||||
const RawIpInfoAsSchema = z.object({
|
||||
asn: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
last_changed: IpInfoDateSchema.optional(),
|
||||
});
|
||||
const RawIpInfoMobileSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
mcc: z.string().optional(),
|
||||
mnc: z.string().optional(),
|
||||
});
|
||||
const RawIpInfoAnonymousSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
last_seen: IpInfoDateSchema.optional(),
|
||||
percent_days_seen: z.number().int().optional(),
|
||||
is_proxy: z.boolean().optional(),
|
||||
is_relay: z.boolean().optional(),
|
||||
is_tor: z.boolean().optional(),
|
||||
is_vpn: z.boolean().optional(),
|
||||
is_res_proxy: z.boolean().optional(),
|
||||
});
|
||||
const RawIpInfoResponseSchema = z.object({
|
||||
ip: z.string(),
|
||||
hostname: z.string().optional(),
|
||||
geo: RawIpInfoGeoSchema,
|
||||
as: RawIpInfoAsSchema,
|
||||
mobile: RawIpInfoMobileSchema.optional(),
|
||||
anonymous: RawIpInfoAnonymousSchema,
|
||||
is_anonymous: z.boolean().optional(),
|
||||
is_anycast: z.boolean().optional(),
|
||||
is_hosting: z.boolean().optional(),
|
||||
is_mobile: z.boolean().optional(),
|
||||
is_satellite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
|
||||
|
||||
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
|
||||
return {
|
||||
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
|
||||
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
|
||||
if (cached !== null) {
|
||||
if (isCachedIpInfoFailure(cached)) {
|
||||
return unavailable(ip, cached.note);
|
||||
}
|
||||
return {...cached, ip};
|
||||
}
|
||||
const existing = inflight.get(cacheKey);
|
||||
if (existing) {
|
||||
const result = await existing;
|
||||
return {...result, ip};
|
||||
}
|
||||
const requestedAt = new Date();
|
||||
const startedAt = Date.now();
|
||||
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
|
||||
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
|
||||
const finalize = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: IpInfoRequestAuditEvent['outcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
await ctx.auditLogger
|
||||
?.record({
|
||||
requestedAt,
|
||||
ip,
|
||||
cacheKey,
|
||||
source: context?.source ?? 'unknown',
|
||||
reason: context?.reason ?? null,
|
||||
metadata: context?.metadata,
|
||||
outcome: params.outcome,
|
||||
httpStatus: params.httpStatus,
|
||||
available: params.result.available,
|
||||
note: params.result.note,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
requestUrl,
|
||||
responseIp: params.result.available ? params.result.ip : null,
|
||||
countryCode: params.result.geo.countryCode,
|
||||
asnNumber: params.result.asn.number,
|
||||
isAnonymous: params.result.anonymous.isAnonymous,
|
||||
isTor: params.result.anonymous.isTor,
|
||||
isVpn: params.result.anonymous.isVpn,
|
||||
isProxy: params.result.anonymous.isProxy,
|
||||
isResidentialProxy: params.result.anonymous.isResidentialProxy,
|
||||
})
|
||||
.catch(() => {});
|
||||
return params.result;
|
||||
};
|
||||
const performLookup = async (): Promise<IpInfoLookupResult> => {
|
||||
const finalizeFailure = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: CachedIpInfoFailure['failureOutcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
const entry: CachedIpInfoFailure = {
|
||||
...params.result,
|
||||
cachedFailure: true,
|
||||
failureOutcome: params.outcome,
|
||||
failureHttpStatus: params.httpStatus,
|
||||
cachedAtMs: Date.now(),
|
||||
};
|
||||
await ctx.cache
|
||||
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
|
||||
.catch(() => {});
|
||||
return finalize(params);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
let payload: unknown;
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
|
||||
outcome: 'http_error',
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo request failed: ${detail}`),
|
||||
outcome: 'request_failed',
|
||||
httpStatus: null,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
}
|
||||
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
|
||||
if (!parsedResponse.success) {
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
|
||||
outcome: 'schema_mismatch',
|
||||
httpStatus: 200,
|
||||
});
|
||||
}
|
||||
const result = parseIpInfoResponse(parsedResponse.data);
|
||||
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
|
||||
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
|
||||
return finalize({
|
||||
result,
|
||||
outcome: 'http_success',
|
||||
httpStatus: 200,
|
||||
});
|
||||
};
|
||||
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
|
||||
if (inflight.get(cacheKey) === promise) {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
|
||||
return {
|
||||
async lookup(ip: string): Promise<IpInfoLookupResult> {
|
||||
return unavailable(ip, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable(ip: string, reason: string): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
available: false,
|
||||
note: reason,
|
||||
geo: emptyGeo(),
|
||||
asn: emptyAsn(),
|
||||
mobile: emptyMobile(),
|
||||
anonymous: emptyAnonymous(),
|
||||
flags: emptyFlags(),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyGeo(): IpInfoGeoBlock {
|
||||
return {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
continent: null,
|
||||
continentCode: null,
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAsn(): IpInfoAsnBlock {
|
||||
return {asn: null, number: null, name: null, domain: null, type: null};
|
||||
}
|
||||
|
||||
function emptyMobile(): IpInfoMobileBlock {
|
||||
return {name: null, mcc: null, mnc: null};
|
||||
}
|
||||
|
||||
function emptyAnonymous(): IpInfoAnonymousBlock {
|
||||
return {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyFlags(): IpInfoFlags {
|
||||
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
|
||||
}
|
||||
|
||||
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
|
||||
const geo = raw.geo;
|
||||
const anon = raw.anonymous;
|
||||
const isAnonymous =
|
||||
raw.is_anonymous === true ||
|
||||
anon.is_res_proxy === true ||
|
||||
anon.is_vpn === true ||
|
||||
anon.is_proxy === true ||
|
||||
anon.is_tor === true ||
|
||||
anon.is_relay === true;
|
||||
return {
|
||||
ip: raw.ip,
|
||||
available: true,
|
||||
note: describeAnonymity(isAnonymous, anon),
|
||||
geo: {
|
||||
countryCode: normalizeCountryCode(geo.country_code),
|
||||
countryName: geo.country ?? null,
|
||||
continent: geo?.continent ?? null,
|
||||
continentCode: normalizeContinentCode(geo.continent_code),
|
||||
region: geo.region ?? null,
|
||||
regionCode: normalizeRegionCode(geo.region_code),
|
||||
city: geo.city ?? null,
|
||||
postalCode: geo.postal_code ?? null,
|
||||
timezone: geo.timezone ?? null,
|
||||
latitude: normalizeCoordinate(geo.latitude),
|
||||
longitude: normalizeCoordinate(geo.longitude),
|
||||
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
|
||||
},
|
||||
asn: parseAsnBlock(raw.as),
|
||||
mobile: {
|
||||
name: raw.mobile?.name ?? null,
|
||||
mcc: raw.mobile?.mcc ?? null,
|
||||
mnc: raw.mobile?.mnc ?? null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous,
|
||||
providerName: anon?.name ?? null,
|
||||
isVpn: anon?.is_vpn === true,
|
||||
isProxy: anon?.is_proxy === true,
|
||||
isResidentialProxy: anon?.is_res_proxy === true,
|
||||
isTor: anon?.is_tor === true,
|
||||
isRelay: anon?.is_relay === true,
|
||||
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: raw.is_anycast === true,
|
||||
isHosting: raw.is_hosting === true,
|
||||
isMobile: raw.is_mobile === true,
|
||||
isSatellite: raw.is_satellite === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatSchemaMismatch(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) {
|
||||
return 'IPInfo response schema mismatch';
|
||||
}
|
||||
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
|
||||
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
|
||||
}
|
||||
|
||||
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
|
||||
const raw = as?.asn ?? null;
|
||||
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
|
||||
return {
|
||||
asn: raw,
|
||||
number: Number.isFinite(numeric) ? numeric : null,
|
||||
name: as?.name ?? null,
|
||||
domain: as?.domain ?? null,
|
||||
type: as?.type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
|
||||
if (!isAnonymous) {
|
||||
return 'IPInfo: IP is not anonymous';
|
||||
}
|
||||
if (!anon) {
|
||||
return 'IPInfo: anonymous IP';
|
||||
}
|
||||
const flags: Array<string> = [];
|
||||
if (anon.is_res_proxy) flags.push('residential proxy');
|
||||
if (anon.is_vpn) flags.push('VPN');
|
||||
if (anon.is_proxy) flags.push('proxy');
|
||||
if (anon.is_tor) flags.push('Tor');
|
||||
if (anon.is_relay) flags.push('relay');
|
||||
const provider = anon.name ? ` (provider: ${anon.name})` : '';
|
||||
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
|
||||
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
|
||||
}
|
||||
|
||||
function normalizeCountryCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeContinentCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeRegionCode(value: string | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
|
||||
interface PostgresIpInfoOptions {
|
||||
client?: IPostgresClient;
|
||||
getClient?: () => IPostgresClient;
|
||||
onError?: (error: unknown, operation: string) => void;
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
|
||||
return options.client ?? options.getClient?.() ?? null;
|
||||
}
|
||||
|
||||
function valueKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function rowKey(values: ReadonlyArray<unknown>): string {
|
||||
return values.map(valueKey).join(VALUE_SEPARATOR);
|
||||
}
|
||||
|
||||
function table(client: IPostgresClient): string {
|
||||
return quoteIdentifier(client.kvTable());
|
||||
}
|
||||
|
||||
async function upsertKvRow(
|
||||
client: IPostgresClient,
|
||||
tableName: string,
|
||||
partitionKey: string,
|
||||
key: string,
|
||||
row: Record<string, unknown>,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
|
||||
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
|
||||
);
|
||||
}
|
||||
|
||||
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return null;
|
||||
const result = await client.query<{row_data: {payload?: string}}>(
|
||||
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
['ipinfo_cache', rowKey([key])],
|
||||
);
|
||||
const payload = result.rows[0]?.row_data?.payload;
|
||||
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_get');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(value);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_serialize');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_cache',
|
||||
rowKey([key]),
|
||||
rowKey([key]),
|
||||
{cache_key: key, payload},
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_set');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
|
||||
return {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
const bucketDate = formatUtcDate(event.requestedAt);
|
||||
const bucketHour = event.requestedAt.getUTCHours();
|
||||
const eventId = randomUUID();
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_requests_by_hour',
|
||||
rowKey([bucketDate, bucketHour]),
|
||||
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
|
||||
{
|
||||
bucket_date: bucketDate,
|
||||
bucket_hour: bucketHour,
|
||||
requested_at: event.requestedAt.toISOString(),
|
||||
event_id: eventId,
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
ip: event.ip,
|
||||
cache_key: event.cacheKey,
|
||||
request_url: event.requestUrl,
|
||||
http_status: event.httpStatus,
|
||||
outcome: event.outcome,
|
||||
available: event.available,
|
||||
risk_note: event.note,
|
||||
latency_ms: event.latencyMs,
|
||||
response_ip: event.responseIp,
|
||||
country_code: event.countryCode,
|
||||
asn: event.asnNumber,
|
||||
is_anonymous: event.isAnonymous,
|
||||
is_tor: event.isTor,
|
||||
is_vpn: event.isVpn,
|
||||
is_proxy: event.isProxy,
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_request_audit_record');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatUtcDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
|
||||
if (!metadata || Object.keys(metadata).length === 0) return null;
|
||||
try {
|
||||
return JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
|
||||
|
||||
interface TieredIpInfoCacheOptions {
|
||||
hot: IpInfoCache;
|
||||
cold: IpInfoCache;
|
||||
hotTtlSeconds?: number;
|
||||
skipColdWrite?: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
|
||||
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const hit = await opts.hot.get<T>(key).catch(() => null);
|
||||
if (hit !== null) return hit;
|
||||
const cold = await opts.cold.get<T>(key).catch(() => null);
|
||||
if (cold === null) return null;
|
||||
if (opts.skipColdWrite?.(cold) === true) return cold;
|
||||
void opts.hot.set(key, cold, hotTtl).catch(() => {});
|
||||
return cold;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
|
||||
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
|
||||
if (opts.skipColdWrite?.(value) !== true) {
|
||||
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -211,3 +211,41 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function withPhoneVerification(master: MasterConfig, selfHosted: boolean, enabled?: boolean): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
instance: {
|
||||
...master.instance,
|
||||
self_hosted: selfHosted,
|
||||
phone_verification_enabled: enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster phone verification', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('is on by default when the instance is not self-hosted', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, false)).instance.phoneVerificationEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('is off by default on a self-hosted instance', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true)).instance.phoneVerificationEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a self-hosted operator switch it on', () => {
|
||||
expect(buildAPIConfigFromMaster(withPhoneVerification(master, true, true)).instance.phoneVerificationEnabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets an operator switch it off when the instance is not self-hosted', () => {
|
||||
expect(
|
||||
buildAPIConfigFromMaster(withPhoneVerification(master, false, false)).instance.phoneVerificationEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,9 +259,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
ipinfo: {
|
||||
apiKey: master.integrations.ipinfo.api_key || undefined,
|
||||
},
|
||||
blocklistFeeds: {
|
||||
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
@@ -370,6 +367,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
instance: {
|
||||
selfHosted: master.instance.self_hosted,
|
||||
phoneVerificationEnabled: master.instance.phone_verification_enabled ?? !master.instance.self_hosted,
|
||||
autoJoinInviteCode: master.instance.auto_join_invite_code,
|
||||
visionariesGuildId: master.instance.visionaries_guild_id,
|
||||
visionariesGuildVisionaryRoleId: master.instance.visionaries_guild_visionary_role_id,
|
||||
@@ -453,6 +451,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||
lifecycle: apiWorkerConfig?.lane_concurrency_overrides?.lifecycle,
|
||||
batch: apiWorkerConfig?.lane_concurrency_overrides?.batch,
|
||||
crosspost: apiWorkerConfig?.lane_concurrency_overrides?.crosspost,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,6 +129,10 @@ import {
|
||||
CHANNELS_BY_GUILD_COLUMNS,
|
||||
type ChannelRow,
|
||||
type ChannelsByGuildRow,
|
||||
CROSSPOST_SOURCE_BY_CHANNEL_COLUMNS,
|
||||
CROSSPOSTED_MESSAGE_COLUMNS,
|
||||
type CrosspostedMessageRow,
|
||||
type CrosspostSourceByChannelRow,
|
||||
DM_STATE_COLUMNS,
|
||||
type DmStateRow,
|
||||
INVITE_COLUMNS,
|
||||
@@ -136,7 +140,9 @@ import {
|
||||
PRIVATE_CHANNEL_COLUMNS,
|
||||
type PrivateChannelRow,
|
||||
WEBHOOK_COLUMNS,
|
||||
WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
|
||||
type WebhookRow,
|
||||
type WebhooksBySourceChannelRow,
|
||||
} from '@app/api/database/types/ChannelTypes';
|
||||
import {USER_CONNECTION_STORAGE_COLUMNS, type UserConnectionStorageRow} from '@app/api/database/types/ConnectionTypes';
|
||||
import {
|
||||
@@ -1089,6 +1095,36 @@ export const WebhooksByGuild = defineTable<WebhooksByGuildRow, 'guild_id' | 'web
|
||||
columns: WEBHOOKS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'webhook_id'],
|
||||
});
|
||||
export const WebhooksBySourceChannel = defineTable<
|
||||
WebhooksBySourceChannelRow,
|
||||
'source_channel_id' | 'webhook_id',
|
||||
'source_channel_id'
|
||||
>({
|
||||
name: 'webhooks_by_source_channel_id',
|
||||
columns: WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
|
||||
primaryKey: ['source_channel_id', 'webhook_id'],
|
||||
partitionKey: ['source_channel_id'],
|
||||
});
|
||||
export const CrosspostedMessages = defineTable<
|
||||
CrosspostedMessageRow,
|
||||
'source_message_id' | 'webhook_id',
|
||||
'source_message_id'
|
||||
>({
|
||||
name: 'crossposted_messages',
|
||||
columns: CROSSPOSTED_MESSAGE_COLUMNS,
|
||||
primaryKey: ['source_message_id', 'webhook_id'],
|
||||
partitionKey: ['source_message_id'],
|
||||
});
|
||||
export const CrosspostSourcesByChannel = defineTable<
|
||||
CrosspostSourceByChannelRow,
|
||||
'source_channel_id' | 'source_message_id',
|
||||
'source_channel_id'
|
||||
>({
|
||||
name: 'crosspost_sources_by_channel',
|
||||
columns: CROSSPOST_SOURCE_BY_CHANNEL_COLUMNS,
|
||||
primaryKey: ['source_channel_id', 'source_message_id'],
|
||||
partitionKey: ['source_channel_id'],
|
||||
});
|
||||
export const InstanceConfiguration = defineTable<InstanceConfigurationRow, 'key'>({
|
||||
name: 'instance_configuration',
|
||||
columns: INSTANCE_CONFIGURATION_COLUMNS,
|
||||
|
||||
@@ -46,6 +46,20 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
where: BannedEmails.where.eq('email_lower'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
|
||||
|
||||
function getEmailBlocklistKeys(email: string): Array<string> {
|
||||
const emailLower = email.trim().toLowerCase();
|
||||
const atIndex = emailLower.lastIndexOf('@');
|
||||
if (atIndex <= 0) {
|
||||
return [emailLower];
|
||||
}
|
||||
const labels = emailLower.slice(atIndex + 1).split('.');
|
||||
const keys = [emailLower];
|
||||
for (let index = 0; index < labels.length - 1; index++) {
|
||||
keys.push(`@${labels.slice(index).join('.')}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
const IS_PHRASE_BANNED_QUERY = BannedPhrases.select({
|
||||
where: BannedPhrases.where.eq('phrase'),
|
||||
});
|
||||
@@ -224,11 +238,15 @@ export class AdminRepository implements IAdminRepository {
|
||||
}
|
||||
|
||||
async isEmailBanned(email: string): Promise<boolean> {
|
||||
const emailLower = email.toLowerCase();
|
||||
const result = await fetchOne<{
|
||||
email_lower: string;
|
||||
}>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower}));
|
||||
return !!result;
|
||||
for (const key of getEmailBlocklistKeys(email)) {
|
||||
const result = await fetchOne<{
|
||||
email_lower: string;
|
||||
}>(IS_EMAIL_BANNED_QUERY.bind({email_lower: key}));
|
||||
if (result) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async banEmail(email: string): Promise<void> {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ const BLOCKLIST_CATALOG = [
|
||||
},
|
||||
{
|
||||
list_type: 'email' as const,
|
||||
description: 'Email addresses that cannot be used to register or be set on an account.',
|
||||
description:
|
||||
'Email addresses that cannot be used to register or be set on an account. An entry written as @example.com covers every address at that domain and its subdomains.',
|
||||
value_field: 'email',
|
||||
fields: [],
|
||||
scoped: false,
|
||||
@@ -338,7 +339,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');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -42,6 +43,7 @@ export function CodesAdminController(app: HonoApp) {
|
||||
count,
|
||||
durationType: duration_type,
|
||||
durationQuantity: duration_quantity,
|
||||
createdByUserId: Config.instance.selfHosted ? ctx.get('adminUserId') : SYSTEM_USER_ID,
|
||||
});
|
||||
await adminService.auditService.createAuditLog({
|
||||
adminUserId: ctx.get('adminUserId'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {GiftCodeDurationType, GiftCodeRow} from '@app/api/database/types/PaymentTypes';
|
||||
import {mapGiftCodeDurationToMonths} from '@app/api/models/GiftCode';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
@@ -15,13 +15,14 @@ interface GenerateGiftCodesOptions {
|
||||
count: number;
|
||||
durationType: GiftCodeDurationType;
|
||||
durationQuantity: number;
|
||||
createdByUserId: UserID;
|
||||
}
|
||||
|
||||
export class AdminCodeGenerationService {
|
||||
constructor(private readonly userRepository: IUserRepository) {}
|
||||
|
||||
async generateGiftCodes(options: GenerateGiftCodesOptions): Promise<Array<string>> {
|
||||
const {count, durationType, durationQuantity} = this.validateOptions(options);
|
||||
const {count, durationType, durationQuantity, createdByUserId} = this.validateOptions(options);
|
||||
const durationMonths = mapGiftCodeDurationToMonths(durationType, durationQuantity);
|
||||
const codes: Array<string> = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
@@ -32,7 +33,7 @@ export class AdminCodeGenerationService {
|
||||
duration_type: durationType,
|
||||
duration_quantity: durationQuantity,
|
||||
created_at: new Date(),
|
||||
created_by_user_id: SYSTEM_USER_ID,
|
||||
created_by_user_id: createdByUserId,
|
||||
redeemed_at: null,
|
||||
redeemed_by_user_id: null,
|
||||
stripe_payment_intent_id: null,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,9 @@ export function AuthController(app: HonoApp) {
|
||||
'Complete the SSO authentication flow with the authorization code from the SSO provider. Returns authentication token and user information.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').completeSso(ctx.req.valid('json'), ctx.req.raw);
|
||||
const result = await ctx
|
||||
.get('authRequestService')
|
||||
.completeSso(ctx.req.valid('json'), ctx.req.raw, ctx.get('requestCache'));
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -168,8 +168,10 @@ export class AuthRequestService {
|
||||
});
|
||||
}
|
||||
|
||||
completeSso(data: SsoCompleteRequest, request: Request) {
|
||||
return this.toSsoCompleteResponse(this.ssoService.completeLogin({code: data.code, state: data.state, request}));
|
||||
completeSso(data: SsoCompleteRequest, request: Request, requestCache: RequestCache) {
|
||||
return this.toSsoCompleteResponse(
|
||||
this.ssoService.completeLogin({code: data.code, state: data.state, request, requestCache}),
|
||||
);
|
||||
}
|
||||
|
||||
async register({data, request, requestCache}: AuthRegisterRequest): Promise<AuthRegisterResponse> {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type InstanceSsoConfig,
|
||||
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
||||
} from '@app/api/instance/InstanceConfigRepository';
|
||||
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
|
||||
import {
|
||||
deriveSsoRedirectUri,
|
||||
getSsoRequestUrlPolicy,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '@app/api/instance/SsoConfigValidation';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {UserSettings} from '@app/api/models/UserSettings';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
@@ -35,6 +37,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 +109,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 +138,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);
|
||||
}
|
||||
|
||||
@@ -261,6 +262,7 @@ export class SsoService {
|
||||
private readonly instanceConfigRepository: InstanceConfigRepository,
|
||||
private readonly discriminatorService: IDiscriminatorService,
|
||||
private readonly kvActivityTracker: KVActivityTracker,
|
||||
private readonly singleCommunityService: SingleCommunityService,
|
||||
) {}
|
||||
|
||||
async getPublicStatus(): Promise<PublicSsoStatus> {
|
||||
@@ -285,16 +287,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 +304,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,10 +326,20 @@ 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<{
|
||||
async completeLogin({
|
||||
code,
|
||||
state,
|
||||
request,
|
||||
requestCache,
|
||||
}: {
|
||||
code: string;
|
||||
state: string;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<{
|
||||
token: string;
|
||||
user_id: string;
|
||||
redirect_to: string;
|
||||
@@ -345,7 +357,7 @@ export class SsoService {
|
||||
config,
|
||||
});
|
||||
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
|
||||
const user = await this.resolveUserFromClaims(claims, config);
|
||||
const user = await this.resolveUserFromClaims(claims, config, requestCache);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
@@ -353,7 +365,11 @@ export class SsoService {
|
||||
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
|
||||
}
|
||||
|
||||
private async resolveUserFromClaims(claims: ResolvedSsoClaims, config: ResolvedSsoConfig): Promise<User> {
|
||||
private async resolveUserFromClaims(
|
||||
claims: ResolvedSsoClaims,
|
||||
config: ResolvedSsoConfig,
|
||||
requestCache: RequestCache,
|
||||
): Promise<User> {
|
||||
if (!claims.emailVerified) {
|
||||
throw InputValidationError.fromCode('email_verified', ValidationErrorCodes.INVALID_SSO_TOKEN);
|
||||
}
|
||||
@@ -389,6 +405,7 @@ export class SsoService {
|
||||
if (pendingApproval) {
|
||||
throw new RegistrationPendingApprovalError();
|
||||
}
|
||||
await this.singleCommunityService.joinStockCommunity(user.id, requestCache);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
@@ -36,7 +37,11 @@ describe('Email blocklist at signup and email change', () => {
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await clearTestEmails(harness);
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'ban:email:add']);
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'ban:email:add',
|
||||
'ban:email:check',
|
||||
]);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
@@ -74,6 +79,52 @@ describe('Email blocklist at signup and email change', () => {
|
||||
expectGenericEmailError(json, 'email');
|
||||
});
|
||||
|
||||
function register(email: string) {
|
||||
return createBuilder<ValidationErrorBody>(harness, '')
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email,
|
||||
username: createUniqueUsername('domain'),
|
||||
global_name: TEST_USER_DATA.DEFAULT_GLOBAL_NAME,
|
||||
password: TEST_CREDENTIALS.STRONG_PASSWORD,
|
||||
date_of_birth: TEST_USER_DATA.DEFAULT_DATE_OF_BIRTH,
|
||||
consent: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('refuses registration at a blocklisted domain and its subdomains', async () => {
|
||||
const domain = `${randomUUID()}.test`;
|
||||
await blocklist(`@${domain.toUpperCase()}`);
|
||||
for (const email of [`someone@${domain}`, `SOMEONE@MAIL.${domain.toUpperCase()}`]) {
|
||||
const {json} = await register(email).expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY').executeWithResponse();
|
||||
expectGenericEmailError(json, 'email');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not extend a domain entry to unrelated domains', async () => {
|
||||
const domain = `${randomUUID()}.test`;
|
||||
await blocklist(`@${domain}`);
|
||||
await register(`someone@not${domain}`).execute();
|
||||
await register(`someone@${domain}.example`).execute();
|
||||
});
|
||||
|
||||
it('reports a domain entry through the blocklist check', async () => {
|
||||
const domain = `${randomUUID()}.test`;
|
||||
await blocklist(`@${domain}`);
|
||||
const {banned} = await createBuilder<{banned: boolean}>(harness, admin.token)
|
||||
.get(`/admin/blocklists/email/entries/${encodeURIComponent(`@${domain}`)}`)
|
||||
.execute();
|
||||
expect(banned).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a malformed domain entry', async () => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/blocklists/email/entries')
|
||||
.body({email: '@not a domain'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('still registers an address that is not blocklisted', async () => {
|
||||
await blocklist(createUniqueEmail('blocked-other'));
|
||||
await createTestAccount(harness);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
setUserACLs,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -131,6 +133,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 +182,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 +191,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')
|
||||
@@ -540,6 +546,29 @@ describe('Auth SSO flow', () => {
|
||||
.expect(403)
|
||||
.execute();
|
||||
});
|
||||
it('joins a provisioned user to the single community', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: guild.id,
|
||||
});
|
||||
await enableSso(harness, admin.token);
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: `sso-single-community-${Date.now()}@example.com`,
|
||||
state: startData.state,
|
||||
})
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${completeData.user_id}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('existing user login', () => {
|
||||
let admin: TestAccount;
|
||||
|
||||
@@ -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']}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user