From 8acb92b782916c5563da0b9c22c8e5e75cf46cff Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 15:27:06 +0200 Subject: [PATCH 1/5] :bug: Normalize fractional rate-limit reset durations (#11254) Round bucket reset intervals up to whole milliseconds before adding them to an instant. This prevents Clojure ratios from reaching duration conversion and disabling rate limiting for the request. Add a regression test for a refill rate that produces fractional milliseconds. Closes #11253 AI-assisted-by: gpt-5.6-luna --- backend/src/app/rpc/rlimit.clj | 5 ++-- .../test/backend_tests/rpc_rlimit_test.clj | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 backend/test/backend_tests/rpc_rlimit_test.clj diff --git a/backend/src/app/rpc/rlimit.clj b/backend/src/app/rpc/rlimit.clj index abc77d81ea..3e05eb9837 100644 --- a/backend/src/app/rpc/rlimit.clj +++ b/backend/src/app/rpc/rlimit.clj @@ -46,6 +46,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.logging :as l] + [app.common.math :as mth] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uri :as uri] @@ -180,8 +181,8 @@ result (rds/eval rconn script) allowed? (boolean (nth result 0)) remaining (nth result 1) - reset (* (/ (inst-ms interval) rate) - (- capacity remaining))] + reset (long (mth/ceil (double (* (/ (inst-ms interval) rate) + (- capacity remaining)))))] (l/trace :hint "limit processed" :method method :limit (name (::name limit)) diff --git a/backend/test/backend_tests/rpc_rlimit_test.clj b/backend/test/backend_tests/rpc_rlimit_test.clj new file mode 100644 index 0000000000..87ef58244f --- /dev/null +++ b/backend/test/backend_tests/rpc_rlimit_test.clj @@ -0,0 +1,28 @@ +;; 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 Espana SL + +(ns backend-tests.rpc-rlimit-test + (:require + [app.common.time :as ct] + [app.redis :as rds] + [app.rpc.rlimit :as rlimit] + [clojure.test :as t])) + +(t/deftest bucket-reset-supports-fractional-milliseconds + (let [now (ct/inst 0) + limit {::rlimit/name :test + ::rlimit/strategy :bucket + ::rlimit/key "test" + ::rlimit/method "main.test" + ::rlimit/capacity 5 + ::rlimit/rate 3 + ::rlimit/interval (ct/duration 1000) + ::rlimit/params [1 3 5] + ::rlimit/opts "5/3/1s"}] + (with-redefs [rds/eval (fn [_ _] [true 4])] + (let [result (rlimit/process-limit nil "profile" now limit)] + (t/is (= (ct/inst 334) + (:app.rpc.rlimit.result/reset result))))))) From 9311737f66585722ead7abcfd0f046fce63f9791 Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Mon, 17 Aug 2026 22:35:10 +0200 Subject: [PATCH 2/5] :bug: Do not cache the environment generated config.js (#11146) On self hosted installs /js/config.js is regenerated from PENPOT_FLAGS on every container start, but nginx served it with the same `public, max-age=604800` used for build assets, and index.html versions it only by the build. A flags only change therefore leaves the URL untouched, so a browser that had already loaded the app kept using its cached copy for up to a week: enabling a flag such as enable-login-with-google had no visible effect for returning users until the cache expired or they cleared their site data. Serve that one file with the same no-store headers already used for index.html, which is the other file whose contents change without its URL changing. Every other static asset keeps the long lived cache. Fixes #10556. Signed-off-by: Filip Sajdak Co-authored-by: Andrey Antukh --- docker/images/files/nginx.conf.template | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index 5ca929c124..75a385f9d6 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -161,6 +161,16 @@ http { location / { include /etc/nginx/overrides/location.d/*.conf; + # Regenerated from the environment on every container start + # (see nginx-entrypoint.sh) while its URL is only versioned by + # the build, so a flags only restart leaves the URL untouched. + # Caching it like a build asset would keep returning users on + # the previous PENPOT_FLAGS for up to a week. + location = /js/config.js { + include /etc/nginx/nginx-security-headers.conf; + add_header Cache-Control "no-store, no-cache, max-age=0" always; + } + location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ { include /etc/nginx/nginx-security-headers.conf; add_header Cache-Control "public, max-age=604800" always; # 7 days From c72bb331effef9a5c069b64334a82fdc58a2428d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Tue, 18 Aug 2026 11:20:02 +0200 Subject: [PATCH 3/5] :bug: Fix nitrate advanced permissions error (#11255) --- frontend/src/app/main/data/team.cljs | 84 ++++++++++--------- frontend/src/app/main/ui/dashboard/team.cljs | 3 + .../src/app/main/ui/dashboard/team_form.cljs | 2 + 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 74b9a97bc9..d250543807 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -609,50 +609,56 @@ (defn check-and-submit-invite-members "Fetches fresh team data from the server to ensure up-to-date organization - permissions, then submits member invitations or shows a restriction modal." + permissions, then submits member invitations or shows a permission error." [{:keys [team-id] :as params} origin do-invite-members] (ptk/reify ::check-and-submit-invite-members ptk/WatchEvent - (watch [_ _ _] - (if (contains? cf/flags :admin-console) - (with-refreshed-team team-id - (fn [team] - (if (not (cto/allowed? :add-anybody-to-team - {:organization-perms (:organization team)})) - (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) - :emails (vec (:emails params))}) - (rx/mapcat - (fn [result] - (let [blocked (into [] (comp (filter (fn [[_ v]] (not v))) - (map first)) - result)] - (cond - (empty? blocked) - (do (do-invite-members params origin) (rx/empty)) + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (if (contains? cf/flags :admin-console) + (with-refreshed-team team-id + (fn [team] + (if (not (cto/can-send-invitations? + {:organization (:organization team) + :profile-id profile-id + :team-permissions (:permissions team)})) + (rx/of (modal/show :no-permission-modal {:type :invite-members})) + (if (not (cto/allowed? :add-anybody-to-team + {:organization-perms (:organization team)})) + (->> (rp/cmd! :check-organization-members {:organization-id (get-in team [:organization :id]) + :emails (vec (:emails params))}) + (rx/mapcat + (fn [result] + (let [blocked (into [] (comp (filter (fn [[_ v]] (not v))) + (map first)) + result)] + (cond + (empty? blocked) + (do (do-invite-members params origin) (rx/empty)) - (= (count blocked) (count result)) - (rx/of - (modal/show - {:type :alert - :title (tr "modals.invite-restricted-members.all-blocked-title") - :message (tr "modals.invite-restricted-members.all-blocked") - :accept-label (tr "labels.accept") - :accept-style :primary})) + (= (count blocked) (count result)) + (rx/of + (modal/show + {:type :alert + :title (tr "modals.invite-restricted-members.all-blocked-title") + :message (tr "modals.invite-restricted-members.all-blocked") + :accept-label (tr "labels.accept") + :accept-style :primary})) - :else - (rx/of - (modal/show - {:type :invite-restricted-members - :blocked-emails blocked - :on-accept (fn [] - (let [valid-emails (into #{} (filter (fn [e] (get result e))) - (:emails params)) - params' (assoc params :emails valid-emails)] - (do-invite-members params' origin)))}))))))) - (do (do-invite-members params origin) - (rx/empty))))) - (do (do-invite-members params origin) - (rx/empty)))))) + :else + (rx/of + (modal/show + {:type :invite-restricted-members + :blocked-emails blocked + :on-accept (fn [] + (let [valid-emails (into #{} (filter (fn [e] (get result e))) + (:emails params)) + params' (assoc params :emails valid-emails)] + (do-invite-members params' origin)))}))))))) + (do (do-invite-members params origin) + (rx/empty)))))) + (do (do-invite-members params origin) + (rx/empty))))))) (defn copy-invitation-link [{:keys [email team-id] :as params}] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index d9f8071981..027238e4b8 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -233,6 +233,9 @@ (= :email-domain-is-not-allowed code)) (st/emit! (ntf/error (tr "errors.email-domain-not-allowed")) (modal/hide)) + (and (= :validation type) + (= :insufficient-permissions code)) + (st/emit! (modal/show :no-permission-modal {:type :invite-members})) :else (st/emit! (ntf/error (tr "errors.generic")) diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 21a9708c4d..d69c108183 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -152,6 +152,8 @@ (tr "dashboard.no-permission-create-team.message" organization-name)] :delete-team [(tr "dashboard.delete-team") (tr "dashboard.no-permission-delete-team.message" organization-name)] + :invite-members [(tr "modals.invite-team-member.title") + (tr "dashboard.invitations.no-permission")] :no-organizations-create [(tr "dashboard.select-organization-modal.title") (tr "dashboard.no-organization-allows-create-team.message")] :no-organizations-change [(tr "dashboard.change-organization-modal.title") From d745dc4a3cb9f8e4c1b1ccc31f317960bf9de5c8 Mon Sep 17 00:00:00 2001 From: 0xTHAC0 Date: Tue, 18 Aug 2026 16:14:22 +0530 Subject: [PATCH 4/5] :bug: Fix grid item date tooltip showing deletion text on non-deleted files (#11161) The grid-item-metadata* component always used :will-be-deleted-at (falling back to :modified-at) and always showed the "Will be deleted %s" tooltip, even for files in the Recent tab that have no deletion date. Now the component branches on the presence of :will-be-deleted-at: - Deleted files: show the deletion timeago with the existing "Will be deleted %s" tooltip. - Regular files: show :modified-at timeago with a new "Last modified %s" tooltip key (dashboard.grid.last-modified-at). Closes #10873 Co-authored-by: Andrey Antukh --- frontend/src/app/main/ui/dashboard/grid.cljs | 18 ++++++++++++------ frontend/translations/en.po | 6 +++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 59442337f8..5588805f7e 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -239,12 +239,18 @@ (mf/defc grid-item-metadata* {::mf/private true} [{:keys [file layout]}] - (let [time (ct/timeago (or (:will-be-deleted-at file) - (:modified-at file)))] - [:span {:class (stl/css-case :grid-item-date (= layout :grid) - :list-item-date (= layout :list)) - :title (tr "dashboard.deleted.will-be-deleted-at" time)} - time])) + (let [deleted-at (:will-be-deleted-at file) + date-class (stl/css-case :grid-item-date (= layout :grid) + :list-item-date (= layout :list))] + (if deleted-at + (let [time (ct/timeago deleted-at)] + [:span {:class date-class + :title (tr "dashboard.deleted.will-be-deleted-at" time)} + time]) + (let [time (ct/timeago (:modified-at file))] + [:span {:class date-class + :title (tr "dashboard.grid.last-modified-at" time)} + time])))) (defn create-counter-element [_element file-count] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 014b63b258..f2b57e315f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -453,10 +453,14 @@ msgstr "Delete team" msgid "dashboard.deleted.empty-state-description" msgstr "Your trash is empty. Deleted files and projects will appear here." -#: src/app/main/ui/dashboard/grid.cljs:247 +#: src/app/main/ui/dashboard/grid.cljs:252 msgid "dashboard.deleted.will-be-deleted-at" msgstr "Will be deleted %s" +#: src/app/main/ui/dashboard/grid.cljs:256 +msgid "dashboard.grid.last-modified-at" +msgstr "Last modified %s" + #: src/app/main/ui/dashboard/file_menu.cljs:329, src/app/main/ui/workspace/main_menu.cljs:732 msgid "dashboard.download-binary-file" msgstr "Download Penpot file (.penpot)" From 5b4a5776cb05064d86f71e103c8537372d405b36 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 18 Aug 2026 11:18:44 +0000 Subject: [PATCH 5/5] :bug: Prevent nil theme in profile updates Omit nil optional profile fields before frontend schema validation and RPC persistence. Preserve omitted language and theme values in backend updates, and add regression coverage for partial profile saves. AI-assisted-by: gpt-5.6-luna --- backend/src/app/rpc/commands/profile.clj | 8 +++-- .../test/backend_tests/rpc_profile_test.clj | 11 +++++++ frontend/src/app/main/data/profile.cljs | 8 +++-- .../frontend_tests/data/profile_test.cljs | 30 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 ++ 5 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 frontend/test/frontend_tests/data/profile_test.cljs diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 77307a1482..114f07be3e 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -166,8 +166,12 @@ ;; the same row/object. (let [profile (get-profile conn profile-id ::db/for-update true) fullname (d/normalize-string fullname) - lang (d/normalize-string lang) - theme (d/normalize-string theme) + lang (if (contains? params :lang) + (d/normalize-string lang) + (:lang profile)) + theme (if (contains? params :theme) + (d/normalize-string theme) + (:theme profile)) ;; Update the profile map with direct params profile (-> profile (assoc :fullname fullname) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index f9900d44ab..de9ae0aacd 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -125,6 +125,17 @@ (t/is (= "en" (:lang result))) (t/is (= "dark" (:theme result)))))) + (t/testing "update profile preserves omitted optional fields" + (let [data {::th/type :update-profile + ::rpc/profile-id (:id profile) + :fullname "Updated Name"} + out (th/command! data)] + + (t/is (nil? (:error out))) + (t/is (= "Updated Name" (get-in out [:result :fullname]))) + (t/is (= "en" (get-in out [:result :lang]))) + (t/is (= "dark" (get-in out [:result :theme]))))) + (t/testing "update photo" (let [data {::th/type :update-profile-photo ::rpc/profile-id (:id profile) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 3f1237c2b7..9d6db2101d 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -122,6 +122,10 @@ ;; --- Update Profile +(defn profile-update-params + [profile] + (d/without-nils (select-keys profile [:fullname :lang :theme]))) + (defn persist-profile [& {:as opts}] (ptk/reify ::persist-profile @@ -130,7 +134,7 @@ (let [on-success (:on-success opts identity) on-error (:on-error opts rx/throw) profile (:profile state) - params (select-keys profile [:fullname :lang :theme])] + params (profile-update-params profile)] (->> (rp/cmd! :update-profile params) (rx/tap on-success) (rx/map set-profile) @@ -143,7 +147,7 @@ props" [profile] - (let [profile (check-profile profile)] + (let [profile (check-profile (d/without-nils profile))] (ptk/reify ::update-profile ptk/WatchEvent (watch [_ state _] diff --git a/frontend/test/frontend_tests/data/profile_test.cljs b/frontend/test/frontend_tests/data/profile_test.cljs new file mode 100644 index 0000000000..97e62f63d6 --- /dev/null +++ b/frontend/test/frontend_tests/data/profile_test.cljs @@ -0,0 +1,30 @@ +;; 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 frontend-tests.data.profile-test + (:require + [app.common.uuid :as uuid] + [app.main.data.profile :as dprof] + [cljs.test :as t :include-macros true])) + +(t/deftest profile-update-params-omits-nil-values + (t/is (= {:fullname "Updated Name"} + (dprof/profile-update-params {:fullname "Updated Name" + :lang nil + :theme nil})))) + +(t/deftest profile-update-params-preserves-present-values + (t/is (= {:fullname "Updated Name" + :lang "en" + :theme "dark"} + (dprof/profile-update-params {:fullname "Updated Name" + :lang "en" + :theme "dark"})))) + +(t/deftest update-profile-accepts-nil-optional-values + (t/is (some? (dprof/update-profile {:id uuid/zero + :fullname "Updated Name" + :theme nil})))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 31896b987e..f4ae117eda 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -11,6 +11,7 @@ [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.nitrate-test] + [frontend-tests.data.profile-test] [frontend-tests.data.repo-test] [frontend-tests.data.store-test] [frontend-tests.data.uploads-test] @@ -106,6 +107,7 @@ 'frontend-tests.copy-as-svg-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.nitrate-test + 'frontend-tests.data.profile-test 'frontend-tests.data.repo-test 'frontend-tests.data.store-test 'frontend-tests.data.exports-assets-test