diff --git a/docs/configuration.md b/docs/configuration.md index 618b1b3..a09e5dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,6 +3,7 @@ - [Intro](#intro) - [Preconfigured page](#preconfigured-page) - [Server](#server) +- [Branding](#branding) - [Theme](#theme) - [Themes](#themes) - [Pages & Columns](#pages--columns) @@ -174,6 +175,42 @@ To be able to point to an asset from your assets path, use the `/assets/` path l icon: /assets/gitea-icon.png ``` +## Branding +You can adjust the various parts of the branding through a top level `branding` property. Example: + +```yaml +branding: + custom-footer: | +

Powered by Glance

+ logo-url: /assets/logo.png + favicon-url: /assets/logo.png +``` + +### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| hide-footer | bool | no | false | +| custom-footer | string | no | | +| logo-text | string | no | G | +| logo-url | string | no | | +| favicon-url | string | no | | + +#### `hide-footer` +Hides the footer when set to `true`. + +#### `custom-footer` +Specify custom HTML to use for the footer. + +#### `logo-text` +Specify custom text to use instead of the "G" found in the navigation. + +#### `logo-url` +Specify a URL to a custom image to use instead of the "G" found in the navigation. If both `logo-text` and `logo-url` are set, only `logo-url` will be used. + +#### `favicon-url` +Specify a URL to a custom image to use for the favicon. + ## Theme Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers. @@ -1063,17 +1100,19 @@ Whether to ignore invalid/self-signed certificates. Whether to open the link in the same or a new tab. ### Releases -Display a list of releases for specific repositories on Github. Draft releases and prereleases will not be shown. +Display a list of latest releases for specific repositories on Github, GitLab or Docker Hub. Example: ```yaml - type: releases + show-source-icon: true repositories: - - immich-app/immich - go-gitea/gitea - - dani-garcia/vaultwarden - jellyfin/jellyfin + - glanceapp/glance + - gitlab:fdroid/fdroidclient + - dockerhub:gotify/server ``` Preview: @@ -1085,12 +1124,41 @@ Preview: | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | repositories | array | yes | | +| show-source-icon | boolean | no | false | | | token | string | no | | +| gitlab-token | string | no | | | limit | integer | no | 10 | | collapse-after | integer | no | 5 | ##### `repositories` -A list of repositores for which to fetch the latest release for. Only the name/repo is required, not the full URL. +A list of repositores to fetch the latest release for. Only the name/repo is required, not the full URL. A prefix can be specified for repositories hosted elsewhere such as GitLab and Docker Hub. Example: + +```yaml +repositories: + - gitlab:inkscape/inkscape + - dockerhub:glanceapp/glance +``` + +Official images on Docker Hub can be specified by ommiting the owner: + +```yaml +repositories: + - dockerhub:nginx + - dockerhub:node + - dockerhub:alpine +``` + +You can also specify specific tags for Docker Hub images: + +```yaml +repositories: + - dockerhub:nginx:latest + - dockerhub:nginx:stable-alpine +``` + + +##### `show-source-icon` +Shows an icon of the source (GitHub/GitLab/Docker Hub) next to the repository name when set to `true`. ##### `token` Without authentication Github allows for up to 60 requests per hour. You can easily exceed this limit and start seeing errors if you're tracking lots of repositories or your cache time is low. To circumvent this you can [create a read only token from your Github account](https://github.com/settings/personal-access-tokens/new) and provide it here. @@ -1115,6 +1183,9 @@ and then use it in your `glance.yml` like this: This way you can safely check your `glance.yml` in version control without exposing the token. +##### `gitlab-token` +Same as the above but used when fetching GitLab releases. + ##### `limit` The maximum number of releases to show. @@ -1181,6 +1252,7 @@ Example: repository: glanceapp/glance pull-requests-limit: 5 issues-limit: 3 + commits-limit: 3 ``` Preview: @@ -1195,6 +1267,7 @@ Preview: | token | string | no | | | pull-requests-limit | integer | no | 3 | | issues-limit | integer | no | 3 | +| commits-limit | integer | no | -1 | ##### `repository` The owner and repository name that will have their information displayed. @@ -1208,6 +1281,9 @@ The maximum number of latest open pull requests to show. Set to `-1` to not show ##### `issues-limit` The maximum number of latest open issues to show. Set to `-1` to not show any. +##### `commits-limit` +The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any. + ### Bookmarks Display a list of links which can be grouped. diff --git a/docs/images/releases-widget-preview.png b/docs/images/releases-widget-preview.png index 47acfd0..ec712bb 100644 Binary files a/docs/images/releases-widget-preview.png and b/docs/images/releases-widget-preview.png differ diff --git a/internal/assets/static/icons/dockerhub.svg b/internal/assets/static/icons/dockerhub.svg new file mode 100644 index 0000000..8669c00 --- /dev/null +++ b/internal/assets/static/icons/dockerhub.svg @@ -0,0 +1 @@ + diff --git a/internal/assets/static/icons/github.svg b/internal/assets/static/icons/github.svg new file mode 100644 index 0000000..6cf48c8 --- /dev/null +++ b/internal/assets/static/icons/github.svg @@ -0,0 +1 @@ + diff --git a/internal/assets/static/icons/gitlab.svg b/internal/assets/static/icons/gitlab.svg new file mode 100644 index 0000000..42e4c97 --- /dev/null +++ b/internal/assets/static/icons/gitlab.svg @@ -0,0 +1 @@ + diff --git a/internal/assets/static/main.css b/internal/assets/static/main.css index 4f35bad..8e17c1b 100644 --- a/internal/assets/static/main.css +++ b/internal/assets/static/main.css @@ -782,6 +782,15 @@ details[open] .summary::after { padding-right: var(--widget-content-horizontal-padding); } +.logo:has(img) { + display: flex; + align-items: center; +} + +.logo img { + max-height: 2.7rem; +} + .nav { height: 100%; gap: var(--header-items-gap); @@ -820,6 +829,13 @@ details[open] .summary::after { color: var(--color-text-highlight); } +.release-source-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.4; +} + .market-chart { margin-left: auto; width: 6.5rem; @@ -997,6 +1013,7 @@ details[open] .summary::after { background-color: var(--color-widget-background-highlight); border-radius: var(--border-radius); padding: 0.5rem; + opacity: 0.7; } .bookmarks-icon { @@ -1005,10 +1022,6 @@ details[open] .summary::after { opacity: 0.8; } -.simple-icon { - opacity: 0.7; -} - :root:not(.light-scheme) .simple-icon { filter: invert(1); } @@ -1307,6 +1320,10 @@ details[open] .summary::after { transition: filter 0.3s, opacity 0.3s; } +.monitor-site-icon.simple-icon { + opacity: 0.7; +} + .monitor-site:hover .monitor-site-icon { filter: grayscale(0); opacity: 1; diff --git a/internal/assets/templates/document.html b/internal/assets/templates/document.html index 6aa1029..c12a908 100644 --- a/internal/assets/templates/document.html +++ b/internal/assets/templates/document.html @@ -12,9 +12,8 @@ - - + {{ block "document-head-after" . }}{{ end }} diff --git a/internal/assets/templates/page.html b/internal/assets/templates/page.html index 73c17d0..4d08e61 100644 --- a/internal/assets/templates/page.html +++ b/internal/assets/templates/page.html @@ -32,7 +32,7 @@
- + @@ -63,11 +63,17 @@
+ {{ if not .App.Config.Branding.HideFooter }} + {{ end }}
diff --git a/internal/assets/templates/releases.html b/internal/assets/templates/releases.html index d6a8974..7cd89f7 100644 --- a/internal/assets/templates/releases.html +++ b/internal/assets/templates/releases.html @@ -2,14 +2,19 @@ {{ define "widget-content" }} +{{ if gt (len .RepositoryDetails.Commits) 0 }} +
+Last {{ .CommitsLimit }} commits +
+ + +
+{{ end }} + {{ if gt (len .RepositoryDetails.PullRequests) 0 }}
Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total) diff --git a/internal/feed/dockerhub.go b/internal/feed/dockerhub.go new file mode 100644 index 0000000..e979d37 --- /dev/null +++ b/internal/feed/dockerhub.go @@ -0,0 +1,102 @@ +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/github.go b/internal/feed/github.go index 4d7dc73..782d612 100644 --- a/internal/feed/github.go +++ b/internal/feed/github.go @@ -2,8 +2,8 @@ package feed import ( "fmt" - "log/slog" "net/http" + "strings" "sync" "time" ) @@ -17,85 +17,41 @@ type githubReleaseLatestResponseJson struct { } `json:"reactions"` } -func parseGithubTime(t string) time.Time { - parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t) - - if err != nil { - return time.Now() - } - - return parsedTime -} - -func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) { - appReleases := make(AppReleases, 0, len(repositories)) - - if len(repositories) == 0 { - return appReleases, nil - } - - requests := make([]*http.Request, len(repositories)) - - for i, repository := range repositories { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository), nil) - - if token != "" { - request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) - } - - requests[i] = request - } - - task := decodeJsonFromRequestTask[githubReleaseLatestResponseJson](defaultClient) - job := newJob(task, requests).withWorkers(15) - responses, errs, err := workerPoolDo(job) +func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository), + nil, + ) if err != nil { return nil, err } - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL) - continue - } - - liveRelease := &responses[i] - - if liveRelease == nil { - slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL) - continue - } - - version := liveRelease.TagName - - if version[0] != 'v' { - version = "v" + version - } - - appReleases = append(appReleases, AppRelease{ - Name: repositories[i], - Version: version, - NotesUrl: liveRelease.HtmlUrl, - TimeReleased: parseGithubTime(liveRelease.PublishedAt), - Downvotes: liveRelease.Reactions.Downvotes, - }) + if request.Token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) } - if len(appReleases) == 0 { - return nil, ErrNoContent + response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest) + + if err != nil { + return nil, err } - appReleases.SortByNewest() + version := response.TagName - if failed > 0 { - return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) + if len(version) > 0 && version[0] != 'v' { + version = "v" + version } - return appReleases, nil + return &AppRelease{ + Source: ReleaseSourceGithub, + Name: request.Repository, + Version: version, + NotesUrl: response.HtmlUrl, + TimeReleased: parseRFC3339Time(response.PublishedAt), + Downvotes: response.Reactions.Downvotes, + }, nil } type GithubTicket struct { @@ -112,6 +68,8 @@ type RepositoryDetails struct { PullRequests []GithubTicket OpenIssues int Issues []GithubTicket + LastCommits int + Commits []CommitDetails } type githubRepositoryDetailsResponseJson struct { @@ -129,21 +87,40 @@ type githubTicketResponseJson struct { } `json:"items"` } -func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) { - repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) +type CommitDetails struct { + Sha string + Author string + CreatedAt time.Time + Message string +} +type gitHubCommitResponseJson struct { + Sha string `json:"sha"` + Commit struct { + Author struct { + Name string `json:"name"` + Date string `json:"date"` + } `json:"author"` + Message string `json:"message"` + } `json:"commit"` +} + +func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) { + repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) if err != nil { return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err) } PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil) issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil) + CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil) if token != "" { token = fmt.Sprintf("Bearer %s", token) repositoryRequest.Header.Add("Authorization", token) PRsRequest.Header.Add("Authorization", token) issuesRequest.Header.Add("Authorization", token) + CommitsRequest.Header.Add("Authorization", token) } var detailsResponse githubRepositoryDetailsResponseJson @@ -152,6 +129,8 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in var PRsErr error var issuesResponse githubTicketResponseJson var issuesErr error + var commitsResponse []gitHubCommitResponseJson + var CommitsErr error var wg sync.WaitGroup wg.Add(1) @@ -176,6 +155,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in })() } + if maxCommits > 0 { + wg.Add(1) + go (func() { + defer wg.Done() + commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest) + })() + } + wg.Wait() if detailsErr != nil { @@ -188,6 +175,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in Forks: detailsResponse.Forks, PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)), Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)), + Commits: make([]CommitDetails, 0, len(commitsResponse)), } err = nil @@ -201,7 +189,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in for i := range PRsResponse.Tickets { details.PullRequests = append(details.PullRequests, GithubTicket{ Number: PRsResponse.Tickets[i].Number, - CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt), + CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt), Title: PRsResponse.Tickets[i].Title, }) } @@ -218,12 +206,27 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in for i := range issuesResponse.Tickets { details.Issues = append(details.Issues, GithubTicket{ Number: issuesResponse.Tickets[i].Number, - CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt), + CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt), Title: issuesResponse.Tickets[i].Title, }) } } } + if maxCommits > 0 { + if CommitsErr != nil { + err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr) + } else { + for i := range commitsResponse { + details.Commits = append(details.Commits, CommitDetails{ + Sha: commitsResponse[i].Sha, + Author: commitsResponse[i].Commit.Author.Name, + CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date), + Message: strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0], + }) + } + } + } + return details, err } diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go new file mode 100644 index 0000000..4e0c1e8 --- /dev/null +++ b/internal/feed/gitlab.go @@ -0,0 +1,54 @@ +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 + } + + version := response.TagName + + if len(version) > 0 && version[0] != 'v' { + version = "v" + version + } + + return &AppRelease{ + Source: ReleaseSourceGitlab, + Name: request.Repository, + Version: version, + NotesUrl: response.Links.Self, + TimeReleased: parseRFC3339Time(response.ReleasedAt), + }, nil +} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go index 916e69b..755b002 100644 --- a/internal/feed/primitives.go +++ b/internal/feed/primitives.go @@ -41,11 +41,13 @@ type Weather struct { } type AppRelease struct { - Name string - Version string - NotesUrl string - TimeReleased time.Time - Downvotes int + Source ReleaseSource + SourceIconURL string + Name string + Version string + NotesUrl string + TimeReleased time.Time + Downvotes int } type AppReleases []AppRelease diff --git a/internal/feed/releases.go b/internal/feed/releases.go new file mode 100644 index 0000000..516801e --- /dev/null +++ b/internal/feed/releases.go @@ -0,0 +1,69 @@ +package feed + +import ( + "errors" + "fmt" + "log/slog" +) + +type ReleaseSource string + +const ( + 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 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/rss.go b/internal/feed/rss.go index 4374ca1..1d38940 100644 --- a/internal/feed/rss.go +++ b/internal/feed/rss.go @@ -161,7 +161,11 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) { } else if url := findThumbnailInItemExtensions(item); url != "" { rssItem.ImageURL = url } else if feed.Image != nil { - rssItem.ImageURL = feed.Image.URL + if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' { + rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL + } else { + rssItem.ImageURL = feed.Image.URL + } } if item.PublishedParsed != nil { diff --git a/internal/feed/utils.go b/internal/feed/utils.go index 16c376b..f86b497 100644 --- a/internal/feed/utils.go +++ b/internal/feed/utils.go @@ -7,6 +7,7 @@ import ( "regexp" "slices" "strings" + "time" ) var ( @@ -79,7 +80,6 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T { return values } - var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) func stripURLScheme(url string) string { @@ -95,3 +95,13 @@ func limitStringLength(s string, max int) (string, bool) { return s, false } + +func parseRFC3339Time(t string) time.Time { + parsed, err := time.Parse(time.RFC3339, t) + + if err != nil { + return time.Now() + } + + return parsed +} diff --git a/internal/glance/config.go b/internal/glance/config.go index 718988c..131ef7f 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -8,9 +8,10 @@ import ( ) type Config struct { - Server Server `yaml:"server"` - Theme Theme `yaml:"theme"` - Pages []Page `yaml:"pages"` + Server Server `yaml:"server"` + Theme Theme `yaml:"theme"` + Branding Branding `yaml:"branding"` + Pages []Page `yaml:"pages"` } func NewConfigFromYml(contents io.Reader) (*Config, error) { diff --git a/internal/glance/glance.go b/internal/glance/glance.go index ce7a7a0..a8485f2 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "html/template" "log/slog" "net/http" "path/filepath" @@ -48,6 +49,14 @@ type Server struct { 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"` @@ -102,6 +111,14 @@ func titleToSlug(s string) string { 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") @@ -114,8 +131,13 @@ func NewApplication(config *Config) (*Application, error) { widgetByID: make(map[uint64]widget.Widget), } + app.Config.Server.AssetsHash = assets.PublicFSHash app.slugToPage[""] = &config.Pages[0] + providers := &widget.Providers{ + AssetResolver: app.AssetPath, + } + for p := range config.Pages { if config.Pages[p].Slug == "" { config.Pages[p].Slug = titleToSlug(config.Pages[p].Title) @@ -127,6 +149,8 @@ func NewApplication(config *Config) (*Application, error) { for w := range config.Pages[p].Columns[c].Widgets { widget := config.Pages[p].Columns[c].Widgets[w] app.widgetByID[widget.GetID()] = widget + + widget.SetProviders(providers) } } } @@ -134,13 +158,16 @@ 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) - if config.Server.BaseURL != "" && - config.Theme.CustomCSSFile != "" && - strings.HasPrefix(config.Theme.CustomCSSFile, "/assets/") { - config.Theme.CustomCSSFile = config.Server.BaseURL + 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.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) + return app, nil } @@ -238,8 +265,6 @@ func (a *Application) AssetPath(asset string) string { } func (a *Application) Serve() error { - a.Config.Server.AssetsHash = assets.PublicFSHash - // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() @@ -252,7 +277,7 @@ func (a *Application) Serve() error { mux.Handle( fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), - http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 8*time.Hour)), + http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), ) if a.Config.Server.AssetsPath != "" { diff --git a/internal/widget/fields.go b/internal/widget/fields.go index cbbfce2..9ae1eda 100644 --- a/internal/widget/fields.go +++ b/internal/widget/fields.go @@ -152,6 +152,10 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { return nil } +func (f *OptionalEnvString) String() string { + return string(*f) +} + func toSimpleIconIfPrefixed(icon string) (string, bool) { if !strings.HasPrefix(icon, "si:") { return icon, false diff --git a/internal/widget/group.go b/internal/widget/group.go index 6bf58e5..9a15510 100644 --- a/internal/widget/group.go +++ b/internal/widget/group.go @@ -55,6 +55,12 @@ func (widget *Group) Update(ctx context.Context) { wg.Wait() } +func (widget *Group) SetProviders(providers *Providers) { + for i := range widget.Widgets { + widget.Widgets[i].SetProviders(providers) + } +} + func (widget *Group) RequiresUpdate(now *time.Time) bool { for i := range widget.Widgets { if widget.Widgets[i].RequiresUpdate(now) { diff --git a/internal/widget/releases.go b/internal/widget/releases.go index 77fe103..c7831cb 100644 --- a/internal/widget/releases.go +++ b/internal/widget/releases.go @@ -2,7 +2,9 @@ package widget import ( "context" + "errors" "html/template" + "strings" "time" "github.com/glanceapp/glance/internal/assets" @@ -10,12 +12,15 @@ import ( ) type Releases struct { - widgetBase `yaml:",inline"` - Releases feed.AppReleases `yaml:"-"` - Repositories []string `yaml:"repositories"` - Token OptionalEnvString `yaml:"token"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` + widgetBase `yaml:",inline"` + Releases feed.AppReleases `yaml:"-"` + releaseRequests []*feed.ReleaseRequest `yaml:"-"` + Repositories []string `yaml:"repositories"` + Token OptionalEnvString `yaml:"token"` + GitLabToken OptionalEnvString `yaml:"gitlab-token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ShowSourceIcon bool `yaml:"show-source-icon"` } func (widget *Releases) Initialize() error { @@ -29,11 +34,50 @@ func (widget *Releases) Initialize() error { widget.CollapseAfter = 5 } + var tokenAsString = widget.Token.String() + var gitLabTokenAsString = widget.GitLabToken.String() + + for _, repository := range widget.Repositories { + parts := strings.SplitN(repository, ":", 2) + var request *feed.ReleaseRequest + + if len(parts) == 1 { + request = &feed.ReleaseRequest{ + Source: feed.ReleaseSourceGithub, + Repository: repository, + } + + if widget.Token != "" { + request.Token = &tokenAsString + } + } else if len(parts) == 2 { + if parts[0] == string(feed.ReleaseSourceGitlab) { + request = &feed.ReleaseRequest{ + Source: feed.ReleaseSourceGitlab, + Repository: parts[1], + } + + if widget.GitLabToken != "" { + request.Token = &gitLabTokenAsString + } + } else if parts[0] == string(feed.ReleaseSourceDockerHub) { + request = &feed.ReleaseRequest{ + Source: feed.ReleaseSourceDockerHub, + Repository: parts[1], + } + } else { + return errors.New("invalid repository source " + parts[0]) + } + } + + widget.releaseRequests = append(widget.releaseRequests, request) + } + return nil } func (widget *Releases) Update(ctx context.Context) { - releases, err := feed.FetchLatestReleasesFromGithub(widget.Repositories, string(widget.Token)) + releases, err := feed.FetchLatestReleases(widget.releaseRequests) if !widget.canContinueUpdateAfterHandlingErr(err) { return @@ -43,6 +87,10 @@ func (widget *Releases) Update(ctx context.Context) { releases = releases[:widget.Limit] } + for i := range releases { + releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg") + } + widget.Releases = releases } diff --git a/internal/widget/repository-overview.go b/internal/widget/repository-overview.go index 85a896c..9d4cab3 100644 --- a/internal/widget/repository-overview.go +++ b/internal/widget/repository-overview.go @@ -15,6 +15,7 @@ type Repository struct { Token OptionalEnvString `yaml:"token"` PullRequestsLimit int `yaml:"pull-requests-limit"` IssuesLimit int `yaml:"issues-limit"` + CommitsLimit int `yaml:"commits-limit"` RepositoryDetails feed.RepositoryDetails } @@ -29,6 +30,10 @@ func (widget *Repository) Initialize() error { widget.IssuesLimit = 3 } + if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 { + widget.CommitsLimit = -1 + } + return nil } @@ -38,6 +43,7 @@ func (widget *Repository) Update(ctx context.Context) { string(widget.Token), widget.PullRequestsLimit, widget.IssuesLimit, + widget.CommitsLimit, ) if !widget.canContinueUpdateAfterHandlingErr(err) { diff --git a/internal/widget/widget.go b/internal/widget/widget.go index db37f5e..c452427 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -113,6 +113,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { type Widget interface { Initialize() error RequiresUpdate(*time.Time) bool + SetProviders(*Providers) Update(context.Context) Render() template.HTML GetType() string @@ -132,6 +133,7 @@ const ( type widgetBase struct { ID uint64 `yaml:"-"` + Providers *Providers `yaml:"-"` Type string `yaml:"type"` Title string `yaml:"title"` TitleURL string `yaml:"title-url"` @@ -148,6 +150,10 @@ type widgetBase struct { HideHeader bool `yaml:"-"` } +type Providers struct { + AssetResolver func(string) string +} + func (w *widgetBase) RequiresUpdate(now *time.Time) bool { if w.cacheType == cacheTypeInfinite { return false @@ -184,6 +190,10 @@ func (w *widgetBase) GetType() string { return w.Type } +func (w *widgetBase) SetProviders(providers *Providers) { + w.Providers = providers +} + func (w *widgetBase) render(data any, t *template.Template) template.HTML { w.templateBuffer.Reset() err := t.Execute(&w.templateBuffer, data)