609 lines
16 KiB
Go
609 lines
16 KiB
Go
|
package main
|
||
|
|
||
|
import (
|
||
|
"bufio"
|
||
|
"bytes"
|
||
|
"flag"
|
||
|
"fmt"
|
||
|
"html/template"
|
||
|
"log"
|
||
|
"net/http"
|
||
|
"os"
|
||
|
"path/filepath"
|
||
|
"sort"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"sync"
|
||
|
"time"
|
||
|
|
||
|
"github.com/fsnotify/fsnotify"
|
||
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||
|
"github.com/russross/blackfriday/v2"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
dataDir = "./data"
|
||
|
templateDir = "./templates"
|
||
|
staticDir = "./static"
|
||
|
notifiedFilePath = "./notified_entries.json"
|
||
|
defaultPort = 8080
|
||
|
pageSize = 5 // Number of blog entries per page
|
||
|
botTokenEnv = "YOUR_TELEGRAM_BOT_TOKEN" // Replace with your bot's token or set via environment variable
|
||
|
YOUR_TELEGRAM_CHAT_ID = 0
|
||
|
discordWebhookURL = "YOUR_DISCORD_WEBHOOK_URL"
|
||
|
)
|
||
|
|
||
|
/*
|
||
|
Spitfire Browser by Internet Addict (https://weforge.xyz/Spitfire/Website)
|
||
|
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
|
||
|
*/
|
||
|
|
||
|
type Blog struct {
|
||
|
Name string
|
||
|
Entries []BlogEntry
|
||
|
}
|
||
|
|
||
|
type BlogEntry struct {
|
||
|
Title string
|
||
|
Description string
|
||
|
Author string
|
||
|
Content string
|
||
|
Date time.Time
|
||
|
Number int
|
||
|
Notified bool // To track if the notification was sent
|
||
|
}
|
||
|
|
||
|
type PageData struct {
|
||
|
Title string
|
||
|
Date string
|
||
|
Desc string
|
||
|
Author string
|
||
|
Content template.HTML
|
||
|
PrevLink string
|
||
|
NextLink string
|
||
|
}
|
||
|
|
||
|
var (
|
||
|
blogs []Blog
|
||
|
bot *tgbotapi.BotAPI
|
||
|
port int
|
||
|
creationTimes = make(map[string]time.Time)
|
||
|
creationTimesM sync.Mutex
|
||
|
notifiedEntries = make(map[int]bool)
|
||
|
)
|
||
|
|
||
|
func init() {
|
||
|
flag.IntVar(&port, "p", defaultPort, "Specify the port to run the server on")
|
||
|
flag.IntVar(&port, "port", defaultPort, "Specify the port to run the server on")
|
||
|
}
|
||
|
|
||
|
func main() {
|
||
|
// Parse the flags
|
||
|
flag.Parse()
|
||
|
|
||
|
// Load the notified entries from the file
|
||
|
loadNotifiedEntries()
|
||
|
|
||
|
// Retrieve the Telegram bot token from the environment variable
|
||
|
botToken := os.Getenv("TELEGRAM_BOT_TOKEN")
|
||
|
if botToken == "" {
|
||
|
botToken = botTokenEnv
|
||
|
}
|
||
|
|
||
|
// Initialize the Telegram bot
|
||
|
var err error
|
||
|
bot, err = tgbotapi.NewBotAPI(botToken)
|
||
|
if err != nil {
|
||
|
log.Printf("Warning: Error creating Telegram bot: %v", err)
|
||
|
} else {
|
||
|
go startTelegramBot()
|
||
|
}
|
||
|
|
||
|
// Retrieve blog entries from the data directory
|
||
|
blogs, err = getBlogs(dataDir)
|
||
|
if err != nil {
|
||
|
log.Fatalf("Error getting blogs: %v", err)
|
||
|
}
|
||
|
|
||
|
// Start the periodic notification checker
|
||
|
go startNotificationChecker(10 * time.Minute)
|
||
|
|
||
|
// Start watching for changes in the data directory
|
||
|
go watchForChanges(dataDir)
|
||
|
|
||
|
// Serve static files (CSS, JS, etc.) from the /static directory
|
||
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
|
||
|
|
||
|
// Custom handler to serve only directories and their contents under /news-assets/
|
||
|
http.Handle("/news-assets/", http.StripPrefix("/news-assets/", http.HandlerFunc(serveDirectoriesOnly)))
|
||
|
|
||
|
// Serve downloads.html at /downloads
|
||
|
http.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
|
||
|
renderTemplate(w, "download.html", nil)
|
||
|
})
|
||
|
|
||
|
// Serve download-linux.html at /download-linux
|
||
|
http.HandleFunc("/download-linux", func(w http.ResponseWriter, r *http.Request) {
|
||
|
renderTemplate(w, "download-linux.html", nil)
|
||
|
})
|
||
|
|
||
|
// Route for generating the RSS feed for all blogs
|
||
|
http.HandleFunc("/rss", func(w http.ResponseWriter, r *http.Request) {
|
||
|
siteURL := fmt.Sprintf("http://%s", r.Host) // or you can use a fixed base URL
|
||
|
generateAtomFeed(w, blogs, siteURL)
|
||
|
})
|
||
|
|
||
|
// Route for generating the RSS feed for a specific blog
|
||
|
http.HandleFunc("/rss/", func(w http.ResponseWriter, r *http.Request) {
|
||
|
pathParts := strings.Split(r.URL.Path, "/")
|
||
|
if len(pathParts) < 3 {
|
||
|
http.NotFound(w, r)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
blogName := pathParts[2]
|
||
|
for _, blog := range blogs {
|
||
|
if blog.Name == blogName {
|
||
|
siteURL := fmt.Sprintf("http://%s", r.Host) // or you can use a fixed base URL
|
||
|
generateBlogAtomFeed(w, blog, siteURL)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
http.NotFound(w, r)
|
||
|
})
|
||
|
|
||
|
// Define route handlers
|
||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
|
if r.URL.Path == "/" {
|
||
|
renderIndex(w)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
pathParts := strings.Split(r.URL.Path, "/")
|
||
|
if len(pathParts) < 2 {
|
||
|
http.NotFound(w, r)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if len(pathParts) == 3 {
|
||
|
blogName := pathParts[1]
|
||
|
entryNumber, err := strconv.Atoi(pathParts[2])
|
||
|
if err == nil {
|
||
|
renderBlogEntry(w, r, blogName, entryNumber)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
blogName := pathParts[1]
|
||
|
for _, blog := range blogs {
|
||
|
if blog.Name == blogName {
|
||
|
http.Redirect(w, r, fmt.Sprintf("/%s/%d", blogName, blog.Entries[0].Number), http.StatusFound)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
http.NotFound(w, r)
|
||
|
})
|
||
|
|
||
|
// Start the HTTP server on the specified port
|
||
|
serverURL := fmt.Sprintf("http://localhost:%d", port)
|
||
|
log.Printf("Starting server on %s", serverURL)
|
||
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil))
|
||
|
}
|
||
|
|
||
|
func startNotificationChecker(interval time.Duration) {
|
||
|
ticker := time.NewTicker(interval)
|
||
|
defer ticker.Stop()
|
||
|
|
||
|
for range ticker.C {
|
||
|
checkAndSendNotifications()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func renderTemplate(w http.ResponseWriter, tmpl string, data interface{}) {
|
||
|
tmplPath := filepath.Join(templateDir, tmpl)
|
||
|
t, err := template.ParseFiles(tmplPath)
|
||
|
if err != nil {
|
||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||
|
log.Printf("Error parsing template %s: %v", tmpl, err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
err = t.Execute(w, data)
|
||
|
if err != nil {
|
||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||
|
log.Printf("Error executing template %s: %v", tmpl, err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func renderIndex(w http.ResponseWriter) {
|
||
|
renderTemplate(w, "index.html", nil)
|
||
|
}
|
||
|
|
||
|
// Helper function to add loading="lazy" to all img tags
|
||
|
func injectLazyLoading(htmlContent string) string {
|
||
|
// Split the content by <img tags to process them individually
|
||
|
parts := strings.Split(htmlContent, "<img")
|
||
|
|
||
|
// Start with the first part which is before any <img tag
|
||
|
modifiedContent := parts[0]
|
||
|
|
||
|
// Iterate over the remaining parts
|
||
|
for _, part := range parts[1:] {
|
||
|
// Find the closing bracket of the img tag
|
||
|
endOfTag := strings.Index(part, ">")
|
||
|
if endOfTag == -1 {
|
||
|
// If no closing bracket is found, add the remaining part as is
|
||
|
modifiedContent += "<img" + part
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Extract the actual <img tag
|
||
|
imgTag := part[:endOfTag]
|
||
|
|
||
|
// Check if loading="lazy" is already present
|
||
|
if !strings.Contains(imgTag, "loading=") {
|
||
|
// Insert loading="lazy" before the closing of the img tag
|
||
|
imgTag = " loading=\"lazy\"" + imgTag
|
||
|
}
|
||
|
|
||
|
// Rebuild the full content with the modified img tag
|
||
|
modifiedContent += "<img" + imgTag + part[endOfTag:]
|
||
|
}
|
||
|
|
||
|
return modifiedContent
|
||
|
}
|
||
|
|
||
|
// For redering HTML Blogs
|
||
|
func renderBlogEntry(w http.ResponseWriter, r *http.Request, blogName string, entryNumber int) {
|
||
|
var blog Blog
|
||
|
for _, b := range blogs {
|
||
|
if b.Name == blogName {
|
||
|
blog = b
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var entry BlogEntry
|
||
|
var prevLink, nextLink string
|
||
|
for i, e := range blog.Entries {
|
||
|
if e.Number == entryNumber {
|
||
|
// Check if the entry date is in the future
|
||
|
if time.Now().Before(e.Date) {
|
||
|
http.NotFound(w, r) // If the post date is in the future, do not show the entry
|
||
|
return
|
||
|
}
|
||
|
entry = e
|
||
|
|
||
|
// Check if the previous entry is visible
|
||
|
if i > 0 && !time.Now().Before(blog.Entries[i-1].Date) {
|
||
|
prevLink = fmt.Sprintf("/%s/%d", blog.Name, blog.Entries[i-1].Number)
|
||
|
}
|
||
|
|
||
|
// Check if the next entry is visible
|
||
|
if i < len(blog.Entries)-1 && !time.Now().Before(blog.Entries[i+1].Date) {
|
||
|
nextLink = fmt.Sprintf("/%s/%d", blog.Name, blog.Entries[i+1].Number)
|
||
|
}
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Convert .md to HTML
|
||
|
htmlContent := blackfriday.Run([]byte(entry.Content))
|
||
|
|
||
|
// Double check "/news-assets/" in URL of images
|
||
|
htmlContent = bytes.ReplaceAll(htmlContent, []byte("src=\"./"), []byte(fmt.Sprintf("src=\"/news-assets/%d/", entryNumber)))
|
||
|
|
||
|
// Apply lazy loading to the generated HTML content
|
||
|
htmlContentWithLazyLoading := injectLazyLoading(string(htmlContent))
|
||
|
|
||
|
pageData := PageData{
|
||
|
Title: entry.Title,
|
||
|
Date: entry.Date.Format("2006-01-02 15:04"),
|
||
|
Desc: entry.Description,
|
||
|
Author: entry.Author,
|
||
|
Content: template.HTML(htmlContentWithLazyLoading),
|
||
|
PrevLink: prevLink,
|
||
|
NextLink: nextLink,
|
||
|
}
|
||
|
|
||
|
renderTemplate(w, "news.html", pageData)
|
||
|
}
|
||
|
|
||
|
func getBlogs(dir string) ([]Blog, error) {
|
||
|
var blogs []Blog
|
||
|
|
||
|
files, err := os.ReadDir(dir)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
for _, file := range files {
|
||
|
if file.IsDir() {
|
||
|
blog, err := getBlogEntries(filepath.Join(dir, file.Name()))
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
blogs = append(blogs, blog)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return blogs, nil
|
||
|
}
|
||
|
|
||
|
func getBlogEntries(dir string) (Blog, error) {
|
||
|
var entries []BlogEntry
|
||
|
|
||
|
files, err := os.ReadDir(dir)
|
||
|
if err != nil {
|
||
|
return Blog{}, err
|
||
|
}
|
||
|
|
||
|
for _, file := range files {
|
||
|
if filepath.Ext(file.Name()) == ".md" {
|
||
|
entry, err := parseMarkdownFile(filepath.Join(dir, file.Name()))
|
||
|
if err != nil {
|
||
|
return Blog{}, err
|
||
|
}
|
||
|
|
||
|
entries = append(entries, entry)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
sort.Slice(entries, func(i, j int) bool {
|
||
|
return entries[i].Number > entries[j].Number
|
||
|
})
|
||
|
|
||
|
blog := Blog{
|
||
|
Name: filepath.Base(dir),
|
||
|
Entries: entries,
|
||
|
}
|
||
|
|
||
|
return blog, nil
|
||
|
}
|
||
|
|
||
|
func parseMarkdownFile(path string) (BlogEntry, error) {
|
||
|
content, err := os.ReadFile(path)
|
||
|
if err != nil {
|
||
|
return BlogEntry{}, err
|
||
|
}
|
||
|
|
||
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||
|
|
||
|
var title, description, postTime, author string
|
||
|
var articleContent strings.Builder
|
||
|
var insideHeader bool
|
||
|
|
||
|
for scanner.Scan() {
|
||
|
line := strings.TrimSpace(scanner.Text())
|
||
|
|
||
|
if line == "[HEADER]" {
|
||
|
insideHeader = true
|
||
|
continue
|
||
|
} else if line == "[END]" {
|
||
|
insideHeader = false
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
if insideHeader {
|
||
|
if strings.HasPrefix(line, "t:") {
|
||
|
title = strings.TrimSpace(line[2:])
|
||
|
} else if strings.HasPrefix(line, "d:") {
|
||
|
description = strings.TrimSpace(line[2:])
|
||
|
} else if strings.HasPrefix(line, "p:") {
|
||
|
postTime = strings.TrimSpace(line[2:])
|
||
|
} else if strings.HasPrefix(line, "a:") {
|
||
|
author = strings.TrimSpace(line[2:])
|
||
|
}
|
||
|
} else {
|
||
|
articleContent.WriteString(line + "\n")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
date := time.Now()
|
||
|
if postTime != "" {
|
||
|
date, err = time.Parse("2006-01-02 15:04", postTime)
|
||
|
if err != nil {
|
||
|
log.Printf("Error parsing date in file %s: %v", path, err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
number, err := strconv.Atoi(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
|
||
|
if err != nil {
|
||
|
log.Printf("Error extracting number from file %s: %v", path, err)
|
||
|
return BlogEntry{}, err
|
||
|
}
|
||
|
|
||
|
// Check if the entry has already been notified
|
||
|
notified := notifiedEntries[number]
|
||
|
|
||
|
entry := BlogEntry{
|
||
|
Title: title,
|
||
|
Description: description,
|
||
|
Author: author,
|
||
|
Content: articleContent.String(),
|
||
|
Date: date,
|
||
|
Number: number,
|
||
|
Notified: notified,
|
||
|
}
|
||
|
|
||
|
return entry, nil
|
||
|
}
|
||
|
|
||
|
func watchForChanges(dir string) {
|
||
|
watcher, err := fsnotify.NewWatcher()
|
||
|
if err != nil {
|
||
|
log.Fatalf("Error creating file watcher: %v", err)
|
||
|
}
|
||
|
defer watcher.Close()
|
||
|
|
||
|
go func() {
|
||
|
for {
|
||
|
select {
|
||
|
case event, ok := <-watcher.Events:
|
||
|
if !ok {
|
||
|
return
|
||
|
}
|
||
|
if event.Op&fsnotify.Create == fsnotify.Create {
|
||
|
handleFileChange(event.Name, true)
|
||
|
} else if event.Op&fsnotify.Write == fsnotify.Write {
|
||
|
handleFileChange(event.Name, false)
|
||
|
}
|
||
|
case err, ok := <-watcher.Errors:
|
||
|
if !ok {
|
||
|
return
|
||
|
}
|
||
|
log.Printf("File watcher error: %v", err)
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
|
||
|
err = watcher.Add(dir)
|
||
|
if err != nil {
|
||
|
log.Fatalf("Error adding directory to watcher: %v", err)
|
||
|
}
|
||
|
|
||
|
// Add subdirectories
|
||
|
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if info.IsDir() && path != dir {
|
||
|
return watcher.Add(path)
|
||
|
}
|
||
|
return nil
|
||
|
})
|
||
|
if err != nil {
|
||
|
log.Fatalf("Error adding subdirectories to watcher: %v", err)
|
||
|
}
|
||
|
|
||
|
// Block forever
|
||
|
select {}
|
||
|
}
|
||
|
|
||
|
// serveDirectoriesOnly handles requests and only serves directories and their contents.
|
||
|
func serveDirectoriesOnly(w http.ResponseWriter, r *http.Request) {
|
||
|
requestedPath := filepath.Join(dataDir, "news", r.URL.Path)
|
||
|
|
||
|
// Check if the requested path is a directory
|
||
|
fileInfo, err := os.Stat(requestedPath)
|
||
|
if err != nil {
|
||
|
http.NotFound(w, r) // If the path doesn't exist, return a 404
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Block access to any files at the root level or the .git directory
|
||
|
if isRootLevel(requestedPath) || isRestrictedDirectory(requestedPath) {
|
||
|
http.NotFound(w, r) // Block access to root-level files like .md and the .git directory
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Block access to any .md files at the root level
|
||
|
if isRootLevel(requestedPath) && strings.HasSuffix(requestedPath, ".md") {
|
||
|
http.NotFound(w, r) // Block access to root-level .md files
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// If the path is a directory, serve its contents
|
||
|
if fileInfo.IsDir() {
|
||
|
http.FileServer(http.Dir(requestedPath)).ServeHTTP(w, r)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
// Serve the file within the subdirectory
|
||
|
http.FileServer(http.Dir(filepath.Join(dataDir, "news"))).ServeHTTP(w, r)
|
||
|
}
|
||
|
|
||
|
// isRootLevel checks if the file is at the root level of /news-assets/
|
||
|
func isRootLevel(path string) bool {
|
||
|
relPath, err := filepath.Rel(filepath.Join(dataDir, "news"), path)
|
||
|
if err != nil {
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
// The relative path should not contain any slashes if it's at the root level
|
||
|
return !strings.Contains(relPath, string(os.PathSeparator))
|
||
|
}
|
||
|
|
||
|
// isRestrictedDirectory checks if the path is within a restricted directory like .git
|
||
|
func isRestrictedDirectory(path string) bool {
|
||
|
// Normalize the path
|
||
|
cleanPath := filepath.Clean(path)
|
||
|
|
||
|
// Check if the path contains .git directory or other restricted directories
|
||
|
return strings.Contains(cleanPath, string(os.PathSeparator)+".git") || strings.HasSuffix(cleanPath, ".git")
|
||
|
}
|
||
|
|
||
|
func handleFileChange(path string, isNew bool) {
|
||
|
if filepath.Ext(path) == ".md" {
|
||
|
creationTimesM.Lock()
|
||
|
defer creationTimesM.Unlock()
|
||
|
if isNew {
|
||
|
creationTimes[path] = time.Now()
|
||
|
}
|
||
|
|
||
|
dir := filepath.Dir(path)
|
||
|
blogName := filepath.Base(dir)
|
||
|
updateBlogEntries(blogName, path)
|
||
|
checkAndSendNotifications()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func updateBlogEntries(blogName, path string) {
|
||
|
for i, blog := range blogs {
|
||
|
if blog.Name == blogName {
|
||
|
entry, err := parseMarkdownFile(path)
|
||
|
if err != nil {
|
||
|
log.Printf("Error parsing markdown file: %v", err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
updated := false
|
||
|
for j, e := range blogs[i].Entries {
|
||
|
if e.Number == entry.Number {
|
||
|
blogs[i].Entries[j] = entry
|
||
|
updated = true
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if !updated {
|
||
|
blogs[i].Entries = append(blogs[i].Entries, entry)
|
||
|
}
|
||
|
|
||
|
sort.Slice(blogs[i].Entries, func(a, b int) bool {
|
||
|
return blogs[i].Entries[a].Number > blogs[i].Entries[b].Number
|
||
|
})
|
||
|
|
||
|
log.Printf("Updated blog %s with entry %d", blogName, entry.Number)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If blog not found, create new one
|
||
|
entry, err := parseMarkdownFile(path)
|
||
|
if err != nil {
|
||
|
log.Printf("Error parsing markdown file: %v", err)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
newBlog := Blog{
|
||
|
Name: blogName,
|
||
|
Entries: []BlogEntry{entry},
|
||
|
}
|
||
|
|
||
|
blogs = append(blogs, newBlog)
|
||
|
log.Printf("Created new blog %s with entry %d", blogName, entry.Number)
|
||
|
}
|
||
|
|
||
|
func sendNotifications(entry BlogEntry) {
|
||
|
message := fmt.Sprintf("New blog post published!\nTitle: %s\nDescription: %s\nAuthor: %s\nDate: %s",
|
||
|
entry.Title, entry.Description, entry.Author, entry.Date.Format("2006-01-02 15:04"))
|
||
|
|
||
|
// Send notification to Telegram
|
||
|
sendTelegramNotification(message)
|
||
|
|
||
|
log.Printf("Sent notifications for entry %d: %s", entry.Number, entry.Title)
|
||
|
}
|