Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ func (a *Admin) CreatePost(c *gin.Context) {
log.Print("CREATING POST: ", requestPost)
(*a.db).Create(&requestPost)

a.b.ComputeBacklinks(&requestPost)

log.Println("POST CREATED: ", requestPost)
c.JSON(http.StatusCreated, requestPost)
}
Expand Down Expand Up @@ -167,6 +169,8 @@ func (a *Admin) UpdatePost(c *gin.Context) {
(*a.db).Model(&existingPost).Select("draft").Update("draft", false)
}

a.b.ComputeBacklinks(&existingPost)

log.Println("POST UPDATED: ", existingPost)
c.JSON(http.StatusAccepted, existingPost)
}
Expand Down Expand Up @@ -423,13 +427,16 @@ func (a *Admin) Post(c *gin.Context) {
})
} else {
c.HTML(http.StatusOK, "post-admin.html", gin.H{
"logged_in": a.auth.IsAdmin(c),
"is_admin": a.auth.IsLoggedIn(c),
"post": post,
"version": a.b.Version,
"recent": a.b.GetLatest(),
"admin_page": true,
"settings": a.b.GetSettings(),
"logged_in": a.auth.IsLoggedIn(c),
"is_admin": a.auth.IsAdmin(c),
"post": post,
"version": a.b.Version,
"recent": a.b.GetLatest(),
"admin_page": true,
"settings": a.b.GetSettings(),
"backlinks": a.b.GetBacklinks(post.ID),
"outbound_links": a.b.GetOutboundLinks(post.ID),
"external_backlinks": a.b.GetExternalBacklinks(post.ID),
})
}
}
152 changes: 141 additions & 11 deletions blog/blog.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package blog
import (
"errors"
"fmt"
"regexp"
scholar "github.com/compscidr/scholar"
"goblog/auth"
"log"
Expand Down Expand Up @@ -178,6 +179,124 @@ func (b *Blog) SearchPosts(query string) []Post {
return posts
}

// reInternalLink matches internal post URLs like /posts/2024/01/15/my-slug or /2024/01/15/my-slug
var reInternalLink = regexp.MustCompile(`\]\(/(?:posts/)?(\d{4})/(\d{1,2})/(\d{1,2})/([^)\s]+)\)`)

// ComputeBacklinks parses a post's content for internal links and upserts backlink records.
func (b *Blog) ComputeBacklinks(post *Post) {
// Clear existing backlinks for this source post
(*b.db).Where("source_post_id = ?", post.ID).Delete(&Backlink{})

matches := reInternalLink.FindAllStringSubmatch(post.Content, -1)
seen := make(map[uint]bool)
for _, match := range matches {
year, err := strconv.Atoi(match[1])
if err != nil {
continue
}
month, err := strconv.Atoi(match[2])
if err != nil {
continue
}
day, err := strconv.Atoi(match[3])
if err != nil {
continue
}
slug := match[4]

// Use exact slug match and bounded date range
var target Post
startOfDay := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
endOfDay := startOfDay.Add(24 * time.Hour)
if err := (*b.db).Preload("Tags").
Where("slug = ? AND created_at >= ? AND created_at < ?", slug, startOfDay, endOfDay).
First(&target).Error; err != nil {
continue
}
if target.ID == post.ID || seen[target.ID] {
continue
}
seen[target.ID] = true
(*b.db).Create(&Backlink{SourcePostID: post.ID, TargetPostID: target.ID})
}
}

// GetBacklinks returns posts that link TO the given post.
func (b *Blog) GetBacklinks(postID uint) []Post {
var posts []Post
(*b.db).Raw(`SELECT p.* FROM posts p
INNER JOIN backlinks bl ON bl.source_post_id = p.id
WHERE bl.target_post_id = ? AND p.deleted_at IS NULL
ORDER BY p.created_at DESC`, postID).Scan(&posts)
return posts
}

// GetOutboundLinks returns posts that the given post links TO.
func (b *Blog) GetOutboundLinks(postID uint) []Post {
var posts []Post
(*b.db).Raw(`SELECT p.* FROM posts p
INNER JOIN backlinks bl ON bl.target_post_id = p.id
WHERE bl.source_post_id = ? AND p.deleted_at IS NULL
ORDER BY p.created_at DESC`, postID).Scan(&posts)
return posts
}

// GetExternalBacklinks returns external referers for a given post.
func (b *Blog) GetExternalBacklinks(postID uint) []ExternalBacklink {
var backlinks []ExternalBacklink
(*b.db).Where("post_id = ?", postID).Order("hit_count desc").Find(&backlinks)
return backlinks
}

// TrackReferer records external referers for a post.
func (b *Blog) TrackReferer(c *gin.Context, postID uint) {
referer := c.Request.Referer()
if referer == "" {
return
}

parsed, err := url.Parse(referer)
if err != nil {
return
}

// Skip self-referrals (compare hostnames without ports)
reqHost := c.Request.Host
if i := strings.LastIndex(reqHost, ":"); i != -1 {
reqHost = reqHost[:i]
}
if strings.EqualFold(parsed.Hostname(), reqHost) {
return
}

// Skip non-HTTP(S) schemes
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return
}

now := time.Now()
var existing ExternalBacklink
result := (*b.db).Where("post_id = ? AND referer = ?", postID, referer).First(&existing)
if result.Error != nil {
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Printf("Error querying external backlinks: %v", result.Error)
return
}
// New referer
(*b.db).Create(&ExternalBacklink{
PostID: postID,
Referer: referer,
FirstSeen: now,
LastSeen: now,
HitCount: 1,
})
} else {
(*b.db).Model(&existing).Updates(map[string]interface{}{
"last_seen": now,
"hit_count": gorm.Expr("hit_count + ?", 1),
})
}
}
//////JSON API///////

// ListPosts lists all blog posts
Expand Down Expand Up @@ -211,17 +330,21 @@ func (b *Blog) NoRoute(c *gin.Context) {
day, _ := strconv.Atoi(tokens[3])
post, err := b.getPostByParams(year, month, day, tokens[4])
if err == nil && post != nil {
b.TrackReferer(c, post.ID)
if b.auth.IsAdmin(c) {
c.HTML(http.StatusOK, "post-admin.html", gin.H{
"logged_in": b.auth.IsLoggedIn(c),
"is_admin": b.auth.IsAdmin(c),
"post": post,
"version": b.Version,
"recent": b.GetLatest(),
"admin_page": false,
"settings": b.GetSettings(),
"comments": b.getCommentsByPostID(post.ID),
"comment_error": c.Query("comment_error"),
"logged_in": b.auth.IsLoggedIn(c),
"is_admin": b.auth.IsAdmin(c),
"post": post,
"version": b.Version,
"recent": b.GetLatest(),
"admin_page": false,
"settings": b.GetSettings(),
"comments": b.getCommentsByPostID(post.ID),
"comment_error": c.Query("comment_error"),
"backlinks": b.GetBacklinks(post.ID),
"outbound_links": b.GetOutboundLinks(post.ID),
"external_backlinks": b.GetExternalBacklinks(post.ID),
})
} else {
c.HTML(http.StatusOK, "post.html", gin.H{
Expand Down Expand Up @@ -301,7 +424,8 @@ func (b *Blog) Post(c *gin.Context) {
"settings": b.GetSettings(),
})
} else {
c.HTML(http.StatusOK, "post.html", gin.H{
b.TrackReferer(c, post.ID)
data := gin.H{
"logged_in": b.auth.IsLoggedIn(c),
"is_admin": b.auth.IsAdmin(c),
"post": post,
Expand All @@ -311,7 +435,13 @@ func (b *Blog) Post(c *gin.Context) {
"settings": b.GetSettings(),
"comments": b.getCommentsByPostID(post.ID),
"comment_error": c.Query("comment_error"),
})
}
if b.auth.IsAdmin(c) {
data["backlinks"] = b.GetBacklinks(post.ID)
data["outbound_links"] = b.GetOutboundLinks(post.ID)
data["external_backlinks"] = b.GetExternalBacklinks(post.ID)
}
c.HTML(http.StatusOK, "post.html", data)
//if b.auth.IsAdmin(c) {
// c.HTML(http.StatusOK, "post-admin.html", gin.H{
// "logged_in": b.auth.IsLoggedIn(c),
Expand Down
160 changes: 160 additions & 0 deletions blog/blog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,163 @@ func TestBlogWorkflow(t *testing.T) {
t.Errorf("Expected redirect with rate_limit error but got: %s", location)
}
}

func TestBacklinks(t *testing.T) {
db, _ := gorm.Open(sqlite.Open(":memory:"))
db.AutoMigrate(&auth.BlogUser{}, &blog.Post{}, &blog.Tag{}, &blog.Backlink{}, &blog.ExternalBacklink{})
a := &Auth{}
sch := scholar.New("profiles.json", "articles.json")
b := blog.New(db, a, "test", sch)

// Create two posts. Post B will link to Post A.
postA := blog.Post{
Title: "Post A",
Content: "This is Post A content",
Slug: "post-a",
}
db.Create(&postA)

// Post B links to Post A using a markdown link
postB := blog.Post{
Title: "Post B",
Content: "Check out [Post A](/posts/" + postA.CreatedAt.Format("2006/1/2") + "/post-a) for more info",
Slug: "post-b",
}
db.Create(&postB)

// Compute backlinks for Post B
b.ComputeBacklinks(&postB)

// Post A should have Post B as a backlink
backlinks := b.GetBacklinks(postA.ID)
if len(backlinks) != 1 {
t.Fatalf("Expected 1 backlink for Post A, got %d", len(backlinks))
}
if backlinks[0].ID != postB.ID {
t.Errorf("Expected backlink from Post B (ID %d), got ID %d", postB.ID, backlinks[0].ID)
}

// Post B should have Post A as an outbound link
outbound := b.GetOutboundLinks(postB.ID)
if len(outbound) != 1 {
t.Fatalf("Expected 1 outbound link for Post B, got %d", len(outbound))
}
if outbound[0].ID != postA.ID {
t.Errorf("Expected outbound link to Post A (ID %d), got ID %d", postA.ID, outbound[0].ID)
}

// Post A should have no outbound links
outboundA := b.GetOutboundLinks(postA.ID)
if len(outboundA) != 0 {
t.Errorf("Expected 0 outbound links for Post A, got %d", len(outboundA))
}

// Post B should have no backlinks
backlinksB := b.GetBacklinks(postB.ID)
if len(backlinksB) != 0 {
t.Errorf("Expected 0 backlinks for Post B, got %d", len(backlinksB))
}

// Update Post B to remove the link, backlinks should be cleared
postB.Content = "Updated content with no links"
db.Save(&postB)
b.ComputeBacklinks(&postB)

backlinks = b.GetBacklinks(postA.ID)
if len(backlinks) != 0 {
t.Errorf("Expected 0 backlinks after removing link, got %d", len(backlinks))
}
}

func TestExternalBacklinks(t *testing.T) {
db, _ := gorm.Open(sqlite.Open(":memory:"))
db.AutoMigrate(&auth.BlogUser{}, &blog.Post{}, &blog.Tag{}, &blog.Backlink{}, &blog.ExternalBacklink{})
a := &Auth{}
sch := scholar.New("profiles.json", "articles.json")
b := blog.New(db, a, "test", sch)

post := blog.Post{
Title: "Test Post",
Content: "Some content",
Slug: "test-post",
}
db.Create(&post)

router := gin.Default()
store := cookie.NewStore([]byte("changelater"))
router.Use(sessions.Sessions("test", store))

// Test external referer is tracked
router.GET("/track", func(c *gin.Context) {
b.TrackReferer(c, post.ID)
c.String(http.StatusOK, "ok")
})

// Request with external referer
req, _ := http.NewRequest("GET", "/track", nil)
req.Header.Set("Referer", "https://example.com/some-page")
req.Host = "myblog.com"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

backlinks := b.GetExternalBacklinks(post.ID)
if len(backlinks) != 1 {
t.Fatalf("Expected 1 external backlink, got %d", len(backlinks))
}
if backlinks[0].Referer != "https://example.com/some-page" {
t.Errorf("Expected referer 'https://example.com/some-page', got '%s'", backlinks[0].Referer)
}
if backlinks[0].HitCount != 1 {
t.Errorf("Expected hit count 1, got %d", backlinks[0].HitCount)
}

// Second request from same referer should increment hit count
req, _ = http.NewRequest("GET", "/track", nil)
req.Header.Set("Referer", "https://example.com/some-page")
req.Host = "myblog.com"
w = httptest.NewRecorder()
router.ServeHTTP(w, req)

backlinks = b.GetExternalBacklinks(post.ID)
if len(backlinks) != 1 {
t.Fatalf("Expected 1 external backlink after second hit, got %d", len(backlinks))
}
if backlinks[0].HitCount != 2 {
t.Errorf("Expected hit count 2 after second hit, got %d", backlinks[0].HitCount)
}

// Self-referral should be skipped
req, _ = http.NewRequest("GET", "/track", nil)
req.Header.Set("Referer", "https://myblog.com/other-page")
req.Host = "myblog.com"
w = httptest.NewRecorder()
router.ServeHTTP(w, req)

backlinks = b.GetExternalBacklinks(post.ID)
if len(backlinks) != 1 {
t.Errorf("Expected self-referral to be skipped, got %d backlinks", len(backlinks))
}

// Self-referral with port mismatch should still be skipped
req, _ = http.NewRequest("GET", "/track", nil)
req.Header.Set("Referer", "https://myblog.com:443/other-page")
req.Host = "myblog.com:8080"
w = httptest.NewRecorder()
router.ServeHTTP(w, req)

backlinks = b.GetExternalBacklinks(post.ID)
if len(backlinks) != 1 {
t.Errorf("Expected self-referral with different port to be skipped, got %d backlinks", len(backlinks))
}

// Empty referer should be skipped
req, _ = http.NewRequest("GET", "/track", nil)
req.Host = "myblog.com"
w = httptest.NewRecorder()
router.ServeHTTP(w, req)

backlinks = b.GetExternalBacklinks(post.ID)
if len(backlinks) != 1 {
t.Errorf("Expected empty referer to be skipped, got %d backlinks", len(backlinks))
}
}
Loading
Loading