aboutsummaryrefslogtreecommitdiff
path: root/cmd/hdrwtch
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2024-08-21 19:30:58 -0400
committerXe Iaso <me@xeiaso.net>2024-08-21 19:30:58 -0400
commitd9a0fb6435165fd3c6044de9ce8cbb0662fb6628 (patch)
treefd20230cb52529531b1e625ecafedb45faba108c /cmd/hdrwtch
parented0b9b410b6c0c8478bb3e456d14753a923d06b4 (diff)
downloadx-d9a0fb6435165fd3c6044de9ce8cbb0662fb6628.tar.xz
x-d9a0fb6435165fd3c6044de9ce8cbb0662fb6628.zip
cmd/hdrwtch: make this ready
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'cmd/hdrwtch')
-rw-r--r--cmd/hdrwtch/dao.go41
-rw-r--r--cmd/hdrwtch/docs.go113
-rw-r--r--cmd/hdrwtch/docs.templ40
-rw-r--r--cmd/hdrwtch/docs/alerts.md20
-rw-r--r--cmd/hdrwtch/docs/contact.md8
-rw-r--r--cmd/hdrwtch/docs/index.md28
-rw-r--r--cmd/hdrwtch/docs/pricing.md45
-rw-r--r--cmd/hdrwtch/docs/reports.md8
-rw-r--r--cmd/hdrwtch/docs/why-in-logs.md14
-rw-r--r--cmd/hdrwtch/docs_templ.go92
-rw-r--r--cmd/hdrwtch/execute.go74
-rw-r--r--cmd/hdrwtch/main.go30
-rw-r--r--cmd/hdrwtch/notify.go23
-rw-r--r--cmd/hdrwtch/package-lock.json53
-rw-r--r--cmd/hdrwtch/package.json3
-rw-r--r--cmd/hdrwtch/probes.go58
-rw-r--r--cmd/hdrwtch/probes.templ227
-rw-r--r--cmd/hdrwtch/probes_templ.go339
-rw-r--r--cmd/hdrwtch/static/css/styles.css2411
-rw-r--r--cmd/hdrwtch/static/img/hero.avifbin0 -> 34108 bytes
-rw-r--r--cmd/hdrwtch/styles.css13
-rw-r--r--cmd/hdrwtch/tailwind.config.js7
-rw-r--r--cmd/hdrwtch/telegram.go12
-rw-r--r--cmd/hdrwtch/web.templ163
-rw-r--r--cmd/hdrwtch/web_templ.go75
25 files changed, 3742 insertions, 155 deletions
diff --git a/cmd/hdrwtch/dao.go b/cmd/hdrwtch/dao.go
index a651e21..4249bd6 100644
--- a/cmd/hdrwtch/dao.go
+++ b/cmd/hdrwtch/dao.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
+ "log/slog"
"strconv"
_ "github.com/ncruces/go-sqlite3/embed"
@@ -26,6 +27,8 @@ func New(dbLoc string) (*DAO, error) {
Logger: slogGorm.New(
slogGorm.WithErrorField("err"),
slogGorm.WithRecordNotFoundError(),
+ slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelInfo),
+ //slogGorm.WithTraceAll(),
),
})
if err != nil {
@@ -36,6 +39,7 @@ func New(dbLoc string) (*DAO, error) {
&TelegramUser{},
&Probe{},
&ProbeResult{},
+ &Doc{},
); err != nil {
return nil, fmt.Errorf("failed to migrate schema: %w", err)
}
@@ -47,6 +51,15 @@ func New(dbLoc string) (*DAO, error) {
return &DAO{db: db}, nil
}
+func (dao *DAO) GetUser(ctx context.Context, id int64) (*TelegramUser, error) {
+ var user TelegramUser
+ if err := dao.db.First(&user, id).WithContext(ctx).Error; err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ return &user, nil
+}
+
func (dao *DAO) UpsertUser(ctx context.Context, user *TelegramUser) error {
if err := dao.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, // primary key
@@ -79,7 +92,7 @@ func (dao *DAO) CreateProbe(ctx context.Context, probe *Probe, tu *TelegramUser)
return nil
}
-func (dao *DAO) CountProbes(ctx context.Context, userID string) (int64, error) {
+func (dao *DAO) CountProbes(ctx context.Context, userID int64) (int64, error) {
var count int64
if err := dao.db.Model(&Probe{}).Where("user_id = ?", userID).Count(&count).WithContext(ctx).Error; err != nil {
return 0, fmt.Errorf("failed to count probes: %w", err)
@@ -88,7 +101,7 @@ func (dao *DAO) CountProbes(ctx context.Context, userID string) (int64, error) {
return count, nil
}
-func (dao *DAO) GetProbe(ctx context.Context, id string, userID string) (*Probe, error) {
+func (dao *DAO) GetProbe(ctx context.Context, id string, userID int64) (*Probe, error) {
var probe Probe
idInt, err := strconv.Atoi(id)
@@ -96,7 +109,7 @@ func (dao *DAO) GetProbe(ctx context.Context, id string, userID string) (*Probe,
return nil, fmt.Errorf("invalid probe ID: %w", err)
}
- if err := dao.db.First(&probe, idInt).WithContext(ctx).Error; err != nil {
+ if err := dao.db.Preload("LastResult").First(&probe, idInt).WithContext(ctx).Error; err != nil {
return nil, fmt.Errorf("failed to get probe: %w", err)
}
@@ -106,3 +119,25 @@ func (dao *DAO) GetProbe(ctx context.Context, id string, userID string) (*Probe,
return &probe, nil
}
+
+func (dao *DAO) CreateProbeResult(ctx context.Context, tx *gorm.DB, probe Probe, result *ProbeResult) error {
+ if err := tx.Create(result).WithContext(ctx).Error; err != nil {
+ return fmt.Errorf("failed to create probe result: %w", err)
+ }
+
+ probe.LastResultID = result.ID
+ if err := tx.Save(&probe).WithContext(ctx).Error; err != nil {
+ return fmt.Errorf("failed to update probe: %w", err)
+ }
+
+ return nil
+}
+
+func (dao *DAO) GetDoc(slug string) (*Doc, error) {
+ var doc Doc
+ if err := dao.db.Where("slug = ?", slug).First(&doc).Error; err != nil {
+ return nil, err
+ }
+
+ return &doc, nil
+}
diff --git a/cmd/hdrwtch/docs.go b/cmd/hdrwtch/docs.go
new file mode 100644
index 0000000..915d563
--- /dev/null
+++ b/cmd/hdrwtch/docs.go
@@ -0,0 +1,113 @@
+package main
+
+import (
+ "embed"
+ "net/http"
+ "strings"
+
+ "github.com/a-h/templ"
+ "github.com/adrg/frontmatter"
+ "github.com/gomarkdown/markdown"
+ "github.com/gomarkdown/markdown/html"
+ "github.com/gomarkdown/markdown/parser"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+var (
+ //go:embed docs
+ docsFS embed.FS
+)
+
+type Doc struct {
+ gorm.Model
+ Frontmatter Frontmatter `gorm:"embedded"`
+ Body string
+ BodyHTML string
+}
+
+type Frontmatter struct {
+ Title string `yaml:"title"`
+ Date string `yaml:"date"`
+ Updated string `yaml:"updated"`
+ Slug string `yaml:"slug" gorm:"uniqueIndex"`
+}
+
+func (s *Server) importDocs() error {
+ files, err := docsFS.ReadDir("docs")
+ if err != nil {
+ return err
+ }
+
+ for _, file := range files {
+ if file.IsDir() {
+ continue
+ }
+
+ fin, err := docsFS.Open("docs/" + file.Name())
+ if err != nil {
+ return err
+ }
+ defer fin.Close()
+
+ doc := Doc{}
+
+ rest, err := frontmatter.Parse(fin, &doc.Frontmatter)
+ if err != nil {
+ return err
+ }
+
+ extensions := parser.CommonExtensions | parser.AutoHeadingIDs
+ p := parser.NewWithExtensions(extensions)
+
+ htmlFlags := html.CommonFlags | html.HrefTargetBlank
+ opts := html.RendererOptions{Flags: htmlFlags}
+ renderer := html.NewRenderer(opts)
+
+ pageHTML := markdown.ToHTML(rest, p, renderer)
+
+ doc.Body = string(rest)
+ doc.BodyHTML = string(pageHTML)
+
+ if err := s.dao.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "slug"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "body",
+ "body_html",
+ "date",
+ "updated",
+ }),
+ }).Create(&doc).Error; err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (s *Server) docsHandler(w http.ResponseWriter, r *http.Request) {
+ var d Doc
+
+ var navbar templ.Component
+
+ tu, ok := s.getTelegramUserData(r)
+ if ok {
+ navbar = authedNavBar(tu)
+ } else {
+ navbar = anonNavBar(false)
+ }
+
+ slug := strings.TrimPrefix(r.URL.Path, "/docs")
+
+ if err := s.dao.db.WithContext(r.Context()).Where("slug = ?", slug).First(&d).Error; err != nil {
+ templ.Handler(
+ base("Not found", nil, anonNavBar(false), notFoundPage()),
+ templ.WithStatus(http.StatusNotFound),
+ ).ServeHTTP(w, r)
+ return
+ }
+
+ templ.Handler(
+ base(d.Frontmatter.Title, nil, navbar, docRender(d)),
+ ).ServeHTTP(w, r)
+}
diff --git a/cmd/hdrwtch/docs.templ b/cmd/hdrwtch/docs.templ
new file mode 100644
index 0000000..cfc11b5
--- /dev/null
+++ b/cmd/hdrwtch/docs.templ
@@ -0,0 +1,40 @@
+package main
+
+templ docRender(d Doc) {
+ <div class="flex p-4 mt-4" aria-label="Breadcrumb">
+ <ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse">
+ <li class="inline-flex items-center">
+ <a href="/" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600">
+ <svg class="w-3 h-3 me-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
+ <path d="m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"></path>
+ </svg>
+ Home
+ </a>
+ </li>
+ if d.Frontmatter.Slug != "/" {
+ <li>
+ <div class="flex items-center">
+ <svg class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"></path>
+ </svg>
+ <a href="/docs/" class="ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2">Docs</a>
+ </div>
+ </li>
+ }
+ <li aria-current="page">
+ <div class="flex items-center">
+ <svg class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"></path>
+ </svg>
+ <span class="ms-1 text-sm font-medium text-gray-500 md:ms-2">{ d.Frontmatter.Title }</span>
+ </div>
+ </li>
+ </ol>
+ </div>
+ <div class="prose max-w-2xl">
+ <h1>{ d.Frontmatter.Title }</h1>
+ <small>Last modified: { d.Frontmatter.Updated }</small>
+ <br/>
+ @templ.Raw(d.BodyHTML)
+ </div>
+}
diff --git a/cmd/hdrwtch/docs/alerts.md b/cmd/hdrwtch/docs/alerts.md
new file mode 100644
index 0000000..59b5c3e
--- /dev/null
+++ b/cmd/hdrwtch/docs/alerts.md
@@ -0,0 +1,20 @@
+---
+title: "Alerts"
+date: 2024-08-21
+updated: 2024-08-21
+slug: "/alerts"
+---
+
+When the Last-Modified header of a URL changes, hdrwtch sends an alert to the user who is monitoring that URL. The alert contains the URL that was monitored, the previous Last-Modified header value, the new Last-Modified header value, and the time of the change.
+
+Here is an example alert:
+
+<div class="flex items-start gap-2.5 not-prose">
+ <div class="flex flex-col w-full max-w-[320px] leading-1.5 p-4 border-gray-200 bg-gray-100 rounded-e-xl rounded-es-xl dark:bg-gray-700">
+ <p class="text-sm font-normal py-2.5 text-gray-900 dark:text-white"><span class="font-medium">Changing route</span>:<br /><br />Last modified: Wed, 21 Aug 2024 18:18:15 GMT<br />Region: yow-dev<br />Status code: 200<br />Remark:</p>
+ </div>
+</div>
+
+The alert is sent to the user via the messaging service that the user has configured in their account settings, but it defaults to Telegram if no other service is configured.
+
+If there is an error making the request, the remark field will contain the error message.
diff --git a/cmd/hdrwtch/docs/contact.md b/cmd/hdrwtch/docs/contact.md
new file mode 100644
index 0000000..fee80d0
--- /dev/null
+++ b/cmd/hdrwtch/docs/contact.md
@@ -0,0 +1,8 @@
+---
+title: "Contact"
+date: 2024-08-21
+updated: 2024-08-21
+slug: "/contact"
+---
+
+To get in touch with us, please send an email to [hdrwtch@xeserv.us](mailto:hdrwtch@xeserv.us). We will respond to your inquiry as soon as possible.
diff --git a/cmd/hdrwtch/docs/index.md b/cmd/hdrwtch/docs/index.md
new file mode 100644
index 0000000..ce50e1b
--- /dev/null
+++ b/cmd/hdrwtch/docs/index.md
@@ -0,0 +1,28 @@
+---
+title: "hdrwtch"
+date: 2024-08-21
+updated: 2024-08-21
+slug: "/"
+---
+
+hdrwtch is a tool that watches for changes in the `Last-Modified` header of a URL. You can use this to monitor the freshness of a web page, or to trigger an action when a page is updated.
+
+## Setup
+
+1. [Log in](/login) with Telegram.
+2. Configure [a new probe](/probe).
+3. Sit back and wait for updates.
+
+[Alerts](/docs/alerts) will be sent to you via Telegram when a probe detects a change in the `Last-Modified` header. They will be aggregated into [reports](/docs/reports) that you can view at any time.
+
+## Pricing
+
+hdrwtch is free to use for up to 5 probes. For more than 5 probes, you will need to [upgrade to a paid plan](/docs/pricing).
+
+## FAQ
+
+Here are some frequently asked questions about hdrwtch and their answers.
+
+### Why is hdrwtch in my logs?
+
+A user configured a probe to watch a URL that you control. This is not a security risk. Please see ["Why is hdrwtch in my logs?"](/docs/why-in-logs) for more information.
diff --git a/cmd/hdrwtch/docs/pricing.md b/cmd/hdrwtch/docs/pricing.md
new file mode 100644
index 0000000..e10000f
--- /dev/null
+++ b/cmd/hdrwtch/docs/pricing.md
@@ -0,0 +1,45 @@
+---
+title: "Pricing"
+date: 2024-08-21
+updated: 2024-08-21
+slug: "/pricing"
+---
+
+hdrwtch offers the following pricing plans:
+
+## Free plan
+
+$0 per month
+
+| Feature | Limit |
+| ----------------- | -------------- |
+| Probes | 5 |
+| Messaging | Telegram only |
+| History retention | 1 month |
+| Support | Community only |
+
+## Pro plan
+
+$3 per month
+
+| Feature | Limit |
+| ----------------- | ------------------------- |
+| Probes | 50 |
+| Messaging | Telegram, Email, Webhooks |
+| History retention | 6 months |
+| Support | Email only |
+
+## Enterprise plan
+
+Custom pricing depending on requirements
+
+| Feature | Limit |
+| ----------------- | ------------------------------------- |
+| Probes | Unlimited |
+| Messaging | Telegram, Email, Webhooks, Slack, SMS |
+| History retention | 1 year |
+| Support | Email, Phone, Chat |
+
+---
+
+To upgrade to a paid plan, please [contact us](/docs/contact).
diff --git a/cmd/hdrwtch/docs/reports.md b/cmd/hdrwtch/docs/reports.md
new file mode 100644
index 0000000..22e50b1
--- /dev/null
+++ b/cmd/hdrwtch/docs/reports.md
@@ -0,0 +1,8 @@
+---
+title: "Reports"
+date: 2024-08-21
+updated: 2024-08-21
+slug: "/reports"
+---
+
+Reports are a work-in-progress feature and will be made public at a later date. Stay tuned for updates!
diff --git a/cmd/hdrwtch/docs/why-in-logs.md b/cmd/hdrwtch/docs/why-in-logs.md
new file mode 100644
index 0000000..f09f15e
--- /dev/null
+++ b/cmd/hdrwtch/docs/why-in-logs.md
@@ -0,0 +1,14 @@
+---
+title: "Why is hdrwtch in my logs?"
+date: 2024-08-21
+updated: 2024-08-21
+slug: "/why-in-logs"
+---
+
+hdrwtch is showing up in your logs because a user is monitoring your website. This allows that user to receive notifications when the contents of a specific URL change. The user can also monitor the status of the website and receive notifications when the website goes down or comes back up.
+
+If you want to block hdrwtch from monitoring your website, you can do so filtering out the user agent string `hdrwtch` in your server configuration. This will prevent hdrwtch from accessing your website and monitoring its contents, but it will also prevent the user from receiving notifications about your website.
+
+hdrwtch is configured to check URLs every 15 minutes.
+
+If you have any questions or concerns about hdrwtch, please [contact us](/docs/contact). We are happy to help you with any issues you may have.
diff --git a/cmd/hdrwtch/docs_templ.go b/cmd/hdrwtch/docs_templ.go
new file mode 100644
index 0000000..8c94fd2
--- /dev/null
+++ b/cmd/hdrwtch/docs_templ.go
@@ -0,0 +1,92 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.747
+package main
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func docRender(d Doc) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"flex p-4 mt-4\" aria-label=\"Breadcrumb\"><ol class=\"inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse\"><li class=\"inline-flex items-center\"><a href=\"/\" class=\"inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600\"><svg class=\"w-3 h-3 me-2.5\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"0 0 20 20\"><path d=\"m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z\"></path></svg> Home</a></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if d.Frontmatter.Slug != "/" {
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><div class=\"flex items-center\"><svg class=\"rtl:rotate-180 w-3 h-3 text-gray-400 mx-1\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 6 10\"><path stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m1 9 4-4-4-4\"></path></svg> <a href=\"/docs/\" class=\"ms-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ms-2\">Docs</a></div></li>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li aria-current=\"page\"><div class=\"flex items-center\"><svg class=\"rtl:rotate-180 w-3 h-3 text-gray-400 mx-1\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 6 10\"><path stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m1 9 4-4-4-4\"></path></svg> <span class=\"ms-1 text-sm font-medium text-gray-500 md:ms-2\">")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Frontmatter.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 29, Col: 87}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></div></li></ol></div><div class=\"prose max-w-2xl\"><h1>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Frontmatter.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 35, Col: 27}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h1><small>Last modified: ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Frontmatter.Updated)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 36, Col: 47}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</small><br>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(d.BodyHTML).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return templ_7745c5c3_Err
+ })
+}
diff --git a/cmd/hdrwtch/execute.go b/cmd/hdrwtch/execute.go
index 9cefd51..f6415ee 100644
--- a/cmd/hdrwtch/execute.go
+++ b/cmd/hdrwtch/execute.go
@@ -3,13 +3,16 @@ package main
import (
"context"
"fmt"
+ "log/slog"
"net/http"
+ "runtime"
"time"
+ "golang.org/x/sync/errgroup"
"within.website/x/web/useragent"
)
-func checkURL(ctx context.Context, probe Probe) (*ProbeResult, error) {
+func checkURL(ctx context.Context, probe Probe) *ProbeResult {
result := &ProbeResult{
ProbeID: probe.ID,
Region: *region,
@@ -22,7 +25,7 @@ func checkURL(ctx context.Context, probe Probe) (*ProbeResult, error) {
if err != nil {
result.Success = false
result.Remark = fmt.Sprintf("failed to create request: %v", err)
- return result, fmt.Errorf("failed to create request: %w", err)
+ return result
}
req.Header.Set("User-Agent", userAgent)
@@ -31,17 +34,72 @@ func checkURL(ctx context.Context, probe Probe) (*ProbeResult, error) {
if err != nil {
result.Success = false
result.Remark = fmt.Sprintf("failed to execute request: %v", err)
- return result, fmt.Errorf("failed to execute request: %w", err)
+ return result
}
defer resp.Body.Close()
result.Success = true
result.StatusCode = resp.StatusCode
result.Duration = time.Since(start)
+ result.LastModified = resp.Header.Get("Last-Modified")
- return &ProbeResult{
- ProbeID: probe.ID,
- StatusCode: resp.StatusCode,
- Duration: time.Since(start),
- }, nil
+ return result
+}
+
+func (s *Server) cron() {
+ ctx, cancel := context.WithTimeout(context.Background(), 14*time.Minute) // 1 minute less than the cron interval
+ defer cancel()
+
+ tx := s.dao.db.Begin().WithContext(ctx)
+ defer tx.Rollback()
+
+ var probes []Probe
+
+ if err := s.dao.db.Preload("LastResult").Find(&probes).Error; err != nil {
+ slog.Error("failed to get probes", "err", err)
+ return
+ }
+
+ g, gCtx := errgroup.WithContext(ctx)
+ g.SetLimit(runtime.NumCPU())
+
+ for _, probe := range probes {
+ probe := probe
+
+ g.Go(func() error {
+ result := checkURL(gCtx, probe)
+
+ if result.LastModified != probe.LastResult.LastModified {
+ slog.Info("probe result changed", "probe", probe.ID, "old", probe.LastResult, "new", result)
+
+ user, err := s.dao.GetUser(gCtx, probe.UserID)
+ if err != nil {
+ slog.Error("failed to get user", "err", err)
+ return err
+ }
+
+ if err := s.messageUser(user, fmt.Sprintf("*%s*:\n\nLast modified: %s\nRegion: %s\nStatus code: %d\nRemark: %s", probe.Name, result.LastModified, result.Region, result.StatusCode, result.Remark)); err != nil {
+ slog.Error("failed to message user", "err", err)
+ return err
+ }
+ }
+
+ if err := s.dao.CreateProbeResult(gCtx, tx, probe, result); err != nil {
+ slog.Error("failed to create probe result", "err", err, "probe", probe, "result", result)
+ return err
+ }
+
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ slog.Error("failed to check probes", "err", err)
+ }
+
+ if err := tx.Commit().Error; err != nil {
+ slog.Error("failed to commit transaction", "err", err)
+ }
+
+ slog.Info("checked probes", "count", len(probes))
}
diff --git a/cmd/hdrwtch/main.go b/cmd/hdrwtch/main.go
index 68d6dfa..4572fb0 100644
--- a/cmd/hdrwtch/main.go
+++ b/cmd/hdrwtch/main.go
@@ -13,6 +13,8 @@ import (
"github.com/a-h/templ"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
+ "github.com/mymmrac/telego"
+ "github.com/robfig/cron/v3"
"within.website/x/htmx"
"within.website/x/internal"
)
@@ -43,6 +45,11 @@ func main() {
os.Exit(1)
}
+ bot, err := telego.NewBot(*botToken)
+ if err != nil {
+ log.Fatal(err)
+ }
+
dao, err := New(*dbLoc)
if err != nil {
log.Fatal(err)
@@ -51,18 +58,24 @@ func main() {
s := &Server{
store: sessions.NewCookieStore([]byte(*cookieSecret)),
dao: dao,
+ tg: bot,
+ }
+
+ if err := s.importDocs(); err != nil {
+ log.Fatal(err)
}
mux := http.NewServeMux()
htmx.Mount(mux)
- mux.Handle("/static/", internal.UnchangingCache(http.FileServer(http.FS(staticFS))))
+ mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
mux.Handle("/{$}", templ.Handler(base("Home", nil, anonNavBar(true), homePage())))
mux.HandleFunc("/login", s.loginHandler)
mux.HandleFunc("/login/callback", s.loginCallbackHandler)
mux.HandleFunc("/logout", s.logoutHandler)
+ mux.HandleFunc("/docs/{slug...}", s.docsHandler)
// authed routes
mux.Handle("/user", s.loggedIn(s.userHandler))
@@ -74,6 +87,7 @@ func main() {
mux.Handle("GET /probe/{id}/edit", s.loggedIn(s.probeEdit))
mux.Handle("PUT /probe/{id}", s.loggedIn(s.probeUpdate))
mux.Handle("DELETE /probe/{id}", s.loggedIn(s.probeDelete))
+ mux.Handle("GET /probe/{id}/run/{result_id}", s.loggedIn(s.probeRunGet))
mux.Handle("/", internal.UnchangingCache(
templ.Handler(
@@ -94,6 +108,15 @@ func main() {
fmt.Fprintln(w, val)
})
+ c := cron.New()
+ if *region == "yow-dev" {
+ c.AddFunc("@every 1m", s.cron)
+ slog.Info("running in dev mode", "cron-frequency", "1m")
+ } else {
+ c.AddFunc("@every 15m", s.cron)
+ }
+ go c.Start()
+
slog.Info("listening", "on", "http://localhost:"+*port)
log.Fatal(http.ListenAndServe(":"+*port, mux))
@@ -102,6 +125,7 @@ func main() {
type Server struct {
store *sessions.CookieStore
dao *DAO
+ tg *telego.Bot
}
func (s *Server) logoutHandler(w http.ResponseWriter, r *http.Request) {
@@ -112,9 +136,7 @@ func (s *Server) logoutHandler(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) loginHandler(w http.ResponseWriter, r *http.Request) {
- if userData, ok := s.getTelegramUserData(r); ok {
- slog.Info("user data", "ok", ok, "data", userData)
-
+ if _, ok := s.getTelegramUserData(r); ok {
http.Redirect(w, r, "/user", http.StatusFound)
return
}
diff --git a/cmd/hdrwtch/notify.go b/cmd/hdrwtch/notify.go
new file mode 100644
index 0000000..682f9f7
--- /dev/null
+++ b/cmd/hdrwtch/notify.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "strings"
+
+ "github.com/mymmrac/telego"
+ tu "github.com/mymmrac/telego/telegoutil"
+)
+
+func (s *Server) messageUser(user *TelegramUser, message string) error {
+ var sb strings.Builder
+
+ sb.WriteString(message)
+
+ msg := tu.Message(tu.ID(user.ID), sb.String())
+ msg.ParseMode = telego.ModeMarkdown
+
+ if _, err := s.tg.SendMessage(msg); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/cmd/hdrwtch/package-lock.json b/cmd/hdrwtch/package-lock.json
index e10fc91..f408ca0 100644
--- a/cmd/hdrwtch/package-lock.json
+++ b/cmd/hdrwtch/package-lock.json
@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
- "@tailwindcss/forms": "^0.5.7"
+ "@tailwindcss/forms": "^0.5.7",
+ "@tailwindcss/typography": "^0.5.14"
}
},
"node_modules/@alloc/quick-lru": {
@@ -157,6 +158,34 @@
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
}
},
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.14",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.14.tgz",
+ "integrity": "sha512-ZvOCjUbsJBjL9CxQBn+VEnFpouzuKhxh2dH8xMIWHILL+HfOYtlAkWcyoon8LlzE53d2Yo6YO6pahKKNW3q1YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.castarray": "^4.4.0",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.merge": "^4.6.2",
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders"
+ }
+ },
+ "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -352,7 +381,6 @@
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"license": "MIT",
- "peer": true,
"bin": {
"cssesc": "bin/cssesc"
},
@@ -652,6 +680,24 @@
"license": "MIT",
"peer": true
},
+ "node_modules/lodash.castarray": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
+ "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -1391,8 +1437,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
diff --git a/cmd/hdrwtch/package.json b/cmd/hdrwtch/package.json
index fe233e3..0a566d