From d99ae55143dc548588dd8594103e4f90dbbf0372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6tzsch?= Date: Tue, 22 Feb 2022 23:48:00 +0100 Subject: [PATCH] improved: upload supervisor picture The url is now stored in the database. While the url can still be derived from the supervisor.id, we now know if an picture was uploaded without trying to load it. --- backend/src/swlkup/db/seed/example.edn | 2 - .../resolver/root/supervisor/deactivate.clj | 5 +- .../resolver/root/supervisor/update.clj | 47 ++++++++++++++++--- backend/src/swlkup/webserver/upload/state.clj | 12 +++-- frontend/codegen/generates.ts | 39 +++++++++++++-- frontend/codegen/queries.ts | 8 +++- .../supervisor/ProfilePictureUpload.tsx | 11 +++-- frontend/components/user/LookupResult.tsx | 9 ++-- 8 files changed, 105 insertions(+), 28 deletions(-) diff --git a/backend/src/swlkup/db/seed/example.edn b/backend/src/swlkup/db/seed/example.edn index 2b4fd66..d023076 100644 --- a/backend/src/swlkup/db/seed/example.edn +++ b/backend/src/swlkup/db/seed/example.edn @@ -132,7 +132,6 @@ :xt/spec :swlkup.model.supervisor/doc :swlkup.model.login/login:ids "login_max_mueller" :name_full "Max Müller" - :photo "https://raw.githubusercontent.com/wiki/community-garden/community-garden.github.io/images/team_avatar_joe.png" :languages [:de :en] :offers #{"counselling_refugees" "crisis_intervention_refugees"} :contacts {:phone "0351 1234 5678" @@ -151,7 +150,6 @@ :xt/spec :swlkup.model.supervisor/doc :swlkup.model.login/login:ids "maria@mm.de" :name_full "Maria Musterfrau" - :photo "https://raw.githubusercontent.com/wiki/community-garden/community-garden.github.io/images/team_avatar_stephanie.png" :languages [:it :es :fr :ar] :offers #{"counseling_refugees" "crisis_intervention_refugees" "mediation" "moderation" "supervision" "workshops"} diff --git a/backend/src/swlkup/resolver/root/supervisor/deactivate.clj b/backend/src/swlkup/resolver/root/supervisor/deactivate.clj index 23eb9ed..ee25d50 100644 --- a/backend/src/swlkup/resolver/root/supervisor/deactivate.clj +++ b/backend/src/swlkup/resolver/root/supervisor/deactivate.clj @@ -3,8 +3,7 @@ [specialist-server.type :as t] [swlkup.auth.core :refer [auth+role->entity]] [swlkup.model.auth :as auth] - [swlkup.model.supervisor :as supervisor] - [xtdb.api :as xt])) + [swlkup.model.supervisor :as supervisor])) (s/fdef supervisor_deactivate :args (s/tuple map? (s/keys :req-un [::auth/auth ::supervisor/deactivated]) map? map?) @@ -21,7 +20,7 @@ '(fn [ctx eid deactivated] (let [db (xtdb.api/db ctx) entity (xtdb.api/entity db eid)] - [[::xt/put (assoc entity :deactivated deactivated)]]))) + [[:xtdb.api/put (assoc entity :deactivated deactivated)]]))) (tx-fn-call :deactivate-supervisor supervisor:id (:deactivated opt)))] (sync) (boolean (:xtdb.api/tx-id tx_result)))) diff --git a/backend/src/swlkup/resolver/root/supervisor/update.clj b/backend/src/swlkup/resolver/root/supervisor/update.clj index 23d63d3..68eaa60 100644 --- a/backend/src/swlkup/resolver/root/supervisor/update.clj +++ b/backend/src/swlkup/resolver/root/supervisor/update.clj @@ -12,15 +12,50 @@ :ret t/boolean) (defn supervisor_update - "Update a supervisors data" + "Update a supervisors profile + + (:supervisor_input opt) will be merged into an existing database entry when existing. + To delete an entry, it must explicitly be in the dictionary. + This allows editing a profile without overwriting :photo and :deactivated. + " [_node opt ctx _info] - (let [{:keys [tx_sync]} (:db_ctx ctx) + (let [{:keys [tx-fn-put tx-fn-call sync]} (:db_ctx ctx) [supervisor:id login:id] (auth+role->entity ctx (:auth opt) ::supervisor/doc) tx_result (when login:id ;; any login, independent of role can be used - (tx_sync [[:xtdb.api/put (assoc (:supervisor_input opt) - :xt/id (or supervisor:id (uuid)) - :xt/spec ::supervisor/doc - ::login/login:ids login:id)]]))] + (tx-fn-put :merge-supervisor-profile + '(fn [ctx eid doc login:id] + (let [db (xtdb.api/db ctx) + entity (xtdb.api/entity db eid)] + [[:xtdb.api/put (assoc (merge entity doc) + :xt/id eid + :xt/spec ::supervisor/doc + ::login/login:ids login:id)]]))) + (tx-fn-call :merge-supervisor-profile (or supervisor:id (uuid)) + (:supervisor_input opt) + login:id))] + (sync) (boolean (:xtdb.api/tx-id tx_result)))) (s/def ::supervisor_update (t/resolver #'supervisor_update)) + + +(defn supervisor_update_photo + "Update a supervisors profile picture + + This mutation is not directly usable (via graphql API), but only called from the `upload-supervisor-picture` handler. + That gives us full control over the url and avoids: + * potential XSS via SVG + * tracking of users by images embedded from other servers + " + [_node opt ctx _info] + (let [{:keys [tx-fn-put tx-fn-call sync]} (:db_ctx ctx) + [supervisor:id _login:id] (auth+role->entity ctx (:auth opt) ::supervisor/doc) + tx_result (when supervisor:id + (tx-fn-put :update-supervisor-photo + '(fn [ctx eid photo] + (let [db (xtdb.api/db ctx) + entity (xtdb.api/entity db eid)] + [[:xtdb.api/put (assoc entity :photo photo)]]))) + (tx-fn-call :update-supervisor-photo supervisor:id (:photo opt)))] + (sync) + (boolean (:xtdb.api/tx-id tx_result)))) diff --git a/backend/src/swlkup/webserver/upload/state.clj b/backend/src/swlkup/webserver/upload/state.clj index 2f50e89..25dcce1 100644 --- a/backend/src/swlkup/webserver/upload/state.clj +++ b/backend/src/swlkup/webserver/upload/state.clj @@ -7,7 +7,7 @@ [clojure.string :as string :refer [split]] [swlkup.auth.core :refer [auth+role->entity]] [swlkup.db.state :refer [db_ctx]] - [swlkup.resolver.root.supervisor.update] + [swlkup.resolver.root.supervisor.update :refer [supervisor_update supervisor_update_photo]] [swlkup.model.supervisor :as supervisor])) (defn -upload-supervisor-picture @@ -23,13 +23,13 @@ (if supervisor:id [supervisor:id login:id] (when login:id - #_(println "create profile") (let [opt {:auth auth :supervisor_input supervisor/empty}] - (swlkup.resolver.root.supervisor.update/supervisor_update nil opt ctx nil)) + (supervisor_update nil opt ctx nil)) (auth+role->entity ctx auth ::supervisor/doc)))) - dest (str (:upload-dir env) "/" supervisor:id ".jpeg")] + dest (str (:upload-dir env) "/" supervisor:id ".jpeg") + public_path (str "/uploads/" supervisor:id ".jpeg")] (if-not supervisor:id {:status 403 :body "Invalid login:id, ensure you are logged in!"} @@ -37,6 +37,10 @@ {:status 413 :body (str "Upload exceeds maximum size of " (:upload-limit-mb env) "MB")} (do (io/copy (io/file source) (io/file dest)) + (let [simple_hash (hash (slurp (io/file dest))) ;; This is not an cryptographic integrety check, but only to determine caching. + opt {:auth auth + :photo (str public_path "?" simple_hash)}] + (supervisor_update_photo nil opt ctx nil)) (response "Upload Successful")))))) (defn -serve-uploaded-supervisor-picture diff --git a/frontend/codegen/generates.ts b/frontend/codegen/generates.ts index 59bafec..344b756 100644 --- a/frontend/codegen/generates.ts +++ b/frontend/codegen/generates.ts @@ -63,7 +63,14 @@ export type MutationType = { supervisor_delete: Scalars['Boolean']; /** Add a new supervisor account to the database and send a mail containing the password via mail */ supervisor_register: Scalars['Boolean']; - /** Update a supervisors data */ + /** + * Update a supervisors profile + * + * (:supervisor_input opt) will be merged into an existing database entry when existing. + * To delete an entry, it must explicitly be in the dictionary. + * This allows editing a profile without overwriting :photo and :deactivated. + * + */ supervisor_update: Scalars['Boolean']; }; @@ -167,6 +174,7 @@ export type QueryTypeSupervisors_RegisteredArgs = { export type SupervisorInput = { /** Self descriptive. */ contacts: ContactsInput; + deactivated?: InputMaybe; languages: Array; /** Self descriptive. */ location: LocationInput; @@ -320,7 +328,14 @@ export type SupervisorGetQueryVariables = Exact<{ }>; -export type SupervisorGetQuery = { __typename?: 'QueryType', supervisor_get?: { __typename?: 'supervisor_get', id: string, deactivated?: boolean | null | undefined, ngos: any, name_full: string, languages: Array, offers: Array, photo?: string | null | undefined, text_specialization?: string | null | undefined, text?: string | null | undefined, contacts: { __typename?: 'Contacts', phone?: string | null | undefined, website?: string | null | undefined, email?: string | null | undefined }, location: { __typename?: 'Location', zip?: string | null | undefined } } | null | undefined, languages: Array<{ __typename?: 'languages', id: string, name: string, flag_url: string, idx: number }>, offers: Array<{ __typename?: 'offers', id: string, target: string, idx: number }>, ngos: Array<{ __typename?: 'ngos', id?: string | null | undefined, name?: string | null | undefined }> }; +export type SupervisorGetQuery = { __typename?: 'QueryType', supervisor_get?: { __typename?: 'supervisor_get', id: string, deactivated?: boolean | null | undefined, ngos: any, name_full: string, languages: Array, offers: Array, text_specialization?: string | null | undefined, text?: string | null | undefined, contacts: { __typename?: 'Contacts', phone?: string | null | undefined, website?: string | null | undefined, email?: string | null | undefined }, location: { __typename?: 'Location', zip?: string | null | undefined } } | null | undefined, languages: Array<{ __typename?: 'languages', id: string, name: string, flag_url: string, idx: number }>, offers: Array<{ __typename?: 'offers', id: string, target: string, idx: number }>, ngos: Array<{ __typename?: 'ngos', id?: string | null | undefined, name?: string | null | undefined }> }; + +export type SupervisorGetPhotoQueryVariables = Exact<{ + auth: Auth; +}>; + + +export type SupervisorGetPhotoQuery = { __typename?: 'QueryType', supervisor_get?: { __typename?: 'supervisor_get', photo?: string | null | undefined } | null | undefined }; export type NgoQueryVariables = Exact<{ auth: Auth; @@ -434,7 +449,6 @@ export const SupervisorGetDocument = ` location { zip } - photo text_specialization text } @@ -467,6 +481,25 @@ export const useSupervisorGetQuery = < fetcher(SupervisorGetDocument, variables), options ); +export const SupervisorGetPhotoDocument = ` + query SupervisorGetPhoto($auth: Auth!) { + supervisor_get(auth: $auth) { + photo + } +} + `; +export const useSupervisorGetPhotoQuery = < + TData = SupervisorGetPhotoQuery, + TError = unknown + >( + variables: SupervisorGetPhotoQueryVariables, + options?: UseQueryOptions + ) => + useQuery( + ['SupervisorGetPhoto', variables], + fetcher(SupervisorGetPhotoDocument, variables), + options + ); export const NgoDocument = ` query Ngo($auth: Auth!) { created_tokens(auth: $auth) { diff --git a/frontend/codegen/queries.ts b/frontend/codegen/queries.ts index 5b2bd46..f5dc9be 100644 --- a/frontend/codegen/queries.ts +++ b/frontend/codegen/queries.ts @@ -68,7 +68,6 @@ export const supervisor_get = gql` location { zip } - photo text_specialization text } @@ -92,6 +91,13 @@ export const supervisor_get = gql` } }` +export const supervisor_get_photo = gql` + query SupervisorGetPhoto($auth: Auth!) { + supervisor_get(auth: $auth) { + photo + } + }` + export const ngo = gql` query Ngo($auth: Auth!) { created_tokens(auth: $auth) {token purpose valid} diff --git a/frontend/components/supervisor/ProfilePictureUpload.tsx b/frontend/components/supervisor/ProfilePictureUpload.tsx index b25c226..0e3dcf7 100644 --- a/frontend/components/supervisor/ProfilePictureUpload.tsx +++ b/frontend/components/supervisor/ProfilePictureUpload.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../../components/Login' -import { useSupervisorGetQuery } from '../../codegen/generates' +import { useSupervisorGetPhotoQuery } from '../../codegen/generates' import { config, fetch_config } from "../../config"; function upload_profile_picture(jwt: String, upload_url: URL, refetch: any) { @@ -26,11 +26,12 @@ export function ProfilePictureUpload() { const {t} = useTranslation() const auth = useAuthStore() - const {data, refetch} = useSupervisorGetQuery({auth}, {enabled: Boolean(auth.jwt)}) - const supervisor = data?.supervisor_get + /** We use a separate query for the profile picture. This allows refetching without compromising unsubmitted changes in the form. **/ + const {data, refetch} = useSupervisorGetPhotoQuery({auth}, {enabled: Boolean(auth.jwt)}) + const photo = data?.supervisor_get?.photo - const img_url = `${config.backend_base_url}/uploads/${supervisor?.id}.jpeg?${new Date().getTime()}` // TODO: url from supervisor.photo const upload_url = `${config.backend_base_url}/api/upload-supervisor-picture` as any as URL + const img_src = `${config.backend_base_url}${photo}` return ( <> @@ -38,7 +39,7 @@ export function ProfilePictureUpload() {
upload_profile_picture(auth.jwt, upload_url, refetch) } /> - + { photo && } ) } diff --git a/frontend/components/user/LookupResult.tsx b/frontend/components/user/LookupResult.tsx index cf098eb..ad03b0c 100644 --- a/frontend/components/user/LookupResult.tsx +++ b/frontend/components/user/LookupResult.tsx @@ -132,7 +132,7 @@ function FilterForm({languages, offers, selections}: function Supervisor({supervisor, languages, backend_base_url}: {supervisor: Supervisors, languages: Languages[], backend_base_url: URL}) { - const img_url = `${backend_base_url}/uploads/${supervisor?.id}.jpeg` // TODO supervisor.photo + const img_src = `${config.backend_base_url}${supervisor?.photo}` return (
@@ -150,9 +150,10 @@ function Supervisor({supervisor, languages, backend_base_url}: {supervisor.text} - { // supervisor.photo && /** TODO **/ - } + { supervisor?.photo && + }