aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSamuel Johnson <[email protected]>2025-11-26 00:58:34 -0500
committerSamuel Johnson <[email protected]>2025-11-26 00:58:34 -0500
commitc72dfab37fd6f0d739ea70d42779b2a8c85e9915 (patch)
tree365fd790550a395daa762f746d6d82b2d1cfbd00
parent350170e0f2a2d26a79924d24b32468057719cf60 (diff)
Add Youtube RSS parser
-rw-r--r--cmd/web/handlers/blog.go1
-rw-r--r--cmd/web/handlers/routes.go8
-rw-r--r--cmd/web/handlers/rss.go218
-rw-r--r--go.mod8
-rw-r--r--go.sum24
-rw-r--r--internal/dbmigrations.go9
-rw-r--r--internal/models/feed.go8
-rw-r--r--static/app.css20
-rw-r--r--ui/html/base.tmpl.html1
-rw-r--r--ui/html/pages/feed.tmpl.html15
-rw-r--r--ui/html/pages/feed_index.tmpl.html37
11 files changed, 346 insertions, 3 deletions
diff --git a/cmd/web/handlers/blog.go b/cmd/web/handlers/blog.go
index 14a0abe..e9512c0 100644
--- a/cmd/web/handlers/blog.go
+++ b/cmd/web/handlers/blog.go
@@ -37,6 +37,7 @@ func (ctx *blogContext) viewPost(w http.ResponseWriter, r *http.Request) {
ctx.err.Printf("Could not prepare statement for DB: %v\n", err)
return
}
+ defer stmt.Close()
row := stmt.QueryRow(postId)
if err != nil {
diff --git a/cmd/web/handlers/routes.go b/cmd/web/handlers/routes.go
index 6a938fb..be48ae3 100644
--- a/cmd/web/handlers/routes.go
+++ b/cmd/web/handlers/routes.go
@@ -23,6 +23,10 @@ func RegisterEndpoints(app types.Application, db *sql.DB) *http.ServeMux {
err: app.Err,
db: db,
}
+ feeds := rssContext{
+ err: app.Err,
+ db: db,
+ }
audio := fsContext{
err: app.Err,
path: app.AudioDir,
@@ -41,6 +45,10 @@ func RegisterEndpoints(app types.Application, db *sql.DB) *http.ServeMux {
blogRouter.HandleFunc("/login", login.handle)
blogRouter.HandleFunc("/logout", auth.Resolve(login.logout))
+ blogRouter.HandleFunc("/feeds", auth.CheckAndInvalidate(feeds.index))
+ blogRouter.HandleFunc("/feeds/new", auth.Resolve(feeds.new))
+ blogRouter.HandleFunc("/feed", feeds.feed)
+
blogRouter.HandleFunc("/audio", audio.readdir)
blogRouter.HandleFunc("/audio/get", audio.get)
diff --git a/cmd/web/handlers/rss.go b/cmd/web/handlers/rss.go
new file mode 100644
index 0000000..3e3019d
--- /dev/null
+++ b/cmd/web/handlers/rss.go
@@ -0,0 +1,218 @@
+package handlers
+
+import (
+ "database/sql"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/mmcdole/gofeed"
+ "paterissa.net/mblog/internal/models"
+)
+
+type video struct {
+ Title string
+ Description string
+ Published string
+ Link string
+}
+
+type rssContext struct {
+ err *log.Logger
+ db *sql.DB
+
+ Feed models.Feed
+ Videos []video
+ Rows []models.Feed
+ IsAuth bool
+}
+
+func (ctx *rssContext) new(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ w.Write([]byte("Method Not Allowed"))
+ return
+ }
+
+ cookie, err := r.Cookie("paterissa_session_token")
+ if err != nil {
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte("Unauthorized"))
+ return
+ }
+
+ err = r.ParseForm()
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Server Error"))
+ return
+ }
+
+ name := r.Form.Get("name")
+ url := r.Form.Get("url")
+
+ stmt, err := ctx.db.Prepare("SELECT * FROM cookies WHERE content = $1;")
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not load cookies from DB: %v\n", err)
+ return
+ }
+ defer stmt.Close()
+
+ var id uint64
+ var content string
+ var userId uint64
+ var expiration time.Time
+
+ row := stmt.QueryRow(cookie.Value)
+ err = row.Scan(&id, &content, &userId, &expiration)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ return
+ }
+
+ insertStmt, err := ctx.db.Prepare("INSERT INTO feeds (name, user_id, url) VALUES ($1, $2, $3);")
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not prepare insert statement to DB: %v\n", err)
+ return
+ }
+ defer insertStmt.Close()
+
+ _, err = insertStmt.Exec(name, userId, url)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not execute feed into DB: %v\n", err)
+ return
+ }
+
+ http.Redirect(w, r, "/feeds", http.StatusFound)
+ return
+}
+
+func (ctx *rssContext) feed(w http.ResponseWriter, r *http.Request) {
+ ctx.Videos = []video{}
+ ctx.Feed = models.Feed{}
+
+ id := r.URL.Query().Get("id")
+
+ stmt, err := ctx.db.Prepare("SELECT * FROM feeds WHERE id = $1;")
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not load feeds from DB: %v\n", err)
+ return
+ }
+ defer stmt.Close()
+
+ var f models.Feed
+
+ row := stmt.QueryRow(id)
+ err = row.Scan(&f.Id, &f.Name, &f.User, &f.Url)
+
+ ctx.Feed = f
+
+ parser := gofeed.NewParser()
+ feed, err := parser.ParseURL(f.Url)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not parse RSS feed: %v\n", err)
+ return
+ }
+
+ for _, raw := range feed.Items {
+ var v video
+
+ v.Title = raw.Title
+ v.Description = raw.Extensions["media"]["group"][0].Children["description"][0].Value
+ v.Published = raw.Published
+ v.Link = strings.Replace(raw.Link, "watch?v=", "embed/", 1)
+
+ ctx.Videos = append(ctx.Videos, v)
+ }
+
+ files := []string{
+ "ui/html/base.tmpl.html",
+ "ui/html/music_player.tmpl.html",
+ "ui/html/pages/feed.tmpl.html",
+ }
+
+ compiled, err := template.ParseFiles(files...)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not parse template: %v\n", err)
+ return
+ }
+
+ err = compiled.ExecuteTemplate(w, "base", ctx)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not execute template: %v\n", err)
+ return
+ }
+
+ return
+}
+
+func (ctx *rssContext) index(w http.ResponseWriter, r *http.Request) {
+ ctx.Rows = []models.Feed{}
+ ctx.IsAuth = false
+
+ _, err := r.Cookie("paterissa_session_token")
+ if err == nil {
+ ctx.IsAuth = true
+ }
+
+ rows, err := ctx.db.Query("SELECT f.id, f.name, u.name, f.url FROM feeds f INNER JOIN logins u ON f.user_id = u.id ORDER BY f.name DESC;")
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not load posts from DB: %v\n", err)
+ return
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var f models.Feed
+
+ if err = rows.Scan(&f.Id, &f.Name, &f.User, &f.Url); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not load posts from DB: %v\n", err)
+ return
+ }
+
+ ctx.Rows = append(ctx.Rows, f)
+ }
+
+ files := []string{
+ "ui/html/base.tmpl.html",
+ "ui/html/music_player.tmpl.html",
+ "ui/html/pages/feed_index.tmpl.html",
+ }
+
+ compiled, err := template.ParseFiles(files...)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not parse template: %v\n", err)
+ return
+ }
+
+ err = compiled.ExecuteTemplate(w, "base", ctx)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("Internal Error"))
+ ctx.err.Printf("Could not execute template: %v\n", err)
+ return
+ }
+}
diff --git a/go.mod b/go.mod
index f5689be..e227af7 100644
--- a/go.mod
+++ b/go.mod
@@ -11,9 +11,17 @@ require (
)
require (
+ github.com/PuerkitoBio/goquery v1.8.0 // indirect
+ github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/mmcdole/gofeed v1.3.0 // indirect
+ github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ golang.org/x/net v0.21.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
diff --git a/go.sum b/go.sum
index 122a5a3..be2aa79 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,11 @@
+github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
+github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
+github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
+github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -13,6 +18,17 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
+github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
+github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
+github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -24,10 +40,18 @@ github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/dbmigrations.go b/internal/dbmigrations.go
index 50badc0..7d520ba 100644
--- a/internal/dbmigrations.go
+++ b/internal/dbmigrations.go
@@ -40,6 +40,15 @@ func Migrate(db *sql.DB, webmaster string, passOne string, passTwo string) {
}
}
+ _, table_check = db.Query("SELECT * FROM feeds;")
+ if table_check != nil {
+ _, err := db.Exec("CREATE TABLE feeds (id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, user_id INTEGER REFERENCES logins(id), url VARCHAR(255) NOT NULL);")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Unable to create feeds table: %v\n", err)
+ os.Exit(1)
+ }
+ }
+
_, table_check = db.Query("SELECT * FROM cookies;")
if table_check != nil {
_, err := db.Exec("CREATE TABLE cookies (id SERIAL PRIMARY KEY, content VARCHAR(255) NOT NULL, user_id INTEGER REFERENCES logins(id), expiration TIMESTAMP);")
diff --git a/internal/models/feed.go b/internal/models/feed.go
new file mode 100644
index 0000000..9c68adb
--- /dev/null
+++ b/internal/models/feed.go
@@ -0,0 +1,8 @@
+package models
+
+type Feed struct {
+ Id int
+ Name string
+ User string
+ Url string
+}
diff --git a/static/app.css b/static/app.css
index 2b86f97..254cbc4 100644
--- a/static/app.css
+++ b/static/app.css
@@ -81,14 +81,17 @@ a:hover::after {
padding: 0.2em 0.5em;
width: 75%;
- background-color: black;
+ background-color: rgba(0, 0, 0, 0.8);
color: white;
- opacity: 0.8;
border-radius: 20px;
box-shadow: 0px 0px 15px 2px #410f5e;
}
+.video {
+ background-color: rgba(0, 0, 0, 1.0);
+}
+
.topline {
display: flex;
flex-direction: row;
@@ -181,18 +184,29 @@ a:hover::after {
width: 95%;
}
+.url_form > input {
+ margin-right: 2em;
+ margin-left: 2em;
+}
+
.markdown_form {
margin-left: 2em;
margin-right: 2em;
}
+.video {
+ opacity: 1;
+ aspect-ratio: 16 / 9;
+ width: 90%;
+}
+
.bottom_elem {
margin-bottom: 2rem;
}
.white {
background-color: white;
- opacity: 1;
+ opacity: 0.65;
}
.text_left {
diff --git a/ui/html/base.tmpl.html b/ui/html/base.tmpl.html
index 628994e..5d69616 100644
--- a/ui/html/base.tmpl.html
+++ b/ui/html/base.tmpl.html
@@ -22,6 +22,7 @@
<a href="https://git.paterissa.net" class="nav_tag">Git</a>
<a href="https://starless.paterissa.net" class="nav_tag">Starless</a>
<a href="https://charts.paterissa.net" class="nav_tag">Parliament Builder</a>
+ <a href="/feeds" class="nav_tag">Feeds</a>
</nav>
</header>
<hr>
diff --git a/ui/html/pages/feed.tmpl.html b/ui/html/pages/feed.tmpl.html
new file mode 100644
index 0000000..83332f2
--- /dev/null
+++ b/ui/html/pages/feed.tmpl.html
@@ -0,0 +1,15 @@
+{{define "title"}}{{.Feed.Name}}{{end}}
+{{define "description"}}"Recent videos from {{.Feed.Name}}"{{end}}
+
+{{define "main"}}
+ {{range .Videos}}
+ <div class="card">
+ <div class="header">
+ <h4><b>{{.Title}} - {{.Published}}</b></h4>
+ <iframe class="video" src="{{.Link}}"></iframe>
+ </div>
+ <p>{{.Description}}</p>
+ </div>
+ {{end}}
+{{end}}
+
diff --git a/ui/html/pages/feed_index.tmpl.html b/ui/html/pages/feed_index.tmpl.html
new file mode 100644
index 0000000..59b37d4
--- /dev/null
+++ b/ui/html/pages/feed_index.tmpl.html
@@ -0,0 +1,37 @@
+{{define "title"}}RSS Feeds{{end}}
+{{define "description"}}"alternative to social media platforms"{{end}}
+
+{{define "main"}}
+ <div class="flex_between">
+ <div class="topline">
+ <h2>Feeds</h2>
+ <p>Alternative to social media platforms</p>
+ </div>
+ <div class="spacer"></div>
+ {{if .IsAuth}}
+ <a href="/logout" class="nav_tag right">Logout</a>
+ {{else}}
+ <a href="/login" class="nav_tag right">Login</a>
+ {{end}}
+ </div>
+ {{if .IsAuth}}
+ <div class="card">
+ <form class="url_form center" action="/feeds/new" method="POST">
+ <label for="name">Channel Name:</label>
+ <input type="text" id="name" name="name">
+ <label for="url">URL:</label>
+ <input type="text" id="url" name="url">
+ <div class="right">
+ <input type="submit" value="Submit">
+ </div>
+ </form>
+ </div>
+ {{end}}
+ {{range .Rows}}
+ <div class="card">
+ <div class="header">
+ <h4><a href="/feed?id={{.Id}}">{{.Name}} - added by {{.User}}</a></h4>
+ </div>
+ </div>
+ {{end}}
+{{end}}