├── .dockerignore
├── .air.toml
├── .github
└── workflows
│ ├── publish-ghcr.yaml
│ └── test.yaml
├── .gitignore
├── .testdata
├── keepachangelog
│ ├── full.md
│ ├── git-cliff.md
│ ├── minimal.md
│ └── unreleased.md
├── v0.0.1-commonmark.md
├── v0.0.2-open-source.md
├── v0.0.5-beta.md
├── v0.0.6-rss.md
├── v0.1.2-custom-domains.md
├── v0.1.5-dark-mode.md
├── v0.2.0-tags.md
├── v0.3.0-password.md
├── v0.3.2-keepachangelog.md
├── v0.4.1-nextjs.md
├── v0.5.0-analytics.md
└── v0.6.0-search.md
├── CONTRIBUTING.md
├── Dockerfile
├── Dockerfile.litefs
├── Dockerfile.sqlite
├── LICENSE
├── MULTI_TENANCY.md
├── README.md
├── api
├── README.md
├── changelog.go
├── client.go
├── go.mod
├── go.sum
├── response.go
├── source.go
└── workspace.go
├── apitypes
├── changelog.go
├── changelog_test.go
├── go.mod
├── go.sum
├── null.go
├── null_test.go
├── source.go
└── workspace.go
├── cmd
└── server.go
├── components
├── article.templ
├── article_templ.go
├── button.templ
├── button_templ.go
├── changelog.templ
├── changelog_templ.go
├── footer.templ
├── footer_templ.go
├── header.templ
├── header_templ.go
├── kbd.templ
├── kbd_templ.go
├── navbar.templ
├── navbar_templ.go
├── prose.templ
├── prose_templ.go
├── search.templ
├── search_templ.go
├── theme.templ
├── theme_templ.go
├── toast.templ
└── toast_templ.go
├── compose.yaml
├── go.mod
├── go.sum
├── internal
├── analytics
│ ├── analytics.go
│ ├── noop.go
│ └── tinybird
│ │ ├── datasources
│ │ └── analytics_events.datasource
│ │ ├── tinybird.go
│ │ └── tinybird_test.go
├── config
│ └── config.go
├── errs
│ └── errors.go
├── events
│ ├── events.go
│ └── listener.go
├── handler
│ ├── rest
│ │ ├── auth.go
│ │ ├── changelog.go
│ │ ├── routes.go
│ │ ├── source.go
│ │ └── workspace.go
│ ├── rss
│ │ ├── feed.tmpl
│ │ ├── routes.go
│ │ └── rss.go
│ ├── utils.go
│ ├── utils_test.go
│ └── web
│ │ ├── admin
│ │ ├── details.go
│ │ ├── index.go
│ │ ├── routes.go
│ │ └── views
│ │ │ ├── overview.templ
│ │ │ ├── overview_templ.go
│ │ │ ├── workspace_details.templ
│ │ │ └── workspace_details_templ.go
│ │ ├── css
│ │ ├── admin.css
│ │ └── base.css
│ │ ├── details.go
│ │ ├── icons
│ │ ├── chevron_left.templ
│ │ ├── chevron_left_templ.go
│ │ ├── chevron_right.templ
│ │ ├── chevron_right_templ.go
│ │ ├── inbox.templ
│ │ ├── inbox_templ.go
│ │ ├── key.templ
│ │ ├── key_templ.go
│ │ ├── rss.templ
│ │ ├── rss_templ.go
│ │ ├── search.templ
│ │ ├── search_templ.go
│ │ ├── spinner.templ
│ │ ├── spinner_templ.go
│ │ ├── x.templ
│ │ └── x_templ.go
│ │ ├── index.go
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ ├── password.go
│ │ ├── password_test.go
│ │ ├── renderer.go
│ │ ├── routes.go
│ │ ├── search.go
│ │ ├── static
│ │ ├── admin.css
│ │ ├── base.css
│ │ └── embed.go
│ │ ├── tailwind.admin.config.js
│ │ ├── tailwind.config.js
│ │ └── views
│ │ ├── details.templ
│ │ ├── details_templ.go
│ │ ├── error.templ
│ │ ├── error_templ.go
│ │ ├── index.templ
│ │ ├── index_templ.go
│ │ ├── layout
│ │ ├── main.templ
│ │ └── main_templ.go
│ │ ├── password.templ
│ │ ├── password_templ.go
│ │ ├── widget.templ
│ │ └── widget_templ.go
├── load
│ └── loader.go
├── pagination.go
├── parse
│ ├── keepachangelog.go
│ ├── keepachangelog_test.go
│ ├── og.go
│ ├── og_test.go
│ ├── parser.go
│ ├── parser_test.go
│ ├── utils.go
│ └── utils_test.go
├── search
│ ├── bleve.go
│ ├── noop.go
│ ├── search.go
│ └── search_test.go
├── source
│ ├── github.go
│ ├── local.go
│ ├── local_test.go
│ └── source.go
├── store
│ ├── color_scheme.go
│ ├── color_scheme_test.go
│ ├── config.go
│ ├── db.go
│ ├── domain.go
│ ├── domain_test.go
│ ├── id.go
│ ├── models.go
│ ├── query.sql
│ ├── query.sql.go
│ ├── sqlite.go
│ ├── store.go
│ └── token.go
├── xcache
│ └── cache.go
└── xlog
│ ├── log.go
│ └── logger.go
├── migrations
├── 20240725163248_workspaces.sql
├── 20240725163326_tokens.sql
├── 20240725163619_changelog.sql
├── 20240725163702_gh_sources.sql
├── 20240725163746_changelog_source.sql
├── 20240905083958_changelog_domain.sql
├── 20240911101434_changelog_color_scheme.sql
├── 20240919193315_changelog_hide_powered_by.sql
├── 20241012104224_changelog_protected.sql
├── 20241103174950_changelog_analytics.sql
└── 20241123104348_changelog_searchable.sql
├── openchangelog.example.yml
├── sqlc.yaml
└── widget
├── next
├── .eslintrc.json
├── .gitignore
├── .npmignore
├── .npmrc
├── CHANGELOG.md
├── README.md
├── next.config.mjs
├── package-lock.json
├── package.json
├── src
│ ├── Changelog.tsx
│ └── index.ts
├── tsconfig.json
└── tsup.config.ts
└── samples
└── next-app
├── .eslintrc.json
├── .gitignore
├── README.md
├── components.json
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
├── app
│ ├── changelog
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components
│ ├── navigation-menu-demo.tsx
│ └── ui
│ │ ├── button.tsx
│ │ └── navigation-menu.tsx
└── lib
│ └── utils.ts
├── tailwind.config.js
└── tsconfig.json
/ .dockerignore:
--------------------------------------------------------------------------------
1 | tmp
2 | .air.toml
3 | node_modules
4 | .DS_Store
5 |
6 | *.pem
7 | .env
8 | openchangelog.example.yml
9 | openchangelog.yml
10 | go.work*
11 |
12 | .cache
13 | test.db
14 | .testdata
15 | Dockerfile*
16 | compose.yaml
17 | .github
18 | unit-tests.xml
--------------------------------------------------------------------------------
/.air.toml:
--------------------------------------------------------------------------------
1 | root = "."
2 | testdata_dir = ".testdata"
3 | tmp_dir = "tmp"
4 |
5 | [build]
6 | args_bin = []
7 | bin = "./tmp/openchangelog"
8 | cmd = "go build -ldflags -w -o ./tmp/openchangelog cmd/server.go"
9 | delay = 1000
10 | exclude_dir = ["assets", "tmp", "vendor", ".testdata", "internal/handler/web/node_modules", ".cache", "widget"]
11 | exclude_file = []
12 | exclude_regex = ["_test.go"]
13 | exclude_unchanged = false
14 | follow_symlink = false
15 | full_bin = ""
16 | include_dir = []
17 | include_ext = ["go", "tpl", "tmpl", "html"]
18 | include_file = []
19 | kill_delay = "0s"
20 | log = "build-errors.log"
21 | poll = false
22 | poll_interval = 0
23 | post_cmd = []
24 | pre_cmd = []
25 | rerun = false
26 | rerun_delay = 500
27 | send_interrupt = false
28 | stop_on_error = false
29 |
30 | [color]
31 | app = ""
32 | build = "yellow"
33 | main = "magenta"
34 | runner = "green"
35 | watcher = "cyan"
36 |
37 | [log]
38 | main_only = false
39 | time = false
40 |
41 | [misc]
42 | clean_on_exit = false
43 |
44 | [screen]
45 | clear_on_rebuild = false
46 | keep_scroll = true
47 |
48 | [proxy]
49 | # Enable live-reloading on the browser.
50 | enabled = true
51 | proxy_port = 6000
52 | app_port = 6001
53 |
--------------------------------------------------------------------------------
/.github/workflows/publish-ghcr.yaml:
--------------------------------------------------------------------------------
1 | name: Build Base Docker Container
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | env:
8 | REGISTRY: ghcr.io
9 | IMAGE_NAME: jonashiltl/openchangelog
10 |
11 | jobs:
12 | build_and_publish:
13 | runs-on: ubuntu-latest
14 |
15 | strategy:
16 | matrix:
17 | include:
18 | - dockerfile: Dockerfile
19 | suffix: ""
20 | - dockerfile: Dockerfile.sqlite
21 | suffix: "-sqlite"
22 | - dockerfile: Dockerfile.litefs
23 | suffix: "-litefs"
24 |
25 | permissions:
26 | contents: read
27 | packages: write
28 | attestations: write
29 | id-token: write
30 |
31 | steps:
32 | - name: Check out the repo
33 | uses: actions/checkout@v4
34 |
35 | - name: Set up QEMU
36 | uses: docker/setup-qemu-action@v3
37 |
38 | - name: Set up Docker Buildx
39 | uses: docker/setup-buildx-action@v3
40 |
41 | - name: Log in to the Container registry
42 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
43 | with:
44 | registry: ${{ env.REGISTRY }}
45 | username: ${{ github.actor }}
46 | password: ${{ secrets.OPENCHANGELOG_PAT }}
47 |
48 | - name: Extract metadata (tags, labels) for Docker
49 | id: meta
50 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
51 | with:
52 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
53 | tags: |
54 | type=semver,pattern={{version}}${{ matrix.suffix }}
55 | ${{ matrix.dockerfile == 'Dockerfile' && 'type=raw,value=latest' || '' }}
56 |
57 | - name: Build and push Docker image
58 | id: push
59 | uses: docker/build-push-action@v6
60 | with:
61 | file: ${{ matrix.dockerfile }}
62 | context: .
63 | platforms: linux/amd64,linux/arm64
64 | push: true
65 | tags: ${{ steps.meta.outputs.tags }}
66 | labels: ${{ steps.meta.outputs.labels }}
67 |
68 | - name: Generate artifact attestation
69 | uses: actions/attest-build-provenance@v1
70 | with:
71 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
72 | subject-digest: ${{ steps.push.outputs.digest }}
73 | push-to-registry: true
74 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Set up Go 1.23
17 | uses: actions/setup-go@v1
18 | with:
19 | go-version: 1.23
20 | id: go
21 |
22 | - name: Check out code
23 | uses: actions/checkout@v1
24 |
25 | - name: Test
26 | run: go run gotest.tools/gotestsum@latest --junitfile unit-tests.xml --format pkgname github.com/jonashiltl/openchangelog/...
27 |
28 | - name: Test Summary
29 | uses: test-summary/action@v2
30 | with:
31 | paths: "unit-tests.xml"
32 | if: always()
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | tmp
2 | node_modules
3 | .DS_Store
4 |
5 | *.pem
6 | .env
7 | openchangelog.yml
8 | openchangelog.cloud.yml
9 |
10 | go.work*
11 | *.txt
12 |
13 | test.db
14 | .cache
15 | .data
16 |
17 | fly.toml
18 | unit-tests.xml
--------------------------------------------------------------------------------
/.testdata/keepachangelog/git-cliff.md:
--------------------------------------------------------------------------------
1 | [](https://git-cliff.org)
2 |
3 | ## [2.6.1](https://github.com/orhun/git-cliff/compare/v2.6.0..v2.6.1) - 2024-09-27
4 |
5 | ### 🐛 Bug Fixes
6 |
7 | - *(npm)* Add missing `--use-branch-tags` flag to TS options ([#874](https://github.com/orhun/git-cliff/issues/874)) - ([e21fb1d](https://github.com/orhun/git-cliff/commit/e21fb1d3895d893fd6a371ecd48aa4632cf4231d))
8 | - *(remote)* Avoid setting multiple remotes ([#885](https://github.com/orhun/git-cliff/issues/885)) - ([a344c68](https://github.com/orhun/git-cliff/commit/a344c68238cf3bb87d4f7eb9be46e97cc964eed9))
9 |
10 | ### 📚 Documentation
11 |
12 | - *(website)* Add conversion to pdf to tips-and-tricks ([#889](https://github.com/orhun/git-cliff/issues/889)) - ([58dc108](https://github.com/orhun/git-cliff/commit/58dc1087ed86794c2f678707f2fbb8199167b0c3))
13 | - *(website)* Add get_env filter example for GitLab CI - ([dfe4459](https://github.com/orhun/git-cliff/commit/dfe4459c5cadd465dec4ea860580ecf82b2b8860))
14 |
15 | ### ⚙️ Miscellaneous Tasks
16 |
17 | - *(ci)* Update pedantic lint command ([#890](https://github.com/orhun/git-cliff/issues/890)) - ([8d10edb](https://github.com/orhun/git-cliff/commit/8d10edb7450aaf189fbce5f78a72274739f73ba9))
18 | - *(docker)* Disable building arm64 docker images temporarily ([#879](https://github.com/orhun/git-cliff/issues/879)) - ([cde2a8e](https://github.com/orhun/git-cliff/commit/cde2a8e3222f5e8f8bdd9ae841fd0e5c42f68846))
19 | - *(fixtures)* Build binaries using dev profile ([#886](https://github.com/orhun/git-cliff/issues/886)) - ([a394f88](https://github.com/orhun/git-cliff/commit/a394f88f1d1742dfa3d30887bcb387361de306bc))
20 |
--------------------------------------------------------------------------------
/.testdata/keepachangelog/minimal.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | Description
3 |
4 | ## [1.1.1] - 2023-03-05
5 |
6 | ### Added
7 |
8 | - Arabic translation (#444).
9 | - v1.1 French translation.
10 | - v1.1 Dutch translation (#371).
11 |
12 | ### Fixed
13 |
14 | - Improve French translation (#377).
15 | - Improve id-ID translation (#416).
16 | - Improve Persian translation (#457).
17 |
18 | ### Changed
19 |
20 | - Upgrade dependencies: Ruby 3.2.1, Middleman, etc.
21 |
22 | ### Removed
23 |
24 | - Unused normalize.css file.
25 | - Identical links assigned in each translation file.
26 | - Duplicate index file for the english version.
--------------------------------------------------------------------------------
/.testdata/keepachangelog/unreleased.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ### Added
11 |
12 | - v1.1 Brazilian Portuguese translation.
13 | - v1.1 German Translation
14 | - v1.1 Spanish translation.
15 | - v1.1 Italian translation.
16 | - v1.1 Polish translation.
17 | - v1.1 Ukrainian translation.
18 |
19 | ### Changed
20 |
21 | - Use frontmatter title & description in each language version template
22 | - Replace broken OpenGraph image with an appropriately-sized Keep a Changelog
23 | image that will render properly (although in English for all languages)
24 | - Fix OpenGraph title & description for all languages so the title and
25 | description when links are shared are language-appropriate
26 |
27 | ### Removed
28 |
29 | - Trademark sign previously shown after the project description in version
30 | 0.3.0
--------------------------------------------------------------------------------
/.testdata/v0.0.1-commonmark.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: CommonMark 0.31.2 compliance
3 | description: Thanks to goldmark we are compliant with CommonMark 0.31.2
4 | publishedAt: 2024-04-03
5 | tags:
6 | - Improvement
7 | ---
8 |
9 | If you like Markdown, you will **love** Openchangelog.
10 | We are fully CommonMark 0.31.2 compliant, so you can use all you favorite Markdown tags.
11 |
12 | How to star?
13 | 1. go to [github.com/jonashiltl/openchangelog](https://github.com/jonashiltl/openchangelog)
14 | 2. Give a Star!
15 |
16 | Or want to contribute ?
17 | - go to [github.com/jonashiltl/openchangelog](https://github.com/jonashiltl/openchangelog)
18 | - open a PR
19 |
--------------------------------------------------------------------------------
/.testdata/v0.0.2-open-source.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Now Open Source
3 | description: Easily publish your product updates on a beatiful & simple Page
4 | publishedAt: 2024-04-24
5 | tags:
6 | - Community
7 | ---
8 |
9 | 
10 |
11 | Openchagelog is fully **open source** & easily **self hostable**.
12 |
13 | We have full **Markdown** support so you can continue writing your Product Updates in markdown.
14 | You can host your Changelogs on **Github**, **locally** and many more sources are planned.
15 |
--------------------------------------------------------------------------------
/.testdata/v0.0.5-beta.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Open Beta
3 | description: The open beta is now available, try it out today
4 | publishedAt: 2024-08-26
5 | tags:
6 | - Community
7 | - Cloud
8 | ---
9 |
10 | I'm excited to announce that the **open beta** of [Openchangelog](https://openchangelog.com) is now live!
11 |
12 | You can now deploy and customize your changelog on our global platform. With our seamless GitHub Integration you can directly sync your product updates from GitHub.
13 |
14 | Please try out our features, and provide feedback to help us shape the future of Openchangelog **together**.
15 |
--------------------------------------------------------------------------------
/.testdata/v0.0.6-rss.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: RSS Feed
3 | description: Keep your users informed with your own RSS feed
4 | publishedAt: 2024-08-27
5 | tags:
6 | - Feature
7 | ---
8 |
9 | 
10 |
11 | You can now easily notify your users about new product updates through an RSS feed.
12 |
13 | Each changelog now has its own RSS feed endpoint, readable by any RSS reader out there.
--------------------------------------------------------------------------------
/.testdata/v0.1.2-custom-domains.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Custom Domains
3 | description: Bring your own domain to showcase your changelog
4 | publishedAt: 2024-09-14
5 | tags:
6 | - Feature
7 | - Cloud
8 | ---
9 |
10 | 
11 | Want to host your changelog on a custom, branded domain like **changelog.company.com**?
12 |
13 | Now, with our new **Custom Domain** feature, you can easily point your changelog to any domain you own. SSL certificates are automatically managed by us, ensuring your changelog is secure without any extra effort on your end.
14 |
--------------------------------------------------------------------------------
/.testdata/v0.1.5-dark-mode.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Dark Mode
3 | description: Customize the theme of your changelogs
4 | publishedAt: 2024-09-15
5 | tags:
6 | - Improvement
7 | ---
8 |
9 | 
10 |
11 | I'm excited to announce that you can now configure the appearance of your changelog with **Dark Mode** support! Choose between **Dark**, **Light**, or follow the **System Theme** for a look that fits your brand.
12 |
13 | The theme can easily be switched in the changelog settings, or using the `openchangelog.yml` config when self-hosting.
14 |
--------------------------------------------------------------------------------
/.testdata/v0.2.0-tags.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Colorful Tags
3 | description: Make your release notes visually pop with custom tags
4 | publishedAt: 2024-10-09
5 | tags:
6 | - Feature
7 | ---
8 |
9 | 
10 |
11 | We've added colorful tags to help categorize your release notes. Whether it's a new feature, bug fix, or improvement, you can now make your updates more visually distinctive.
12 |
13 | Just add tags to your release notes and we'll automatically color them based on the tag name.
14 |
--------------------------------------------------------------------------------
/.testdata/v0.3.0-password.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Password Protection
3 | description: Keep your changelog private with password protection
4 | publishedAt: 2024-10-14
5 | tags:
6 | - Feature
7 | ---
8 |
9 | 
10 |
11 | We're excited to introduce password protection for your changelogs.
12 | It's perfect for internal changelogs or those containing sensitive information.
13 |
14 | If enabled, visitors will need to enter the password once to view your changelog.
15 | Or specify the `authorize` URL param with the password to bypass protection.
--------------------------------------------------------------------------------
/.testdata/v0.3.2-keepachangelog.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Keep A Changelog
3 | description: Create a sleek changelog website from your CHANGELOG.md
4 | publishedAt: 2024-10-19
5 | tags:
6 | - Improvement
7 | ---
8 | 
9 | Openchangelog can now parse your `CHANGELOG.md` file if you follow the [keep a changelog](https://keepachangelog.com/en/1.1.0/) format.
10 | You no longer need to write your release notes in separate files, just use your existing `CHANGELOG.md`.
--------------------------------------------------------------------------------
/.testdata/v0.4.1-nextjs.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Next.js Widget
3 | description: Embed your changelog directly into your Next.js app
4 | publishedAt: 2024-10-26
5 | tags:
6 | - Integration
7 | ---
8 |
9 | With our new `@openchangelog/next` package you can embed your Openchangelog changelog directly into your Next.js app, featuring server-side rendering and App Router support.
10 |
11 | Find out more in our [docs](https://openchangelog.com/docs/sdks/next/).
--------------------------------------------------------------------------------
/.testdata/v0.5.0-analytics.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Analytics
3 | description: Gain real-time insights into your changelog visitors
4 | publishedAt: 2024-11-08
5 | tags:
6 | - Cloud
7 | ---
8 |
9 | 
10 |
11 | We're excited to introduce Analytics to Openchangelog!
12 | You can now monitor key metrics, like daily visitor counts and country-based location data, directly from your changelog dashboard. Allowing you to understand your audience and optimize your changelog.
13 |
14 | For **self-hosting** we currently support [Tinybird](https://www.tinybird.co) for storing analytics events.
--------------------------------------------------------------------------------
/.testdata/v0.6.0-search.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Full Text Search
3 | description: Quickly find relevant release notes with our category filter and full text search
4 | publishedAt: 2024-11-25
5 | tags:
6 | - Feature
7 | ---
8 |
9 | 
10 |
11 | Openchangelog can now maintain a full text index of your changelog.
12 | With this feature you can search through your entire changelog with ease and filter by category to find relevant release notes.
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Openchangelog
2 |
3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1:
4 |
5 | Below are a set of guidlines for contributing to Openchangelog. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.
6 |
7 | ## Table of Contents
8 | 1. [Tech Stack](#tech-stack)
9 | 2. [Project structure](#project-structure)
10 | 3. [Environment Setup](#environment-setup)
11 | 4. [Starting Openchangelog as a contributor](#starting-openchangelog-as-a-contributor)
12 | 5. [Creating a PR](#creating-a-pr)
13 |
14 | ## Tech Stack
15 | - [Go](https://go.dev) The main programming language for writing the Openchangelog server.
16 | - [Templ](https://templ.guide) For building type-safe HTML components.
17 | - [Tailwind](https://tailwindcss.com) For easy styling of the HTML components.
18 | - [sqlc](https://github.com/sqlc-dev/sqlc) For generating type-safe code from sql queries.
19 |
20 | ## Project structure
21 | Openchangelog is divided into **three** separate packages.
22 | - The Openchangelog server is located in the repo root, it's entry point is the `cmd/server.go` file. It's the HTTP server that loads the changelog from a config or `sqlite` db and also loads it's articles through a `source` (GitHub or local). Then it parses the markdown files and responds to the user with the rendered HTML changelog.
23 | - The **apitypes** package holds all models that are returned from the api. These are shared between the `go` api client and the Openchangelog server.
24 | - The **api** package is the `go` api client which can be used to interact with the Openchangelog API (mostly needed in multi-tenancy setup).
25 |
26 | ## Environment Setup
27 | Install [Go](https://go.dev/dl/), [Templ](https://templ.guide/quick-start/installation) and optionally [Air](https://github.com/air-verse/air) for live reloading.
28 |
29 | ## Starting Openchangelog as a contributor
30 | Create a `openchangelog.yml` file in the repo root. Have a look at the `openchangelog.example.yml` file for inspiration or just copy it's content fully for a working config.
31 | Run `templ generate --watch` in the repo root to have `templ` automatically generate go code from the `*.templ` files.
32 |
33 | Inside `internal/handler/web` run `npm run watch:base` to generate the `base.css` file with tailwind whenever anything changes.
34 |
35 | Now run `air` or `go run cmd/server.go` in the repo root to start Openchangelog with live reloading. Since `base.css` is embedded on server start, `air` sometimes doesn't update the `css` file after it changes. Rerunning `air` fixes this issue.
36 |
37 | Since the changelog page is cached for 5 minutes, you might need to disable the cache in the dev tools to see latest changelog updates.
38 |
39 | ## Creating a PR
40 | If you've made changes to any `*.templ` files, ensure you run `templ generate` afterward.
41 | Additionally, after using watch mode, manually run `templ generate` again. Watch mode updates every `*_templ.go` file, even if no actual changes were made. Without this step, many lines may appear modified, even though no `*.templ` files were changed.
42 |
43 | If you changed any tailwind classes, make sure you ran `npm run watch` to generate the new `base.css` file with the tailwind styling.
44 |
45 | After following the above steps, you can create a PR to Openchangelog and a maintainer will review your PR swiftly.
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Non alpine because of CGO glibc dependency
2 | FROM golang:1.23 AS builder
3 |
4 | WORKDIR /build
5 | ENV CGO_ENABLED=1
6 | COPY go.mod .
7 | COPY go.sum .
8 | RUN go mod tidy
9 |
10 | COPY . .
11 | RUN go build -buildvcs=false -ldflags "-s -w -extldflags '-static'" -o ./openchangelog cmd/server.go
12 |
13 | FROM alpine
14 |
15 | ARG config=i-should-never-exists.jla
16 | # Try to copy config, the * makes sure we don't fail if the file isn't found
17 | COPY *$config /etc/openchangelog.yaml
18 |
19 | WORKDIR /app
20 | COPY --from=builder /build/openchangelog ./openchangelog
21 |
22 | ENTRYPOINT ["/app/openchangelog"]
--------------------------------------------------------------------------------
/Dockerfile.litefs:
--------------------------------------------------------------------------------
1 | # Non alpine because of CGO glibc dependency
2 | FROM golang:1.23 AS builder
3 |
4 | WORKDIR /build
5 | ENV CGO_ENABLED=1
6 | COPY go.mod .
7 | COPY go.sum .
8 | RUN go mod tidy
9 |
10 | COPY . .
11 | RUN go build -buildvcs=false -ldflags "-s -w -extldflags '-static'" -o ./openchangelog cmd/server.go
12 |
13 | # Build goose binary
14 | FROM golang:1.23 AS goose_builder
15 |
16 | WORKDIR /build
17 |
18 | RUN git clone https://github.com/pressly/goose && \
19 | cd goose && \
20 | go mod tidy && \
21 | CGO_ENABLED=0 go build -buildvcs=false -ldflags "-s -w -extldflags '-static'" -tags='no_postgres no_redshift no_tidb no_vertica no_ydb no_clickhouse no_mssql no_mysql no_libsql' -o goose ./cmd/goose
22 |
23 | FROM alpine
24 |
25 | ARG config=i-should-never-exists.jla
26 | # Try to copy config, the * makes sure we don't fail if the file isn't found
27 | COPY *$config /etc/openchangelog.yaml
28 |
29 | # Setup our environment to include FUSE & SQLite. We install ca-certificates
30 | # so we can communicate with the Consul server over HTTPS.
31 | RUN apk add ca-certificates fuse3 sqlite
32 | COPY --from=builder /build/openchangelog /app/openchangelog
33 | COPY --from=builder /build/migrations /app/migrations
34 |
35 | COPY --from=goose_builder /build/goose/goose /usr/bin/goose
36 | COPY --from=flyio/litefs:0.5 /usr/local/bin/litefs /usr/local/bin/litefs
37 |
38 | # Run LiteFS as the entrypoint. After it has connected and sync'd with the
39 | # cluster, it will run the commands listed in the "exec" field of the config.
40 | ENTRYPOINT ["litefs", "mount"]
--------------------------------------------------------------------------------
/Dockerfile.sqlite:
--------------------------------------------------------------------------------
1 | # Non alpine because of CGO glibc dependency
2 | FROM golang:1.23 AS builder
3 |
4 | WORKDIR /build
5 | ENV CGO_ENABLED=1
6 | COPY go.mod .
7 | COPY go.sum .
8 | RUN go mod tidy
9 |
10 | COPY . .
11 | RUN go build -buildvcs=false -ldflags "-s -w -extldflags '-static'" -o ./openchangelog cmd/server.go
12 |
13 | # Build goose binary
14 | FROM golang:1.23 AS goose_builder
15 |
16 | WORKDIR /build
17 |
18 | RUN git clone https://github.com/pressly/goose && \
19 | cd goose && \
20 | go mod tidy && \
21 | CGO_ENABLED=0 go build -buildvcs=false -ldflags "-s -w -extldflags '-static'" -tags='no_postgres no_redshift no_tidb no_vertica no_ydb no_clickhouse no_mssql no_mysql no_libsql' -o goose ./cmd/goose
22 |
23 | FROM alpine
24 |
25 | ARG config=i-should-never-exists.jla
26 | # Try to copy config, the * makes sure we don't fail if the file isn't found
27 | COPY *$config /etc/openchangelog.yaml
28 |
29 | RUN apk add sqlite
30 |
31 | WORKDIR /app
32 | COPY --from=builder /build/openchangelog ./openchangelog
33 | COPY --from=builder /build/migrations ./migrations
34 | COPY --from=goose_builder /build/goose/goose /usr/bin/goose
35 |
36 | ENTRYPOINT ["/app/openchangelog"]
--------------------------------------------------------------------------------
/MULTI_TENANCY.md:
--------------------------------------------------------------------------------
1 | # Multi Tenancy
2 |
3 | Openchangelog supports multi tenancy by storing `workspaces`, `sources` & `changelogs` in SQLite.
4 | **Note**: We do **not** store the changelog articles in SQLite, they still must be stored in a source like Github.
5 |
6 | To configure sqlite and enable Multi Tenancy you need to specify the SQLite URL on the `openchangelog.yml`.
7 | Also set `?_foreign_keys=on` on the sqlite url to enfore foreign key constraints.
8 | ```
9 | # openchangelog.yml
10 | sqliteUrl:
11 | ```
12 |
13 | You can render the changelog of a specific workspace by accessing it through the changelog's subdomain or host.
14 |
15 | To interact with `workspaces`, `sources` & `changelogs` you can use the REST API under the `/api/` endpoint.
16 | You need to authenticate on every request with a specific workspace by using the supplied `bearer` token when creating/returning your workspace.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Openchangelog
6 |
7 |
8 | Transform your changelog Markdown files to beautiful product updates
9 |
10 |
11 | Website
12 | ·
13 | Docs
14 | ·
15 | Get Started
16 | ·
17 | Demo
18 |
19 |
20 |
21 |
22 |
23 | Openchangelog takes your Changelog, hosted on GitHub or locally and renders it as a beautiful Changelog Website.
24 | - Full Text Search
25 | - Password Protection
26 | - Analytics
27 | - Dark, Light and System themes
28 | - Automatic RSS feed
29 | - Colorful Tags
30 | - Supports [keep a changelog](https://keepachangelog.com/en/1.1.0/) `CHANGELOG.md` format or one Markdown file per release
31 | - Next.js embed
32 | - Various integrations, open an issue to request a new integration
33 |
34 | ## Quickstart
35 | Create an `openchangelog.yml` config file, from the sample `openchangelog.example.yml`. For more configuration settings visit our [Docs](https://openchangelog.com/docs/getting-started/self-hosting/#configuration).
36 | ```
37 | docker run -v ./openchangelog.yml:/etc/openchangelog.yml:ro -v ./release-notes:/release-notes -p 6001:6001 ghcr.io/jonashiltl/openchangelog:0.6.2
38 | ```
39 | Or
40 | ```yaml
41 | services:
42 | openchangelog:
43 | image: "ghcr.io/jonashiltl/openchangelog:0.6.2"
44 | ports:
45 | - "6001:6001"
46 | volumes:
47 | - ./release-notes:/release-notes
48 | - type: bind
49 | source: openchangelog.yml
50 | target: /etc/openchangelog.yml
51 | ```
52 | Once deployed, your changelog will be available at http://localhost:6001.
53 |
--------------------------------------------------------------------------------
/api/README.md:
--------------------------------------------------------------------------------
1 | # Openchangelog API
2 |
3 | The `github.com/jonashiltl/openchangelog/api` package holds the Golang API Client to interact with the Openchangelog API.
--------------------------------------------------------------------------------
/api/client.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "net/url"
10 | "path"
11 |
12 | "github.com/hashicorp/go-cleanhttp"
13 | "golang.org/x/net/http2"
14 | )
15 |
16 | const (
17 | DefaultAddress = "https://localhost:6001/api"
18 | AuthHeader = "Authorization"
19 | )
20 |
21 | // Config is used to configure the creation of the client.
22 | type Config struct {
23 | AuthToken string
24 | Address string
25 | HttpClient *http.Client
26 | }
27 |
28 | type Client struct {
29 | addr *url.URL
30 | cfg *Config
31 | headers http.Header
32 | }
33 |
34 | func DefaultConfig() (*Config, error) {
35 | cfg := &Config{
36 | Address: DefaultAddress,
37 | HttpClient: cleanhttp.DefaultPooledClient(),
38 | }
39 | transport := cfg.HttpClient.Transport.(*http.Transport)
40 |
41 | if err := http2.ConfigureTransport(transport); err != nil {
42 | return nil, err
43 | }
44 | return cfg, nil
45 | }
46 |
47 | func NewClient(c *Config) (*Client, error) {
48 | def, err := DefaultConfig()
49 | if err != nil {
50 | return nil, err
51 | }
52 | if c == nil {
53 | c = def
54 | }
55 |
56 | if c.AuthToken == "" {
57 | return nil, errors.New("missing auth token for openchangelog api client")
58 | }
59 |
60 | if c.Address == "" {
61 | c.Address = def.Address
62 | }
63 | if c.HttpClient == nil {
64 | c.HttpClient = def.HttpClient
65 | }
66 | if c.HttpClient.Transport == nil {
67 | c.HttpClient.Transport = def.HttpClient.Transport
68 | }
69 |
70 | u, err := url.Parse(c.Address)
71 | if err != nil {
72 | return nil, err
73 | }
74 |
75 | client := &Client{
76 | addr: u,
77 | cfg: c,
78 | headers: make(http.Header),
79 | }
80 | client.headers[AuthHeader] = []string{fmt.Sprintf("Bearer %s", c.AuthToken)}
81 | return client, err
82 | }
83 |
84 | func (c *Client) NewRequest(ctx context.Context, method, requestPath string, body io.Reader) (*http.Request, error) {
85 | p, err := url.Parse(requestPath)
86 | if err != nil {
87 | return nil, err
88 | }
89 |
90 | url := &url.URL{
91 | User: c.addr.User,
92 | Scheme: c.addr.Scheme,
93 | Host: c.addr.Host,
94 | Path: path.Join(c.addr.Path, p.Path),
95 | RawQuery: p.RawQuery,
96 | }
97 |
98 | req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
99 | if err != nil {
100 | return nil, err
101 | }
102 |
103 | req.Header = c.headers
104 | return req, nil
105 | }
106 |
107 | func (c *Client) rawRequestWithContext(r *http.Request) (*Response, error) {
108 | httpClient := c.cfg.HttpClient
109 |
110 | var result *Response
111 | resp, err := httpClient.Do(r)
112 | if resp != nil {
113 | result = &Response{Response: resp}
114 | }
115 | if err != nil {
116 | return result, err
117 | }
118 |
119 | if err := result.Error(); err != nil {
120 | return result, err
121 | }
122 |
123 | return result, nil
124 | }
125 |
--------------------------------------------------------------------------------
/api/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jonashiltl/openchangelog/api
2 |
3 | go 1.22.2
4 |
5 | require (
6 | github.com/hashicorp/go-cleanhttp v0.5.2
7 | github.com/jonashiltl/openchangelog/apitypes v0.0.0-20241124125835-a4a1bb42e055
8 | golang.org/x/net v0.25.0
9 | )
10 |
11 | require golang.org/x/text v0.15.0 // indirect
12 |
--------------------------------------------------------------------------------
/api/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
4 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
5 | github.com/jonashiltl/openchangelog/apitypes v0.0.0-20241123114718-0416928fd673 h1:vohHP2kAYAFbXJmsHThn6Ww2Ybs/dH+1fcUbqnoX4cs=
6 | github.com/jonashiltl/openchangelog/apitypes v0.0.0-20241123114718-0416928fd673/go.mod h1:1C1oY27qUCTAqi0SLZRCBGA6QoMTHkWtGlMMwh8pZxk=
7 | github.com/jonashiltl/openchangelog/apitypes v0.0.0-20241124125835-a4a1bb42e055 h1:JcFCIBJ7vG1n+CXq4URn6btgLWBQY569QiHaVhboNd8=
8 | github.com/jonashiltl/openchangelog/apitypes v0.0.0-20241124125835-a4a1bb42e055/go.mod h1:1C1oY27qUCTAqi0SLZRCBGA6QoMTHkWtGlMMwh8pZxk=
9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
11 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
12 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
13 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
14 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
15 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
16 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
19 |
--------------------------------------------------------------------------------
/api/response.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "io"
7 | "net/http"
8 | )
9 |
10 | type Response struct {
11 | *http.Response
12 | }
13 |
14 | // DecodeJSON will decode the response body to a JSON structure. This
15 | // will consume the response body, but will not close it. Close must
16 | // still be called.
17 | func (r *Response) DecodeJSON(out interface{}) error {
18 | dec := json.NewDecoder(r.Body)
19 | dec.UseNumber()
20 | return dec.Decode(out)
21 | }
22 |
23 | // Error returns an error response if there is one. If there is an error,
24 | // this will fully consume the response body, but will not close it. The
25 | // body must still be closed manually.
26 | func (r *Response) Error() error {
27 | // 200 to 399 are okay status codes.
28 | if r.StatusCode >= 200 && r.StatusCode < 400 {
29 | return nil
30 | }
31 |
32 | // We have an error. Let's copy the body into our own buffer first,
33 | // so that if we can't decode JSON, we can at least copy it raw.
34 | bodyBuf := &bytes.Buffer{}
35 | if _, err := io.Copy(bodyBuf, r.Body); err != nil {
36 | return err
37 | }
38 |
39 | r.Body.Close()
40 | r.Body = io.NopCloser(bodyBuf)
41 |
42 | // Build up the error object
43 | respErr := &ApiError{
44 | HTTPMethod: r.Request.Method,
45 | URL: r.Request.URL.String(),
46 | StatusCode: r.StatusCode,
47 | }
48 |
49 | var resp struct {
50 | Code int `json:"code"`
51 | Message string `json:"message"`
52 | }
53 | dec := json.NewDecoder(bytes.NewReader(bodyBuf.Bytes()))
54 | dec.UseNumber()
55 | if err := dec.Decode(&resp); err != nil {
56 | // failed to decode ApiError, just return body as string
57 | resp.Message = bodyBuf.String()
58 | } else {
59 | respErr.Message = resp.Message
60 | }
61 |
62 | return respErr
63 | }
64 |
65 | type ApiError struct {
66 | // HTTPMethod is the HTTP method for the request (PUT, GET, etc).
67 | HTTPMethod string
68 |
69 | // URL is the URL of the request.
70 | URL string
71 |
72 | // StatusCode is the HTTP status code.
73 | StatusCode int
74 |
75 | Message string
76 | }
77 |
78 | func (a ApiError) Error() string {
79 | return a.Message
80 | }
81 |
--------------------------------------------------------------------------------
/api/source.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 |
10 | "github.com/jonashiltl/openchangelog/apitypes"
11 | )
12 |
13 | type Source = apitypes.Source
14 | type GHSource = apitypes.CreateGHSourceBody
15 | type CreateGHSourceBody = apitypes.CreateGHSourceBody
16 |
17 | func (c *Client) CreateGHSource(ctx context.Context, args CreateGHSourceBody) (GHSource, error) {
18 | body, err := json.Marshal(args)
19 | if err != nil {
20 | return GHSource{}, err
21 | }
22 |
23 | req, err := c.NewRequest(
24 | ctx,
25 | http.MethodPost,
26 | "/sources/gh",
27 | bytes.NewReader(body),
28 | )
29 | if err != nil {
30 | return GHSource{}, err
31 | }
32 |
33 | resp, err := c.rawRequestWithContext(req)
34 | if err != nil {
35 | return GHSource{}, fmt.Errorf("error while creating github source: %w", err)
36 | }
37 | defer resp.Body.Close()
38 |
39 | var s GHSource
40 | err = resp.DecodeJSON(&s)
41 | return s, err
42 | }
43 |
44 | func (c *Client) DeleteGHSource(ctx context.Context, sourceID string) error {
45 | req, err := c.NewRequest(
46 | ctx,
47 | http.MethodDelete,
48 | fmt.Sprintf("/sources/gh/%s", sourceID),
49 | nil,
50 | )
51 | if err != nil {
52 | return err
53 | }
54 |
55 | _, err = c.rawRequestWithContext(req)
56 | if err != nil {
57 | return fmt.Errorf("error while deleting github source %s: %w", sourceID, err)
58 | }
59 | return nil
60 | }
61 |
62 | func (c *Client) ListSources(ctx context.Context) ([]Source, error) {
63 | req, err := c.NewRequest(ctx, http.MethodGet, "/sources", nil)
64 | if err != nil {
65 | return nil, err
66 | }
67 |
68 | resp, err := c.rawRequestWithContext(req)
69 | if err != nil {
70 | return nil, fmt.Errorf("error while listing sources: %w", err)
71 | }
72 | defer resp.Body.Close()
73 |
74 | var objs []json.RawMessage
75 | err = resp.DecodeJSON(&objs)
76 | if err != nil {
77 | return nil, err
78 | }
79 |
80 | res := make([]Source, len(objs))
81 | for i, obj := range objs {
82 | res[i] = apitypes.DecodeSource(obj)
83 | }
84 |
85 | return res, nil
86 | }
87 |
--------------------------------------------------------------------------------
/api/workspace.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "net/http"
9 |
10 | "github.com/jonashiltl/openchangelog/apitypes"
11 | )
12 |
13 | type Workspace = apitypes.Workspace
14 |
15 | func (c *Client) CreateWorkspace(ctx context.Context, args apitypes.CreateWorkspaceBody) (Workspace, error) {
16 | body, err := json.Marshal(args)
17 | if err != nil {
18 | return Workspace{}, err
19 | }
20 |
21 | req, err := c.NewRequest(
22 | ctx,
23 | http.MethodPost,
24 | "/workspaces",
25 | bytes.NewReader(body),
26 | )
27 | if err != nil {
28 | return Workspace{}, err
29 | }
30 |
31 | resp, err := c.rawRequestWithContext(req)
32 | if err != nil {
33 | return Workspace{}, fmt.Errorf("error while creating workspace: %w", err)
34 | }
35 | defer resp.Body.Close()
36 |
37 | var w Workspace
38 | err = resp.DecodeJSON(&w)
39 | return w, err
40 | }
41 |
42 | func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
43 | req, err := c.NewRequest(
44 | ctx, http.MethodDelete,
45 | fmt.Sprintf("/workspaces/%s", id),
46 | nil,
47 | )
48 | if err != nil {
49 | return err
50 | }
51 |
52 | _, err = c.rawRequestWithContext(req)
53 | if err != nil {
54 | return fmt.Errorf("error while creating workspace: %w", err)
55 | }
56 | return nil
57 | }
58 |
--------------------------------------------------------------------------------
/apitypes/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jonashiltl/openchangelog/apitypes
2 |
3 | go 1.22.2
4 |
5 | require github.com/stretchr/testify v1.9.0
6 |
7 | require (
8 | github.com/davecgh/go-spew v1.1.1 // indirect
9 | github.com/pmezard/go-difflib v1.0.0 // indirect
10 | gopkg.in/yaml.v3 v3.0.1 // indirect
11 | )
12 |
--------------------------------------------------------------------------------
/apitypes/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
6 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
11 |
--------------------------------------------------------------------------------
/apitypes/null.go:
--------------------------------------------------------------------------------
1 | package apitypes
2 |
3 | import (
4 | "database/sql"
5 | "database/sql/driver"
6 | "encoding/json"
7 | "fmt"
8 | )
9 |
10 | // Represents a nullable value.
11 | // Supports JSON and SQL un/marshaling.
12 | type Null[T comparable] struct {
13 | v T
14 | isNull bool
15 | }
16 |
17 | func NewValue[T comparable](v T) Null[T] {
18 | return Null[T]{
19 | v: v,
20 | }
21 | }
22 |
23 | func NewNull[T comparable]() Null[T] {
24 | return Null[T]{
25 | isNull: true,
26 | }
27 | }
28 |
29 | func (n Null[T]) IsZero() bool {
30 | if n.IsNull() {
31 | return false
32 | }
33 | return n.v == *new(T)
34 | }
35 |
36 | func (n Null[T]) IsNull() bool {
37 | return n.isNull
38 | }
39 |
40 | // Returns true if n is neither null or zero value.
41 | func (n Null[T]) IsValid() bool {
42 | return !n.IsNull() && !n.IsZero()
43 | }
44 |
45 | // Returns zero value if n is null, else it's internal value.
46 | func (n Null[T]) V() T {
47 | if n.IsNull() {
48 | return *new(T)
49 | }
50 |
51 | return n.v
52 | }
53 |
54 | func (n *Null[T]) UnmarshalJSON(data []byte) error {
55 | if len(data) > 0 && data[0] == 'n' {
56 | n.isNull = true
57 | return nil
58 | }
59 |
60 | if err := json.Unmarshal(data, &n.v); err != nil {
61 | return fmt.Errorf("Null: couldn't unmarshal JSON: %w", err)
62 | }
63 |
64 | return nil
65 | }
66 |
67 | func (ns Null[T]) MarshalJSON() ([]byte, error) {
68 | if ns.IsNull() {
69 | return []byte("null"), nil
70 | }
71 | return json.Marshal(ns.V())
72 | }
73 |
74 | func (n *Null[T]) Scan(value interface{}) error {
75 | sn := sql.Null[T]{}
76 | err := sn.Scan(value)
77 | if err != nil {
78 | return err
79 | }
80 |
81 | n.isNull = !sn.Valid // !valid means value is NULL in db
82 | if sn.Valid {
83 | n.v = sn.V
84 | }
85 | return nil
86 | }
87 |
88 | func (n Null[T]) Value() (driver.Value, error) {
89 | sn := sql.Null[T]{
90 | V: n.V(),
91 | Valid: n.IsValid(), // this way we also store zero values as NULL in db
92 | }
93 | return sn.Value()
94 | }
95 |
96 | type NullString = Null[string]
97 |
98 | // Create a new NullString from a string value
99 | func NewString(str string) NullString {
100 | return NewValue(str)
101 | }
102 |
103 | // Creates a new null NullString
104 | func NewNullString() NullString {
105 | return NewNull[string]()
106 | }
107 |
108 | type NullColorScheme = Null[ColorScheme]
109 |
110 | type NullBool = Null[bool]
111 |
112 | func NewBool(b bool) NullBool {
113 | return NewValue(b)
114 | }
115 |
116 | func NewNullBool() NullBool {
117 | return NewNull[bool]()
118 | }
119 |
--------------------------------------------------------------------------------
/apitypes/source.go:
--------------------------------------------------------------------------------
1 | package apitypes
2 |
3 | import (
4 | "encoding/json"
5 | )
6 |
7 | type SourceType string
8 |
9 | const (
10 | GitHub SourceType = "github"
11 | )
12 |
13 | type Source interface {
14 | Type() SourceType
15 | }
16 |
17 | type GHSource struct {
18 | ID string `json:"id"`
19 | WorkspaceID string `json:"workspaceId"`
20 | Owner string `json:"owner"`
21 | Repo string `json:"repo"`
22 | Path string `json:"path,omitempty"`
23 | }
24 |
25 | func (g GHSource) Type() SourceType {
26 | return GitHub
27 | }
28 |
29 | func (g GHSource) MarshalJSON() (b []byte, e error) {
30 | // needed to bypass recursive marshaling of GHSource
31 | type Alias GHSource
32 | return json.Marshal(struct {
33 | Type SourceType `json:"type"`
34 | Alias
35 | }{
36 | Type: g.Type(),
37 | Alias: Alias(g),
38 | })
39 | }
40 |
41 | type CreateGHSourceBody struct {
42 | Owner string `json:"owner"`
43 | Repo string `json:"repo"`
44 | Path string `json:"path"`
45 | InstallationID int64 `json:"installationID"`
46 | }
47 |
--------------------------------------------------------------------------------
/apitypes/workspace.go:
--------------------------------------------------------------------------------
1 | package apitypes
2 |
3 | type Workspace struct {
4 | ID string `json:"id"`
5 | Name string `json:"name"`
6 | Token string `json:"token"`
7 | }
8 |
9 | type CreateWorkspaceBody struct {
10 | Name string `json:"name"`
11 | }
12 |
--------------------------------------------------------------------------------
/components/article.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "time"
7 | )
8 |
9 | type ArticleListArgs struct {
10 | Articles []ArticleArgs
11 | }
12 |
13 | templ ArticleList(a ArticleListArgs) {
14 | for _, item := range a.Articles {
15 | @Article(item)
16 | }
17 | }
18 |
19 | type ArticleArgs struct {
20 | ID string
21 | Title string
22 | Description string
23 | PublishedAt time.Time
24 | Tags []string
25 | Content string
26 | }
27 |
28 | templ Article(a ArticleArgs) {
29 |
30 |
39 | { a.Description }
40 |
41 | if !a.PublishedAt.IsZero() {
42 |
{ a.PublishedAt.Format("02 Jan 2006") }
43 | }
44 |
45 |
50 | for _, t := range a.Tags {
51 | @Tag(t)
52 | }
53 |
54 |
55 | @templ.Raw(a.Content)
56 |
57 | }
58 |
59 | templ Tag(name string) {
60 | { name }
61 | }
62 |
63 | css tagStyle(tag string) {
64 | --tag-color: { templ.SafeCSSProperty(tagBaseColor(tag)) };
65 | --tag-bg: color-mix(in srgb, var(--tag-color) 20%, transparent);
66 | --tag-text-dark: color-mix(in srgb, var(--tag-color) 70%, black);
67 | --tag-text-light: color-mix(in srgb, var(--tag-color) 80%, white);
68 | background-color: var(--tag-bg);
69 | border-color: var(--tag-bg);
70 | color: var(--tag-text-dark);
71 | }
72 |
73 | func tagBaseColor(tag string) string {
74 | h, s, l := stringToHSL(tag)
75 | return fmt.Sprintf("hsl(%d %d%% %d%%)", h, s, l)
76 | }
77 |
78 | func stringToHSL(str string) (h int32, s int32, l int32) {
79 | var hash int32
80 | for _, char := range str {
81 | hash = int32(char) + ((hash << 5) - hash)
82 | }
83 |
84 | hash = int32(math.Abs(float64(hash)))
85 |
86 | h = hash % 360
87 | s = 60 + (hash % 40) // 60-100%
88 | l = 30 + (hash % 30) // 30-60%
89 | return
90 | }
91 |
--------------------------------------------------------------------------------
/components/button.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import "github.com/jonashiltl/openchangelog/internal/handler/web/icons"
4 |
5 | templ BackButton(link string) {
6 |
11 |
12 | @icons.ChevronLeft(16, 16)
13 |
14 |
15 | { children... }
16 |
17 |
18 | }
19 |
20 | templ ForwardButton(link string) {
21 |
26 |
27 | { children... }
28 |
29 |
30 | @icons.ChevronRight(16, 16)
31 |
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/components/changelog.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import "html/template"
4 |
5 | type ChangelogContainerArgs struct {
6 | CurrentURL string
7 | HasMoreArticle bool
8 | }
9 |
10 | // Contains the article list and footer
11 | templ ChangelogContainer(args ChangelogContainerArgs) {
12 |
13 | { children... }
14 |
21 |
22 | if args.HasMoreArticle {
23 | @templ.FromGoHTML(infiniteScrollTemplate, args.CurrentURL)
24 | }
25 | }
26 |
27 | var infiniteScrollTemplate = template.Must(template.New("infiniteScrollTemplate").Parse(`
28 |
73 | `,
74 | ))
75 |
--------------------------------------------------------------------------------
/components/footer.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | type FooterArgs struct {
4 | HidePoweredBy bool
5 | }
6 |
7 | templ Footer(args FooterArgs) {
8 |
15 | }
16 |
--------------------------------------------------------------------------------
/components/footer_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package components
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | type FooterArgs struct {
12 | HidePoweredBy bool
13 | }
14 |
15 | func Footer(args FooterArgs) templ.Component {
16 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
17 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
18 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
19 | if !templ_7745c5c3_IsBuffer {
20 | defer func() {
21 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
22 | if templ_7745c5c3_Err == nil {
23 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
24 | }
25 | }()
26 | }
27 | ctx = templ.InitializeContext(ctx)
28 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
29 | if templ_7745c5c3_Var1 == nil {
30 | templ_7745c5c3_Var1 = templ.NopComponent
31 | }
32 | ctx = templ.ClearChildren(ctx)
33 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
44 | if templ_7745c5c3_Err != nil {
45 | return templ_7745c5c3_Err
46 | }
47 | return templ_7745c5c3_Err
48 | })
49 | }
50 |
51 | var _ = templruntime.GeneratedTemplate
52 |
--------------------------------------------------------------------------------
/components/header.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import "github.com/jonashiltl/openchangelog/apitypes"
4 |
5 | templ HeaderContainer() {
6 |
9 | }
10 |
11 | type HeaderArgs struct {
12 | Title apitypes.NullString
13 | Subtitle apitypes.NullString
14 | ShowBack bool
15 | }
16 |
17 | templ HeaderContent(args HeaderArgs) {
18 | if args.ShowBack {
19 |
20 | @BackButton("/") {
21 | Back
22 | }
23 |
24 | }
25 | if args.Title.IsValid() {
26 | { args.Title.V() }
27 | }
28 | if args.Subtitle.IsValid() {
29 | { args.Subtitle.V() }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/components/kbd.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | templ KBD(key string) {
4 |
5 | { key }
6 |
7 | }
8 |
--------------------------------------------------------------------------------
/components/kbd_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package components
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | func KBD(key string) templ.Component {
12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
14 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
15 | if !templ_7745c5c3_IsBuffer {
16 | defer func() {
17 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
18 | if templ_7745c5c3_Err == nil {
19 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
20 | }
21 | }()
22 | }
23 | ctx = templ.InitializeContext(ctx)
24 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
25 | if templ_7745c5c3_Var1 == nil {
26 | templ_7745c5c3_Var1 = templ.NopComponent
27 | }
28 | ctx = templ.ClearChildren(ctx)
29 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
30 | if templ_7745c5c3_Err != nil {
31 | return templ_7745c5c3_Err
32 | }
33 | var templ_7745c5c3_Var2 string
34 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(key)
35 | if templ_7745c5c3_Err != nil {
36 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/kbd.templ`, Line: 5, Col: 7}
37 | }
38 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
39 | if templ_7745c5c3_Err != nil {
40 | return templ_7745c5c3_Err
41 | }
42 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
43 | if templ_7745c5c3_Err != nil {
44 | return templ_7745c5c3_Err
45 | }
46 | return templ_7745c5c3_Err
47 | })
48 | }
49 |
50 | var _ = templruntime.GeneratedTemplate
51 |
--------------------------------------------------------------------------------
/components/navbar.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | "github.com/jonashiltl/openchangelog/apitypes"
5 | "github.com/jonashiltl/openchangelog/internal/handler/web/icons"
6 | )
7 |
8 | type Logo struct {
9 | Src apitypes.NullString
10 | Width apitypes.NullString
11 | Height apitypes.NullString
12 | Alt apitypes.NullString
13 | Link apitypes.NullString
14 | }
15 |
16 | css imgSize(width string, height string) {
17 | width: { width };
18 | height: { height };
19 | }
20 |
21 | templ Navbar() {
22 |
27 | }
28 |
29 | templ NavbarActions() {
30 |
31 | { children... }
32 |
33 | }
34 |
35 | type RSSArgs struct {
36 | FeedURL string
37 | }
38 |
39 | templ RSS(args RSSArgs) {
40 |
45 | @icons.RSS(16, 16)
46 |
47 | }
48 |
49 | templ LogoImg(args Logo) {
50 | if args.Link.V() == "" {
51 | @img(args)
52 | } else {
53 |
54 | @img(args)
55 |
56 | }
57 | }
58 |
59 | templ img(args Logo) {
60 |
65 | }
66 |
--------------------------------------------------------------------------------
/components/prose.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | // A wrapper to apply tailwind typography styles to the underlying texts
4 | templ Prose() {
5 |
11 | { children... }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/components/prose_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package components
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | // A wrapper to apply tailwind typography styles to the underlying texts
12 | func Prose() templ.Component {
13 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
14 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
15 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
16 | if !templ_7745c5c3_IsBuffer {
17 | defer func() {
18 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
19 | if templ_7745c5c3_Err == nil {
20 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
21 | }
22 | }()
23 | }
24 | ctx = templ.InitializeContext(ctx)
25 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
26 | if templ_7745c5c3_Var1 == nil {
27 | templ_7745c5c3_Var1 = templ.NopComponent
28 | }
29 | ctx = templ.ClearChildren(ctx)
30 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
31 | if templ_7745c5c3_Err != nil {
32 | return templ_7745c5c3_Err
33 | }
34 | templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
35 | if templ_7745c5c3_Err != nil {
36 | return templ_7745c5c3_Err
37 | }
38 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
")
39 | if templ_7745c5c3_Err != nil {
40 | return templ_7745c5c3_Err
41 | }
42 | return templ_7745c5c3_Err
43 | })
44 | }
45 |
46 | var _ = templruntime.GeneratedTemplate
47 |
--------------------------------------------------------------------------------
/components/theme.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import "github.com/jonashiltl/openchangelog/apitypes"
4 |
5 | type ThemeArgs struct {
6 | ColorScheme apitypes.ColorScheme
7 | }
8 |
9 | templ Theme(args ThemeArgs) {
10 | if args.ColorScheme == apitypes.System {
11 |
31 | }
32 |
39 | { children... }
40 |
41 | }
42 |
43 | func getColorSchemeString(cs apitypes.ColorScheme) string {
44 | switch cs {
45 | case apitypes.Light:
46 | return "light"
47 | case apitypes.Dark:
48 | return "dark"
49 | default:
50 | return ""
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/components/toast.templ:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import "github.com/jonashiltl/openchangelog/internal/handler/web/icons"
4 |
5 | type ToastType int
6 |
7 | const (
8 | Success ToastType = iota
9 | Warning
10 | Fail
11 | )
12 |
13 | type ToastArgs struct {
14 | Msg string
15 | Type ToastType
16 | }
17 |
18 | func toastStyle(t ToastType) string {
19 | switch t {
20 | case Warning:
21 | return "o-bg-orange-200 o-border-orange-300 o-text-orange-700"
22 | case Fail:
23 | return "o-bg-red-200 o-border-red-300 o-text-red-800"
24 | default:
25 | return "o-bg-base-100"
26 | }
27 | }
28 |
29 | templ Toast(t ToastType, msg string) {
30 |
31 |
39 |
42 | { msg }
43 |
46 |
47 |
48 |
49 | }
50 |
51 | templ ToastContainer() {
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | openchangelog:
3 | image: ghcr.io/jonashiltl/openchangelog:0.6.2
4 | ports:
5 | - "6001:6001"
6 | volumes:
7 | - type: bind
8 | source: openchangelog.example.yml
9 | target: /etc/openchangelog.yml
--------------------------------------------------------------------------------
/internal/analytics/analytics.go:
--------------------------------------------------------------------------------
1 | package analytics
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/jonashiltl/openchangelog/internal/store"
9 | )
10 |
11 | type Event struct {
12 | Time time.Time `json:"time"`
13 | WID string `json:"wid"`
14 | CID string `json:"cid"`
15 | City string `json:"city"` // ISO 3166
16 | CountryCode string `json:"countryCode"`
17 | ContinentCode string `json:"continentCode"`
18 | Lat float64 `json:"lat"`
19 | Lng float64 `json:"lng"`
20 | AccessDenied bool `json:"accessDenied"` // changelog protected
21 | }
22 |
23 | func NewEvent(r *http.Request, cl store.Changelog) Event {
24 | return newEvent(r, cl, false)
25 | }
26 |
27 | func NewAccessDeniedEvent(r *http.Request, cl store.Changelog) Event {
28 | return newEvent(r, cl, true)
29 | }
30 |
31 | func newEvent(r *http.Request, cl store.Changelog, denied bool) Event {
32 | return Event{
33 | CID: cl.ID.String(),
34 | WID: cl.WorkspaceID.String(),
35 | City: r.Header.Get("cf-ipcity"),
36 | CountryCode: r.Header.Get("cf-ipcountry"),
37 | ContinentCode: r.Header.Get("cf-ipcontinent"),
38 | Lat: parseFloat(r.Header.Get("cf-iplatitude")),
39 | Lng: parseFloat(r.Header.Get("cf-iplongitude")),
40 | AccessDenied: denied,
41 | Time: time.Now(),
42 | }
43 | }
44 |
45 | func parseFloat(str string) float64 {
46 | if str == "" {
47 | return 0
48 | }
49 | f, err := strconv.ParseFloat(str, 64)
50 | if err != nil {
51 | return 0
52 | }
53 | return f
54 | }
55 |
56 | type Emitter interface {
57 | Emit(Event)
58 | }
59 |
--------------------------------------------------------------------------------
/internal/analytics/noop.go:
--------------------------------------------------------------------------------
1 | package analytics
2 |
3 | func NewNoopEmitter() Emitter {
4 | return noopEmitter{}
5 | }
6 |
7 | type noopEmitter struct{}
8 |
9 | func (e noopEmitter) Emit(Event) {}
10 |
--------------------------------------------------------------------------------
/internal/analytics/tinybird/datasources/analytics_events.datasource:
--------------------------------------------------------------------------------
1 | SCHEMA >
2 | `time` DateTime `json:$.time`,
3 | `wid` LowCardinality(String) `json:$.wid`,
4 | `cid` LowCardinality(String) `json:$.cid`,
5 | `city` Nullable(String) `json:$.city`,
6 | `country_code` LowCardinality(Nullable(String)) `json:$.countryCode`,
7 | `continent_code` LowCardinality(Nullable(String)) `json:$.continentCode`,
8 | `lat` Nullable(Float64) `json:$.lat`,
9 | `lng` Nullable(Float64) `json:$.lng`,
10 | `access_denied` Bool `json:$.accessDenied`
11 |
12 | ENGINE "MergeTree"
13 | ENGINE_SORTING_KEY "wid, cid, time"
--------------------------------------------------------------------------------
/internal/analytics/tinybird/tinybird.go:
--------------------------------------------------------------------------------
1 | package tinybird
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "log/slog"
7 | "net/http"
8 | "sync"
9 | "time"
10 |
11 | "github.com/jonashiltl/openchangelog/internal/analytics"
12 | "github.com/jonashiltl/openchangelog/internal/xlog"
13 | "github.com/olivere/ndjson"
14 | )
15 |
16 | const (
17 | data_source = "analytics_events"
18 | events_api = "https://api.tinybird.co/v0/events"
19 | default_flush_interval = 10 * time.Second
20 | default_batch_size = 50
21 | )
22 |
23 | type TinybirdOptions struct {
24 | AccessToken string
25 | }
26 |
27 | func New(opts TinybirdOptions) analytics.Emitter {
28 | b := &bird{
29 | buffer: make([]analytics.Event, 0),
30 | flushInterval: default_flush_interval,
31 | batchSize: default_batch_size,
32 | client: &http.Client{Timeout: time.Second * 10},
33 | opts: opts,
34 | }
35 | go b.startFlusher()
36 | return b
37 | }
38 |
39 | type bird struct {
40 | buffer []analytics.Event
41 | mutex sync.Mutex
42 | flushInterval time.Duration
43 | batchSize int
44 | client *http.Client
45 | opts TinybirdOptions
46 | }
47 |
48 | func (b *bird) Emit(e analytics.Event) {
49 | b.mutex.Lock()
50 | defer b.mutex.Unlock()
51 |
52 | b.buffer = append(b.buffer, e)
53 | if len(b.buffer) >= b.batchSize {
54 | b.flush()
55 | }
56 | }
57 |
58 | func (b *bird) startFlusher() {
59 | ticker := time.NewTicker(b.flushInterval)
60 | defer ticker.Stop()
61 |
62 | for range ticker.C {
63 | b.mutex.Lock()
64 | b.flush()
65 | b.mutex.Unlock()
66 | }
67 | }
68 |
69 | func (b *bird) flush() {
70 | if len(b.buffer) == 0 {
71 | return
72 | }
73 |
74 | batch := b.buffer
75 | b.buffer = make([]analytics.Event, 0)
76 |
77 | go b.sendBatch(batch)
78 | }
79 |
80 | func (b *bird) sendBatch(events []analytics.Event) error {
81 | url := fmt.Sprintf("%s?name=%s", events_api, data_source)
82 |
83 | var buf bytes.Buffer
84 | writer := ndjson.NewWriter(&buf)
85 | for _, event := range events {
86 | if err := writer.Encode(event); err != nil {
87 | return err
88 | }
89 | }
90 |
91 | req, err := http.NewRequest("POST", url, &buf)
92 | if err != nil {
93 | slog.Error("failed create new analytics request to tinybird", xlog.ErrAttr(err))
94 | return err
95 | }
96 |
97 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", b.opts.AccessToken))
98 | req.Header.Set("Content-Type", "application/json")
99 |
100 | resp, err := b.client.Do(req)
101 | if err != nil {
102 | slog.Error("failed to send events to tinybird", xlog.ErrAttr(err))
103 | return err
104 | }
105 | defer resp.Body.Close()
106 |
107 | if resp.StatusCode > http.StatusAccepted {
108 | slog.Error("received error status from tinybird", slog.String("status", resp.Status))
109 | return fmt.Errorf("received error status from tinybird: %s", resp.Status)
110 | }
111 |
112 | return nil
113 | }
114 |
--------------------------------------------------------------------------------
/internal/analytics/tinybird/tinybird_test.go:
--------------------------------------------------------------------------------
1 | package tinybird
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/jonashiltl/openchangelog/internal/analytics"
7 | )
8 |
9 | func TestNew(t *testing.T) {
10 | bird := New(TinybirdOptions{}).(*bird)
11 |
12 | if len(bird.buffer) != 0 {
13 | t.Errorf("Expected buffer to be empty, got %d", len(bird.buffer))
14 | }
15 | }
16 |
17 | func TestEmit(t *testing.T) {
18 | bird := New(TinybirdOptions{}).(*bird)
19 | bird.Emit(analytics.Event{})
20 |
21 | if len(bird.buffer) != 1 {
22 | t.Errorf("Expected buffer length to be %d, got %d", 1, len(bird.buffer))
23 | }
24 | }
25 |
26 | func TestFlush(t *testing.T) {
27 | bird := New(TinybirdOptions{}).(*bird)
28 | bird.Emit(analytics.Event{})
29 | bird.flush()
30 |
31 | if len(bird.buffer) != 0 {
32 | t.Errorf("Expected buffer to be empty, got %d", len(bird.buffer))
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/internal/errs/errors.go:
--------------------------------------------------------------------------------
1 | package errs
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | )
7 |
8 | // Generic errors that can be wrapped in the domain
9 | var (
10 | ErrBadRequest = errors.New("bad request")
11 | ErrNotFound = errors.New("not found")
12 | ErrUnauthorized = errors.New("unauthorized")
13 | ErrServiceUnavailable = errors.New("service unavailable")
14 | )
15 |
16 | type Error struct {
17 | // The specific app error or the service
18 | appErr error
19 | // The generic error type
20 | domainErr error
21 | }
22 |
23 | func (e Error) Status() int {
24 | switch e.domainErr {
25 | case ErrBadRequest:
26 | return http.StatusBadRequest
27 | case ErrNotFound:
28 | return http.StatusNotFound
29 | case ErrUnauthorized:
30 | return http.StatusUnauthorized
31 | case ErrServiceUnavailable:
32 | return http.StatusServiceUnavailable
33 | }
34 |
35 | return http.StatusInternalServerError
36 | }
37 |
38 | func (e Error) Msg() string {
39 | return e.appErr.Error()
40 | }
41 |
42 | func NewError(domainErr error, appErr error) error {
43 | return Error{
44 | appErr: appErr,
45 | domainErr: domainErr,
46 | }
47 | }
48 |
49 | func NewBadRequest(wrapped error) error {
50 | return Error{
51 | appErr: wrapped,
52 | domainErr: ErrBadRequest,
53 | }
54 | }
55 |
56 | func NewNotFound(wrapped error) error {
57 | return Error{
58 | appErr: wrapped,
59 | domainErr: ErrNotFound,
60 | }
61 | }
62 |
63 | func NewUnauthorized(wrapped error) error {
64 | return Error{
65 | appErr: wrapped,
66 | domainErr: ErrUnauthorized,
67 | }
68 | }
69 |
70 | func NewServiceUnavailable(wrapped error) error {
71 | return Error{
72 | appErr: wrapped,
73 | domainErr: ErrServiceUnavailable,
74 | }
75 | }
76 |
77 | func (e Error) AppErr() error {
78 | return e.appErr
79 | }
80 |
81 | func (e Error) DomainErr() error {
82 | return e.domainErr
83 | }
84 |
85 | func (e Error) Error() string {
86 | return errors.Join(e.domainErr, e.appErr).Error()
87 | }
88 |
--------------------------------------------------------------------------------
/internal/events/events.go:
--------------------------------------------------------------------------------
1 | package events
2 |
3 | import (
4 | "github.com/jonashiltl/openchangelog/internal/source"
5 | "github.com/jonashiltl/openchangelog/internal/store"
6 | )
7 |
8 | // Fired if sources data changed
9 | type SourceContentChanged struct {
10 | CL store.Changelog
11 | Source source.Source
12 | }
13 |
14 | type ChangelogUpdated struct {
15 | CL store.Changelog // the updated changelog
16 | Args store.UpdateChangelogArgs
17 | }
18 |
--------------------------------------------------------------------------------
/internal/handler/rest/auth.go:
--------------------------------------------------------------------------------
1 | package rest
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/jonashiltl/openchangelog/internal/errs"
9 | "github.com/jonashiltl/openchangelog/internal/store"
10 | "github.com/jonashiltl/openchangelog/internal/xlog"
11 | )
12 |
13 | type Token struct {
14 | Key string
15 | WorkspaceID store.WorkspaceID
16 | }
17 |
18 | func bearerAuth(e *env, r *http.Request) (Token, error) {
19 | h := r.Header.Get("Authorization")
20 | if h == "" {
21 | return Token{}, errs.NewError(errs.ErrUnauthorized, errors.New("missing authorization header"))
22 | }
23 |
24 | parts := strings.Split(h, " ")
25 | if len(parts) != 2 {
26 | return Token{}, errs.NewError(errs.ErrUnauthorized, errors.New("invalid bearer token format"))
27 | }
28 | key := parts[1]
29 | id, err := e.store.GetWorkspaceIDByToken(r.Context(), key)
30 | if err != nil {
31 | return Token{}, err
32 | }
33 | xlog.AddWorkspaceID(r, id.String())
34 | return Token{
35 | Key: key,
36 | WorkspaceID: id,
37 | }, nil
38 | }
39 |
--------------------------------------------------------------------------------
/internal/handler/rest/routes.go:
--------------------------------------------------------------------------------
1 | package rest
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "net/http"
7 |
8 | "github.com/btvoidx/mint"
9 | "github.com/jonashiltl/openchangelog/internal/errs"
10 | "github.com/jonashiltl/openchangelog/internal/load"
11 | "github.com/jonashiltl/openchangelog/internal/parse"
12 | "github.com/jonashiltl/openchangelog/internal/store"
13 | "github.com/jonashiltl/openchangelog/internal/xlog"
14 | )
15 |
16 | func RegisterRestHandler(mux *http.ServeMux, e *env) {
17 | // Workspace
18 | mux.HandleFunc("POST /api/workspaces", serveHTTP(e, createWorkspace))
19 | mux.HandleFunc("GET /api/workspaces/my", serveHTTP(e, getMyWorkspace))
20 | mux.HandleFunc("GET /api/workspaces/{wid}", serveHTTP(e, getWorkspace))
21 | mux.HandleFunc("PATCH /api/workspaces/{wid}", serveHTTP(e, updateWorkspace))
22 | mux.HandleFunc("DELETE /api/workspaces/{wid}", serveHTTP(e, deleteWorkspace))
23 |
24 | // Sources
25 | mux.HandleFunc("GET /api/sources", serveHTTP(e, listSources))
26 |
27 | // GH sources
28 | mux.HandleFunc("POST /api/sources/gh", serveHTTP(e, createGHSource))
29 | mux.HandleFunc("GET /api/sources/gh", serveHTTP(e, listGHSources))
30 | mux.HandleFunc("GET /api/sources/gh/{id}", serveHTTP(e, getGHSource))
31 | mux.HandleFunc("DELETE /api/sources/gh/{id}", serveHTTP(e, deleteGHSources))
32 |
33 | // changelog
34 | mux.HandleFunc("POST /api/changelogs", serveHTTP(e, createChangelog))
35 | mux.HandleFunc("GET /api/changelogs", serveHTTP(e, listChangelogs))
36 | mux.HandleFunc("GET /api/changelogs/{cid}", serveHTTP(e, getChangelog))
37 | mux.HandleFunc("GET /api/changelogs/{cid}/full", serveHTTP(e, getFullChangelog))
38 | mux.HandleFunc("PATCH /api/changelogs/{cid}", serveHTTP(e, updateChangelog))
39 | mux.HandleFunc("DELETE /api/changelogs/{cid}", serveHTTP(e, deleteChangelog))
40 | mux.HandleFunc("PUT /api/changelogs/{cid}/source/{sid}", serveHTTP(e, setChangelogSource))
41 | mux.HandleFunc("DELETE /api/changelogs/{cid}/source", serveHTTP(e, deleteChangelogSource))
42 | }
43 |
44 | func NewEnv(store store.Store, loader *load.Loader, parser parse.Parser, e *mint.Emitter) *env {
45 | return &env{
46 | store: store,
47 | loader: loader,
48 | parser: parser,
49 | e: e,
50 | }
51 | }
52 |
53 | type env struct {
54 | store store.Store
55 | loader *load.Loader
56 | parser parse.Parser
57 | e *mint.Emitter
58 | }
59 |
60 | func serveHTTP(env *env, h func(e *env, w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
61 | return xlog.AttachLogger(func(w http.ResponseWriter, r *http.Request) {
62 | err := h(env, w, r)
63 |
64 | if err != nil {
65 | status := http.StatusInternalServerError
66 | msg := err.Error()
67 |
68 | var domErr errs.Error
69 | if errors.As(err, &domErr) {
70 | msg = domErr.Msg()
71 | status = domErr.Status()
72 | }
73 |
74 | res := map[string]any{
75 | "message": msg,
76 | "code": status,
77 | }
78 | w.Header().Set("Content-Type", "application/json")
79 | w.WriteHeader(status)
80 | err := json.NewEncoder(w).Encode(res)
81 | if err != nil {
82 | http.Error(w, err.Error(), http.StatusInternalServerError)
83 | }
84 |
85 | xlog.LogRequest(r.Context(), status, msg)
86 | }
87 | })
88 | }
89 |
--------------------------------------------------------------------------------
/internal/handler/rest/workspace.go:
--------------------------------------------------------------------------------
1 | package rest
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "net/http"
7 |
8 | "github.com/jonashiltl/openchangelog/apitypes"
9 | "github.com/jonashiltl/openchangelog/internal/errs"
10 | "github.com/jonashiltl/openchangelog/internal/store"
11 | )
12 |
13 | const (
14 | workspace_id_param = "wid"
15 | )
16 |
17 | func encodeWorkspace(w http.ResponseWriter, ws store.Workspace) error {
18 | res := apitypes.Workspace{
19 | ID: ws.ID.String(),
20 | Name: ws.Name,
21 | Token: ws.Token.String(),
22 | }
23 | w.Header().Set("Content-Type", "application/json")
24 | return json.NewEncoder(w).Encode(res)
25 | }
26 |
27 | func createWorkspace(e *env, w http.ResponseWriter, r *http.Request) error {
28 | var req apitypes.CreateWorkspaceBody
29 | err := json.NewDecoder(r.Body).Decode(&req)
30 | if err != nil {
31 | return err
32 | }
33 |
34 | ws, err := e.store.SaveWorkspace(r.Context(), store.Workspace{
35 | ID: store.NewWID(),
36 | Name: req.Name,
37 | Token: store.NewToken(),
38 | })
39 | if err != nil {
40 | return err
41 | }
42 | return encodeWorkspace(w, ws)
43 | }
44 |
45 | func updateWorkspace(e *env, w http.ResponseWriter, r *http.Request) error {
46 | t, err := bearerAuth(e, r)
47 | if err != nil {
48 | return err
49 | }
50 | wId := r.PathValue(workspace_id_param)
51 | if wId != t.WorkspaceID.String() {
52 | return errNoWorkspace
53 | }
54 |
55 | var req struct {
56 | Name string `json:"name"`
57 | }
58 | err = json.NewDecoder(r.Body).Decode(&req)
59 | if err != nil {
60 | return err
61 | }
62 |
63 | ws, err := e.store.SaveWorkspace(r.Context(), store.Workspace{
64 | ID: t.WorkspaceID,
65 | Name: req.Name,
66 | })
67 | if err != nil {
68 | return err
69 | }
70 |
71 | return encodeWorkspace(w, ws)
72 | }
73 |
74 | var errNoWorkspace = errs.NewError(errs.ErrNotFound, errors.New("workspace not found"))
75 |
76 | func getWorkspace(e *env, w http.ResponseWriter, r *http.Request) error {
77 | t, err := bearerAuth(e, r)
78 | if err != nil {
79 | return err
80 | }
81 |
82 | wId := r.PathValue(workspace_id_param)
83 | if wId != t.WorkspaceID.String() {
84 | return errNoWorkspace
85 | }
86 |
87 | ws, err := e.store.GetWorkspace(r.Context(), t.WorkspaceID)
88 | if err != nil {
89 | return err
90 | }
91 |
92 | return encodeWorkspace(w, ws)
93 | }
94 |
95 | func getMyWorkspace(e *env, w http.ResponseWriter, r *http.Request) error {
96 | t, err := bearerAuth(e, r)
97 | if err != nil {
98 | return err
99 | }
100 |
101 | ws, err := e.store.GetWorkspace(r.Context(), t.WorkspaceID)
102 | if err != nil {
103 | return err
104 | }
105 |
106 | return encodeWorkspace(w, ws)
107 | }
108 |
109 | func deleteWorkspace(e *env, w http.ResponseWriter, r *http.Request) error {
110 | t, err := bearerAuth(e, r)
111 | if err != nil {
112 | return err
113 | }
114 |
115 | wId := r.PathValue(workspace_id_param)
116 | if wId != t.WorkspaceID.String() {
117 | return errNoWorkspace
118 | }
119 |
120 | err = e.store.DeleteWorkspace(r.Context(), t.WorkspaceID)
121 | if err != nil {
122 | return err
123 | }
124 | return nil
125 | }
126 |
--------------------------------------------------------------------------------
/internal/handler/rss/feed.tmpl:
--------------------------------------------------------------------------------
1 | {{- /* feed.templ */ -}}
2 |
3 |
4 |
5 | {{.CL.Title.V}}
6 | {{.CL.Subtitle.V}}
7 | {{ .Link }}
8 | {{ toRFC822 .CL.CreatedAt }}
9 |
10 | {{range .Articles}}
11 | -
12 | {{ addFragment $.Link .Meta.ID }}
13 | {{.Meta.Title}}
14 | {{ addFragment $.Link .Meta.ID }}
15 | {{range .Meta.Tags}}
16 | {{ . }}
17 | {{end}}
18 |
19 | {{.Meta.Description }}
21 | {{.Content}}
22 | ]]>
23 |
24 | {{ toRFC822 .Meta.PublishedAt }}
25 |
26 | {{end}}
27 |
28 |
29 |
--------------------------------------------------------------------------------
/internal/handler/rss/routes.go:
--------------------------------------------------------------------------------
1 | package rss
2 |
3 | import (
4 | "encoding/xml"
5 | "errors"
6 | "net/http"
7 |
8 | "github.com/jonashiltl/openchangelog/internal/config"
9 | "github.com/jonashiltl/openchangelog/internal/errs"
10 | "github.com/jonashiltl/openchangelog/internal/load"
11 | "github.com/jonashiltl/openchangelog/internal/parse"
12 | "github.com/jonashiltl/openchangelog/internal/xlog"
13 | )
14 |
15 | type env struct {
16 | cfg config.Config
17 | loader *load.Loader
18 | parser parse.Parser
19 | }
20 |
21 | func NewEnv(cfg config.Config, loader *load.Loader, parser parse.Parser) *env {
22 | return &env{
23 | cfg: cfg,
24 | loader: loader,
25 | parser: parser,
26 | }
27 | }
28 |
29 | func RegisterRSSHandler(mux *http.ServeMux, e *env) {
30 | mux.HandleFunc("GET /feed", serveHTTP(e, feedHandler))
31 | }
32 |
33 | func serveHTTP(env *env, h func(e *env, w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
34 | return xlog.AttachLogger(func(w http.ResponseWriter, r *http.Request) {
35 | err := h(env, w, r)
36 | if err != nil {
37 | status := http.StatusInternalServerError
38 | msg := err.Error()
39 |
40 | var domErr errs.Error
41 | if errors.As(err, &domErr) {
42 | msg = domErr.AppErr().Error()
43 | switch domErr.DomainErr() {
44 | case errs.ErrBadRequest:
45 | status = http.StatusBadRequest
46 | case errs.ErrNotFound:
47 | status = http.StatusNotFound
48 | case errs.ErrUnauthorized:
49 | status = http.StatusUnauthorized
50 | case errs.ErrServiceUnavailable:
51 | status = http.StatusServiceUnavailable
52 | }
53 | }
54 |
55 | type XMLError struct {
56 | XMLName xml.Name `xml:"xml"`
57 | Message string `xml:"string"`
58 | Code int `xml:"code"`
59 | }
60 |
61 | res := XMLError{
62 | Message: msg,
63 | Code: status,
64 | }
65 | w.Header().Set("Content-Type", "application/xml")
66 | w.WriteHeader(status)
67 | err := xml.NewEncoder(w).Encode(res)
68 | if err != nil {
69 | http.Error(w, err.Error(), http.StatusInternalServerError)
70 | }
71 |
72 | xlog.LogRequest(r.Context(), status, msg)
73 | }
74 | })
75 | }
76 |
--------------------------------------------------------------------------------
/internal/handler/rss/rss.go:
--------------------------------------------------------------------------------
1 | package rss
2 |
3 | import (
4 | _ "embed"
5 | "errors"
6 | "net/http"
7 | "net/url"
8 | "strings"
9 | "text/template"
10 | "time"
11 |
12 | "github.com/jonashiltl/openchangelog/internal"
13 | "github.com/jonashiltl/openchangelog/internal/errs"
14 | "github.com/jonashiltl/openchangelog/internal/handler"
15 | )
16 |
17 | //go:embed feed.tmpl
18 | var feedTemplate string
19 |
20 | func feedHandler(e *env, w http.ResponseWriter, r *http.Request) error {
21 | loaded, err := e.loader.LoadAndParse(r, internal.NoPagination())
22 | if err != nil {
23 | return err
24 | }
25 |
26 | if loaded.CL.Protected {
27 | authorize := r.URL.Query().Get(handler.AUTHORIZE_QUERY)
28 | if authorize == "" {
29 | return errs.NewBadRequest(errors.New("can't load rss feed of protected changelog, specify \"authorize\" query param to subscribe"))
30 | }
31 |
32 | err = handler.ValidatePassword(loaded.CL.PasswordHash, authorize)
33 | if err != nil {
34 | return errs.NewBadRequest(err)
35 | }
36 | }
37 |
38 | tmpl, err := template.
39 | New("feed").
40 | Funcs(template.FuncMap{
41 | "addFragment": addFragment,
42 | "toRFC822": toRFC822,
43 | }).
44 | Parse(feedTemplate)
45 | if err != nil {
46 | return errs.NewBadRequest(errors.New("failed to parse feed template"))
47 | }
48 |
49 | w.Header().Set("Content-Type", "application/rss+xml")
50 | link := handler.FeedToChangelogURL(r)
51 | args := map[string]any{
52 | "CL": loaded.CL,
53 | "Articles": loaded.Notes,
54 | "HasMore": loaded.HasMore,
55 | "Link": strings.ReplaceAll(link, "&", "&"), // & is reserved in xml
56 | }
57 | return tmpl.Execute(w, args)
58 | }
59 |
60 | func toRFC822(t time.Time) string {
61 | return t.Format(time.RFC822)
62 | }
63 |
64 | // Adds a fragment to the specified url
65 | func addFragment(u string, fragment string) string {
66 | parsed, err := url.Parse(u)
67 | if err != nil {
68 | return ""
69 | }
70 | parsed.Fragment = fragment
71 | return parsed.String()
72 | }
73 |
--------------------------------------------------------------------------------
/internal/handler/utils.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/url"
7 | "strconv"
8 | "strings"
9 |
10 | "golang.org/x/crypto/bcrypt"
11 | )
12 |
13 | const (
14 | WS_ID_QUERY = "wid"
15 | CL_ID_QUERY = "cid"
16 | AUTHORIZE_QUERY = "authorize"
17 | )
18 |
19 | // Turns the changelog request into the feed url of the changelog
20 | func GetFeedURL(r *http.Request) string {
21 | rq := r.URL.Query()
22 | // only copy the query params we want, don't want page or page-size
23 | q := url.Values{}
24 | if len(rq.Get(WS_ID_QUERY)) > 0 {
25 | q.Add(WS_ID_QUERY, rq.Get(WS_ID_QUERY))
26 | }
27 | if len(rq.Get(CL_ID_QUERY)) > 0 {
28 | q.Add(CL_ID_QUERY, rq.Get(CL_ID_QUERY))
29 | }
30 | if len(rq.Get(AUTHORIZE_QUERY)) > 0 {
31 | q.Add(AUTHORIZE_QUERY, rq.Get(AUTHORIZE_QUERY))
32 | }
33 |
34 | newURL := &url.URL{
35 | Scheme: r.URL.Scheme,
36 | Host: r.URL.Host,
37 | RawQuery: q.Encode(),
38 | Path: "feed",
39 | }
40 |
41 | if newURL.Host == "" {
42 | newURL.Host = r.Host
43 | }
44 | if strings.Contains(newURL.Host, "localhost") {
45 | newURL.Scheme = "http"
46 | } else {
47 | newURL.Scheme = "https"
48 | }
49 | return newURL.String()
50 | }
51 |
52 | // Turns the rss feed request into the changelog url.
53 | // Done by stripping away the request path (/feed)
54 | func FeedToChangelogURL(r *http.Request) string {
55 | newURL := &url.URL{
56 | Scheme: r.URL.Scheme,
57 | Host: r.URL.Host,
58 | RawQuery: r.URL.RawQuery,
59 | }
60 |
61 | if newURL.Host == "" {
62 | newURL.Host = r.Host
63 | }
64 | if strings.Contains(newURL.Host, "localhost") {
65 | newURL.Scheme = "http"
66 | } else {
67 | newURL.Scheme = "https"
68 | }
69 |
70 | return newURL.String()
71 | }
72 |
73 | // Returns the full url of the current request.
74 | // If request is htmx request (password submit) will use HX-Current-URL
75 | func GetFullURL(r *http.Request) string {
76 | var newURL *url.URL
77 | if r.Header.Get("HX-Current-URL") != "" {
78 | newURL, _ = url.Parse(r.Header.Get("HX-Current-URL"))
79 | } else {
80 | newURL, _ = url.Parse(r.URL.String()) // deep clone (dirty)
81 | }
82 |
83 | if newURL.Host == "" {
84 | newURL.Host = r.Host
85 | }
86 | if strings.Contains(newURL.Host, "localhost") {
87 | newURL.Scheme = "http"
88 | } else {
89 | newURL.Scheme = "https"
90 | }
91 | return newURL.String()
92 | }
93 |
94 | func ParsePagination(q url.Values) (page int, size int) {
95 | const default_page, default_page_size = 1, 10
96 | page, err := strconv.Atoi(q.Get("page"))
97 | if err != nil {
98 | page = default_page
99 | }
100 | pageSize, err := strconv.Atoi(q.Get("page-size"))
101 | if err != nil {
102 | pageSize = default_page_size
103 | }
104 |
105 | return page, pageSize
106 | }
107 |
108 | func ValidatePassword(hash, plaintext string) error {
109 | if hash == "" {
110 | return errors.New("protection is enabled, please configure the password")
111 | }
112 | if plaintext == "" {
113 | return errors.New("missing password")
114 | }
115 |
116 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
117 | if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
118 | return errors.New("invalid password")
119 | }
120 | if err != nil {
121 | return err
122 | }
123 | return nil
124 | }
125 |
--------------------------------------------------------------------------------
/internal/handler/web/admin/details.go:
--------------------------------------------------------------------------------
1 | package admin
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/jonashiltl/openchangelog/internal/handler"
7 | adminviews "github.com/jonashiltl/openchangelog/internal/handler/web/admin/views"
8 | "github.com/jonashiltl/openchangelog/internal/store"
9 | "golang.org/x/sync/errgroup"
10 | )
11 |
12 | func details(e *env, w http.ResponseWriter, r *http.Request) error {
13 | authorize := r.URL.Query().Get(handler.AUTHORIZE_QUERY)
14 | err := handler.ValidatePassword(e.cfg.Admin.PasswordHash, authorize)
15 | if err != nil {
16 | return err
17 | }
18 |
19 | wid, err := store.ParseWID(r.PathValue("wid"))
20 | if err != nil {
21 | return err
22 | }
23 |
24 | var ws store.Workspace
25 | var cls []store.Changelog
26 | var eg errgroup.Group
27 |
28 | eg.Go(func() error {
29 | ws, err = e.st.GetWorkspace(r.Context(), wid)
30 | return err
31 | })
32 | eg.Go(func() error {
33 | cls, err = e.st.ListChangelogs(r.Context(), wid)
34 | return err
35 | })
36 |
37 | err = eg.Wait()
38 | if err != nil {
39 | return err
40 | }
41 |
42 | return adminviews.WorkspaceDetails(adminviews.WorkspaceDetailsArgs{
43 | Workspace: ws,
44 | Changelogs: cls,
45 | }).Render(r.Context(), w)
46 | }
47 |
--------------------------------------------------------------------------------
/internal/handler/web/admin/index.go:
--------------------------------------------------------------------------------
1 | package admin
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/jonashiltl/openchangelog/internal/handler"
7 | adminviews "github.com/jonashiltl/openchangelog/internal/handler/web/admin/views"
8 | "github.com/jonashiltl/openchangelog/internal/handler/web/static"
9 | )
10 |
11 | func adminOverview(e *env, w http.ResponseWriter, r *http.Request) error {
12 | authorize := r.URL.Query().Get(handler.AUTHORIZE_QUERY)
13 | err := handler.ValidatePassword(e.cfg.Admin.PasswordHash, authorize)
14 | if err != nil {
15 | return err
16 | }
17 |
18 | rows, err := e.st.ListWorkspacesChangelogCount(r.Context())
19 | if err != nil {
20 | return err
21 | }
22 |
23 | return adminviews.Overview(adminviews.OverviewArgs{
24 | CSS: static.AdminCSS,
25 | Workspaces: rows,
26 | Authorize: authorize,
27 | }).Render(r.Context(), w)
28 | }
29 |
--------------------------------------------------------------------------------
/internal/handler/web/admin/routes.go:
--------------------------------------------------------------------------------
1 | package admin
2 |
3 | import (
4 | "errors"
5 | "log/slog"
6 | "net/http"
7 |
8 | "github.com/jonashiltl/openchangelog/internal/config"
9 | "github.com/jonashiltl/openchangelog/internal/errs"
10 | "github.com/jonashiltl/openchangelog/internal/store"
11 | )
12 |
13 | func RegisterAdminHandler(mux *http.ServeMux, e *env) {
14 | if e.cfg.Admin == nil {
15 | return
16 | }
17 |
18 | slog.Info("admin view is enabled at /admin")
19 | mux.HandleFunc("GET /admin", serveHTTP(e, adminOverview))
20 | mux.HandleFunc("GET /admin/{wid}", serveHTTP(e, details))
21 | }
22 |
23 | func NewEnv(cfg config.Config, st store.Store) *env {
24 | return &env{
25 | cfg: cfg,
26 | st: st,
27 | }
28 | }
29 |
30 | type env struct {
31 | cfg config.Config
32 | st store.Store
33 | }
34 |
35 | func serveHTTP(env *env, h func(e *env, w http.ResponseWriter, r *http.Request) error) func(http.ResponseWriter, *http.Request) {
36 | return func(w http.ResponseWriter, r *http.Request) {
37 | err := h(env, w, r)
38 | if err != nil {
39 | var domErr errs.Error
40 | if errors.As(err, &domErr) {
41 | http.Error(w, domErr.Msg(), domErr.Status())
42 | return
43 | }
44 |
45 | http.Error(w, err.Error(), http.StatusInternalServerError)
46 | return
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/internal/handler/web/admin/views/overview.templ:
--------------------------------------------------------------------------------
1 | package adminviews
2 |
3 | import (
4 | "fmt"
5 | "github.com/jonashiltl/openchangelog/internal/handler/web/views/layout"
6 | "github.com/jonashiltl/openchangelog/internal/store"
7 | )
8 |
9 | type OverviewArgs struct {
10 | CSS string
11 | Authorize string
12 | Workspaces []store.WorkspaceChangelogCount
13 | }
14 |
15 | templ Overview(args OverviewArgs) {
16 | @layout.Main(layout.MainArgs{
17 | Title: "Admin",
18 | CSS: args.CSS,
19 | IncludeHTMX: true,
20 | }) {
21 |
22 |
23 |
Workspaces
24 |
25 |
26 |
27 | Name |
28 | Changelogs |
29 |
30 |
31 |
32 | for _, ws := range args.Workspaces {
33 |
38 | { ws.Workspace.Name } |
39 | { fmt.Sprint(ws.ChangelogCount) } |
40 |
41 | }
42 |
43 |
44 |
45 |
46 |
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/internal/handler/web/admin/views/workspace_details.templ:
--------------------------------------------------------------------------------
1 | package adminviews
2 |
3 | import (
4 | "fmt"
5 | "github.com/jonashiltl/openchangelog/internal/store"
6 | )
7 |
8 | type WorkspaceDetailsArgs struct {
9 | Workspace store.Workspace
10 | Changelogs []store.Changelog
11 | }
12 |
13 | templ WorkspaceDetails(args WorkspaceDetailsArgs) {
14 |
15 |
{ args.Workspace.Name }
16 |
17 |
Token: { args.Workspace.Token.String() }
18 |
ID: { args.Workspace.ID.String() }
19 |
20 |
21 |
22 |
23 | ID |
24 | Title |
25 | Protected |
26 | Subdomain |
27 | Domain |
28 |
29 |
30 |
31 | for _, cl := range args.Changelogs {
32 |
33 | { cl.ID.String() } |
34 | { cl.Title.V() } |
35 | { fmt.Sprint(cl.Protected) } |
36 | { cl.Subdomain.String() } |
37 | { cl.Domain.String() } |
38 |
39 | }
40 |
41 |
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/internal/handler/web/css/admin.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/internal/handler/web/css/base.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer components {
6 | .input {
7 | @apply o-text-sm o-py-2 o-px-3 o-rounded-md o-border o-text-black/70 dark:o-text-white/80 dark:o-border-white/10 dark:focus-within:o-border-white/20
8 | }
9 |
10 | .btn {
11 | @apply o-inline-flex o-items-center o-justify-center o-whitespace-nowrap o-rounded-md o-text-sm o-font-medium o-h-10 o-px-4 o-py-2
12 | }
13 |
14 | .btn:disabled {
15 | opacity: 0.3;
16 | pointer-events: none;
17 | }
18 |
19 | .btn-primary {
20 | @apply o-bg-primary hover:o-bg-primary/90 o-text-white
21 | }
22 |
23 | .htmx-indicator {
24 | display: none;
25 | }
26 |
27 | .htmx-request .htmx-indicator {
28 | display: inline;
29 | }
30 |
31 | .htmx-request.htmx-indicator {
32 | display: inline;
33 | }
34 |
35 | .htmx-indicator-rev {
36 | display: inline;
37 | }
38 |
39 | .htmx-request .htmx-indicator-rev {
40 | display: none;
41 | }
42 |
43 | .htmx-request.htmx-indicator-rev {
44 | display: none;
45 | }
46 |
47 | input {
48 | background-color: transparent;
49 | }
50 |
51 | input:focus {
52 | outline: none;
53 | }
54 |
55 | form {
56 | margin: 0px;
57 | }
58 |
59 | mark {
60 | @apply o-bg-primary/20 dark:o-bg-primary/40 o-rounded o-text-current
61 | }
62 | }
63 |
64 | :root {
65 | scroll-padding-top: 64px;
66 | scroll-behavior: smooth;
67 | }
--------------------------------------------------------------------------------
/internal/handler/web/details.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "net/http"
7 |
8 | "github.com/jonashiltl/openchangelog/components"
9 | "github.com/jonashiltl/openchangelog/internal"
10 | "github.com/jonashiltl/openchangelog/internal/errs"
11 | "github.com/jonashiltl/openchangelog/internal/handler"
12 | "github.com/jonashiltl/openchangelog/internal/handler/web/static"
13 | "github.com/jonashiltl/openchangelog/internal/handler/web/views"
14 | "github.com/jonashiltl/openchangelog/internal/parse"
15 | )
16 |
17 | func details(e *env, w http.ResponseWriter, r *http.Request) error {
18 | noteID := r.PathValue("nid")
19 | loaded, err := e.loader.LoadAndParse(r, internal.NoPagination())
20 | if err != nil {
21 | return err
22 | }
23 |
24 | if loaded.CL.Protected {
25 | err = ensurePasswordProvided(r, loaded.CL.PasswordHash)
26 | if err != nil {
27 | slog.InfoContext(
28 | r.Context(),
29 | "blocked access to changelog details",
30 | slog.String("changelog", loaded.CL.ID.String()),
31 | slog.String("release", noteID),
32 | )
33 | return views.PasswordProtection(views.PasswordProtectionArgs{
34 | CSS: static.BaseCSS,
35 | ThemeArgs: components.ThemeArgs{
36 | ColorScheme: loaded.CL.ColorScheme.ToApiTypes(),
37 | },
38 | FooterArgs: components.FooterArgs{
39 | HidePoweredBy: loaded.CL.HidePoweredBy,
40 | },
41 | }).Render(r.Context(), w)
42 | }
43 | }
44 |
45 | setCacheControlHeader(r, w, loaded.CL.Protected)
46 |
47 | for i, note := range loaded.Notes {
48 | if note.Meta.ID == noteID {
49 | var prev, next parse.ParsedReleaseNote
50 | if i > 0 {
51 | prev = loaded.Notes[i-1]
52 | }
53 | if i < len(loaded.Notes)-1 {
54 | next = loaded.Notes[i+1]
55 | }
56 |
57 | return e.render.RenderDetails(r.Context(), w, RenderDetailsArgs{
58 | CL: loaded.CL,
59 | ReleaseNote: note,
60 | FeedURL: handler.GetFeedURL(r),
61 | Prev: prev,
62 | Next: next,
63 | HasMetaKey: requestFromMac(r.Header),
64 | })
65 | }
66 | }
67 |
68 | return errs.NewNotFound(fmt.Errorf("release note %s not found", noteID))
69 | }
70 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/chevron_left.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ ChevronLeft(h, w int) {
6 |
15 | }
16 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/chevron_left_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func ChevronLeft(h, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/chevron_right.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ ChevronRight(h, w int) {
6 |
15 | }
16 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/chevron_right_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func ChevronRight(h, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/inbox.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ Inbox(h, w int) {
6 |
15 | }
16 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/inbox_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func Inbox(h, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/key.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ Key(h int, w int) {
6 |
19 | }
20 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/key_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func Key(h int, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/rss.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ RSS(h int, w int) {
6 |
9 | }
10 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/rss_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func RSS(h int, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/search.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ Search(h int, w int) {
6 |
9 | }
10 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/search_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func Search(h int, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/spinner.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ Spinner(h, w int) {
6 |
30 | }
31 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/x.templ:
--------------------------------------------------------------------------------
1 | package icons
2 |
3 | import "fmt"
4 |
5 | templ X(h, w int) {
6 |
15 | }
16 |
--------------------------------------------------------------------------------
/internal/handler/web/icons/x_templ.go:
--------------------------------------------------------------------------------
1 | // Code generated by templ - DO NOT EDIT.
2 |
3 | // templ: version: v0.2.771
4 | package icons
5 |
6 | //lint:file-ignore SA4006 This context is only used if a nested component is present.
7 |
8 | import "github.com/a-h/templ"
9 | import templruntime "github.com/a-h/templ/runtime"
10 |
11 | import "fmt"
12 |
13 | func X(h, w int) templ.Component {
14 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
15 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
16 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
17 | if !templ_7745c5c3_IsBuffer {
18 | defer func() {
19 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
20 | if templ_7745c5c3_Err == nil {
21 | templ_7745c5c3_Err = templ_7745c5c3_BufErr
22 | }
23 | }()
24 | }
25 | ctx = templ.InitializeContext(ctx)
26 | templ_7745c5c3_Var1 := templ.GetChildren(ctx)
27 | if templ_7745c5c3_Var1 == nil {
28 | templ_7745c5c3_Var1 = templ.NopComponent
29 | }
30 | ctx = templ.ClearChildren(ctx)
31 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("")
58 | if templ_7745c5c3_Err != nil {
59 | return templ_7745c5c3_Err
60 | }
61 | return templ_7745c5c3_Err
62 | })
63 | }
64 |
65 | var _ = templruntime.GeneratedTemplate
66 |
--------------------------------------------------------------------------------
/internal/handler/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "gen:base": "npx tailwindcss -c ./tailwind.config.js -i ./css/base.css -o ./static/base.css --minify",
8 | "gen:admin": "npx tailwindcss -c ./tailwind.admin.config.js -i ./css/admin.css -o ./static/admin.css --minify",
9 | "gen:all": "npm run gen:base && npm run gen:admin",
10 | "watch:base": "npm run gen:base -- --watch",
11 | "watch:admin": "npm run gen:admin -- --watch"
12 | },
13 | "keywords": [],
14 | "author": "",
15 | "license": "ISC",
16 | "devDependencies": {
17 | "@tailwindcss/typography": "^0.5.13",
18 | "concurrently": "^8.2.2",
19 | "daisyui": "^4.12.14",
20 | "tailwindcss": "^3.4.3"
21 | },
22 | "dependencies": {
23 | "tailwind-scrollbar-hide": "^1.1.7"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/internal/handler/web/password.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/url"
7 | "strings"
8 |
9 | "github.com/jonashiltl/openchangelog/internal"
10 | "github.com/jonashiltl/openchangelog/internal/handler"
11 | "github.com/jonashiltl/openchangelog/internal/handler/web/views"
12 | )
13 |
14 | func passwordSubmit(e *env, w http.ResponseWriter, r *http.Request) error {
15 | err := r.ParseForm()
16 | if err != nil {
17 | return views.PasswordProtectionError(err.Error()).Render(r.Context(), w)
18 | }
19 |
20 | pw := r.FormValue("password")
21 | if pw == "" {
22 | return views.PasswordProtectionError("missing password").Render(r.Context(), w)
23 | }
24 |
25 | u, err := url.Parse(r.Header.Get("HX-Current-URL"))
26 | if err != nil {
27 | return views.PasswordProtectionError(err.Error()).Render(r.Context(), w)
28 | }
29 |
30 | page, pageSize := handler.ParsePagination(u.Query())
31 | pagination := internal.NewPagination(pageSize, page)
32 | loaded, err := e.loader.LoadAndParse(r, pagination)
33 | if err != nil {
34 | return err
35 | }
36 |
37 | err = handler.ValidatePassword(loaded.CL.PasswordHash, pw)
38 | if err != nil {
39 | return views.PasswordProtectionError(err.Error()).Render(r.Context(), w)
40 | }
41 |
42 | w.Header().Set("HX-Retarget", "body")
43 | // the hashed password does not add any actual security, but we do it for
44 | // obfuscation purposes
45 | setProtectedCookie(r, w, loaded.CL.PasswordHash)
46 |
47 | return e.render.RenderChangelog(r.Context(), w, RenderChangelogArgs{
48 | FeedURL: handler.GetFeedURL(r),
49 | CurrentURL: handler.GetFullURL(r),
50 | CL: loaded.CL,
51 | ReleaseNotes: loaded.Notes,
52 | HasMore: loaded.HasMore,
53 | HasMetaKey: requestFromMac(r.Header),
54 | })
55 | }
56 |
57 | func setProtectedCookie(r *http.Request, w http.ResponseWriter, pwHash string) {
58 | const yearSeconds = 365 * 24 * 60 * 60
59 |
60 | c := &http.Cookie{
61 | Name: createCookieKey(r),
62 | Value: pwHash,
63 | MaxAge: yearSeconds,
64 | Path: "/",
65 | Secure: true,
66 | HttpOnly: true,
67 | SameSite: http.SameSiteStrictMode,
68 | }
69 |
70 | // safari doesn't set secure cookie on localhost
71 | if getHost(r) == "localhost" {
72 | c.Secure = false
73 | }
74 |
75 | http.SetCookie(w, c)
76 | }
77 |
78 | func getProtectedCookieValue(r *http.Request) (string, error) {
79 | c, err := r.Cookie(createCookieKey(r))
80 | if err != nil {
81 | return "", err
82 | }
83 |
84 | return c.Value, nil
85 | }
86 |
87 | func getHost(r *http.Request) string {
88 | host := r.Host
89 | if r.Header.Get("X-Forwarded-Host") != "" {
90 | host = r.Header.Get("X-Forwarded-Host")
91 | }
92 |
93 | // remove port
94 | return strings.Split(host, ":")[0]
95 | }
96 |
97 | func createCookieKey(r *http.Request) string {
98 | host := getHost(r)
99 |
100 | return fmt.Sprintf("protected-%s", host)
101 | }
102 |
--------------------------------------------------------------------------------
/internal/handler/web/password_test.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 | "testing"
7 | )
8 |
9 | func TestCreateCookieKey(t *testing.T) {
10 | tables := []struct {
11 | requestURL string
12 | host string
13 | expected string
14 | }{
15 | {
16 | host: "changelog.openchangelog.com",
17 | expected: "protected-changelog.openchangelog.com",
18 | },
19 | {
20 | requestURL: "/",
21 | host: "openchangelog.com",
22 | expected: "protected-openchangelog.com",
23 | },
24 | }
25 |
26 | for _, table := range tables {
27 | u, err := url.Parse(table.requestURL)
28 | if err != nil {
29 | t.Error(err)
30 | }
31 | r := &http.Request{
32 | URL: u,
33 | Host: table.host,
34 | }
35 | key := createCookieKey(r)
36 | if key != table.expected {
37 | t.Errorf("Expected %s to equal %s", key, table.expected)
38 | }
39 | }
40 | }
41 |
42 | func TestGetHost(t *testing.T) {
43 | tables := []struct {
44 | r *http.Request
45 | expected string
46 | }{
47 | {
48 | r: &http.Request{
49 | Host: "localhost:6001",
50 | },
51 | expected: "localhost",
52 | },
53 | {
54 | r: &http.Request{
55 | Host: "openchangelog.com",
56 | },
57 | expected: "openchangelog.com",
58 | },
59 | {
60 | r: &http.Request{
61 | Host: "subdomain.openchangelog.com",
62 | },
63 | expected: "subdomain.openchangelog.com",
64 | },
65 | }
66 |
67 | for _, table := range tables {
68 | host := getHost(table.r)
69 | if host != table.expected {
70 | t.Errorf("Expected %s to equal %s", host, table.expected)
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/internal/handler/web/search.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/jonashiltl/openchangelog/components"
9 | "github.com/jonashiltl/openchangelog/internal/errs"
10 | "github.com/jonashiltl/openchangelog/internal/search"
11 | "github.com/jonashiltl/openchangelog/internal/source"
12 | )
13 |
14 | func searchSubmit(e *env, w http.ResponseWriter, r *http.Request) error {
15 | err := r.ParseForm()
16 | if err != nil {
17 | return errs.NewBadRequest(err)
18 | }
19 |
20 | cl, err := e.loader.GetChangelog(r)
21 | if err != nil {
22 | return errs.NewBadRequest(err)
23 | }
24 |
25 | if !cl.Searchable {
26 | return errs.NewBadRequest(errors.New("changelog is not searchable"))
27 | }
28 |
29 | if cl.Protected {
30 | err = ensurePasswordProvided(r, cl.PasswordHash)
31 | if err != nil {
32 | return errs.NewUnauthorized(err)
33 | }
34 | }
35 |
36 | sid := source.NewIDFromChangelog(cl)
37 | if sid == "" {
38 | return errs.NewBadRequest(errors.New("changelog has no active source"))
39 | }
40 |
41 | q := r.FormValue("query")
42 | var tags []string
43 | for name, value := range r.PostForm {
44 | if strings.HasPrefix(name, "tag-") && len(value) > 0 && value[0] == "on" {
45 | tags = append(tags, strings.TrimPrefix(name, "tag-"))
46 | }
47 | }
48 |
49 | if q == "" && len(tags) == 0 {
50 | return components.SearchResults(components.SearchResultsArgs{
51 | Result: search.SearchResults{},
52 | }).Render(r.Context(), w)
53 | }
54 |
55 | res, err := e.searcher.Search(r.Context(), search.SearchArgs{
56 | SID: sid.String(),
57 | Query: q,
58 | Tags: tags,
59 | })
60 | if err != nil {
61 | return errs.NewBadRequest(err)
62 | }
63 |
64 | return components.SearchResults(components.SearchResultsArgs{
65 | Query: q,
66 | Result: res,
67 | }).Render(r.Context(), w)
68 | }
69 |
70 | func searchTags(e *env, w http.ResponseWriter, r *http.Request) error {
71 | cl, err := e.loader.GetChangelog(r)
72 | if err != nil {
73 | return errs.NewBadRequest(err)
74 | }
75 |
76 | if !cl.Searchable {
77 | return errs.NewBadRequest(errors.New("changelog is not searchable"))
78 | }
79 |
80 | if cl.Protected {
81 | err = ensurePasswordProvided(r, cl.PasswordHash)
82 | if err != nil {
83 | return errs.NewUnauthorized(err)
84 | }
85 | }
86 |
87 | sid := source.NewIDFromChangelog(cl)
88 | if sid == "" {
89 | return errs.NewBadRequest(errors.New("changelog has no active source"))
90 | }
91 |
92 | tags := e.searcher.GetAllTags(r.Context(), sid.String())
93 | return components.TagSelectors(tags).Render(r.Context(), w)
94 | }
95 |
--------------------------------------------------------------------------------
/internal/handler/web/static/embed.go:
--------------------------------------------------------------------------------
1 | package static
2 |
3 | import (
4 | _ "embed"
5 | )
6 |
7 | //go:embed base.css
8 | var BaseCSS string
9 |
10 | //go:embed admin.css
11 | var AdminCSS string
12 |
--------------------------------------------------------------------------------
/internal/handler/web/tailwind.admin.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme')
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: ["./admin/views/**/*.templ", "./icons/**/*.templ"],
6 | theme: {
7 | extend: {
8 | fontFamily: {
9 | sans: ['Inter var', ...defaultTheme.fontFamily.sans],
10 | },
11 | },
12 | },
13 | prefix: "o-",
14 | plugins: [
15 | require('tailwind-scrollbar-hide'),
16 | require('daisyui'),
17 | ]
18 | }
19 |
20 |
--------------------------------------------------------------------------------
/internal/handler/web/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme')
2 | const plugin = require('tailwindcss/plugin')
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | module.exports = {
6 | content: ["./views/**/*.templ", "./icons/**/*.templ", "../../../components/**/*.templ"],
7 | darkMode: ['selector', '[color-scheme="dark"]'],
8 | theme: {
9 | extend: {
10 | fontFamily: {
11 | sans: ['Inter var', ...defaultTheme.fontFamily.sans],
12 | },
13 | colors: (theme) => ({
14 | "primary": theme.colors.blue[500],
15 | "caption": theme.colors.gray[400]
16 | }),
17 | keyframes: {
18 | "slide-bottom": {
19 | "0%": {
20 | transform: "translateY(100%)"
21 | },
22 | "100%": {
23 | transform: "translateY(0)"
24 | }
25 | },
26 | "slide-right": {
27 | "0%": {
28 | transform: "translateX(0)"
29 | },
30 | "100%": {
31 | transform: "translateX(100%)"
32 | }
33 | }
34 | },
35 | animation: {
36 | "slide-bottom": "slide-bottom 0.2s ease-out",
37 | "slide-right": "slide-right 0.2s ease-in",
38 | },
39 | },
40 | },
41 | prefix: "o-",
42 | safelist: ["quail-image-wrapper"],
43 | plugins: [
44 | require('tailwind-scrollbar-hide'),
45 | require('@tailwindcss/typography'),
46 | plugin(function ({ addVariant }) {
47 | addVariant('htmx-settling', ['&[class~="htmx-settling"]'])
48 | addVariant('htmx-request', ['&[class~="htmx-request"]'])
49 | addVariant('htmx-swapping', ['&[class~="htmx-swapping"]'])
50 | addVariant('htmx-added', ['&[class~="htmx-added"]'])
51 | }),
52 | ],
53 | }
54 |
55 |
--------------------------------------------------------------------------------
/internal/handler/web/views/details.templ:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | import (
4 | "fmt"
5 | "github.com/jonashiltl/openchangelog/components"
6 | "github.com/jonashiltl/openchangelog/internal/handler/web/views/layout"
7 | )
8 |
9 | type DetailsArgs struct {
10 | layout.MainArgs
11 | components.ThemeArgs
12 | components.RSSArgs
13 | components.Logo
14 | components.HeaderArgs
15 | components.ArticleArgs
16 | Prev components.ArticleArgs
17 | Next components.ArticleArgs
18 | components.FooterArgs
19 | ShowSearchButton bool
20 | components.SearchButtonArgs
21 | }
22 |
23 | templ Details(arg DetailsArgs) {
24 | @layout.Main(layout.MainArgs{
25 | Title: arg.MainArgs.Title,
26 | Description: arg.MainArgs.Description,
27 | CSS: arg.MainArgs.CSS,
28 | IncludeHTMX: true,
29 | }) {
30 | @components.Theme(arg.ThemeArgs) {
31 | @components.Navbar() {
32 | @components.LogoImg(arg.Logo)
33 | @components.NavbarActions() {
34 | if arg.ShowSearchButton {
35 | @components.SearchButton(arg.SearchButtonArgs)
36 | }
37 | @components.RSS(arg.RSSArgs)
38 | }
39 | }
40 | @components.Prose() {
41 | @components.ChangelogContainer(components.ChangelogContainerArgs{
42 | HasMoreArticle: false,
43 | }) {
44 | @components.HeaderContainer() {
45 | @components.HeaderContent(arg.HeaderArgs)
46 | }
47 | @components.Article(arg.ArticleArgs)
48 |
49 | if arg.Prev.ID != "" {
50 | @components.BackButton(fmt.Sprintf("/%s", arg.Prev.ID)) {
51 | { arg.Prev.Title }
52 | }
53 | } else {
54 |
55 | }
56 | if arg.Next.ID != "" {
57 | @components.ForwardButton(fmt.Sprintf("/%s", arg.Next.ID)) {
58 | { arg.Next.Title }
59 | }
60 | } else {
61 |
62 | }
63 |
64 | }
65 | @components.Footer(arg.FooterArgs)
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/internal/handler/web/views/error.templ:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | import (
4 | "fmt"
5 | "github.com/jonashiltl/openchangelog/internal/handler/web/views/layout"
6 | )
7 |
8 | type ErrorArgs struct {
9 | Status int
10 | Message string
11 | Path string
12 | CSS string
13 | }
14 |
15 | templ Error(args ErrorArgs) {
16 | @layout.Main(layout.MainArgs{
17 | Title: "Changelog Error",
18 | CSS: args.CSS,
19 | }) {
20 |
21 | { fmt.Sprintf("%d", args.Status) }
22 | { args.Message }
23 | Try Again
24 |
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/internal/handler/web/views/index.templ:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | import (
4 | "github.com/jonashiltl/openchangelog/components"
5 | "github.com/jonashiltl/openchangelog/internal/handler/web/views/layout"
6 | )
7 |
8 | type IndexArgs struct {
9 | layout.MainArgs
10 | components.ThemeArgs
11 | components.RSSArgs
12 | components.Logo
13 | components.ChangelogContainerArgs
14 | components.HeaderArgs
15 | components.ArticleListArgs
16 | components.FooterArgs
17 | ShowSearchButton bool
18 | components.SearchButtonArgs
19 | }
20 |
21 | templ Index(arg IndexArgs) {
22 | @layout.Main(layout.MainArgs{
23 | Title: arg.MainArgs.Title,
24 | Description: arg.MainArgs.Description,
25 | CSS: arg.MainArgs.CSS,
26 | IncludeHTMX: true,
27 | }) {
28 | @components.Theme(arg.ThemeArgs) {
29 | @components.Navbar() {
30 | @components.LogoImg(arg.Logo)
31 | @components.NavbarActions() {
32 | if arg.ShowSearchButton {
33 | @components.SearchButton(arg.SearchButtonArgs)
34 | }
35 | @components.RSS(arg.RSSArgs)
36 | }
37 | }
38 | @components.Prose() {
39 | @components.ChangelogContainer(arg.ChangelogContainerArgs) {
40 | @components.HeaderContainer() {
41 | @components.HeaderContent(arg.HeaderArgs)
42 | }
43 | @components.ArticleList(arg.ArticleListArgs)
44 | }
45 | @components.Footer(arg.FooterArgs)
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/internal/handler/web/views/layout/main.templ:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import (
4 | "github.com/jonashiltl/openchangelog/components"
5 | "html/template"
6 | )
7 |
8 | type MainArgs struct {
9 | Title string
10 | Description string
11 | CSS string
12 | IncludeHTMX bool
13 | }
14 |
15 | var inlinceCSSTemplate = template.Must(template.New("inlinceCSSTemplate").Parse(`
16 |
19 | `,
20 | ))
21 |
22 | templ InlineCSS(css string) {
23 | @templ.FromGoHTML(inlinceCSSTemplate, template.CSS(css))
24 | }
25 |
26 | templ Main(args MainArgs) {
27 |
28 |
29 |
30 |
31 | if args.Title != "" {
32 | { args.Title }
33 | }
34 | if args.Description != "" {
35 |
36 | }
37 |
38 |
39 | // required for the password protection page
40 | if args.IncludeHTMX {
41 |
42 | }
43 | if args.CSS != "" {
44 | @InlineCSS(args.CSS)
45 | }
46 |
47 |
48 | { children... }
49 | @components.ToastContainer()
50 |
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/internal/handler/web/views/password.templ:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | import (
4 | "github.com/jonashiltl/openchangelog/components"
5 | "github.com/jonashiltl/openchangelog/internal/handler/web/icons"
6 | "github.com/jonashiltl/openchangelog/internal/handler/web/views/layout"
7 | )
8 |
9 | type PasswordProtectionArgs struct {
10 | Error string
11 | CSS string
12 | components.ThemeArgs
13 | components.FooterArgs
14 | }
15 |
16 | templ PasswordProtection(args PasswordProtectionArgs) {
17 | @layout.Main(layout.MainArgs{
18 | Title: "Password Protection",
19 | Description: "This changelog is password protected. Please contact your organization admin to receive your password",
20 | CSS: args.CSS,
21 | IncludeHTMX: true,
22 | }) {
23 | @components.Theme(args.ThemeArgs) {
24 | @components.Prose() {
25 |
26 |
27 |
Protected Page
28 |
Enter the password to access this changelog.
29 |
46 |
47 |
48 | @components.Footer(args.FooterArgs)
49 | }
50 | }
51 | }
52 | }
53 |
54 | templ PasswordProtectionError(msg string) {
55 | { msg }
59 | }
60 |
--------------------------------------------------------------------------------
/internal/handler/web/views/widget.templ:
--------------------------------------------------------------------------------
1 | package views
2 |
3 | import (
4 | "github.com/jonashiltl/openchangelog/components"
5 | "github.com/jonashiltl/openchangelog/internal/handler/web/views/layout"
6 | )
7 |
8 | type WidgetArgs struct {
9 | CSS string
10 | components.ChangelogContainerArgs
11 | components.HeaderArgs
12 | components.ArticleListArgs
13 | components.FooterArgs
14 | }
15 |
16 | templ Widget(arg WidgetArgs) {
17 | if arg.CSS != "" {
18 | @layout.InlineCSS(arg.CSS)
19 | }
20 | @components.Prose() {
21 | @components.ChangelogContainer(arg.ChangelogContainerArgs) {
22 | @components.HeaderContainer() {
23 | @components.HeaderContent(arg.HeaderArgs)
24 | }
25 | @components.ArticleList(arg.ArticleListArgs)
26 | }
27 | @components.Footer(arg.FooterArgs)
28 | }
29 | }
30 |
31 | templ WidgetError(err error) {
32 | Error: { err.Error() }
33 | }
34 |
--------------------------------------------------------------------------------
/internal/load/loader.go:
--------------------------------------------------------------------------------
1 | package load
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log/slog"
7 | "net/http"
8 |
9 | mint "github.com/btvoidx/mint/context"
10 | "github.com/jonashiltl/openchangelog/internal"
11 | "github.com/jonashiltl/openchangelog/internal/config"
12 | "github.com/jonashiltl/openchangelog/internal/errs"
13 | "github.com/jonashiltl/openchangelog/internal/events"
14 | "github.com/jonashiltl/openchangelog/internal/parse"
15 | "github.com/jonashiltl/openchangelog/internal/source"
16 | "github.com/jonashiltl/openchangelog/internal/store"
17 | "github.com/jonashiltl/openchangelog/internal/xcache"
18 | "github.com/jonashiltl/openchangelog/internal/xlog"
19 | )
20 |
21 | type LoadedChangelog struct {
22 | CL store.Changelog
23 | Notes []parse.ParsedReleaseNote
24 | HasMore bool
25 | }
26 |
27 | // Creates a new Loader.
28 | func NewLoader(
29 | cfg config.Config,
30 | store store.Store,
31 | cache xcache.Cache,
32 | parser parse.Parser,
33 | e *mint.Emitter,
34 | ) *Loader {
35 | return &Loader{
36 | cfg: cfg,
37 | store: store,
38 | cache: cache,
39 | parser: parser,
40 | e: e,
41 | }
42 | }
43 |
44 | // The loader combines the source and parse package.
45 | // It first loads the raw release notes using the source package and then parses it using the parse package.
46 | type Loader struct {
47 | cfg config.Config
48 | store store.Store
49 | cache xcache.Cache
50 | parser parse.Parser
51 | e *mint.Emitter
52 | }
53 |
54 | // Returns the changelog of the request.
55 | func (l *Loader) GetChangelog(r *http.Request) (store.Changelog, error) {
56 | host := r.Host
57 | if r.Header.Get("X-Forwarded-Host") != "" {
58 | host = r.Header.Get("X-Forwarded-Host")
59 | }
60 |
61 | if l.cfg.IsConfigMode() {
62 | return l.store.GetChangelog(r.Context(), "", "")
63 | }
64 | return l.fromHost(r.Context(), host)
65 | }
66 |
67 | // Loads the changelog and parses it's release notes for the specified http request.
68 | func (l *Loader) LoadAndParse(r *http.Request, page internal.Pagination) (LoadedChangelog, error) {
69 | cl, err := l.GetChangelog(r)
70 | if err != nil {
71 | return LoadedChangelog{}, err
72 | }
73 |
74 | return l.LoadAndParseReleaseNotes(r.Context(), cl, page)
75 | }
76 |
77 | // Loads and parses the release notes for the specified changelog.
78 | func (l *Loader) LoadAndParseReleaseNotes(ctx context.Context, cl store.Changelog, page internal.Pagination) (LoadedChangelog, error) {
79 | s, err := source.NewSourceFromStore(l.cfg, cl, l.cache)
80 | if err != nil {
81 | return LoadedChangelog{}, err
82 | }
83 |
84 | if s != nil {
85 | loaded, err := s.Load(ctx, page)
86 | if err != nil {
87 | return LoadedChangelog{}, err
88 | }
89 | // emit event if release notes have changed
90 | if loaded.HasChanged() {
91 | err = mint.Emit(l.e, ctx, events.SourceContentChanged{
92 | CL: cl,
93 | Source: s,
94 | })
95 | if err != nil {
96 | slog.Debug("failed to emit source changed event", xlog.ErrAttr(err))
97 | }
98 | }
99 | parsed := l.parser.Parse(ctx, loaded.Raw, page)
100 | return LoadedChangelog{
101 | CL: cl,
102 | Notes: parsed.ReleaseNotes,
103 | HasMore: loaded.HasMore || parsed.HasMore,
104 | }, nil
105 | }
106 |
107 | return LoadedChangelog{CL: cl}, nil
108 | }
109 |
110 | func (l *Loader) fromHost(ctx context.Context, host string) (store.Changelog, error) {
111 | subdomain, err1 := store.SubdomainFromHost(host)
112 | domain, err2 := store.ParseDomain(host)
113 | if err1 != nil && err2 != nil {
114 | return store.Changelog{}, errs.NewBadRequest(errors.New("host & subdomain is not a valid url"))
115 | }
116 |
117 | return l.store.GetChangelogByDomainOrSubdomain(ctx, domain, subdomain)
118 | }
119 |
--------------------------------------------------------------------------------
/internal/pagination.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | type Pagination interface {
4 | PageSize() int
5 | Page() int
6 | StartIdx() int
7 | EndIdx() int
8 | // Returns true if the pagination is defined, else false and pagination should be ignored
9 | IsDefined() bool
10 | }
11 |
12 | type pagination struct {
13 | pageSize int
14 | page int
15 | isDefined bool
16 | }
17 |
18 | func NewPagination(pageSize int, page int) Pagination {
19 | return pagination{
20 | pageSize: pageSize,
21 | page: page,
22 | isDefined: true,
23 | }
24 | }
25 |
26 | func NoPagination() Pagination {
27 | return pagination{
28 | isDefined: false,
29 | }
30 | }
31 |
32 | func (p pagination) PageSize() int {
33 | return p.pageSize
34 | }
35 |
36 | func (p pagination) Page() int {
37 | return p.page
38 | }
39 |
40 | func (p pagination) StartIdx() int {
41 | return (p.page - 1) * p.pageSize
42 | }
43 |
44 | func (p pagination) EndIdx() int {
45 | return p.page*p.pageSize - 1
46 | }
47 |
48 | func (p pagination) IsDefined() bool {
49 | return p.isDefined
50 | }
51 |
--------------------------------------------------------------------------------
/internal/parse/og.go:
--------------------------------------------------------------------------------
1 | package parse
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 | "log/slog"
8 |
9 | "github.com/jonashiltl/openchangelog/internal/xlog"
10 | enclave "github.com/quail-ink/goldmark-enclave"
11 | "github.com/yuin/goldmark"
12 | "github.com/yuin/goldmark/extension"
13 | gmparser "github.com/yuin/goldmark/parser"
14 | "github.com/yuin/goldmark/renderer/html"
15 | "go.abhg.dev/goldmark/frontmatter"
16 | "mvdan.cc/xurls/v2"
17 | )
18 |
19 | // This is the original parser that expects one markdown file per release note.
20 | // All the meta information should be defined with Frontmatter.
21 | type ogparser struct {
22 | gm goldmark.Markdown
23 | }
24 |
25 | // Creates a new goldmark instance, used to parse Markdown to HTML.
26 | func CreateGoldmark() goldmark.Markdown {
27 | return goldmark.New(
28 | goldmark.WithRendererOptions(
29 | html.WithUnsafe(),
30 | ),
31 | goldmark.WithExtensions(
32 | extension.GFM,
33 | enclave.New(&enclave.Config{}),
34 | &frontmatter.Extender{},
35 | extension.NewLinkify(
36 | extension.WithLinkifyAllowedProtocols([]string{
37 | "http:",
38 | "https:",
39 | }),
40 | extension.WithLinkifyURLRegexp(
41 | xurls.Strict(),
42 | ),
43 | ),
44 | ),
45 | )
46 | }
47 |
48 | func NewOGParser(gm goldmark.Markdown) *ogparser {
49 | return &ogparser{
50 | gm: gm,
51 | }
52 | }
53 |
54 | // Takes a raw article in our original markdown format and parses it.
55 | func (g *ogparser) parseReleaseNote(article io.Reader) (ParsedReleaseNote, error) {
56 | source, err := io.ReadAll(article)
57 | if err != nil {
58 | return ParsedReleaseNote{}, err
59 | }
60 |
61 | return g.parseReleaseNoteBytes(source)
62 | }
63 |
64 | // Parses the raw article content, but expects a part of the content to be already read (to detect the file format).
65 | func (g *ogparser) parseReleaseNoteRead(read string, rest io.Reader) (ParsedReleaseNote, error) {
66 | source, err := io.ReadAll(rest)
67 | if err != nil {
68 | return ParsedReleaseNote{}, err
69 | }
70 |
71 | full := append([]byte(read), source...)
72 |
73 | return g.parseReleaseNoteBytes(full)
74 | }
75 |
76 | // Don't use diretly, use parseArticle() and parseArticleRead() instead.
77 | func (g *ogparser) parseReleaseNoteBytes(content []byte) (ParsedReleaseNote, error) {
78 | ctx := gmparser.NewContext()
79 |
80 | var target bytes.Buffer
81 | err := g.gm.Convert(content, &target, gmparser.WithContext(ctx))
82 | if err != nil {
83 | slog.Warn("failed to convert to html", xlog.ErrAttr(err))
84 | return ParsedReleaseNote{}, err
85 | }
86 |
87 | data := frontmatter.Get(ctx)
88 | if data == nil {
89 | return ParsedReleaseNote{
90 | Content: &target,
91 | }, nil
92 | }
93 | var meta Meta
94 | err = data.Decode(&meta)
95 | if err != nil {
96 | slog.Warn("failed to parse frontmatter", xlog.ErrAttr(err))
97 | return ParsedReleaseNote{}, err
98 | }
99 |
100 | meta.ID = fmt.Sprint(meta.PublishedAt.Unix())
101 |
102 | return ParsedReleaseNote{
103 | Meta: meta,
104 | Content: &target,
105 | }, nil
106 | }
107 |
--------------------------------------------------------------------------------
/internal/parse/og_test.go:
--------------------------------------------------------------------------------
1 | package parse
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "reflect"
7 | "testing"
8 | "time"
9 | )
10 |
11 | func openOGTestData(name string) (*os.File, error) {
12 | file, err := os.Open(fmt.Sprintf("../../.testdata/%s.md", name))
13 | if err != nil {
14 | return nil, err
15 | }
16 | return file, nil
17 | }
18 |
19 | func TestOGParseArticle(t *testing.T) {
20 | p := NewOGParser(CreateGoldmark())
21 | file, err := openOGTestData("v0.0.1-commonmark")
22 | if err != nil {
23 | t.Fatal(err)
24 | }
25 |
26 | parsed, err := p.parseReleaseNote(file)
27 | if err != nil {
28 | t.Fatal(err)
29 | }
30 |
31 | expectedTags := []string{"Improvement"}
32 | if !reflect.DeepEqual(parsed.Meta.Tags, expectedTags) {
33 | t.Errorf("Expected %s to equal %s", parsed.Meta.Tags, expectedTags)
34 | }
35 |
36 | expectedTitle := "CommonMark 0.31.2 compliance"
37 | if parsed.Meta.Title != expectedTitle {
38 | t.Errorf("Expected %s to equal %s", parsed.Meta.Title, expectedTitle)
39 | }
40 |
41 | expectedPublishedAt := time.Date(2024, 4, 3, 0, 0, 0, 0, time.UTC)
42 | if parsed.Meta.PublishedAt != expectedPublishedAt {
43 | t.Errorf("Expected %s to equal %s", parsed.Meta.PublishedAt, expectedPublishedAt)
44 | }
45 | }
46 |
47 | func TestOGParseArticleRead(t *testing.T) {
48 | p := NewOGParser(CreateGoldmark())
49 | file, err := openOGTestData("v0.0.5-beta")
50 | if err != nil {
51 | t.Fatal(err)
52 | }
53 | _, read := detectFileFormat(file)
54 | parsed, err := p.parseReleaseNoteRead(read, file)
55 | if err != nil {
56 | t.Fatal(err)
57 | }
58 |
59 | expectedTags := []string{"Community", "Cloud"}
60 | if !reflect.DeepEqual(parsed.Meta.Tags, expectedTags) {
61 | t.Errorf("Expected %s to equal %s", parsed.Meta.Tags, expectedTags)
62 | }
63 |
64 | expectedTitle := "Open Beta"
65 | if parsed.Meta.Title != expectedTitle {
66 | t.Errorf("Expected %s to equal %s", parsed.Meta.Title, expectedTitle)
67 | }
68 |
69 | expectedPublishedAt := time.Date(2024, 8, 26, 0, 0, 0, 0, time.UTC)
70 | if parsed.Meta.PublishedAt != expectedPublishedAt {
71 | t.Errorf("Expected %s to equal %s", parsed.Meta.PublishedAt, expectedPublishedAt)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/internal/parse/parser_test.go:
--------------------------------------------------------------------------------
1 | package parse
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "testing"
8 |
9 | "github.com/jonashiltl/openchangelog/internal"
10 | "github.com/jonashiltl/openchangelog/internal/source"
11 | )
12 |
13 | func TestParse(t *testing.T) {
14 | p := NewParser(CreateGoldmark())
15 | tables := []struct {
16 | name string
17 | files []string
18 | page internal.Pagination
19 | expectedHasMore bool
20 | expectedArticleLength int
21 | }{
22 | {
23 | name: "OG one",
24 | files: []string{"v0.0.1-commonmark.md"},
25 | page: internal.NoPagination(),
26 | expectedHasMore: false,
27 | expectedArticleLength: 1,
28 | },
29 | {
30 | name: "OG multiple",
31 | files: []string{"v0.0.1-commonmark.md", "v0.0.2-open-source.md", "v0.0.5-beta.md"},
32 | page: internal.NoPagination(),
33 | expectedHasMore: false,
34 | expectedArticleLength: 3,
35 | },
36 | {
37 | name: "OG pagination not used",
38 | files: []string{"v0.0.1-commonmark.md", "v0.0.2-open-source.md", "v0.0.5-beta.md"},
39 | page: internal.NewPagination(2, 1),
40 | expectedHasMore: false,
41 | expectedArticleLength: 3,
42 | },
43 | {
44 | name: "Keepachangelog no pagination",
45 | files: []string{"keepachangelog/minimal.md"},
46 | page: internal.NoPagination(),
47 | expectedHasMore: false,
48 | expectedArticleLength: 1,
49 | },
50 | {
51 | name: "Keepachangelog full no pagination",
52 | files: []string{"keepachangelog/full.md"},
53 | page: internal.NoPagination(),
54 | expectedHasMore: false,
55 | expectedArticleLength: 15,
56 | },
57 | {
58 | name: "Keepachangelog full paginated",
59 | files: []string{"keepachangelog/full.md"},
60 | page: internal.NewPagination(4, 2),
61 | expectedHasMore: true,
62 | expectedArticleLength: 4,
63 | },
64 | }
65 |
66 | for _, table := range tables {
67 | t.Run(table.name, func(t *testing.T) {
68 | notes := make([]source.RawReleaseNote, 0, len(table.files))
69 | for _, file := range table.files {
70 | content, err := os.Open(fmt.Sprintf("../../.testdata/%s", file))
71 | if err != nil {
72 | t.Fatal(err)
73 | }
74 | notes = append(notes, source.RawReleaseNote{Content: content})
75 | }
76 |
77 | parsed := p.Parse(context.Background(), notes, table.page)
78 | if len(parsed.ReleaseNotes) != table.expectedArticleLength {
79 | t.Errorf("Expected article length %d but got %d", table.expectedArticleLength, len(parsed.ReleaseNotes))
80 | }
81 | if parsed.HasMore != table.expectedHasMore {
82 | t.Errorf("Expected hasMore %t but got %t", table.expectedHasMore, parsed.HasMore)
83 | }
84 | })
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/internal/parse/utils.go:
--------------------------------------------------------------------------------
1 | package parse
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | )
7 |
8 | type FileFormat int
9 |
10 | const (
11 | OG FileFormat = iota
12 | KeepAChangelog
13 | )
14 |
15 | // Detects the file format of r and returns the string read to detect the file format.
16 | // The read string can not be read again from r.
17 | func detectFileFormat(r io.Reader) (FileFormat, string) {
18 | var buf bytes.Buffer
19 | _, err := io.CopyN(&buf, r, 3)
20 | if err != nil {
21 | return OG, ""
22 | }
23 | start := buf.String()
24 | if start == "---" {
25 | // if content has frontmatter => it's probably our own file format
26 | return OG, start
27 | }
28 | return KeepAChangelog, start
29 | }
30 |
31 | // Sorts ParsedArticles by their published date.
32 | func sortArticleDesc(a ParsedReleaseNote, b ParsedReleaseNote) int {
33 | if a.Meta.PublishedAt.IsZero() && b.Meta.PublishedAt.IsZero() {
34 | return 0
35 | }
36 | if a.Meta.PublishedAt.IsZero() {
37 | return -1
38 | }
39 | if b.Meta.PublishedAt.IsZero() {
40 | return 1
41 | }
42 |
43 | if a.Meta.PublishedAt.After(b.Meta.PublishedAt) {
44 | return -1
45 | }
46 |
47 | return 1
48 | }
49 |
--------------------------------------------------------------------------------
/internal/parse/utils_test.go:
--------------------------------------------------------------------------------
1 | package parse
2 |
3 | import (
4 | "io"
5 | "slices"
6 | "strings"
7 | "testing"
8 | "time"
9 | )
10 |
11 | func TestSortArticleDesc(t *testing.T) {
12 | tables := []struct {
13 | name string
14 | a time.Time
15 | b time.Time
16 | aFirst bool
17 | }{
18 | {
19 | name: "a zero",
20 | b: time.Now(),
21 | aFirst: true,
22 | },
23 | {
24 | name: "b zero",
25 | a: time.Now(),
26 | aFirst: false,
27 | },
28 | {
29 | name: "a earlier",
30 | a: time.Date(2024, 10, 20, 0, 0, 0, 0, time.UTC),
31 | b: time.Date(2024, 10, 19, 0, 0, 0, 0, time.UTC),
32 | aFirst: true,
33 | },
34 | {
35 | name: "b earlier",
36 | a: time.Date(2024, 10, 19, 0, 0, 0, 0, time.UTC),
37 | b: time.Date(2024, 10, 20, 0, 0, 0, 0, time.UTC),
38 | },
39 | }
40 |
41 | for _, table := range tables {
42 | t.Run(table.name, func(t *testing.T) {
43 |
44 | slice := []ParsedReleaseNote{
45 | {Meta: Meta{Title: "a", PublishedAt: table.a}},
46 | {Meta: Meta{Title: "b", PublishedAt: table.b}},
47 | }
48 | slices.SortFunc(slice, sortArticleDesc)
49 |
50 | aFirst := slice[0].Meta.Title == "a"
51 | if aFirst != table.aFirst {
52 | t.Error("Expected a to be first but got b")
53 | }
54 | })
55 | }
56 | }
57 |
58 | func TestDetectFileFormat(t *testing.T) {
59 | testCases := []struct {
60 | name string
61 | file string
62 | expected FileFormat
63 | }{
64 | {
65 | name: "OG format with frontmatter",
66 | file: "---\ntitle: Test\n---",
67 | expected: OG,
68 | },
69 | {
70 | name: "KeepAChangelog format",
71 | file: "# Changelog",
72 | expected: KeepAChangelog,
73 | },
74 | {
75 | name: "Empty line",
76 | file: "",
77 | expected: OG,
78 | },
79 | {
80 | name: "Line without frontmatter",
81 | file: "This is a regular line",
82 | expected: KeepAChangelog,
83 | },
84 | }
85 |
86 | for _, tc := range testCases {
87 | t.Run(tc.name, func(t *testing.T) {
88 | r := strings.NewReader(tc.file)
89 | result, read := detectFileFormat(r)
90 | if result != tc.expected {
91 | t.Errorf("Expected %v, but got %v", tc.expected, result)
92 | }
93 |
94 | rest, _ := io.ReadAll(r)
95 | expectedRest, _ := strings.CutPrefix(tc.file, read)
96 | if string(rest) != expectedRest {
97 | t.Errorf("Expected rest=%q got=%q", expectedRest, string(rest))
98 | }
99 | })
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/internal/search/noop.go:
--------------------------------------------------------------------------------
1 | package search
2 |
3 | import "context"
4 |
5 | func NewNoopSearcher() Searcher {
6 | return noopSearcher{}
7 | }
8 |
9 | type noopSearcher struct{}
10 |
11 | func (s noopSearcher) Close() {
12 |
13 | }
14 |
15 | func (s noopSearcher) Index(context.Context, IndexArgs) error {
16 | return nil
17 | }
18 |
19 | func (s noopSearcher) BatchIndex(context.Context, BatchIndexArgs) error {
20 | return nil
21 | }
22 |
23 | func (s noopSearcher) Search(context.Context, SearchArgs) (SearchResults, error) {
24 | return SearchResults{}, nil
25 | }
26 |
27 | func (s noopSearcher) GetAllTags(ctx context.Context, sid string) []string {
28 | return []string{}
29 | }
30 |
31 | func (s noopSearcher) BatchRemove(ctx context.Context, args BatchRemoveArgs) error {
32 | return nil
33 | }
34 |
--------------------------------------------------------------------------------
/internal/source/source.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "io"
7 |
8 | "github.com/jonashiltl/openchangelog/internal"
9 | "github.com/jonashiltl/openchangelog/internal/config"
10 | "github.com/jonashiltl/openchangelog/internal/store"
11 | "github.com/jonashiltl/openchangelog/internal/xcache"
12 | )
13 |
14 | type RawReleaseNote struct {
15 | Content io.Reader
16 | hasChanged bool // only available if caching is enabled
17 | }
18 |
19 | type LoadResult struct {
20 | Raw []RawReleaseNote
21 | HasMore bool
22 | }
23 |
24 | // Returns if any of the loaded release notes have changed since last access.
25 | func (r LoadResult) HasChanged() bool {
26 | for _, note := range r.Raw {
27 | if note.hasChanged {
28 | return true
29 | }
30 | }
31 | return false
32 | }
33 |
34 | // A source can be used to load raw release notes from a (remote) source like GitHub.
35 | type Source interface {
36 | Load(ctx context.Context, page internal.Pagination) (LoadResult, error)
37 | ID() ID
38 | }
39 |
40 | // A unique identifier for a source
41 | type ID string
42 |
43 | func (i ID) String() string {
44 | return string(i)
45 | }
46 |
47 | func NewIDFromChangelog(cl store.Changelog) ID {
48 | if cl.LocalSource.Valid {
49 | return NewLocalID(cl.LocalSource.V.Path)
50 | } else if cl.GHSource.Valid {
51 | return NewGitHubID(cl.GHSource.V.Owner, cl.GHSource.V.Repo, cl.GHSource.V.Path)
52 | }
53 | return ""
54 | }
55 |
56 | func NewSourceFromStore(cfg config.Config, cl store.Changelog, cache xcache.Cache) (Source, error) {
57 | if cl.LocalSource.Valid {
58 | return NewLocalSourceFromStore(cl.LocalSource.ValueOrZero(), cache), nil
59 | } else if cl.GHSource.Valid {
60 | return NewGHSourceFromStore(cfg, cl.GHSource.ValueOrZero(), cache)
61 | }
62 | return nil, errors.New("changelog has no active source")
63 | }
64 |
--------------------------------------------------------------------------------
/internal/store/color_scheme.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "database/sql/driver"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/jonashiltl/openchangelog/apitypes"
9 | )
10 |
11 | type ColorScheme int
12 |
13 | const (
14 | System ColorScheme = 1
15 | Light ColorScheme = 2
16 | Dark ColorScheme = 3
17 | )
18 |
19 | func NewColorScheme(cs apitypes.ColorScheme) ColorScheme {
20 | switch cs {
21 | case apitypes.System:
22 | return System
23 | case apitypes.Dark:
24 | return Dark
25 | case apitypes.Light:
26 | return Light
27 | }
28 | return 0
29 | }
30 |
31 | func (cs ColorScheme) String() string {
32 | switch cs {
33 | case System:
34 | return "system"
35 | case Light:
36 | return "light"
37 | case Dark:
38 | return "dark"
39 | }
40 | return "unkown"
41 | }
42 |
43 | func (cs ColorScheme) ToApiTypes() apitypes.ColorScheme {
44 | switch cs {
45 | case System:
46 | return apitypes.System
47 | case Dark:
48 | return apitypes.Dark
49 | case Light:
50 | return apitypes.Light
51 | }
52 | return apitypes.System
53 | }
54 |
55 | func (cs *ColorScheme) Scan(value interface{}) error {
56 | i, ok := value.(int64)
57 | if !ok {
58 | return errors.New("ColorScheme.Scan: value is not an int64")
59 | }
60 |
61 | switch ColorScheme(i) {
62 | case System, Light, Dark:
63 | *cs = ColorScheme(i)
64 | return nil
65 | default:
66 | return fmt.Errorf("ColorScheme.Scan: failed to scan %d", i)
67 | }
68 | }
69 |
70 | func (cs ColorScheme) Value() (driver.Value, error) {
71 | return int64(cs), nil
72 | }
73 |
--------------------------------------------------------------------------------
/internal/store/color_scheme_test.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestColorSchemeValue(t *testing.T) {
8 | tests := []struct {
9 | scheme ColorScheme
10 | expected int64
11 | }{
12 | {
13 | scheme: Dark,
14 | expected: 3,
15 | },
16 | {
17 | scheme: Light,
18 | expected: 2,
19 | },
20 | {
21 | scheme: System,
22 | expected: 1,
23 | },
24 | }
25 |
26 | for _, test := range tests {
27 | t.Run(test.scheme.String(), func(t *testing.T) {
28 | v, err := test.scheme.Value()
29 | if err != nil {
30 | t.Error(err)
31 | }
32 | if v.(int64) != test.expected {
33 | t.Errorf("Expected %d to equal %d", v, test.expected)
34 | }
35 | })
36 | }
37 | }
38 |
39 | func TestColorSchemeScan(t *testing.T) {
40 | schemes := []ColorScheme{
41 | System, Dark, Light,
42 | }
43 |
44 | for _, input := range schemes {
45 | t.Run(input.String(), func(t *testing.T) {
46 | v, err := input.Value()
47 | if err != nil {
48 | t.Error(err)
49 | }
50 |
51 | var scanned ColorScheme
52 | err = scanned.Scan(v)
53 | if err != nil {
54 | t.Error(err)
55 | }
56 |
57 | if scanned != input {
58 | t.Errorf("Expected %s to equal %s", scanned, input)
59 | }
60 | })
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/internal/store/db.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.26.0
4 |
5 | package store
6 |
7 | import (
8 | "context"
9 | "database/sql"
10 | )
11 |
12 | type DBTX interface {
13 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14 | PrepareContext(context.Context, string) (*sql.Stmt, error)
15 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17 | }
18 |
19 | func New(db DBTX) *Queries {
20 | return &Queries{db: db}
21 | }
22 |
23 | type Queries struct {
24 | db DBTX
25 | }
26 |
27 | func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28 | return &Queries{
29 | db: tx,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/internal/store/domain.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/url"
7 | "regexp"
8 | "strings"
9 |
10 | "github.com/gosimple/slug"
11 | "github.com/jonashiltl/openchangelog/apitypes"
12 | "github.com/jonashiltl/openchangelog/internal/errs"
13 | "github.com/sio/coolname"
14 | "golang.org/x/exp/rand"
15 | )
16 |
17 | type Domain apitypes.NullString
18 |
19 | func (d Domain) String() string {
20 | return d.NullString().V()
21 | }
22 |
23 | func (d Domain) NullString() apitypes.NullString {
24 | return apitypes.NullString(d)
25 | }
26 |
27 | var errInvalidDomain = errs.NewBadRequest(errors.New("domain is not valid"))
28 |
29 | // strips everything from domain except the host
30 | func ParseDomain(domain string) (Domain, error) {
31 | if !strings.Contains(domain, ".") {
32 | return Domain{}, errInvalidDomain
33 | }
34 | if !strings.Contains(domain, "://") {
35 | domain = "http://" + domain // Add a default scheme, else host is empty
36 | }
37 |
38 | parsedUrl, err := url.Parse(domain)
39 | if err != nil {
40 | return Domain{}, errInvalidDomain
41 | }
42 |
43 | domain = parsedUrl.Host
44 | return Domain(apitypes.NewString(domain)), nil
45 | }
46 |
47 | // if ns is valid, it parses the domain by stripping everything except the host from the string.
48 | func ParseDomainNullString(ns apitypes.NullString) (Domain, error) {
49 | if !ns.IsValid() {
50 | return Domain(ns), nil
51 | }
52 | return ParseDomain(ns.V())
53 | }
54 |
55 | type Subdomain string
56 |
57 | func (s Subdomain) String() string {
58 | return string(s)
59 | }
60 |
61 | func (s Subdomain) NullString() apitypes.NullString {
62 | return apitypes.NewString(s.String())
63 | }
64 |
65 | func NewSubdomain(workspaceName string) Subdomain {
66 | suffix, err := coolname.SlugN(2)
67 | if err != nil {
68 | suffix = fmt.Sprint(rand.Intn(100000))
69 | }
70 |
71 | subdomain := slug.Make(fmt.Sprintf("%s %s", workspaceName, suffix))
72 | return Subdomain(subdomain)
73 | }
74 |
75 | var subdomainRegex = regexp.MustCompile("^[a-z0-9-]*$")
76 |
77 | // Returns the subdomain from the host.
78 | // Returns an error if the host doesn't have a subdomain
79 | func SubdomainFromHost(host string) (Subdomain, error) {
80 | // add scheme, else parsed url won't include host
81 | if !strings.Contains(host, "://") {
82 | host = "https://" + host
83 | }
84 |
85 | parsedURL, err := url.Parse(host)
86 | if err != nil {
87 | return "", errs.NewBadRequest(errors.New("invalid URL"))
88 | }
89 |
90 | // Extract the host from the parsed URL
91 | host = parsedURL.Host
92 | parts := strings.Split(host, ".")
93 | if parts[0] == "www" {
94 | parts = parts[1:]
95 | }
96 |
97 | // subdomain exists, e.g. tenant.openchangelog.com
98 | if len(parts) > 2 {
99 | if !subdomainRegex.MatchString(parts[0]) {
100 | return "", errs.NewBadRequest(errors.New("subdomain not valid"))
101 | }
102 | return Subdomain(parts[0]), nil
103 | }
104 | return "", errs.NewBadRequest(errors.New("host has no subdomain"))
105 | }
106 |
--------------------------------------------------------------------------------
/internal/store/domain_test.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/jonashiltl/openchangelog/apitypes"
7 | )
8 |
9 | func TestParseDomain(t *testing.T) {
10 | tables := []struct {
11 | host string
12 | expected apitypes.NullString
13 | expectErr bool
14 | }{
15 |
16 | {
17 | host: "openchangelog.com",
18 | expected: apitypes.NewString("openchangelog.com"),
19 | },
20 | {
21 | host: "changelog.openchangelog.com",
22 | expected: apitypes.NewString("changelog.openchangelog.com"),
23 | },
24 | {
25 | host: "changelog.openchangelog.com:3000",
26 | expected: apitypes.NewString("changelog.openchangelog.com:3000"),
27 | },
28 | {
29 | host: "https://changelog.openchangelog.com",
30 | expected: apitypes.NewString("changelog.openchangelog.com"),
31 | },
32 | {
33 | host: "http://changelog.openchangelog.com",
34 | expected: apitypes.NewString("changelog.openchangelog.com"),
35 | },
36 | {
37 | host: "https://test com",
38 | expectErr: true,
39 | },
40 | {
41 | host: "openchangelog",
42 | expectErr: true,
43 | },
44 | }
45 |
46 | for _, table := range tables {
47 | t.Run(table.host, func(t *testing.T) {
48 | d, err := ParseDomain(table.host)
49 | if table.expectErr && err == nil {
50 | t.Error("expected to error but no error returned")
51 | }
52 | if d.String() != table.expected.V() {
53 | t.Errorf("expected %s to equal %s", d.String(), table.expected.V())
54 | }
55 | })
56 | }
57 | }
58 |
59 | func TestParseSubdomain(t *testing.T) {
60 | tables := []struct {
61 | host string
62 | subdomain string
63 | }{
64 | {
65 | host: "tenant.openchangelog.com",
66 | subdomain: "tenant",
67 | },
68 | {
69 | host: "tenant-2.openchangelog.com",
70 | subdomain: "tenant-2",
71 | },
72 | {
73 | host: "https://changelog.test.com",
74 | subdomain: "changelog",
75 | },
76 | {
77 | host: "https://changelog.test.com:6001",
78 | subdomain: "changelog",
79 | },
80 | {
81 | host: "openchangelog.com",
82 | subdomain: "",
83 | },
84 | {
85 | host: "www.openchangelog.com",
86 | subdomain: "",
87 | },
88 | {
89 | host: "",
90 | subdomain: "",
91 | },
92 | {
93 | host: ".",
94 | subdomain: "",
95 | },
96 | {
97 | host: ".com",
98 | subdomain: "",
99 | },
100 | }
101 |
102 | for _, table := range tables {
103 | t.Run(table.host, func(t *testing.T) {
104 | s, _ := SubdomainFromHost(table.host)
105 | if table.subdomain != s.String() {
106 | t.Fatalf("expected %s to equal %s", s, table.subdomain)
107 | }
108 | })
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/internal/store/id.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "errors"
5 | "strings"
6 |
7 | "github.com/jonashiltl/openchangelog/internal/errs"
8 | "github.com/rs/xid"
9 | )
10 |
11 | const (
12 | wid_prefix = "ws"
13 | cid_prefix = "cl"
14 | ghid_prefix = "gh"
15 | id_separator = "_"
16 | )
17 |
18 | type WorkspaceID string
19 |
20 | func NewWID() WorkspaceID {
21 | return WorkspaceID(wid_prefix + id_separator + xid.New().String())
22 | }
23 |
24 | var errWSFormat = errs.NewError(errs.ErrBadRequest, errors.New("wrong workspace id format"))
25 |
26 | func ParseWID(id string) (WorkspaceID, error) {
27 | if id == WS_DEFAULT_ID.String() {
28 | return WS_DEFAULT_ID, nil
29 | }
30 |
31 | parts := strings.Split(id, id_separator)
32 | if len(parts) != 2 {
33 | return "", errWSFormat
34 | }
35 | if parts[0] != wid_prefix {
36 | return "", errs.NewError(errs.ErrBadRequest, errors.New("invalid workspace id prefix"))
37 | }
38 | _, err := xid.FromString(parts[1])
39 | if err != nil {
40 | return "", errWSFormat
41 | }
42 | return WorkspaceID(id), nil
43 | }
44 |
45 | func (i WorkspaceID) String() string {
46 | return string(i)
47 | }
48 |
49 | type ChangelogID string
50 |
51 | func NewCID() ChangelogID {
52 | return ChangelogID(cid_prefix + id_separator + xid.New().String())
53 | }
54 |
55 | var errCLFormat = errs.NewError(errs.ErrBadRequest, errors.New("wrong changelog id format"))
56 |
57 | func ParseCID(id string) (ChangelogID, error) {
58 | if id == CL_DEFAULT_ID.String() {
59 | return CL_DEFAULT_ID, nil
60 | }
61 | parts := strings.Split(id, id_separator)
62 | if len(parts) != 2 {
63 | return "", errCLFormat
64 | }
65 | if parts[0] != cid_prefix {
66 | return "", errs.NewError(errs.ErrBadRequest, errors.New("invalid changelog id prefix"))
67 | }
68 | _, err := xid.FromString(parts[1])
69 | if err != nil {
70 | return "", errCLFormat
71 | }
72 | return ChangelogID(id), nil
73 | }
74 |
75 | func (i ChangelogID) String() string {
76 | return string(i)
77 | }
78 |
79 | type GHSourceID string
80 |
81 | func NewGHID() GHSourceID {
82 | return GHSourceID(ghid_prefix + id_separator + xid.New().String())
83 | }
84 |
85 | var errGHFormat = errs.NewError(errs.ErrBadRequest, errors.New("wrong github source id format"))
86 |
87 | func ParseGHID(id string) (GHSourceID, error) {
88 | if id == GH_DEFAULT_ID.String() {
89 | return GH_DEFAULT_ID, nil
90 | }
91 | parts := strings.Split(id, id_separator)
92 | if len(parts) != 2 {
93 | return "", errGHFormat
94 | }
95 | if parts[0] != ghid_prefix {
96 | return "", errs.NewError(errs.ErrBadRequest, errors.New("invalid gh source id prefix"))
97 | }
98 | _, err := xid.FromString(parts[1])
99 | if err != nil {
100 | return "", errGHFormat
101 | }
102 | return GHSourceID(id), nil
103 | }
104 |
105 | func IsGHID(id string) bool {
106 | return strings.HasPrefix(id, ghid_prefix+id_separator)
107 | }
108 |
109 | func (i GHSourceID) String() string {
110 | return string(i)
111 | }
112 |
--------------------------------------------------------------------------------
/internal/store/models.go:
--------------------------------------------------------------------------------
1 | // Code generated by sqlc. DO NOT EDIT.
2 | // versions:
3 | // sqlc v1.26.0
4 |
5 | package store
6 |
7 | import (
8 | "database/sql"
9 |
10 | "github.com/jonashiltl/openchangelog/apitypes"
11 | )
12 |
13 | type changelog struct {
14 | ID string
15 | WorkspaceID string
16 | Subdomain string
17 | Title apitypes.NullString
18 | Subtitle apitypes.NullString
19 | SourceID apitypes.NullString
20 | LogoSrc apitypes.NullString
21 | LogoLink apitypes.NullString
22 | LogoAlt apitypes.NullString
23 | LogoHeight apitypes.NullString
24 | LogoWidth apitypes.NullString
25 | CreatedAt int64
26 | Domain apitypes.NullString
27 | ColorScheme ColorScheme
28 | HidePoweredBy int64
29 | Protected int64
30 | PasswordHash apitypes.NullString
31 | Analytics int64
32 | Searchable int64
33 | }
34 |
35 | type changelogSource struct {
36 | ID apitypes.NullString
37 | WorkspaceID apitypes.NullString
38 | Owner apitypes.NullString
39 | Repo apitypes.NullString
40 | Path apitypes.NullString
41 | InstallationID sql.NullInt64
42 | }
43 |
44 | type ghSource struct {
45 | ID string
46 | WorkspaceID string
47 | Owner string
48 | Repo string
49 | Path string
50 | InstallationID int64
51 | }
52 |
53 | type token struct {
54 | Key string
55 | WorkspaceID string
56 | }
57 |
58 | type workspace struct {
59 | ID string
60 | Name string
61 | }
62 |
--------------------------------------------------------------------------------
/internal/store/store.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/jonashiltl/openchangelog/apitypes"
8 | _ "github.com/mattn/go-sqlite3"
9 |
10 | "github.com/guregu/null/v5"
11 | )
12 |
13 | type Changelog struct {
14 | WorkspaceID WorkspaceID
15 | ID ChangelogID
16 | Subdomain Subdomain
17 | Domain Domain
18 | Title apitypes.NullString
19 | Subtitle apitypes.NullString
20 | LogoSrc apitypes.NullString
21 | LogoLink apitypes.NullString
22 | LogoAlt apitypes.NullString
23 | LogoHeight apitypes.NullString
24 | LogoWidth apitypes.NullString
25 | ColorScheme ColorScheme
26 | Analytics bool
27 | HidePoweredBy bool
28 | Protected bool
29 | Searchable bool
30 | PasswordHash string
31 | CreatedAt time.Time
32 | GHSource null.Value[GHSource]
33 | LocalSource null.Value[LocalSource]
34 | }
35 |
36 | type Workspace struct {
37 | ID WorkspaceID
38 | Name string
39 | Token Token
40 | }
41 |
42 | type GHSource struct {
43 | ID GHSourceID
44 | WorkspaceID WorkspaceID
45 | Owner string
46 | Repo string
47 | Path string
48 | InstallationID int64
49 | }
50 |
51 | type LocalSource struct {
52 | Path string
53 | }
54 |
55 | type UpdateChangelogArgs struct {
56 | Title apitypes.NullString
57 | Subdomain apitypes.NullString
58 | Domain Domain
59 | Subtitle apitypes.NullString
60 | LogoSrc apitypes.NullString
61 | LogoLink apitypes.NullString
62 | LogoAlt apitypes.NullString
63 | LogoHeight apitypes.NullString
64 | LogoWidth apitypes.NullString
65 | ColorScheme ColorScheme
66 | HidePoweredBy *bool
67 | Protected *bool
68 | Analytics *bool
69 | Searchable *bool
70 | PasswordHash apitypes.NullString
71 | }
72 |
73 | type WorkspaceChangelogCount struct {
74 | Workspace Workspace
75 | ChangelogCount int64
76 | }
77 |
78 | type Store interface {
79 | GetChangelog(context.Context, WorkspaceID, ChangelogID) (Changelog, error)
80 | GetChangelogByDomainOrSubdomain(ctx context.Context, domain Domain, subdomain Subdomain) (Changelog, error)
81 | ListChangelogs(context.Context, WorkspaceID) ([]Changelog, error)
82 | CreateChangelog(context.Context, Changelog) (Changelog, error)
83 | UpdateChangelog(context.Context, WorkspaceID, ChangelogID, UpdateChangelogArgs) (Changelog, error)
84 | DeleteChangelog(context.Context, WorkspaceID, ChangelogID) error
85 | SetChangelogGHSource(context.Context, WorkspaceID, ChangelogID, GHSourceID) error
86 | DeleteChangelogSource(context.Context, WorkspaceID, ChangelogID) error
87 |
88 | // Workspace
89 | GetWorkspace(context.Context, WorkspaceID) (Workspace, error)
90 | SaveWorkspace(context.Context, Workspace) (Workspace, error)
91 | GetWorkspaceIDByToken(ctx context.Context, token string) (WorkspaceID, error)
92 | DeleteWorkspace(context.Context, WorkspaceID) error
93 |
94 | // admin only methods
95 | ListWorkspacesChangelogCount(context.Context) ([]WorkspaceChangelogCount, error)
96 |
97 | // Source
98 | CreateGHSource(context.Context, GHSource) (GHSource, error)
99 | GetGHSource(context.Context, WorkspaceID, GHSourceID) (GHSource, error)
100 | ListGHSources(context.Context, WorkspaceID) ([]GHSource, error)
101 | DeleteGHSource(context.Context, WorkspaceID, GHSourceID) error
102 | }
103 |
--------------------------------------------------------------------------------
/internal/store/token.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "crypto/md5"
5 | "encoding/hex"
6 | "errors"
7 | "strings"
8 |
9 | "github.com/jonashiltl/openchangelog/internal/errs"
10 | "github.com/rs/xid"
11 | )
12 |
13 | const (
14 | token_prefix = "tkn"
15 | )
16 |
17 | type Token string
18 |
19 | func NewToken() Token {
20 | id := xid.New()
21 | hasher := md5.New()
22 | hasher.Write(id.Bytes())
23 |
24 | return Token("tkn_" + hex.EncodeToString(hasher.Sum(nil)))
25 | }
26 |
27 | var errKeyFormat = errs.NewError(errs.ErrBadRequest, errors.New("wrong token key format"))
28 |
29 | func ParseToken(key string) (Token, error) {
30 | parts := strings.Split(key, id_separator)
31 | if len(parts) != 2 {
32 | return "", errKeyFormat
33 | }
34 | if parts[0] != token_prefix {
35 | return "", errs.NewError(errs.ErrBadRequest, errors.New("invalid token key prefix"))
36 | }
37 | _, err := xid.FromString(parts[1])
38 | if err != nil {
39 | return "", errKeyFormat
40 | }
41 | return Token(key), nil
42 | }
43 |
44 | func (k Token) String() string {
45 | return string(k)
46 | }
47 |
48 | func (k Token) IsSet() bool {
49 | return string(k) != ""
50 | }
51 |
--------------------------------------------------------------------------------
/internal/xcache/cache.go:
--------------------------------------------------------------------------------
1 | package xcache
2 |
3 | import (
4 | "github.com/gregjones/httpcache"
5 | "github.com/gregjones/httpcache/diskcache"
6 | "github.com/jonashiltl/openchangelog/internal/config"
7 | "github.com/peterbourgon/diskv"
8 | "github.com/sourcegraph/s3cache"
9 | )
10 |
11 | type Cache = httpcache.Cache
12 |
13 | func NewS3Cache(bucket string) httpcache.Cache {
14 | return s3cache.New(bucket)
15 | }
16 |
17 | func NewDiskCache(cfg config.Config) httpcache.Cache {
18 | return diskcache.NewWithDiskv(diskv.New(diskv.Options{
19 | BasePath: cfg.Cache.Disk.Location,
20 | CacheSizeMax: cfg.Cache.Disk.MaxSize, // bytes
21 | }))
22 | }
23 |
24 | func NewMemoryCache() httpcache.Cache {
25 | return httpcache.NewMemoryCache()
26 | }
27 |
--------------------------------------------------------------------------------
/internal/xlog/log.go:
--------------------------------------------------------------------------------
1 | package xlog
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "net/http"
7 | "time"
8 |
9 | "github.com/rs/xid"
10 | )
11 |
12 | type contextKey string
13 |
14 | func (c contextKey) String() string {
15 | return "handler context key " + string(c)
16 | }
17 |
18 | var (
19 | ctxKeyRequestID = contextKey("request-id")
20 | ctxKeyWorkspaceID = contextKey("workspace-id")
21 | ctxKeyRequestPath = contextKey("request-path")
22 | ctxKeyRequestMethod = contextKey("request-method")
23 | ctxKeyRequestStart = contextKey("request-start")
24 | ctxKeyRequestHost = contextKey("request-host")
25 | )
26 |
27 | // Attaches logger attributes to the handler.
28 | func AttachLogger(fn http.HandlerFunc) http.HandlerFunc {
29 | return func(w http.ResponseWriter, r *http.Request) {
30 | addRequestID(r)
31 | addRequestPath(r)
32 | addRequestMethod(r)
33 | addRequestStart(r)
34 | addHost(r)
35 | fn(w, r)
36 | }
37 | }
38 |
39 | // Attaches a workspace to the request context.
40 | // Useful for logging.
41 | func AddWorkspaceID(r *http.Request, id string) {
42 | *r = *r.WithContext(context.WithValue(r.Context(), ctxKeyWorkspaceID, id))
43 | }
44 |
45 | func addRequestID(r *http.Request) {
46 | id := r.Header.Get("X-Request-ID")
47 | if id == "" {
48 | id = xid.New().String()
49 | }
50 | *r = *r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id))
51 | }
52 |
53 | func addRequestPath(r *http.Request) {
54 | *r = *r.WithContext(context.WithValue(r.Context(), ctxKeyRequestPath, r.URL.String()))
55 | }
56 |
57 | func addRequestMethod(r *http.Request) {
58 | *r = *r.WithContext(context.WithValue(r.Context(), ctxKeyRequestMethod, r.Method))
59 | }
60 |
61 | func addRequestStart(r *http.Request) {
62 | *r = *r.WithContext(context.WithValue(r.Context(), ctxKeyRequestStart, time.Now()))
63 | }
64 |
65 | func addHost(r *http.Request) {
66 | host := r.Host
67 | if r.Header.Get("X-Forwarded-Host") != "" {
68 | host = r.Header.Get("X-Forwarded-Host")
69 | }
70 | *r = *r.WithContext(context.WithValue(r.Context(), ctxKeyRequestHost, host))
71 | }
72 |
73 | func ErrAttr(err error) slog.Attr {
74 | return slog.Any("error", err)
75 | }
76 |
77 | func LogRequest(ctx context.Context, status int, msg string) {
78 | level := slog.LevelError
79 | if status < 400 {
80 | level = slog.LevelDebug
81 | }
82 |
83 | slog.LogAttrs(ctx, level, msg, slog.Int("status", status))
84 | }
85 |
--------------------------------------------------------------------------------
/internal/xlog/logger.go:
--------------------------------------------------------------------------------
1 | package xlog
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 | "time"
8 |
9 | "github.com/jonashiltl/openchangelog/internal/config"
10 | )
11 |
12 | func NewLogger(cfg config.Config) *slog.Logger {
13 | var sh slog.Handler
14 |
15 | if cfg.Log == nil {
16 | sh = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})
17 | } else {
18 | switch cfg.Log.Style {
19 | case "json":
20 | sh = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.Level.ToSlog()})
21 | default:
22 | sh = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.Level.ToSlog()})
23 | }
24 | }
25 | return slog.New(&myHandler{
26 | Handler: sh,
27 | })
28 | }
29 |
30 | type myHandler struct {
31 | slog.Handler
32 | }
33 |
34 | func (h *myHandler) Handle(ctx context.Context, r slog.Record) error {
35 | if rID, ok := ctx.Value(ctxKeyRequestID).(string); ok {
36 | r.AddAttrs(slog.String("request_id", rID))
37 | }
38 | if wID, ok := ctx.Value(ctxKeyWorkspaceID).(string); ok {
39 | r.AddAttrs(slog.String("workspace_id", wID))
40 | }
41 | if host, ok := ctx.Value(ctxKeyRequestHost).(string); ok {
42 | r.AddAttrs(slog.String("host", host))
43 | }
44 | if rPath, ok := ctx.Value(ctxKeyRequestPath).(string); ok {
45 | r.AddAttrs(slog.String("path", rPath))
46 | }
47 | if rMth, ok := ctx.Value(ctxKeyRequestMethod).(string); ok {
48 | r.AddAttrs(slog.String("method", rMth))
49 | }
50 | if start, ok := ctx.Value(ctxKeyRequestStart).(time.Time); ok {
51 | r.AddAttrs(slog.Duration("duration", time.Since(start)))
52 | }
53 | return h.Handler.Handle(ctx, r)
54 | }
55 |
56 | func (h *myHandler) Enabled(ctx context.Context, level slog.Level) bool {
57 | return h.Handler.Enabled(ctx, level)
58 | }
59 |
60 | func (h *myHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
61 | return &myHandler{Handler: h.Handler.WithAttrs(attrs)}
62 | }
63 |
64 | func (h *myHandler) WithGroup(name string) slog.Handler {
65 | return &myHandler{Handler: h.Handler.WithGroup(name)}
66 | }
67 |
--------------------------------------------------------------------------------
/migrations/20240725163248_workspaces.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | CREATE TABLE IF NOT EXISTS workspaces (
4 | id TEXT NOT NULL PRIMARY KEY,
5 | name TEXT NOT NULL
6 | ) STRICT;
7 | -- +goose StatementEnd
8 |
9 | -- +goose Down
10 | -- +goose StatementBegin
11 | DROP TABLE workspaces;
12 | -- +goose StatementEnd
13 |
--------------------------------------------------------------------------------
/migrations/20240725163326_tokens.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | CREATE TABLE IF NOT EXISTS tokens (
4 | key TEXT PRIMARY KEY,
5 | workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE
6 | ) STRICT;
7 | -- +goose StatementEnd
8 |
9 | -- +goose Down
10 | -- +goose StatementBegin
11 | DROP TABLE tokens;
12 | -- +goose StatementEnd
13 |
--------------------------------------------------------------------------------
/migrations/20240725163619_changelog.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | CREATE TABLE IF NOT EXISTS changelogs (
4 | id TEXT NOT NULL,
5 | workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
6 | subdomain TEXT NOT NULL,
7 | title TEXT,
8 | subtitle TEXT,
9 | source_id TEXT,
10 | logo_src TEXT,
11 | logo_link TEXT,
12 | logo_alt TEXT,
13 | logo_height TEXT,
14 | logo_width TEXT,
15 | created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
16 | PRIMARY KEY (workspace_id, id)
17 | ) STRICT;
18 |
19 | CREATE UNIQUE INDEX changelogs_subdomain ON changelogs(subdomain);
20 | -- +goose StatementEnd
21 |
22 | -- +goose Down
23 | -- +goose StatementBegin
24 | DROP INDEX changelogs_subdomain;
25 | DROP TABLE changelogs;
26 | -- +goose StatementEnd
27 |
--------------------------------------------------------------------------------
/migrations/20240725163702_gh_sources.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | CREATE TABLE IF NOT EXISTS gh_sources (
4 | id TEXT NOT NULL,
5 | workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
6 | owner TEXT NOT NULL,
7 | repo TEXT NOT NULL,
8 | path TEXT NOT NULL,
9 | installation_id INTEGER NOT NULL,
10 | PRIMARY KEY (workspace_id, id)
11 | );
12 | -- +goose StatementEnd
13 |
14 | -- +goose Down
15 | -- +goose StatementBegin
16 | DROP TABLE gh_sources;
17 | -- +goose StatementEnd
18 |
--------------------------------------------------------------------------------
/migrations/20240725163746_changelog_source.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | -- sqlc embed currently not workign with nullable embeds see this https://github.com/sqlc-dev/sqlc/issues/2997
4 | -- this view is used, because sqlc treats views as nullable
5 | CREATE VIEW changelog_source AS
6 | SELECT gh.* -- in future probably need to prefix this with gh_
7 | FROM changelogs cl
8 | LEFT JOIN gh_sources gh
9 | ON cl.workspace_id = gh.workspace_id
10 | AND cl.source_id LIKE 'gh_%'
11 | AND cl.source_id = gh.id
12 | GROUP BY source_id, gh.workspace_id;
13 | -- +goose StatementEnd
14 |
15 | -- +goose Down
16 | -- +goose StatementBegin
17 | DROP VIEW changelog_source;
18 | -- +goose StatementEnd
19 |
--------------------------------------------------------------------------------
/migrations/20240905083958_changelog_domain.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | ALTER TABLE changelogs ADD domain TEXT;
4 | CREATE UNIQUE INDEX changelogs_domain ON changelogs(domain);
5 | -- +goose StatementEnd
6 |
7 | -- +goose Down
8 | -- +goose StatementBegin
9 | DROP INDEX changelogs_domain;
10 | ALTER TABLE changelogs DROP domain;
11 | -- +goose StatementEnd
12 |
--------------------------------------------------------------------------------
/migrations/20240911101434_changelog_color_scheme.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | ALTER TABLE changelogs ADD color_scheme INTEGER NOT NULL DEFAULT 1;
4 | -- +goose StatementEnd
5 |
6 | -- +goose Down
7 | -- +goose StatementBegin
8 | ALTER TABLE changelogs DROP color_scheme;
9 | -- +goose StatementEnd
10 |
--------------------------------------------------------------------------------
/migrations/20240919193315_changelog_hide_powered_by.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | ALTER TABLE changelogs ADD hide_powered_by INTEGER NOT NULL DEFAULT 0 check (hide_powered_by in (0, 1));
4 | -- +goose StatementEnd
5 |
6 | -- +goose Down
7 | -- +goose StatementBegin
8 | ALTER TABLE changelogs DROP hide_powered_by;
9 | -- +goose StatementEnd
10 |
--------------------------------------------------------------------------------
/migrations/20241012104224_changelog_protected.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | ALTER TABLE changelogs ADD protected INTEGER NOT NULL DEFAULT 0 check (protected in (0, 1));
4 | ALTER TABLE changelogs ADD password_hash TEXT;
5 | -- +goose StatementEnd
6 |
7 | -- +goose Down
8 | -- +goose StatementBegin
9 | ALTER TABLE changelogs DROP protected;
10 | ALTER TABLE changelogs DROP password_hash;
11 | -- +goose StatementEnd
12 |
--------------------------------------------------------------------------------
/migrations/20241103174950_changelog_analytics.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | ALTER TABLE changelogs ADD analytics INTEGER NOT NULL DEFAULT 0 check (analytics in (0, 1));
4 | -- +goose StatementEnd
5 |
6 | -- +goose Down
7 | -- +goose StatementBegin
8 | ALTER TABLE changelogs DROP analytics;
9 | -- +goose StatementEnd
10 |
--------------------------------------------------------------------------------
/migrations/20241123104348_changelog_searchable.sql:
--------------------------------------------------------------------------------
1 | -- +goose Up
2 | -- +goose StatementBegin
3 | ALTER TABLE changelogs ADD searchable INTEGER NOT NULL DEFAULT 0 check (searchable in (0, 1));
4 | -- +goose StatementEnd
5 |
6 | -- +goose Down
7 | -- +goose StatementBegin
8 | ALTER TABLE changelogs DROP searchable;
9 | -- +goose StatementEnd
10 |
--------------------------------------------------------------------------------
/openchangelog.example.yml:
--------------------------------------------------------------------------------
1 | addr: 0.0.0.0:6001
2 | #log:
3 | #level: INFO (default), DEBUG, WARN, ERROR
4 | #style: text (default), json
5 | #github:
6 | # owner:
7 | # repo:
8 | # path:
9 | # auth:
10 | # accessToken:
11 | local:
12 | filesPath: /release-notes
13 | cache:
14 | type: disk
15 | disk:
16 | location: /data/cache/
17 | #analytics:
18 | #provider: tinybird
19 | #tinybird:
20 | # accessToken:
21 | page:
22 | title: Changelog
23 | subtitle: The latest product updates from Openchangelog
24 | colorScheme: system
25 | hidePoweredBy: false
26 | logo:
27 | src: https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png
28 | width: 70px
29 | height: 25px
30 | link: https://www.openchangelog.com
31 | auth:
32 | enabled: false
33 | # passwordHash: bcrypt encrypted hash of password
34 | search:
35 | type: disk
36 | disk:
37 | path: /data/search
38 |
--------------------------------------------------------------------------------
/sqlc.yaml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | sql:
3 | - engine: "sqlite"
4 | queries:
5 | - "internal/store/query.sql"
6 | schema:
7 | - "migrations/"
8 | gen:
9 | go:
10 | package: "store"
11 | out: "internal/store/"
12 | overrides:
13 | - db_type: "INTEGER"
14 | go_type: "int64"
15 | - db_type: "TEXT"
16 | nullable: true
17 | go_type:
18 | import: "github.com/jonashiltl/openchangelog/apitypes"
19 | type: "NullString"
20 | - column: "changelogs.color_scheme"
21 | go_type:
22 | type: "ColorScheme"
23 |
24 | rename:
25 | workspace: "workspace"
26 | token: "token"
27 | changelog: "changelog"
28 | gh_source: "ghSource"
29 | changelog_source: "changelogSource"
30 |
--------------------------------------------------------------------------------
/widget/next/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "next/typescript"
5 | ],
6 | "rules": {
7 | "@typescript-eslint/no-unused-vars": [
8 | "error",
9 | {
10 | "args": "all",
11 | "argsIgnorePattern": "^_",
12 | "caughtErrors": "all",
13 | "caughtErrorsIgnorePattern": "^_",
14 | "destructuredArrayIgnorePattern": "^_",
15 | "varsIgnorePattern": "^_",
16 | "ignoreRestSiblings": true
17 | }
18 | ]
19 | }
20 | }
--------------------------------------------------------------------------------
/widget/next/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 | /dist
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/widget/next/.npmignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /src
3 | /public
4 |
5 | .env
6 |
7 | tailwind.config.ts
8 | tsconfig.json
9 | tsup.config.ts
--------------------------------------------------------------------------------
/widget/next/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
2 | always-auth=true
--------------------------------------------------------------------------------
/widget/next/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [0.0.6] - 2024-10-27
9 | ### Fixed
10 | - Default *baseUrl* now points to the correct Openchangelog cloud url
11 |
12 | ## [0.0.5] - 2024-10-26
13 |
14 | ### Added
15 | - Support for Next.js 15
16 | - Support for React 19
--------------------------------------------------------------------------------
/widget/next/README.md:
--------------------------------------------------------------------------------
1 | # Openchangelog Next.js Widget
2 |
3 | A Next.js Server Component to embed your Openchangelog changelog into your Next.js app.
4 | For a summary of the most recent changes to the project, please see the [Changelog](https://next.openchangelog.com).
5 |
6 | ## Installation
7 | ```
8 | npm i @openchangelog/next
9 | ```
10 |
11 | ## Usage
12 | ```ts
13 | import { Changelog } from "@openchangelog/next"
14 |
15 |
16 | export default function ChangelogPage() {
17 | return (
18 |
24 | );
25 | }
26 | ```
27 |
28 | ## Suspense
29 | The `Changelog` component is built as an async component, making it compatible with React Suspense. You can wrap it in a Suspense boundary to show loading states while the changelog data is being fetched:
30 |
31 | ```ts
32 | import { Changelog } from "@openchangelog/next"
33 |
34 | export default function ChangelogPage() {
35 | return (
36 | Loading changelog...}>
37 |
38 |
39 | );
40 | }
41 | ```
--------------------------------------------------------------------------------
/widget/next/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/widget/next/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@openchangelog/next",
3 | "version": "0.0.6",
4 | "homepage": "https://github.com/JonasHiltl/openchangelog",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "description": "Embed your Openchangelog Changelog into your Next.js app.",
8 | "license": "MIT",
9 | "files": [
10 | "dist"
11 | ],
12 | "keywords": [
13 | "next.js",
14 | "openchangelog",
15 | "changelog",
16 | "github",
17 | "release notes",
18 | "release"
19 | ],
20 | "scripts": {
21 | "lint": "eslint --fix",
22 | "build": "tsup",
23 | "dev": "tsup --watch",
24 | "prepare": "npm run build"
25 | },
26 | "peerDependencies": {
27 | "next": ">=14.0.0 || >=15.0.0-rc",
28 | "react": ">=18.0.0 || >=19.0.0-beta",
29 | "react-dom": ">=18.0.0 || >=19.0.0-beta"
30 | },
31 | "devDependencies": {
32 | "@types/node": "^20",
33 | "@types/react": "^18",
34 | "@types/react-dom": "^18",
35 | "eslint": "^8",
36 | "eslint-config-next": "14.2.15",
37 | "tsup": "^8.3.0",
38 | "typescript": "^5"
39 | }
40 | }
--------------------------------------------------------------------------------
/widget/next/src/Changelog.tsx:
--------------------------------------------------------------------------------
1 | type BaseChangelogProps = {
2 | className?: string
3 | page?: number
4 | pageSize?: number
5 | baseUrl?: string
6 | theme?: "dark" | "light"
7 | }
8 |
9 | type LocalChangelogProps = BaseChangelogProps & {
10 | changelogID?: never
11 | workspaceID?: never
12 | }
13 |
14 | type CloudChangelogProps = BaseChangelogProps & {
15 | changelogID: string
16 | workspaceID: string
17 | }
18 |
19 | type ChangelogProps = LocalChangelogProps | CloudChangelogProps
20 |
21 | async function fetchChangelog(args: ChangelogProps): Promise {
22 | const baseURL = args.baseUrl || "https://app.openchangelog.com/"
23 | const params = new URLSearchParams({
24 | widget: "true",
25 | ...(args.changelogID && { cid: args.changelogID }),
26 | ...(args.workspaceID && { wid: args.workspaceID }),
27 | ...(args.page && { page: args.page.toString() }),
28 | ...(args.pageSize && { "page-size": args.pageSize.toString() }),
29 | })
30 | const url = new URL(`?${params.toString()}`, baseURL)
31 | const res = await fetch(url, {
32 | cache: "default",
33 | })
34 | if (!res.ok) {
35 | throw new Error(`failed to render changelog ${res.status}: ${await res.text()}`)
36 | }
37 | return res.text()
38 | }
39 |
40 | /**
41 | * Render your Openchangelog changelog on the server.
42 | * Specify `changelogID` and `workspaceID` if using openchangelog cloud,
43 | * otherwise use `baseUrl` to point to your hosted instance.
44 | */
45 | export async function Changelog(props: ChangelogProps) {
46 | const html = await fetchChangelog(props)
47 |
48 | return (
49 |
50 | )
51 | }
--------------------------------------------------------------------------------
/widget/next/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Changelog } from "./Changelog"
--------------------------------------------------------------------------------
/widget/next/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "es2020",
5 | "moduleResolution": "node",
6 | "jsx": "react-jsx",
7 | "strict": true,
8 | "declaration": true,
9 | "emitDeclarationOnly": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "preserveWatchOutput": true
13 | },
14 | "include": [
15 | "src/**/*.ts",
16 | "src/**/*.tsx",
17 | "tsup.config.ts"
18 | ],
19 | "exclude": [
20 | "node_modules",
21 | "dist",
22 | ]
23 | }
--------------------------------------------------------------------------------
/widget/next/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig({
4 | target: 'esnext',
5 | clean: true,
6 | dts: true,
7 | entry: ['src/index.ts'],
8 | keepNames: true,
9 | minify: true,
10 | sourcemap: true,
11 | format: ['cjs'],
12 | })
13 |
--------------------------------------------------------------------------------
/widget/samples/next-app/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "next/typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/widget/samples/next-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # env files (can opt-in for commiting if needed)
33 | .env*
34 |
35 | # vercel
36 | .vercel
37 |
38 | # typescript
39 | *.tsbuildinfo
40 | next-env.d.ts
41 |
--------------------------------------------------------------------------------
/widget/samples/next-app/README.md:
--------------------------------------------------------------------------------
1 | ## Next.js Openchangelog Sample
2 |
3 | This repo shows how to embed an Openchangelog changelog into your Next.js app
--------------------------------------------------------------------------------
/widget/samples/next-app/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": false,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
--------------------------------------------------------------------------------
/widget/samples/next-app/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/widget/samples/next-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@openchangelog/next": "^0.0.6",
13 | "@radix-ui/react-icons": "^1.3.0",
14 | "@radix-ui/react-navigation-menu": "^1.2.1",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "class-variance-authority": "^0.7.0",
17 | "clsx": "^2.1.1",
18 | "next": "15.0.0",
19 | "react": "19.0.0-rc-65a56d0e-20241020",
20 | "react-dom": "19.0.0-rc-65a56d0e-20241020",
21 | "tailwind-merge": "^2.5.4",
22 | "tailwindcss-animate": "^1.0.7"
23 | },
24 | "devDependencies": {
25 | "@types/node": "^20",
26 | "@types/react": "^18",
27 | "@types/react-dom": "^18",
28 | "autoprefixer": "^10.4.20",
29 | "eslint": "^8",
30 | "eslint-config-next": "15.0.0",
31 | "postcss": "^8.4.47",
32 | "tailwindcss": "^3.4.14",
33 | "typescript": "^5"
34 | },
35 | "overrides": {
36 | "@radix-ui/react-icons": {
37 | "react": "$react"
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/widget/samples/next-app/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/widget/samples/next-app/src/app/changelog/page.tsx:
--------------------------------------------------------------------------------
1 | import { Changelog } from "@openchangelog/next"
2 | import { Suspense } from "react";
3 |
4 | export default function ChangelogPage() {
5 | return (
6 |
7 | Loading...}>
8 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/widget/samples/next-app/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JonasHiltl/openchangelog/807f27bb7b58b2c7918e0ab06dce25bb85ca6cc4/widget/samples/next-app/src/app/favicon.ico
--------------------------------------------------------------------------------
/widget/samples/next-app/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 47.4% 11.2%;
9 |
10 | --muted: 210 40% 96.1%;
11 | --muted-foreground: 215.4 16.3% 46.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 47.4% 11.2%;
15 |
16 | --border: 214.3 31.8% 91.4%;
17 | --input: 214.3 31.8% 91.4%;
18 |
19 | --card: 0 0% 100%;
20 | --card-foreground: 222.2 47.4% 11.2%;
21 |
22 | --primary: 222.2 47.4% 11.2%;
23 | --primary-foreground: 210 40% 98%;
24 |
25 | --secondary: 210 40% 96.1%;
26 | --secondary-foreground: 222.2 47.4% 11.2%;
27 |
28 | --accent: 210 40% 96.1%;
29 | --accent-foreground: 222.2 47.4% 11.2%;
30 |
31 | --destructive: 0 100% 50%;
32 | --destructive-foreground: 210 40% 98%;
33 |
34 | --ring: 215 20.2% 65.1%;
35 |
36 | --radius: 0.5rem;
37 | }
38 |
39 | .dark {
40 | --background: 224 71% 4%;
41 | --foreground: 213 31% 91%;
42 |
43 | --muted: 223 47% 11%;
44 | --muted-foreground: 215.4 16.3% 56.9%;
45 |
46 | --accent: 216 34% 17%;
47 | --accent-foreground: 210 40% 98%;
48 |
49 | --popover: 224 71% 4%;
50 | --popover-foreground: 215 20.2% 65.1%;
51 |
52 | --border: 216 34% 17%;
53 | --input: 216 34% 17%;
54 |
55 | --card: 224 71% 4%;
56 | --card-foreground: 213 31% 91%;
57 |
58 | --primary: 210 40% 98%;
59 | --primary-foreground: 222.2 47.4% 1.2%;
60 |
61 | --secondary: 222.2 47.4% 11.2%;
62 | --secondary-foreground: 210 40% 98%;
63 |
64 | --destructive: 0 63% 31%;
65 | --destructive-foreground: 210 40% 98%;
66 |
67 | --ring: 216 34% 17%;
68 |
69 | --radius: 0.5rem;
70 | }
71 | }
72 |
73 | @layer base {
74 | * {
75 | @apply border-border;
76 | }
77 |
78 | body {
79 | @apply bg-background text-foreground;
80 | font-feature-settings: "rlig" 1, "calt" 1;
81 | }
82 | }
--------------------------------------------------------------------------------
/widget/samples/next-app/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from 'next/font/google'
3 | import "./globals.css";
4 | import { NavigationMenuDemo } from "@/components/navigation-menu-demo";
5 | import { cn } from "@/lib/utils";
6 |
7 | export const metadata: Metadata = {
8 | title: "Next Changelog Demo",
9 | description: "Embed Openchangelog Changelog into Next.js app",
10 | };
11 |
12 | const inter = Inter({ subsets: ['latin'] })
13 |
14 | export default function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode;
18 | }>) {
19 | return (
20 |
21 |
22 |
23 | {children}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/widget/samples/next-app/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Index() {
2 | return null
3 | }
--------------------------------------------------------------------------------
/widget/samples/next-app/src/components/navigation-menu-demo.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import Link from "next/link"
5 |
6 | import {
7 | NavigationMenu,
8 | NavigationMenuItem,
9 | NavigationMenuLink,
10 | NavigationMenuList,
11 | navigationMenuTriggerStyle,
12 | NavigationMenuTrigger
13 | } from "@/components/ui/navigation-menu"
14 | import { Button } from "./ui/button"
15 |
16 | export function NavigationMenuDemo() {
17 | return (
18 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/widget/samples/next-app/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-zinc-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
14 | destructive:
15 | "bg-red-500 text-zinc-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
18 | secondary:
19 | "bg-zinc-100 text-zinc-900 shadow-sm hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
20 | ghost: "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
21 | link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/widget/samples/next-app/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/widget/samples/next-app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require("tailwindcss/defaultTheme")
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ["class"],
6 | content: [
7 | "./src/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | container: {
11 | center: true,
12 | padding: "2rem",
13 | screens: {
14 | "2xl": "1400px",
15 | },
16 | },
17 | extend: {
18 | colors: {
19 | border: "hsl(var(--border))",
20 | input: "hsl(var(--input))",
21 | ring: "hsl(var(--ring))",
22 | background: "hsl(var(--background))",
23 | foreground: "hsl(var(--foreground))",
24 | primary: {
25 | DEFAULT: "hsl(var(--primary))",
26 | foreground: "hsl(var(--primary-foreground))",
27 | },
28 | secondary: {
29 | DEFAULT: "hsl(var(--secondary))",
30 | foreground: "hsl(var(--secondary-foreground))",
31 | },
32 | destructive: {
33 | DEFAULT: "hsl(var(--destructive))",
34 | foreground: "hsl(var(--destructive-foreground))",
35 | },
36 | muted: {
37 | DEFAULT: "hsl(var(--muted))",
38 | foreground: "hsl(var(--muted-foreground))",
39 | },
40 | accent: {
41 | DEFAULT: "hsl(var(--accent))",
42 | foreground: "hsl(var(--accent-foreground))",
43 | },
44 | popover: {
45 | DEFAULT: "hsl(var(--popover))",
46 | foreground: "hsl(var(--popover-foreground))",
47 | },
48 | card: {
49 | DEFAULT: "hsl(var(--card))",
50 | foreground: "hsl(var(--card-foreground))",
51 | },
52 | },
53 | borderRadius: {
54 | lg: `var(--radius)`,
55 | md: `calc(var(--radius) - 2px)`,
56 | sm: "calc(var(--radius) - 4px)",
57 | },
58 | fontFamily: {
59 | sans: ["var(--font-sans)", ...fontFamily.sans],
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | }
79 |
80 |
--------------------------------------------------------------------------------
/widget/samples/next-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./src/*"
28 | ]
29 | }
30 | },
31 | "include": [
32 | "next-env.d.ts",
33 | "**/*.ts",
34 | "**/*.tsx",
35 | ".next/types/**/*.ts"
36 | ],
37 | "exclude": [
38 | "node_modules"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------