commit 68c1f38b8dd72d1d025d359148080d56339b1759 Author: James Brechtel Date: Tue Jul 7 21:58:39 2026 -0400 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c5ee193 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +*.db +podstalk +.git/ diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..7790956 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f42309 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..230ff78 --- /dev/null +++ b/README.md @@ -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 +``, ``, ``, and `` for each episode. + +## Running Without Docker + +```sh +go run . +# or +CGO_ENABLED=0 go build -o podstalk . && ./podstalk +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d91d0a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4db88c6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/handler/auth.go b/handler/auth.go new file mode 100644 index 0000000..8739f2e --- /dev/null +++ b/handler/auth.go @@ -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) + } +} diff --git a/handler/episode.go b/handler/episode.go new file mode 100644 index 0000000..518322f --- /dev/null +++ b/handler/episode.go @@ -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 +} diff --git a/handler/rss.go b/handler/rss.go new file mode 100644 index 0000000..d16167e --- /dev/null +++ b/handler/rss.go @@ -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 +} diff --git a/handler/settings.go b/handler/settings.go new file mode 100644 index 0000000..1518261 --- /dev/null +++ b/handler/settings.go @@ -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) +} diff --git a/handler/template.go b/handler/template.go new file mode 100644 index 0000000..caa9198 --- /dev/null +++ b/handler/template.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ae47bb5 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..76128ed --- /dev/null +++ b/store/store.go @@ -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 +} diff --git a/template/dashboard.html b/template/dashboard.html new file mode 100644 index 0000000..9a0d5d9 --- /dev/null +++ b/template/dashboard.html @@ -0,0 +1,133 @@ + + + + + + Podstalk — Dashboard + + + + + +
+
+

Podcast Settings

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ {{if .PodcastImage}} +
+ Podcast image +
+ {{end}} +
+ +
+
+

RSS Feed

+

+ + +

+
+
+ + +

Episodes

+ + + + {{if .Episodes}} +
+
All Episodes ({{len .Episodes}})
+ + + + + + + + + + + + {{range .Episodes}} + + + + + + + + {{end}} + +
TitleImagePublishedAudioActions
{{.Title}} + {{$img := .ImagePath}} + {{if not $img}}{{$img = $.PodcastImage}}{{end}} + {{if $img}}{{else}}—{{end}} + {{.PublishedAt.Format "Jan 2, 2006"}}{{if .AudioPath}}✓{{else}}—{{end}} +
+ Edit +
+ +
+
+
+
+ {{else}} +
+
+

No episodes yet

+

Create your first episode to get started.

+
+
+ {{end}} +
+ + diff --git a/template/episode_form.html b/template/episode_form.html new file mode 100644 index 0000000..47f2a8e --- /dev/null +++ b/template/episode_form.html @@ -0,0 +1,76 @@ + + + + + + Podstalk — {{if .Editing}}Edit Episode{{else}}New Episode{{end}} + + + + + +
+ {{if .Error}}
{{.Error}}
{{end}} +

{{if .Editing}}Edit Episode{{else}}New Episode{{end}}

+ +
+
+ + +
+
+ + +
+
+ + + {{if .Editing}} +
+ {{if .Episode.AudioPath}}Current: {{.Episode.AudioPath}}{{else}}No audio file uploaded.{{end}} +
+ {{end}} +
+
+ + + {{if .Editing}} +
+ {{if .Episode.ImagePath}}Current: {{.Episode.ImagePath}}{{else}}No image uploaded.{{end}} +
+ {{end}} +
+
+ + +
+
+ + Cancel +
+
+
+ + diff --git a/template/signin.html b/template/signin.html new file mode 100644 index 0000000..fdee9e2 --- /dev/null +++ b/template/signin.html @@ -0,0 +1,47 @@ + + + + + + Podstalk — Sign In + + + + + +
+ {{if .Error}}
{{.Error}}
{{end}} +

Sign In

+

Sign in to manage your podcast episodes.

+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + diff --git a/template/signup.html b/template/signup.html new file mode 100644 index 0000000..57b1e09 --- /dev/null +++ b/template/signup.html @@ -0,0 +1,47 @@ + + + + + + Podstalk — Sign Up + + + + + +
+ {{if .Error}}
{{.Error}}
{{end}} +

Create Your Account

+

Set up your Podstalk account to start managing your podcast.

+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ +