4 Commits

Author SHA1 Message Date
orangix
67624368de don't error on rewriteUrl outside site 2026-01-26 03:09:48 +01:00
orangix
14192e2943 change Album and Tag to use encoding/json
* move sanitization code to views

* split Tag into Tag and the subset TagMeta to match Imgur API

* make the code to parse an array generic

* change some of the views to match
2026-01-26 03:08:54 +01:00
orangix
e241d35efe move sanitization code out of api package 2026-01-25 06:25:21 +01:00
orangix
02be603dcc refactor api package comments
* use encoding/json for comment parsing

* refactor by moving loop code to an UnmarshalJSON

* use a preallocated array and indices to maintain order while using
  goroutines again, this was removed a while ago

* use new struct in comment.hbs and contextComment.hbs

* rewriteUrl partial to reduce rimgo-specific code in api

* move RenderError into pages package to avoid import cycle between render and utils
2026-01-25 06:08:28 +01:00
23 changed files with 360 additions and 345 deletions

View File

@@ -1,37 +1,55 @@
package api package api
import ( import (
"strings" "encoding/json"
"time" "time"
"codeberg.org/rimgo/rimgo/utils" "codeberg.org/rimgo/rimgo/utils"
"github.com/microcosm-cc/bluemonday"
"github.com/tidwall/gjson"
) )
type Album struct { type Album struct {
Id string ID string `json:"id"`
Title string Title string `json:"title"`
Views int64 SharedWithCommunity bool `json:"shared_with_community"`
Upvotes int64 ViewCount int `json:"view_count"`
Downvotes int64 UpvoteCount int `json:"upvote_count"`
SharedWithCommunity bool DownvoteCount int `json:"downvote_count"`
CreatedAt string PointCount int `json:"point_count"`
UpdatedAt string CommentCount int `json:"comment_count"`
Comments int64 Media []Media `json:"media"`
User User Tags array[TagMeta] `json:"tags"`
Media []Media Account _ApiUser `json:"account"`
Tags []Tag
} }
type Media struct { type Media struct {
Id string Id string `json:"id"`
Name string Name string `json:"name"`
Title string Title string `json:"-"`
Description string Description string `json:"description"` // used outside metadata in user page cover
Url string Url string `json:"url"`
Type string Type string `json:"type"`
MimeType string MimeType string `json:"mime_type"`
}
func (media *Media) UnmarshalJSON(data []byte) error {
type PlainMedia Media // type alias to prevent calling this function again
var m struct {
PlainMedia
// Media type is used for user page covers too, but without the metadata field in JSON
// so it makes more sense to have these fields at top level as they were before
Metadata struct {
Title string `json:"title"`
Description string `json:"description"`
} `json:"metadata"`
}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
*media = Media(m.PlainMedia)
media.Title = m.Metadata.Title
media.Description = m.Metadata.Description
return nil
} }
func (client *Client) FetchAlbum(albumID string) (Album, error) { func (client *Client) FetchAlbum(albumID string) (Album, error) {
@@ -40,12 +58,13 @@ func (client *Client) FetchAlbum(albumID string) (Album, error) {
return cacheData.(Album), nil return cacheData.(Album), nil
} }
data, err := utils.GetJSON("https://api.imgur.com/post/v1/albums/" + albumID + "?client_id=" + client.ClientID + "&include=media%2Caccount") data, err := utils.GetJSONNew("https://api.imgur.com/post/v1/albums/" + albumID + "?client_id=" + client.ClientID + "&include=media%2Caccount")
if err != nil { if err != nil {
return Album{}, err return Album{}, err
} }
album, err := parseAlbum(data) var album Album
err = json.Unmarshal(data, &album)
if err != nil { if err != nil {
return Album{}, err return Album{}, err
} }
@@ -60,12 +79,13 @@ func (client *Client) FetchPosts(albumID string) (Album, error) {
return cacheData.(Album), nil return cacheData.(Album), nil
} }
data, err := utils.GetJSON("https://api.imgur.com/post/v1/posts/" + albumID + "?client_id=" + client.ClientID + "&include=media%2Caccount%2Ctags") data, err := utils.GetJSONNew("https://api.imgur.com/post/v1/posts/" + albumID + "?client_id=" + client.ClientID + "&include=media%2Caccount%2Ctags")
if err != nil { if err != nil {
return Album{}, err return Album{}, err
} }
album, err := parseAlbum(data) var album Album
err = json.Unmarshal(data, &album)
if err != nil { if err != nil {
return Album{}, err return Album{}, err
} }
@@ -80,12 +100,13 @@ func (client *Client) FetchMedia(mediaID string) (Album, error) {
return cacheData.(Album), nil return cacheData.(Album), nil
} }
data, err := utils.GetJSON("https://api.imgur.com/post/v1/media/" + mediaID + "?client_id=" + client.ClientID + "&include=media%2Caccount") data, err := utils.GetJSONNew("https://api.imgur.com/post/v1/media/" + mediaID + "?client_id=" + client.ClientID + "&include=media%2Caccount")
if err != nil { if err != nil {
return Album{}, err return Album{}, err
} }
album, err := parseAlbum(data) var album Album
err = json.Unmarshal(data, &album)
if err != nil { if err != nil {
return Album{}, err return Album{}, err
} }
@@ -93,71 +114,3 @@ func (client *Client) FetchMedia(mediaID string) (Album, error) {
client.Cache.Set(mediaID+"-media", album, 1*time.Hour) client.Cache.Set(mediaID+"-media", album, 1*time.Hour)
return album, nil return album, nil
} }
func parseAlbum(data gjson.Result) (Album, error) {
media := make([]Media, 0)
data.Get("media").ForEach(
func(key gjson.Result, value gjson.Result) bool {
url := value.Get("url").String()
url = strings.ReplaceAll(url, "https://i.imgur.com", "")
description := value.Get("metadata.description").String()
description = strings.ReplaceAll(description, "\n", "<br>")
description = bluemonday.UGCPolicy().Sanitize(description)
media = append(media, Media{
Id: value.Get("id").String(),
Name: value.Get("name").String(),
MimeType: value.Get("mime_type").String(),
Type: value.Get("type").String(),
Title: value.Get("metadata.title").String(),
Description: description,
Url: url,
})
return true
},
)
tags := make([]Tag, 0)
data.Get("tags").ForEach(
func(key gjson.Result, value gjson.Result) bool {
tags = append(tags, Tag{
Tag: value.Get("tag").String(),
Display: value.Get("display").String(),
Background: "/" + value.Get("background_id").String() + ".webp",
BackgroundId: value.Get("background_id").String(),
})
return true
},
)
createdAt, err := utils.FormatDate(data.Get("created_at").String())
if err != nil {
return Album{}, err
}
album := Album{
Id: data.Get("id").String(),
Title: data.Get("title").String(),
SharedWithCommunity: data.Get("shared_with_community").Bool(),
Views: data.Get("view_count").Int(),
Upvotes: data.Get("upvote_count").Int(),
Downvotes: data.Get("downvote_count").Int(),
Comments: data.Get("comment_count").Int(),
CreatedAt: createdAt,
Media: media,
Tags: tags,
}
account := data.Get("account")
if account.Raw != "" {
album.User = User{
Id: account.Get("id").Int(),
Username: account.Get("username").String(),
Avatar: strings.ReplaceAll(account.Get("avatar_url").String(), "https://i.imgur.com", ""),
}
}
return album, nil
}

View File

@@ -1,145 +1,62 @@
package api package api
import ( import (
"regexp" "encoding/json"
"strings"
"sync"
"time" "time"
"codeberg.org/rimgo/rimgo/utils" "codeberg.org/rimgo/rimgo/utils"
"github.com/dustin/go-humanize"
"github.com/microcosm-cc/bluemonday"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
"gitlab.com/golang-commonmark/linkify"
) )
type Comment struct { type Comment struct {
Comments []Comment ID int `json:"id"`
User User Comment string `json:"comment"`
Post Submission UpvoteCount int `json:"upvote_count"`
Id string DownvoteCount int `json:"downvote_count"`
Comment string PointCount int `json:"point_count"`
Upvotes int64 CreatedAt time.Time `json:"created_at"`
Downvotes int64 UpdatedAt time.Time `json:"updated_at"`
Platform string DeletedAt time.Time `json:"deleted_at"`
CreatedAt string Comments array[Comment] `json:"comments"`
RelTime string AccountID int `json:"account_id"`
UpdatedAt string Account _ApiUser `json:"account"`
DeletedAt string Post Submission `json:"post"`
}
type _ApiUser struct {
ID int `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
} }
func (client *Client) FetchComments(galleryID string) ([]Comment, error) { func (client *Client) FetchComments(galleryID string) ([]Comment, error) {
cacheData, found := client.Cache.Get(galleryID + "-comments") cacheData, found := client.Cache.Get(galleryID + "-comments")
if found { if found {
return cacheData.([]Comment), nil return cacheData.(array[Comment]), nil
} }
data, err := utils.GetJSON("https://api.imgur.com/comment/v1/comments?client_id=" + client.ClientID + "&filter[post]=eq:" + galleryID + "&include=account,adconfig&per_page=30&sort=best") data, err := utils.GetJSONNew("https://api.imgur.com/comment/v1/comments?client_id=" + client.ClientID + "&filter[post]=eq:" + galleryID + "&include=account,adconfig&per_page=30&sort=best")
if err != nil { if err != nil {
return []Comment{}, nil return []Comment{}, nil
} }
wg := sync.WaitGroup{} var parsed commentApiResponse
comments := make([]Comment, 0) err = json.Unmarshal(data, &parsed)
data.Get("data").ForEach( if err != nil {
func(key, value gjson.Result) bool { return []Comment{}, err
wg.Add(1)
go func() {
defer wg.Done()
comments = append(comments, parseComment(value))
}()
return true
},
)
wg.Wait()
client.Cache.Set(galleryID+"-comments", comments, cache.DefaultExpiration)
return comments, nil
} }
var imgurRe = regexp.MustCompile(`https?://imgur\.com/(gallery|a)?/(.*)`) client.Cache.Set(galleryID+"-comments", parsed.Data, cache.DefaultExpiration)
var imgurRe2 = regexp.MustCompile(`https?://imgur\.com/(.*)`) return parsed.Data, nil
var imgRe = regexp.MustCompile(`https?://i\.imgur\.com/(.*)\.(png|gif|jpe?g|webp)`)
var vidRe = regexp.MustCompile(`https?://i\.imgur\.com/(.*)\.(mp4|webm)`)
var vidFormatRe = regexp.MustCompile(`\.(mp4|webm)`)
var iImgurRe = regexp.MustCompile(`https?://i\.imgur\.com`)
func parseComment(data gjson.Result) Comment {
createdTime, _ := time.Parse("2006-01-02T15:04:05Z", data.Get("created_at").String())
createdAt := createdTime.Format("January 2, 2006 3:04 PM")
updatedAt, _ := utils.FormatDate(data.Get("updated_at").String())
deletedAt, _ := utils.FormatDate(data.Get("deleted_at").String())
userAvatar := strings.ReplaceAll(data.Get("account.avatar").String(), "https://i.imgur.com", "")
wg := sync.WaitGroup{}
comments := make([]Comment, 0)
data.Get("comments").ForEach(
func(key, value gjson.Result) bool {
wg.Add(1)
go func() {
defer wg.Done()
comments = append(comments, parseComment(value))
}()
return true
},
)
wg.Wait()
comment := data.Get("comment").String()
comment = strings.ReplaceAll(comment, "\n", "<br>")
for _, match := range imgRe.FindAllString(comment, -1) {
img := iImgurRe.ReplaceAllString(match, "")
img = `<img src="` + img + `" class="comment__media" loading="lazy"/>`
comment = strings.Replace(comment, match, img, 1)
} }
for _, match := range vidRe.FindAllString(comment, -1) {
vid := iImgurRe.ReplaceAllString(match, "")
vid = `<video class="comment__media" controls loop preload="none" poster="` + vidFormatRe.ReplaceAllString(vid, ".webp") + `"><source type="` + strings.Split(vid, ".")[1] + `" src="` + vid + `" /></video>`
comment = strings.Replace(comment, match, vid, 1)
}
for _, l := range linkify.Links(comment) {
origLink := comment[l.Start:l.End]
link := `<a href="` + origLink + `">` + origLink + `</a>`
comment = strings.Replace(comment, origLink, link, 1)
}
comment = imgurRe.ReplaceAllString(comment, "/$1/$2")
comment = imgurRe2.ReplaceAllString(comment, "/$1")
p := bluemonday.UGCPolicy() // temporary
p.AllowImages() func (post *Submission) UnmarshalJSON(data []byte) error {
p.AllowElements("video", "source") *post = parseSubmission(gjson.ParseBytes(data))
p.AllowAttrs("src", "tvpe").OnElements("source") return nil
p.AllowAttrs("controls", "loop", "preload", "poster").OnElements("video") }
p.AllowAttrs("class", "loading").OnElements("img", "video")
p.RequireNoReferrerOnLinks(true)
p.RequireNoFollowOnLinks(true)
p.RequireCrossOriginAnonymous(true)
comment = p.Sanitize(comment)
return Comment{ type commentApiResponse struct {
Comments: comments, Data array[Comment] `json:"data"`
User: User{
Id: data.Get("account.id").Int(),
Username: data.Get("account.username").String(),
Avatar: userAvatar,
},
Post: parseSubmission(data.Get("post")),
Id: data.Get("id").String(),
Comment: comment,
Upvotes: data.Get("upvote_count").Int(),
Downvotes: data.Get("downvote_count").Int(),
Platform: data.Get("platform").String(),
CreatedAt: createdAt,
RelTime: humanize.Time(createdTime),
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
}
} }

46
api/json.go Normal file
View File

@@ -0,0 +1,46 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"sync"
)
type array[T any] []T
func (arr *array[T]) UnmarshalJSON(data []byte) error {
var rawArr []json.RawMessage
err := json.Unmarshal(data, &rawArr)
if err != nil {
return err
}
*arr = make(array[T], len(rawArr))
errs := make([]error, 0, len(rawArr))
wg := sync.WaitGroup{}
var handlePanic = func() {
if v := recover(); v != nil {
v, ok := v.(error)
if !ok {
v = fmt.Errorf("%v", v)
}
errs = append(errs, v)
}
}
for i, value := range rawArr {
wg.Add(1)
go func() {
defer handlePanic()
defer wg.Done()
err := json.Unmarshal(value, &(*arr)[i])
if err != nil {
panic(err)
}
}()
}
wg.Wait()
if len(errs) != 0 {
return errors.Join(errs...)
}
return nil
}

View File

@@ -1,23 +1,26 @@
package api package api
import ( import (
"encoding/json"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"github.com/tidwall/gjson"
) )
type TagMeta struct {
Tag string `json:"tag"`
Display string `json:"display"`
Sort string `json:"sort"`
PostCount int64 `json:"post_count"`
BackgroundId string `json:"background_id"`
}
type Tag struct { type Tag struct {
Tag string TagMeta
Display string Background string `json:"-"`
Sort string Posts []Submission `json:"posts"`
PostCount int64
Posts []Submission
Background string
BackgroundId string
} }
func (client *Client) FetchTag(tag string, sort string, page string) (Tag, error) { func (client *Client) FetchTag(tag string, sort string, page string) (Tag, error) {
@@ -54,57 +57,19 @@ func (client *Client) FetchTag(tag string, sort string, page string) (Tag, error
} }
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
res, err := http.DefaultClient.Do(req) res, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return Tag{}, err return Tag{}, err
} }
body, err := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return Tag{}, err return Tag{}, err
} }
var tagData Tag
data := gjson.Parse(string(body)) err = json.Unmarshal(body, &tagData)
if err != nil {
posts := make([]Submission, 0) return Tag{}, err
data.Get("posts").ForEach(
func(key, value gjson.Result) bool {
url, _ := url.Parse(strings.ReplaceAll(value.Get("url").String(), "https://imgur.com", ""))
q := url.Query()
q.Add("tag", tag+"."+sort+"."+page+"."+key.String())
url.RawQuery = q.Encode()
posts = append(posts, Submission{
Id: value.Get("id").String(),
Title: value.Get("title").String(),
Link: url.String(),
Cover: Media{
Id: value.Get("cover_id").String(),
Type: value.Get("cover.type").String(),
Url: strings.ReplaceAll(value.Get("cover.url").String(), "https://i.imgur.com", ""),
},
Points: value.Get("point_count").Int(),
Upvotes: value.Get("upvote_count").Int(),
Downvotes: value.Get("downvote_count").Int(),
Comments: value.Get("comment_count").Int(),
Views: value.Get("view_count").Int(),
IsAlbum: value.Get("is_album").Bool(),
})
return true
},
)
tagData := Tag{
Tag: tag,
Display: data.Get("display").String(),
Sort: sort,
PostCount: data.Get("post_count").Int(),
Posts: posts,
Background: "/" + data.Get("background_id").String() + ".webp",
} }
client.Cache.Set(tag+sort+page+"-tag", tagData, 4*cache.DefaultExpiration) client.Cache.Set(tag+sort+page+"-tag", tagData, 4*cache.DefaultExpiration)
return tagData, nil return tagData, nil
} }

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"io" "io"
"net/http" "net/http"
"strings" "strings"
@@ -154,7 +155,7 @@ func (client *Client) FetchUserFavorites(username string, sort string, page stri
func (client *Client) FetchUserComments(username string) ([]Comment, error) { func (client *Client) FetchUserComments(username string) ([]Comment, error) {
cacheData, found := client.Cache.Get(username + "-usercomments") cacheData, found := client.Cache.Get(username + "-usercomments")
if found { if found {
return cacheData.([]Comment), nil return cacheData.(array[Comment]), nil
} }
req, err := http.NewRequest("GET", "https://api.imgur.com/comment/v1/comments", nil) req, err := http.NewRequest("GET", "https://api.imgur.com/comment/v1/comments", nil)
@@ -176,23 +177,19 @@ func (client *Client) FetchUserComments(username string) ([]Comment, error) {
return []Comment{}, err return []Comment{}, err
} }
body, err := io.ReadAll(res.Body) data, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return []Comment{}, err return []Comment{}, err
} }
data := gjson.Parse(string(body)) var parsed commentApiResponse
err = json.Unmarshal(data, &parsed)
if err != nil {
return []Comment{}, err
}
comments := make([]Comment, 0) client.Cache.Set(username+"-usercomments", parsed.Data, cache.DefaultExpiration)
data.Get("data").ForEach( return parsed.Data, nil
func(key, value gjson.Result) bool {
comments = append(comments, parseComment(value))
return true
},
)
client.Cache.Set(username+"-usercomments", comments, cache.DefaultExpiration)
return comments, nil
} }
func parseSubmission(value gjson.Result) Submission { func parseSubmission(value gjson.Result) Submission {

View File

@@ -23,13 +23,13 @@ func wrapHandler(h handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
if v := recover(); v != nil { if v := recover(); v != nil {
utils.RenderError(w, r, 500, fmt.Sprint(v)) pages.RenderError(w, r, 500, fmt.Sprint(v))
} }
}() }()
err := h(w, r) err := h(w, r)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
utils.RenderError(w, r, 500, err.Error()) pages.RenderError(w, r, 500, err.Error())
} }
}) })
} }
@@ -61,14 +61,14 @@ func main() {
if os.Getenv("ENV") == "dev" { if os.Getenv("ENV") == "dev" {
app.Handle("GET /errors/429", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.Handle("GET /errors/429", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
utils.RenderError(w, r, 429) pages.RenderError(w, r, 429)
})) }))
app.Handle("GET /errors/429/img", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.Handle("GET /errors/429/img", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/static/img/error-429.png") w.Header().Set("Location", "/static/img/error-429.png")
w.WriteHeader(302) w.WriteHeader(302)
})) }))
app.Handle("GET /errors/404", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.Handle("GET /errors/404", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
utils.RenderError(w, r, 404) pages.RenderError(w, r, 404)
})) }))
app.Handle("GET /errors/404/img", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.Handle("GET /errors/404/img", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/static/img/error-404.png") w.Header().Set("Location", "/static/img/error-404.png")

View File

@@ -24,10 +24,10 @@ func HandleEmbed(w http.ResponseWriter, r *http.Request) error {
post, err = ApiClient.FetchMedia(r.PathValue("postID")) post, err = ApiClient.FetchMedia(r.PathValue("postID"))
} }
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil && post.Id == "" && strings.Contains(err.Error(), "404") { if err != nil && post.ID == "" && strings.Contains(err.Error(), "404") {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} }
if err != nil { if err != nil {
return err return err

View File

@@ -1,4 +1,4 @@
package utils package pages
import ( import (
"fmt" "fmt"
@@ -8,6 +8,7 @@ import (
"codeberg.org/rimgo/rimgo/render" "codeberg.org/rimgo/rimgo/render"
"codeberg.org/rimgo/rimgo/static" "codeberg.org/rimgo/rimgo/static"
"codeberg.org/rimgo/rimgo/utils"
) )
func RenderError(w http.ResponseWriter, r *http.Request, code int, str ...string) (err error) { func RenderError(w http.ResponseWriter, r *http.Request, code int, str ...string) (err error) {
@@ -21,7 +22,7 @@ func RenderError(w http.ResponseWriter, r *http.Request, code int, str ...string
if code != 500 { if code != 500 {
codeStr = strconv.Itoa(code) codeStr = strconv.Itoa(code)
} }
if !Accepts(r, "text/html") && r.PathValue("extension") != "" { if !utils.Accepts(r, "text/html") && r.PathValue("extension") != "" {
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.WriteHeader(code) w.WriteHeader(code)
file, _ := static.GetFiles().Open("img/error-" + codeStr + ".png") file, _ := static.GetFiles().Open("img/error-" + codeStr + ".png")

View File

@@ -73,9 +73,9 @@ func handleMedia(w http.ResponseWriter, r *http.Request, url string) error {
} }
if res.StatusCode == 404 || strings.Contains(res.Request.URL.String(), "error/404") { if res.StatusCode == 404 || strings.Contains(res.Request.URL.String(), "error/404") {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} else if res.StatusCode == 429 { } else if res.StatusCode == 429 {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Accept-Ranges", "bytes")

View File

@@ -55,10 +55,10 @@ func HandlePost(w http.ResponseWriter, r *http.Request) error {
post, err = ApiClient.FetchMedia(postId) post, err = ApiClient.FetchMedia(postId)
} }
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil && post.Id == "" && strings.Contains(err.Error(), "404") { if err != nil && post.ID == "" && strings.Contains(err.Error(), "404") {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} }
if err != nil { if err != nil {
return err return err

View File

@@ -78,7 +78,7 @@ func HandleUserRSS(w http.ResponseWriter, r *http.Request) error {
submissions, err := ApiClient.FetchSubmissions(user, "newest", "1") submissions, err := ApiClient.FetchSubmissions(user, "newest", "1")
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err

View File

@@ -26,13 +26,13 @@ func HandleTag(w http.ResponseWriter, r *http.Request) error {
tag, err := ApiClient.FetchTag(r.PathValue("tag"), r.URL.Query().Get("sort"), page) tag, err := ApiClient.FetchTag(r.PathValue("tag"), r.URL.Query().Get("sort"), page)
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err
} }
if tag.Display == "" { if tag.Display == "" {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} }
return render.Render(w, "tag", map[string]any{ return render.Render(w, "tag", map[string]any{

View File

@@ -26,18 +26,18 @@ func HandleUser(w http.ResponseWriter, r *http.Request) error {
user, err := ApiClient.FetchUser(r.PathValue("userID")) user, err := ApiClient.FetchUser(r.PathValue("userID"))
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err
} }
if user.Username == "" { if user.Username == "" {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} }
submissions, err := ApiClient.FetchSubmissions(r.PathValue("userID"), "newest", page) submissions, err := ApiClient.FetchSubmissions(r.PathValue("userID"), "newest", page)
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err
@@ -60,18 +60,18 @@ func HandleUserComments(w http.ResponseWriter, r *http.Request) error {
user, err := ApiClient.FetchUser(r.PathValue("userID")) user, err := ApiClient.FetchUser(r.PathValue("userID"))
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err
} }
if user.Username == "" { if user.Username == "" {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} }
comments, err := ApiClient.FetchUserComments(r.PathValue("userID")) comments, err := ApiClient.FetchUserComments(r.PathValue("userID"))
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err
@@ -101,18 +101,18 @@ func HandleUserFavorites(w http.ResponseWriter, r *http.Request) error {
user, err := ApiClient.FetchUser(r.PathValue("userID")) user, err := ApiClient.FetchUser(r.PathValue("userID"))
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err
} }
if user.Username == "" { if user.Username == "" {
return utils.RenderError(w, r, 404) return RenderError(w, r, 404)
} }
favorites, err := ApiClient.FetchUserFavorites(r.PathValue("userID"), "newest", page) favorites, err := ApiClient.FetchUserFavorites(r.PathValue("userID"), "newest", page)
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return utils.RenderError(w, r, 429) return RenderError(w, r, 429)
} }
if err != nil { if err != nil {
return err return err

View File

@@ -1,10 +1,21 @@
package render package render
import "github.com/mailgun/raymond/v2" import (
"time"
"codeberg.org/rimgo/rimgo/utils"
"github.com/dustin/go-humanize"
"github.com/mailgun/raymond/v2"
)
func (r *renderer) registerHelpers() { func (r *renderer) registerHelpers() {
funcmap := map[string]any{ funcmap := map[string]any{
"noteq": noteq, "noteq": noteq,
"ifNonZeroTime": ifNonZeroTime,
"relTime": relTime,
"rewriteUrl": rewriteUrl,
"sanitizeDescription": sanitizeDescription,
"sanitizeComment": sanitizeComment,
} }
raymond.RegisterHelpers(funcmap) raymond.RegisterHelpers(funcmap)
} }
@@ -15,3 +26,19 @@ func noteq(a, b any, options *raymond.Options) any {
} }
return "" return ""
} }
func ifNonZeroTime(v any, options *raymond.Options) any {
if v.(time.Time).IsZero() {
return ""
}
return options.Fn()
}
func relTime(date time.Time) string {
return humanize.Time(date)
}
func rewriteUrl(link string) string {
r, err := utils.RewriteUrl(link)
if err != nil {
panic(err)
}
return r
}

53
render/sanitize.go Normal file
View File

@@ -0,0 +1,53 @@
package render
import (
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"gitlab.com/golang-commonmark/linkify"
)
var imgurRe = regexp.MustCompile(`https?://imgur\.com/(gallery|a)?/(.*)`)
var imgurRe2 = regexp.MustCompile(`https?://imgur\.com/(.*)`)
var imgRe = regexp.MustCompile(`https?://i\.imgur\.com/(.*)\.(png|gif|jpe?g|webp)`)
var vidRe = regexp.MustCompile(`https?://i\.imgur\.com/(.*)\.(mp4|webm)`)
var vidFormatRe = regexp.MustCompile(`\.(mp4|webm)`)
var iImgurRe = regexp.MustCompile(`https?://i\.imgur\.com`)
func sanitizeDescription(src string) string {
src = strings.ReplaceAll(src, "\n", "<br>")
return bluemonday.UGCPolicy().Sanitize(src)
}
func sanitizeComment(src string) string {
src = strings.ReplaceAll(src, "\n", "<br>")
for _, match := range imgRe.FindAllString(src, -1) {
img := iImgurRe.ReplaceAllString(match, "")
img = `<img src="` + img + `" class="comment__media" loading="lazy"/>`
src = strings.Replace(src, match, img, 1)
}
for _, match := range vidRe.FindAllString(src, -1) {
vid := iImgurRe.ReplaceAllString(match, "")
vid = `<video class="comment__media" controls loop preload="none" poster="` + vidFormatRe.ReplaceAllString(vid, ".webp") + `"><source type="` + strings.Split(vid, ".")[1] + `" src="` + vid + `" /></video>`
src = strings.Replace(src, match, vid, 1)
}
for _, l := range linkify.Links(src) {
origLink := (src)[l.Start:l.End]
link := `<a href="` + origLink + `">` + origLink + `</a>`
src = strings.Replace(src, origLink, link, 1)
}
src = imgurRe.ReplaceAllString(src, "/$1/$2")
src = imgurRe2.ReplaceAllString(src, "/$1")
p := bluemonday.UGCPolicy()
p.AllowImages()
p.AllowElements("video", "source")
p.AllowAttrs("src", "tvpe").OnElements("source")
p.AllowAttrs("controls", "loop", "preload", "poster").OnElements("video")
p.AllowAttrs("class", "loading").OnElements("img", "video")
p.RequireNoReferrerOnLinks(true)
p.RequireNoFollowOnLinks(true)
p.RequireCrossOriginAnonymous(true)
return p.Sanitize(src)
}

View File

@@ -1,6 +1,7 @@
package utils package utils
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -9,6 +10,42 @@ import (
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
func GetJSONNew(url string) (json.RawMessage, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return json.RawMessage{}, err
}
SetReqHeaders(req)
client := http.Client{}
res, err := client.Do(req)
if err != nil {
return json.RawMessage{}, err
}
rateLimitRemaining := res.Header.Get("X-RateLimit-UserRemaining")
if rateLimitRemaining != "" {
ratelimit, _ := strconv.Atoi(rateLimitRemaining)
if ratelimit <= 0 {
return json.RawMessage{}, fmt.Errorf("ratelimited by imgur")
}
}
body, err := io.ReadAll(res.Body)
if err != nil {
return json.RawMessage{}, err
}
switch res.StatusCode {
case 200:
return body, nil
case 429:
return json.RawMessage{}, fmt.Errorf("ratelimited by imgur")
default:
return json.RawMessage{}, fmt.Errorf("received status %s, expected 200 OK.\n%s", res.Status, string(body))
}
}
func GetJSON(url string) (gjson.Result, error) { func GetJSON(url string) (gjson.Result, error) {
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {

19
utils/rewriteUrl.go Normal file
View File

@@ -0,0 +1,19 @@
package utils
import (
"net/url"
)
func RewriteUrl(link string) (string, error) {
url, err := url.Parse(link)
if err != nil {
return "", err
}
switch url.Host {
case "", "imgur.com", "www.imgur.com", "i.imgur.com":
return url.Path, nil
case "i.stack.imgur.com":
return "/stack" + url.Path, nil
}
return link, nil
}

View File

@@ -25,7 +25,7 @@
<div class="postMeta"> <div class="postMeta">
<div class="postDetails"> <div class="postDetails">
{{#if post.TItle}} {{#if post.TItle}}
<a href="/{{post.Id}}"> <a href="/{{post.ID}}">
<h3>{{post.Title}}</h3> <h3>{{post.Title}}</h3>
</a> </a>
{{/if}} {{/if}}
@@ -35,10 +35,10 @@
</div> </div>
</div> </div>
<div class="flex flex-center"> <div class="flex flex-center">
<a href="/{{post.Id}}"> <a href="/{{post.ID}}">
<img src="/static/img/rimgo.svg" width="32px" height="32px" class="logo"> <img src="/static/img/rimgo.svg" width="32px" height="32px" class="logo">
</a> </a>
<a href="/{{post.Id}}"> <a href="/{{post.ID}}">
rimgo rimgo
</a> </a>
</div> </div>

View File

@@ -19,10 +19,10 @@
<div class="postMeta"> <div class="postMeta">
<a href="/{{id}}.mp4" download="{{id}}.mp4">download</a> <a href="/{{id}}.mp4" download="{{id}}.mp4">download</a>
<div class="flex flex-center"> <div class="flex flex-center">
<a href="/{{post.Id}}"> <a href="/{{post.ID}}">
<img src="/static/img/rimgo.svg" width="32px" height="32px" class="logo"> <img src="/static/img/rimgo.svg" width="32px" height="32px" class="logo">
</a> </a>
<a href="/{{post.Id}}"> <a href="/{{post.ID}}">
rimgo rimgo
</a> </a>
</div> </div>

View File

@@ -1,25 +1,25 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
{{#noteq this.User.Username "[deleted]"}} {{#noteq this.Account.Username "[deleted]"}}
<img src="{{this.User.Avatar}}" class="rounded-full" width="24" height="24" loading="lazy"> <img src="{{rewriteUrl(this.Account.Avatar)}}" class="rounded-full" width="24" height="24" loading="lazy">
<a href="/user/{{this.User.Username}}"> <a href="/user/{{this.Account.Username}}">
<p class="whitespace-nowrap text-ellipsis overflow-hidden"><b>{{this.User.Username}}</b></p> <p class="whitespace-nowrap text-ellipsis overflow-hidden"><b>{{this.Account.Username}}</b></p>
</a> </a>
{{/noteq}} {{/noteq}}
{{#equal this.User.Username "[deleted]"}} {{#equal this.Account.Username "[deleted]"}}
<p class="whitespace-nowrap text-ellipsis overflow-hidden"><b>[deleted]</b></p> <p class="whitespace-nowrap text-ellipsis overflow-hidden"><b>[deleted]</b></p>
{{/equal}} {{/equal}}
</div> </div>
<div> <div>
<p>{{{this.Comment}}}</p> <p>{{{sanitizeComment(this.Comment)}}}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<span title="{{this.CreatedAt}}">{{this.RelTime}}</span> <span title="{{this.CreatedAt}}">{{relTime(this.CreatedAt)}}</span>
{{#if this.DeletedAt}} {{#ifNonZeroTime this.DeletedAt}}
<span class="text-md">(deleted {{this.DeletedAt}})</span> <span class="text-md">(deleted {{this.DeletedAt}})</span>
{{/if}} {{/ifNonZeroTime}}
| |
<img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px"> {{this.Upvotes}} <img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px"> {{this.UpvoteCount}}
<img class="invert icon" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px"> {{this.Downvotes}} <img class="invert icon" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px"> {{this.DownvoteCount}}
</div> </div>
</div> </div>
{{#if this.Comments}} {{#if this.Comments}}

View File

@@ -3,18 +3,18 @@
<img class="object-cover block w-full h-[300px] sm:w-[120px] sm:h-[140px] rounded-lg rounded-b-none sm:rounded-b-lg" src="{{this.Post.Cover.Url}}" alt=""> <img class="object-cover block w-full h-[300px] sm:w-[120px] sm:h-[140px] rounded-lg rounded-b-none sm:rounded-b-lg" src="{{this.Post.Cover.Url}}" alt="">
<div class="flex flex-col gap-2 bg-slate-600 p-4 rounded-lg rounded-t-none sm:rounded-t-lg w-full"> <div class="flex flex-col gap-2 bg-slate-600 p-4 rounded-lg rounded-t-none sm:rounded-t-lg w-full">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<p class="md-container">{{{this.Comment}}}</p> <p class="md-container">{{{sanitizeComment(this.Comment)}}}</p>
<div class="grow"></div> <div class="grow"></div>
<div class="flex gap-2"> <div class="flex gap-2">
<span title="{{this.CreatedAt}}">{{this.RelTime}}</span> <span title="{{this.CreatedAt}}">{{relTime(this.CreatedAt)}}</span>
{{#if this.DeletedAt}} {{#ifNonZeroTime this.DeletedAt}}
<span class="text-md">(deleted {{this.DeletedAt}})</span> <span class="text-md">(deleted {{this.DeletedAt}})</span>
{{/if}} {{/ifNonZeroTime}}
| |
<img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px"> <img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px">
{{this.Upvotes}} {{this.UpvoteCount}}
<img class="invert icon" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px"> <img class="invert icon" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px">
{{this.Downvotes}} {{this.DownvoteCount}}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,25 +2,25 @@
<div class="bg-slate-600 rounded-lg"> <div class="bg-slate-600 rounded-lg">
{{#equal Cover.Type "video"}} {{#equal Cover.Type "video"}}
<video controls loop poster="/{{Cover.Id}}.webp" preload="none" width="100%" height="100%"> <video controls loop poster="/{{Cover.Id}}.webp" preload="none" width="100%" height="100%">
<source src="{{Cover.Url}}" type="video/mp4" /> <source src="{{rewriteUrl(Cover.Url)}}" type="video/mp4" />
</video> </video>
{{/equal}} {{/equal}}
{{#equal Cover.Type "image"}} {{#equal Cover.Type "image"}}
<img src="{{Cover.Url}}" loading="lazy" width="100%" height="100%"> <img src="{{rewriteUrl(Cover.Url)}}" loading="lazy" width="100%" height="100%">
{{/equal}} {{/equal}}
<p class="m-2 text-ellipsis whitespace-nowrap overflow-hidden">{{Title}}</p> <p class="m-2 text-ellipsis whitespace-nowrap overflow-hidden">{{Title}}</p>
<div class="flex gap-2 p-2"> <div class="flex gap-2 p-2">
<div class="flex gap-1"> <div class="flex gap-1">
<img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Points" width="18px" height="18px"> <img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Points" width="18px" height="18px">
{{Points}} {{PointCount}}
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<img class="invert icon" src="/static/icons/PhChat.svg" alt="Comments" width="18px" height="18px"> <img class="invert icon" src="/static/icons/PhChat.svg" alt="Comments" width="18px" height="18px">
{{Comments}} {{CommentCount}}
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<img class="invert icon" src="/static/icons/PhEye.svg" alt="Views" width="18px" height="18px"> <img class="invert icon" src="/static/icons/PhEye.svg" alt="Views" width="18px" height="18px">
{{Views}} {{ViewCount}}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@
<div class="flex flex-col gap-2 md:flex-row md:gap-4 md:items-center"> <div class="flex flex-col gap-2 md:flex-row md:gap-4 md:items-center">
{{#if post.User.Username}} {{#if post.User.Username}}
<a href="/user/{{post.User.Username}}" class="flex gap-2 items-center"> <a href="/user/{{post.User.Username}}" class="flex gap-2 items-center">
<img src="{{post.User.Avatar}}" class="rounded-full" width="36" height="36" /> <img src="{{rewriteUrl(post.User.Avatar)}}" class="rounded-full" width="36" height="36" />
<p> <p>
<b>{{post.User.Username}}</b> <b>{{post.User.Username}}</b>
</p> </p>
@@ -38,16 +38,16 @@
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<div class="flex flex-center gap-2"> <div class="flex flex-center gap-2">
<img class="icon invert" src="/static/icons/PhEye.svg" alt="Views" width="24px" height="24px"> <img class="icon invert" src="/static/icons/PhEye.svg" alt="Views" width="24px" height="24px">
<p>{{post.Views}}</p> <p>{{post.ViewCount}}</p>
</div> </div>
{{#if post.SharedWithCommunity}} {{#if post.SharedWithCommunity}}
<div class="flex flex-center gap-2"> <div class="flex flex-center gap-2">
<img class="icon invert" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px"> <img class="icon invert" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px">
<p>{{post.Upvotes}}</p> <p>{{post.UpvoteCount}}</p>
</div> </div>
<div class="flex flex-center gap-2"> <div class="flex flex-center gap-2">
<img class="icon invert" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px"> <img class="icon invert" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px">
<p>{{post.Downvotes}}</p> <p>{{post.DownvoteCount}}</p>
</div> </div>
{{/if}} {{/if}}
</div> </div>
@@ -66,16 +66,16 @@
{{/if}} {{/if}}
{{#equal this.Type "image"}} {{#equal this.Type "image"}}
<img class="my-2 max-h-96 object-contain" src="{{this.Url}}" loading="lazy"> <img class="my-2 max-h-96 object-contain" src="{{rewriteUrl(this.Url)}}" loading="lazy">
{{/equal}} {{/equal}}
{{#equal this.Type "video"}} {{#equal this.Type "video"}}
<video class="my-2 max-h-96 object-contain" controls loop> <video class="my-2 max-h-96 object-contain" controls loop>
<source type="{{this.MimeType}}" src="{{this.Url}}" /> <source type="{{this.MimeType}}" src="{{rewriteUrl(this.Url)}}" />
</video> </video>
{{/equal}} {{/equal}}
{{#if this.Description}} {{#if this.Metadata.Description}}
<p>{{{this.Description}}}</p> <p>{{{sanitizeDescription(this.Metadata.Description)}}}</p>
{{/if}} {{/if}}
{{/each}} {{/each}}
</div> </div>
@@ -84,12 +84,12 @@
<div class="flex gap-2 my-2 flex-wrap"> <div class="flex gap-2 my-2 flex-wrap">
<style nonce="{{nonce}}"> <style nonce="{{nonce}}">
{{#each post.tags}} {{#each post.tags}}
.{{this.BackgroundId}} { background-image: url('{{this.Background}}') } .{{this.BackgroundID}} { background-image: url('{{this.Background}}') }
{{/each}} {{/each}}
</style> </style>
{{#each post.tags}} {{#each post.tags}}
<a href="/t/{{this.Tag}}"> <a href="/t/{{this.Tag}}">
<div class="rounded-md p-4 min-w-[110px] bg-slate-500 {{this.BackgroundId}}"> <div class="rounded-md p-4 min-w-[110px] bg-slate-500 {{this.BackgroundID}}">
<p class="font-bold text-white text-center"> <p class="font-bold text-white text-center">
{{#if tag.Display}} {{#if tag.Display}}
{{this.Display}} {{this.Display}}
@@ -110,7 +110,7 @@
<input id="comments__expandBtn" type="checkbox" checked> <input id="comments__expandBtn" type="checkbox" checked>
<label class="comments__expandBtn__label my-2 py-4 border-solid border-t-2 border-slate-400" <label class="comments__expandBtn__label my-2 py-4 border-solid border-t-2 border-slate-400"
for="comments__expandBtn"> for="comments__expandBtn">
<h3 class="text-xl font-bold">Comments ({{post.Comments}})</h3> <h3 class="text-xl font-bold">Comments ({{post.CommentCount}})</h3>
<span class="text-xl font-bold"></span> <span class="text-xl font-bold"></span>
</label> </label>
<div class="comments flex flex-col gap-2"> <div class="comments flex flex-col gap-2">