mirror of
https://github.com/penpot/penpot.git
synced 2026-08-24 21:59:02 +00:00
* ✨ Add media-processor service for image and font processing Externalizes ImageMagick and FontForge subprocess invocations into a separate Node.js HTTP service (media-processor/). Backend dispatches via feature flag :use-remote-media-processing. Key changes: - media-processor module (TypeScript, Express 5, Sharp, FontForge/woff) - POST /api/image/info, /api/image/thumbnail, /api/font/generate - Resource limits: 128MP rejection, prlimit (512MB + 30s CPU) - Streaming multipart via SequenceInputStream - app.media split into validation (leaf), local (shell impls), remote (HTTP) - Schema enforcement: :upload and :input schemas in validation namespace - Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT) - 78 tests across 4 files (image, font, middleware, config) - FontForge path escaping for command injection prevention - Parallel font variant conversions with Promise.all AI-assisted-by: mimo-v2.5-pro * 🐳 Revert docker-compose changes from media-processor commit Remove docker-compose.yaml modifications that were part of the media-processor service commit. The media-processor service definition, flags, and environment variables are reverted to their previous state. AI-assisted-by: qwen3.7-plus * ⬆️ Update dependencies * 🐛 Fix PR review issues in media-processor - Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir when input is a file path, ensuring output lands in expected location - Error preservation: execCommand preserves killed/signal/code properties from child process errors for OOM detection - Content-Length: service-multipart-request calculates and includes Content-Length header for streaming multipart requests AI-assisted-by: qwen3.7-plus * 🐛 Fix code review issues in media-processor - Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY in devenv to match backend config key - Fix timeout middleware to destroy request AFTER response finishes, preventing truncated 504 responses - Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85) - Replace require('fs') with proper ES module import in upload-storage.ts - Refactor font conversion temp-dir boilerplate into withTempInput helper - Document FontForge escaping limitations (single quotes only) - Fix misleading comment in image.ts about sharp metadata decoding AI-assisted-by: qwen3.7-plus * 🐛 Fix code review issues in media-processor (round 2) - Fix queue middleware to skip next() when response already ended, preventing orphaned work after timeout - Fix hybrid storage to use disk when Content-Length is absent (chunked transfer), preventing unbounded memory allocation - Add source image format validation in generateThumbnail to reject unsupported formats (TIFF, BMP, etc.) with 400 instead of 500 - Remove dead code in convertFont for unreachable woff→woff path - Remove unused isEnabled() method from LokiLogTransport - Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type - Extract queue middleware to separate file for testability - Add comprehensive tests for queue middleware and upload storage AI-assisted-by: qwen3.7-plus * 🐛 Fix code review issues in media-processor (round 3) - Fix disk-backed upload cleanup after successful requests by adding cleanup middleware that removes temp files on response finish/close - Wrap sharp metadata/decoding errors as 400 validation errors instead of 500 internal errors - Only apply flatten() for JPEG output to preserve alpha channel in PNG and WebP outputs AI-assisted-by: qwen3.7-plus * ✨ Add comprehensive tests for media-processor Phase 1 - Cleanup verification: - Add cleanup middleware unit tests (6 tests) - Add HTTP upload cleanup integration tests (5 tests) Phase 2 - Error handling & alpha preservation: - Add sharp error wrapping tests (4 tests) - Add HTTP malformed image tests (2 tests) - Add alpha preservation tests (3 tests) Phase 3 - Edge cases: - Add upload storage edge case tests (3 tests) - Add queue middleware edge case tests (4 tests) Phase 4 - Backend mock verification: - Fix backend mocks to include :mtype field in image info responses - Verify all error codes match actual service behavior Total: 27 new tests added (160 tests passing) AI-assisted-by: qwen3.7-plus * 🐛 Fix code review issues in media-processor (round 4) - Add Zod validation constraints for config values (int, positive, min) - Fix auth middleware to compare Buffer byte lengths instead of string lengths - Validate requested output dimensions in generateThumbnail (crop mode) - Change queue middleware to release slot via callback in finally block - Add comprehensive tests for all fixes AI-assisted-by: qwen3.7-plus * 🐛 Close HTTP response streams in backend media remote - Wrap stream consumption in try/finally with .close() calls - Add tests to verify stream closure for info, font-convert, and thumbnail AI-assisted-by: qwen3.7-plus * 🐛 Fix queue slot leak on upload failures Make releaseQueue idempotent and attach fallback listener to release slot when response finishes. This covers Multer errors that bypass the route handler's finally block, preventing permanent queue stall. AI-assisted-by: qwen3.7-plus * 🐛 Cancel processing on timeout Create AbortController in timeout middleware and abort signal when timeout fires. Pass signal to Sharp and FontForge to cancel ongoing processing and release resources when request is cancelled. AI-assisted-by: qwen3.7-plus * 🐛 Fix code review issues in media-processor (round 6) - Error handler: check headersSent before writing response to prevent ERR_HTTP_HEADERS_SENT when timeout already sent 504 - Timeout config: increase default requestTimeout from 60s to 180s to match font processing timeout (120s) and backend request timeout - Image processing: check abort signal before starting Sharp operations to cancel processing when timeout fires - Queue lifecycle: remove res.on('close', release) fallback to hold queue slot until processing completes, preventing concurrency limit violation when client disconnects AI-assisted-by: qwen3.7-plus * 🐛 Close HTTP response stream in download-image Wrap response body in with-open to ensure stream is closed after writing to temp file, preventing HTTP connection leaks on repeated URL imports. AI-assisted-by: qwen3.7-plus * 🐛 Close HTTP response stream on validation errors in download-image Move with-open to wrap the entire validation and processing block, ensuring the response body stream is closed even when validation fails (non-2xx status, missing size, invalid media type). This prevents HTTP connection leaks on repeated failed downloads. Add test to verify stream closure on validation errors. AI-assisted-by: qwen3.7-plus * 🐛 Pass abort signal to Sharp toBuffer for timeout cancellation Wrap Sharp's toBuffer() with Promise.race to check abort signal during processing. This ensures large thumbnails stop processing when the request times out, preventing wasted CPU/memory and queue capacity. Add test to verify abort during toBuffer operation. AI-assisted-by: qwen3.7-plus * 🐛 Hold queue slot until Sharp completes and handle client disconnect - Remove Promise.race from generateThumbnail — Sharp processing now completes fully before queue slot is released, preventing concurrency limit violations under timeout conditions - Remove res.on("finish", release) fallback from queue middleware — error handler now explicitly calls releaseQueue in all error paths - Add res.on("close") handler in timeout middleware to abort signal when client disconnects, ensuring processing stops early - Add tests for client disconnect handling and queue slot lifecycle AI-assisted-by: qwen3.7-plus * 🐛 Address round 9 review findings - Document Sharp 0.35.3 cancellation limitation in image.ts - Add integration test for timeout cleanup with large images - Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw ProcessingError on resource limit kills instead of returning null - Validate font signatures for same-format conversions to prevent arbitrary files from being persisted as valid fonts - Fix concurrent mkdtemp race in upload-storage by using shared initialization promise AI-assisted-by: qwen3.7-plus * 🐛 Address round 10 review findings - Add tmpdir assertion in font.ts to prevent path injection - Preserve original error in queue middleware catch handler - Change auth middleware response type from "internal" to "authorization" - Add cleanup flag to prevent double cleanup in cleanup middleware - Move quality clamping into parseQuality function for consistency - Add integration tests for quality parameter clamping at route level - Update existing tests to match new auth response type AI-assisted-by: qwen3.7-plus * 🐛 Address round 11 review findings - Extract releaseSlot helper in error-handler to reduce duplication - Remove redundant try/catch in font.ts withTempDir cleanup - Improve font path validation error message for clarity - Move path validation before try/catch to prevent swallowing - Add debug logging for cleanup failures in cleanup middleware - Inline TransportTargetSpec type alias in logger.ts - Extract logging middleware to separate file for consistency - Remove duplicate MIME validation in image thumbnail route - Add test for font path validation (outside tmpdir rejection) - Add tests for error handler queue release across all branches AI-assisted-by: qwen3.7-plus * 🐛 Remove Content-Length header from multipart requests The JDK's HttpClient rejects Content-Length as a restricted header, causing IllegalArgumentException when sending multipart requests to the media-processor. Remove the explicit Content-Length header and let the JDK use chunked transfer encoding. The media-processor will use disk storage for all multipart requests (safe default behavior). Remove unused size computations (file-size, header-bytes, footer-bytes, total-size) that were only used for Content-Length. Update test to verify Content-Length is not present in request headers. AI-assisted-by: qwen3.7-plus * 🐛 Fix pino ESM bundling for media-processor Mark pino and its transports (pino-pretty, pino-loki) as external to avoid bundling issues with worker thread modules that reference __dirname (not available in ES modules). AI-assisted-by: qwen3.7-plus
1027 lines
37 KiB
Clojure
1027 lines
37 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
|
|
|
(ns app.rpc.management.nitrate
|
|
"Internal Nitrate HTTP RPC API. Provides authenticated access to
|
|
organization management and token validation endpoints."
|
|
(:require
|
|
[app.auth :as aauth]
|
|
[app.auth.oidc :as oidc]
|
|
[app.common.data :as d]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.schema :as sm]
|
|
[app.common.time :as ct]
|
|
[app.common.types.organization :as cto]
|
|
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
|
[app.common.types.team :refer [schema:team]]
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.db :as db]
|
|
[app.email :as eml]
|
|
[app.http :as-alias http]
|
|
[app.http.session :as session]
|
|
[app.loggers.audit :as audit]
|
|
[app.media.validation :as media.v]
|
|
[app.nitrate :as nitrate]
|
|
[app.rpc :as rpc]
|
|
[app.rpc.commands.auth :as auth]
|
|
[app.rpc.commands.files :as files]
|
|
[app.rpc.commands.nitrate :as cnit]
|
|
[app.rpc.commands.profile :as profile]
|
|
[app.rpc.commands.teams :as teams]
|
|
[app.rpc.commands.teams-invitations :as ti]
|
|
[app.rpc.doc :as doc]
|
|
[app.rpc.nitrate.emails-helper :as neh]
|
|
[app.rpc.nitrate.organization-helper :as noh]
|
|
[app.rpc.notifications :as notifications]
|
|
[app.storage :as sto]
|
|
[app.util.services :as sv]
|
|
[app.worker :as wrk]
|
|
[cuerdas.core :as str]))
|
|
|
|
|
|
(defn- profile-to-map [profile]
|
|
{:id (:id profile)
|
|
:name (:fullname profile)
|
|
:email (:email profile)
|
|
:created-at (:created-at profile)
|
|
:photo-url (files/resolve-public-uri (get profile :photo-id))})
|
|
|
|
;; ---- API: authenticate
|
|
|
|
(sv/defmethod ::authenticate
|
|
"Authenticate the current user"
|
|
{::doc/added "2.14"
|
|
::sm/params [:map]
|
|
::sm/result schema:profile
|
|
::nitrate/sso false}
|
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
|
(let [profile (profile/get-profile cfg profile-id)]
|
|
(-> (profile-to-map profile)
|
|
(assoc :theme (:theme profile))
|
|
(assoc :lang (:lang profile)))))
|
|
|
|
;; ---- API: get-teams
|
|
|
|
(def ^:private sql:get-teams
|
|
"SELECT t.*
|
|
FROM team AS t
|
|
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
|
|
WHERE tpr.profile_id = ?
|
|
AND tpr.is_owner IS TRUE
|
|
AND t.is_default IS FALSE
|
|
AND t.deleted_at IS NULL;")
|
|
|
|
;; ---- API: get-penpot-version
|
|
|
|
(def ^:private schema:get-penpot-version-result
|
|
[:map
|
|
[:version
|
|
[:map
|
|
[:full [:maybe ::sm/text]]
|
|
[:branch [:maybe ::sm/text]]
|
|
[:base [:maybe ::sm/text]]
|
|
[:main [:maybe ::sm/text]]
|
|
[:major [:maybe ::sm/text]]
|
|
[:minor [:maybe ::sm/text]]
|
|
[:patch [:maybe ::sm/text]]
|
|
[:modifier [:maybe ::sm/text]]
|
|
[:commit [:maybe ::sm/text]]
|
|
[:commit-hash [:maybe ::sm/text]]]]])
|
|
|
|
(sv/defmethod ::get-penpot-version
|
|
"Get the current Penpot version"
|
|
{::doc/added "2.14"
|
|
::sm/params [:map]
|
|
::sm/result schema:get-penpot-version-result
|
|
::rpc/auth false}
|
|
[_cfg _params]
|
|
{:version cf/version})
|
|
|
|
(def ^:private schema:get-teams-result
|
|
[:vector schema:team])
|
|
|
|
(sv/defmethod ::get-teams
|
|
"List teams for which current user is owner"
|
|
{::doc/added "2.14"
|
|
::sm/params [:map]
|
|
::sm/result schema:get-teams-result
|
|
::nitrate/sso false}
|
|
[cfg {:keys [::rpc/profile-id]}]
|
|
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
|
|
(->> (db/exec! cfg [sql:get-teams current-user-id])
|
|
(map #(select-keys % [:id :name])))))
|
|
|
|
;; ---- API: upload-organization-logo
|
|
|
|
(def ^:private schema:upload-organization-logo
|
|
[:map
|
|
[:content media.v/schema:upload]
|
|
[:organization-id ::sm/uuid]
|
|
[:previous-id {:optional true} ::sm/uuid]])
|
|
|
|
(def ^:private schema:upload-organization-logo-result
|
|
[:map [:id ::sm/uuid]])
|
|
|
|
(sv/defmethod ::upload-organization-logo
|
|
"Store an organization logo in penpot storage and return its ID.
|
|
Accepts an optional previous-id to mark the old logo for garbage
|
|
collection when replacing an existing one."
|
|
{::doc/added "2.17"
|
|
::sm/params schema:upload-organization-logo
|
|
::sm/result schema:upload-organization-logo-result
|
|
::nitrate/sso false}
|
|
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
|
(when previous-id
|
|
(sto/touch-object! storage previous-id))
|
|
(let [hash (sto/calculate-hash (:path content))
|
|
data (-> (sto/content (:path content))
|
|
(sto/wrap-with-hash hash))
|
|
obj (sto/put-object! storage {::sto/content data
|
|
::sto/deduplicate? true
|
|
:bucket "organization"
|
|
:content-type (:mtype content)
|
|
:organization-id organization-id})]
|
|
{:id (:id obj)}))
|
|
|
|
;; ---- API: notify-team-change
|
|
|
|
(sv/defmethod ::notify-team-change
|
|
"Notify to Penpot a team change from nitrate"
|
|
{::doc/added "2.14"
|
|
::sm/params cto/schema:team-with-organization
|
|
::rpc/auth false}
|
|
[cfg team]
|
|
(notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
|
|
nil)
|
|
|
|
;; ---- API: notify-user-added-to-organization
|
|
|
|
(def ^:private schema:notify-user-added-to-organization
|
|
[:map
|
|
[:profile-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]
|
|
[:role ::sm/text]])
|
|
|
|
(sv/defmethod ::notify-user-added-to-organization
|
|
"Notify to Penpot that an user has joined an organization from nitrate"
|
|
{::doc/added "2.14"
|
|
::sm/params schema:notify-user-added-to-organization
|
|
::rpc/auth false}
|
|
[cfg {:keys [profile-id organization-id]}]
|
|
(db/tx-run! cfg teams/create-default-organization-team profile-id organization-id))
|
|
|
|
|
|
;; ---- API: get-managed-profiles
|
|
|
|
(def ^:private sql:get-managed-profiles
|
|
"SELECT DISTINCT p.id, p.fullname as name, p.email
|
|
FROM profile p
|
|
JOIN team_profile_rel tpr_member
|
|
ON tpr_member.profile_id = p.id
|
|
WHERE p.id <> ?
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM team_profile_rel tpr_owner
|
|
JOIN team t
|
|
ON t.id = tpr_owner.team_id
|
|
WHERE tpr_owner.profile_id = ?
|
|
AND tpr_owner.team_id = tpr_member.team_id
|
|
AND tpr_owner.is_owner IS TRUE
|
|
AND t.is_default IS FALSE
|
|
AND t.deleted_at IS NULL);")
|
|
|
|
(def schema:managed-profile-result
|
|
[:vector schema:basic-profile])
|
|
|
|
(sv/defmethod ::get-managed-profiles
|
|
"List profiles that belong to teams for which current user is owner"
|
|
{::doc/added "2.14"
|
|
::sm/params [:map]
|
|
::sm/result schema:managed-profile-result
|
|
::nitrate/sso false}
|
|
[cfg {:keys [::rpc/profile-id]}]
|
|
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
|
|
(db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))
|
|
|
|
;; ---- API: get-teams-summary
|
|
|
|
(def ^:private sql:get-teams-summary
|
|
"SELECT t.id, t.name, t.is_default
|
|
FROM team AS t
|
|
WHERE t.id = ANY(?)
|
|
AND t.deleted_at IS NULL;")
|
|
|
|
(def ^:private sql:get-files-count
|
|
"SELECT COUNT(f.*) AS count
|
|
FROM file AS f
|
|
JOIN project AS p ON f.project_id = p.id
|
|
JOIN team AS t ON t.id = p.team_id
|
|
WHERE p.team_id = ANY(?)
|
|
AND t.deleted_at IS NULL
|
|
AND p.deleted_at IS NULL
|
|
AND f.deleted_at IS NULL;")
|
|
|
|
(def ^:private schema:get-teams-summary-params
|
|
[:map
|
|
[:ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
|
|
|
|
(def ^:private schema:get-teams-summary-result
|
|
[:map
|
|
[:teams [:vector [:map
|
|
[:id ::sm/uuid]
|
|
[:name ::sm/text]
|
|
[:is-default ::sm/boolean]]]]
|
|
[:num-files ::sm/int]])
|
|
|
|
(sv/defmethod ::get-teams-summary
|
|
"Get summary information for a list of teams"
|
|
{::doc/added "2.15"
|
|
::sm/params schema:get-teams-summary-params
|
|
::sm/result schema:get-teams-summary-result
|
|
::nitrate/sso false}
|
|
[cfg {:keys [ids]}]
|
|
(let [;; Handle one or multiple params
|
|
ids (cond
|
|
(uuid? ids)
|
|
[ids]
|
|
|
|
(and (vector? ids) (every? uuid? ids))
|
|
ids
|
|
|
|
:else
|
|
[])]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
(let [ids-array (db/create-array conn "uuid" ids)
|
|
teams (db/exec! conn [sql:get-teams-summary ids-array])
|
|
files-count (-> (db/exec-one! conn [sql:get-files-count ids-array]) :count)]
|
|
{:teams teams
|
|
:num-files files-count})))))
|
|
|
|
|
|
;; ---- API: delete-teams-keeping-your-penpot-projects
|
|
|
|
(def ^:private sql:prefix-teams-name-and-unset-default
|
|
"UPDATE team
|
|
SET name = ? || name,
|
|
is_default = FALSE
|
|
WHERE id = ANY(?)
|
|
RETURNING id, name;")
|
|
|
|
(def ^:private sql:get-teams-files-counts
|
|
"SELECT p.team_id, COUNT(f.*) AS total
|
|
FROM file AS f
|
|
JOIN project AS p ON (p.id = f.project_id)
|
|
JOIN team AS t ON (t.id = p.team_id)
|
|
WHERE t.id = ANY(?)
|
|
AND t.deleted_at IS NULL
|
|
AND p.deleted_at IS NULL
|
|
AND f.deleted_at IS NULL
|
|
GROUP BY p.team_id;")
|
|
|
|
(def ^:private sql:soft-delete-teams
|
|
"UPDATE team
|
|
SET deleted_at = ?
|
|
WHERE id = ANY(?)
|
|
RETURNING id, deleted_at;")
|
|
|
|
|
|
;; ---- API: notify-organization-deletion
|
|
|
|
(def ^:private schema:notify-organization-deletion
|
|
[:map
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
|
|
(defn- soft-delete-teams!
|
|
"Soft-delete the provided team ids and submit a delete task per team."
|
|
[{:keys [::db/conn] :as cfg} team-ids]
|
|
(when (seq team-ids)
|
|
(let [delay (cf/get-deletion-delay)
|
|
deleted-at (ct/in-future delay)
|
|
updated (db/exec! conn [sql:soft-delete-teams
|
|
deleted-at
|
|
(db/create-array conn "uuid" team-ids)])]
|
|
(doseq [{:keys [id deleted-at]} updated]
|
|
(wrk/submit! {::db/conn conn
|
|
::wrk/task :delete-object
|
|
::wrk/params {:object :team
|
|
:deleted-at deleted-at
|
|
:id id}}))))
|
|
nil)
|
|
|
|
(defn manage-deleted-organization-teams
|
|
"For a deleted organization, preserve organization teams unchanged and only prefix or
|
|
delete member Your Penpot teams depending on whether they still contain files."
|
|
[cfg {:keys [organization-id organization-name teams]}]
|
|
(let [all-team-ids (->> teams
|
|
(map :id)
|
|
(filter uuid?)
|
|
distinct
|
|
(into []))
|
|
your-penpot-team-ids (->> teams
|
|
(filter :is-your-penpot)
|
|
(map :id)
|
|
(filter uuid?)
|
|
distinct
|
|
(into []))]
|
|
(when (seq all-team-ids)
|
|
(let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
|
(db/tx-run!
|
|
cfg
|
|
(fn [{:keys [::db/conn] :as cfg}]
|
|
(let [teams-with-files (if (seq your-penpot-team-ids)
|
|
(->> (db/exec! conn [sql:get-teams-files-counts
|
|
(db/create-array conn "uuid" your-penpot-team-ids)])
|
|
(filter (fn [{:keys [total]}] (pos? total)))
|
|
(map :team-id)
|
|
(into #{}))
|
|
#{})
|
|
teams-to-prefix (->> your-penpot-team-ids (filter teams-with-files) (into []))
|
|
teams-to-delete (->> your-penpot-team-ids (remove teams-with-files) (into []))]
|
|
|
|
;; Organization teams move to the fallback organization unchanged. Only imported
|
|
;; Your Penpot teams keep the organization prefix when they still have files.
|
|
(when (seq teams-to-prefix)
|
|
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
|
organization-prefix
|
|
(db/create-array conn "uuid" teams-to-prefix)]))
|
|
|
|
;; Empty imported Your Penpot teams disappear entirely.
|
|
(soft-delete-teams! cfg teams-to-delete)
|
|
|
|
(notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete)
|
|
nil)))))))
|
|
|
|
|
|
(sv/defmethod ::notify-organization-deletion
|
|
"For a deleted organization, preserve organization teams and only prefix or delete
|
|
imported Your Penpot teams before notifying connected users."
|
|
{::doc/added "2.15"
|
|
::sm/params schema:notify-organization-deletion
|
|
::rpc/auth false}
|
|
[cfg {:keys [organization-id]}]
|
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
|
teams (:teams organization-summary)]
|
|
(manage-deleted-organization-teams cfg {:organization-name (:name organization-summary)
|
|
:organization-id (:id organization-summary)
|
|
:teams teams})
|
|
nil))
|
|
|
|
;; ---- API: notify-user-organizations-deletion
|
|
|
|
(def ^:private schema:notify-user-organizations-deletion
|
|
[:map
|
|
[:profile-id ::sm/uuid]])
|
|
|
|
(sv/defmethod ::notify-user-organizations-deletion
|
|
"For a given user, find all owned organizations and apply the deleted-organization
|
|
transfer rules to their imported Your Penpot teams."
|
|
{::doc/added "2.18"
|
|
::sm/params schema:notify-user-organizations-deletion
|
|
::nitrate/sso false}
|
|
[cfg {:keys [profile-id]}]
|
|
(let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})]
|
|
(doseq [organization owned-organizations]
|
|
(let [organization-name (:name organization)
|
|
teams (:teams organization)]
|
|
(manage-deleted-organization-teams cfg {:organization-name organization-name
|
|
:organization-id (:id organization)
|
|
:teams teams}))))
|
|
nil)
|
|
|
|
|
|
|
|
|
|
;; ---- API: get-profile-by-email
|
|
|
|
(def ^:private sql:get-profile-by-email
|
|
"SELECT DISTINCT id, fullname, email, photo_id
|
|
FROM profile
|
|
WHERE email = ?
|
|
AND deleted_at IS NULL;")
|
|
|
|
(sv/defmethod ::get-profile-by-email
|
|
"Get profile by email"
|
|
{::doc/added "2.15"
|
|
::sm/params [:map [:email ::sm/email]]
|
|
::sm/result schema:profile
|
|
::nitrate/sso false}
|
|
[cfg {:keys [email]}]
|
|
(let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
|
|
(when-not profile
|
|
(ex/raise :type :not-found
|
|
:code :profile-not-found
|
|
:hint "profile does not exist"
|
|
:email email))
|
|
(profile-to-map profile)))
|
|
|
|
|
|
;; ---- API: get-profile-by-id
|
|
|
|
(def ^:private sql:get-profile-by-id
|
|
"SELECT DISTINCT id, fullname, email, photo_id
|
|
FROM profile
|
|
WHERE id = ?
|
|
AND deleted_at IS NULL;")
|
|
|
|
(sv/defmethod ::get-profile-by-id
|
|
"Get profile by email"
|
|
{::doc/added "2.15"
|
|
::sm/params [:map [:id ::sm/uuid]]
|
|
::sm/result schema:profile
|
|
::nitrate/sso false}
|
|
[cfg {:keys [id]}]
|
|
(let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
|
|
(when-not profile
|
|
(ex/raise :type :not-found
|
|
:code :profile-not-found
|
|
:hint "profile does not exist"
|
|
:id id))
|
|
(profile-to-map profile)))
|
|
|
|
|
|
;; ---- API: get-organization-member-team-counts
|
|
|
|
(def ^:private sql:get-organization-member-team-counts
|
|
"SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
|
|
FROM team_profile_rel AS tpr
|
|
JOIN team AS t ON t.id = tpr.team_id
|
|
WHERE t.id = ANY(?)
|
|
AND t.deleted_at IS NULL
|
|
AND t.is_default IS FALSE
|
|
GROUP BY tpr.profile_id;")
|
|
|
|
(def ^:private schema:get-organization-member-team-counts-params
|
|
[:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])
|
|
|
|
(def ^:private schema:get-organization-member-team-counts-result
|
|
[:vector [:map
|
|
[:profile-id ::sm/uuid]
|
|
[:team-count ::sm/int]]])
|
|
|
|
(sv/defmethod ::get-organization-member-team-counts
|
|
"Get the number of non-default teams each profile belongs to within a set of teams."
|
|
{::doc/added "2.15"
|
|
::sm/params schema:get-organization-member-team-counts-params
|
|
::sm/result schema:get-organization-member-team-counts-result
|
|
::rpc/auth false}
|
|
[cfg {:keys [team-ids]}]
|
|
(let [team-ids (cond
|
|
(uuid? team-ids)
|
|
[team-ids]
|
|
|
|
(and (vector? team-ids) (every? uuid? team-ids))
|
|
team-ids
|
|
|
|
:else
|
|
[])]
|
|
(if (empty? team-ids)
|
|
[]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
|
(db/exec! conn [sql:get-organization-member-team-counts ids-array])))))))
|
|
|
|
|
|
;; API: invite-to-organization
|
|
|
|
(sv/defmethod ::invite-to-organization
|
|
"Invite to organization"
|
|
{::doc/added "2.15"
|
|
::sm/params [:map
|
|
[:email ::sm/email]
|
|
[:organization cto/schema:organization-with-avatar]]
|
|
::nitrate/sso false}
|
|
[cfg params]
|
|
(db/tx-run! cfg ti/create-organization-invitation params)
|
|
nil)
|
|
|
|
|
|
;; API: get-organization-invitations
|
|
|
|
(def ^:private schema:get-organization-invitations-params
|
|
[:map
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
(def ^:private schema:get-organization-invitations-result
|
|
[:vector
|
|
[:map
|
|
[:id ::sm/uuid]
|
|
[:organization-id {:optional true} [:maybe ::sm/uuid]]
|
|
[:email ::sm/email]
|
|
[:sent-at ::sm/inst]
|
|
[:name {:optional true} [:maybe ::sm/text]]
|
|
[:profile-id {:optional true} [:maybe ::sm/uuid]]
|
|
[:photo-url {:optional true} ::sm/uri]]])
|
|
|
|
(sv/defmethod ::get-organization-invitations
|
|
"Get valid invitations for an organization, returning at most one invitation per email."
|
|
{::doc/added "2.16"
|
|
::sm/params schema:get-organization-invitations-params
|
|
::sm/result schema:get-organization-invitations-result
|
|
::nitrate/sso false}
|
|
[cfg {:keys [organization-id]}]
|
|
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
(->> (noh/get-organization-invitations conn organization-id team-ids)
|
|
(mapv (fn [{:keys [photo-id] :as invitation}]
|
|
(cond-> (dissoc invitation :photo-id)
|
|
photo-id
|
|
(assoc :photo-url (files/resolve-public-uri photo-id))))))))))
|
|
|
|
|
|
;; API: delete-organization-invitations
|
|
|
|
(def ^:private sql:delete-organization-invitations
|
|
"DELETE FROM team_invitation AS ti
|
|
WHERE ti.email_to = ?
|
|
AND (ti.org_id = ? OR ti.team_id = ANY(?));")
|
|
|
|
(def ^:private schema:delete-organization-invitations-params
|
|
[:map
|
|
[:organization-id ::sm/uuid]
|
|
[:email ::sm/email]])
|
|
|
|
(sv/defmethod ::delete-organization-invitations
|
|
"Delete all invitations for one email in an organization scope (organization + organization teams)."
|
|
{::doc/added "2.16"
|
|
::sm/params schema:delete-organization-invitations-params
|
|
::nitrate/sso false}
|
|
[cfg {:keys [organization-id email]}]
|
|
(let [clean-email (profile/clean-email email)
|
|
team-ids (noh/get-organization-team-ids cfg organization-id)]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
|
(db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
|
|
nil))
|
|
|
|
|
|
;; API: delete-all-organization-invitations
|
|
|
|
(def ^:private sql:delete-all-organization-invitations
|
|
"DELETE FROM team_invitation AS ti
|
|
WHERE ti.org_id = ?
|
|
OR ti.team_id = ANY(?);")
|
|
|
|
(def ^:private schema:delete-all-organization-invitations-params
|
|
[:map
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
(sv/defmethod ::delete-all-organization-invitations
|
|
"Delete every pending invitation associated with an organization (organization-level + team-level).
|
|
Called from Nitrate when an organization is about to be deleted, so users that click
|
|
their invitation token hit the existing invalid-token landing page."
|
|
{::doc/added "2.18"
|
|
::sm/params schema:delete-all-organization-invitations-params
|
|
::rpc/auth false}
|
|
[cfg {:keys [organization-id]}]
|
|
(let [team-ids (noh/get-organization-team-ids cfg organization-id)]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
|
(db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
|
|
nil))
|
|
|
|
|
|
;; API: remove-from-organization
|
|
|
|
(def ^:private sql:get-reassign-to
|
|
"SELECT tpr.profile_id
|
|
FROM team_profile_rel AS tpr
|
|
WHERE tpr.team_id = ?
|
|
AND tpr.profile_id <> ?
|
|
AND tpr.is_owner IS NOT TRUE
|
|
ORDER BY CASE
|
|
WHEN tpr.is_admin IS TRUE THEN 1
|
|
ELSE 2
|
|
END,
|
|
tpr.created_at,
|
|
tpr.profile_id
|
|
LIMIT 1;")
|
|
|
|
(defn add-reassign-to [cfg profile-id team-to-transfer]
|
|
(let [reassign-to (-> (db/exec-one! cfg [sql:get-reassign-to (:id team-to-transfer) profile-id])
|
|
:profile-id)]
|
|
(when-not reassign-to
|
|
(ex/raise :type :validation
|
|
:code :nobody-to-reassign-team))
|
|
|
|
(assoc team-to-transfer :reassign-to reassign-to)))
|
|
|
|
(sv/defmethod ::remove-from-organization
|
|
"Remove an user from an organization"
|
|
{::doc/added "2.17"
|
|
::sm/params [:map
|
|
[:profile-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]
|
|
[:organization-name ::sm/text]
|
|
[:default-team-id ::sm/uuid]]
|
|
::db/transaction true
|
|
::nitrate/sso false}
|
|
[cfg {actor-profile-id ::rpc/profile-id
|
|
:keys [profile-id organization-id organization-name default-team-id]
|
|
:as params}]
|
|
(let [actor-profile-id (when-not (= actor-profile-id uuid/zero)
|
|
actor-profile-id)
|
|
{:keys [valid-teams-to-delete-ids
|
|
valid-teams-to-transfer
|
|
valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
|
|
add-reassign-to (partial add-reassign-to cfg profile-id)
|
|
|
|
valid-teams-to-leave (into valid-teams-to-exit
|
|
(map add-reassign-to valid-teams-to-transfer))]
|
|
|
|
(cnit/leave-organization cfg (assoc params
|
|
:id organization-id
|
|
:name organization-name
|
|
:teams-to-delete valid-teams-to-delete-ids
|
|
:teams-to-leave valid-teams-to-leave
|
|
:skip-validation true
|
|
:user-who-delete-member actor-profile-id
|
|
:deleted-by-role (when actor-profile-id
|
|
"organization-owner")))
|
|
(notifications/notify-user-organization-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-organization")
|
|
nil))
|
|
|
|
;; API: get-remove-from-organization-summary
|
|
|
|
(def ^:private schema:get-remove-from-organization-summary-result
|
|
[:map
|
|
[:teams-to-delete ::sm/int]
|
|
[:teams-to-transfer ::sm/int]
|
|
[:teams-to-exit ::sm/int]
|
|
[:teams-to-detach ::sm/int]])
|
|
|
|
(sv/defmethod ::get-remove-from-organization-summary
|
|
"Get a summary of the teams that would be deleted, transferred, or exited
|
|
if the user were removed from the organization"
|
|
{::doc/added "2.17"
|
|
::sm/params [:map
|
|
[:profile-id ::sm/uuid]
|
|
[:organization-id ::sm/uuid]
|
|
[:default-team-id ::sm/uuid]]
|
|
::sm/result schema:get-remove-from-organization-summary-result
|
|
::db/transaction true
|
|
::nitrate/sso false}
|
|
[cfg {:keys [profile-id organization-id default-team-id]}]
|
|
(let [{:keys [valid-teams-to-delete-ids
|
|
valid-teams-to-transfer
|
|
valid-teams-to-exit
|
|
valid-default-team]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)]
|
|
(when-not valid-default-team
|
|
(ex/raise :type :validation
|
|
:code :not-valid-teams))
|
|
(cnit/get-leave-organization-summary cfg
|
|
default-team-id
|
|
valid-teams-to-delete-ids
|
|
(count valid-teams-to-transfer)
|
|
(count valid-teams-to-exit))))
|
|
|
|
;; API: send-renewal-email
|
|
|
|
(def ^:private schema:send-renewal-email-params
|
|
[:map
|
|
[:profile-id ::sm/uuid]
|
|
[:user-email ::sm/email]
|
|
[:user-name [:maybe ::sm/text]]
|
|
[:renewal-date :string]
|
|
[:estimated-amount :double]
|
|
[:organizations [:vector cto/schema:organization-with-avatar]]])
|
|
|
|
(sv/defmethod ::send-renewal-email
|
|
"Send an Enterprise subscription renewal notice email to a user."
|
|
{::doc/added "2.17"
|
|
::sm/params schema:send-renewal-email-params
|
|
::rpc/auth false}
|
|
[cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
|
|
(let [amount-str (format "$%.2f" estimated-amount)
|
|
user-name (if (str/empty? user-name)
|
|
(:fullname (profile/get-profile cfg profile-id))
|
|
user-name)]
|
|
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
|
(eml/send! {::eml/conn conn
|
|
::eml/factory eml/renewal-notice
|
|
:public-uri (cf/get :public-uri)
|
|
:to user-email
|
|
:user-name user-name
|
|
:renewal-date renewal-date
|
|
:estimated-amount amount-str
|
|
:organizations organizations}))))
|
|
nil)
|
|
|
|
;; API: exists-organization-team-invitations-for-non-members /
|
|
;; delete-organization-team-invitations-for-non-members
|
|
|
|
(def ^:private sql:get-profile-emails-by-ids
|
|
"SELECT email
|
|
FROM profile
|
|
WHERE id = ANY(?)
|
|
AND deleted_at IS NULL")
|
|
|
|
(def ^:private sql:exists-non-member-organization-team-invitations
|
|
"SELECT EXISTS (
|
|
SELECT 1
|
|
FROM team_invitation
|
|
WHERE team_id = ANY(?)
|
|
AND email_to <> ALL(?)
|
|
) AS non_member")
|
|
|
|
(def ^:private sql:delete-non-member-organization-team-invitations
|
|
"DELETE FROM team_invitation
|
|
WHERE team_id = ANY(?)
|
|
AND email_to <> ALL(?)
|
|
RETURNING email_to")
|
|
|
|
(def ^:private schema:organization-team-invitations-for-non-members-params
|
|
[:map
|
|
[:team-ids [:vector ::sm/uuid]]
|
|
[:member-ids [:vector ::sm/uuid]]])
|
|
|
|
(def ^:private schema:exists-organization-team-invitations-for-non-members-result
|
|
[:map [:exists ::sm/boolean]])
|
|
|
|
(defn- organization-team-invitations-for-non-members-arrays
|
|
"Member emails and PG arrays used by exists/delete organization team invitation endpoints."
|
|
[conn {:keys [team-ids member-ids]}]
|
|
(let [member-ids-array (db/create-array conn "uuid" member-ids)
|
|
member-emails (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
|
|
(map :email)
|
|
(into #{}))]
|
|
{:emails-array (db/create-array conn "text" (vec member-emails))
|
|
:teams-array (db/create-array conn "uuid" team-ids)}))
|
|
|
|
(defn- non-member-organization-team-invitations-exist?
|
|
[conn params]
|
|
(let [{:keys [emails-array teams-array]}
|
|
(organization-team-invitations-for-non-members-arrays conn params)]
|
|
(-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
|
|
teams-array
|
|
emails-array])
|
|
:non-member)))
|
|
|
|
(sv/defmethod ::exists-organization-team-invitations-for-non-members
|
|
"Return if there are any team invitations for emails that are not organization members."
|
|
{::doc/added "2.18"
|
|
::sm/params schema:organization-team-invitations-for-non-members-params
|
|
::sm/result schema:exists-organization-team-invitations-for-non-members-result
|
|
::nitrate/sso false}
|
|
[cfg params]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
{:exists (boolean (non-member-organization-team-invitations-exist? conn params))})))
|
|
|
|
(sv/defmethod ::delete-organization-team-invitations-for-non-members
|
|
"Delete team invitations for emails that are not organization members."
|
|
{::doc/added "2.18"
|
|
::sm/params schema:organization-team-invitations-for-non-members-params
|
|
::db/transaction true
|
|
::nitrate/sso false}
|
|
[cfg params]
|
|
(db/run! cfg (fn [{:keys [::db/conn]}]
|
|
(let [{:keys [emails-array teams-array]}
|
|
(organization-team-invitations-for-non-members-arrays conn params)]
|
|
(db/exec! conn [sql:delete-non-member-organization-team-invitations
|
|
teams-array
|
|
emails-array])
|
|
nil))))
|
|
|
|
;; ---- API: push-audit-events
|
|
|
|
(def ^:private schema:nitrate-audit-event
|
|
[:map {:title "NitrateAuditEvent"}
|
|
[:name [:and [:string {:max 250}]
|
|
[:re #"[\d\w-]{1,50}"]]]
|
|
[:type {:optional true} ::sm/text]
|
|
[:profile-id ::sm/uuid]
|
|
[:props {:optional true} [:map-of :keyword :any]]
|
|
[:context {:optional true} [:map-of :keyword :any]]])
|
|
|
|
(def ^:private schema:push-audit-events-params
|
|
[:map {:title "PushAuditEventsParams"}
|
|
[:events [:vector schema:nitrate-audit-event]]])
|
|
|
|
(sv/defmethod ::push-audit-events
|
|
"Push audit events from nitrate (strictly for nitrate backend
|
|
events)"
|
|
|
|
{::doc/added "2.19"
|
|
::audit/skip true
|
|
::sm/params schema:push-audit-events-params
|
|
::rpc/auth false}
|
|
[cfg {:keys [::rpc/request-at events] :as params}]
|
|
(let [request (-> params meta ::http/request)
|
|
context' (-> (audit/prepare-context-from-request request)
|
|
(assoc :request-id (::rpc/request-id params)))
|
|
|
|
ip-addr (::rpc/ip-addr params)]
|
|
|
|
(run! (fn [{:keys [type name profile-id props context] :as event}]
|
|
(let [context (-> (merge context (d/without-nils context'))
|
|
(d/without-nils))]
|
|
(audit/submit cfg {:type (d/nilv type "action")
|
|
:name name
|
|
:profile-id profile-id
|
|
:props (or props {})
|
|
:context context
|
|
:tracked-at request-at
|
|
:ip-addr ip-addr})))
|
|
events)
|
|
|
|
nil))
|
|
|
|
|
|
;; ---- API: get-teams-detail
|
|
|
|
(def ^:private sql:get-teams-detail
|
|
"SELECT
|
|
t.id,
|
|
t.name,
|
|
t.photo_id,
|
|
t.created_at,
|
|
(SELECT MAX(activity.modified_at)
|
|
FROM (
|
|
SELECT p2.modified_at
|
|
FROM project AS p2
|
|
WHERE p2.team_id = t.id
|
|
AND p2.deleted_at IS NULL
|
|
AND p2.is_default IS FALSE
|
|
UNION ALL
|
|
SELECT f.modified_at
|
|
FROM file AS f
|
|
JOIN project AS p ON p.id = f.project_id
|
|
WHERE p.team_id = t.id
|
|
AND p.deleted_at IS NULL
|
|
AND f.deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT tpr2.created_at
|
|
FROM team_profile_rel AS tpr2
|
|
WHERE tpr2.team_id = t.id
|
|
AND tpr2.is_owner IS NOT TRUE
|
|
UNION ALL
|
|
SELECT ti.updated_at
|
|
FROM team_invitation AS ti
|
|
WHERE ti.team_id = t.id
|
|
) AS activity) AS last_activity_at,
|
|
owner_tpr.profile_id AS owner_profile_id,
|
|
owner_p.fullname AS owner_name,
|
|
owner_p.photo_id AS owner_photo_id,
|
|
(SELECT COUNT(*)
|
|
FROM project AS p3
|
|
WHERE p3.team_id = t.id
|
|
AND p3.deleted_at IS NULL
|
|
AND p3.is_default IS FALSE) AS num_projects,
|
|
(SELECT COUNT(*)
|
|
FROM file AS f
|
|
JOIN project AS p4 ON p4.id = f.project_id
|
|
WHERE p4.team_id = t.id
|
|
AND f.deleted_at IS NULL
|
|
AND p4.deleted_at IS NULL) AS num_files,
|
|
(SELECT COUNT(*)
|
|
FROM team_profile_rel AS tpr
|
|
WHERE tpr.team_id = t.id) AS num_members
|
|
FROM team AS t
|
|
LEFT JOIN team_profile_rel AS owner_tpr
|
|
ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE
|
|
LEFT JOIN profile AS owner_p
|
|
ON owner_p.id = owner_tpr.profile_id
|
|
WHERE t.id = ANY(?)
|
|
AND t.deleted_at IS NULL
|
|
AND t.is_default IS FALSE
|
|
ORDER BY last_activity_at DESC NULLS LAST")
|
|
|
|
(def ^:private schema:get-teams-detail-params
|
|
[:map
|
|
[:organization-id ::sm/uuid]])
|
|
|
|
(def ^:private schema:get-teams-detail-result
|
|
[:vector
|
|
[:map
|
|
[:id ::sm/uuid]
|
|
[:name ::sm/text]
|
|
[:photo-url {:optional true} ::sm/uri]
|
|
[:created-at ::sm/inst]
|
|
[:last-activity-at {:optional true} [:maybe ::sm/inst]]
|
|
[:owner-profile-id {:optional true} [:maybe ::sm/uuid]]
|
|
[:owner-name {:optional true} [:maybe ::sm/text]]
|
|
[:owner-photo-url {:optional true} ::sm/uri]
|
|
[:num-projects ::sm/int]
|
|
[:num-files ::sm/int]
|
|
[:num-members ::sm/int]]])
|
|
|
|
(sv/defmethod ::get-teams-detail
|
|
"Get detailed information for all non-deleted teams in an organization,
|
|
including owner info and project/file/member counts."
|
|
{::doc/added "2.20"
|
|
::sm/params schema:get-teams-detail-params
|
|
::sm/result schema:get-teams-detail-result
|
|
::nitrate/sso false}
|
|
[cfg {:keys [organization-id]}]
|
|
(let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
|
|
team-ids (into [] (comp d/xf:map-id (filter uuid?)) (:teams organization-summary))]
|
|
(if (empty? team-ids)
|
|
[]
|
|
(db/run! cfg
|
|
(fn [{:keys [::db/conn]}]
|
|
(let [ids-array (db/create-array conn "uuid" team-ids)]
|
|
(->> (db/exec! conn [sql:get-teams-detail ids-array])
|
|
(mapv (fn [{:keys [photo-id owner-photo-id] :as row}]
|
|
(cond-> (dissoc row :photo-id :owner-photo-id)
|
|
photo-id (assoc :photo-url (files/resolve-public-uri photo-id))
|
|
owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id))))))))))))
|
|
|
|
;; ---- API: check-organization-sso
|
|
|
|
(def ^:private schema:check-organization-sso-result
|
|
[:map
|
|
[:valid ::sm/boolean]])
|
|
|
|
(sv/defmethod ::check-organization-sso
|
|
"Validate an organization SSO configuration by generating a login redirect URL.
|
|
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
|
discovery before saving the settings."
|
|
{::doc/added "2.20"
|
|
::sm/params cto/schema:nitrate-sso
|
|
::sm/result schema:check-organization-sso-result
|
|
::rpc/auth false}
|
|
[cfg params]
|
|
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
|
|
|
|
;; ---- API: notify-organization-sso-change
|
|
(sv/defmethod ::notify-organization-sso-change
|
|
"Nitrate notifies that an organization sso values have changed"
|
|
{::doc/added "2.19"
|
|
::sm/params [:map
|
|
[:organization-id ::sm/uuid]
|
|
[:updated-props ::sm/boolean]
|
|
[:announce-activation ::sm/boolean]]
|
|
::rpc/auth false}
|
|
[{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}]
|
|
(when updated-props
|
|
(rpc/invalidate-organization-sso-cache-by-organization! organization-id)
|
|
(session/clear-organization-sso-sessions! pool organization-id))
|
|
(notifications/notify-organization-change-sso cfg organization-id)
|
|
(when announce-activation
|
|
(neh/send-organization-setup-sso-emails! cfg organization-id))
|
|
nil)
|
|
|
|
;; ---- API: bulk-create-profiles
|
|
|
|
(def ^:private schema:bulk-create-profiles-params
|
|
[:map
|
|
[:password [::sm/word-string {:max 500}]]
|
|
[:emails [:vector ::sm/email]]])
|
|
|
|
(def ^:private schema:bulk-create-profiles-result
|
|
[:map
|
|
[:created [:vector ::sm/email]]
|
|
[:skipped [:vector ::sm/email]]])
|
|
|
|
(defn- create-active-profile!
|
|
"Create a single already-active profile (email pre-verified, onboarding
|
|
skipped) plus its default team. Returns nil; existence checks happen in the
|
|
caller so duplicates are skipped instead of aborting the whole batch."
|
|
[cfg email password]
|
|
(let [fullname (-> (str/split email "@") first)]
|
|
(->> {:email email
|
|
:fullname fullname
|
|
:password password
|
|
:is-active true
|
|
:props {:onboarding-viewed true}}
|
|
(auth/create-profile cfg)
|
|
(auth/create-profile-rels cfg))
|
|
nil))
|
|
|
|
(sv/defmethod ::bulk-create-profiles
|
|
"Create multiple already-active profiles that share a single password. The
|
|
created users skip email verification and onboarding. Emails that already
|
|
belong to an existing profile are skipped. Intended for the Nitrate admin
|
|
bulk-creation screen; access is gated by the shared key and, in Nitrate, an
|
|
email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled
|
|
by default so it is only available on test environments."
|
|
{::doc/added "2.19"
|
|
::sm/params schema:bulk-create-profiles-params
|
|
::sm/result schema:bulk-create-profiles-result
|
|
::rpc/auth false}
|
|
[cfg {:keys [password emails]}]
|
|
|
|
(when-not (contains? cf/flags :admin-console-bulk-create-profiles)
|
|
(ex/raise :type :restriction
|
|
:code :bulk-create-profiles-not-allowed
|
|
:hint "Bulk profile creation is disabled by config."))
|
|
|
|
(let [derived (aauth/derive-password password)]
|
|
(db/tx-run!
|
|
cfg
|
|
(fn [{:keys [::db/conn] :as cfg}]
|
|
(reduce
|
|
(fn [acc email]
|
|
(let [email (eml/clean email)]
|
|
(if (profile/get-profile-by-email conn email)
|
|
(update acc :skipped conj email)
|
|
(do
|
|
(create-active-profile! cfg email derived)
|
|
(update acc :created conj email)))))
|
|
{:created [] :skipped []}
|
|
emails)))))
|