Fix blank page: URL types and inline style issues in templates
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Build and Deploy / build-and-deploy (push) Successful in 1m3s
- Use template.URL for all URLs passed to templates (RSSURL, ImageURL) - Pre-compute episode image URLs in handler as safe types - Remove all inline style attributes, use CSS classes instead - Add template error logging to catch silent failures - HTML/template rejects raw strings in URL and CSS+URL contexts
This commit is contained in:
+34
-6
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -40,20 +41,47 @@ func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request)
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Podstalk"
|
title = "Podstalk"
|
||||||
}
|
}
|
||||||
|
type epView struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
AudioPath string
|
||||||
|
ImageURL template.URL
|
||||||
|
PublishedAt time.Time
|
||||||
|
}
|
||||||
|
var epViews []epView
|
||||||
|
for _, ep := range episodes {
|
||||||
|
ev := epView{
|
||||||
|
ID: ep.ID,
|
||||||
|
Title: ep.Title,
|
||||||
|
Description: ep.Description,
|
||||||
|
AudioPath: ep.AudioPath,
|
||||||
|
PublishedAt: ep.PublishedAt,
|
||||||
|
}
|
||||||
|
if ep.ImagePath != "" {
|
||||||
|
ev.ImageURL = template.URL("/uploads/" + ep.ImagePath)
|
||||||
|
} else if user.PodcastImage != "" {
|
||||||
|
ev.ImageURL = template.URL("/uploads/" + user.PodcastImage)
|
||||||
|
}
|
||||||
|
epViews = append(epViews, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
podcastImageURL := template.URL("")
|
||||||
|
if user.PodcastImage != "" {
|
||||||
|
podcastImageURL = template.URL("/uploads/" + user.PodcastImage)
|
||||||
|
}
|
||||||
|
|
||||||
h.Tpl.Render(w, "dashboard", map[string]any{
|
h.Tpl.Render(w, "dashboard", map[string]any{
|
||||||
"Episodes": episodes,
|
"Episodes": epViews,
|
||||||
"PodcastTitle": title,
|
"PodcastTitle": title,
|
||||||
"PodcastSlug": user.PodcastSlug,
|
"PodcastSlug": user.PodcastSlug,
|
||||||
"PodcastImage": user.PodcastImage,
|
"PodcastImageURL": podcastImageURL,
|
||||||
"PodcastAuthor": user.PodcastAuthor,
|
"PodcastAuthor": user.PodcastAuthor,
|
||||||
"BaseURL": h.BaseURL,
|
"RSSURL": template.URL(h.BaseURL + "/rss/" + user.PodcastSlug),
|
||||||
"UserID": user.ID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := UserIDFromContext(r)
|
userID := UserIDFromContext(r)
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
h.Tpl.Render(w, "episode_form", map[string]any{"Editing": false})
|
h.Tpl.Render(w, "episode_form", map[string]any{"Editing": false})
|
||||||
return
|
return
|
||||||
|
|||||||
+4
-2
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Templates struct {
|
type Templates struct {
|
||||||
@@ -12,7 +13,8 @@ type Templates struct {
|
|||||||
func NewTemplates(tmpl *template.Template) *Templates {
|
func NewTemplates(tmpl *template.Template) *Templates {
|
||||||
return &Templates{tmpl: tmpl}
|
return &Templates{tmpl: tmpl}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Templates) Render(w io.Writer, name string, data any) {
|
func (t *Templates) Render(w io.Writer, name string, data any) {
|
||||||
_ = t.tmpl.ExecuteTemplate(w, name+".html", data)
|
if err := t.tmpl.ExecuteTemplate(w, name+".html", data); err != nil {
|
||||||
|
log.Printf("template error: %s: %v", name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
+25
-19
@@ -11,15 +11,22 @@
|
|||||||
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
||||||
.flash-error { background: #ffcdd2; }
|
.flash-error { background: #ffcdd2; }
|
||||||
.flash-success { background: #c8e6c9; }
|
.flash-success { background: #c8e6c9; }
|
||||||
.flash-info { background: #e3f2fd; }
|
.hidden { display: none; }
|
||||||
.episode-actions { display: flex; gap: 0.5rem; }
|
.episode-actions { display: flex; gap: 0.5rem; }
|
||||||
.settings-section { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid #ddd; }
|
.settings-section { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid #ddd; }
|
||||||
.settings-form { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; }
|
.settings-form { display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; }
|
||||||
.settings-form .form-group { margin-bottom: 0; }
|
.settings-form .form-group { margin-bottom: 0; }
|
||||||
.hidden { display: none; }
|
|
||||||
.file-upload { position: relative; display: inline-block; }
|
.file-upload { position: relative; display: inline-block; }
|
||||||
.file-upload input[type="file"] { position: absolute; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
|
.file-upload input[type="file"] { position: absolute; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
|
||||||
.file-name { margin-left: 0.5rem; font-size: 0.85rem; color: #666; }
|
.file-name { margin-left: 0.5rem; font-size: 0.85rem; color: #666; }
|
||||||
|
.form-flex-1 { flex: 1; min-width: 200px; }
|
||||||
|
.form-flex-slug { flex: 1; min-width: 160px; }
|
||||||
|
.podcast-img-preview { margin-top: 1rem; }
|
||||||
|
.podcast-img-preview img { max-width: 150px; max-height: 150px; border: 2px solid #000; }
|
||||||
|
.rss-card { margin-bottom: 1.5rem; }
|
||||||
|
.new-ep-row { margin-bottom: 1.5rem; }
|
||||||
|
.ep-thumb { max-width: 48px; max-height: 48px; border: 1px solid #000; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
@@ -41,20 +48,20 @@
|
|||||||
<h2>Podcast Settings</h2>
|
<h2>Podcast Settings</h2>
|
||||||
<form method="post" action="/settings" enctype="multipart/form-data">
|
<form method="post" action="/settings" enctype="multipart/form-data">
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
<div class="form-group" style="flex: 1; min-width: 200px;">
|
<div class="form-group form-flex-1">
|
||||||
<label for="podcast_title">Podcast Title</label>
|
<label for="podcast_title">Podcast Title</label>
|
||||||
<input type="text" id="podcast_title" name="podcast_title" class="nb-input blue"
|
<input type="text" id="podcast_title" name="podcast_title" class="nb-input blue"
|
||||||
value="{{.PodcastTitle}}" />
|
value="{{.PodcastTitle}}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex: 1; min-width: 160px;">
|
<div class="form-group form-flex-slug">
|
||||||
<label for="podcast_slug">URL Slug</label>
|
<label for="podcast_slug">URL Slug</label>
|
||||||
<input type="text" id="podcast_slug" name="podcast_slug" class="nb-input blue"
|
<input type="text" id="podcast_slug" name="podcast_slug" class="nb-input blue"
|
||||||
value="{{.PodcastSlug}}" placeholder="my-podcast" />
|
value="{{.PodcastSlug}}" placeholder="my-podcast" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="flex: 1; min-width: 200px;">
|
<div class="form-group form-flex-1">
|
||||||
<label for="podcast_author">Author</label>
|
<label for="podcast_author">Author</label>
|
||||||
<input type="text" id="podcast_author" name="podcast_author" class="nb-input blue"
|
<input type="text" id="podcast_author" name="podcast_author" class="nb-input blue"
|
||||||
value="{{.PodcastAuthor}}" />
|
value="{{.PodcastAuthor}}" placeholder="Podcast Host" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="podcast_image">Podcast Image</label>
|
<label for="podcast_image">Podcast Image</label>
|
||||||
@@ -69,22 +76,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{if .PodcastImage}}
|
{{if .PodcastImageURL}}
|
||||||
<div style="margin-top: 1rem;">
|
<div class="podcast-img-preview">
|
||||||
<img src="/uploads/{{.PodcastImage}}" alt="Podcast image" style="max-width: 150px; max-height: 150px; border: 2px solid #000;" />
|
<img src="{{.PodcastImageURL}}" alt="Podcast image" />
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nb-card" style="margin-bottom: 1.5rem;">
|
<div class="nb-card rss-card">
|
||||||
<div class="nb-card-content">
|
<div class="nb-card-content">
|
||||||
<h4 class="nb-card-title">RSS Feed</h4>
|
<h4 class="nb-card-title">RSS Feed</h4>
|
||||||
<p class="nb-card-text">
|
<p class="nb-card-text">
|
||||||
<code id="rss-url"></code>
|
<code id="rss-url"></code>
|
||||||
<button class="nb-button default" onclick="copyRSS()" style="margin-left: 0.5rem;">Copy</button>
|
<span id="rss-url-src" hidden>{{.RSSURL}}</span>
|
||||||
|
<button class="nb-button default" onclick="copyRSS()">Copy</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function updateFileName(input) {
|
function updateFileName(input) {
|
||||||
const span = document.getElementById(input.id + '_name');
|
const span = document.getElementById(input.id + '_name');
|
||||||
@@ -92,7 +101,7 @@
|
|||||||
span.textContent = input.files[0].name;
|
span.textContent = input.files[0].name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flash messages from query params
|
|
||||||
(function() {
|
(function() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const err = params.get('error');
|
const err = params.get('error');
|
||||||
@@ -107,13 +116,12 @@
|
|||||||
el.textContent = ok.replace(/\+/g, ' ').replace(/%20/g, ' ');
|
el.textContent = ok.replace(/\+/g, ' ').replace(/%20/g, ' ');
|
||||||
el.classList.remove('hidden');
|
el.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
// Clean URL
|
|
||||||
if (err || ok) {
|
if (err || ok) {
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const rssURL = '{{.BaseURL}}/rss/{{.PodcastSlug}}';
|
const rssURL = document.getElementById('rss-url-src').textContent;
|
||||||
document.getElementById('rss-url').textContent = rssURL;
|
document.getElementById('rss-url').textContent = rssURL;
|
||||||
function copyRSS() {
|
function copyRSS() {
|
||||||
navigator.clipboard.writeText(rssURL).then(() => {
|
navigator.clipboard.writeText(rssURL).then(() => {
|
||||||
@@ -126,7 +134,7 @@
|
|||||||
|
|
||||||
<h2>Episodes</h2>
|
<h2>Episodes</h2>
|
||||||
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div class="new-ep-row">
|
||||||
<a href="/episodes/new" class="nb-button blue">+ New Episode</a>
|
<a href="/episodes/new" class="nb-button blue">+ New Episode</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,16 +156,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{.Title}}</td>
|
<td>{{.Title}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{$img := .ImagePath}}
|
{{if .ImageURL}}<img src="{{.ImageURL}}" class="ep-thumb" />{{else}}—{{end}}
|
||||||
{{if not $img}}{{$img = $.PodcastImage}}{{end}}
|
|
||||||
{{if $img}}<img src="/uploads/{{$img}}" style="max-width: 48px; max-height: 48px; border: 1px solid #000;" />{{else}}—{{end}}
|
|
||||||
</td>
|
</td>
|
||||||
<td>{{.PublishedAt.Format "Jan 2, 2006"}}</td>
|
<td>{{.PublishedAt.Format "Jan 2, 2006"}}</td>
|
||||||
<td>{{if .AudioPath}}✓{{else}}—{{end}}</td>
|
<td>{{if .AudioPath}}✓{{else}}—{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="episode-actions">
|
<div class="episode-actions">
|
||||||
<a href="/episodes/edit?id={{.ID}}" class="nb-button default">Edit</a>
|
<a href="/episodes/edit?id={{.ID}}" class="nb-button default">Edit</a>
|
||||||
<form method="post" action="/episodes/delete?id={{.ID}}" style="display:inline" onsubmit="return confirm('Delete this episode?')">
|
<form method="post" action="/episodes/delete?id={{.ID}}" onsubmit="return confirm('Delete this episode?')">
|
||||||
<button type="submit" class="nb-button orange">Delete</button>
|
<button type="submit" class="nb-button orange">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user