mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 01:22:15 +03:00
Nolt sso (along with a better nav dropdown) (#3395)
* Add SSO link with signed JWT token * Falls back to Nolt URL without SSO if token cannot be generated * Add profile image (gravatar) to Nolt SSO link * Improve navbar dropdown * Add 'contact support' link to nav dropdown * Add CSS rule to prevent horizontal jumps * Dark mode styling * Close dropdown when link is clicked * Clarify links in dropdown * Clarify CSS comment * Use Alpine.data() over window * Rename suggestions_dropdown -> combo-box * Mix format * Make logout link look good on dark mode * Use proxy for gravatar * Do not use Gravatar proxy in self-hosted * Changelog * Add Github Repo link to nav dropdown * Make dialyzer happy * Add proxy for Gravatar * Update assets/css/app.css Co-authored-by: hq1 <hq@mtod.org> * Update lib/plausible_web/controllers/avatar_controller.ex Co-authored-by: hq1 <hq@mtod.org> * Fix alpine <> Liveview integration --------- Co-authored-by: hq1 <hq@mtod.org>
This commit is contained in:
parent
99efb93082
commit
97b24c0492
@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Add `custom_props.csv` to CSV export (almost the same as the old `prop_breakdown.csv`, but has different column headers, and includes props for pageviews too, not only custom events)
|
||||
- Add `referrers.csv` to CSV export
|
||||
- Improve password validation in registration and password reset forms
|
||||
- Adds Gravatar profile image to navbar
|
||||
- Enforce email reverification on update
|
||||
|
||||
### Removed
|
||||
|
@ -41,6 +41,7 @@ html {
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
width:100vw; /* Prevents content from jumping when scrollbar is added/removed due to vertical overflow */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
@ -2,11 +2,16 @@ import "../css/app.css"
|
||||
import "flatpickr/dist/flatpickr.min.css"
|
||||
import "./polyfills/closest"
|
||||
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
|
||||
import 'alpinejs'
|
||||
import Alpine from 'alpinejs'
|
||||
import "./liveview/live_socket"
|
||||
import "./liveview/suggestions_dropdown"
|
||||
import comboBox from "./liveview/combo-box"
|
||||
import dropdown from "./liveview/dropdown"
|
||||
import "./liveview/phx_events"
|
||||
|
||||
Alpine.data('dropdown', dropdown)
|
||||
Alpine.data('comboBox', comboBox)
|
||||
Alpine.start()
|
||||
|
||||
const triggers = document.querySelectorAll('[data-dropdown-trigger]')
|
||||
|
||||
for (const trigger of triggers) {
|
||||
|
77
assets/js/liveview/combo-box.js
Normal file
77
assets/js/liveview/combo-box.js
Normal file
@ -0,0 +1,77 @@
|
||||
// Courtesy of Benjamin von Polheim:
|
||||
// https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
|
||||
|
||||
export default (id) => ({
|
||||
isOpen: false,
|
||||
id: id,
|
||||
focus: null,
|
||||
setFocus(f) {
|
||||
this.focus = f;
|
||||
},
|
||||
initFocus() {
|
||||
if (this.focus === null) {
|
||||
this.setFocus(this.leastFocusableIndex())
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.initFocus()
|
||||
this.isOpen = true
|
||||
},
|
||||
suggestionsCount() {
|
||||
return this.$refs.suggestions?.querySelectorAll('li').length
|
||||
},
|
||||
hasCreatableOption() {
|
||||
return this.$refs.suggestions?.querySelector('li').classList.contains("creatable")
|
||||
},
|
||||
leastFocusableIndex() {
|
||||
if (this.suggestionsCount() === 0) {
|
||||
return 0
|
||||
}
|
||||
return this.hasCreatableOption() ? 0 : 1
|
||||
},
|
||||
maxFocusableIndex() {
|
||||
return this.hasCreatableOption() ? this.suggestionsCount() - 1 : this.suggestionsCount()
|
||||
},
|
||||
nextFocusableIndex() {
|
||||
const currentFocus = this.focus
|
||||
return currentFocus + 1 > this.maxFocusableIndex() ? this.leastFocusableIndex() : currentFocus + 1
|
||||
},
|
||||
prevFocusableIndex() {
|
||||
const currentFocus = this.focus
|
||||
return currentFocus - 1 >= this.leastFocusableIndex() ? currentFocus - 1 : this.maxFocusableIndex()
|
||||
},
|
||||
close(e) {
|
||||
// Pressing Escape should not propagate to window,
|
||||
// so we'll only close the suggestions pop-up
|
||||
if (this.isOpen && e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
}
|
||||
this.isOpen = false
|
||||
},
|
||||
select() {
|
||||
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
|
||||
this.close()
|
||||
document.getElementById(this.id).blur()
|
||||
},
|
||||
scrollTo(idx) {
|
||||
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView(
|
||||
{ block: 'nearest', behavior: 'smooth', inline: 'start' }
|
||||
)
|
||||
},
|
||||
focusNext() {
|
||||
const nextIndex = this.nextFocusableIndex()
|
||||
|
||||
if (!this.isOpen) this.open()
|
||||
|
||||
this.setFocus(nextIndex)
|
||||
this.scrollTo(nextIndex)
|
||||
},
|
||||
focusPrev() {
|
||||
const prevIndex = this.prevFocusableIndex()
|
||||
|
||||
if (!this.isOpen) this.open()
|
||||
|
||||
this.setFocus(prevIndex)
|
||||
this.scrollTo(prevIndex)
|
||||
}
|
||||
})
|
26
assets/js/liveview/dropdown.js
Normal file
26
assets/js/liveview/dropdown.js
Normal file
@ -0,0 +1,26 @@
|
||||
// From https://alpinejs.dev/component/dropdown
|
||||
|
||||
export default () => ({
|
||||
open: false,
|
||||
toggle() {
|
||||
if (this.open) {
|
||||
return this.close()
|
||||
}
|
||||
|
||||
this.$refs.button.focus()
|
||||
this.open = true
|
||||
},
|
||||
|
||||
close(focusAfter) {
|
||||
if (! this.open) return
|
||||
|
||||
this.open = false
|
||||
focusAfter && focusAfter.focus()
|
||||
},
|
||||
|
||||
onPanelClick(e) {
|
||||
if (e.target.tagName === 'A') {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
})
|
@ -1,4 +1,5 @@
|
||||
import "phoenix_html"
|
||||
import Alpine from 'alpinejs'
|
||||
import { Socket } from "phoenix"
|
||||
import { LiveSocket } from "phoenix_live_view"
|
||||
|
||||
@ -26,8 +27,8 @@ if (csrfToken && websocketUrl) {
|
||||
params: { _csrf_token: token }, hooks: Hooks, dom: {
|
||||
// for alpinejs integration
|
||||
onBeforeElUpdated(from, to) {
|
||||
if (from.__x) {
|
||||
window.Alpine.clone(from.__x, to);
|
||||
if (from._x_dataStack) {
|
||||
Alpine.clone(from, to);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -1,80 +0,0 @@
|
||||
// Courtesy of Benjamin von Polheim:
|
||||
// https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
|
||||
let suggestionsDropdown = function(id) {
|
||||
return {
|
||||
isOpen: false,
|
||||
id: id,
|
||||
focus: null,
|
||||
setFocus(f) {
|
||||
this.focus = f;
|
||||
},
|
||||
initFocus() {
|
||||
if (this.focus === null) {
|
||||
this.setFocus(this.leastFocusableIndex())
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.initFocus()
|
||||
this.isOpen = true
|
||||
},
|
||||
suggestionsCount() {
|
||||
return this.$refs.suggestions?.querySelectorAll('li').length
|
||||
},
|
||||
hasCreatableOption() {
|
||||
return this.$refs.suggestions?.querySelector('li').classList.contains("creatable")
|
||||
},
|
||||
leastFocusableIndex() {
|
||||
if (this.suggestionsCount() === 0) {
|
||||
return 0
|
||||
}
|
||||
return this.hasCreatableOption() ? 0 : 1
|
||||
},
|
||||
maxFocusableIndex() {
|
||||
return this.hasCreatableOption() ? this.suggestionsCount() - 1 : this.suggestionsCount()
|
||||
},
|
||||
nextFocusableIndex() {
|
||||
const currentFocus = this.focus
|
||||
return currentFocus + 1 > this.maxFocusableIndex() ? this.leastFocusableIndex() : currentFocus + 1
|
||||
},
|
||||
prevFocusableIndex() {
|
||||
const currentFocus = this.focus
|
||||
return currentFocus - 1 >= this.leastFocusableIndex() ? currentFocus - 1 : this.maxFocusableIndex()
|
||||
},
|
||||
close(e) {
|
||||
// Pressing Escape should not propagate to window,
|
||||
// so we'll only close the suggestions pop-up
|
||||
if (this.isOpen && e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
}
|
||||
this.isOpen = false
|
||||
},
|
||||
select() {
|
||||
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
|
||||
this.close()
|
||||
document.getElementById(this.id).blur()
|
||||
},
|
||||
scrollTo(idx) {
|
||||
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView(
|
||||
{ block: 'nearest', behavior: 'smooth', inline: 'start' }
|
||||
)
|
||||
},
|
||||
focusNext() {
|
||||
const nextIndex = this.nextFocusableIndex()
|
||||
|
||||
if (!this.isOpen) this.open()
|
||||
|
||||
this.setFocus(nextIndex)
|
||||
this.scrollTo(nextIndex)
|
||||
},
|
||||
focusPrev() {
|
||||
const prevIndex = this.prevFocusableIndex()
|
||||
|
||||
if (!this.isOpen) this.open()
|
||||
|
||||
this.setFocus(prevIndex)
|
||||
this.scrollTo(prevIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.suggestionsDropdown = suggestionsDropdown
|
46
assets/package-lock.json
generated
46
assets/package-lock.json
generated
@ -21,7 +21,7 @@
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@tailwindcss/typography": "^0.4.1",
|
||||
"abortcontroller-polyfill": "^1.7.3",
|
||||
"alpinejs": "^2.8.2",
|
||||
"alpinejs": "^3.13.1",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"babel-loader": "^8.2.2",
|
||||
"chart.js": "^3.3.2",
|
||||
@ -2025,6 +2025,19 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz",
|
||||
@ -2289,9 +2302,12 @@
|
||||
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ=="
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
|
||||
"integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.1.tgz",
|
||||
"integrity": "sha512-/LZ7mumW02V7AV5xTTftJFHS0I3KOXLl7tHm4xpxXAV+HJ/zjTT0n8MU7RZ6UoGPhmO/i+KEhQojaH/0RsH5tg==",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
@ -11101,6 +11117,19 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
|
||||
},
|
||||
"@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"requires": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
|
||||
},
|
||||
"@webassemblyjs/ast": {
|
||||
"version": "1.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz",
|
||||
@ -11328,9 +11357,12 @@
|
||||
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ=="
|
||||
},
|
||||
"alpinejs": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-2.8.2.tgz",
|
||||
"integrity": "sha512-5yOUtckn4CBp0qsHpo2qgjZyZit84uXvHbB7NJ27sn4FA6UlFl2i9PGUAdTXkcbFvvxDJBM+zpOD8RuNYFvQAw=="
|
||||
"version": "3.13.1",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.1.tgz",
|
||||
"integrity": "sha512-/LZ7mumW02V7AV5xTTftJFHS0I3KOXLl7tHm4xpxXAV+HJ/zjTT0n8MU7RZ6UoGPhmO/i+KEhQojaH/0RsH5tg==",
|
||||
"requires": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
|
@ -23,7 +23,7 @@
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@tailwindcss/typography": "^0.4.1",
|
||||
"abortcontroller-polyfill": "^1.7.3",
|
||||
"alpinejs": "^2.8.2",
|
||||
"alpinejs": "^3.13.1",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"babel-loader": "^8.2.2",
|
||||
"chart.js": "^3.3.2",
|
||||
|
@ -509,6 +509,9 @@ config :plausible, :hcaptcha,
|
||||
sitekey: hcaptcha_sitekey,
|
||||
secret: hcaptcha_secret
|
||||
|
||||
nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET")
|
||||
config :joken, default_signer: nolt_sso_secret
|
||||
|
||||
config :plausible, Plausible.Sentry.Client,
|
||||
finch_request_opts: [
|
||||
pool_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_POOL_TIMEOUT", 5000),
|
||||
|
@ -153,6 +153,17 @@ defmodule Plausible.Auth.User do
|
||||
%{suggestions: [], warning: "", score: 3}
|
||||
end
|
||||
|
||||
def profile_img_url(%__MODULE__{email: email}) do
|
||||
hash =
|
||||
email
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> :erlang.md5()
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash])
|
||||
end
|
||||
|
||||
defp validate_email_changed(changeset) do
|
||||
if !get_change(changeset, :email) && !changeset.errors[:email] do
|
||||
add_error(changeset, :email, "can't be the same", validation: :different_email)
|
||||
|
@ -47,11 +47,89 @@ defmodule PlausibleWeb.Components.Generic do
|
||||
|
||||
attr :id, :any, default: nil
|
||||
attr :href, :string, required: true
|
||||
attr :new_tab, :boolean
|
||||
attr :new_tab, :boolean, default: false
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
|
||||
def styled_link(assigns) do
|
||||
~H"""
|
||||
<.unstyled_link
|
||||
new_tab={@new_tab}
|
||||
href={@href}
|
||||
class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.unstyled_link>
|
||||
"""
|
||||
end
|
||||
|
||||
slot :button, required: true do
|
||||
attr :class, :string
|
||||
end
|
||||
|
||||
slot :panel, required: true do
|
||||
attr :class, :string
|
||||
end
|
||||
|
||||
def dropdown(assigns) do
|
||||
~H"""
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
x-data="dropdown"
|
||||
x-on:keydown.escape.prevent.stop="close($refs.button)"
|
||||
x-on:focusin.window="! $refs.panel.contains($event.target) && close()"
|
||||
x-id="['dropdown-button']"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
x-ref="button"
|
||||
x-on:click="toggle()"
|
||||
x-bind:aria-expanded="open"
|
||||
x-bind:aria-controls="$id('dropdown-button')"
|
||||
type="button"
|
||||
class={List.first(@button).class}
|
||||
>
|
||||
<%= render_slot(List.first(@button)) %>
|
||||
</button>
|
||||
<div
|
||||
x-ref="panel"
|
||||
x-show="open"
|
||||
x-transition.origin.top.left
|
||||
x-on:click.outside="close($refs.button)"
|
||||
x-on:click="onPanelClick"
|
||||
x-bind:id="$id('dropdown-button')"
|
||||
style="display: none;"
|
||||
class={List.first(@panel).class}
|
||||
>
|
||||
<%= render_slot(List.first(@panel)) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :href, :string, required: true
|
||||
attr :new_tab, :boolean, default: false
|
||||
slot :inner_block, required: true
|
||||
|
||||
def dropdown_link(assigns) do
|
||||
~H"""
|
||||
<.unstyled_link
|
||||
new_tab={@new_tab}
|
||||
href={@href}
|
||||
class="w-full justify-between text-gray-700 dark:text-gray-300 block px-3.5 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.unstyled_link>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :href, :string, required: true
|
||||
attr :new_tab, :boolean, default: false
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
|
||||
def unstyled_link(assigns) do
|
||||
if assigns[:new_tab] do
|
||||
assigns = assign(assigns, :icon_class, icon_class(assigns))
|
||||
|
||||
@ -59,7 +137,7 @@ defmodule PlausibleWeb.Components.Generic do
|
||||
<.link
|
||||
id={@id}
|
||||
class={[
|
||||
"inline-flex items-center gap-x-0.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600",
|
||||
"inline-flex items-center gap-x-0.5",
|
||||
@class
|
||||
]}
|
||||
href={@href}
|
||||
@ -67,18 +145,12 @@ defmodule PlausibleWeb.Components.Generic do
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
<Heroicons.arrow_top_right_on_square class={@icon_class} />
|
||||
<Heroicons.arrow_top_right_on_square class={["opacity-60", @icon_class]} />
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<.link
|
||||
class={[
|
||||
"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600",
|
||||
@class
|
||||
]}
|
||||
href={@href}
|
||||
>
|
||||
<.link class={@class} href={@href}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
"""
|
||||
|
38
lib/plausible_web/controllers/avatar_controller.ex
Normal file
38
lib/plausible_web/controllers/avatar_controller.ex
Normal file
@ -0,0 +1,38 @@
|
||||
defmodule PlausibleWeb.AvatarController do
|
||||
@moduledoc """
|
||||
This module proxies requests to BASE_URL/avatar/:hash to www.gravatar.com/avatar/:hash.
|
||||
|
||||
The purpose is to make use of Gravatar's convenient avatar service without exposing information
|
||||
that could be used for tracking the Plausible user. Compared to requesting the Gravatar directly
|
||||
from the browser, this proxy module protects the Plausible user from disclosing to Gravatar:
|
||||
1. The client IP address
|
||||
2. User-Agent
|
||||
3. Referer header which can be used to track which site the user is visiting (i.e. plausible.io or self-hosted URL)
|
||||
|
||||
The downside is the added latency from the request having to go through the Plausible server, rather than contacting the
|
||||
local CDN server operated by Gravatar's service.
|
||||
"""
|
||||
use PlausibleWeb, :controller
|
||||
alias Plausible.HTTPClient
|
||||
|
||||
@gravatar_base_url "https://www.gravatar.com"
|
||||
def avatar(conn, params) do
|
||||
url = Path.join(@gravatar_base_url, ["avatar/", params["hash"]]) <> "?s=150&d=identicon"
|
||||
|
||||
case HTTPClient.impl().get(url) do
|
||||
{:ok, %Finch.Response{status: 200, body: body, headers: headers}} ->
|
||||
conn
|
||||
|> forward_headers(headers)
|
||||
|> send_resp(200, body)
|
||||
|
||||
{:error, _} ->
|
||||
send_resp(conn, 400, "")
|
||||
end
|
||||
end
|
||||
|
||||
@forwarded_headers ["content-type", "cache-control", "expires"]
|
||||
defp forward_headers(conn, headers) do
|
||||
headers_to_forward = Enum.filter(headers, fn {k, _} -> k in @forwarded_headers end)
|
||||
%Plug.Conn{conn | resp_headers: headers_to_forward}
|
||||
end
|
||||
end
|
@ -80,7 +80,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
|
||||
<div
|
||||
id={"input-picker-main-#{@id}"}
|
||||
class={@class}
|
||||
x-data={"window.suggestionsDropdown('#{@id}')"}
|
||||
x-data={"comboBox('#{@id}')"}
|
||||
x-on:keydown.arrow-up.prevent="focusPrev"
|
||||
x-on:keydown.arrow-down.prevent="focusNext"
|
||||
x-on:keydown.enter.prevent="select"
|
||||
|
@ -171,6 +171,7 @@ defmodule PlausibleWeb.Router do
|
||||
post "/password/request-reset", AuthController, :password_reset_request
|
||||
get "/password/reset", AuthController, :password_reset_form
|
||||
post "/password/reset", AuthController, :password_reset
|
||||
get "/avatar/:hash", AvatarController, :avatar
|
||||
post "/error_report", ErrorReportController, :submit_error_report
|
||||
end
|
||||
|
||||
|
@ -1,72 +0,0 @@
|
||||
<nav class="relative z-20 py-8">
|
||||
<div class="container">
|
||||
<nav class="relative flex items-center justify-between sm:h-10 md:justify-center">
|
||||
<div class="flex items-center flex-1 md:absolute md:inset-y-0 md:left-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="<%= home_dest(@conn) %>">
|
||||
<%= img_tag(PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_logo_dark.png"), class: "h-8 w-auto sm:h-10 -mt-2 hidden dark:inline", alt: "Plausible logo", loading: "lazy")%>
|
||||
<%= img_tag(PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_logo.png"), class: "h-8 w-auto sm:h-10 -mt-2 inline dark:hidden", alt: "Plausible logo", loading: "lazy") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center justify-end w-2/3 sm:w-auto">
|
||||
<%= cond do %>
|
||||
<% @conn.assigns[:current_user] -> %>
|
||||
<ul class="flex w-full sm:w-auto">
|
||||
<%= if Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<li class="hidden mr-6 sm:block">
|
||||
<%= link(to: "https://github.com/plausible/analytics", class: "font-bold rounded m-1 ml-0 p-1 hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100", style: "line-height: 40px;", target: "_blank") do %>
|
||||
<svg class="inline w-4 h-4 mr-px -mt-1" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
Repo
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<%= if Plausible.Billing.on_trial?(@conn.assigns[:current_user]) && !Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<li class="hidden mr-6 sm:block">
|
||||
<%= link(trial_notificaton(@conn.assigns[:current_user]), to: "/settings", class: "font-bold text-sm text-yellow-900 dark:text-yellow-900 rounded p-2 bg-yellow-100 dark:bg-yellow-100", style: "line-height: 40px;") %>
|
||||
</li>
|
||||
<% end %>
|
||||
<li class="hidden mr-6 sm:block">
|
||||
<%= link("Docs", to: "https://plausible.io/docs", class: "font-bold rounded m-1 p-1 hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100", style: "line-height: 40px;", target: "_blank", rel: "noreferrer") %>
|
||||
</li>
|
||||
<li class="w-full sm:w-auto">
|
||||
<div class="relative font-bold rounded">
|
||||
<div data-dropdown-trigger class="flex items-center justify-end p-1 m-1 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-900 dark:text-gray-100">
|
||||
<span class="pl-2 mr-2 truncate"><%= @conn.assigns[:current_user].name || @conn.assigns[:current_user].email %></span>
|
||||
<svg style="height: 18px; transform: translateY(2px); fill: #606f7b;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 512 640" enable-background="new 0 0 512 512" xml:space="preserve"><g><circle cx="256" cy="52.8" r="50.8"/><circle cx="256" cy="256" r="50.8"/><circle cx="256" cy="459.2" r="50.8"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div data-dropdown style="top: 42px; right: 0px; width: 185px;" class="absolute right-0 hidden bg-white border border-gray-300 rounded shadow-md dropdown-content dark:bg-gray-800 dark:border-gray-500">
|
||||
<%= link("Settings", to: "/settings", class: "block py-2 px-2 border-b border-gray-300 dark:border-gray-500 hover:bg-gray-100 dark:hover:bg-gray-900 dark:text-gray-100") %>
|
||||
<%= link("Log out", to: "/logout", class: "block py-2 px-2 hover:bg-gray-100 dark:hover:bg-gray-900 dark:text-gray-100") %>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<%= if @conn.assigns[:current_user] && !Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<li id="changelog-notification" class="relative py-2"></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false -> %>
|
||||
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
|
||||
<li>
|
||||
<div class="inline-flex">
|
||||
<a href="/login" class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out">Login</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<% true -> %>
|
||||
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
|
||||
<li>
|
||||
<div class="inline-flex">
|
||||
<a href="/login" class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out">Login</a>
|
||||
</div>
|
||||
<div class="inline-flex ml-6 rounded shadow">
|
||||
<a href="/register" class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent leading-6 rounded-md hover:bg-indigo-500 focus:outline-none focus:ring transition duration-150 ease-in-out">Sign up</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
121
lib/plausible_web/templates/layout/_header.html.heex
Normal file
121
lib/plausible_web/templates/layout/_header.html.heex
Normal file
@ -0,0 +1,121 @@
|
||||
<nav class="relative z-20 py-8">
|
||||
<div class="container">
|
||||
<nav class="relative flex items-center justify-between sm:h-10 md:justify-center">
|
||||
<div class="flex items-center flex-1 md:absolute md:inset-y-0 md:left-0">
|
||||
<a href={home_dest(@conn)}>
|
||||
<%= img_tag(
|
||||
PlausibleWeb.Router.Helpers.static_path(
|
||||
@conn,
|
||||
"/images/icon/plausible_logo_dark.png"
|
||||
),
|
||||
class: "h-8 w-auto sm:h-10 -mt-2 hidden dark:inline",
|
||||
alt: "Plausible logo",
|
||||
loading: "lazy"
|
||||
) %>
|
||||
<%= img_tag(
|
||||
PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_logo.png"),
|
||||
class: "h-8 w-auto sm:h-10 -mt-2 inline dark:hidden",
|
||||
alt: "Plausible logo",
|
||||
loading: "lazy"
|
||||
) %>
|
||||
</a>
|
||||
</div>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center justify-end">
|
||||
<%= cond do %>
|
||||
<% @conn.assigns[:current_user] -> %>
|
||||
<ul class="flex items-center w-full sm:w-auto">
|
||||
<%= if Plausible.Billing.on_trial?(@conn.assigns[:current_user]) && !Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<li class="hidden mr-6 sm:block">
|
||||
<%= link(trial_notificaton(@conn.assigns[:current_user]),
|
||||
to: "/settings",
|
||||
class:
|
||||
"text-sm text-yellow-900 dark:text-yellow-900 rounded px-3 py-2 rounded-md bg-yellow-100 dark:bg-yellow-100"
|
||||
) %>
|
||||
</li>
|
||||
<% end %>
|
||||
<li class="w-full sm:w-auto">
|
||||
<.dropdown>
|
||||
<:button class="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<span class="font-medium truncate dark:text-gray-100">
|
||||
<%= @conn.assigns[:current_user].name %>
|
||||
</span>
|
||||
<%= img_tag(Plausible.Auth.User.profile_img_url(@conn.assigns[:current_user]),
|
||||
class: "w-7 rounded-full"
|
||||
) %>
|
||||
</:button>
|
||||
<:panel class="absolute right-0 z-10 mt-2 w-60 origin-top-right divide-y divide-gray-100 dark:divide-gray-400 rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div class="px-3.5 py-3" role="none">
|
||||
<p class="block text-sm text-gray-500 dark:text-gray-400" role="none">
|
||||
Signed in as
|
||||
</p>
|
||||
<p class="truncate font-medium text-gray-900 dark:text-gray-100" role="none">
|
||||
<%= @conn.assigns[:current_user].email %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="py-1.5" role="none">
|
||||
<.dropdown_link href="/settings">Account Settings</.dropdown_link>
|
||||
<.dropdown_link new_tab href="https://plausible.io/docs">
|
||||
Help Center
|
||||
</.dropdown_link>
|
||||
<%= if Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<.dropdown_link new_tab href="https://github.com/plausible/analytics">
|
||||
Github Repo
|
||||
</.dropdown_link>
|
||||
<% else %>
|
||||
<.dropdown_link new_tab href="https://plausible.io/contact">
|
||||
Contact Support
|
||||
</.dropdown_link>
|
||||
<.dropdown_link new_tab href="https://feedback.plausible.io">
|
||||
Feature Requests
|
||||
</.dropdown_link>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="py-1.5" role="none">
|
||||
<.dropdown_link href="/logout">Log Out</.dropdown_link>
|
||||
</div>
|
||||
</:panel>
|
||||
</.dropdown>
|
||||
</li>
|
||||
<%= if @conn.assigns[:current_user] && !Application.get_env(:plausible, :is_selfhost) do %>
|
||||
<li id="changelog-notification" class="relative py-2"></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% Keyword.fetch!(Application.get_env(:plausible, :selfhost), :disable_registration) != false -> %>
|
||||
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
|
||||
<li>
|
||||
<div class="inline-flex">
|
||||
<a
|
||||
href="/login"
|
||||
class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<% true -> %>
|
||||
<ul class="flex" x-show="!document.cookie.includes('logged_in=true')">
|
||||
<li>
|
||||
<div class="inline-flex">
|
||||
<a
|
||||
href="/login"
|
||||
class="font-medium text-gray-500 dark:text-gray-200 hover:text-gray-900 focus:outline-none focus:text-gray-900 transition duration-150 ease-in-out"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
</div>
|
||||
<div class="inline-flex ml-6 rounded shadow">
|
||||
<a
|
||||
href="/register"
|
||||
class="inline-flex items-center justify-center px-5 py-2 text-base font-medium text-white bg-indigo-600 border border-transparent leading-6 rounded-md hover:bg-indigo-500 focus:outline-none focus:ring transition duration-150 ease-in-out"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
@ -12,7 +12,7 @@
|
||||
<li class="py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<%= gravatar(membership.user.email, class: "h-8 w-8 rounded-full") %>
|
||||
<%= img_tag(Plausible.Auth.User.profile_img_url(membership.user), class: "h-8 w-8 rounded-full") %>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-50 truncate">
|
||||
|
@ -40,6 +40,27 @@ defmodule PlausibleWeb.LayoutView do
|
||||
end
|
||||
end
|
||||
|
||||
defmodule JWT do
|
||||
use Joken.Config
|
||||
end
|
||||
|
||||
def feedback_link(user) do
|
||||
token_params = %{
|
||||
"id" => user.id,
|
||||
"email" => user.email,
|
||||
"name" => user.name,
|
||||
"imageUrl" => Plausible.Auth.User.profile_img_url(user)
|
||||
}
|
||||
|
||||
case JWT.generate_and_sign(token_params) do
|
||||
{:ok, token, _claims} ->
|
||||
"https://feedback.plausible.io/sso/#{token}?returnUrl=https://feedback.plausible.io"
|
||||
|
||||
_ ->
|
||||
"https://feedback.plausible.io"
|
||||
end
|
||||
end
|
||||
|
||||
def home_dest(conn) do
|
||||
if conn.assigns[:current_user] do
|
||||
"/sites"
|
||||
|
@ -23,18 +23,6 @@ defmodule PlausibleWeb.SiteView do
|
||||
Plausible.Sites.shared_link_url(site, link)
|
||||
end
|
||||
|
||||
def gravatar(email, opts) do
|
||||
hash =
|
||||
email
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> :erlang.md5()
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
img = "https://www.gravatar.com/avatar/#{hash}?s=150&d=identicon"
|
||||
img_tag(img, opts)
|
||||
end
|
||||
|
||||
def render_snippet(site) do
|
||||
tracker =
|
||||
if site.custom_domain do
|
||||
|
1
mix.exs
1
mix.exs
@ -124,6 +124,7 @@ defmodule Plausible.MixProject do
|
||||
{:heroicons, "~> 0.5.0"},
|
||||
{:zxcvbn, git: "https://github.com/techgaun/zxcvbn-elixir.git"},
|
||||
{:open_api_spex, "~> 3.18"},
|
||||
{:joken, "~> 2.5"},
|
||||
{:paginator, git: "https://github.com/duffelhq/paginator.git"}
|
||||
]
|
||||
end
|
||||
|
2
mix.lock
2
mix.lock
@ -67,6 +67,8 @@
|
||||
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
|
||||
"joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"},
|
||||
"jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"},
|
||||
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
|
||||
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
|
||||
"kaffy": {:hex, :kaffy, "0.9.4", "6a5446cd2c782b8e122061eab409254eb1fa412adb5824169f0528d16775dc45", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "91736c9ddc34a94ed76cb56058fdb6b206c9d777b71856c90ef4554f485f13b9"},
|
||||
|
34
test/plausible_web/controllers/avatar_controller_test.exs
Normal file
34
test/plausible_web/controllers/avatar_controller_test.exs
Normal file
@ -0,0 +1,34 @@
|
||||
defmodule PlausibleWeb.AvatarControllerTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
|
||||
import Mox
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "GET /avatar/:hash" do
|
||||
test "proxies the request to gravatar", %{conn: conn} do
|
||||
expect(
|
||||
Plausible.HTTPClient.Mock,
|
||||
:get,
|
||||
fn "https://www.gravatar.com/avatar/myhash?s=150&d=identicon" ->
|
||||
{:ok,
|
||||
%Finch.Response{
|
||||
status: 200,
|
||||
body: "avatar response body",
|
||||
headers: [
|
||||
{"content-type", "image/png"},
|
||||
{"cache-control", "max-age=300"},
|
||||
{"expires", "soon"}
|
||||
]
|
||||
}}
|
||||
end
|
||||
)
|
||||
|
||||
conn = get(conn, "/avatar/myhash")
|
||||
|
||||
assert response(conn, 200) =~ "avatar response body"
|
||||
assert {"content-type", "image/png"} in conn.resp_headers
|
||||
assert {"cache-control", "max-age=300"} in conn.resp_headers
|
||||
assert {"expires", "soon"} in conn.resp_headers
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user