This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
*.db
|
||||||
|
podstalk
|
||||||
|
.git/
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "Unformatted files:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: CGO_ENABLED=0 go build -o podstalk .
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
env:
|
||||||
|
CONTAINER_REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$CONTAINER_REGISTRY_TOKEN" ]; then
|
||||||
|
echo "CONTAINER_REGISTRY_TOKEN not set, skipping Docker publish"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "$CONTAINER_REGISTRY_TOKEN" | docker login git.roo.lol -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
env:
|
||||||
|
CONTAINER_REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$CONTAINER_REGISTRY_TOKEN" ]; then
|
||||||
|
echo "CONTAINER_REGISTRY_TOKEN not set, skipping Docker publish"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
docker build \
|
||||||
|
-t git.roo.lol/${{ github.repository }}:latest \
|
||||||
|
-t git.roo.lol/${{ github.repository }}:${{ github.sha }} \
|
||||||
|
.
|
||||||
|
docker push git.roo.lol/${{ github.repository }}:latest
|
||||||
|
docker push git.roo.lol/${{ github.repository }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Deploy to production
|
||||||
|
env:
|
||||||
|
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||||
|
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
||||||
|
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
||||||
|
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DEPLOY_KEY" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_DIRECTORY" ]; then
|
||||||
|
[ -z "$DEPLOY_KEY" ] && echo "MISSING SECRET: DEPLOY_KEY"
|
||||||
|
[ -z "$DEPLOY_USER" ] && echo "MISSING VAR: DEPLOY_USER"
|
||||||
|
[ -z "$DEPLOY_HOST" ] && echo "MISSING VAR: DEPLOY_HOST"
|
||||||
|
[ -z "$DEPLOY_DIRECTORY" ] && echo "MISSING VAR: DEPLOY_DIRECTORY"
|
||||||
|
echo "Deploy secrets not fully set, skipping deployment"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"cd $DEPLOY_DIRECTORY && docker compose pull && docker compose down && docker compose up -d"
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o podstalk .
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=build /app/podstalk /usr/local/bin/podstalk
|
||||||
|
|
||||||
|
RUN mkdir -p /data /uploads
|
||||||
|
VOLUME /data /uploads
|
||||||
|
|
||||||
|
ENV PODSTALK_DB=/data/podstalk.db
|
||||||
|
ENV PODSTALK_UPLOAD_DIR=/uploads
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/podstalk"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Podstalk
|
||||||
|
|
||||||
|
Podstalk is a bare-bones podcast hosting platform. It hosts a single podcast —
|
||||||
|
allowing a user to create and manage episodes and serve an RSS feed for
|
||||||
|
subscribers.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Build the image
|
||||||
|
docker build -t podstalk .
|
||||||
|
|
||||||
|
# Run with persistent storage and your podcast's public URL
|
||||||
|
docker run -d \
|
||||||
|
--name podstalk \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v podstalk-data:/data \
|
||||||
|
-v podstalk-uploads:/uploads \
|
||||||
|
-e PODSTALK_LINK=https://podcast.example.com \
|
||||||
|
-e PODSTALK_TITLE="My Podcast" \
|
||||||
|
podstalk
|
||||||
|
```
|
||||||
|
|
||||||
|
On first launch, visit `http://localhost:8080/signup` to create your account.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `8080` | HTTP listen port |
|
||||||
|
| `PODSTALK_DB` | `podstalk.db` | SQLite database path |
|
||||||
|
| `PODSTALK_UPLOAD_DIR` | `uploads` | Audio/image file storage |
|
||||||
|
| `PODSTALK_LINK` | `http://localhost:8080` | Public URL (used in RSS feed) |
|
||||||
|
| `PODSTALK_TITLE` | `Podstalk` | Fallback podcast title |
|
||||||
|
| `PODSTALK_AUTHOR` | `Podstalk` | RSS author field |
|
||||||
|
|
||||||
|
The podcast title and image can also be set from the dashboard after signing in.
|
||||||
|
|
||||||
|
## RSS Feed
|
||||||
|
|
||||||
|
The RSS 2.0 feed (with iTunes extensions) is available at `/rss`. It includes
|
||||||
|
`<enclosure>`, `<itunes:image>`, `<guid>`, and `<pubDate>` for each episode.
|
||||||
|
|
||||||
|
## Running Without Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run .
|
||||||
|
# or
|
||||||
|
CGO_ENABLED=0 go build -o podstalk . && ./podstalk
|
||||||
|
```
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module podstalk
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
modernc.org/sqlite v1.53.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
+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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"podstalk/handler"
|
||||||
|
"podstalk/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed template/*.html
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := os.Getenv("PODSTALK_DB")
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "podstalk.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDir := os.Getenv("PODSTALK_UPLOAD_DIR")
|
||||||
|
if uploadDir == "" {
|
||||||
|
uploadDir = "uploads"
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := os.Getenv("PODSTALK_LINK")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
tmpl, err := template.New("").ParseFS(templateFS, "template/*.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to parse templates: %v", err)
|
||||||
|
}
|
||||||
|
tpl := handler.NewTemplates(tmpl)
|
||||||
|
|
||||||
|
sessions := handler.NewSessionStore()
|
||||||
|
|
||||||
|
authMid := &handler.AuthMiddleware{Store: st, Sessions: sessions}
|
||||||
|
|
||||||
|
authH := &handler.AuthHandler{Store: st, Sessions: sessions, Tpl: tpl}
|
||||||
|
epH := &handler.EpisodeHandler{
|
||||||
|
Store: st,
|
||||||
|
Tpl: tpl,
|
||||||
|
UploadDir: uploadDir,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
}
|
||||||
|
setH := &handler.SettingsHandler{
|
||||||
|
Store: st,
|
||||||
|
UploadDir: uploadDir,
|
||||||
|
}
|
||||||
|
rssH := &handler.RSSHandler{
|
||||||
|
Store: st,
|
||||||
|
BaseTitle: envOrDefault("PODSTALK_TITLE", "Podstalk"),
|
||||||
|
Link: baseURL,
|
||||||
|
Author: envOrDefault("PODSTALK_AUTHOR", "Podstalk"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth routes — redirect to dashboard if already signed in.
|
||||||
|
http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup))
|
||||||
|
http.HandleFunc("/signin", authMid.RedirectIfAuthed(authH.ServeSignin))
|
||||||
|
http.HandleFunc("/signout", authH.ServeSignout)
|
||||||
|
|
||||||
|
// Protected routes.
|
||||||
|
http.HandleFunc("/", authMid.RequireAuth(epH.ServeDashboard))
|
||||||
|
http.HandleFunc("/episodes/new", authMid.RequireAuth(epH.ServeNew))
|
||||||
|
http.HandleFunc("/episodes/edit", authMid.RequireAuth(epH.ServeEdit))
|
||||||
|
http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete))
|
||||||
|
http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings))
|
||||||
|
|
||||||
|
// Public RSS feed.
|
||||||
|
http.HandleFunc("/rss", rssH.ServeRSS)
|
||||||
|
|
||||||
|
// Public uploaded files.
|
||||||
|
http.HandleFunc("/uploads/", epH.ServeUpload)
|
||||||
|
|
||||||
|
port := envOrDefault("PORT", "8080")
|
||||||
|
addr := fmt.Sprintf(":%s", port)
|
||||||
|
log.Printf("Podstalk listening on http://0.0.0.0%s", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Episode struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
AudioPath string
|
||||||
|
ImagePath string
|
||||||
|
PublishedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err := migrate(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(db *sql.DB) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
audio_path TEXT NOT NULL DEFAULT '',
|
||||||
|
image_path TEXT NOT NULL DEFAULT '',
|
||||||
|
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err := db.Exec(schema)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
func (s *Store) UserCount() (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateUser(email, passwordHash string) (int64, error) {
|
||||||
|
res, err := s.db.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserByEmail(email string) (*User, error) {
|
||||||
|
u := &User{}
|
||||||
|
err := s.db.QueryRow("SELECT id, email, password_hash, created_at FROM users WHERE email = ?", email).
|
||||||
|
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Episodes ---
|
||||||
|
func (s *Store) ListEpisodes() ([]Episode, error) {
|
||||||
|
rows, err := s.db.Query("SELECT id, title, description, audio_path, image_path, published_at, created_at FROM episodes ORDER BY published_at DESC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var eps []Episode
|
||||||
|
for rows.Next() {
|
||||||
|
var e Episode
|
||||||
|
if err := rows.Scan(&e.ID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
eps = append(eps, e)
|
||||||
|
}
|
||||||
|
return eps, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetEpisode(id int64) (*Episode, error) {
|
||||||
|
e := &Episode{}
|
||||||
|
err := s.db.QueryRow("SELECT id, title, description, audio_path, image_path, published_at, created_at FROM episodes WHERE id = ?", id).
|
||||||
|
Scan(&e.ID, &e.Title, &e.Description, &e.AudioPath, &e.ImagePath, &e.PublishedAt, &e.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateEpisode(title, description, audioPath, imagePath string, publishedAt time.Time) (int64, error) {
|
||||||
|
res, err := s.db.Exec("INSERT INTO episodes (title, description, audio_path, image_path, published_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
title, description, audioPath, imagePath, publishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateEpisode(id int64, title, description, audioPath, imagePath string, publishedAt time.Time) error {
|
||||||
|
_, err := s.db.Exec("UPDATE episodes SET title=?, description=?, audio_path=?, image_path=?, published_at=? WHERE id=?",
|
||||||
|
title, description, audioPath, imagePath, publishedAt, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteEpisode(id int64) error {
|
||||||
|
_, err := s.db.Exec("DELETE FROM episodes WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
|
|
||||||
|
func (s *Store) GetSetting(key string) (string, error) {
|
||||||
|
var val string
|
||||||
|
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&val)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetSetting(key, value string) error {
|
||||||
|
_, err := s.db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podstalk — Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest" />
|
||||||
|
<style>
|
||||||
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }
|
||||||
|
.container { margin-top: 2rem; }
|
||||||
|
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
||||||
|
.flash-error { background: #ffcdd2; }
|
||||||
|
.flash-success { background: #c8e6c9; }
|
||||||
|
.episode-actions { display: flex; gap: 0.5rem; }
|
||||||
|
.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 .form-group { margin-bottom: 0; }
|
||||||
|
.inline-form { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">{{.PodcastTitle}}</a>
|
||||||
|
<ul class="nb-navbar-nav" role="menubar">
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/" class="nb-navbar-link active" role="menuitem" aria-current="page">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/signout" class="nb-navbar-link" role="menuitem">Sign Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Podcast Settings</h2>
|
||||||
|
<form method="post" action="/settings" enctype="multipart/form-data">
|
||||||
|
<div class="settings-form">
|
||||||
|
<div class="form-group" style="flex: 1; min-width: 200px;">
|
||||||
|
<label for="podcast_title">Podcast Title</label>
|
||||||
|
<input type="text" id="podcast_title" name="podcast_title" class="nb-input blue"
|
||||||
|
value="{{.PodcastTitle}}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="podcast_image">Podcast Image</label>
|
||||||
|
<input type="file" id="podcast_image" name="podcast_image" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="nb-button green">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{if .PodcastImage}}
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<img src="/uploads/{{.PodcastImage}}" alt="Podcast image" style="max-width: 150px; max-height: 150px; border: 2px solid #000;" />
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nb-card" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="nb-card-content">
|
||||||
|
<h4 class="nb-card-title">RSS Feed</h4>
|
||||||
|
<p class="nb-card-text">
|
||||||
|
<code id="rss-url"></code>
|
||||||
|
<button class="nb-button default" onclick="copyRSS()" style="margin-left: 0.5rem;">Copy</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const rssURL = '{{.BaseURL}}/rss';
|
||||||
|
document.getElementById('rss-url').textContent = rssURL;
|
||||||
|
function copyRSS() {
|
||||||
|
navigator.clipboard.writeText(rssURL).then(() => {
|
||||||
|
const btn = event.target;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2>Episodes</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<a href="/episodes/new" class="nb-button blue">+ New Episode</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Episodes}}
|
||||||
|
<div class="nb-table-container">
|
||||||
|
<div class="nb-table-header">All Episodes ({{len .Episodes}})</div>
|
||||||
|
<table class="nb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>Published</th>
|
||||||
|
<th>Audio</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Episodes}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td>
|
||||||
|
{{$img := .ImagePath}}
|
||||||
|
{{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>{{.PublishedAt.Format "Jan 2, 2006"}}</td>
|
||||||
|
<td>{{if .AudioPath}}✓{{else}}—{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="episode-actions">
|
||||||
|
<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?')">
|
||||||
|
<button type="submit" class="nb-button orange">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="nb-card">
|
||||||
|
<div class="nb-card-content">
|
||||||
|
<h4 class="nb-card-title">No episodes yet</h4>
|
||||||
|
<p class="nb-card-text">Create your first episode to get started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podstalk — {{if .Editing}}Edit Episode{{else}}New Episode{{end}}</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest" />
|
||||||
|
<style>
|
||||||
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }
|
||||||
|
.container { margin-top: 2rem; }
|
||||||
|
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
||||||
|
.flash-error { background: #ffcdd2; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; margin-bottom: 0.25rem; font-weight: bold; }
|
||||||
|
.form-actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
|
||||||
|
.current-file { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
|
||||||
|
<ul class="nb-navbar-nav" role="menubar">
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/" class="nb-navbar-link" role="menuitem">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/signout" class="nb-navbar-link" role="menuitem">Sign Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
|
||||||
|
<h2>{{if .Editing}}Edit Episode{{else}}New Episode{{end}}</h2>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input type="text" id="title" name="title" class="nb-input blue" required autofocus
|
||||||
|
value="{{if .Editing}}{{.Episode.Title}}{{end}}" />
|
||||||
|
</div>
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" class="nb-textarea"
|
||||||
|
rows="4">{{if .Editing}}{{.Episode.Description}}{{end}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="audio_file">Audio File</label>
|
||||||
|
<input type="file" id="audio_file" name="audio_file" accept="audio/*" />
|
||||||
|
{{if .Editing}}
|
||||||
|
<div class="current-file">
|
||||||
|
{{if .Episode.AudioPath}}Current: {{.Episode.AudioPath}}{{else}}No audio file uploaded.{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="image_file">Episode Image (optional)</label>
|
||||||
|
<input type="file" id="image_file" name="image_file" accept="image/*" />
|
||||||
|
{{if .Editing}}
|
||||||
|
<div class="current-file">
|
||||||
|
{{if .Episode.ImagePath}}Current: {{.Episode.ImagePath}}{{else}}No image uploaded.{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="published_at">Published At</label>
|
||||||
|
<input type="datetime-local" id="published_at" name="published_at" class="nb-input blue"
|
||||||
|
value="{{if .Editing}}{{.PublishedAt}}{{end}}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="nb-button green">{{if .Editing}}Save Changes{{else}}Create Episode{{end}}</button>
|
||||||
|
<a href="/" class="nb-button default">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podstalk — Sign In</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest" />
|
||||||
|
<style>
|
||||||
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }
|
||||||
|
.container { margin-top: 2rem; }
|
||||||
|
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
||||||
|
.flash-error { background: #ffcdd2; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; margin-bottom: 0.25rem; font-weight: bold; }
|
||||||
|
.form-actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
|
||||||
|
<ul class="nb-navbar-nav" role="menubar">
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/signup" class="nb-navbar-link" role="menuitem">Sign Up</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
|
||||||
|
<h2>Sign In</h2>
|
||||||
|
<p>Sign in to manage your podcast episodes.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/signin">
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" class="nb-input blue" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" class="nb-input blue" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="nb-button blue">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podstalk — Sign Up</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest" />
|
||||||
|
<style>
|
||||||
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }
|
||||||
|
.container { margin-top: 2rem; }
|
||||||
|
.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; }
|
||||||
|
.flash-error { background: #ffcdd2; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; margin-bottom: 0.25rem; font-weight: bold; }
|
||||||
|
.form-actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
|
||||||
|
<ul class="nb-navbar-nav" role="menubar">
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/signin" class="nb-navbar-link" role="menuitem">Sign In</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
|
||||||
|
<h2>Create Your Account</h2>
|
||||||
|
<p>Set up your Podstalk account to start managing your podcast.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/signup">
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" class="nb-input blue" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" class="nb-input blue" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="nb-button blue">Sign Up</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user