├── .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 | [![animation](https://raw.githubusercontent.com/orhun/git-cliff/main/website/static/img/git-cliff-anim.gif)](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 | ![](https://i.ibb.co/9H8cj7Z/Screenshot-2024-04-24-at-17-54-19.png) 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 | ![](https://github.com/user-attachments/assets/b712044a-237b-459b-bcda-8965982c3fa9) 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 | ![](https://github.com/user-attachments/assets/ebc15809-bd1d-4a0e-abd8-2967627a1aec) 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 | ![](https://github.com/user-attachments/assets/7022b110-e86f-4496-a7df-c8278c9db101) 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 | ![](https://github.com/user-attachments/assets/cf07e9e8-41ee-404b-ab7b-1d41c10cdbdd) 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 | ![](https://github.com/user-attachments/assets/e8b7214d-108d-44a5-aa4a-f9167df0dbc0) 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 | ![](https://i.ibb.co/2nrm2RW/Group-2.webp) 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 | ![](https://i.ibb.co/bX101D5/Group-10.png) 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 | ![](https://i.ibb.co/bgBh9qv/search.webp) 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 |

31 | { a.Title } 32 | 36 | # 37 | 38 |

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 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
7 | { children... } 8 |
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 | 28 | 29 | 30 | 31 | 32 | for _, ws := range args.Workspaces { 33 | 38 | 39 | 40 | 41 | } 42 | 43 |
NameChangelogs
{ ws.Workspace.Name }{ fmt.Sprint(ws.ChangelogCount) }
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 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | for _, cl := range args.Changelogs { 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | } 40 | 41 |
IDTitleProtectedSubdomainDomain
{ cl.ID.String() }{ cl.Title.V() }{ fmt.Sprint(cl.Protected) }{ cl.Subdomain.String() }{ cl.Domain.String() }
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 | 13 | 14 | 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 | 13 | 14 | 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 | 13 | 14 | 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 | 13 | 18 | 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 | 7 | 8 | 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 | 7 | 8 | 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 | 14 | 21 | 29 | 30 | } 31 | -------------------------------------------------------------------------------- /internal/handler/web/icons/x.templ: -------------------------------------------------------------------------------- 1 | package icons 2 | 3 | import "fmt" 4 | 5 | templ X(h, w int) { 6 | 13 | 14 | 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 |
37 |
38 | 42 | @PasswordProtectionError(args.Error) 43 |
44 | 45 |
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 | --------------------------------------------------------------------------------