diff --git a/Dockerfile b/Dockerfile index 48f214b..63298d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,4 @@ WORKDIR /app COPY --from=builder /app/glance . EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index dec9ac4..2fbf915 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -5,4 +5,4 @@ COPY glance . EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/README.md b/README.md index 3519a03..a198219 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a ``` #### Docker + + > [!IMPORTANT] > > Make sure you have a valid `glance.yml` file in the same directory before running the container. diff --git a/docs/configuration.md b/docs/configuration.md index 26a0953..cb8d924 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,7 @@ - [Docker](#docker) ## Intro + Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error. ## Preconfigured page @@ -114,6 +115,8 @@ This will give you a page that looks like the following: Configure the widgets, add more of them, add extra pages, etc. Make it your own! + + ## Server Server configuration is done through a top level `server` property. Example: @@ -1341,6 +1344,7 @@ Preview: | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | service | string | no | pihole | +| allow-insecure | bool | no | false | | url | string | yes | | | username | string | when service is `adguard` | | | password | string | when service is `adguard` | | @@ -1350,6 +1354,9 @@ Preview: ##### `service` Either `adguard` or `pihole`. +##### `allow-insecure` +Whether to allow invalid/self-signed certificates when making the request to the service. + ##### `url` The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. @@ -1597,15 +1604,25 @@ Example: ```yaml - type: calendar + start-sunday: false ``` Preview: ![](images/calendar-widget-preview.png) +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| start-sunday | boolean | no | false | + +##### `start-sunday` +Whether calendar weeks start on Sunday or Monday. + > [!NOTE] > -> There is currently no customizability available for the calendar. Extra features will be added in the future. +> There is currently little customizability available for the calendar. Extra features will be added in the future. ### Markets Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. diff --git a/go.mod b/go.mod index 107ad3e..ed40bc8 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,10 @@ module github.com/glanceapp/glance go 1.23.1 require ( + github.com/fsnotify/fsnotify v1.8.0 github.com/mmcdole/gofeed v1.3.0 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.18.0 + golang.org/x/text v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,5 +19,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/net v0.29.0 // indirect -) \ No newline at end of file + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect +) diff --git a/go.sum b/go.sum index d02eefd..aeab91d 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -40,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -52,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -61,8 +65,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/assets/files.go b/internal/assets/files.go deleted file mode 100644 index 2c7c09e..0000000 --- a/internal/assets/files.go +++ /dev/null @@ -1,56 +0,0 @@ -package assets - -import ( - "crypto/md5" - "embed" - "encoding/hex" - "io" - "io/fs" - "log/slog" - "strconv" - "time" -) - -//go:embed static -var _publicFS embed.FS - -//go:embed templates -var _templateFS embed.FS - -var PublicFS, _ = fs.Sub(_publicFS, "static") -var TemplateFS, _ = fs.Sub(_templateFS, "templates") - -func getFSHash(files fs.FS) string { - hash := md5.New() - - err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - if d.IsDir() { - return nil - } - - file, err := files.Open(path) - - if err != nil { - return err - } - - if _, err := io.Copy(hash, file); err != nil { - return err - } - - return nil - }) - - if err == nil { - return hex.EncodeToString(hash.Sum(nil))[:10] - } - - slog.Warn("Could not compute assets cache", "err", err) - return strconv.FormatInt(time.Now().Unix(), 10) -} - -var PublicFSHash = getFSHash(PublicFS) diff --git a/internal/assets/templates.go b/internal/assets/templates.go deleted file mode 100644 index 3512f0c..0000000 --- a/internal/assets/templates.go +++ /dev/null @@ -1,113 +0,0 @@ -package assets - -import ( - "fmt" - "html/template" - "math" - "strconv" - "time" - - "golang.org/x/text/language" - "golang.org/x/text/message" -) - -var ( - PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") - PageContentTemplate = compileTemplate("content.html") - CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") - ClockTemplate = compileTemplate("clock.html", "widget-base.html") - BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") - IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") - WeatherTemplate = compileTemplate("weather.html", "widget-base.html") - ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html") - RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") - RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") - ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") - ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") - VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") - VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - MarketsTemplate = compileTemplate("markets.html", "widget-base.html") - RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") - RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") - RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") - RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") - MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") - MonitorCompactTemplate = compileTemplate("monitor-compact.html", "widget-base.html") - TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") - TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") - RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") - SearchTemplate = compileTemplate("search.html", "widget-base.html") - ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") - GroupTemplate = compileTemplate("group.html", "widget-base.html") - DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html") - SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html") - CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html") - DockerTemplate = compileTemplate("docker.html", "widget-base.html") -) - -var GlobalTemplateFunctions = template.FuncMap{ - "relativeTime": relativeTimeSince, - "formatViewerCount": formatViewerCount, - "formatNumber": intl.Sprint, - "absInt": func(i int) int { - return int(math.Abs(float64(i))) - }, - "formatPrice": func(price float64) string { - return intl.Sprintf("%.2f", price) - }, - "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { - return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) - }, -} - -func compileTemplate(primary string, dependencies ...string) *template.Template { - t, err := template.New(primary). - Funcs(GlobalTemplateFunctions). - ParseFS(TemplateFS, append([]string{primary}, dependencies...)...) - - if err != nil { - panic(err) - } - - return t -} - -var intl = message.NewPrinter(language.English) - -func formatViewerCount(count int) string { - if count < 1_000 { - return strconv.Itoa(count) - } - - if count < 10_000 { - return fmt.Sprintf("%.1fk", float64(count)/1_000) - } - - if count < 1_000_000 { - return fmt.Sprintf("%dk", count/1_000) - } - - return fmt.Sprintf("%.1fm", float64(count)/1_000_000) -} - -func relativeTimeSince(t time.Time) string { - delta := time.Since(t) - - if delta < time.Minute { - return "1m" - } - if delta < time.Hour { - return fmt.Sprintf("%dm", delta/time.Minute) - } - if delta < 24*time.Hour { - return fmt.Sprintf("%dh", delta/time.Hour) - } - if delta < 30*24*time.Hour { - return fmt.Sprintf("%dd", delta/(24*time.Hour)) - } - if delta < 12*30*24*time.Hour { - return fmt.Sprintf("%dmo", delta/(30*24*time.Hour)) - } - - return fmt.Sprintf("%dy", delta/(365*24*time.Hour)) -} diff --git a/internal/assets/templates/page-style-overrides.gotmpl b/internal/assets/templates/page-style-overrides.gotmpl deleted file mode 100644 index 0bf2a99..0000000 --- a/internal/assets/templates/page-style-overrides.gotmpl +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/internal/feed/adguard.go b/internal/feed/adguard.go deleted file mode 100644 index 87182c3..0000000 --- a/internal/feed/adguard.go +++ /dev/null @@ -1,120 +0,0 @@ -package feed - -import ( - "net/http" - "strings" -) - -type adguardStatsResponse struct { - TotalQueries int `json:"num_dns_queries"` - QueriesSeries []int `json:"dns_queries"` - BlockedQueries int `json:"num_blocked_filtering"` - BlockedSeries []int `json:"blocked_filtering"` - ResponseTime float64 `json:"avg_processing_time"` - TopBlockedDomains []map[string]int `json:"top_blocked_domains"` -} - -func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - request.SetBasicAuth(username, password) - - responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - ResponseTime: int(responseJson.ResponseTime * 1000), - TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount), - } - - if stats.TotalQueries <= 0 { - return stats, nil - } - - stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - - for i := 0; i < topBlockedDomainsCount; i++ { - domain := responseJson.TopBlockedDomains[i] - var firstDomain string - - for k := range domain { - firstDomain = k - break - } - - if firstDomain == "" { - continue - } - - stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{ - Domain: firstDomain, - }) - - if stats.BlockedQueries > 0 { - stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) - } - } - - queriesSeries := responseJson.QueriesSeries - blockedSeries := responseJson.BlockedSeries - - const bars = 8 - const hoursSpan = 24 - const hoursPerBar int = hoursSpan / bars - - if len(queriesSeries) > hoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] - } else if len(queriesSeries) < hoursSpan { - queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) - } - - if len(blockedSeries) > hoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] - } else if len(blockedSeries) < hoursSpan { - blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) - } - - maxQueriesInSeries := 0 - - for i := 0; i < bars; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < hoursPerBar; j++ { - queries += queriesSeries[i*hoursPerBar+j] - blocked += blockedSeries[i*hoursPerBar+j] - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - } - - for i := 0; i < bars; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/calendar.go b/internal/feed/calendar.go deleted file mode 100644 index f7ec5d4..0000000 --- a/internal/feed/calendar.go +++ /dev/null @@ -1,53 +0,0 @@ -package feed - -import "time" - -// TODO: very inflexible, refactor to allow more customizability -// TODO: allow changing first day of week -// TODO: allow changing between showing the previous and next week and the entire month -func NewCalendar(now time.Time) *Calendar { - year, week := now.ISOWeek() - weekday := now.Weekday() - - if weekday == 0 { - weekday = 7 - } - - currentMonthDays := daysInMonth(now.Month(), year) - - var previousMonthDays int - - if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 { - previousMonthDays = daysInMonth(12, year-1) - } else { - previousMonthDays = daysInMonth(previousMonthNumber, year) - } - - startDaysFrom := now.Day() - int(weekday+6) - - days := make([]int, 21) - - for i := 0; i < 21; i++ { - day := startDaysFrom + i - - if day < 1 { - day = previousMonthDays + day - } else if day > currentMonthDays { - day = day - currentMonthDays - } - - days[i] = day - } - - return &Calendar{ - CurrentDay: now.Day(), - CurrentWeekNumber: week, - CurrentMonthName: now.Month().String(), - CurrentYear: year, - Days: days, - } -} - -func daysInMonth(m time.Month, year int) int { - return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() -} diff --git a/internal/feed/codeberg.go b/internal/feed/codeberg.go deleted file mode 100644 index d5e7b7c..0000000 --- a/internal/feed/codeberg.go +++ /dev/null @@ -1,39 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" -) - -type codebergReleaseResponseJson struct { - TagName string `json:"tag_name"` - PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` -} - -func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://codeberg.org/api/v1/repos/%s/releases/latest", - request.Repository, - ), - nil, - ) - if err != nil { - return nil, err - } - - response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - return &AppRelease{ - Source: ReleaseSourceCodeberg, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.HtmlUrl, - TimeReleased: parseRFC3339Time(response.PublishedAt), - }, nil -} diff --git a/internal/feed/custom-api.go b/internal/feed/custom-api.go deleted file mode 100644 index 9a17785..0000000 --- a/internal/feed/custom-api.go +++ /dev/null @@ -1,148 +0,0 @@ -package feed - -import ( - "bytes" - "errors" - "html/template" - "io" - "log/slog" - "net/http" - - "github.com/glanceapp/glance/internal/assets" - "github.com/tidwall/gjson" -) - -func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { - emptyBody := template.HTML("") - - resp, err := defaultClient.Do(req) - if err != nil { - return emptyBody, err - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return emptyBody, err - } - - body := string(bodyBytes) - - if !gjson.Valid(body) { - truncatedBody, isTruncated := limitStringLength(body, 100) - if isTruncated { - truncatedBody += "... " - } - - slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody) - return emptyBody, errors.New("invalid response JSON") - } - - var templateBuffer bytes.Buffer - - data := CustomAPITemplateData{ - JSON: DecoratedGJSONResult{gjson.Parse(body)}, - Response: resp, - } - - err = tmpl.Execute(&templateBuffer, &data) - if err != nil { - return emptyBody, err - } - - return template.HTML(templateBuffer.String()), nil -} - -type DecoratedGJSONResult struct { - gjson.Result -} - -type CustomAPITemplateData struct { - JSON DecoratedGJSONResult - Response *http.Response -} - -func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult { - decoratedResults := make([]DecoratedGJSONResult, len(results)) - - for i, result := range results { - decoratedResults[i] = DecoratedGJSONResult{result} - } - - return decoratedResults -} - -func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult { - if key == "" { - return GJsonResultArrayToDecoratedResultArray(r.Result.Array()) - } - - return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array()) -} - -func (r *DecoratedGJSONResult) String(key string) string { - if key == "" { - return r.Result.String() - } - - return r.Get(key).String() -} - -func (r *DecoratedGJSONResult) Int(key string) int64 { - if key == "" { - return r.Result.Int() - } - - return r.Get(key).Int() -} - -func (r *DecoratedGJSONResult) Float(key string) float64 { - if key == "" { - return r.Result.Float() - } - - return r.Get(key).Float() -} - -func (r *DecoratedGJSONResult) Bool(key string) bool { - if key == "" { - return r.Result.Bool() - } - - return r.Get(key).Bool() -} - -var CustomAPITemplateFuncs = func() template.FuncMap { - funcs := template.FuncMap{ - "toFloat": func(a int64) float64 { - return float64(a) - }, - "toInt": func(a float64) int64 { - return int64(a) - }, - "mathexpr": func(left float64, op string, right float64) float64 { - if right == 0 { - return 0 - } - - switch op { - case "+": - return left + right - case "-": - return left - right - case "*": - return left * right - case "/": - return left / right - default: - return 0 - } - }, - } - - for key, value := range assets.GlobalTemplateFunctions { - funcs[key] = value - } - - return funcs -}() diff --git a/internal/feed/dockerhub.go b/internal/feed/dockerhub.go deleted file mode 100644 index e979d37..0000000 --- a/internal/feed/dockerhub.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "strings" -) - -type dockerHubRepositoryTagsResponse struct { - Results []dockerHubRepositoryTagResponse `json:"results"` -} - -type dockerHubRepositoryTagResponse struct { - Name string `json:"name"` - LastPushed string `json:"tag_last_pushed"` -} - -const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s" -const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s" -const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags" -const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s" - -func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) { - - nameParts := strings.Split(request.Repository, "/") - - if len(nameParts) > 2 { - return nil, fmt.Errorf("invalid repository name: %s", request.Repository) - } else if len(nameParts) == 1 { - nameParts = []string{"library", nameParts[0]} - } - - tagParts := strings.SplitN(nameParts[1], ":", 2) - - var requestURL string - - if len(tagParts) == 2 { - requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1]) - } else { - requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1]) - } - - httpRequest, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) - } - - var tag *dockerHubRepositoryTagResponse - - if len(tagParts) == 1 { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - if len(response.Results) == 0 { - return nil, fmt.Errorf("no tags found for repository: %s", request.Repository) - } - - tag = &response.Results[0] - } else { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - tag = &response - } - - var repo string - var displayName string - var notesURL string - - if len(tagParts) == 1 { - repo = nameParts[1] - } else { - repo = tagParts[0] - } - - if nameParts[0] == "library" { - displayName = repo - notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name) - } else { - displayName = nameParts[0] + "/" + repo - notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name) - } - - return &AppRelease{ - Source: ReleaseSourceDockerHub, - NotesUrl: notesURL, - Name: displayName, - Version: tag.Name, - TimeReleased: parseRFC3339Time(tag.LastPushed), - }, nil -} diff --git a/internal/feed/extension.go b/internal/feed/extension.go deleted file mode 100644 index 916ee78..0000000 --- a/internal/feed/extension.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "html" - "html/template" - "io" - "log/slog" - "net/http" - "net/url" -) - -type ExtensionType int - -const ( - ExtensionContentHTML ExtensionType = iota - ExtensionContentUnknown = iota -) - -var ExtensionStringToType = map[string]ExtensionType{ - "html": ExtensionContentHTML, -} - -const ( - ExtensionHeaderTitle = "Widget-Title" - ExtensionHeaderContentType = "Widget-Content-Type" -) - -type ExtensionRequestOptions struct { - URL string `yaml:"url"` - FallbackContentType string `yaml:"fallback-content-type"` - Parameters map[string]string `yaml:"parameters"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` -} - -type Extension struct { - Title string - Content template.HTML -} - -func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML { - switch contentType { - case ExtensionContentHTML: - if options.AllowHtml { - return template.HTML(content) - } - - fallthrough - default: - return template.HTML(html.EscapeString(string(content))) - } -} - -func FetchExtension(options ExtensionRequestOptions) (Extension, error) { - request, _ := http.NewRequest("GET", options.URL, nil) - - query := url.Values{} - - for key, value := range options.Parameters { - query.Set(key, value) - } - - request.URL.RawQuery = query.Encode() - - response, err := http.DefaultClient.Do(request) - - if err != nil { - slog.Error("failed fetching extension", "error", err, "url", options.URL) - return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - - if err != nil { - slog.Error("failed reading response body of extension", "error", err, "url", options.URL) - return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err) - } - - extension := Extension{} - - if response.Header.Get(ExtensionHeaderTitle) == "" { - extension.Title = "Extension" - } else { - extension.Title = response.Header.Get(ExtensionHeaderTitle) - } - - contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)] - - if !ok { - contentType, ok = ExtensionStringToType[options.FallbackContentType] - - if !ok { - contentType = ExtensionContentUnknown - } - } - - extension.Content = convertExtensionContent(options, body, contentType) - - return extension, nil -} diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go deleted file mode 100644 index 3ff0f00..0000000 --- a/internal/feed/gitlab.go +++ /dev/null @@ -1,48 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "net/url" -) - -type gitlabReleaseResponseJson struct { - TagName string `json:"tag_name"` - ReleasedAt string `json:"released_at"` - Links struct { - Self string `json:"self"` - } `json:"_links"` -} - -func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", - url.QueryEscape(request.Repository), - ), - nil, - ) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token) - } - - response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - return &AppRelease{ - Source: ReleaseSourceGitlab, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.Links.Self, - TimeReleased: parseRFC3339Time(response.ReleasedAt), - }, nil -} diff --git a/internal/feed/hacker-news.go b/internal/feed/hacker-news.go deleted file mode 100644 index f1db111..0000000 --- a/internal/feed/hacker-news.go +++ /dev/null @@ -1,98 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" - "strconv" - "strings" - "time" -) - -type hackerNewsPostResponseJson struct { - Id int `json:"id"` - Score int `json:"score"` - Title string `json:"title"` - TargetUrl string `json:"url,omitempty"` - CommentCount int `json:"descendants"` - TimePosted int64 `json:"time"` -} - -func getHackerNewsPostIds(sort string) ([]int, error) { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) - response, err := decodeJsonFromRequest[[]int](defaultClient, request) - - if err != nil { - return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent) - } - - return response, nil -} - -func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) { - requests := make([]*http.Request, len(postIds)) - - for i, id := range postIds { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil) - requests[i] = request - } - - task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient) - job := newJob(task, requests).withWorkers(30) - results, errs, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - posts := make(ForumPosts, 0, len(postIds)) - - for i := range results { - if errs[i] != nil { - slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL) - continue - } - - var commentsUrl string - - if commentsUrlTemplate == "" { - commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id) - } else { - commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) - } - - posts = append(posts, ForumPost{ - Title: results[i].Title, - DiscussionUrl: commentsUrl, - TargetUrl: results[i].TargetUrl, - TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl), - CommentCount: results[i].CommentCount, - Score: results[i].Score, - TimePosted: time.Unix(results[i].TimePosted, 0), - }) - } - - if len(posts) == 0 { - return nil, ErrNoContent - } - - if len(posts) != len(postIds) { - return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent) - } - - return posts, nil -} - -func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) { - postIds, err := getHackerNewsPostIds(sort) - - if err != nil { - return nil, err - } - - if len(postIds) > limit { - postIds = postIds[:limit] - } - - return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate) -} diff --git a/internal/feed/lobsters.go b/internal/feed/lobsters.go deleted file mode 100644 index 1bb5420..0000000 --- a/internal/feed/lobsters.go +++ /dev/null @@ -1,91 +0,0 @@ -package feed - -import ( - "net/http" - "strings" - "time" -) - -type lobstersPostResponseJson struct { - CreatedAt string `json:"created_at"` - Title string `json:"title"` - URL string `json:"url"` - Score int `json:"score"` - CommentCount int `json:"comment_count"` - CommentsURL string `json:"comments_url"` - Tags []string `json:"tags"` -} - -type lobstersFeedResponseJson []lobstersPostResponseJson - -func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) { - request, err := http.NewRequest("GET", feedUrl, nil) - - if err != nil { - return nil, err - } - - feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) - - if err != nil { - return nil, err - } - - posts := make(ForumPosts, 0, len(feed)) - - for i := range feed { - createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt) - - posts = append(posts, ForumPost{ - Title: feed[i].Title, - DiscussionUrl: feed[i].CommentsURL, - TargetUrl: feed[i].URL, - TargetUrlDomain: extractDomainFromUrl(feed[i].URL), - CommentCount: feed[i].CommentCount, - Score: feed[i].Score, - TimePosted: createdAt, - Tags: feed[i].Tags, - }) - } - - if len(posts) == 0 { - return nil, ErrNoContent - } - - return posts, nil -} - -func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) { - var feedUrl string - - if customURL != "" { - feedUrl = customURL - } else { - if instanceURL != "" { - instanceURL = strings.TrimRight(instanceURL, "/") + "/" - } else { - instanceURL = "https://lobste.rs/" - } - - if sortBy == "hot" { - sortBy = "hottest" - } else if sortBy == "new" { - sortBy = "newest" - } - - if len(tags) == 0 { - feedUrl = instanceURL + sortBy + ".json" - } else { - tags := strings.Join(tags, ",") - feedUrl = instanceURL + "t/" + tags + ".json" - } - } - - posts, err := getLobstersPostsFromFeed(feedUrl) - - if err != nil { - return nil, err - } - - return posts, nil -} diff --git a/internal/feed/monitor.go b/internal/feed/monitor.go deleted file mode 100644 index a3da636..0000000 --- a/internal/feed/monitor.go +++ /dev/null @@ -1,77 +0,0 @@ -package feed - -import ( - "context" - "errors" - "net/http" - "time" -) - -type SiteStatusRequest struct { - URL string `yaml:"url"` - CheckURL string `yaml:"check-url"` - AllowInsecure bool `yaml:"allow-insecure"` -} - -type SiteStatus struct { - Code int - TimedOut bool - ResponseTime time.Duration - Error error -} - -func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { - var url string - if statusRequest.CheckURL != "" { - url = statusRequest.CheckURL - } else { - url = statusRequest.URL - } - request, err := http.NewRequest(http.MethodGet, url, nil) - - if err != nil { - return SiteStatus{ - Error: err, - }, nil - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) - defer cancel() - request = request.WithContext(ctx) - requestSentAt := time.Now() - var response *http.Response - - if !statusRequest.AllowInsecure { - response, err = defaultClient.Do(request) - } else { - response, err = defaultInsecureClient.Do(request) - } - - status := SiteStatus{ResponseTime: time.Since(requestSentAt)} - - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - status.TimedOut = true - } - - status.Error = err - return status, nil - } - - defer response.Body.Close() - - status.Code = response.StatusCode - - return status, nil -} - -func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) { - job := newJob(getSiteStatusTask, requests).withWorkers(20) - results, _, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - return results, nil -} diff --git a/internal/feed/pihole.go b/internal/feed/pihole.go deleted file mode 100644 index 3c7f1b5..0000000 --- a/internal/feed/pihole.go +++ /dev/null @@ -1,136 +0,0 @@ -package feed - -import ( - "encoding/json" - "errors" - "log/slog" - "net/http" - "sort" - "strings" -) - -type piholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries map[int64]int `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` -} - -// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array -// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type piholeTopBlockedDomains map[string]int - -func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { - // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow - // because of the UnmarshalJSON method getting called recursively - temp := make(map[string]int) - - err := json.Unmarshal(data, &temp) - - if err != nil { - *p = make(piholeTopBlockedDomains) - } else { - *p = temp - } - - return nil -} - -func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) { - if token == "" { - return nil, errors.New("missing API token") - } - - requestURL := strings.TrimRight(instanceURL, "/") + - "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - BlockedPercent: int(responseJson.BlockedPercentage), - DomainsBlocked: responseJson.DomainsBlocked, - } - - if len(responseJson.TopBlockedDomains) > 0 { - domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) - - for domain, count := range responseJson.TopBlockedDomains { - domains = append(domains, DNSStatsBlockedDomain{ - Domain: domain, - PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - - // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 - if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { - slog.Warn( - "DNS stats for pihole: did not get expected 144 data points", - "len(queries)", len(responseJson.QueriesSeries), - "len(blocked)", len(responseJson.BlockedSeries), - ) - return stats, nil - } - - var lowestTimestamp int64 = 0 - - for timestamp := range responseJson.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - - maxQueriesInSeries := 0 - - for i := 0; i < 8; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < 18; j++ { - index := lowestTimestamp + int64(i*10800+j*600) - - queries += responseJson.QueriesSeries[index] - blocked += responseJson.BlockedSeries[index] - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - } - - for i := 0; i < 8; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go deleted file mode 100644 index 90a6a52..0000000 --- a/internal/feed/primitives.go +++ /dev/null @@ -1,247 +0,0 @@ -package feed - -import ( - "math" - "sort" - "time" -) - -type ForumPost struct { - Title string - DiscussionUrl string - TargetUrl string - TargetUrlDomain string - ThumbnailUrl string - CommentCount int - Score int - Engagement float64 - TimePosted time.Time - Tags []string - IsCrosspost bool -} - -type ForumPosts []ForumPost - -type Calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -type Weather struct { - Temperature int - ApparentTemperature int - WeatherCode int - CurrentColumn int - SunriseColumn int - SunsetColumn int - Columns []weatherColumn -} - -type AppRelease struct { - Source ReleaseSource - SourceIconURL string - Name string - Version string - NotesUrl string - TimeReleased time.Time - Downvotes int -} - -type AppReleases []AppRelease - -type Video struct { - ThumbnailUrl string - Title string - Url string - Author string - AuthorUrl string - TimePosted time.Time -} - -type Videos []Video - -var currencyToSymbol = map[string]string{ - "USD": "$", - "EUR": "€", - "JPY": "¥", - "CAD": "C$", - "AUD": "A$", - "GBP": "£", - "CHF": "Fr", - "NZD": "N$", - "INR": "₹", - "BRL": "R$", - "RUB": "₽", - "TRY": "₺", - "ZAR": "R", - "CNY": "¥", - "KRW": "₩", - "HKD": "HK$", - "SGD": "S$", - "SEK": "kr", - "NOK": "kr", - "DKK": "kr", - "PLN": "zł", - "PHP": "₱", -} - -type DNSStats struct { - TotalQueries int - BlockedQueries int - BlockedPercent int - ResponseTime int - DomainsBlocked int - Series [8]DNSStatsSeries - TopBlockedDomains []DNSStatsBlockedDomain -} - -type DNSStatsSeries struct { - Queries int - Blocked int - PercentTotal int - PercentBlocked int -} - -type DNSStatsBlockedDomain struct { - Domain string - PercentBlocked int -} - -type MarketRequest struct { - Name string `yaml:"name"` - Symbol string `yaml:"symbol"` - ChartLink string `yaml:"chart-link"` - SymbolLink string `yaml:"symbol-link"` -} - -type Market struct { - MarketRequest - Currency string `yaml:"-"` - Price float64 `yaml:"-"` - PercentChange float64 `yaml:"-"` - SvgChartPoints string `yaml:"-"` -} - -type Markets []Market - -func (t Markets) SortByAbsChange() { - sort.Slice(t, func(i, j int) bool { - return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) - }) -} - -func (t Markets) SortByChange() { - sort.Slice(t, func(i, j int) bool { - return t[i].PercentChange > t[j].PercentChange - }) -} - -var weatherCodeTable = map[int]string{ - 0: "Clear Sky", - 1: "Mainly Clear", - 2: "Partly Cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Rime Fog", - 51: "Drizzle", - 53: "Drizzle", - 55: "Drizzle", - 56: "Drizzle", - 57: "Drizzle", - 61: "Rain", - 63: "Moderate Rain", - 65: "Heavy Rain", - 66: "Freezing Rain", - 67: "Freezing Rain", - 71: "Snow", - 73: "Moderate Snow", - 75: "Heavy Snow", - 77: "Snow Grains", - 80: "Rain", - 81: "Moderate Rain", - 82: "Heavy Rain", - 85: "Snow", - 86: "Snow", - 95: "Thunderstorm", - 96: "Thunderstorm", - 99: "Thunderstorm", -} - -func (w *Weather) WeatherCodeAsString() string { - if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { - return weatherCode - } - - return "" -} - -const depreciatePostsOlderThanHours = 7 -const maxDepreciation = 0.9 -const maxDepreciationAfterHours = 24 - -func (p ForumPosts) CalculateEngagement() { - var totalComments int - var totalScore int - - for i := range p { - totalComments += p[i].CommentCount - totalScore += p[i].Score - } - - numberOfPosts := float64(len(p)) - averageComments := float64(totalComments) / numberOfPosts - averageScore := float64(totalScore) / numberOfPosts - - for i := range p { - p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2 - - elapsed := time.Since(p[i].TimePosted) - - if elapsed < time.Hour*depreciatePostsOlderThanHours { - continue - } - - p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation - } -} - -func (p ForumPosts) SortByEngagement() { - sort.Slice(p, func(i, j int) bool { - return p[i].Engagement > p[j].Engagement - }) -} - -func (s *ForumPost) HasTargetUrl() bool { - return s.TargetUrl != "" -} - -func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost { - recent := make([]ForumPost, 0, len(p)) - - for i := range p { - if time.Since(p[i].TimePosted) < postedBefore { - recent = append(recent, p[i]) - } - } - - return recent -} - -func (r AppReleases) SortByNewest() AppReleases { - sort.Slice(r, func(i, j int) bool { - return r[i].TimeReleased.After(r[j].TimeReleased) - }) - - return r -} - -func (v Videos) SortByNewest() Videos { - sort.Slice(v, func(i, j int) bool { - return v[i].TimePosted.After(v[j].TimePosted) - }) - - return v -} diff --git a/internal/feed/releases.go b/internal/feed/releases.go deleted file mode 100644 index b0cdc25..0000000 --- a/internal/feed/releases.go +++ /dev/null @@ -1,72 +0,0 @@ -package feed - -import ( - "errors" - "fmt" - "log/slog" -) - -type ReleaseSource string - -const ( - ReleaseSourceCodeberg ReleaseSource = "codeberg" - ReleaseSourceGithub ReleaseSource = "github" - ReleaseSourceGitlab ReleaseSource = "gitlab" - ReleaseSourceDockerHub ReleaseSource = "dockerhub" -) - -type ReleaseRequest struct { - Source ReleaseSource - Repository string - Token *string -} - -func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) { - job := newJob(fetchLatestReleaseTask, requests).withWorkers(20) - results, errs, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - var failed int - - releases := make(AppReleases, 0, len(requests)) - - for i := range results { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i]) - continue - } - - releases = append(releases, *results[i]) - } - - if failed == len(requests) { - return nil, ErrNoContent - } - - releases.SortByNewest() - - if failed > 0 { - return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) - } - - return releases, nil -} - -func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) { - switch request.Source { - case ReleaseSourceCodeberg: - return fetchLatestCodebergRelease(request) - case ReleaseSourceGithub: - return fetchLatestGithubRelease(request) - case ReleaseSourceGitlab: - return fetchLatestGitLabRelease(request) - case ReleaseSourceDockerHub: - return fetchLatestDockerHubRelease(request) - } - - return nil, errors.New("unsupported source") -} diff --git a/internal/feed/yahoo.go b/internal/feed/yahoo.go deleted file mode 100644 index f962695..0000000 --- a/internal/feed/yahoo.go +++ /dev/null @@ -1,104 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" -) - -type marketResponseJson struct { - Chart struct { - Result []struct { - Meta struct { - Currency string `json:"currency"` - Symbol string `json:"symbol"` - RegularMarketPrice float64 `json:"regularMarketPrice"` - ChartPreviousClose float64 `json:"chartPreviousClose"` - } `json:"meta"` - Indicators struct { - Quote []struct { - Close []float64 `json:"close,omitempty"` - } `json:"quote"` - } `json:"indicators"` - } `json:"result"` - } `json:"chart"` -} - -// TODO: allow changing chart time frame -const marketChartDays = 21 - -func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) { - requests := make([]*http.Request, 0, len(marketRequests)) - - for i := range marketRequests { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil) - requests = append(requests, request) - } - - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - markets := make(Markets, 0, len(responses)) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i]) - continue - } - - response := responses[i] - - if len(response.Chart.Result) == 0 { - failed++ - slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol) - continue - } - - prices := response.Chart.Result[0].Indicators.Quote[0].Close - - if len(prices) > marketChartDays { - prices = prices[len(prices)-marketChartDays:] - } - - previous := response.Chart.Result[0].Meta.RegularMarketPrice - - if len(prices) >= 2 && prices[len(prices)-2] != 0 { - previous = prices[len(prices)-2] - } - - points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - - currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency] - - if !exists { - currency = response.Chart.Result[0].Meta.Currency - } - - markets = append(markets, Market{ - MarketRequest: marketRequests[i], - Price: response.Chart.Result[0].Meta.RegularMarketPrice, - Currency: currency, - PercentChange: percentChange( - response.Chart.Result[0].Meta.RegularMarketPrice, - previous, - ), - SvgChartPoints: points, - }) - } - - if len(markets) == 0 { - return nil, ErrNoContent - } - - if failed > 0 { - return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed) - } - - return markets, nil -} diff --git a/internal/feed/youtube.go b/internal/feed/youtube.go deleted file mode 100644 index 5016b6b..0000000 --- a/internal/feed/youtube.go +++ /dev/null @@ -1,115 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" - "net/url" - "strings" - "time" -) - -type youtubeFeedResponseXml struct { - Channel string `xml:"author>name"` - ChannelLink string `xml:"author>uri"` - Videos []struct { - Title string `xml:"title"` - Published string `xml:"published"` - Link struct { - Href string `xml:"href,attr"` - } `xml:"link"` - - Group struct { - Thumbnail struct { - Url string `xml:"url,attr"` - } `xml:"http://search.yahoo.com/mrss/ thumbnail"` - } `xml:"http://search.yahoo.com/mrss/ group"` - } `xml:"entry"` -} - -func parseYoutubeFeedTime(t string) time.Time { - parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) - - if err != nil { - return time.Now() - } - - return parsedTime -} - -func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) { - requests := make([]*http.Request, 0, len(channelIds)) - - for i := range channelIds { - var feedUrl string - if !includeShorts && strings.HasPrefix(channelIds[i], "UC") { - playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1) - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId - } else { - feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i] - } - - request, _ := http.NewRequest("GET", feedUrl, nil) - requests = append(requests, request) - } - - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) - - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - videos := make(Videos, 0, len(channelIds)*15) - - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i]) - continue - } - - response := responses[i] - - for j := range response.Videos { - video := &response.Videos[j] - var videoUrl string - - if videoUrlTemplate == "" { - videoUrl = video.Link.Href - } else { - parsedUrl, err := url.Parse(video.Link.Href) - - if err == nil { - videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) - } else { - videoUrl = "#" - } - } - - videos = append(videos, Video{ - ThumbnailUrl: video.Group.Thumbnail.Url, - Title: video.Title, - Url: videoUrl, - Author: response.Channel, - AuthorUrl: response.ChannelLink + "/videos", - TimePosted: parseYoutubeFeedTime(video.Published), - }) - } - } - - if len(videos) == 0 { - return nil, ErrNoContent - } - - videos.SortByNewest() - - if failed > 0 { - return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed) - } - - return videos, nil -} diff --git a/internal/glance/cli.go b/internal/glance/cli.go index 5987368..e231706 100644 --- a/internal/glance/cli.go +++ b/internal/glance/cli.go @@ -2,41 +2,66 @@ package glance import ( "flag" + "fmt" "os" + "strings" ) -type CliIntent uint8 +type cliIntent uint8 const ( - CliIntentServe CliIntent = iota - CliIntentCheckConfig = iota + cliIntentServe cliIntent = iota + cliIntentConfigValidate = iota + cliIntentConfigPrint = iota + cliIntentDiagnose = iota ) -type CliOptions struct { - Intent CliIntent - ConfigPath string +type cliOptions struct { + intent cliIntent + configPath string } -func ParseCliOptions() (*CliOptions, error) { +func parseCliOptions() (*cliOptions, error) { flags := flag.NewFlagSet("", flag.ExitOnError) + flags.Usage = func() { + fmt.Println("Usage: glance [options] command") - checkConfig := flags.Bool("check-config", false, "Check whether the config is valid") + fmt.Println("\nOptions:") + flags.PrintDefaults() + + fmt.Println("\nCommands:") + fmt.Println(" config:validate Validate the config file") + fmt.Println(" config:print Print the parsed config file with embedded includes") + fmt.Println(" diagnose Run diagnostic checks") + } configPath := flags.String("config", "glance.yml", "Set config path") - err := flags.Parse(os.Args[1:]) - if err != nil { return nil, err } - intent := CliIntentServe + var intent cliIntent + var args = flags.Args() + unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " ")) - if *checkConfig { - intent = CliIntentCheckConfig + if len(args) == 0 { + intent = cliIntentServe + } else if len(args) == 1 { + if args[0] == "config:validate" { + intent = cliIntentConfigValidate + } else if args[0] == "config:print" { + intent = cliIntentConfigPrint + } else if args[0] == "diagnose" { + intent = cliIntentDiagnose + } else { + return nil, unknownCommandErr + } + } else { + return nil, unknownCommandErr } - return &CliOptions{ - Intent: intent, - ConfigPath: *configPath, + return &cliOptions{ + intent: intent, + configPath: *configPath, }, nil } diff --git a/internal/widget/fields.go b/internal/glance/config-fields.go similarity index 59% rename from internal/widget/fields.go rename to internal/glance/config-fields.go index 47072bb..130cfce 100644 --- a/internal/widget/fields.go +++ b/internal/glance/config-fields.go @@ -1,4 +1,4 @@ -package widget +package glance import ( "fmt" @@ -12,70 +12,66 @@ import ( "gopkg.in/yaml.v3" ) -var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) -var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`) +var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) const ( - HSLHueMax = 360 - HSLSaturationMax = 100 - HSLLightnessMax = 100 + hslHueMax = 360 + hslSaturationMax = 100 + hslLightnessMax = 100 ) -type HSLColorField struct { +type hslColorField struct { Hue uint16 Saturation uint8 Lightness uint8 } -func (c *HSLColorField) String() string { +func (c *hslColorField) String() string { return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) } -func (c *HSLColorField) AsCSSValue() template.CSS { +func (c *hslColorField) AsCSSValue() template.CSS { return template.CSS(c.String()) } -func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { +func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err } - matches := HSLColorPattern.FindStringSubmatch(value) + matches := hslColorFieldPattern.FindStringSubmatch(value) if len(matches) != 4 { return fmt.Errorf("invalid HSL color format: %s", value) } hue, err := strconv.ParseUint(matches[1], 10, 16) - if err != nil { return err } - if hue > HSLHueMax { - return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax) + if hue > hslHueMax { + return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax) } saturation, err := strconv.ParseUint(matches[2], 10, 8) - if err != nil { return err } - if saturation > HSLSaturationMax { - return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax) + if saturation > hslSaturationMax { + return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax) } lightness, err := strconv.ParseUint(matches[3], 10, 8) - if err != nil { return err } - if lightness > HSLLightnessMax { - return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax) + if lightness > hslLightnessMax { + return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) } c.Hue = uint16(hue) @@ -85,77 +81,76 @@ func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { return nil } -var DurationPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) +var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) -type DurationField time.Duration +type durationField time.Duration -func (d *DurationField) UnmarshalYAML(node *yaml.Node) error { +func (d *durationField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err } - matches := DurationPattern.FindStringSubmatch(value) + matches := durationFieldPattern.FindStringSubmatch(value) if len(matches) != 3 { return fmt.Errorf("invalid duration format: %s", value) } duration, err := strconv.Atoi(matches[1]) - if err != nil { return err } switch matches[2] { case "s": - *d = DurationField(time.Duration(duration) * time.Second) + *d = durationField(time.Duration(duration) * time.Second) case "m": - *d = DurationField(time.Duration(duration) * time.Minute) + *d = durationField(time.Duration(duration) * time.Minute) case "h": - *d = DurationField(time.Duration(duration) * time.Hour) + *d = durationField(time.Duration(duration) * time.Hour) case "d": - *d = DurationField(time.Duration(duration) * 24 * time.Hour) + *d = durationField(time.Duration(duration) * 24 * time.Hour) } return nil } -type OptionalEnvString string +var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`) -func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { +type optionalEnvField string + +func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error { var value string err := node.Decode(&value) - if err != nil { return err } - replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string { + replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string { if err != nil { return "" } - groups := EnvFieldPattern.FindStringSubmatch(whole) + groups := optionalEnvFieldPattern.FindStringSubmatch(match) if len(groups) != 3 { - return whole + return match } prefix, key := groups[1], groups[2] if prefix == `\` { - if len(whole) >= 2 { - return whole[1:] + if len(match) >= 2 { + return match[1:] } else { return "" } } value, found := os.LookupEnv(key) - if !found { err = fmt.Errorf("environment variable %s not found", key) return "" @@ -168,16 +163,16 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { return err } - *f = OptionalEnvString(replaced) + *f = optionalEnvField(replaced) return nil } -func (f *OptionalEnvString) String() string { +func (f *optionalEnvField) String() string { return string(*f) } -type CustomIcon struct { +type customIconField struct { URL string IsFlatIcon bool // TODO: along with whether the icon is flat, we also need to know @@ -185,8 +180,13 @@ type CustomIcon struct { // invert the color based on the theme being light or dark } -func (i *CustomIcon) FromURL(url string) error { - prefix, icon, found := strings.Cut(url, ":") +func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { + var value string + if err := node.Decode(&value); err != nil { + return err + } + + prefix, icon, found := strings.Cut(value, ":") if !found { i.URL = url return nil diff --git a/internal/glance/config.go b/internal/glance/config.go index 131ef7f..0f6b259 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -1,43 +1,91 @@ package glance import ( + "bytes" "fmt" - "io" + "html/template" + "log" + "maps" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + "github.com/fsnotify/fsnotify" "gopkg.in/yaml.v3" ) -type Config struct { - Server Server `yaml:"server"` - Theme Theme `yaml:"theme"` - Branding Branding `yaml:"branding"` - Pages []Page `yaml:"pages"` +type config struct { + Server struct { + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + AssetsPath string `yaml:"assets-path"` + BaseURL string `yaml:"base-url"` + StartedAt time.Time `yaml:"-"` // used in custom css file + } `yaml:"server"` + + Document struct { + Head template.HTML `yaml:"head"` + } `yaml:"document"` + + Theme struct { + BackgroundColor *hslColorField `yaml:"background-color"` + PrimaryColor *hslColorField `yaml:"primary-color"` + PositiveColor *hslColorField `yaml:"positive-color"` + NegativeColor *hslColorField `yaml:"negative-color"` + Light bool `yaml:"light"` + ContrastMultiplier float32 `yaml:"contrast-multiplier"` + TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` + CustomCSSFile string `yaml:"custom-css-file"` + } `yaml:"theme"` + + Branding struct { + HideFooter bool `yaml:"hide-footer"` + CustomFooter template.HTML `yaml:"custom-footer"` + LogoText string `yaml:"logo-text"` + LogoURL string `yaml:"logo-url"` + FaviconURL string `yaml:"favicon-url"` + } `yaml:"branding"` + + Pages []page `yaml:"pages"` } -func NewConfigFromYml(contents io.Reader) (*Config, error) { - config := NewConfig() +type page struct { + Title string `yaml:"name"` + Slug string `yaml:"slug"` + Width string `yaml:"width"` + ShowMobileHeader bool `yaml:"show-mobile-header"` + ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` + HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` + CenterVertically bool `yaml:"center-vertically"` + Columns []struct { + Size string `yaml:"size"` + Widgets widgets `yaml:"widgets"` + } `yaml:"columns"` + PrimaryColumnIndex int8 `yaml:"-"` + mu sync.Mutex `yaml:"-"` +} - contentBytes, err := io.ReadAll(contents) +func newConfigFromYAML(contents []byte) (*config, error) { + config := &config{} + config.Server.Port = 8080 + err := yaml.Unmarshal(contents, config) if err != nil { return nil, err } - err = yaml.Unmarshal(contentBytes, config) - - if err != nil { - return nil, err - } - - if err = configIsValid(config); err != nil { + if err = isConfigStateValid(config); err != nil { return nil, err } for p := range config.Pages { for c := range config.Pages[p].Columns { for w := range config.Pages[p].Columns[c].Widgets { - if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil { - return nil, err + if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil { + return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w]) } } } @@ -46,36 +94,213 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) { return config, nil } -func NewConfig() *Config { - config := &Config{} - - config.Server.Host = "" - config.Server.Port = 8080 - - return config +func formatWidgetInitError(err error, w widget) error { + return fmt.Errorf("%s widget: %v", w.GetType(), err) } -func configIsValid(config *Config) error { +var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) + +func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { + mainFileContents, err := os.ReadFile(mainFilePath) + if err != nil { + return nil, nil, fmt.Errorf("reading main YAML file: %w", err) + } + + mainFileAbsPath, err := filepath.Abs(mainFilePath) + if err != nil { + return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err) + } + mainFileDir := filepath.Dir(mainFileAbsPath) + + includes := make(map[string]struct{}) + var includesLastErr error + + mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { + if includesLastErr != nil { + return nil + } + + matches := includePattern.FindSubmatch(match) + if len(matches) != 3 { + includesLastErr = fmt.Errorf("invalid include match: %v", matches) + return nil + } + + indent := string(matches[1]) + includeFilePath := strings.TrimSpace(string(matches[2])) + if !filepath.IsAbs(includeFilePath) { + includeFilePath = filepath.Join(mainFileDir, includeFilePath) + } + + var fileContents []byte + var err error + + fileContents, err = os.ReadFile(includeFilePath) + if err != nil { + includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err) + return nil + } + + includes[includeFilePath] = struct{}{} + return []byte(prefixStringLines(indent, string(fileContents))) + }) + + if includesLastErr != nil { + return nil, nil, includesLastErr + } + + return mainFileContents, includes, nil +} + +func configFilesWatcher( + mainFilePath string, + lastContents []byte, + lastIncludes map[string]struct{}, + onChange func(newContents []byte), + onErr func(error), +) (func() error, error) { + mainFileAbsPath, err := filepath.Abs(mainFilePath) + if err != nil { + return nil, fmt.Errorf("getting absolute path of main file: %w", err) + } + + // TODO: refactor, flaky + lastIncludes[mainFileAbsPath] = struct{}{} + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("creating watcher: %w", err) + } + + updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) { + for filePath := range previousWatched { + if _, ok := newWatched[filePath]; !ok { + watcher.Remove(filePath) + } + } + + for filePath := range newWatched { + if _, ok := previousWatched[filePath]; !ok { + if err := watcher.Add(filePath); err != nil { + log.Printf( + "Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v", + filePath, err, + ) + } + } + } + } + + updateWatchedFiles(nil, lastIncludes) + + // needed for lastContents and lastIncludes because they get updated in multiple goroutines + mu := sync.Mutex{} + + checkForContentChangesBeforeCallback := func() { + currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath) + if err != nil { + onErr(fmt.Errorf("parsing main file contents for comparison: %w", err)) + return + } + + // TODO: refactor, flaky + currentIncludes[mainFileAbsPath] = struct{}{} + + mu.Lock() + defer mu.Unlock() + + if !maps.Equal(currentIncludes, lastIncludes) { + updateWatchedFiles(lastIncludes, currentIncludes) + lastIncludes = currentIncludes + } + + if !bytes.Equal(lastContents, currentContents) { + lastContents = currentContents + onChange(currentContents) + } + } + + const debounceDuration = 500 * time.Millisecond + var debounceTimer *time.Timer + debouncedCallback := func() { + if debounceTimer != nil { + debounceTimer.Stop() + debounceTimer.Reset(debounceDuration) + } else { + debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback) + } + } + + go func() { + for { + select { + case event, isOpen := <-watcher.Events: + if !isOpen { + return + } + if event.Has(fsnotify.Write) { + debouncedCallback() + } else if event.Has(fsnotify.Remove) { + func() { + mu.Lock() + defer mu.Unlock() + fileAbsPath, _ := filepath.Abs(event.Name) + delete(lastIncludes, fileAbsPath) + }() + + debouncedCallback() + } + case err, isOpen := <-watcher.Errors: + if !isOpen { + return + } + onErr(fmt.Errorf("watcher error: %w", err)) + } + } + }() + + onChange(lastContents) + + return func() error { + if debounceTimer != nil { + debounceTimer.Stop() + } + + return watcher.Close() + }, nil +} + +func isConfigStateValid(config *config) error { + if len(config.Pages) == 0 { + return fmt.Errorf("no pages configured") + } + + if config.Server.AssetsPath != "" { + if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) { + return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath) + } + } + for i := range config.Pages { if config.Pages[i].Title == "" { - return fmt.Errorf("Page %d has no title", i+1) + return fmt.Errorf("page %d has no name", i+1) } if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { - return fmt.Errorf("Page %d: width can only be either wide or slim", i+1) + return fmt.Errorf("page %d: width can only be either wide or slim", i+1) } if len(config.Pages[i].Columns) == 0 { - return fmt.Errorf("Page %d has no columns", i+1) + return fmt.Errorf("page %d has no columns", i+1) } if config.Pages[i].Width == "slim" { if len(config.Pages[i].Columns) > 2 { - return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1) + return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1) } } else { if len(config.Pages[i].Columns) > 3 { - return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) + return fmt.Errorf("page %d has more than 3 columns", i+1) } } @@ -83,7 +308,7 @@ func configIsValid(config *Config) error { for j := range config.Pages[i].Columns { if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { - return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1) + return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1) } columnSizesCount[config.Pages[i].Columns[j].Size]++ @@ -92,7 +317,7 @@ func configIsValid(config *Config) error { full := columnSizesCount["full"] if full > 2 || full == 0 { - return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1) + return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1) } } diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go new file mode 100644 index 0000000..892aa5f --- /dev/null +++ b/internal/glance/diagnose.go @@ -0,0 +1,205 @@ +package glance + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "runtime" + "strings" + "sync" + "time" +) + +const httpTestRequestTimeout = 10 * time.Second + +var diagnosticSteps = []diagnosticStep{ + { + name: "resolve cloudflare.com through Cloudflare DoH", + fn: func() (string, error) { + return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{ + "accept": "application/dns-json", + }, 200) + }, + }, + { + name: "resolve cloudflare.com through Google DoH", + fn: func() (string, error) { + return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200) + }, + }, + { + name: "resolve github.com", + fn: func() (string, error) { + return testDNSResolution("github.com") + }, + }, + { + name: "resolve reddit.com", + fn: func() (string, error) { + return testDNSResolution("reddit.com") + }, + }, + { + name: "resolve twitch.tv", + fn: func() (string, error) { + return testDNSResolution("twitch.tv") + }, + }, + { + name: "fetch data from YouTube RSS feed", + fn: func() (string, error) { + return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200) + }, + }, + { + name: "fetch data from Twitch.tv GQL", + fn: func() (string, error) { + // this should always return 0 bytes, we're mainly looking for a 200 status code + return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200) + }, + }, + { + name: "fetch data from GitHub API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://api.github.com", 200) + }, + }, + { + name: "fetch data from Open-Meteo API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200) + }, + }, + { + name: "fetch data from Reddit API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://www.reddit.com/search.json", 200) + }, + }, + { + name: "fetch data from Yahoo finance API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200) + }, + }, + { + name: "fetch data from Hacker News Firebase API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200) + }, + }, + { + name: "fetch data from Docker Hub API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200) + }, + }, +} + +func runDiagnostic() { + fmt.Println("```") + fmt.Println("Glance version: " + buildVersion) + fmt.Println("Go version: " + runtime.Version()) + fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) + fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no")) + + fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds())) + + var wg sync.WaitGroup + for i := range diagnosticSteps { + step := &diagnosticSteps[i] + wg.Add(1) + go func() { + defer wg.Done() + start := time.Now() + step.extraInfo, step.err = step.fn() + step.elapsed = time.Since(start) + }() + } + wg.Wait() + + for _, step := range diagnosticSteps { + var extraInfo string + + if step.extraInfo != "" { + extraInfo = "| " + step.extraInfo + " " + } + + fmt.Printf( + "%s %s %s| %dms\n", + boolToString(step.err == nil, "✓ Can", "✗ Can't"), + step.name, + extraInfo, + step.elapsed.Milliseconds(), + ) + + if step.err != nil { + fmt.Printf("└╴ error: %v\n", step.err) + } + } + fmt.Println("```") +} + +type diagnosticStep struct { + name string + fn func() (string, error) + extraInfo string + err error + elapsed time.Duration +} + +func testHttpRequest(method, url string, expectedStatusCode int) (string, error) { + return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode) +} + +func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout) + defer cancel() + + request, _ := http.NewRequestWithContext(ctx, method, url, nil) + for key, value := range headers { + request.Header.Add(key, value) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + printableBody := strings.ReplaceAll(string(body), "\n", "") + if len(printableBody) > 50 { + printableBody = printableBody[:50] + "..." + } + if len(printableBody) > 0 { + printableBody = ", " + printableBody + } + + extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody) + + if response.StatusCode != expectedStatusCode { + return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode) + } + + return extraInfo, nil +} + +func testDNSResolution(domain string) (string, error) { + ips, err := net.LookupIP(domain) + + var ipStrings []string + if err == nil { + for i := range ips { + ipStrings = append(ipStrings, ips[i].String()) + } + } + + return strings.Join(ipStrings, ", "), err +} diff --git a/internal/glance/embed.go b/internal/glance/embed.go new file mode 100644 index 0000000..7bb07c9 --- /dev/null +++ b/internal/glance/embed.go @@ -0,0 +1,62 @@ +package glance + +import ( + "crypto/md5" + "embed" + "encoding/hex" + "io" + "io/fs" + "log" + "strconv" + "time" +) + +//go:embed static +var _staticFS embed.FS + +//go:embed templates +var _templateFS embed.FS + +var staticFS, _ = fs.Sub(_staticFS, "static") +var templateFS, _ = fs.Sub(_templateFS, "templates") + +var staticFSHash = func() string { + hash, err := computeFSHash(staticFS) + if err != nil { + log.Printf("Could not compute static assets cache key: %v", err) + return strconv.FormatInt(time.Now().Unix(), 10) + } + + return hash +}() + +func computeFSHash(files fs.FS) (string, error) { + hash := md5.New() + + err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + file, err := files.Open(path) + if err != nil { + return err + } + + if _, err := io.Copy(hash, file); err != nil { + return err + } + + return nil + }) + + if err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil))[:10], nil +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 1ef654a..daa920c 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -5,140 +5,48 @@ import ( "context" "fmt" "html/template" - "log/slog" + "log" "net/http" "path/filepath" - "regexp" "strconv" "strings" "sync" "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/widget" ) -var buildVersion = "dev" +var ( + pageTemplate = mustParseTemplate("page.html", "document.html") + pageContentTemplate = mustParseTemplate("page-content.html") + pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") +) -var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) +type application struct { + Version string + Config config + ParsedThemeStyle template.HTML -type Application struct { - Version string - Config Config - slugToPage map[string]*Page - widgetByID map[uint64]widget.Widget + slugToPage map[string]*page + widgetByID map[uint64]widget } -type Theme struct { - BackgroundColor *widget.HSLColorField `yaml:"background-color"` - PrimaryColor *widget.HSLColorField `yaml:"primary-color"` - PositiveColor *widget.HSLColorField `yaml:"positive-color"` - NegativeColor *widget.HSLColorField `yaml:"negative-color"` - Light bool `yaml:"light"` - ContrastMultiplier float32 `yaml:"contrast-multiplier"` - TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` - CustomCSSFile string `yaml:"custom-css-file"` -} - -type Server struct { - Host string `yaml:"host"` - Port uint16 `yaml:"port"` - AssetsPath string `yaml:"assets-path"` - BaseURL string `yaml:"base-url"` - AssetsHash string `yaml:"-"` - StartedAt time.Time `yaml:"-"` // used in custom css file -} - -type Branding struct { - HideFooter bool `yaml:"hide-footer"` - CustomFooter template.HTML `yaml:"custom-footer"` - LogoText string `yaml:"logo-text"` - LogoURL string `yaml:"logo-url"` - FaviconURL string `yaml:"favicon-url"` -} - -type Column struct { - Size string `yaml:"size"` - Widgets widget.Widgets `yaml:"widgets"` -} - -type templateData struct { - App *Application - Page *Page -} - -type Page struct { - Title string `yaml:"name"` - Slug string `yaml:"slug"` - Width string `yaml:"width"` - ShowMobileHeader bool `yaml:"show-mobile-header"` - ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` - HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` - CenterVertically bool `yaml:"center-vertically"` - Columns []Column `yaml:"columns"` - PrimaryColumnIndex int8 `yaml:"-"` - mu sync.Mutex -} - -func (p *Page) UpdateOutdatedWidgets() { - now := time.Now() - - var wg sync.WaitGroup - context := context.Background() - - for c := range p.Columns { - for w := range p.Columns[c].Widgets { - widget := p.Columns[c].Widgets[w] - - if !widget.RequiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.Update(context) - }() - } - } - - wg.Wait() -} - -// TODO: fix, currently very simple, lots of uncovered edge cases -func titleToSlug(s string) string { - s = strings.ToLower(s) - s = sequentialWhitespacePattern.ReplaceAllString(s, "-") - s = strings.Trim(s, "-") - - return s -} - -func (a *Application) TransformUserDefinedAssetPath(path string) string { - if strings.HasPrefix(path, "/assets/") { - return a.Config.Server.BaseURL + path - } - - return path -} - -func NewApplication(config *Config) (*Application, error) { - if len(config.Pages) == 0 { - return nil, fmt.Errorf("no pages configured") - } - - app := &Application{ +func newApplication(config *config) (*application, error) { + app := &application{ Version: buildVersion, Config: *config, - slugToPage: make(map[string]*Page), - widgetByID: make(map[uint64]widget.Widget), + slugToPage: make(map[string]*page), + widgetByID: make(map[uint64]widget), } - app.Config.Server.AssetsHash = assets.PublicFSHash app.slugToPage[""] = &config.Pages[0] - providers := &widget.Providers{ - AssetResolver: app.AssetPath, + providers := &widgetProviders{ + assetResolver: app.AssetPath, + } + + var err error + app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme) + if err != nil { + return nil, fmt.Errorf("parsing theme style: %v", err) } for p := range config.Pages { @@ -160,9 +68,9 @@ func NewApplication(config *Config) (*Application, error) { for w := range column.Widgets { widget := column.Widgets[w] - app.widgetByID[widget.GetID()] = widget + app.widgetByID[widget.id()] = widget - widget.SetProviders(providers) + widget.setProviders(providers) } } } @@ -170,35 +78,75 @@ func NewApplication(config *Config) (*Application, error) { config = &app.Config config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") - config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile) + config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile) if config.Branding.FaviconURL == "" { config.Branding.FaviconURL = app.AssetPath("favicon.png") } else { - config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL) + config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL) } - config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) + config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL) return app, nil } -func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { +func (p *page) updateOutdatedWidgets() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + + var wg sync.WaitGroup + context := context.Background() + + for c := range p.Columns { + for w := range p.Columns[c].Widgets { + widget := p.Columns[c].Widgets[w] + + if !widget.requiresUpdate(&now) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(context) + }() + } + } + + wg.Wait() +} + +func (a *application) transformUserDefinedAssetPath(path string) string { + if strings.HasPrefix(path, "/assets/") { + return a.Config.Server.BaseURL + path + } + + return path +} + +type pageTemplateData struct { + App *application + Page *page +} + +func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, App: a, } var responseBytes bytes.Buffer - err := assets.PageTemplate.Execute(&responseBytes, pageData) - + err := pageTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -208,25 +156,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) w.Write(responseBytes.Bytes()) } -func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, } - page.mu.Lock() - defer page.mu.Unlock() - page.UpdateOutdatedWidgets() + page.updateOutdatedWidgets() var responseBytes bytes.Buffer - err := assets.PageContentTemplate.Execute(&responseBytes, pageData) - + err := pageContentTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -236,74 +181,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re w.Write(responseBytes.Bytes()) } -func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) { +func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) w.Write([]byte("Page not found")) } -func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { - server := http.FileServer(fs) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: fix always setting cache control even if the file doesn't exist - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))) - server.ServeHTTP(w, r) - }) -} - -func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) - if err != nil { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } widget, exists := a.widgetByID[widgetID] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - widget.HandleRequest(w, r) + widget.handleRequest(w, r) } -func (a *Application) AssetPath(asset string) string { - return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset +func (a *application) AssetPath(asset string) string { + return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } -func (a *Application) Serve() error { +func (a *application) server() (func() error, func() error) { // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() - mux.HandleFunc("GET /{$}", a.HandlePageRequest) - mux.HandleFunc("GET /{page}", a.HandlePageRequest) + mux.HandleFunc("GET /{$}", a.handlePageRequest) + mux.HandleFunc("GET /{page}", a.handlePageRequest) - mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) - mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest) + mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest) + mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest) mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.Handle( - fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), - http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), + fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), + http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)), ) + var absAssetsPath string if a.Config.Server.AssetsPath != "" { - absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) - - if err != nil { - return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath) - } - - slog.Info("Serving assets", "path", absAssetsPath) - assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) + absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) + assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) } @@ -312,8 +241,25 @@ func (a *Application) Serve() error { Handler: mux, } - a.Config.Server.StartedAt = time.Now() - slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL) + start := func() error { + a.Config.Server.StartedAt = time.Now() + log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", + a.Config.Server.Host, + a.Config.Server.Port, + a.Config.Server.BaseURL, + absAssetsPath, + ) - return server.ListenAndServe() + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil + } + + stop := func() error { + return server.Close() + } + + return start, stop } diff --git a/internal/glance/main.go b/internal/glance/main.go index 426c41f..35211a9 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -2,45 +2,174 @@ package glance import ( "fmt" + "io" + "log" + "net/http" "os" ) -func Main() int { - options, err := ParseCliOptions() +var buildVersion = "dev" +func Main() int { + options, err := parseCliOptions() if err != nil { fmt.Println(err) return 1 } - configFile, err := os.Open(options.ConfigPath) - - if err != nil { - fmt.Printf("failed opening config file: %v\n", err) - return 1 - } - - config, err := NewConfigFromYml(configFile) - configFile.Close() - - if err != nil { - fmt.Printf("failed parsing config file: %v\n", err) - return 1 - } - - if options.Intent == CliIntentServe { - app, err := NewApplication(config) + switch options.intent { + case cliIntentServe: + // remove in v0.10.0 + if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) { + return 1 + } + if err := serveApp(options.configPath); err != nil { + fmt.Println(err) + return 1 + } + case cliIntentConfigValidate: + contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed creating application: %v\n", err) + fmt.Printf("Could not parse config file: %v\n", err) return 1 } - if err := app.Serve(); err != nil { - fmt.Printf("http server error: %v\n", err) + if _, err := newConfigFromYAML(contents); err != nil { + fmt.Printf("Config file is invalid: %v\n", err) return 1 } + case cliIntentConfigPrint: + contents, _, err := parseYAMLIncludes(options.configPath) + if err != nil { + fmt.Printf("Could not parse config file: %v\n", err) + return 1 + } + + fmt.Println(string(contents)) + case cliIntentDiagnose: + runDiagnostic() } return 0 } + +func serveApp(configPath string) error { + exitChannel := make(chan struct{}) + // the onChange method gets called at most once per 500ms due to debouncing so we shouldn't + // need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason + hadValidConfigOnStartup := false + var stopServer func() error + + onChange := func(newContents []byte) { + if stopServer != nil { + log.Println("Config file changed, reloading...") + } + + config, err := newConfigFromYAML(newContents) + if err != nil { + log.Printf("Config has errors: %v", err) + + if !hadValidConfigOnStartup { + close(exitChannel) + } + + return + } else if !hadValidConfigOnStartup { + hadValidConfigOnStartup = true + } + + app, err := newApplication(config) + if err != nil { + log.Printf("Failed to create application: %v", err) + return + } + + if stopServer != nil { + if err := stopServer(); err != nil { + log.Printf("Error while trying to stop server: %v", err) + } + } + + go func() { + var startServer func() error + startServer, stopServer = app.server() + + if err := startServer(); err != nil { + log.Printf("Failed to start server: %v", err) + } + }() + } + + onErr := func(err error) { + log.Printf("Error watching config files: %v", err) + } + + configContents, configIncludes, err := parseYAMLIncludes(configPath) + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr) + if err == nil { + defer stopWatching() + } else { + log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err) + + config, err := newConfigFromYAML(configContents) + if err != nil { + return fmt.Errorf("validating config file: %w", err) + } + + app, err := newApplication(config) + if err != nil { + return fmt.Errorf("creating application: %w", err) + } + + startServer, _ := app.server() + if err := startServer(); err != nil { + return fmt.Errorf("starting server: %w", err) + } + } + + <-exitChannel + return nil +} + +func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { + if !isRunningInsideDockerContainer() { + return false + } + + if _, err := os.Stat(configPath); err == nil { + return false + } + + // glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory + if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() { + return false + } + + templateFile, _ := templateFS.Open("v0.7-update-notice-page.html") + bodyContents, _ := io.ReadAll(templateFile) + + // TODO: update - add link + fmt.Println("!!! WARNING !!!") + fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see for more information.") + + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(bodyContents)) + }) + + server := http.Server{ + Addr: ":8080", + Handler: mux, + } + server.ListenAndServe() + + return true +} diff --git a/internal/assets/static/app-icon.png b/internal/glance/static/app-icon.png similarity index 100% rename from internal/assets/static/app-icon.png rename to internal/glance/static/app-icon.png diff --git a/internal/assets/static/favicon.png b/internal/glance/static/favicon.png similarity index 100% rename from internal/assets/static/favicon.png rename to internal/glance/static/favicon.png diff --git a/internal/assets/static/fonts/JetBrainsMono-Regular.woff2 b/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 similarity index 100% rename from internal/assets/static/fonts/JetBrainsMono-Regular.woff2 rename to internal/glance/static/fonts/JetBrainsMono-Regular.woff2 diff --git a/internal/assets/static/icons/codeberg.svg b/internal/glance/static/icons/codeberg.svg similarity index 100% rename from internal/assets/static/icons/codeberg.svg rename to internal/glance/static/icons/codeberg.svg diff --git a/internal/assets/static/icons/dockerhub.svg b/internal/glance/static/icons/dockerhub.svg similarity index 100% rename from internal/assets/static/icons/dockerhub.svg rename to internal/glance/static/icons/dockerhub.svg diff --git a/internal/assets/static/icons/github.svg b/internal/glance/static/icons/github.svg similarity index 100% rename from internal/assets/static/icons/github.svg rename to internal/glance/static/icons/github.svg diff --git a/internal/assets/static/icons/gitlab.svg b/internal/glance/static/icons/gitlab.svg similarity index 100% rename from internal/assets/static/icons/gitlab.svg rename to internal/glance/static/icons/gitlab.svg diff --git a/internal/assets/static/js/main.js b/internal/glance/static/js/main.js similarity index 100% rename from internal/assets/static/js/main.js rename to internal/glance/static/js/main.js diff --git a/internal/assets/static/js/masonry.js b/internal/glance/static/js/masonry.js similarity index 100% rename from internal/assets/static/js/masonry.js rename to internal/glance/static/js/masonry.js diff --git a/internal/assets/static/js/popover.js b/internal/glance/static/js/popover.js similarity index 100% rename from internal/assets/static/js/popover.js rename to internal/glance/static/js/popover.js diff --git a/internal/assets/static/js/utils.js b/internal/glance/static/js/utils.js similarity index 83% rename from internal/assets/static/js/utils.js rename to internal/glance/static/js/utils.js index 5f5b2c7..1d1816a 100644 --- a/internal/assets/static/js/utils.js +++ b/internal/glance/static/js/utils.js @@ -28,6 +28,9 @@ export function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } +// NOTE: inconsistent behavior between browsers when it comes to +// whether the newly opened tab gets focused or not, potentially +// depending on the event that this function is called from export function openURLInNewTab(url, focus = true) { const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); diff --git a/internal/assets/static/main.css b/internal/glance/static/main.css similarity index 99% rename from internal/assets/static/main.css rename to internal/glance/static/main.css index 1207cfa..6917466 100644 --- a/internal/assets/static/main.css +++ b/internal/glance/static/main.css @@ -525,6 +525,7 @@ kbd:active { list-style: none; position: relative; display: flex; + z-index: 1; } .details[open] .summary { @@ -546,6 +547,10 @@ kbd:active { opacity: 1; } +.details:not([open]) .list-with-transition { + display: none; +} + .summary::after { content: "◀"; font-size: 1.2em; @@ -1106,7 +1111,6 @@ details[open] .summary::after { .dns-stats-graph-gridlines-container { position: absolute; - z-index: -1; inset: 0; } @@ -1133,7 +1137,6 @@ details[open] .summary::after { content: ''; position: absolute; inset: 1px 0; - z-index: -1; opacity: 0; background: var(--color-text-base); transition: opacity .2s; @@ -1275,7 +1278,6 @@ details[open] .summary::after { overflow: hidden; mask-image: linear-gradient(0deg, transparent 40%, #000); -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000); - z-index: -1; } .weather-column-rain::before { diff --git a/internal/assets/static/manifest.json b/internal/glance/static/manifest.json similarity index 100% rename from internal/assets/static/manifest.json rename to internal/glance/static/manifest.json diff --git a/internal/glance/templates.go b/internal/glance/templates.go new file mode 100644 index 0000000..f3e6158 --- /dev/null +++ b/internal/glance/templates.go @@ -0,0 +1,56 @@ +package glance + +import ( + "fmt" + "html/template" + "math" + "strconv" + "time" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var intl = message.NewPrinter(language.English) + +var globalTemplateFunctions = template.FuncMap{ + "formatViewerCount": formatViewerCount, + "formatNumber": intl.Sprint, + "absInt": func(i int) int { + return int(math.Abs(float64(i))) + }, + "formatPrice": func(price float64) string { + return intl.Sprintf("%.2f", price) + }, + "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { + return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) + }, +} + +func mustParseTemplate(primary string, dependencies ...string) *template.Template { + t, err := template.New(primary). + Funcs(globalTemplateFunctions). + ParseFS(templateFS, append([]string{primary}, dependencies...)...) + + if err != nil { + panic(err) + } + + return t +} + +func formatViewerCount(count int) string { + if count < 1_000 { + return strconv.Itoa(count) + } + + if count < 10_000 { + return fmt.Sprintf("%.1fk", float64(count)/1_000) + } + + if count < 1_000_000 { + return fmt.Sprintf("%dk", count/1_000) + } + + return fmt.Sprintf("%.1fm", float64(count)/1_000_000) +} diff --git a/internal/assets/templates/bookmarks.html b/internal/glance/templates/bookmarks.html similarity index 100% rename from internal/assets/templates/bookmarks.html rename to internal/glance/templates/bookmarks.html diff --git a/internal/assets/templates/calendar.html b/internal/glance/templates/calendar.html similarity index 83% rename from internal/assets/templates/calendar.html rename to internal/glance/templates/calendar.html index af15e5a..020d6ac 100644 --- a/internal/assets/templates/calendar.html +++ b/internal/glance/templates/calendar.html @@ -11,13 +11,18 @@
+ {{ if .StartSunday }} +
Su
+ {{ end }}
Mo
Tu
We
Th
Fr
Sa
-
Su
+ {{ if not .StartSunday }} +
Su
+ {{ end }}
diff --git a/internal/assets/templates/change-detection.html b/internal/glance/templates/change-detection.html similarity index 100% rename from internal/assets/templates/change-detection.html rename to internal/glance/templates/change-detection.html diff --git a/internal/assets/templates/clock.html b/internal/glance/templates/clock.html similarity index 100% rename from internal/assets/templates/clock.html rename to internal/glance/templates/clock.html diff --git a/internal/assets/templates/custom-api.html b/internal/glance/templates/custom-api.html similarity index 100% rename from internal/assets/templates/custom-api.html rename to internal/glance/templates/custom-api.html diff --git a/internal/assets/templates/dns-stats.html b/internal/glance/templates/dns-stats.html similarity index 98% rename from internal/assets/templates/dns-stats.html rename to internal/glance/templates/dns-stats.html index 5d83508..8447ce1 100644 --- a/internal/assets/templates/dns-stats.html +++ b/internal/glance/templates/dns-stats.html @@ -73,7 +73,7 @@ Top blocked domains diff --git a/internal/assets/templates/group.html b/internal/glance/templates/group.html similarity index 100% rename from internal/assets/templates/group.html rename to internal/glance/templates/group.html diff --git a/internal/assets/templates/iframe.html b/internal/glance/templates/iframe.html similarity index 100% rename from internal/assets/templates/iframe.html rename to internal/glance/templates/iframe.html diff --git a/internal/assets/templates/markets.html b/internal/glance/templates/markets.html similarity index 100% rename from internal/assets/templates/markets.html rename to internal/glance/templates/markets.html diff --git a/internal/assets/templates/monitor-compact.html b/internal/glance/templates/monitor-compact.html similarity index 100% rename from internal/assets/templates/monitor-compact.html rename to internal/glance/templates/monitor-compact.html diff --git a/internal/assets/templates/monitor.html b/internal/glance/templates/monitor.html similarity index 100% rename from internal/assets/templates/monitor.html rename to internal/glance/templates/monitor.html diff --git a/internal/assets/templates/content.html b/internal/glance/templates/page-content.html similarity index 100% rename from internal/assets/templates/content.html rename to internal/glance/templates/page-content.html diff --git a/internal/assets/templates/page.html b/internal/glance/templates/page.html similarity index 97% rename from internal/assets/templates/page.html rename to internal/glance/templates/page.html index e452dde..2a0c776 100644 --- a/internal/assets/templates/page.html +++ b/internal/glance/templates/page.html @@ -14,10 +14,13 @@ {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }} {{ define "document-head-after" }} -{{ template "page-style-overrides.gotmpl" . }} +{{ .App.ParsedThemeStyle }} + {{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ end }} + +{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ end }} {{ define "navigation-links" }} diff --git a/internal/assets/templates/reddit-horizontal-cards.html b/internal/glance/templates/reddit-horizontal-cards.html similarity index 100% rename from internal/assets/templates/reddit-horizontal-cards.html rename to internal/glance/templates/reddit-horizontal-cards.html diff --git a/internal/assets/templates/reddit-vertical-cards.html b/internal/glance/templates/reddit-vertical-cards.html similarity index 100% rename from internal/assets/templates/reddit-vertical-cards.html rename to internal/glance/templates/reddit-vertical-cards.html diff --git a/internal/assets/templates/releases.html b/internal/glance/templates/releases.html similarity index 88% rename from internal/assets/templates/releases.html rename to internal/glance/templates/releases.html index 7cd89f7..3643524 100644 --- a/internal/assets/templates/releases.html +++ b/internal/glance/templates/releases.html @@ -7,7 +7,7 @@
{{ .Name }} {{ if $.ShowSourceIcon }} - + {{ end }}