This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"podstalk/store"
|
||||
)
|
||||
|
||||
// Simple in-memory session store.
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]int64 // token -> userID
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{sessions: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func (ss *SessionStore) Create(userID int64) string {
|
||||
token := make([]byte, 32)
|
||||
rand.Read(token)
|
||||
key := hex.EncodeToString(token)
|
||||
ss.mu.Lock()
|
||||
ss.sessions[key] = userID
|
||||
ss.mu.Unlock()
|
||||
return key
|
||||
}
|
||||
|
||||
func (ss *SessionStore) Get(token string) (int64, bool) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
id, ok := ss.sessions[token]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (ss *SessionStore) Delete(token string) {
|
||||
ss.mu.Lock()
|
||||
delete(ss.sessions, token)
|
||||
ss.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
type AuthHandler struct {
|
||||
Store *store.Store
|
||||
Sessions *SessionStore
|
||||
Tpl *Templates
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
h.Tpl.Render(w, "signup", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
h.Tpl.Render(w, "signup", map[string]string{"Error": "Email and password are required."})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
h.Tpl.Render(w, "signup", map[string]string{"Error": "Internal error."})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.Store.CreateUser(email, string(hash))
|
||||
if err != nil {
|
||||
h.Tpl.Render(w, "signup", map[string]string{"Error": "A user with that email already exists."})
|
||||
return
|
||||
}
|
||||
|
||||
token := h.Sessions.Create(userID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
h.Tpl.Render(w, "signin", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := h.Store.UserByEmail(email)
|
||||
if err != nil {
|
||||
h.Tpl.Render(w, "signin", map[string]string{"Error": "Invalid email or password."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
h.Tpl.Render(w, "signin", map[string]string{"Error": "Invalid email or password."})
|
||||
return
|
||||
}
|
||||
|
||||
token := h.Sessions.Create(user.ID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ServeSignout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
h.Sessions.Delete(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// AuthMiddleware redirects to /signin (or /signup if no users) when not authenticated.
|
||||
type AuthMiddleware struct {
|
||||
Store *store.Store
|
||||
Sessions *SessionStore
|
||||
}
|
||||
|
||||
func (am *AuthMiddleware) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, ok := am.Sessions.Get(cookie.Value); !ok {
|
||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectIfAuthed sends authenticated users to / instead of showing auth pages.
|
||||
func (am *AuthMiddleware) RedirectIfAuthed(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
if _, ok := am.Sessions.Get(cookie.Value); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"podstalk/store"
|
||||
)
|
||||
|
||||
const maxUploadSize = 200 << 20 // 200 MB
|
||||
|
||||
type EpisodeHandler struct {
|
||||
Store *store.Store
|
||||
Tpl *Templates
|
||||
UploadDir string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func (h *EpisodeHandler) ServeDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
episodes, err := h.Store.ListEpisodes()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
title, _ := h.Store.GetSetting("podcast_title")
|
||||
if title == "" {
|
||||
title = "Podstalk"
|
||||
}
|
||||
|
||||
podcastImage, _ := h.Store.GetSetting("podcast_image")
|
||||
|
||||
h.Tpl.Render(w, "dashboard", map[string]any{
|
||||
"Episodes": episodes,
|
||||
"PodcastTitle": title,
|
||||
"PodcastImage": podcastImage,
|
||||
"BaseURL": h.BaseURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *EpisodeHandler) ServeNew(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{"Editing": false})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||
"Editing": false,
|
||||
"Error": "File too large (max 200 MB).",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
title := r.FormValue("title")
|
||||
description := r.FormValue("description")
|
||||
publishedStr := r.FormValue("published_at")
|
||||
|
||||
publishedAt := time.Now()
|
||||
if publishedStr != "" {
|
||||
if t, err := time.Parse("2006-01-02T15:04", publishedStr); err == nil {
|
||||
publishedAt = t
|
||||
}
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||
"Editing": false,
|
||||
"Error": "Title is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
audioPath, err := h.saveUploadedFile(r, "audio_file", "audio")
|
||||
if err != nil {
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||
"Editing": false,
|
||||
"Error": fmt.Sprintf("Audio upload failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
imagePath, _ := h.saveUploadedFile(r, "image_file", "images")
|
||||
|
||||
_, err = h.Store.CreateEpisode(title, description, audioPath, imagePath, publishedAt)
|
||||
if err != nil {
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||
"Editing": false,
|
||||
"Error": "Failed to create episode.",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EpisodeHandler) ServeEdit(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
ep, err := h.Store.GetEpisode(id)
|
||||
if err != nil {
|
||||
http.Error(w, "episode not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||
"Editing": true,
|
||||
"Episode": ep,
|
||||
"PublishedAt": ep.PublishedAt.Format("2006-01-02T15:04"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
title := r.FormValue("title")
|
||||
description := r.FormValue("description")
|
||||
publishedStr := r.FormValue("published_at")
|
||||
|
||||
publishedAt := time.Now()
|
||||
if publishedStr != "" {
|
||||
if t, err := time.Parse("2006-01-02T15:04", publishedStr); err == nil {
|
||||
publishedAt = t
|
||||
}
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
ep, _ := h.Store.GetEpisode(id)
|
||||
h.Tpl.Render(w, "episode_form", map[string]any{
|
||||
"Editing": true,
|
||||
"Episode": ep,
|
||||
"PublishedAt": publishedStr,
|
||||
"Error": "Title is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Load existing episode to know which files to replace
|
||||
existing, err := h.Store.GetEpisode(id)
|
||||
if err != nil {
|
||||
http.Error(w, "episode not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
audioPath := existing.AudioPath
|
||||
if newPath, err := h.saveUploadedFile(r, "audio_file", "audio"); err == nil && newPath != "" {
|
||||
h.removeUploadedFile(audioPath)
|
||||
audioPath = newPath
|
||||
}
|
||||
|
||||
imagePath := existing.ImagePath
|
||||
if newPath, err := h.saveUploadedFile(r, "image_file", "images"); err == nil && newPath != "" {
|
||||
h.removeUploadedFile(imagePath)
|
||||
imagePath = newPath
|
||||
}
|
||||
|
||||
if err := h.Store.UpdateEpisode(id, title, description, audioPath, imagePath, publishedAt); err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EpisodeHandler) ServeDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete associated files
|
||||
ep, err := h.Store.GetEpisode(id)
|
||||
if err == nil {
|
||||
h.removeUploadedFile(ep.AudioPath)
|
||||
h.removeUploadedFile(ep.ImagePath)
|
||||
}
|
||||
|
||||
if err := h.Store.DeleteEpisode(id); err != nil {
|
||||
http.Error(w, "delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ServeUpload serves files from the upload directory.
|
||||
func (h *EpisodeHandler) ServeUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// Strip /uploads/ prefix to get the relative path
|
||||
relPath := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
||||
if relPath == "" || strings.Contains(relPath, "..") {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(h.UploadDir, relPath))
|
||||
}
|
||||
|
||||
// saveUploadedFile saves a file from a multipart form field to the upload directory.
|
||||
// Returns the relative path (category/filename) or empty string if no file uploaded.
|
||||
func (h *EpisodeHandler) saveUploadedFile(r *http.Request, field, category string) (string, error) {
|
||||
file, header, err := r.FormFile(field)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
base := sanitizeFilename(header.Filename)
|
||||
ext := filepath.Ext(base)
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
} else {
|
||||
base = base[:len(base)-len(ext)]
|
||||
}
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), base, ext)
|
||||
|
||||
catDir := filepath.Join(h.UploadDir, category)
|
||||
if err := os.MkdirAll(catDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dst, err := os.Create(filepath.Join(catDir, filename))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return category + "/" + filename, nil
|
||||
}
|
||||
|
||||
func (h *EpisodeHandler) removeUploadedFile(relPath string) {
|
||||
if relPath == "" {
|
||||
return
|
||||
}
|
||||
os.Remove(filepath.Join(h.UploadDir, relPath))
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
// Keep only the base name, lowercased, alphanumeric + dots + dashes
|
||||
name = filepath.Base(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '-' {
|
||||
return r
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + 32
|
||||
}
|
||||
return -1
|
||||
}, name)
|
||||
return name
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"podstalk/store"
|
||||
)
|
||||
|
||||
type RSSHandler struct {
|
||||
Store *store.Store
|
||||
BaseTitle string
|
||||
Link string
|
||||
Author string
|
||||
}
|
||||
|
||||
func (h *RSSHandler) resolveTitle() string {
|
||||
title, err := h.Store.GetSetting("podcast_title")
|
||||
if err == nil && title != "" {
|
||||
return title
|
||||
}
|
||||
if h.BaseTitle != "" {
|
||||
return h.BaseTitle
|
||||
}
|
||||
return "Podstalk"
|
||||
}
|
||||
|
||||
func (h *RSSHandler) resolveImage() string {
|
||||
img, err := h.Store.GetSetting("podcast_image")
|
||||
if err == nil && img != "" {
|
||||
return img
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
ItunesNS string `xml:"xmlns:itunes,attr"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
Image *rssImage `xml:"image,omitempty"`
|
||||
ItunesImage *rssItunesImage `xml:"itunes:image,omitempty"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssImage struct {
|
||||
URL string `xml:"url"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
}
|
||||
|
||||
type rssItunesImage struct {
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Description string `xml:"description"`
|
||||
Enclosure rssEnclosure `xml:"enclosure"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
GUID string `xml:"guid"`
|
||||
ItunesImage *rssItunesImage `xml:"itunes:image,omitempty"`
|
||||
}
|
||||
|
||||
type rssEnclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length string `xml:"length,attr,omitempty"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
func (h *RSSHandler) ServeRSS(w http.ResponseWriter, r *http.Request) {
|
||||
episodes, err := h.Store.ListEpisodes()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load episodes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
podcastTitle := h.resolveTitle()
|
||||
podcastImage := h.resolveImage()
|
||||
|
||||
items := make([]rssItem, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
var itemImage *rssItunesImage
|
||||
epImage := ep.ImagePath
|
||||
if epImage == "" {
|
||||
epImage = podcastImage
|
||||
}
|
||||
if epImage != "" {
|
||||
itemImage = &rssItunesImage{Href: h.fullURL(epImage)}
|
||||
}
|
||||
|
||||
items = append(items, rssItem{
|
||||
Title: ep.Title,
|
||||
Description: ep.Description,
|
||||
Enclosure: rssEnclosure{
|
||||
URL: h.fullURL(ep.AudioPath),
|
||||
Type: "audio/mpeg",
|
||||
},
|
||||
PubDate: ep.PublishedAt.Format(time.RFC1123Z),
|
||||
GUID: h.fullURL(ep.AudioPath),
|
||||
ItunesImage: itemImage,
|
||||
})
|
||||
}
|
||||
|
||||
var chanImage *rssImage
|
||||
var chanItunesImage *rssItunesImage
|
||||
if podcastImage != "" {
|
||||
chanImage = &rssImage{
|
||||
URL: h.fullURL(podcastImage),
|
||||
Title: podcastTitle,
|
||||
Link: h.Link,
|
||||
}
|
||||
chanItunesImage = &rssItunesImage{Href: h.fullURL(podcastImage)}
|
||||
}
|
||||
|
||||
feed := rssFeed{
|
||||
Version: "2.0",
|
||||
ItunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||
Channel: rssChannel{
|
||||
Title: podcastTitle,
|
||||
Link: h.Link,
|
||||
Description: podcastTitle,
|
||||
Language: "en-us",
|
||||
Image: chanImage,
|
||||
ItunesImage: chanItunesImage,
|
||||
Items: items,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Write([]byte(xml.Header))
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent("", " ")
|
||||
enc.Encode(feed)
|
||||
}
|
||||
|
||||
func (h *RSSHandler) fullURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if h.Link == "" {
|
||||
return path
|
||||
}
|
||||
return h.Link + "/uploads/" + path
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"podstalk/store"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
Store *store.Store
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) ServeSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Redirect(w, r, "/?error=file+too+large", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
title := r.FormValue("podcast_title")
|
||||
if title != "" {
|
||||
h.Store.SetSetting("podcast_title", title)
|
||||
}
|
||||
|
||||
// Handle podcast image upload
|
||||
file, header, err := r.FormFile("podcast_image")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
|
||||
catDir := filepath.Join(h.UploadDir, "images")
|
||||
os.MkdirAll(catDir, 0755)
|
||||
|
||||
ext := filepath.Ext(header.Filename)
|
||||
dst, err := os.Create(filepath.Join(catDir, "podcast"+ext))
|
||||
if err == nil {
|
||||
defer dst.Close()
|
||||
dst.ReadFrom(file)
|
||||
h.Store.SetSetting("podcast_image", "images/podcast"+ext)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Templates struct {
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
func NewTemplates(tmpl *template.Template) *Templates {
|
||||
return &Templates{tmpl: tmpl}
|
||||
}
|
||||
|
||||
func (t *Templates) Render(w io.Writer, name string, data any) {
|
||||
_ = t.tmpl.ExecuteTemplate(w, name+".html", data)
|
||||
}
|
||||
Reference in New Issue
Block a user