Function to re-sign photo URLs on profile pages

* With the new JWT signatures on photo URLs, it was no longer possible for
  creative users to embed their gallery photos on their profile page.
* Add a function to ReSignPhotoLinks that finds/replaces (on the server side)
  all references to paths under "/static/photos/" and gives them a fresh
  ?jwt= query string signature.
* Note: only applies to the profile page essays, ReSignPhotoLinks is a
  template func that must be opted-in on a per page basis.

Other miscellaneous fixes

* Add "Edit" buttons in the corners of profile cards, when the current user
  looks at their profile page. They link to URIs like
  "/settings#profile/about_me" which will now:
  1. Select the "Profile settings" tab like #profile
  2. Scroll and focus the profile essay field that the user clicked to edit.
This commit is contained in:
Noah Petherbridge 2024-10-13 19:50:11 -07:00
parent cb37934935
commit b7bee75e1f
5 changed files with 91 additions and 20 deletions

View File

@ -25,6 +25,10 @@ const (
PhotoDiskPath = "./web/static/photos" PhotoDiskPath = "./web/static/photos"
) )
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
// Security // Security
const ( const (
BcryptCost = 14 BcryptCost = 14

View File

@ -1,6 +1,7 @@
package photo package photo
import ( import (
"strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
@ -28,6 +29,30 @@ func VisibleAvatarURL(user, currentUser *models.User) string {
return "/static/img/shy.png" return "/static/img/shy.png"
} }
// ReSignPhotoLinks will search a blob of text for photo gallery links ("/static/photos/*") and re-sign
// their JWT security tokens.
func ReSignPhotoLinks(currentUser *models.User, text string) string {
var matches = config.PhotoURLRegexp.FindAllStringSubmatch(text, -1)
for _, m := range matches {
var (
origString = m[0]
url = m[1]
filename string
)
log.Error("ReSignPhotoLinks: got [%s] url [%s]", origString, url)
// Trim the /static/photos/ prefix off to get the URL down to its base filename.
filename = strings.Split(url, "?")[0]
filename = strings.TrimPrefix(filename, config.PhotoWebPath)
filename = strings.TrimPrefix(filename, "/")
// Sign the URL and replace the original.
signed := SignedPhotoURL(currentUser, filename)
text = strings.ReplaceAll(text, origString, signed)
}
return text
}
// SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only. // SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only.
func SignedPhotoURL(user *models.User, filename string) string { func SignedPhotoURL(user *models.User, filename string) string {
return createSignedPhotoURL(user.ID, user.Username, filename, false) return createSignedPhotoURL(user.ID, user.Username, filename, false)

View File

@ -71,6 +71,14 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
// Get a description for an admin scope (e.g. for transparency page). // Get a description for an admin scope (e.g. for transparency page).
"AdminScopeDescription": config.AdminScopeDescription, "AdminScopeDescription": config.AdminScopeDescription,
// "ReSignPhotoLinks": photo.ReSignPhotoLinks,
"ReSignPhotoLinks": func(s template.HTML) template.HTML {
if currentUser, err := session.CurrentUser(r); err == nil {
return template.HTML(photo.ReSignPhotoLinks(currentUser, string(s)))
}
return s
},
} }
} }

View File

@ -314,42 +314,75 @@
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<div class="card block"> <div class="card block">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <div class="card-header-title">
About Me <div class="columns is-mobile is-gapless nonshy-fullwidth">
</p> <div class="column">
About Me
</div>
{{if eq .CurrentUser.ID .User.ID}}
<div class="column is-narrow">
<a href="/settings#profile/about_me" class="button is-outlined is-small">
<i class="fa fa-pencil"></i>
</a>
</div>
{{end}}
</div>
</div>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
{{or (ToMarkdown (.User.GetProfileField "about_me")) "n/a"}} {{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "about_me"))) "n/a"}}
</div> </div>
</div> </div>
</div> </div>
<div class="card block"> <div class="card block">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <div class="card-header-title">
My Interests <div class="columns is-mobile is-gapless nonshy-fullwidth">
</p> <div class="column">
My Interests
</div>
{{if eq .CurrentUser.ID .User.ID}}
<div class="column is-narrow">
<a href="/settings#profile/interests" class="button is-outlined is-small">
<i class="fa fa-pencil"></i>
</a>
</div>
{{end}}
</div>
</div>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
{{or (ToMarkdown (.User.GetProfileField "interests")) "n/a"}} {{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "interests"))) "n/a"}}
</div> </div>
</div> </div>
</div> </div>
<div class="card block"> <div class="card block">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <div class="card-header-title">
Music/Bands/Movies <div class="columns is-mobile is-gapless nonshy-fullwidth">
</p> <div class="column">
Music/Bands/Movies
</div>
{{if eq .CurrentUser.ID .User.ID}}
<div class="column is-narrow">
<a href="/settings#profile/music_movies" class="button is-outlined is-small">
<i class="fa fa-pencil"></i>
</a>
</div>
{{end}}
</div>
</div>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="content"> <div class="content">
{{or (ToMarkdown (.User.GetProfileField "music_movies")) "n/a"}} {{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "music_movies"))) "n/a"}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -307,7 +307,7 @@
</div> </div>
</div> </div>
<div class="field"> <div class="field" id="profile/about_me">
<label class="label" for="about_me">About Me</label> <label class="label" for="about_me">About Me</label>
<textarea class="textarea" cols="60" rows="4" <textarea class="textarea" cols="60" rows="4"
id="about_me" id="about_me"
@ -318,7 +318,7 @@
</p> </p>
</div> </div>
<div class="field"> <div class="field" id="profile/interests">
<label class="label" for="interests">My Interests</label> <label class="label" for="interests">My Interests</label>
<textarea class="textarea" cols="60" rows="4" <textarea class="textarea" cols="60" rows="4"
id="interests" id="interests"
@ -326,7 +326,7 @@
placeholder="What kinds of things make you curious?">{{$User.GetProfileField "interests"}}</textarea> placeholder="What kinds of things make you curious?">{{$User.GetProfileField "interests"}}</textarea>
</div> </div>
<div class="field"> <div class="field" id="profile/music_movies">
<label class="label" for="music_movies">Music/Bands/Movies</label> <label class="label" for="music_movies">Music/Bands/Movies</label>
<textarea class="textarea" cols="60" rows="4" <textarea class="textarea" cols="60" rows="4"
id="music_movies" id="music_movies"
@ -1421,9 +1421,10 @@ window.addEventListener("DOMContentLoaded", (event) => {
// Global function to toggle the active tab. // Global function to toggle the active tab.
const showTab = (name) => { const showTab = (name) => {
name = name.replace(/\.$/, ''); name = name.replace(/\.$/, '');
if (!name) name = "profile"; let tabName = name.split('/')[0]; // "#profile/about_me"
if (!tabName) name = "profile";
$activeTab.style.display = 'none'; $activeTab.style.display = 'none';
switch (name) { switch (tabName) {
case "look": case "look":
$activeTab = $look; $activeTab = $look;
break; break;
@ -1486,9 +1487,9 @@ window.addEventListener("DOMContentLoaded", (event) => {
// Show the requested tab on first page load. // Show the requested tab on first page load.
showTab(window.location.hash.replace(/^#/, '')); showTab(window.location.hash.replace(/^#/, ''));
window.requestAnimationFrame(() => { // window.requestAnimationFrame(() => {
window.scrollTo(0, 0); // window.scrollTo(0, 0);
}); // });
}); });
// Look & Feel tab scripts. // Look & Feel tab scripts.