diff --git a/.gitignore b/.gitignore index 6dd6722..035dd09 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ lib lib64 .env .vscode +.venv # coverage reports .coverage @@ -26,6 +27,7 @@ htmlcov/ venv/ .idea +.sisyphus env/ rootfs/web/yarn-error.log rootfs/node_modules/ diff --git a/.woodpecker/build-linux.yml b/.woodpecker/build-linux.yml index 782d2f5..ef4ecfd 100644 --- a/.woodpecker/build-linux.yml +++ b/.woodpecker/build-linux.yml @@ -14,16 +14,22 @@ steps: - export VERSION=$([ -z $CI_COMMIT_TAG ] && echo latest || echo $CI_COMMIT_TAG)-$(sed 's#/#-#g' <<< $CI_SYSTEM_PLATFORM) - echo $CONTAINER_PASSWORD | podman login $DRYCC_REGISTRY --username $CONTAINER_USERNAME --password-stdin > /dev/null 2>&1 - make podman-build podman-immutable-push - secrets: - - codename - - dev_registry - - drycc_registry - - container_username - - container_password + environment: + CODENAME: + from_secret: codename + DEV_REGISTRY: + from_secret: dev_registry + DRYCC_REGISTRY: + from_secret: drycc_registry + CONTAINER_USERNAME: + from_secret: container_username + CONTAINER_PASSWORD: + from_secret: container_password when: event: - push - tag + - cron depends_on: - test-linux \ No newline at end of file diff --git a/.woodpecker/chart.yaml b/.woodpecker/chart.yaml index 5aa7eb3..cfcd3ec 100644 --- a/.woodpecker/chart.yaml +++ b/.woodpecker/chart.yaml @@ -14,15 +14,20 @@ steps: - helm package -u charts/$${CI_REPO_NAME} --version $CHART_VERSION --app-version $APP_VERSION - echo $CONTAINER_PASSWORD | helm registry login $DRYCC_REGISTRY -u $CONTAINER_USERNAME --password-stdin - helm push $${CI_REPO_NAME}-$CHART_VERSION.tgz oci://$DRYCC_REGISTRY/$([ -z $CI_COMMIT_TAG ] && echo charts-testing || echo charts) - secrets: - - dev_registry - - drycc_registry - - container_username - - container_password + environment: + DEV_REGISTRY: + from_secret: dev_registry + DRYCC_REGISTRY: + from_secret: drycc_registry + CONTAINER_USERNAME: + from_secret: container_username + CONTAINER_PASSWORD: + from_secret: container_password when: event: - push - tag + - cron depends_on: - manifest diff --git a/.woodpecker/manifest.yml b/.woodpecker/manifest.yml index b4735ee..19fc70c 100644 --- a/.woodpecker/manifest.yml +++ b/.woodpecker/manifest.yml @@ -8,12 +8,14 @@ steps: commands: - sed -i "s/{{project}}/$${CI_REPO_NAME}/g" .woodpecker/manifest.tmpl - sed -i "s/registry.drycc.cc/$${DRYCC_REGISTRY}/g" .woodpecker/manifest.tmpl - secrets: - - drycc_registry + environment: + DRYCC_REGISTRY: + from_secret: drycc_registry when: event: - tag - push + - cron - name: publish-manifest image: bash @@ -26,13 +28,16 @@ steps: -v $(pwd):$(pwd) -w $(pwd) docker.io/plugins/manifest - secrets: - - container_username - - container_password + environment: + CONTAINER_USERNAME: + from_secret: container_username + CONTAINER_PASSWORD: + from_secret: container_password when: event: - tag - push + - cron depends_on: - build-linux diff --git a/.woodpecker/test-linux.yml b/.woodpecker/test-linux.yml index 3738680..f22a5f0 100644 --- a/.woodpecker/test-linux.yml +++ b/.woodpecker/test-linux.yml @@ -12,11 +12,15 @@ steps: image: bash commands: - make test podman-build-test upload-coverage - secrets: - - codename - - dev_registry - - codecov_token + environment: + CODENAME: + from_secret: codename + DEV_REGISTRY: + from_secret: dev_registry + CODECOV_TOKEN: + from_secret: codecov_token when: event: - push - tag + - cron diff --git a/charts/passport/files/reserved-name-patterns.txt b/charts/passport/files/reserved-name-patterns.txt new file mode 100644 index 0000000..1b6ca23 --- /dev/null +++ b/charts/passport/files/reserved-name-patterns.txt @@ -0,0 +1,13 @@ +^backup$ +^catalog$ +^cert-manager$ +^default$ +^drycc(?:-[\w-]+)?$ +^istio(?:-[\w-]+)?$ +^kube(?:-[\w-]+)?$ +^longhorn-system$ +^metallb$ +^mount-s3$ +^topolvm$ +^rook-ceph$ +^personal$ \ No newline at end of file diff --git a/charts/passport/templates/_helpers.tpl b/charts/passport/templates/_helpers.tpl index 0cd32cc..e29a72f 100644 --- a/charts/passport/templates/_helpers.tpl +++ b/charts/passport/templates/_helpers.tpl @@ -16,6 +16,8 @@ rbac.authorization.k8s.io/v1 env: - name: TZ value: {{ .Values.time_zone | default "UTC" | quote }} +- name: "DJANGO_SETTINGS_MODULE" + value: "api.settings.production" - name: VERSION value: {{ .Chart.AppVersion }} - name: ADMIN_USERNAME @@ -28,6 +30,21 @@ env: value: {{ .Values.global.platformDomain }} - name: CERT_MANAGER_ENABLED value: "{{ .Values.global.certManagerEnabled }}" +{{- if (.Values.valkeyUrl) }} +- name: DRYCC_VALKEY_URL + valueFrom: + secretKeyRef: + name: passport-creds + key: valkey-url +{{- else if .Values.valkey.enabled }} +- name: VALKEY_PASSWORD + valueFrom: + secretKeyRef: + name: valkey-creds + key: password +- name: DRYCC_VALKEY_URL + value: "redis://:$(VALKEY_PASSWORD)@drycc-valkey:16379/1" +{{- end }} {{- if (.Values.databaseUrl) }} - name: DRYCC_DATABASE_URL valueFrom: @@ -41,7 +58,7 @@ env: name: passport-creds key: database-replica-url {{- end }} -{{- else if eq .Values.global.databaseLocation "on-cluster" }} +{{- else if .Values.database.enabled }} - name: DRYCC_DATABASE_USER valueFrom: secretKeyRef: @@ -53,20 +70,10 @@ env: name: database-creds key: password - name: DRYCC_DATABASE_URL - value: "postgres://$(DRYCC_DATABASE_USER):$(DRYCC_DATABASE_PASSWORD)@drycc-database.{{.Release.Namespace}}.svc.{{.Values.global.clusterDomain}}:5432/passport" + value: "postgres://$(DRYCC_DATABASE_USER):$(DRYCC_DATABASE_PASSWORD)@drycc-database:5432/passport" - name: DRYCC_DATABASE_REPLICA_URL - value: "postgres://$(DRYCC_DATABASE_USER):$(DRYCC_DATABASE_PASSWORD)@drycc-database-replica.{{.Release.Namespace}}.svc.{{.Values.global.clusterDomain}}:5432/passport" + value: "postgres://$(DRYCC_DATABASE_USER):$(DRYCC_DATABASE_PASSWORD)@drycc-database-replica:5432/passport" {{- end }} -- name: DRYCC_REDIS_ADDRS - valueFrom: - secretKeyRef: - name: redis-creds - key: addrs -- name: DRYCC_REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: redis-creds - key: password {{- range $key, $value := .Values.environment }} - name: {{ $key }} value: {{ $value | quote }} @@ -74,21 +81,6 @@ env: {{- end }} -{{/* Generate passport deployment limits */}} -{{- define "passport.limits" -}} -{{- if or (.Values.limitsCpu) (.Values.limitsMemory) }} -resources: - limits: -{{- if (.Values.limitsCpu) }} - cpu: {{.Values.limitsCpu}} -{{- end }} -{{- if (.Values.limitsMemory) }} - memory: {{.Values.limitsMemory}} -{{- end }} -{{- end }} -{{- end }} - - {{/* Generate passport deployment volumeMounts */}} {{- define "passport.volumeMounts" }} volumeMounts: @@ -100,7 +92,6 @@ volumeMounts: readOnly: true {{- end }} - {{/* Generate passport deployment volumes */}} {{- define "passport.volumes" }} volumes: diff --git a/charts/passport/templates/passport-deployment.yaml b/charts/passport/templates/api/passport-api-deployment.yaml similarity index 67% rename from charts/passport/templates/passport-deployment.yaml rename to charts/passport/templates/api/passport-api-deployment.yaml index 8017c06..a06f714 100644 --- a/charts/passport/templates/passport-deployment.yaml +++ b/charts/passport/templates/api/passport-api-deployment.yaml @@ -1,14 +1,13 @@ -{{- if eq .Values.global.passportLocation "on-cluster" }} apiVersion: apps/v1 kind: Deployment metadata: - name: drycc-passport + name: drycc-passport-api labels: heritage: drycc annotations: component.drycc.cc/version: {{ .Values.imageTag }} spec: - replicas: {{ .Values.replicas }} + replicas: {{ .Values.api.replicas }} strategy: rollingUpdate: maxSurge: 1 @@ -17,28 +16,30 @@ spec: selector: matchLabels: app: drycc-passport + component: drycc-passport-api template: metadata: labels: {{- include "common.labels.standard" . | nindent 8 }} app: drycc-passport + component: drycc-passport-api spec: affinity: - podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.podAffinityPreset.type "component" "" "extraMatchLabels" .Values.podAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }} - podAntiAffinity: {{- include "common.affinities.pods" (dict "type" .Values.podAntiAffinityPreset.type "component" "" "extraMatchLabels" .Values.podAntiAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }} - nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.nodeAffinityPreset.type "key" .Values.nodeAffinityPreset.key "values" .Values.nodeAffinityPreset.values ) | nindent 10 }} + podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.api.podAffinityPreset.type "component" "" "extraMatchLabels" .Values.api.podAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }} + podAntiAffinity: {{- include "common.affinities.pods" (dict "type" .Values.api.podAntiAffinityPreset.type "component" "" "extraMatchLabels" .Values.api.podAntiAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }} + nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.api.nodeAffinityPreset.type "key" .Values.api.nodeAffinityPreset.key "values" .Values.api.nodeAffinityPreset.values ) | nindent 10 }} serviceAccount: drycc-passport initContainers: - - name: drycc-passport-init + - name: drycc-passport-api-init image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/python-dev:latest imagePullPolicy: {{.Values.imagePullPolicy}} args: - - netcat - - -v - - -u - - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL) + - netcat + - -v + - -u + - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL),$(DRYCC_VALKEY_URL) {{- include "passport.envs" . | indent 8 }} containers: - - name: drycc-passport + - name: drycc-passport-api image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/passport:{{.Values.imageTag}} imagePullPolicy: {{.Values.imagePullPolicy}} {{- if .Values.diagnosticMode.enabled }} @@ -63,8 +64,10 @@ spec: ports: - containerPort: 8000 name: http - {{- include "passport.limits" . | indent 8 }} + {{- with index .Values "api" "resources" }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} {{- include "passport.envs" . | indent 8 }} {{- include "passport.volumeMounts" . | indent 8 }} {{- include "passport.volumes" . | indent 6 }} -{{- end }} diff --git a/charts/passport/templates/celery/passport-celery-deployment.yaml b/charts/passport/templates/celery/passport-celery-deployment.yaml new file mode 100644 index 0000000..5c118b0 --- /dev/null +++ b/charts/passport/templates/celery/passport-celery-deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: drycc-passport-celery + labels: + heritage: drycc + annotations: + component.drycc.cc/version: {{ .Values.imageTag }} +spec: + replicas: {{ .Values.celery.replicas }} + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + type: RollingUpdate + selector: + matchLabels: + app: drycc-passport + component: drycc-passport-celery + template: + metadata: + labels: {{- include "common.labels.standard" . | nindent 8 }} + app: drycc-passport + component: drycc-passport-celery + spec: + affinity: + podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.celery.podAffinityPreset.type "component" "" "extraMatchLabels" .Values.celery.podAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }} + podAntiAffinity: {{- include "common.affinities.pods" (dict "type" .Values.celery.podAntiAffinityPreset.type "component" "" "extraMatchLabels" .Values.celery.podAntiAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }} + nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.celery.nodeAffinityPreset.type "key" .Values.celery.nodeAffinityPreset.key "values" .Values.celery.nodeAffinityPreset.values ) | nindent 10 }} + serviceAccount: drycc-passport + initContainers: + - name: drycc-passport-init + image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/python-dev:latest + imagePullPolicy: {{.Values.imagePullPolicy}} + args: + - netcat + - -v + - -u + - $(DRYCC_DATABASE_URL),$(DRYCC_DATABASE_REPLICA_URL),$(DRYCC_VALKEY_URL) + {{- include "passport.envs" . | indent 8 }} + containers: + - name: drycc-passport-celery + image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/passport:{{.Values.imageTag}} + imagePullPolicy: {{.Values.imagePullPolicy}} + {{- if .Values.diagnosticMode.enabled }} + command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 10 }} + args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 10 }} + {{- else }} + args: + - celery + - -A + - api + - worker + - -Q + - passport.notifications + - -l + - info + {{- end }} + {{- with index .Values "celery" "resources" }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- include "passport.envs" . | indent 8 }} + {{- include "passport.volumeMounts" . | indent 8 }} + {{- include "passport.volumes" . | indent 6 }} diff --git a/charts/passport/templates/passport-configmap.yaml b/charts/passport/templates/passport-configmap.yaml index 858b6cb..8280dcf 100644 --- a/charts/passport/templates/passport-configmap.yaml +++ b/charts/passport/templates/passport-configmap.yaml @@ -1,12 +1,15 @@ -{{- if eq .Values.global.passportLocation "on-cluster" }} apiVersion: v1 kind: ConfigMap metadata: name: passport-config labels: heritage: drycc -data: data: init-applications.json: |- {{ toPrettyJson .Values.initApplications | indent 4 }} -{{- end }} + reserved-name-patterns.txt: |- + {{- if .Values.reservedNames }} + {{- (tpl .Values.reservedNames $) | nindent 4 }} + {{- else}} + {{- .Files.Get "files/reserved-name-patterns.txt" | nindent 4 }} + {{- end }} \ No newline at end of file diff --git a/charts/passport/templates/passport-job-upgrade.yaml b/charts/passport/templates/passport-job-upgrade.yaml index 1af08ea..b15a2e0 100644 --- a/charts/passport/templates/passport-job-upgrade.yaml +++ b/charts/passport/templates/passport-job-upgrade.yaml @@ -1,12 +1,9 @@ -{{- if eq .Values.global.passportLocation "on-cluster" }} apiVersion: batch/v1 kind: Job metadata: name: drycc-passport-job-upgrade annotations: component.drycc.cc/version: {{ .Values.imageTag }} - helm.sh/hook: post-install,pre-upgrade,pre-rollback - helm.sh/hook-delete-policy: before-hook-creation spec: template: spec: @@ -40,9 +37,11 @@ spec: fi python /workspace/manage.py create_oauth2_application --path /etc/drycc/passport/init-applications.json {{- end }} - {{- include "passport.limits" . | indent 8 }} + {{- with index .Values "resources" }} + resources: + {{- toYaml . | nindent 10 }} + {{- end }} {{- include "passport.envs" . | indent 8 }} {{- include "passport.volumeMounts" . | indent 8 }} {{- include "passport.volumes" . | indent 6 }} restartPolicy: Never -{{- end }} diff --git a/charts/passport/templates/passport-secret-creds.yaml b/charts/passport/templates/passport-secret-creds.yaml index 11dba27..a3b0618 100644 --- a/charts/passport/templates/passport-secret-creds.yaml +++ b/charts/passport/templates/passport-secret-creds.yaml @@ -1,4 +1,3 @@ -{{- if eq .Values.global.passportLocation "on-cluster" }} apiVersion: v1 kind: Secret metadata: @@ -6,19 +5,20 @@ metadata: labels: heritage: drycc data: + {{- if (.Values.valkeyUrl) }} + valkey-url: {{ .Values.valkeyUrl | b64enc }} + {{- end }} {{- if (.Values.databaseUrl) }} database-url: {{ .Values.databaseUrl | b64enc }} {{- end }} {{- if (.Values.databaseReplicaUrl) }} database-replica-url: {{ .Values.databaseReplicaUrl | b64enc }} {{- end }} - django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" "django-secret-key" "defaultValue" (randAscii 64) "context" $)) }} + django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" "django-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }} oidc-rsa-private-key: {{genPrivateKey "rsa" | b64enc}} {{- range $item := .Values.initApplications }} - {{- if ($item.prefix) }} {{- $name := ($item.name | replace " " "-" | lower) }} - drycc-passport-{{$name}}-key: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" "drycc-passport-($name)-key" "defaultValue" ($item.key | default (randAlphaNum 40)) "context" $)) }} - drycc-passport-{{$name}}-secret: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" "drycc-passport-($name)-secret" "defaultValue" ($item.secret | default (randAlphaNum 64)) "context" $)) }} - {{- end }} + drycc-passport-{{$name}}-key: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" (printf "drycc-passport-%s-key" $name) "defaultValue" ($item.key | default (randAlphaNum 40)) "context" $)) }} + drycc-passport-{{$name}}-secret: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" (printf "drycc-passport-%s-secret" $name) "defaultValue" ($item.secret | default (randAlphaNum 64)) "context" $)) }} + drycc-passport-{{$name}}-scopes: {{ ($item.allowed_scopes | default "") | b64enc | quote }} {{- end }} -{{- end }} diff --git a/charts/passport/templates/passport-service-account.yaml b/charts/passport/templates/passport-service-account.yaml index 9952519..c5714ad 100644 --- a/charts/passport/templates/passport-service-account.yaml +++ b/charts/passport/templates/passport-service-account.yaml @@ -1,8 +1,6 @@ -{{- if eq .Values.global.passportLocation "on-cluster" }} apiVersion: v1 kind: ServiceAccount metadata: name: drycc-passport labels: heritage: drycc -{{- end }} diff --git a/charts/passport/templates/passport-service.yaml b/charts/passport/templates/passport-service.yaml index 1549301..17119f7 100644 --- a/charts/passport/templates/passport-service.yaml +++ b/charts/passport/templates/passport-service.yaml @@ -1,4 +1,3 @@ -{{- if eq .Values.global.passportLocation "on-cluster" }} apiVersion: v1 kind: Service metadata: @@ -8,7 +7,9 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} labels: + app: drycc-passport heritage: drycc + component: drycc-passport-api spec: ports: - name: http @@ -16,4 +17,4 @@ spec: targetPort: 8000 selector: app: drycc-passport -{{- end }} \ No newline at end of file + component: drycc-passport-api diff --git a/charts/passport/values.yaml b/charts/passport/values.yaml index 89fcbcf..ac0982b 100644 --- a/charts/passport/values.yaml +++ b/charts/passport/values.yaml @@ -18,26 +18,54 @@ diagnosticMode: args: - infinity -nodeAffinityPreset: - key: "drycc.cc/node" - type: "soft" - values: - - "true" - -podAffinityPreset: - type: "" - extraMatchLabels: - security: "drycc-security" - -podAntiAffinityPreset: - type: "soft" - extraMatchLabels: - app: "drycc-passport" +api: + replicas: 1 + resources: {} + # limits: + # cpu: 200m + # memory: 50Mi + # requests: + # cpu: 100m + # memory: 30Mi + nodeAffinityPreset: + key: "drycc.cc/node" + type: "soft" + values: + - "true" + podAffinityPreset: + type: "" + extraMatchLabels: + security: "drycc-security" + podAntiAffinityPreset: + type: "soft" + extraMatchLabels: + app: "drycc-passport-api" -# Set passport deployment replicas -replicas: 1 -# limitsCpu: "100m" -# limitsMemory: "50Mi" +celery: + replicas: 1 + resources: {} + # limits: + # cpu: 200m + # memory: 50Mi + # requests: + # cpu: 100m + # memory: 30Mi + nodeAffinityPreset: + key: "drycc.cc/node" + type: "soft" + values: + - "true" + podAffinityPreset: + type: "" + extraMatchLabels: + security: "drycc-security" + podAntiAffinityPreset: + type: "soft" + extraMatchLabels: + app: "drycc-passport" + component: "drycc-passport-celery" +## valkeyUrl is will no longer use the built-in valkey component +valkeyUrl: "" ## databaseUrl and databaseReplicaUrl are will no longer use the built-in database component databaseUrl: "" databaseReplicaUrl: "" @@ -78,7 +106,8 @@ environment: adminUsername: "admin" adminPassword: "admin" adminEmail: "admin@email.com" - +# Reserved names +reservedNames: "" # The following configurations to initialize oauth2 application # Names are all lowercase letters # The key and secret are generated automatically if they are empty @@ -89,53 +118,50 @@ initApplications: key: "" secret: "" prefix: "drycc" - grant_type: "password" + grant_type: "internal" + client_type: "confidential" + allowed_scopes: "openid profile email manager:workspace manager:usage passport:message" redirect_uri: "/v2/complete/drycc/" - name: "grafana" key: "" secret: "" - prefix: "drycc-monitor-grafana" - grant_type: "authorization-code" - redirect_uri: "/login/generic_oauth" + prefix: "drycc-grafana" + grant_type: "internal" + client_type: "confidential" + allowed_scopes: "openid profile email passport:message controller:metrics controller:logs" + redirect_uri: "/oauth2/callback" +- name: "builder" + key: "" + secret: "" + prefix: "" + grant_type: "client-credentials" + client_type: "confidential" + allowed_scopes: "controller:hook" + redirect_uri: "" +- name: "manager" + key: "" + secret: "" + prefix: "" + grant_type: "client-credentials" + client_type: "confidential" + allowed_scopes: "controller:blocklist" + redirect_uri: "" # Service service: # Provide any additional service annotations annotations: {} -redis: - replicas: 1 +valkey: + enabled: true -global: - # Set the location of Workflow's grafana instance - # - # Valid values are: - # - on-cluster: Run Grafana within the Kubernetes cluster - # - off-cluster: Grafana is running outside of the cluster - grafanaLocation: "on-cluster" +database: + enabled: true - # Admin email, used for each component to send email to administrator - email: "drycc@drycc.cc" - # Set the location of Workflow's PostgreSQL database - # - # Valid values are: - # - on-cluster: Run PostgreSQL within the Kubernetes cluster (credentials are generated - # automatically; backups are sent to object storage - # configured above) - # - off-cluster: Run PostgreSQL outside the Kubernetes cluster (configure in database section) - databaseLocation: "on-cluster" - - # Please check `kubernetes.io/ingress.class` - ingressClass: "" - # A domain name consists of one or more parts. - # Periods (.) are used to separate these parts. - # Each part must be 1 to 63 characters in length and can contain lowercase letters, digits, and hyphens (-). - # It must start and end with a lowercase letter or digit. - clusterDomain: "cluster.local" +global: # The public resolvable hostname to build your cluster with. # # This will be the hostname that is used to build endpoints such as "drycc.$HOSTNAME" platformDomain: "" # Whether cert_passport is enabled to automatically generate passport certificates certManagerEnabled: true - passportLocation: "on-cluster" diff --git a/rootfs/Dockerfile b/rootfs/Dockerfile index bb58da3..c08620f 100644 --- a/rootfs/Dockerfile +++ b/rootfs/Dockerfile @@ -4,7 +4,7 @@ FROM registry.drycc.cc/drycc/base:${CODENAME} as build-app ADD web /web WORKDIR /web -ENV NODE_VERSION="20" +ENV NODE_VERSION="24" RUN install-stack node $NODE_VERSION && . init-stack \ && npm install --global yarn \ @@ -13,10 +13,10 @@ RUN install-stack node $NODE_VERSION && . init-stack \ FROM registry.drycc.cc/drycc/base:${CODENAME} -ENV DRYCC_UID=1001 \ +ARG DRYCC_UID=1001 \ DRYCC_GID=1001 \ DRYCC_HOME_DIR=/workspace \ - PYTHON_VERSION="3.12" + PYTHON_VERSION="3.14" RUN groupadd drycc --gid ${DRYCC_GID} \ && useradd drycc -u ${DRYCC_UID} -g ${DRYCC_GID} -s /bin/bash -m -d ${DRYCC_HOME_DIR} diff --git a/rootfs/Dockerfile.test b/rootfs/Dockerfile.test index 3e0230f..ea284df 100644 --- a/rootfs/Dockerfile.test +++ b/rootfs/Dockerfile.test @@ -1,11 +1,33 @@ +ARG CODENAME +FROM registry.drycc.cc/drycc/base:${CODENAME} as build-app + +ADD web /web +WORKDIR /web + +ENV NODE_VERSION="24" + +RUN install-stack node $NODE_VERSION && . init-stack \ + && npm install --global yarn \ + && yarn install \ + && yarn build + + ARG CODENAME FROM registry.drycc.cc/drycc/base:${CODENAME} -ENV DRYCC_HOME_DIR=/workspace \ - PGDATA="/var/lib/postgresql/data" \ - PYTHON_VERSION="3.12" \ - POSTGRES_VERSION="15.5" \ - GOSU_VERSION="1.17" +ARG DRYCC_UID=1001 \ + DRYCC_GID=1001 \ + DRYCC_HOME_DIR=/workspace \ + PYTHON_VERSION="3.14" \ + VALKEY_VERSION="9.0.1" \ + POSTGRES_VERSION="18.1" \ + GOSU_VERSION="1.19" + + +RUN groupadd drycc --gid ${DRYCC_GID} \ + && useradd drycc -u ${DRYCC_UID} -g ${DRYCC_GID} -s /bin/bash -m -d ${DRYCC_HOME_DIR} + +ENV PGDATA="/var/lib/postgresql/data" COPY requirements.txt ${DRYCC_HOME_DIR}/requirements.txt COPY dev_requirements.txt ${DRYCC_HOME_DIR}/dev_requirements.txt @@ -15,6 +37,7 @@ RUN buildDeps='gcc rustc cargo libffi-dev musl-dev libldap2-dev libsasl2-dev'; \ && curl -SsL https://cli.codecov.io/latest/$([ $(dpkg --print-architecture) == "arm64" ] && echo linux-arm64 || echo linux)/codecov -o /usr/local/bin/codecov \ && chmod +x /usr/local/bin/codecov \ && install-stack python $PYTHON_VERSION \ + && install-stack valkey $VALKEY_VERSION \ && install-stack postgresql $POSTGRES_VERSION \ && install-stack gosu $GOSU_VERSION && . init-stack \ && python3 -m venv ${DRYCC_HOME_DIR}/.venv \ @@ -49,6 +72,9 @@ RUN buildDeps='gcc rustc cargo libffi-dev musl-dev libldap2-dev libsasl2-dev'; \ && gosu postgres initdb -D $PGDATA COPY . ${DRYCC_HOME_DIR} +COPY --chown=${DRYCC_UID}:${DRYCC_GID} . ${DRYCC_HOME_DIR} +COPY --chown=${DRYCC_UID}:${DRYCC_GID} --from=build-app /web/dist ${DRYCC_HOME_DIR}/web/dist + WORKDIR ${DRYCC_HOME_DIR} CMD ["bin/boot"] EXPOSE 8000 diff --git a/rootfs/api/__init__.py b/rootfs/api/__init__.py index 1a72d32..15d33ec 100644 --- a/rootfs/api/__init__.py +++ b/rootfs/api/__init__.py @@ -1 +1,7 @@ +""" +The **api** Django app presents a RESTful web API for interacting with the **drycc** system. +""" +from .settings.celery import app as celery_app + __version__ = '1.1.0' +__all__ = ('celery_app',) diff --git a/rootfs/api/admin.py b/rootfs/api/admin.py index c37cf8b..e6328f0 100644 --- a/rootfs/api/admin.py +++ b/rootfs/api/admin.py @@ -1,7 +1,22 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from oauth2_provider.admin import ApplicationAdmin as BaseApplicationAdmin -from .models import User +from .models import User, Message, MessagePreference, Application + + +try: + admin.site.unregister(Application) +except admin.sites.NotRegistered: + pass + + +@admin.register(Application) +class ApplicationAdmin(BaseApplicationAdmin): + list_display = ( + "id", "name", "user", "client_type", + "authorization_grant_type", "allowed_scopes", + ) class UserAdmin(BaseUserAdmin): @@ -13,4 +28,18 @@ class UserAdmin(BaseUserAdmin): ) +class MessageAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'category', 'title', 'severity', 'is_read', 'created_at') + list_filter = ('category', 'severity', 'is_read', 'created_at') + search_fields = ('title', 'content', 'user__username') + readonly_fields = ('created_at', 'updated_at') + + +class MessagePreferenceAdmin(admin.ModelAdmin): + list_display = ('user', 'email_alerts', 'push_alerts') + search_fields = ('user__username',) + + admin.site.register(User, UserAdmin) +admin.site.register(Message, MessageAdmin) +admin.site.register(MessagePreference, MessagePreferenceAdmin) diff --git a/rootfs/api/apps.py b/rootfs/api/apps.py new file mode 100644 index 0000000..85e1f47 --- /dev/null +++ b/rootfs/api/apps.py @@ -0,0 +1,10 @@ +from django import apps + + +class AppConfig(apps.AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' + + def ready(self): + super(AppConfig, self).ready() + __import__("api.signals") diff --git a/rootfs/api/apps_extra/__init__.py b/rootfs/api/apps_extra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/api/apps_extra/social_core/__init__.py b/rootfs/api/apps_extra/social_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/api/apps_extra/social_core/backends.py b/rootfs/api/apps_extra/social_core/backends.py new file mode 100644 index 0000000..34f8ab6 --- /dev/null +++ b/rootfs/api/apps_extra/social_core/backends.py @@ -0,0 +1,82 @@ +import json +from os.path import join, dirname +from pathlib import Path +from social_core.backends.oauth import BaseOAuth2 +from social_core.backends.github import GithubOAuth2 as BaseGithubOAuth2 +from social_core.backends.google import GoogleOAuth2 as BaseGoogleOAuth2 +from social_core.backends.weixin import WeixinOAuth2 as BaseWeixinOAuth2 + + +def icon_path(name): + return Path(join(dirname(__file__), "icons", f"{name}.svg")) + + +class GithubOAuth2(BaseGithubOAuth2): + icon = icon_path("github").read_text(encoding="utf-8") + + +class GoogleOAuth2(BaseGoogleOAuth2): + name = "google" + icon = icon_path("google").read_text(encoding="utf-8") + + +class WeixinOAuth2(BaseWeixinOAuth2): + icon = icon_path("weixin").read_text(encoding="utf-8") + + +class FeiShuOAuth2(BaseOAuth2): + """FeiShu OAuth2 authentication backend""" + name = 'feishu' + icon = icon_path("feishu").read_text(encoding="utf-8") + + AUTHORIZATION_URL = 'https://accounts.feishu.cn/open-apis/authen/v1/authorize' + ACCESS_TOKEN_URL = 'https://open.feishu.cn/open-apis/authen/v2/oauth/token' + USER_INFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info' + + DEFAULT_SCOPE = [''] + REDIRECT_STATE = False + ACCESS_TOKEN_METHOD = 'POST' + EXTRA_DATA = [ + ('refresh_token', 'refresh_token'), + ('expires_in', 'expires'), + ('union_id', 'union_id'), + ('access_token', 'access_token'), + ] + + def request_access_token(self, *args, **kwargs): + """Override to send JSON request provided by FeiShu""" + if 'data' in kwargs: + kwargs['data']['app_id'] = self.setting('KEY') + kwargs['data']['app_secret'] = self.setting('SECRET') + kwargs['data'] = json.dumps(kwargs['data']) + if 'headers' not in kwargs: + kwargs['headers'] = {} + kwargs['headers']['Content-Type'] = 'application/json; charset=utf-8' + return super().request_access_token(*args, **kwargs) + + def get_user_details(self, response): + """Return user details from FeiShu account""" + return { + 'username': response.get('name', ''), + 'email': response.get('email', ''), + } + + def user_data(self, access_token, *args, **kwargs): + """Loads user data from service""" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json; charset=utf-8' + } + response = self.get_json( + self.USER_INFO_URL, + headers=headers + ) + # FeiShu returns data wrapped in 'data' field + if 'data' in response: + response.update(response['data']) + + response['union_id'] = response.get('union_id', '') + return response + + +__all__ = [GithubOAuth2, GoogleOAuth2, WeixinOAuth2, FeiShuOAuth2] diff --git a/rootfs/api/apps_extra/social_core/icons/feishu.svg b/rootfs/api/apps_extra/social_core/icons/feishu.svg new file mode 100644 index 0000000..7afdb94 --- /dev/null +++ b/rootfs/api/apps_extra/social_core/icons/feishu.svg @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/rootfs/api/apps_extra/social_core/icons/github.svg b/rootfs/api/apps_extra/social_core/icons/github.svg new file mode 100644 index 0000000..c5858b0 --- /dev/null +++ b/rootfs/api/apps_extra/social_core/icons/github.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/rootfs/api/apps_extra/social_core/icons/google.svg b/rootfs/api/apps_extra/social_core/icons/google.svg new file mode 100644 index 0000000..c2fd469 --- /dev/null +++ b/rootfs/api/apps_extra/social_core/icons/google.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/rootfs/api/apps_extra/social_core/icons/weixin.svg b/rootfs/api/apps_extra/social_core/icons/weixin.svg new file mode 100644 index 0000000..fe20def --- /dev/null +++ b/rootfs/api/apps_extra/social_core/icons/weixin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rootfs/api/apps_extra/social_core/pipelines.py b/rootfs/api/apps_extra/social_core/pipelines.py new file mode 100644 index 0000000..d8f24b2 --- /dev/null +++ b/rootfs/api/apps_extra/social_core/pipelines.py @@ -0,0 +1,59 @@ +""" +Custom social auth pipeline for OAuth2 binding and creation guidance. +""" + +import logging + +from django.shortcuts import redirect +from social_django.models import UserSocialAuth + +from api.utils import get_oauth_callback + +logger = logging.getLogger(__name__) + + +def handle_authenticated_binding(backend, uid, details, response, *args, **kwargs): + request = backend.strategy.request + if not request or not request.user.is_authenticated: + return None + + current_user = request.user + try: + existing_social = UserSocialAuth.objects.filter( + provider=backend.name, uid=uid + ).select_related('user').first() + if existing_social and existing_social.user_id != current_user.id: + return redirect(get_oauth_callback(request, 'conflict', backend.name)) + if existing_social and existing_social.user_id == current_user.id: + return redirect(get_oauth_callback(request, 'already_linked', backend.name)) + + UserSocialAuth.objects.create( + user=current_user, + provider=backend.name, + uid=uid, + extra_data=response or {} + ) + return redirect(get_oauth_callback(request, 'linked', backend.name)) + except Exception as exc: + logger.exception(exc) + return redirect(get_oauth_callback(request, 'error', backend.name)) + + +def require_username_password(backend, uid, details, response, user=None, *args, **kwargs): + request = backend.strategy.request + if request and request.user.is_authenticated: + return None + if user: + return None + + email = details.get('email') + if not email: + return redirect(get_oauth_callback(request, 'missing_email', backend.name)) + + backend.strategy.session_set('oauth_pending', { + 'provider': backend.name, + 'uid': uid, + 'email': email, + 'extra_data': response or {}, + }) + return redirect(get_oauth_callback(request, 'pending', backend.name)) diff --git a/rootfs/api/exceptions.py b/rootfs/api/exceptions.py index d409c39..dfd7d2e 100644 --- a/rootfs/api/exceptions.py +++ b/rootfs/api/exceptions.py @@ -43,7 +43,6 @@ def custom_exception_handler(exc, context): # No response means DRF couldn't handle it # Output a generic 500 in a JSON format - print(response) if response is None: logging.exception('Uncaught Exception', exc_info=exc) set_rollback() diff --git a/rootfs/api/management/commands/create_oauth2_application.py b/rootfs/api/management/commands/create_oauth2_application.py index 14f59d8..bd01a6c 100644 --- a/rootfs/api/management/commands/create_oauth2_application.py +++ b/rootfs/api/management/commands/create_oauth2_application.py @@ -4,6 +4,7 @@ import string import pathlib from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password from django.core.management.base import BaseCommand from oauth2_provider.models import get_application_model @@ -14,7 +15,26 @@ class Command(BaseCommand): - """Management command for create Oauth2 application""" + """Management command for create Oauth2 application. + + Credential resolution order for ``client_id`` / ``client_secret``: + + 1. Explicit value from the init-applications JSON file (highest priority, + lets operators pin credentials via ``--set initApplications[...]``). + 2. Mounted Kubernetes secret file at + ``/var/run/secrets/drycc/passport/drycc-passport--``. + This is the source of truth for credentials that the chart's + ``passport-creds`` Secret already exposes to every consumer (controller, + grafana, builder, manager, ...). Reading it here keeps the DB and the + Secret consistent for *every* application, including m2m apps that + have no public sub-domain (``prefix == ""``). + 3. Newly generated random string (only when neither of the above is + available, e.g. local dev without the volume mount). + + Note: ``prefix`` only controls how ``redirect_uri`` is composed. It is + intentionally NOT used to gate credential discovery -- m2m applications + legitimately have an empty prefix. + """ def add_arguments(self, parser): super(Command, self).add_arguments(parser) @@ -28,32 +48,55 @@ def handle(self, *args, **options): user = User.objects.filter(is_superuser=True).first() for item in json.loads(pathlib.Path(base_path).read_text()): name = item["name"] - _, updated = Application.objects.update_or_create( - name=name.lower(), - defaults={ - 'client_id': self._get_creds(item, "key", 40), - 'client_secret': self._get_creds(item, "secret", 60), - 'user': user, - 'redirect_uris': self._get_redirect_uri(item), - 'authorization_grant_type': item['grant_type'], - 'client_type': 'public', - 'algorithm': 'RS256' - } + client_id = self._get_creds(item, "key", 40) + client_secret = self._get_creds(item, "secret", 60) + defaults = { + 'client_id': client_id, + 'user': user, + 'redirect_uris': self._get_redirect_uri(item), + 'authorization_grant_type': item.get('grant_type', 'public'), + 'client_type': item.get('client_type', 'public'), + 'allowed_scopes': item.get('allowed_scopes', ''), + 'algorithm': 'RS256', + } + existing = Application.objects.filter(name=name.lower()).first() + secret_unchanged = ( + existing is not None + and check_password(client_secret, existing.client_secret) + ) + if not secret_unchanged: + defaults['client_secret'] = client_secret + _, created = Application.objects.update_or_create( + name=name.lower(), defaults=defaults, ) - if updated: - self.stdout.write('Drycc % app created' % name) + if created: + self.stdout.write('Drycc %s app created' % name) else: - self.stdout.write('Drycc % app updated' % name) + self.stdout.write('Drycc %s app updated' % name) def _get_creds(self, item, suffix, size): - name, secret, prefix = item["name"], item[suffix], item["prefix"] - if not secret: - default_secret_path = os.path.join( - secrets_path, "drycc-passport-%s-%s" % (name, suffix)) - if prefix and os.path.exists(default_secret_path): - secret = pathlib.Path(default_secret_path).read_text() - else: - secret = ''.join([random.choice(string.ascii_letters) for _ in range(size)]) + name = item["name"] + secret = item.get(suffix) + if secret: + self.stdout.write( + '[%s/%s] credential source: init-config' % (name, suffix)) + return secret + + default_secret_path = os.path.join( + secrets_path, "drycc-passport-%s-%s" % (name, suffix)) + if os.path.exists(default_secret_path): + # ``.strip()`` defends against trailing newlines that some + # tooling adds when materialising secrets onto disk. + secret = pathlib.Path(default_secret_path).read_text().strip() + self.stdout.write( + '[%s/%s] credential source: mounted-file (%s)' + % (name, suffix, default_secret_path)) + return secret + + secret = ''.join(random.choice(string.ascii_letters) for _ in range(size)) + self.stdout.write( + '[%s/%s] credential source: generated-random ' + '(no init value, no mounted file)' % (name, suffix)) return secret def _get_redirect_uri(self, item): diff --git a/rootfs/api/migrations/0003_application_allowed_origins_and_more.py b/rootfs/api/migrations/0003_application_allowed_origins_and_more.py new file mode 100644 index 0000000..4e30289 --- /dev/null +++ b/rootfs/api/migrations/0003_application_allowed_origins_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-11-14 05:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_application'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='allowed_origins', + field=models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated'), + ), + migrations.AddField( + model_name='application', + name='hash_client_secret', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='application', + name='post_logout_redirect_uris', + field=models.TextField(blank=True, default='', help_text='Allowed Post Logout URIs list, space separated'), + ), + ] diff --git a/rootfs/api/migrations/0004_alter_application_authorization_grant_type_message_and_more.py b/rootfs/api/migrations/0004_alter_application_authorization_grant_type_message_and_more.py new file mode 100644 index 0000000..3e7d8bd --- /dev/null +++ b/rootfs/api/migrations/0004_alter_application_authorization_grant_type_message_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.13 on 2026-04-23 03:45 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_application_allowed_origins_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44), + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('system', 'System'), ('product', 'Product Updates'), ('security', 'Security'), ('alert', 'Alerts'), ('service', 'Service')], default='system', max_length=20, verbose_name='category')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('content', models.TextField(verbose_name='content')), + ('full_content', models.TextField(blank=True, verbose_name='full content')), + ('severity', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=20, verbose_name='severity')), + ('is_read', models.BooleanField(default=False, verbose_name='is read')), + ('action_link', models.CharField(blank=True, max_length=500, verbose_name='action link')), + ('action_text', models.CharField(blank=True, max_length=255, verbose_name='action text')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'message', + 'verbose_name_plural': 'messages', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='MessagePreference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email_alerts', models.BooleanField(default=True, verbose_name='email alerts')), + ('push_alerts', models.BooleanField(default=False, verbose_name='push alerts')), + ('webhook_url', models.URLField(blank=True, max_length=500, verbose_name='webhook url')), + ('notify_security', models.BooleanField(default=True, verbose_name='notify security')), + ('notify_system', models.BooleanField(default=True, verbose_name='notify system')), + ('notify_product', models.BooleanField(default=False, verbose_name='notify product')), + ('notify_alert', models.BooleanField(default=True, verbose_name='notify alert')), + ('notify_service', models.BooleanField(default=True, verbose_name='notify service')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='message_preference', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'message preference', + 'verbose_name_plural': 'message preferences', + }, + ), + ] diff --git a/rootfs/api/migrations/0005_application_allowed_scopes_and_internal_grant.py b/rootfs/api/migrations/0005_application_allowed_scopes_and_internal_grant.py new file mode 100644 index 0000000..a5be209 --- /dev/null +++ b/rootfs/api/migrations/0005_application_allowed_scopes_and_internal_grant.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.13 on 2026-05-09 12:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +def forward_data_migration(apps, schema_editor): + Application = apps.get_model('api', 'Application') + oauth2_settings = getattr(settings, 'OAUTH2_PROVIDER', {}) + default_provider_scopes_list = oauth2_settings.get("DEFAULT_SCOPES", ['openid', 'email', 'profile']) + default_provider_scopes = " ".join(default_provider_scopes_list) + + for app in Application.objects.all(): + if not app.allowed_scopes: + app.allowed_scopes = default_provider_scopes + + if app.name.lower() in ("controller", "grafana"): + app.authorization_grant_type = "internal" + + app.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_alter_application_authorization_grant_type_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='allowed_scopes', + field=models.TextField(blank=True, default=''), + ), + migrations.RunPython(forward_data_migration, reverse_code=migrations.RunPython.noop), + ] \ No newline at end of file diff --git a/rootfs/api/models.py b/rootfs/api/models.py index cd4a53c..6e08c64 100644 --- a/rootfs/api/models.py +++ b/rootfs/api/models.py @@ -1,17 +1,132 @@ from django.db import models from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ - from oauth2_provider.models import AbstractApplication +from .validators import UsernameValidator class User(AbstractUser): + username_validator = UsernameValidator() email = models.EmailField(_('email address'), unique=True) class Application(AbstractApplication): + GRANT_INTERNAL = "internal" + GRANT_TYPES = AbstractApplication.GRANT_TYPES + ( + (GRANT_INTERNAL, _("Internal")), + ) + allowed_scopes = models.TextField(blank=True, default="") def allows_grant_type(self, *grant_types): - return self.GRANT_AUTHORIZATION_CODE in grant_types or super().allows_grant_type( - *grant_types - ) + if self.authorization_grant_type == self.GRANT_INTERNAL: + return True + return super().allows_grant_type(*grant_types) + + +class Message(models.Model): + CATEGORY_CHOICES = [ + ('system', _('System')), + ('product', _('Product Updates')), + ('security', _('Security')), + ('alert', _('Alerts')), + ('service', _('Service')), + ] + + SEVERITY_CHOICES = [ + ('info', _('Info')), + ('warning', _('Warning')), + ('error', _('Error')), + ('success', _('Success')), + ] + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='messages', + verbose_name=_('user') + ) + category = models.CharField( + _('category'), + max_length=20, + choices=CATEGORY_CHOICES, + default='system' + ) + title = models.CharField(_('title'), max_length=255) + content = models.TextField(_('content')) + full_content = models.TextField(_('full content'), blank=True) + severity = models.CharField( + _('severity'), + max_length=20, + choices=SEVERITY_CHOICES, + default='info' + ) + is_read = models.BooleanField(_('is read'), default=False) + action_link = models.CharField( + _('action link'), + max_length=500, + blank=True + ) + action_text = models.CharField( + _('action text'), + max_length=255, + blank=True + ) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + updated_at = models.DateTimeField(_('updated at'), auto_now=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = _('message') + verbose_name_plural = _('messages') + + def __str__(self): + return f"[{self.category}] {self.title}" + + +class MessagePreference(models.Model): + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='message_preference', + verbose_name=_('user') + ) + email_alerts = models.BooleanField( + _('email alerts'), + default=True + ) + push_alerts = models.BooleanField( + _('push alerts'), + default=False + ) + webhook_url = models.URLField( + _('webhook url'), + max_length=500, + blank=True + ) + notify_security = models.BooleanField( + _('notify security'), + default=True + ) + notify_system = models.BooleanField( + _('notify system'), + default=True + ) + notify_product = models.BooleanField( + _('notify product'), + default=True + ) + notify_alert = models.BooleanField( + _('notify alert'), + default=True + ) + notify_service = models.BooleanField( + _('notify service'), + default=True + ) + + class Meta: + verbose_name = _('message preference') + verbose_name_plural = _('message preferences') + + def __str__(self): + return f"{self.user.username}'s message preference" diff --git a/rootfs/api/oauth2_validators.py b/rootfs/api/oauth2_validators.py index ee6f654..d968276 100644 --- a/rootfs/api/oauth2_validators.py +++ b/rootfs/api/oauth2_validators.py @@ -3,7 +3,26 @@ class CustomOAuth2Validator(OAuth2Validator): - oidc_claim_scope = None + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + if client.allowed_scopes: + allowed = set(client.allowed_scopes.split()) + if not set(scopes).issubset(allowed): + return False + return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs) + + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({ + "id": "profile", + "name": "profile", + "username": "profile", + "email": "email", + "first_name": "profile", + "last_name": "profile", + "is_staff": "profile", + "is_active": "profile", + "is_superuser": "profile", + "preferred_username": "profile", + }) def get_additional_claims(self, request): claims = super().get_additional_claims(request) @@ -16,4 +35,5 @@ def get_additional_claims(self, request): claims["is_staff"] = request.user.is_staff claims["is_active"] = request.user.is_active claims["is_superuser"] = request.user.is_superuser + claims["preferred_username"] = request.user.username return claims diff --git a/rootfs/api/permissions.py b/rootfs/api/permissions.py new file mode 100644 index 0000000..cb8ad2e --- /dev/null +++ b/rootfs/api/permissions.py @@ -0,0 +1,32 @@ +from django.utils import timezone +from rest_framework import permissions + +from oauth2_provider.models import AccessToken + + +class HasOAuthScope(permissions.BasePermission): + """ + Object-level permission to allow only requests with specific OAuth scopes. + The required scopes are defined on the view as `required_oauth_scopes = ['scope1', 'scope2']` + """ + def has_permission(self, request, view): + required_oauth_scopes = getattr(view, 'required_oauth_scopes', []) + if not required_oauth_scopes: + return True + + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() == 'bearer': + token_string = parts[1] + else: + return False + + scopes = self.get_token_scopes(token_string) + return set(required_oauth_scopes).issubset(scopes) + + def get_token_scopes(self, token_string): + try: + access_token = AccessToken.objects.get(token=token_string, expires__gt=timezone.now()) + return set(access_token.scope.split()) + except AccessToken.DoesNotExist: + return set() diff --git a/rootfs/api/routers.py b/rootfs/api/routers.py index 801d6c9..3e5e981 100644 --- a/rootfs/api/routers.py +++ b/rootfs/api/routers.py @@ -32,4 +32,8 @@ def allow_relation(self, obj1, obj2, **hints): return True def allow_migrate(self, db, app_label, model_name=None, **hints): + if 'replica' in settings.DATABASES and 'model' in hints: + model = hints['model'] + tracker_key = ".".join([model.__module__, model.__name__]) + setattr(self._tracker, tracker_key, 'default') return True diff --git a/rootfs/api/serializers.py b/rootfs/api/serializers.py index 9deb711..4ba083f 100644 --- a/rootfs/api/serializers.py +++ b/rootfs/api/serializers.py @@ -8,6 +8,7 @@ from oauth2_provider.models import Grant, AccessToken from rest_framework import serializers +from api.models import Message, MessagePreference from api.utils import timestamp2datetime User = get_user_model() @@ -56,6 +57,7 @@ class Meta: class UserTokensSerializer(serializers.ModelSerializer): """Serialize user status for a AccessToken model.""" application = serializers.ReadOnlyField(source='application.name') + token_checksum = serializers.ReadOnlyField(required=False) class Meta: model = AccessToken @@ -73,3 +75,44 @@ class Meta: fields = '__all__' read_only_fields = ['action_time', 'user', 'content_type', 'object_id', 'object_repr', 'action_flag', 'change_message'] + + +class MessageSerializer(serializers.ModelSerializer): + """Serialize message model.""" + date = serializers.DateTimeField( + source='created_at', format='%b %d, %Y, %H:%M', read_only=True + ) + + class Meta: + model = Message + fields = ['id', 'category', 'title', 'content', 'full_content', + 'severity', 'is_read', 'action_link', 'action_text', 'date'] + read_only_fields = ['id', 'date'] + + +class MessagePreferenceSerializer(serializers.ModelSerializer): + """Serialize message preference model.""" + + class Meta: + model = MessagePreference + fields = ['email_alerts', 'push_alerts', 'webhook_url', + 'notify_security', 'notify_system', 'notify_product', + 'notify_alert', 'notify_service'] + + +class ServiceMessageSerializer(MessageSerializer): + username = serializers.CharField(write_only=True) + + class Meta(MessageSerializer.Meta): + fields = MessageSerializer.Meta.fields + ['username'] + + def create(self, validated_data): + from django.contrib.auth import get_user_model + User = get_user_model() + username = validated_data.pop('username') + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise serializers.ValidationError({"username": "User not found."}) + validated_data['user'] = user + return super().create(validated_data) diff --git a/rootfs/api/settings/celery.py b/rootfs/api/settings/celery.py new file mode 100644 index 0000000..30af003 --- /dev/null +++ b/rootfs/api/settings/celery.py @@ -0,0 +1,56 @@ +import os + +from kombu import Exchange, Queue +from celery import Celery + + +class Config(object): + # Celery Configuration Options + enable_utc = True + task_serializer = 'pickle' + accept_content = frozenset([ + 'application/data', + 'application/json', + 'application/text', + 'application/x-python-serialize', + ]) + task_track_started = True + task_time_limit = 5 * 60 + worker_max_tasks_per_child = 200 + worker_prefetch_multiplier = 1 + result_expires = 24 * 60 * 60 + cache_backend = 'django-cache' + task_default_queue = 'passport.notifications' + task_default_exchange = 'passport.priority' + task_default_routing_key = 'passport.priority.notifications' + broker_transport_options = {"queue_order_strategy": "sorted"} + task_create_missing_queues = True + task_inherit_parent_priority = True + broker_connection_retry_on_startup = True + worker_cancel_long_running_tasks_on_connection_loss = True + + +app = Celery('passport') +app.config_from_object(Config()) +app.conf.update( + timezone=os.environ.get('TZ', 'UTC'), + task_routes={ + 'api.tasks.send_notification': { + 'queue': 'passport.notifications', + 'exchange': 'passport.priority', 'routing_key': 'passport.priority.notifications', + }, + }, + task_queues=( + Queue( + 'passport.notifications', exchange=Exchange('passport.priority', type="direct"), + routing_key='passport.priority.notifications', + ), + ), +) +DRYCC_VALKEY_URL = os.environ.get('DRYCC_VALKEY_URL', 'redis://:@127.0.0.1:6379') +app.conf.update( + broker_url=DRYCC_VALKEY_URL, + result_backend=DRYCC_VALKEY_URL, + broker_transport_options={"queue_order_strategy": "sorted", "visibility_timeout": 43200}, +) +app.autodiscover_tasks() diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py index fa0063e..c3e5e3b 100644 --- a/rootfs/api/settings/production.py +++ b/rootfs/api/settings/production.py @@ -25,13 +25,19 @@ # https://docs.djangoproject.com/en/2.2/ref/settings/#debug-propagate-exceptions DEBUG_PROPAGATE_EXCEPTIONS = True -# Enable Legal -LEGAL_ENABLED = os.environ.get('LEGAL_ENABLED', 'false').lower() == "true" # Enable Django admin ADMIN_ENABLED = os.environ.get('ADMIN_ENABLED', 'false').lower() == "true" -# Enable Registration -# If this function is enabled, please set Django email related parameters + +# The following information is for configuring app settings +# Enable Legal +LEGAL_ENABLED = os.environ.get('LEGAL_ENABLED', 'false').lower() == "true" +# Enable Registration, email configuration is required REGISTRATION_ENABLED = os.environ.get('REGISTRATION_ENABLED', 'false').lower() == "true" +# URLs for the application +DASHBOARD_URL = os.environ.get('DASHBOARD_URL', '/') +# Contact support URL, which can be a mailto: link or a link to a support ticket system +CONTACT_SUPPORT_URL = os.environ.get('CONTACT_SUPPORT_URL', 'https://community.drycc.cc/') + # Silence two security messages around SSL as router takes care of them # https://docs.djangoproject.com/en/2.2/ref/checks/#security SILENCED_SYSTEM_CHECKS = [ @@ -116,6 +122,7 @@ 'gunicorn', 'rest_framework', 'oauth2_provider', + 'social_django', # passport apps 'api' ) @@ -124,6 +131,10 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTHENTICATION_BACKENDS = ( + "api.apps_extra.social_core.backends.FeiShuOAuth2", + "api.apps_extra.social_core.backends.WeixinOAuth2", + "api.apps_extra.social_core.backends.GithubOAuth2", + "api.apps_extra.social_core.backends.GoogleOAuth2", "django.contrib.auth.backends.AllowAllUsersModelBackend", ) @@ -168,6 +179,7 @@ SECURE_BROWSER_XSS_FILTER = True CSRF_COOKIE_SECURE = os.environ.get('CSRF_COOKIE_SECURE', 'false').lower() == "true" SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == "true" +CSRF_TRUSTED_ORIGINS = [r for r in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if r] # Honor HTTPS from a trusted proxy # see https://docs.djangoproject.com/en/2.2/ref/settings/#secure-proxy-ssl-header @@ -274,7 +286,7 @@ SECRET_KEY = os.environ.get('DRYCC_SECRET_KEY', random_secret) # database default setting -DRYCC_DATABASE_URL = os.environ.get('DRYCC_DATABASE_URL', 'postgres://postgres:123456@49.232.207.93:5432/drycc_passport') # noqa +DRYCC_DATABASE_URL = os.environ.get('DRYCC_DATABASE_URL', 'postgres://postgres:@:5432/passport') DATABASES = { 'default': dj_database_url.config(default=DRYCC_DATABASE_URL) } @@ -292,9 +304,6 @@ STATIC_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'web', 'dist', 'assets')) STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage' -# Avatar URL -AVATAR_URL = "https://cravatar.cn/avatar/" - # see: https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html?highlight=oidc.key#creating-rsa-private-key # noqa OIDC_ENABLED = False OIDC_RSA_PRIVATE_KEY = None @@ -317,26 +326,27 @@ "REFRESH_TOKEN_EXPIRE_SECONDS": int(os.environ.get('REFRESH_TOKEN_EXPIRE_SECONDS', 60 * 86400)), # noqa "ROTATE_REFRESH_TOKEN": True, "SCOPES": { + "email": "Email", "profile": "Profile", "openid": "OpenID Connect scope", + "controller:hook": "Controller Hook", + "controller:blocklist": "Controller Blocklist", + "controller:logs": "Controller Logs", + "controller:metrics": "Controller Metrics", + "manager:workspace": "Manager Workspace", + "manager:usage": "Manager Usage", + "passport:message": "Passport Message", }, - "DEFAULT_SCOPES": ['openid', ], + "DEFAULT_SCOPES": ['openid', 'email', 'profile'], "DEFAULT_CODE_CHALLENGE_METHOD": 'S256', } OAUTH2_PROVIDER_APPLICATION_MODEL = 'api.Application' -# Redis Configuration -DRYCC_REDIS_ADDRS = os.environ.get('DRYCC_REDIS_ADDRS', '127.0.0.1:6379').split(",") -DRYCC_REDIS_PASSWORD = os.environ.get('DRYCC_REDIS_PASSWORD', '') -# Cache Configuration +# Cache Valkey Configuration CACHES = { "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ['redis://:{}@{}'.format(DRYCC_REDIS_PASSWORD, DRYCC_REDIS_ADDR) \ - for DRYCC_REDIS_ADDR in DRYCC_REDIS_ADDRS], # noqa - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.ShardClient", - } + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": os.environ.get('DRYCC_VALKEY_URL', 'redis://:@127.0.0.1:6379'), } } @@ -352,6 +362,42 @@ LDAP_STAFF_GROUP = os.environ.get('LDAP_STAFF_GROUP', '') LDAP_SUPERUSER_GROUP = os.environ.get('LDAP_SUPERUSER_GROUP', '') +# Social Auth settings for OAuth2 providers +SOCIAL_AUTH_FEISHU_KEY = os.environ.get('SOCIAL_AUTH_FEISHU_KEY', '') +SOCIAL_AUTH_FEISHU_SECRET = os.environ.get('SOCIAL_AUTH_FEISHU_SECRET', '') + +SOCIAL_AUTH_WEIXIN_KEY = os.environ.get('SOCIAL_AUTH_WEIXIN_KEY', '') +SOCIAL_AUTH_WEIXIN_SECRET = os.environ.get('SOCIAL_AUTH_WEIXIN_SECRET', '') + +SOCIAL_AUTH_GITHUB_KEY = os.environ.get('SOCIAL_AUTH_GITHUB_KEY', '') +SOCIAL_AUTH_GITHUB_SECRET = os.environ.get('SOCIAL_AUTH_GITHUB_SECRET', '') + +SOCIAL_AUTH_GOOGLE_KEY = os.environ.get('SOCIAL_AUTH_GOOGLE_KEY', '') +SOCIAL_AUTH_GOOGLE_SECRET = os.environ.get('SOCIAL_AUTH_GOOGLE_SECRET', '') + +# Social Auth Pipeline +SOCIAL_AUTH_PIPELINE = ( + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.auth_allowed', + 'api.apps_extra.social_core.pipelines.handle_authenticated_binding', + 'social_core.pipeline.social_auth.social_user', + 'api.apps_extra.social_core.pipelines.require_username_password', + 'social_core.pipeline.social_auth.associate_user', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', +) + +# Social Auth settings +SOCIAL_AUTH_URL_NAMESPACE = 'social' +SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/access-tokens' +SOCIAL_AUTH_LOGIN_CALLBACK_URL = '/oauth/callback' +SOCIAL_AUTH_LOGIN_ERROR_URL = f'{SOCIAL_AUTH_LOGIN_CALLBACK_URL}?status=error' +SOCIAL_AUTH_NEW_USER_REDIRECT_URL = f'{SOCIAL_AUTH_LOGIN_CALLBACK_URL}?status=pending' +SOCIAL_AUTH_USER_MODEL = 'api.User' +SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = True +SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email',] + # Django LDAP backend configuration. # See https://pythonhosted.org/django-auth-ldap/reference.html # for variables' details. @@ -402,5 +448,18 @@ EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'false').lower() == "true" EMAIL_USE_SSL = os.environ.get('EMAIL_USE_SSL', 'false').lower() == "true" +# username regex +USERNAME_REGEX = os.environ.get('USERNAME_REGEX', '^[a-z][a-z0-9]{4,}$') + +# names which apps cannot reserve for routing +RESERVED_NAME_PATTERNS_PATH = os.environ.get( + 'RESERVED_NAME_PATTERNS_PATH', '/etc/controller/reserved-name-patterns.txt') +if os.path.exists(RESERVED_NAME_PATTERNS_PATH): + with open(RESERVED_NAME_PATTERNS_PATH) as f: + RESERVED_NAME_PATTERNS = [line.strip() for line in f if line.strip()] +else: + RESERVED_NAME_PATTERNS = [r"^drycc(?:-[\w-]+)?$", r"^kube(?:-[\w-]+)?$", r"^default$"] + +# hcaptcha config H_CAPTCHA_KEY = os.environ.get("H_CAPTCHA_KEY") H_CAPTCHA_SECRET = os.environ.get("H_CAPTCHA_SECRET") diff --git a/rootfs/api/settings/testing.py b/rootfs/api/settings/testing.py index ffb1527..7f3331c 100644 --- a/rootfs/api/settings/testing.py +++ b/rootfs/api/settings/testing.py @@ -2,9 +2,18 @@ import string import os +from api.settings.celery import app + from api.settings.production import * # noqa from api.settings.production import DATABASES + +app.conf.update(task_always_eager=True) +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' +OAUTH2_PROVIDER_GRANT_MODEL = 'oauth2_provider.Grant' +OAUTH2_PROVIDER_ID_TOKEN_MODEL = 'oauth2_provider.IDToken' + # A boolean that turns on/off debug mode. # https://docs.djangoproject.com/en/2.2/ref/settings/#debug DEBUG = True diff --git a/rootfs/api/signals.py b/rootfs/api/signals.py new file mode 100644 index 0000000..e13a018 --- /dev/null +++ b/rootfs/api/signals.py @@ -0,0 +1,15 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth import get_user_model + +from api.models import Message + +User = get_user_model() + + +@receiver(post_save, sender=Message) +def message_changed_handle(sender, instance, created, **kwargs): + """Queue async notification dispatch when a new message is created.""" + if created: + from api.tasks import send_notification + send_notification.delay(instance) diff --git a/rootfs/api/static/css/main.css b/rootfs/api/static/css/main.css index 432e6bb..685b23d 100644 --- a/rootfs/api/static/css/main.css +++ b/rootfs/api/static/css/main.css @@ -1,37 +1,77 @@ +:root { + --ui-color-bg: #ffffff; + --ui-color-surface: #ffffff; + --ui-color-text: #3f3f44; + --ui-color-text-secondary: #62748e; + --ui-color-primary: #409eff; + --ui-color-primary-hover: #337ecc; + --ui-color-border: #e2e8f0; + --ui-color-muted-bg: #f1f5f9; + --ui-color-danger: #c20707; + + --ui-font-family-base: benton-sans, 'Helvetica Neue', helvetica, arial, sans-serif; + --ui-font-size-base: 14px; + --ui-line-height-base: 1.42857; + + --ui-radius-sm: 6px; + --ui-radius-md: 8px; + --ui-radius-lg: 12px; + + --ui-shadow-sm: 0 2px 4px rgba(64, 158, 255, 0.3); + --ui-shadow-md: 0 4px 8px rgba(64, 158, 255, 0.4); + --ui-shadow-card: 0 4px 6px rgba(0, 0, 0, 0.1); + + --ui-space-1: 4px; + --ui-space-2: 8px; + --ui-space-3: 12px; + --ui-space-4: 16px; + --ui-space-5: 20px; + --ui-space-6: 24px; + --ui-space-7: 32px; +} + +*, *::before, *::after { + box-sizing: border-box; +} + html,body { height: 100%; } body { - font-family: benton-sans,'Helvetica Neue',helvetica,arial,sans-serif; - font-size: 14px; - line-height: 1.42857; - color: #3f3f44; - background-color: #fff; + font-family: var(--ui-font-family-base); + font-size: var(--ui-font-size-base); + line-height: var(--ui-line-height-base); + color: var(--ui-color-text); + background-color: var(--ui-color-bg); margin: 0; } .privacy { - text-align: justify!important; + text-align: justify; } .page-wrap { - min-height: 100%; + min-height: 100vh; display: flex; flex-direction: column; - justify-content: space-between } .gradient-primary { - background-image: linear-gradient(to bottom,#409EFF, #337ecc); - background-color: #337ecc + background-image: linear-gradient(to bottom, var(--ui-color-primary), var(--ui-color-primary-hover)); + background-color: var(--ui-color-primary-hover) } .container { position: relative; - height: 100%; - text-align: center; - padding-top: 2%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 20px; + box-sizing: border-box; + flex: 1; + width: 100%; } h1.logo { @@ -55,31 +95,30 @@ h1.logo a:before { } .panel { - max-width: 430px; - margin: 0 auto 20px; - border-radius: 8px; - background-color: rgb(255, 255, 255); - box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 1px; - border-width: 1px; - border-style: solid; - border-color: transparent; - border-image: initial; + min-width: 0; + width: 100%; + max-width: 520px; + margin: 0 auto; + border-radius: var(--ui-radius-lg); + background-color: var(--ui-color-surface); + box-shadow: var(--ui-shadow-card); + border: none; } .panel .h3 { - margin: 40px 20px 0; + margin: 40px 30px 20px; line-height: 1.5; font-size: 24px; - color: #409EFF; + color: var(--ui-color-primary); } h2 { font-family: inherit; font-weight: 200; - color: #409EFF; + color: var(--ui-color-primary); } .panel form { - padding: 30px + padding: 0px 30px 30px 30px } form { @@ -102,7 +141,7 @@ label { form label { text-align: left; width: 100%; - color: rgb(98, 116, 142); + color: var(--ui-color-text-secondary); font-size: 12px; font-weight: bold; } @@ -118,19 +157,22 @@ input:-webkit-autofill { box-shadow: inset 0 1px 2px rgba(203,203,210,0.4), inset 0 0 10px 1000px #fffedb; } -.form-control { - padding-right: 8px; -} - .form-control { display: block; width: 100%; - padding: 6px 12px; + padding: 10px 16px; font-size: 14px; line-height: 1.42857; - border: 1px solid #cbcbd2; - border-radius: 4px; + border: 1px solid var(--ui-color-border); + border-radius: var(--ui-radius-sm); transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s; + background-color: var(--ui-color-surface); +} + +.form-control:focus { + outline: none; + border-color: var(--ui-color-primary); + box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1); } input, button, select, textarea { @@ -146,7 +188,7 @@ button, input, optgroup, select, textarea { } input { - -webkit-writing-mode: horizontal-tb !important; + writing-mode: horizontal-tb !important; text-rendering: auto; letter-spacing: normal; word-spacing: normal; @@ -154,12 +196,17 @@ input { text-indent: 0px; text-shadow: none; text-align: start; - -webkit-appearance: textfield; + appearance: textfield; background-color: white; -webkit-rtl-ordering: logical; cursor: text; } +input[disabled], input[readonly] { + background: var(--ui-color-muted-bg); + cursor: not-allowed; +} + form .btn { border: 1px solid transparent; } @@ -173,16 +220,82 @@ form .btn { .btn-primary { color: #fff; - background-color: #337ecc; - background-image: -webkit-linear-gradient(left bottom,rgba(159,88,150,0) 0,rgba(121,187,255,0.6) 100%); + background-color: var(--ui-color-primary); + background-image: none; + border: none; + box-shadow: var(--ui-shadow-sm); + transition: all 0.2s ease; +} + +.btn-primary:hover { + background-color: var(--ui-color-primary-hover); + box-shadow: var(--ui-shadow-md); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(64, 158, 255, 0.3); } .btn { - border-radius: 5px; + border-radius: var(--ui-radius-sm); padding-left: 18px; padding-right: 18px; } +@media (max-width: 576px) { + .panel { + max-width: 100%; + } + + .panel .h3 { + margin: 30px 20px 16px; + font-size: 22px; + } + + .panel form, + .oauth2-container { + padding-left: 20px; + padding-right: 20px; + } + + .oauth2-btn { + flex: 1 0 100%; + width: 100%; + } + + .panel-footer { + font-size: 15px; + padding: 16px; + } + + .inverted-wrapper { + padding: 20px 20px 0 20px; + } + + .legal li { + display: block; + margin: 8px 0; + } +} + +@media (min-width: 577px) and (max-width: 768px) { + .panel { + max-width: 490px; + } +} + +/* hCaptcha responsive */ +.h-captcha { + display: flex; + justify-content: center; +} + +.h-captcha iframe { + max-width: 100%; +} + .btn-block { display: block; width: 100%; @@ -199,6 +312,88 @@ form .btn { user-select: none; } +/* OAuth2 buttons styles */ +.oauth2-container { + padding: 30px 30px 10px; +} + +.oauth2-buttons { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 24px; + justify-content: space-between; +} + +.oauth2-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background-color: #ffffff; + color: #4a5568; + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + flex: 1 0 calc(50% - 10px); + width: calc(50% - 10px); + min-width: 140px; + max-width: none; + justify-content: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.oauth2-btn:hover { + border-color: #cbd5e0; + background-color: #f7fafc; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); +} + +.oauth2-btn:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); +} + +.oauth2-icon { + width: 20px; + height: 20px; + display: inline-block; + flex-shrink: 0; +} + +.oauth2-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +.oauth2-email { + background: #f1f5f9; +} + +.oauth2-divider { + display: flex; + align-items: center; + text-align: center; +} + +.oauth2-divider .divider-line { + flex: 1; + height: 1px; + background-color: #e2e8f0; +} + +.oauth2-divider .divider-text { + padding: 0 16px; + color: #a0aec0; + font-size: 14px; + font-weight: 500; +} + .panel-footer { background-color: #f5f5f5; border-top: 1px solid #ddd; @@ -211,6 +406,7 @@ form .btn { display: block; font-size: 17px; color: #4a5568; + text-align: center; } a.panel-footer { diff --git a/rootfs/api/static/icons/logo.svg b/rootfs/api/static/icons/logo.svg new file mode 100644 index 0000000..d970da0 --- /dev/null +++ b/rootfs/api/static/icons/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rootfs/api/tasks.py b/rootfs/api/tasks.py new file mode 100644 index 0000000..e52abef --- /dev/null +++ b/rootfs/api/tasks.py @@ -0,0 +1,27 @@ +"""Celery tasks for Drycc Passport.""" +import logging + +from django.conf import settings +from celery import shared_task +from api.utils import send_email_notification, send_webhook_notification +from api.models import MessagePreference + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=10) +def send_notification(self, message) -> None: + use_email, use_webhook = False, False + try: + preference = message.user.message_preference + except MessagePreference.DoesNotExist: + use_email = True + logger.info("Send email notification for message %s without user preference", message.id) + else: + if getattr(preference, f"notify_{message.category}", True): + use_email = preference.email_alerts + use_webhook = preference.push_alerts and preference.webhook_url + if use_email and message.user.email and getattr(settings, 'EMAIL_HOST', ''): + send_email_notification(message) + if use_webhook: + send_webhook_notification(message, preference.webhook_url) diff --git a/rootfs/api/templates/base/footer.html b/rootfs/api/templates/base/footer.html index 6dbaf4e..3b3a62c 100644 --- a/rootfs/api/templates/base/footer.html +++ b/rootfs/api/templates/base/footer.html @@ -1,4 +1,5 @@ -