Website/main.go

612 lines
16 KiB
Go
Raw Normal View History

2024-09-03 18:12:33 +00:00
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://spitfirebrowser.com)
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)
// Send notification to Discord
sendDiscordNotification(message)
log.Printf("Sent notifications for entry %d: %s", entry.Number, entry.Title)
}