Repository: go-shiori/shiori Branch: master Commit: 585ea341aa59 Files: 328 Total size: 1.7 MB Directory structure: gitextract_iww9x6qw/ ├── .cursorrules ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ ├── stale.yml │ └── workflows/ │ ├── _buildx.yml │ ├── _delete-registry-tag.yml │ ├── _e2e.yml │ ├── _golangci-lint.yml │ ├── _gorelease.yml │ ├── _mkdocs-check.yml │ ├── _mkdocs-publish.yml │ ├── _styles-check.yml │ ├── _swagger-check.yml │ ├── _test.yml │ ├── pull_request.yml │ ├── pull_request_closed.yml │ ├── push.yml │ └── version_bump.yml ├── .gitignore ├── .golangci.bck.yml ├── .golangci.yml ├── .goreleaser.yaml ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Dockerfile.alpine ├── Dockerfile.compose ├── Dockerfile.e2e ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── app.json ├── bun.lockb ├── codecov.yml ├── docker-compose.yaml ├── docs/ │ ├── API.md │ ├── APIv1.md │ ├── CLI.md │ ├── Configuration.md │ ├── Contribute.md │ ├── Installation.md │ ├── Screenshots.md │ ├── Storage.md │ ├── Usage.md │ ├── assets/ │ │ └── css/ │ │ └── style.css │ ├── faq.md │ ├── index.md │ ├── postman/ │ │ └── shiori.postman_collection.json │ └── swagger/ │ ├── docs.go │ ├── swagger.json │ └── swagger.yaml ├── e2e/ │ ├── e2eutil/ │ │ └── containers.go │ ├── playwright/ │ │ ├── accounts_test.go │ │ ├── auth_test.go │ │ ├── playwright_test.go │ │ ├── reporter.go │ │ └── testhelper.go │ └── server/ │ ├── auth_test.go │ └── basic_test.go ├── go.mod ├── go.sum ├── internal/ │ ├── assets.go │ ├── cmd/ │ │ ├── add.go │ │ ├── check.go │ │ ├── delete.go │ │ ├── export.go │ │ ├── import.go │ │ ├── open.go │ │ ├── pocket.go │ │ ├── pocket_test.go │ │ ├── print.go │ │ ├── root.go │ │ ├── serve.go │ │ ├── server.go │ │ ├── server_test.go │ │ ├── update.go │ │ ├── utils.go │ │ ├── utils_test.go │ │ └── version.go │ ├── config/ │ │ ├── config.go │ │ ├── config_test.go │ │ └── storage.go │ ├── core/ │ │ ├── core.go │ │ ├── download.go │ │ ├── ebook.go │ │ ├── ebook_test.go │ │ ├── processing.go │ │ ├── processing_test.go │ │ └── url.go │ ├── database/ │ │ ├── database.go │ │ ├── database_tags.go │ │ ├── database_tags_test.go │ │ ├── database_test.go │ │ ├── migrations/ │ │ │ ├── mysql/ │ │ │ │ ├── 0000_system_create.up.sql │ │ │ │ ├── 0000_system_insert.up.sql │ │ │ │ ├── 0001_initial_account.up.sql │ │ │ │ ├── 0002_initial_bookmark.up.sql │ │ │ │ ├── 0003_initial_tag.up.sql │ │ │ │ ├── 0004_initial_bookmark_tag.up.sql │ │ │ │ ├── 0005_rename_to_created_at.up.sql │ │ │ │ ├── 0006_change_created_at_settings.up.sql │ │ │ │ ├── 0007_add_modified_at.up.sql │ │ │ │ ├── 0008_set_modified_at_equal_created_at.up.sql │ │ │ │ ├── 0009_index_for_created_at.up.sql │ │ │ │ └── 0010_index_for_modified_at.up.sql │ │ │ ├── postgres/ │ │ │ │ ├── 0000_system.up.sql │ │ │ │ ├── 0001_initial.up.sql │ │ │ │ └── 0002_created_time.up.sql │ │ │ └── sqlite/ │ │ │ ├── 0000_system.up.sql │ │ │ ├── 0001_initial.up.sql │ │ │ ├── 0002_denormalize_content.up.sql │ │ │ ├── 0003_uniq_id.up.sql │ │ │ └── 0004_created_time.up.sql │ │ ├── migrations.go │ │ ├── mysql.go │ │ ├── mysql_test.go │ │ ├── pg.go │ │ ├── pg_test.go │ │ ├── sqlite.go │ │ ├── sqlite_noncgo.go │ │ ├── sqlite_openbsd.go │ │ └── sqlite_test.go │ ├── dependencies/ │ │ └── dependencies.go │ ├── domains/ │ │ ├── accounts.go │ │ ├── accounts_test.go │ │ ├── archiver.go │ │ ├── auth.go │ │ ├── auth_test.go │ │ ├── bookmark_tags_test.go │ │ ├── bookmarks.go │ │ ├── bookmarks_test.go │ │ ├── storage.go │ │ ├── storage_test.go │ │ ├── tags.go │ │ └── tags_test.go │ ├── http/ │ │ ├── handlers/ │ │ │ ├── api/ │ │ │ │ └── v1/ │ │ │ │ ├── accounts.go │ │ │ │ ├── accounts_test.go │ │ │ │ ├── auth.go │ │ │ │ ├── auth_test.go │ │ │ │ ├── bookmark_tags_test.go │ │ │ │ ├── bookmarks.go │ │ │ │ ├── bookmarks_test.go │ │ │ │ ├── system.go │ │ │ │ ├── system_test.go │ │ │ │ ├── tags.go │ │ │ │ └── tags_test.go │ │ │ ├── api.go │ │ │ ├── bookmark.go │ │ │ ├── bookmark_test.go │ │ │ ├── frontend.go │ │ │ ├── frontend_test.go │ │ │ ├── legacy.go │ │ │ ├── legacy_test.go │ │ │ ├── swagger.go │ │ │ ├── swagger_test.go │ │ │ ├── system.go │ │ │ └── system_test.go │ │ ├── http.go │ │ ├── http_test.go │ │ ├── middleware/ │ │ │ ├── auth.go │ │ │ ├── auth_sso_proxy.go │ │ │ ├── auth_sso_proxy_test.go │ │ │ ├── auth_test.go │ │ │ ├── cors.go │ │ │ ├── cors_test.go │ │ │ ├── logging.go │ │ │ ├── message_response.go │ │ │ ├── message_response_test.go │ │ │ ├── request_id.go │ │ │ └── request_id_test.go │ │ ├── response/ │ │ │ ├── file.go │ │ │ ├── file_test.go │ │ │ ├── response.go │ │ │ ├── response_test.go │ │ │ ├── shortcuts.go │ │ │ └── shortcuts_test.go │ │ ├── server.go │ │ ├── server_test.go │ │ ├── templates/ │ │ │ └── templates.go │ │ └── webcontext/ │ │ ├── auth.go │ │ ├── auth_test.go │ │ ├── context.go │ │ └── keys.go │ ├── model/ │ │ ├── account.go │ │ ├── bookmark.go │ │ ├── bookmark_test.go │ │ ├── const.go │ │ ├── database.go │ │ ├── dependencies.go │ │ ├── domains.go │ │ ├── errors.go │ │ ├── http.go │ │ ├── legacy.go │ │ ├── main.go │ │ ├── ptr.go │ │ ├── slices.go │ │ ├── slices_test.go │ │ ├── tag.go │ │ ├── tag_test.go │ │ └── validation.go │ ├── testutil/ │ │ ├── accounts.go │ │ ├── accounts_test.go │ │ ├── http.go │ │ ├── response.go │ │ └── shiori.go │ ├── view/ │ │ ├── 404.html │ │ ├── archive.html │ │ ├── assets/ │ │ │ ├── css/ │ │ │ │ ├── archive.css │ │ │ │ └── style.css │ │ │ ├── js/ │ │ │ │ ├── component/ │ │ │ │ │ ├── bookmark.js │ │ │ │ │ ├── dialog.js │ │ │ │ │ ├── eventBus.js │ │ │ │ │ ├── login.js │ │ │ │ │ └── pagination.js │ │ │ │ ├── page/ │ │ │ │ │ ├── base.js │ │ │ │ │ ├── home.js │ │ │ │ │ └── setting.js │ │ │ │ ├── url.js │ │ │ │ ├── utils/ │ │ │ │ │ └── api.js │ │ │ │ └── vue.js │ │ │ ├── less/ │ │ │ │ ├── archive.less │ │ │ │ ├── bookmark-item.less │ │ │ │ ├── common.less │ │ │ │ ├── custom-dialog.less │ │ │ │ ├── style.less │ │ │ │ ├── theme.less │ │ │ │ └── variables.less │ │ │ └── manifest.webmanifest │ │ ├── content.html │ │ ├── embed.go │ │ └── index.html │ └── webserver/ │ ├── handler-api-ext.go │ ├── handler-api.go │ ├── handler.go │ ├── server.go │ ├── utils.go │ ├── utils_ip.go │ └── utils_ip_test.go ├── main.go ├── mkdocs.yml ├── package.json ├── scripts/ │ ├── buildx.sh │ ├── e2e.sh │ ├── styles.sh │ ├── styles_check.sh │ ├── swagger.sh │ ├── swagger_check.sh │ └── test.sh ├── testdata/ │ ├── nginx.conf │ ├── pocket-new.csv │ └── pocket-old.csv └── webapp/ ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .prettierrc.json ├── README.md ├── dist/ │ ├── assets/ │ │ ├── ArchiveView-DZOySksr.js │ │ ├── FoldersView-B-TWh6ac.js │ │ ├── FoldersView-tn0RQdqM.css │ │ ├── SettingsView-BWJgD3kk.js │ │ ├── TagsView-CmDnarVi.js │ │ ├── index-C8c580-n.js │ │ └── index-DoBsnBZ2.css │ └── index.html ├── embed.go ├── env.d.ts ├── eslint.config.ts ├── index.html ├── package.json ├── src/ │ ├── App.vue │ ├── assets/ │ │ └── main.css │ ├── client/ │ │ ├── .openapi-generator/ │ │ │ ├── FILES │ │ │ └── VERSION │ │ ├── .openapi-generator-ignore │ │ ├── apis/ │ │ │ ├── AccountsApi.ts │ │ │ ├── AuthApi.ts │ │ │ ├── SystemApi.ts │ │ │ ├── TagsApi.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── models/ │ │ │ ├── ApiV1BookmarkTagPayload.ts │ │ │ ├── ApiV1BulkUpdateBookmarkTagsPayload.ts │ │ │ ├── ApiV1InfoResponse.ts │ │ │ ├── ApiV1InfoResponseVersion.ts │ │ │ ├── ApiV1LoginRequestPayload.ts │ │ │ ├── ApiV1LoginResponseMessage.ts │ │ │ ├── ApiV1ReadableResponseMessage.ts │ │ │ ├── ApiV1UpdateAccountPayload.ts │ │ │ ├── ApiV1UpdateCachePayload.ts │ │ │ ├── ModelAccount.ts │ │ │ ├── ModelAccountDTO.ts │ │ │ ├── ModelBookmarkDTO.ts │ │ │ ├── ModelTagDTO.ts │ │ │ ├── ModelUserConfig.ts │ │ │ └── index.ts │ │ └── runtime.ts │ ├── components/ │ │ └── layout/ │ │ ├── AppLayout.vue │ │ ├── LanguageSelector.vue │ │ ├── Sidebar.vue │ │ └── TopBar.vue │ ├── locales/ │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fr.json │ │ └── ja.json │ ├── main.ts │ ├── router/ │ │ └── index.ts │ ├── stores/ │ │ ├── auth.ts │ │ └── tags.ts │ ├── utils/ │ │ └── i18n.ts │ └── views/ │ ├── AboutView.vue │ ├── ArchiveView.vue │ ├── FoldersView.vue │ ├── HomeView.vue │ ├── LoginView.vue │ ├── SettingsView.vue │ └── TagsView.vue ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.vitest.json ├── vite.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursorrules ================================================ # Shiori Test Commands # Run the entire test suite make unittest # Run SQLite database tests only go test -timeout 10s -count=1 -tags test_sqlite_only ./internal/database ================================================ FILE: .dockerignore ================================================ dev-data* docs !docs/swagger Dockerfile *.md /.* ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Report problems with Shiori title: One line description of the bug labels: type:bug assignees: '' --- ## Data - **Shiori version**: If unknown, run `shiori version` in your terminal or check your server logs. If you don't have the command or information in your logs you are probably running an older version (1.5.4 or older). - **Database Engine**: If unknown, SQLite is the default. - **Operating system**: - **CLI/Web interface/Web Extension**: ## Describe the bug / actual behavior A clear and concise description of what the bug is. ## Expected behavior A clear and concise description of what you expected to happen. ## To Reproduce Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Screenshots If applicable, add screenshots to help explain your problem. ## Notes Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: PLEASE READ ISSUE title: '' labels: '' assignees: '' --- Please report feature requests in the [discussions section](https://github.com/go-shiori/shiori/discussions/categories/feature-requests). Feature requests in issues would be likely moved on there until we plan to work on them somewhere in the future. Having them in discussions helps with the conversation and voting of future new features, as well as to keep the issues section clean and with items to address only. Thank you. ================================================ FILE: .github/dependabot.yml ================================================ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: all: patterns: - "*" # Maintain dependencies for Golang - package-ecosystem: "gomod" directory: "/" schedule: interval: "monthly" groups: all: patterns: - "*" ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 30 # Number of days of inactivity before a stale issue is closed daysUntilClose: -1 # Issues with these labels will never be considered stale exemptLabels: - tag:no-stale - type:bug - type:enhancement - type:documentation # Label to use when marking an issue as stale staleLabel: tag:stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had any activity for quite some time. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false pulls: daysUntilStale: 10 daysUntilClose: -1 onlyLabels: - tag:stalebot ================================================ FILE: .github/workflows/_buildx.yml ================================================ name: "Build Docker" on: workflow_call: secrets: DOCKERHUB_USERNAME: required: true DOCKERHUB_TOKEN: required: true inputs: tag_prefix: description: 'The tag prefix to use' required: false type: string default: '' dockerfile: description: 'The Dockerfile to use' required: false type: string default: 'Dockerfile' jobs: buildx: runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} name: Build Docker steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 5.0.0 with: name: dist path: ./dist # Every pull request that goes into master - name: Prepare master push tags if: github.event_name == 'push' && github.ref == 'refs/heads/master' run: | REPO=ghcr.io/${{ github.repository }} TAG=$(git describe --tags) echo "tag_flags=--tag $REPO:${{ inputs.tag_prefix }}$TAG" >> $GITHUB_ENV # New tagged version - name: Prepare version push tags if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') run: | REPO=ghcr.io/${{ github.repository }} DOCKERHUB_REPO=shioriapp/shiori TAG=$(git describe --tags) if [[ "$TAG" == *"rc"* ]] then TAG2="dev" else TAG2="latest" fi echo "tag_flags=--tag $REPO:${{ inputs.tag_prefix }}$TAG --tag $REPO:${{ inputs.tag_prefix }}$TAG2 --tag $DOCKERHUB_REPO:${{ inputs.tag_prefix }}$TAG --tag $DOCKERHUB_REPO:${{ inputs.tag_prefix }}$TAG2" >> $GITHUB_ENV # Every pull request - name: Prepare pull request tags if: github.event_name == 'pull_request' run: | echo "tag_flags=--tag ${{ github.ref }}" >> $GITHUB_ENV REPO=ghcr.io/${{ github.repository }} echo "tag_flags=--tag $REPO:${{ inputs.tag_prefix }}pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV - name: Buildx run: | set -x echo "${{ secrets.GITHUB_TOKEN }}" | docker login -u "${{ github.repository_owner }}" --password-stdin ghcr.io # Login to DockerHub for versioned releases if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == refs/tags/v* ]]; then echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin fi make buildx CONTAINERFILE_NAME=${{ inputs.dockerfile }} CONTAINER_BUILDX_OPTIONS="--push ${{ env.tag_flags }}" ================================================ FILE: .github/workflows/_delete-registry-tag.yml ================================================ name: Delete registry tag on: workflow_call: inputs: tag_name: description: 'The docker tag to remove' required: true type: string jobs: purge-image-tag: name: Delete image from ghcr.io runs-on: ubuntu-latest steps: - name: Delete image tag uses: bots-house/ghcr-delete-image-action@3827559c68cb4dcdf54d813ea9853be6d468d3a4 # v1.1.0 with: owner: go-shiori name: shiori token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ inputs.tag_name }} ================================================ FILE: .github/workflows/_e2e.yml ================================================ name: "E2E Tests" on: workflow_call jobs: e2e-tests: runs-on: ubuntu-latest name: Tests steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: ./go.mod - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v4.1.2 - name: Install browsers run: npx playwright install --with-deps - run: make e2e - name: Upload test report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: e2e-test-report path: e2e-report.html if-no-files-found: ignore ================================================ FILE: .github/workflows/_golangci-lint.yml ================================================ name: "golangci-lint" on: workflow_call permissions: contents: read pull-requests: read jobs: golangci: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # 8.0.0 with: version: "v2.5.0" # Optional: working directory, useful for monorepos # working-directory: somedir # Optional: golangci-lint command line arguments. # args: --issues-exit-code=0 # Optional: show only new issues if it's a pull request. The default value is `false`. only-new-issues: true # Optional: if set to true then the action will use pre-installed Go. # skip-go-installation: true # Optional: if set to true then the action don't cache or restore ~/go/pkg. # skip-pkg-cache: true # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. # skip-build-cache: true ================================================ FILE: .github/workflows/_gorelease.yml ================================================ name: "Goreleaser" on: workflow_call permissions: contents: write jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Setup Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: 'go.mod' - name: Run GoReleaser uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # 6.4.0 with: distribution: goreleaser version: v2.4.8 args: release --clean ${{ env.flags }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 with: name: dist path: ./dist/* ================================================ FILE: .github/workflows/_mkdocs-check.yml ================================================ name: "Check mkdocs documentation" on: workflow_call jobs: mkdocs-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - name: check run: make docs env: MKDOCS_EXTRA_FLAGS: --strict ================================================ FILE: .github/workflows/_mkdocs-publish.yml ================================================ name: "Publish documentation" on: workflow_call permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 - run: make docs env: DOCS_COMMAND: publish ================================================ FILE: .github/workflows/_styles-check.yml ================================================ name: "styles-check" on: workflow_call jobs: styles-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Bun uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v1 with: bun-version: "1.0.1" - name: Check run: make styles-check ================================================ FILE: .github/workflows/_swagger-check.yml ================================================ name: "swagger-check" on: workflow_call jobs: swagger-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: 'go.mod' - name: Install dependencies run: go install $(cat go.mod | grep swaggo/swag | cut -d " " -f 1)/cmd/swag@$(cat go.mod | grep swaggo/swag | cut -d " " -f 2) - name: check run: make swag-check ================================================ FILE: .github/workflows/_test.yml ================================================ name: "Unit Tests" on: workflow_call: secrets: CODECOV_TOKEN: required: true env: CGO_ENABLED: 0 jobs: test-linux: runs-on: ubuntu-latest services: postgres: image: postgres:13.18 env: POSTGRES_PASSWORD: shiori POSTGRES_USER: shiori options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 mariadb: image: mariadb:10.5.27 env: MYSQL_USER: shiori MYSQL_PASSWORD: shiori MYSQL_DATABASE: shiori MYSQL_ROOT_PASSWORD: shiori options: >- --health-cmd="/usr/local/bin/healthcheck.sh --connect" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 mysql: image: mysql:8.0.40 env: MYSQL_USER: shiori MYSQL_PASSWORD: shiori MYSQL_DATABASE: shiori MYSQL_ROOT_PASSWORD: shiori options: >- --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 ports: - 3307:3306 name: Go unit tests (ubuntu-latest) steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: ./go.mod - name: Set up gotestfmt uses: gotesttools/gotestfmt-action@8b4478c7019be847373babde9300210e7de34bfb # v2.2.0 - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # 4.3.0 with: path: | ~/.cache/go-build ~/go/pkg key: golangci-lint.cache-{platform-arch}-{interval_number}-{go.mod_hash} restore-keys: | golangci-lint.cache-{interval_number}- golangci-lint.cache- - run: make unittest env: SHIORI_TEST_PG_URL: "postgres://shiori:shiori@localhost:5432/shiori?sslmode=disable" SHIORI_TEST_MYSQL_URL: "shiori:shiori@(localhost:3306)/shiori" SHIORI_TEST_MARIADB_URL: "shiori:shiori@(localhost:3307)/shiori" CGO_ENABLED: 1 # go test -race requires cgo - run: go build -tags osusergo,netgo -ldflags="-s -w -X main.version=$(git describe --tags) -X main.date=$(date --iso-8601=seconds)" - name: Upload coverage reports to Codecov uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # 5.5.1 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} test-windows-macos: strategy: matrix: os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} name: Go unit tests (${{ matrix.os }}) steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: ./go.mod - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # 4.3.0 with: path: | ~/.cache/go-build ~/go/pkg key: golangci-lint.cache-{platform-arch}-{interval_number}-{go.mod_hash} restore-keys: | golangci-lint.cache-{interval_number}- golangci-lint.cache- - run: make unittest GO_TEST_FLAGS="-tags test_sqlite_only -race -v -count=1" env: CGO_ENABLED: 1 # go test -race requires cgo - run: go build -tags osusergo,netgo -ldflags="-s -w -X main.version=$(git describe --tags) -X main.date=$(date --iso-8601=seconds)" # Please note BSD support is offered on a best-effort basis, this check is not blocking but for us to be aware of issues. # This test also does not take into consideration the go version specified in the go.mod file and just uses the # latest version available in the openbsd package repository. test-bsd: continue-on-error: true runs-on: ubuntu-latest strategy: matrix: os: - name: openbsd architecture: x86-64 version: "7.7" steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Test on ${{ matrix.os.name }} uses: cross-platform-actions/action@e8a7b572196ff79ded1979dc2bb9ee67d1ddb252 # v0.29.0 with: environment_variables: GO_VERSION operating_system: ${{ matrix.os.name }} architecture: ${{ matrix.os.architecture }} version: ${{ matrix.os.version }} shell: bash memory: 1G cpu_count: 1 run: | sudo pkg_add -u sudo pkg_add gmake git curl -L https://go.dev/dl/go1.25.1.openbsd-amd64.tar.gz | sudo tar -C /usr/local -xzf - export PATH=$PATH:/usr/local/go/bin gmake unittest GO_TEST_FLAGS="-tags test_sqlite_only -v -count=1" ================================================ FILE: .github/workflows/pull_request.yml ================================================ name: 'Pull Request' on: pull_request: branches: - master concurrency: group: ci-tests-${{ github.ref }}-1 cancel-in-progress: true jobs: call-lint: uses: ./.github/workflows/_golangci-lint.yml call-test: uses: ./.github/workflows/_test.yml secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} call-swagger-check: uses: ./.github/workflows/_swagger-check.yml call-mkdocs-check: uses: ./.github/workflows/_mkdocs-check.yml call-styles-check: uses: ./.github/workflows/_styles-check.yml call-e2e: needs: [call-lint, call-test, call-swagger-check, call-styles-check, call-mkdocs-check] uses: ./.github/workflows/_e2e.yml call-gorelease: needs: [call-e2e] uses: ./.github/workflows/_gorelease.yml call-buildx: needs: call-gorelease if: ${{ !startsWith(github.head_ref, 'dependabot/') }} uses: ./.github/workflows/_buildx.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} call-buildx-alpine: needs: call-gorelease if: ${{ !startsWith(github.head_ref, 'dependabot/') }} uses: ./.github/workflows/_buildx.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} with: tag_prefix: alpine- dockerfile: Dockerfile.alpine ================================================ FILE: .github/workflows/pull_request_closed.yml ================================================ name: 'Clean up Docker images from PR' on: pull_request: types: - closed jobs: delete-tag: uses: ./.github/workflows/_delete-registry-tag.yml if: github.event.pull_request.head.repo.fork == false with: tag_name: pr-${{ github.event.pull_request.number }} ================================================ FILE: .github/workflows/push.yml ================================================ name: 'Push' on: push: branches: - "master" tags: - "v*" concurrency: group: ci-tests-${{ github.ref }}-1 cancel-in-progress: true jobs: call-lint: uses: ./.github/workflows/_golangci-lint.yml call-test: uses: ./.github/workflows/_test.yml secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN}} call-e2e: uses: ./.github/workflows/_e2e.yml call-gorelease: needs: [call-lint, call-test, call-e2e] uses: ./.github/workflows/_gorelease.yml call-buildx: needs: call-gorelease uses: ./.github/workflows/_buildx.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} call-buildx-alpine: needs: call-gorelease # only build on pull requests from the same repo for now uses: ./.github/workflows/_buildx.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} with: tag_prefix: alpine- dockerfile: Dockerfile.alpine ================================================ FILE: .github/workflows/version_bump.yml ================================================ name: "Tag release" on: workflow_dispatch: inputs: version: description: "Version to bump to, example: v1.5.2" required: true ref: description: "Ref to release from" required: true type: string default: master jobs: tag-release: runs-on: ubuntu-latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 ref: ${{ inputs.ref }} - name: Tag release run: | git config user.email "${{github.repository_owner}}@users.noreply.github.com" git config user.name "${{github.repository_owner}}" git tag -a ${{ github.event.inputs.version }} -m "tag release ${{ github.event.inputs.version }}" git push --follow-tags call-gorelease: needs: tag-release uses: ./.github/workflows/_gorelease.yml call-buildx: needs: call-gorelease uses: ./.github/workflows/_buildx.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} call-buildx-alpine: needs: call-gorelease uses: ./.github/workflows/_buildx.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} with: tag_prefix: alpine- dockerfile: Dockerfile.alpine call-mkdocs-publish: needs: [call-buildx, call-buildx-alpine] uses: ./.github/workflows/_mkdocs-publish.yml ================================================ FILE: .gitignore ================================================ # Exclude IDE .vscode/ .idea/ # Exclude config file *.toml # Exclude executable file /shiori* # Exclude development data /dev-data* # Tests /coverage.* e2e-report.html # Dist files /dist # macOS trash files .DS_Store # frontend node_modules # golang go.work* # workaround for buildx using podman type=docker # Docs docs/.venv build/docs ================================================ FILE: .golangci.bck.yml ================================================ # Docs: https://golangci-lint.run/usage/configuration/#config-file run: timeout: 5m issues: max-issues-per-linter: 0 max-same-issues: 0 exclude-dirs: - internal/mocks linters-settings: gofmt: simplify: true govet: enable-all: true disable: - fieldalignment linters: disable-all: true enable: - copyloopvar - gofmt - gosimple # - govet # Re-enable when all shadow declarations are fixed - ineffassign - predeclared - staticcheck - unconvert - unused ================================================ FILE: .golangci.yml ================================================ version: "2" linters: default: none enable: - copyloopvar - ineffassign - predeclared - staticcheck - unconvert - unused settings: govet: disable: - fieldalignment enable-all: true exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - internal/mocks - third_party$ - builtin$ - examples$ issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gofmt settings: gofmt: simplify: true exclusions: generated: lax paths: - internal/mocks - third_party$ - builtin$ - examples$ ================================================ FILE: .goreleaser.yaml ================================================ version: 2 before: hooks: - go mod tidy git: ignore_tags: - "{{ if not .IsNightly }}*-rc*{{ end }}" builds: - binary: shiori env: - CGO_ENABLED=0 - GIN_MODE=release tags: - netgo - osusergo goos: - linux - windows - darwin goarch: - amd64 - arm - arm64 goarm: - "7" ignore: - goos: darwin goarch: arm - goos: windows goarch: arm - goos: windows goarch: arm64 archives: - id: shiori name_template: >- {{ .ProjectName }}_ {{- if eq .Os "darwin" }}Darwin{{- else if eq .Os "linux" }}Linux{{- else if eq .Os "windows" }}Windows{{- else }}{{ .Os }}{{ end }}_ {{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "arm64" }}aarch64{{- else }}{{ .Arch }}{{ end }}_{{ .Version }} format_overrides: - goos: windows format: zip # TODO: # upx: # - enabled: true # ids: # - shiori # goos: [linux, darwin] # goarch: [amd64, arm, arm64] # goarm: ["7"] checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ incpatch .Version }}-next" changelog: sort: asc groups: - title: Features regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' order: 0 - title: "Fixes" regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' order: 1 - title: "Performance" regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' order: 2 - title: API regexp: '^.*?api(\([[:word:]]+\))??!?:.+$' order: 3 - title: Documentation regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' order: 4 - title: "Tests" regexp: '^.*?test(\([[:word:]]+\))??!?:.+$' order: 5 - title: CI and Delivery regexp: '^.*?ci(\([[:word:]]+\))??!?:.+$' order: 6 - title: Others order: 999 filters: exclude: - "^deps:" - "^chore\\(deps\\):" release: prerelease: auto ================================================ FILE: .prettierignore ================================================ # Ignore some files we don't want to format *.html *.json *.md *.yml *.yaml # Ignore build artifacts internal/view/assets/css/ # Ignore bundled dependencies internal/view/assets/js/dayjs.min.js internal/view/assets/js/url.js internal/view/assets/js/url.min.js internal/view/assets/js/vue.js internal/view/assets/js/vue.min.js ================================================ FILE: .prettierrc ================================================ { "useTabs": true } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Community Conduct Guideline The following conduct guideline is based on [Ruby's](https://www.ruby-lang.org/en/conduct/) code of conduct. This document provides community guidelines for a safe, respectful, productive, and collaborative place for any person who is willing to contribute to the Shiori community. It applies to all "collaborative space", which is defined as community communications channels (such as issues, mailing lists, submitted patches, commit comments, etc.). - Participants will be tolerant of opposing views. - Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks. - When interpreting the words and actions of others, participants should always assume good intentions. - Behaviour which can be reasonably considered harassment will not be tolerated. Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported by contacting the project maintainer at deanishe@deanishe.net. ================================================ FILE: Dockerfile ================================================ # Build stage ARG ALPINE_VERSION=3.19 FROM docker.io/library/alpine:${ALPINE_VERSION} AS builder ARG TARGETARCH ARG TARGETOS ARG TARGETVARIANT COPY dist/shiori_${TARGETOS}_${TARGETARCH}${TARGETVARIANT}/shiori /usr/bin/shiori RUN apk add --no-cache ca-certificates tzdata && \ chmod +x /usr/bin/shiori && \ rm -rf /tmp/* # Server image FROM scratch ENV PORT=8080 ENV SHIORI_DIR=/shiori WORKDIR ${SHIORI_DIR} LABEL org.opencontainers.image.source="https://github.com/go-shiori/shiori" LABEL maintainer="Felipe Martin " COPY --from=builder /tmp /tmp COPY --from=builder /usr/bin/shiori /usr/bin/shiori COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt EXPOSE ${PORT} ENTRYPOINT ["/usr/bin/shiori"] CMD ["server"] ================================================ FILE: Dockerfile.alpine ================================================ ARG ALPINE_VERSION=3.19 FROM docker.io/library/alpine:${ALPINE_VERSION} ARG TARGETARCH ARG TARGETOS ARG TARGETVARIANT COPY dist/shiori_${TARGETOS}_${TARGETARCH}${TARGETVARIANT}/shiori /usr/bin/shiori RUN apk add --no-cache ca-certificates tzdata && \ chmod +x /usr/bin/shiori && \ rm -rf /tmp/* && \ apk cache clean ENV PORT=8080 ENV SHIORI_DIR=/shiori WORKDIR ${SHIORI_DIR} LABEL org.opencontainers.image.source="https://github.com/go-shiori/shiori" LABEL maintainer="Felipe Martin " EXPOSE ${PORT} ENTRYPOINT ["/usr/bin/shiori"] CMD ["server"] ================================================ FILE: Dockerfile.compose ================================================ # This Dockerfile is intented for Development purposes only to use # with the provided docker-compose.yaml file. # Please do not run this Dockerfile in any environment that is not # a local development scenario as this is not throroughly updated nor # tested. FROM docker.io/golang:1.22-alpine WORKDIR /src/shiori ENTRYPOINT ["go", "run", "main.go"] CMD ["server"] ================================================ FILE: Dockerfile.e2e ================================================ ARG ALPINE_VERSION ARG GOLANG_VERSION FROM docker.io/golang:${GOLANG_VERSION}-alpine${ALPINE_VERSION} WORKDIR /src/shiori COPY . /src/shiori RUN apk --update add git && \ go run main.go version # Using this to force go dep download by running the main command. ENTRYPOINT ["go", "run", "main.go"] CMD ["server"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018-present Radhi Fadlillah Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ GO ?= $(shell command -v go 2> /dev/null) BASH ?= $(shell command -v bash 2> /dev/null) GOLANG_VERSION := $(shell head -n 4 go.mod | tail -n 1 | cut -d " " -f 2) # Development SHIORI_DIR ?= dev-data SOURCE_FILES ?=./internal/... # Build CGO_ENABLED ?= 0 BUILD_TIME := $(shell date -u +%Y%m%d.%H%M%S) BUILD_HASH := $(shell git describe --tags) BUILD_TAGS ?= osusergo,netgo,fts5 LDFLAGS += -s -w -X main.version=$(BUILD_HASH) -X main.date=$(BUILD_TIME) # Build (container) CONTAINER_RUNTIME := docker CONTAINERFILE_NAME := Dockerfile CONTAINER_ALPINE_VERSION := 3.22 BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7 # This is used for local development only, forcing linux to create linux only images but with the arch # of the running machine. Far from perfect but works. LOCAL_BUILD_PLATFORM = linux/$(shell go env GOARCH) # Testing GO_TEST_FLAGS ?= -v -race -count=1 -tags $(BUILD_TAGS) -covermode=atomic -coverprofile=coverage.out GOTESTFMT_FLAGS ?= SHIORI_TEST_MYSQL_URL ?=shiori:shiori@tcp(127.0.0.1:3306)/shiori SHIORI_TEST_MARIADB_URL ?= shiori:shiori@tcp(127.0.0.1:3307)/shiori SHIORI_TEST_PG_URL ?= postgres://shiori:shiori@127.0.0.1:5432/shiori?sslmode=disable # Development GIN_MODE ?= debug SHIORI_DEVELOPMENT ?= true # Swagger SWAG_VERSION := $(shell grep "swaggo/swag" go.mod | cut -d " " -f 2) SWAGGER_DOCS_PATH ?= ./docs/swagger # Frontend CLEANCSS_OPTS ?= --with-rebase # Common exports export GOLANG_VERSION export CONTAINER_RUNTIME export CONTAINERFILE_NAME export CONTAINER_ALPINE_VERSION export BUILDX_PLATFORMS export SOURCE_FILES export SHIORI_TEST_MYSQL_URL export SHIORI_TEST_MARIADB_URL export SHIORI_TEST_PG_URL # Help documentatin à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html .PHONY: help help: @cat Makefile | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort ## Cleans up build artifacts .PHONY: clean clean: rm -rf dist ## Runs server for local development .PHONY: run-server run-server: generate GIN_MODE=$(GIN_MODE) SHIORI_DEVELOPMENT=$(SHIORI_DEVELOPMENT) go run main.go server --log-level debug ## Runs server for local development with v2 web UI .PHONY: run-server-v2 run-server-v2: generate GIN_MODE=$(GIN_MODE) SHIORI_DEVELOPMENT=$(SHIORI_DEVELOPMENT) SHIORI_HTTP_SERVE_WEB_UI_V2=true go run main.go server --log-level debug ## Generate swagger docs .PHONY: swagger swagger: SWAGGER_DOCS_PATH=$(SWAGGER_DOCS_PATH) $(BASH) ./scripts/swagger.sh .PHONY: swag-check swag-check: REQUIRED_SWAG_VERSION=$(SWAG_VERSION) SWAGGER_DOCS_PATH=$(SWAGGER_DOCS_PATH) $(BASH) ./scripts/swagger_check.sh .PHONY: swag-fmt swag-fmt: swag fmt --dir internal/http go fmt ./internal/http/... ## Run linters .PHONY: lint lint: golangci-lint swag-check ## Run golangci-lint .PHONY: golangci-lint golangci-lint: golangci-lint run ## Run unit tests .PHONY: unittest unittest: GIN_MODE=$(GIN_MODE) GO_TEST_FLAGS="$(GO_TEST_FLAGS)" GOTESTFMT_FLAGS="$(GOTESTFMT_FLAGS)" $(BASH) -xe ./scripts/test.sh ## Run end to end tests .PHONY: e2e e2e: $(BASH) -xe ./scripts/e2e.sh ## Build styles .PHONY: styles styles: CLEANCSS_OPTS=$(CLEANCSS_OPTS) $(BASH) ./scripts/styles.sh ## Build styles .PHONY: styles-check styles-check: CLEANCSS_OPTS=$(CLEANCSS_OPTS) $(BASH) ./scripts/styles_check.sh ## Build binary .PHONY: build build: clean GIN_MODE=$(GIN_MODE) goreleaser build --clean --snapshot ## Build binary for current targer build-local: clean GIN_MODE=$(GIN_MODE) goreleaser build --clean --snapshot --single-target ## Build docker image using Buildx. # used for multi-arch builds suing mainly the CI, that's why the task does not # build the binaries using a dependency task. .PHONY: buildx buildx: $(info: Make: Buildx) @bash scripts/buildx.sh ## Build docker image for local development buildx-local: build-local $(info: Make: Build image locally) CONTAINER_BUILDX_OPTIONS="-t shiori:localdev --output type=docker" BUILDX_PLATFORMS=$(LOCAL_BUILD_PLATFORM) scripts/buildx.sh ## Creates a coverage report .PHONY: coverage coverage: $(GO) test $(GO_TEST_FLAGS) -coverprofile=coverage.txt $(SOURCE_FILES) $(GO) tool cover -html=coverage.txt ## Run generate accross the project .PHONY: generate generate: $(GO) generate ./... ================================================ FILE: Procfile ================================================ web: bin/shiori server -p $PORT ================================================ FILE: README.md ================================================ # Shiori [![IC](https://github.com/go-shiori/shiori/actions/workflows/push.yml/badge.svg?branch=master)](https://github.com/go-shiori/shiori/actions/workflows/push.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/go-shiori/shiori)](https://goreportcard.com/report/github.com/go-shiori/shiori) [![#shiori-general:matrix.org](https://img.shields.io/badge/matrix-%23shiori-orange)](https://matrix.to/#/#shiori:matrix.org) [![Containers](https://img.shields.io/static/v1?label=Container&message=Images&color=1488C6&logo=docker)](https://github.com/go-shiori/shiori/pkgs/container/shiori) **Check out our latest [Announcements](https://github.com/go-shiori/shiori/discussions/categories/announcements)** Shiori is a simple bookmarks manager written in the Go language. Intended as a simple clone of [Pocket][pocket]. You can use it as a command line application or as a web application. This application is distributed as a single binary, which means it can be installed and used easily. ![Screenshot][screenshot] ## Features - Basic bookmarks management i.e. add, edit, delete and search. - Import and export bookmarks from and to Netscape Bookmark file. - Import bookmarks from Pocket. - Simple and clean command line interface. - Simple and pretty web interface for those who don't want to use a command line app. - Portable, thanks to its single binary format. - Support for sqlite3, PostgreSQL, MariaDB and MySQL as its database. - Where possible, by default `shiori` will parse the readable content and create an offline archive of the webpage. - [BETA] [web extension][web-extension] support for Firefox and Chrome. ![Comparison of reader mode and archive mode][mode-comparison] ## Documentation All documentation is available in the [docs folder][documentation]. If you think there is incomplete or incorrect information, feel free to edit it by submitting a pull request. ## License Shiori is distributed under the terms of the [MIT license][mit], which means you can use it and modify it however you want. However, if you make an enhancement for it, if possible, please send a pull request. [documentation]: https://github.com/go-shiori/shiori/blob/master/docs/index.md [mit]: https://choosealicense.com/licenses/mit/ [web-extension]: https://github.com/go-shiori/shiori-web-ext [screenshot]: https://raw.githubusercontent.com/go-shiori/shiori/master/docs/assets/screenshots/cover.png [mode-comparison]: https://raw.githubusercontent.com/go-shiori/shiori/master/docs/assets/screenshots/comparison.png [pocket]: https://getpocket.com/ [256]: https://github.com/go-shiori/shiori/issues/256 ================================================ FILE: app.json ================================================ { "name": "Shiori", "description": "Shiori is a simple bookmarks manager written in Go language. Intended as a simple clone of Pocket", "keywords": [ "bookmark", "go", "pocket" ], "website": "http://github.com/go-shiori/shiori", "repository": "http://github.com/go-shiori/shiori" } ================================================ FILE: codecov.yml ================================================ github_checks: annotations: false ================================================ FILE: docker-compose.yaml ================================================ # Docker compose for development purposes only. # Edit it to fit your current development needs. services: shiori: build: context: . dockerfile: Dockerfile.compose container_name: shiori command: - "server" - "--log-level" - "debug" ports: - "8080:8080" volumes: - "./dev-data:/srv/shiori" - ".:/src/shiori" - "go-mod-cache:/go/pkg/mod" restart: unless-stopped links: - "postgres" - "mariadb" environment: SHIORI_DIR: /srv/shiori # SHIORI_HTTP_ROOT_PATH: /shiori/ # SHIORI_DATABASE_URL: mysql://shiori:shiori@(mariadb)/shiori?charset=utf8mb4 # SHIORI_DATABASE_URL: postgres://shiori:shiori@postgres/shiori?sslmode=disable nginx: image: nginx:alpine ports: - "8081:8081" volumes: - "./testdata/nginx.conf:/etc/nginx/nginx.conf:ro" depends_on: - shiori postgres: image: postgres:13.18 environment: POSTGRES_PASSWORD: shiori POSTGRES_USER: shiori ports: - "5432:5432" mariadb: image: mariadb:10.5.27 environment: MYSQL_ROOT_PASSWORD: toor MYSQL_DATABASE: shiori MYSQL_USER: shiori MYSQL_PASSWORD: shiori ports: - "3306:3306" mysql: image: mysql:8.0.40 environment: MYSQL_ROOT_PASSWORD: toor MYSQL_DATABASE: shiori MYSQL_USER: shiori MYSQL_PASSWORD: shiori ports: - "3307:3306" volumes: go-mod-cache: ================================================ FILE: docs/API.md ================================================ This is a brief explanation of Shiori's API. For more examples you can import this [collection](https://github.com/go-shiori/shiori/blob/master/docs/postman/shiori.postman_collection.json) in Postman. > ⚠️ **This is the documentation for the old API. This API is deprecated and will be removed in the future. Please refer and start migrating to the [API v1](./APIv1.md) instead.** - [Auth](#auth) - [Log in](#log-in) - [Log out](#log-out) - [Bookmarks](#bookmarks) - [Get bookmarks](#get-bookmarks) - [Add bookmark](#add-bookmark) - [Edit bookmark](#edit-bookmark) - [Delete bookmark](#delete-bookmark) - [Tags](#tags) - [Get tags](#get-tags) - [Rename tag](#rename-tag) - [Accounts](#accounts) - [List accounts](#list-accounts) - [Create account](#create-account) - [Edit account](#edit-account) - [Delete accounts](#delete-accounts) # Auth ## Log in Most actions require a session id. For that, you'll need to log in using your username and password. |Request info|Value| |-|-| |Endpoint|`/api/login`| |Method|`POST`| Body: ```json { "username": "shiori", "password": "gopher", "remember": true, "owner": true } ``` It will return your session ID in a JSON: ```json { "session": "YOUR_SESSION_ID", "account": { "id": 1, "username": "shiori", "owner": true } } ``` ## Log out Log out of a session ID. |Request info|Value| |-|-| |Endpoint|`/api/logout`| |Method|`POST`| |`X-Session-Id` Header|`sessionId`| # Bookmarks ## Get bookmarks Gets the last 30 bookmarks (last page). |Request info|Value| |-|-| |Endpoint|`/api/bookmarks`| |Method|`GET`| |`X-Session-Id` Header|`sessionId`| Returns: ```json { "bookmarks": [ { "id": 825, "url": "https://interesting_cool_article.com", "title": "Cool Interesting Article", "excerpt": "An interesting and cool article indeed!", "author": "", "public": 0, "modified": "2020-12-06 00:00:00", "imageURL": "", "hasContent": true, "hasArchive": true, "tags": [ { "id": 7, "name": "TAG" } ], "createArchive": false }, ], "maxPage": 19, "page": 1 } ``` ## Add bookmark Add a bookmark. For some reason, Shiori ignores the provided title and excerpt, and instead fetches them automatically. Note the tag format, a regular JSON list will result in an error. |Request info|Value| |-|-| |Endpoint|`/api/bookmarks`| |Method|`POST`| |`X-Session-Id` Header|`sessionId`| Body: ```json { "url": "https://interesting_cool_article.com", "createArchive": true, "public": 1, "tags": [{"name": "Interesting"}, {"name": "Cool"}], "title": "Cool Interesting Article", "excerpt": "An interesting and cool article indeed!" } ``` Returns: ```json { "id": 827, "url": "https://interesting_cool_article.com", "title": "TITLE", "excerpt": "EXCERPT", "author": "AUTHOR", "public": 1, "modified": "DATE", "html": "HTML", "imageURL": "/bookmark/827/thumb", "hasContent": false, "hasArchive": true, "tags": [ { "name": "Interesting" }, { "name": "Cool" } ], "createArchive": true } ``` ## Edit bookmark Modifies a bookmark, by ID. |Request info|Value| |-|-| |Endpoint|`/api/bookmarks`| |Method|`PUT`| |`X-Session-Id` Header|`sessionId`| Body: ```json { "id": 3, "url": "https://interesting_cool_article.com", "title": "Cool Interesting Article", "excerpt": "An interesting and cool article indeed!", "author": "AUTHOR", "public": 1, "modified": "2019-09-22 00:00:00", "imageURL": "/bookmark/3/thumb", "hasContent": false, "hasArchive": false, "tags": [], "createArchive": false } ``` After providing the ID, provide the modified fields. The syntax is the same as [adding](#Add-a-bookmark). ## Delete bookmark Deletes a list of bookmarks, by their IDs. |Request info|Value| |-|-| |Endpoint|`/api/bookmarks`| |Method|`DEL`| |`X-Session-Id` Header|`sessionId`| Body: ```json [1, 2, 3] ``` # Tags ## Get tags Gets the list of tags, their IDs and the number of entries that have those tags. |Request info|Value| |-|-| |Endpoint|`/api/tags`| |Method|`GET`| |`X-Session-Id` Header|`sessionId`| Returns: ```json [ { "id": 1, "name": "Cool", "nBookmarks": 1 }, { "id": 2, "name": "Interesting", "nBookmarks": 1 } ``` ## Rename tag Renames a tag, provided its ID. |Request info|Value| |-|-| |Endpoint|`/api/tags`| |Method|`PUT`| |`X-Session-Id` Header|`sessionId`| Body: ```json { "id": 1, "name": "TAG_NEW_NAME" } ``` # Accounts ## List accounts Gets the list of all user accounts, their IDs, and whether or not they are owners. |Request info|Value| |-|-| |Endpoint|`/api/accounts`| |Method|`GET`| |`X-Session-Id` Header|`sessionId`| Returns: ```json [ { "id": 1, "username": "shiori", "owner": true } ] ``` ## Create account Creates a new user. |Request info|Value| |-|-| |Endpoint|`/api/accounts`| |Method|`POST`| |`X-Session-Id` Header|`sessionId`| Body: ```json { "username": "shiori2", "password": "gopher", "owner": false } ``` ## Edit account Changes an account's password or owner status. |Request info|Value| |-|-| |Endpoint|`/api/accounts`| |Method|`PUT`| |`X-Session-Id` Header|`sessionId`| Body: ```json { "username": "shiori", "oldPassword": "gopher", "newPassword": "gopher", "owner": true } ``` ## Delete accounts Deletes a list of users. |Request info|Value| |-|-| |Endpoint|`/api/accounts`| |Method|`DEL`| |`X-Session-Id` Header|`sessionId`| Body: ```json ["shiori", "shiori2"] ``` ================================================ FILE: docs/APIv1.md ================================================ # API v1 > ℹ️ **This is the documentation for the new API. This API is still in development and though the finished endpoints should not change please consider that breaking changes may occur once its properly released. If you are looking for the current API, please [see here](./API.md).** The new API is an ongoing effort to migrate the current API to a more modern and standard API. The main goals of this new API are: - Ease of development - Use of a [modern framework](https://gin-gonic.com) - Use of a [standard API specification](https://swagger.io/specification/) - Self-documented API using [Swag](https://github.com/swaggo/swag) - Improved authentication and sessions using [JWT](https://jwt.io) - Deduplicate code between the webserver and the API by refactoring the logic into domains - Improve testability by using interfaces and dependency injection The current status of this new API can be checked [here](https://github.com/go-shiori/shiori/issues/640). Since the API is self-docummented, you can check the API documentation by [running the server locally](./Contribute.md#running-the-server-locally) and visiting the [`/swagger/index.html` endpoint](http://localhost:8080/swagger/index.html). ================================================ FILE: docs/CLI.md ================================================ Content --- - [Add bookmark](#add-bookmark) Add bookmark --- To add bookmark with CLI you can use `shiori add`. Shiori has flags to add bookmark: `shiori add --help` ``` Bookmark the specified URL Usage: shiori add url [flags] Flags: -e, --excerpt string Custom excerpt for this bookmark -h, --help help for add --log-archival Log the archival process -a, --no-archival Save bookmark without creating offline archive -o, --offline Save bookmark without fetching data from internet -t, --tags strings Comma-separated tags for this bookmark -i, --title string Custom title for this bookmark Global Flags: --log-caller logrus report caller or not --log-level string set logrus loglevel (default "info") --portable run shiori in portable mode --storage-directory string path to store shiori data ``` Examples: Add url: `shiori add https://example.com` Add url with tags: `shiori add https://example.com -t "example-1,example-2"` Add url with custom title: `shiori add https://example.com --title "example example"` ================================================ FILE: docs/Configuration.md ================================================ # Configuration - [Overall Configuration](#overall-configuration) - [Global configuration](#global-configuration) - [HTTP configuration variables](#http-configuration-variables) - [Storage Configuration](#storage-configuration) - [The data Directory](#the-data-directory) - [Database Configuration](#database-configuration) - [MySQL](#mysql) - [PostgreSQL](#postgresql) - [Reverse proxies and the webroot path](#reverse-proxies-and-the-webroot-path) - [Nginx](#nginx) ## Overall Configuration Most configuration can be set directly using environment variables or flags. The available flags can be found by running `shiori --help`. The available environment variables are listed below. ### Global configuration | Environment variable | Default | Required | Description | | -------------------- | ------- | -------- | -------------------------------------- | | `SHIORI_DEVELOPMENT` | `False` | No | Specifies if the server is in dev mode | ### HTTP configuration variables | Environment variable | Default | Required | Description | | ------------------------------------------ | ------- | -------- | ----------------------------------------------------- | | `SHIORI_HTTP_ENABLED` | True | No | Enable HTTP service | | `SHIORI_HTTP_PORT` | 8080 | No | Port number for the HTTP service | | `SHIORI_HTTP_ADDRESS` | : | No | Address for the HTTP service | | `SHIORI_HTTP_ROOT_PATH` | / | No | Root path for the HTTP service | | `SHIORI_HTTP_ACCESS_LOG` | True | No | Logging accessibility for HTTP requests | | `SHIORI_HTTP_SERVE_WEB_UI` | True | No | Serving Web UI via HTTP. Disable serves only the API. | | `SHIORI_HTTP_SECRET_KEY` | | **Yes** | Secret key for HTTP sessions. | | `SHIORI_HTTP_BODY_LIMIT` | 1024 | No | Limit for request body size | | `SHIORI_HTTP_READ_TIMEOUT` | 10s | No | Maximum duration for reading the entire request | | `SHIORI_HTTP_WRITE_TIMEOUT` | 10s | No | Maximum duration before timing out writes | | `SHIORI_HTTP_IDLE_TIMEOUT` | 10s | No | Maximum amount of time to wait for the next request | | `SHIORI_HTTP_DISABLE_KEEP_ALIVE` | true | No | Disable HTTP keep-alive connections | | `SHIORI_HTTP_DISABLE_PARSE_MULTIPART_FORM` | true | No | Disable pre-parsing of multipart form | | `SHIORI_SSO_PROXY_AUTH_ENABLED` | false | No | Enable SSO Auth Proxy Header | | `SHIORI_SSO_PROXY_AUTH_HEADER_NAME` | Remote-User | No | List of CIDRs of trusted proxies | | `SHIORI_SSO_PROXY_AUTH_TRUSTED` | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 | No | List of CIDRs of trusted proxies | ### Storage Configuration The `StorageConfig` struct contains settings related to storage. | Environment variable | Default | Required | Description | | -------------------- | ------------- | -------- | --------------------------------------- | | `SHIORI_DIR` | (current dir) | No | Directory where Shiori stores its data. | #### The data Directory Shiori is designed to work out of the box, but you can change where it stores your bookmarks if you need to. By default, Shiori saves your bookmarks in one of the following directories: | Platform | Directory | | -------- | ------------------------------------------------------------ | | Linux | `${XDG_DATA_HOME}/shiori` (default: `~/.local/share/shiori`) | | macOS | `~/Library/Application Support/shiori` | | Windows | `%LOCALAPPDATA%/shiori` | If you pass the flag `--portable` to Shiori, your data will be stored in the `shiori-data` subdirectory alongside the shiori executable. To specify a custom path, set the `SHIORI_DIR` environment variable. ### Database Configuration | Environment variable | Default | Required | Description | | -------------------------- | ------- | -------- | ----------------------------------------------- | | `SHIORI_DBMS` (deprecated) | `DBMS` | No | Deprecated (Use environment variables for DBMS) | | `SHIORI_DATABASE_URL` | `URL` | No | URL for the database (required) | > `SHIORI_DBMS` is deprecated and will be removed in a future release. Please use `SHIORI_DATABASE_URL` instead. Shiori uses an SQLite3 database stored in the above [data directory by default](#storage-configuration). If you prefer, you can also use MySQL or PostgreSQL database by setting the `SHIORI_DATABASE_URL` environment variable. #### MySQL MySQL example: `SHIORI_DATABASE_URL="mysql://username:password@(hostname:port)/database?charset=utf8mb4"` You can find additional details in [go mysql sql driver documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name). #### PostgreSQL PostgreSQL example: `SHIORI_DATABASE_URL="postgres://pqgotest:password@hostname/database?sslmode=verify-full"` You can find additional details in [go postgres sql driver documentation](https://pkg.go.dev/github.com/lib/pq). ## Reverse proxies and the webroot path If you want to serve Shiori behind a reverse proxy, you can set the `SHIORI_HTTP_ROOT_PATH` environment variable to the path where Shiori is served, e.g. `/shiori/`. Keep in mind this configuration wont make Shiori accessible from `/shiori` path so you need to setup your reverse proxy accordingly so it can strip the webroot path. We provide some examples for popular reverse proxies below. Please follow your reverse proxy documentation in order to setup it properly. ### Nginx Fox nginx, you can use the following configuration as a example. The important part **is the trailing slash in `proxy_pass` directive**: ```nginx location /shiori/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } ``` ================================================ FILE: docs/Contribute.md ================================================ # Contribute 1. [Running the server locally](#running-the-server-locally) 2. [Updating the API documentation](#updating-the-api-documentation) 3. [Lint the code](#lint-the-code) 4. [Running tests](#running-tests) ## Running the server locally To run the current development server with the defaults you can run the following command: ```bash make run-server ``` ## Updating the API documentation > **ℹ️ Note:** This only applies for the Rest API documentation under the `internal/http` folder, **not** the one under `internal/webserver`. If you make any changes to the Rest API endpoints, you need to update the swagger documentation. In order to do that, you need to have installed [swag](https://github.com/swaggo/swag). Then, run the following command: ```bash make swagger ``` ## Updating the frontend styles The styles that are bundled with Shiori are stored under `internal/view/assets/css/style.css` and `internal/view/assets/css/archive.css` and created from the less files under `internal/views/assets/less`. If you want to make frontend changes you need to do that under the less files and then compile them to css. In order to do that, you need to have installed [bun](https://bun.sh). Then, run the following command: ```bash make styles ``` The `style.css`/`archive.css` will be updated and changes **needs to be committed** to the repository. ## Lint the code In order to lint the code, you need to have installed [golangci-lint](https://golangci-lint.run) and [swag](https://github.com/swaggo/swag). After that, run the following command: ```bash make lint ``` If any errors are found please fix them before submitting your PR. ## Running tests In order to run the test suite, you need to have running a local instance of MariaDB and PostgreSQL. If you have docker, you can do this by running the following command with the compose file provided: ```bash docker-compose up -d mariadb mysql postgres ``` After that, provide the environment variables for the unitest to connect to the database engines: - `SHIORI_TEST_MYSQL_URL` for MySQL - `SHIORI_TEST_MARIADB_URL` for MariaDB - `SHIORI_TEST_PG_URL` for PostgreSQL ``` SHIORI_TEST_PG_URL=postgres://shiori:shiori@127.0.0.1:5432/shiori?sslmode=disable SHIORI_TEST_MYSQL_URL=shiori:shiori@tcp(127.0.0.1:3306)/shiori SHIORI_TEST_MARIADB_URL=shiori:shiori@tcp(127.0.0.1:3307)/shiori ``` Finally, run the tests with the following command: ```bash make unittest ``` ## Building the documentation The documentation is built using MkDocs with the Material theme. For installation instructions, please refer to the [MkDocs installation guide](https://www.mkdocs.org/user-guide/installation/). To preview the documentation locally while making changes, run: ```bash mkdocs serve ``` This will start a local server at `http://127.0.0.1:8000` where you can preview your changes in real-time. Documentation for production is generated automatically on every release and published using github pages. ## Running the server with docker To run the development server using Docker, you can use the provided `docker-compose.yaml` file which includes both PostgreSQL and MariaDB databases: ```bash docker compose up shiori ``` This will start the Shiori server on port 8080 with hot-reload enabled. Any changes you make to the code will automatically rebuild and restart the server. By default, it uses SQLite mounting the local `dev-data` folder in the source code path. To use MariaDB or PostgreSQL instead, uncomment the `SHIORI_DATABASE_URL` line for the appropriate engine in the `docker-compose.yaml` file. ## Running the server using an nginx reverse proxy and a custom webroot To test Shiori behind an nginx reverse proxy with a custom webroot (e.g., `/shiori/`), you can use the provided nginx configuration: 1. First, ensure the `SHIORI_HTTP_ROOT_PATH` environment variable is uncommented in `docker-compose.yaml`: ```yaml SHIORI_HTTP_ROOT_PATH: /shiori/ ``` 2. Then start both Shiori and nginx services: ```bash docker compose up shiori nginx ``` This will start the shiori service along with nginx. You can access Shiori using [http://localhost:8081/shiori](http://localhost:8081/shiori). The nginx configuration in `testdata/nginx.conf` handles all the necessary configuration. ================================================ FILE: docs/Installation.md ================================================ There are several installation methods available : - [Supported](#supported) - [Using Precompiled Binary](#using-precompiled-binary) - [Building From Source](#building-from-source) - [Using Docker Image](#using-docker-image) - [Community provided](#community-provided) - [Using Kubernetes manifests](#using-kubernetes-manifests) - [Managed Hosting](#managed-hosting) - [PikaPods](#pikapods) ## Supported ### Using Precompiled Binary Download the latest version of `shiori` from [the release page](https://github.com/go-shiori/shiori/releases/latest), then put it in your `PATH`. On Linux or MacOS, you can do it by adding this line to your profile file (either `$HOME/.bash_profile` or `$HOME/.profile`): ``` export PATH=$PATH:/path/to/shiori ``` Note that this will not automatically update your path for the remainder of the session. To do this, you should run: ``` source $HOME/.bash_profile or source $HOME/.profile ``` On Windows, you can simply set the `PATH` by using the advanced system settings. ### Building From Source Shiori uses Go module so make sure you have version of `go >= 1.14.1` installed, then run: ``` go get -u -v github.com/go-shiori/shiori ``` ### Using Docker Image To use Docker image, you can pull the latest automated build from Docker Hub : ``` docker pull ghcr.io/go-shiori/shiori ``` If you want to build the Docker image on your own, Shiori already has its [Dockerfile](https://github.com/go-shiori/shiori/blob/master/Dockerfile), so you can build the Docker image by running : ``` docker build -t shiori . ``` ## Community provided Below this there are other ways to deploy Shiori which are not supported by the team but were provided by the community to help others have a starting point. ### Using Kubernetes manifests If you're self-hosting with a Kubernetes cluster, here are manifest files that you can use to deploy Shiori: `deploy.yaml`: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: shiori labels: app: shiori spec: replicas: 1 selector: matchLabels: app: shiori template: metadata: labels: app: shiori spec: volumes: - name: app hostPath: path: /path/to/data/dir - name: tmp emptyDir: medium: Memory containers: - name: shiori image: ghcr.io/go-shiori/shiori:latest command: ["/usr/bin/shiori", "serve"] imagePullPolicy: Always ports: - containerPort: 8080 env: - name: SHIORI_DIR value: /srv/shiori volumeMounts: - mountPath: /srv/shiori name: app - mountPath: /tmp name: tmp ``` Here we are using a local directory to persist Shiori's data. You will need to replace `/path/to/data/dir` with the path to the directory where you want to keep your data. We are also mounting an `EmptyDir` volume for `/tmp` so we can successfully generate ebooks. Since we haven't configured a database in particular, Shiori will use SQLite. I don't think Postgres or MySQL is worth it for such an app, but that's up to you. If you decide to use SQLite, I strongly suggest to keep `replicas` set to 1 since SQLite usually allows at most one writer to proceed concurrently. To route requests to your deployment, you will need a `Service` that gets used by an `Ingress` to handle routing. If you wand to add a path suffix or use a sub domain, you can do so through the ingress config. We only show the bare minimum config to get you started. `service.yaml` ```yaml apiVersion: v1 kind: Service metadata: name: shiori spec: type: LoadBalancer selector: app: shiori ports: - port: 8080 targetPort: 8080 ``` This is using a `LoadBalancer` type which gives the most flexibility. `ingress.yaml`: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: shiori spec: ingressClassName: nginx rules: - http: paths: - path: / pathType: Prefix backend: service: name: shiori port: number: 8080 ``` ## Managed Hosting If you don't manage your own server, the below providers will host Shiori for you. None are endorsed by or affiliated with the team. Support is provided by the providers. ### CloudBreak [CloudBreak](https://cloudbreak.app/products/shiori?utm_medium=referral&utm_source=shiori-docs&rby=shiori-docs) offers Shiori hosting from $12/year ($1/month). Get $3 off with coupon `SHIORI`. Subscribe on CloudBreak ### PikaPods [PikaPods](https://www.pikapods.com/) offers Shiori hosting from $1.20/month with $5 free welcome credit. EU and US regions available. Updates are applied weekly and user data backed up daily. [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=shiori) ================================================ FILE: docs/Screenshots.md ================================================ # Desktop Screenshots ## Login Screen === "Light Theme" [![Login Screen Light](./assets/screenshots/01-login.png)](./assets/screenshots/01-login.png) === "Dark Theme" [![Login Screen Dark](./assets/screenshots/05-dark-login.png)](./assets/screenshots/05-dark-login.png) ## Grid Mode === "Light Theme" [![Grid Mode Light](./assets/screenshots/02-home.png)](./assets/screenshots/02-home.png) === "Dark Theme" [![Grid Mode Dark](./assets/screenshots/06-dark-home.png)](./assets/screenshots/06-dark-home.png) ## List Mode === "Light Theme" [![List Mode Light](./assets/screenshots/03-home-list.png)](./assets/screenshots/03-home-list.png) === "Dark Theme" [![List Mode Dark](./assets/screenshots/07-dark-home-list.png)](./assets/screenshots/07-dark-home-list.png) ## Options Page === "Light Theme" [![Options Light](./assets/screenshots/04-options.png)](./assets/screenshots/04-options.png) === "Dark Theme" [![Options Dark](./assets/screenshots/08-dark-options.png)](./assets/screenshots/08-dark-options.png) # Mobile Screenshots ## Login Screen === "Light Theme" [![Mobile Login Light](./assets/screenshots/09-mobile-login.png)](./assets/screenshots/09-mobile-login.png) === "Dark Theme" [![Mobile Login Dark](./assets/screenshots/13-mobile-dark-login.png)](./assets/screenshots/13-mobile-dark-login.png) ## Grid Mode === "Light Theme" [![Mobile Grid Light](./assets/screenshots/10-mobile-home.png)](./assets/screenshots/10-mobile-home.png) === "Dark Theme" [![Mobile Grid Dark](./assets/screenshots/14-mobile-dark-home.png)](./assets/screenshots/14-mobile-dark-home.png) ## List Mode === "Light Theme" [![Mobile List Light](./assets/screenshots/11-mobile-home-list.png)](./assets/screenshots/11-mobile-home-list.png) === "Dark Theme" [![Mobile List Dark](./assets/screenshots/15-mobile-dark-home-list.png)](./assets/screenshots/15-mobile-dark-home-list.png) ## Options Page === "Light Theme" [![Mobile Options Light](./assets/screenshots/12-mobile-options.png)](./assets/screenshots/12-mobile-options.png) === "Dark Theme" [![Mobile Options Dark](./assets/screenshots/16-mobile-dark-options.png)](./assets/screenshots/16-mobile-dark-options.png) ================================================ FILE: docs/Storage.md ================================================ # Storage Shiori requires a folder to store several pieces of data, such as the bookmark archives, thumbnails, ebooks, and others. If the database engine used is sqlite, then the database file will also be stored in this folder. You can specify the storage folder by using `--storage-dir` or `--portable` flags when running Shiori. If none specified, Shiori will try to find the correct app folder for your OS. For example: - In Windows, Shiori will use `%APPDATA%`. - In Linux, it will use `$XDG_CONFIG_HOME` or `$HOME/.local/share` if `$XDG_CONFIG_HOME` is not set. - In macOS, it will use `$HOME/Library/Application Support`. > For more and up to date information about app folder discovery check [muesli/go-app-paths](https://github.com/muesli/go-app-paths) ================================================ FILE: docs/Usage.md ================================================ Before using `shiori`, make sure it has been installed on your system. By default, `shiori` will store its data in directory `$HOME/.local/share/shiori`. If you want to set the data directory to another location, you can set the environment variable `SHIORI_DIR` (`ENV_SHIORI_DIR` when you are before `1.5.0`) to your desired path. - [Running Docker Container](#running-docker-container) - [Using Command Line Interface](#using-command-line-interface) - [Search syntax](#search-syntax) - [Using Web Interface](#using-web-interface) - [Community contributions](#community-contributions) - [Improved import from Pocket](#improved-import-from-pocket) - [Import from Wallabag](#import-from-wallabag) - [Add URL to Shiori from Android](#add-url-to-shiori-from-android) ## Running Docker Container > If you are not using `shiori` from Docker image, you can skip this section. After building or pulling the image, you will be able to start a container from it. To preserve the data, you need to bind the directory for storing database, thumbnails and archive. In this example we're binding the data directory to our current working directory : ``` docker run -d --rm --name shiori -p 8080:8080 -v $(pwd):/shiori ghcr.io/go-shiori/shiori ``` The above command will : - Creates a new container from image `ghcr.io/go-shiori/shiori`. - Set the container name to `shiori` (option `--name`). - Bind the host current working directory to `/shiori` inside container (option `-v`). - Expose port `8080` in container to port `8080` in host machine (option `-p`). - Run the container in background (option `-d`). - Automatically remove the container when it stopped (option `--rm`). After you've run the container in background, you can access console of the container: > In order to be able to access the container and execute commands you need to use the `alpine-` prefixed images. ``` docker exec -it shiori ash ``` Now you can use `shiori` like normal. If you've finished, you can stop and remove the container by running : ``` docker stop shiori ``` ## Using Command Line Interface Shiori is composed by several subcommands. To see the documentation, run `shiori -h` : ``` Simple command-line bookmark manager built with Go Usage: shiori [command] Available Commands: add Bookmark the specified URL check Find bookmarked sites that no longer exists on the internet delete Delete the saved bookmarks export Export bookmarks into HTML file in Netscape Bookmark format help Help about any command import Import bookmarks from HTML file in Netscape Bookmark format open Open the saved bookmarks pocket Import bookmarks from Pocket's exported HTML file print Print the saved bookmarks server Run the Shiori webserver update Update the saved bookmarks version Output the shiori version Flags: -h, --help help for shiori --portable run shiori in portable mode Use "shiori [command] --help" for more information about a command. ``` ### Search syntax With the `print` command line interface, you can use `-s` flag to submit keywords that will be searched either in url, title, excerpts or cached content. You may also use `-t` flag to include tags and `-e` flag to exclude tags. ## Using Web Interface To access web interface run `shiori server` or start Docker container following tutorial above. If you want to use a different port instead of 8080, you can simply run `shiori server -p `. Once started you can access the web interface in `http://localhost:8080` or `http://localhost:` if you customized it. You will be greeted with login screen like this : ![Login screen](https://raw.githubusercontent.com/go-shiori/shiori/master/docs/screenshots/01-login.png) Since this is our first time, we don't have any account registered yet. With that said, we can use the default user to access web interface: ``` username: shiori password: gopher ``` Once login succeed you will be able to use the web interface. To add the new account, open the settings page and add accounts as needed: ![Options page](https://raw.githubusercontent.com/go-shiori/shiori/master/docs/screenshots/04-options.png) When searching for bookmarks, you may use `tag:tagname` to include tags and `-tag:tagname` to exclude tags in the search bar. You can also use tags dialog to do this : - `Click` on the tag name to include it; - `Alt + Click` on the tag name to exclude it. ## Community contributions ### Improved import from Pocket Shiori offers a [Command Line Interface](https://github.com/go-shiori/shiori/blob/master/docs/Usage.md#using-command-line-interface) with the command `shiori pocket` to import Pocket entries but with this can only import them as links and not as complete entries. To import your bookmarks from [Pocket](https://getpocket.com/) with the text and images follow these simple steps (based on [Issue 252](https://github.com/go-shiori/shiori/issues/252)): 1. Export your entries from Pocket by visiting https://getpocket.com/export 2. Download [this shell script](https://gist.github.com/fmartingr/88a258bfad47fb00a3ef9d6c38e5699e). [*You need to download this in your docker container or on the device that you are hosting shiori*]. Name it for instance `pocket2shiori.sh`. > Tip: checkout the documentation for [opening a console in the docker container](https://github.com/go-shiori/shiori/blob/master/docs/Usage.md#running-docker-container). 3. Execute the shell script. Here are the commands you need to run: ```sh wget 'https://gist.githubusercontent.com/fmartingr/88a258bfad47fb00a3ef9d6c38e5699e/raw/a21afb20b56d5383b8b975410e0eb538de02b422/pocket2shiori.sh' chmod +x pocket2shiori.sh pocket2shiori.sh 'path_to_your/pocket_export.html' ``` > Tip: If you’re using shiori's docker container, ensure that the exported HTML from pocket is accessible inside the docker container. You should now see `shiori` importing your Pocket entries properly with the text and images. This is optional, but once the import is complete you can clean up by running: ```sh rm pocket2shiori.sh 'path_to_your/pocket_export.html' ``` ### Import from Wallabag 1. Export your entries from Wallabag as a json file 2. Install [jq](https://stedolan.github.io/jq/download/). You will need this installed before running the script. 3. Download the shell script [here](https://gist.githubusercontent.com/Aerex/01499c66f6b36a5d997f97ca1b0ab5b1/raw/bf793515540278fc675c7769be74a77ca8a41e62/wallabag2shiori). Similar to the `pocket2shiori.sh` script if you are shiori is in a docker container you will next to run this script inside the container. 4. Execute the script. Here are the commands that you can run. ```sh curl -sSOL https://gist.githubusercontent.com/Aerex/01499c66f6b36a5d997f97ca1b0ab5b1/raw/bf793515540278fc675c7769be74a77ca8a41e62/wallabag2shiori' chmod +x wallabag2shiori ./wallabag2shiori 'path/to/to/wallabag_export_json_file' ``` ### Add URL to Shiori from Android 1. Install [Termux](https://termux.dev/en/) 2. Open termux and run bellow command ```bash mkdir -p ~/bin touch ~/bin/termux-url-opener chmod +x ~/bin/termux-url-opener nano ~/bin/termux-url-opener ``` 3. Edit bellow code and replace `Shiori_URL`, `Username`, `Password` with yours ```bash #!/bin/bash # shiori settings Shiori_URL="http://127.0.0.1:8080" Username="shiori" Password="gopher" token=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username": "'"$Username"'" , "password": "'"$Password"'", "remember": true}' $Shiori_URL/api/v1/auth/login | grep -oP '(?<="token":")[^"]*') curl -s -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{ "url": "'"$1"'", "createArchive": false, "public": 1, "tags": [], "title": "", "excerpt": "" }' $Shiori_URL/api/bookmarks exit ``` 4. Paste above content in editor and `Volume-down` and `o` than Enter to save file. 5. `Volume-down` and `x` to exit editor. 6. close termux You can share links with termux from Share menu links will automatically add to Shiori from mobile device. ================================================ FILE: docs/assets/css/style.css ================================================ [data-md-color-scheme="shiori"] { --md-primary-fg-color: rgb(244, 67, 54); } ================================================ FILE: docs/faq.md ================================================ # Frequently asked questions - [General](#general) - [What is this project ?](#what-is-this-project-) - [How does it compare to other bookmarks manager ?](#how-does-it-compare-to-other-bookmarks-manager-) - [What are the system requirements ?](#what-are-the-system-requirements-) - [What is the status for this app ?](#what-is-the-status-for-this-app-) - [Is this app actively maintained ?](#is-this-app-actively-maintained-) - [How to make a contribution ?](#how-to-make-a-contribution-) - [How to make a donation ?](#how-to-make-a-donation-) - [Common Issues](#common-issues) - [What is the default account to login at the first time ?](#what-is-the-default-account-to-login-at-the-first-time-) - [Why my old accounts can't do anything after upgrading Shiori to v1.5.0 ?](#why-my-old-accounts-cant-do-anything-after-upgrading-shiori-to-v150-) - [`Failed to get bookmarks: failed to fetch data: no such module: fts4` ?](#failed-to-get-bookmarks-failed-to-fetch-data-no-such-module-fts4-) - [Advanced](#advanced) - [How to run `shiori` on start up (Linux)?](#how-to-run-shiori-on-start-up-linux) - [How to run `shiori` on start up (macOS)?](#how-to-run-shiori-on-start-up-macos) ## General ### What is this project ? Shiori is a bookmarks manager that built with Go. I've got the idea to make this after reading a comment on HN back in [April 2017](https://news.ycombinator.com/item?id=14203383) : ``` ... for me the dream bookmark manager would be something really simple with two commands like: $ bookmark add http://... That will: a. Download a static copy of the webpage in a single HTML file, with a PDF exported copy, that also take care of removing ads and unrelated content from the stored content. b. Run something like http://smmry.com/ to create a summary of the page in few sentences and store it. c. Use NLP techniques to extract the principle keywords and use them as tags And another command like: $ bookmark search "..." That will: d. Not use regexp or complicated search pattern, but instead; e. Search titles, tags, page content smartly and interactively, and; f. Sort/filter results smartly by relevance, number of matches, frequency, or anything else useful g. Storing everything in a git repository or simple file structure for easy synchronization, bonus point for browsers integrations. ``` I do like using bookmarks and those idea sounds useful to me. More importantly, it seems possible enough to do. Not too hard that it's impossible for me, but not too easy that it doesn't teach me anything. Looking back now, the only thing that I (kind of) managed to do is a, b, d and e. But it's enough for me, so it's fine I guess :laughing:. ### How does it compare to other bookmarks manager ? To be honest I don't know. The only bookmarks manager that I've used is Pocket and the one that bundled in web browser. I do like Pocket though. However, since bookmarks is kind of sensitive data, I prefer it stays offline or in my own server. ### What are the system requirements ? It runs in the lowest tier of Digital Ocean VPS, so I guess it should be able to run anywhere. ### What is the status for this app ? It's stable enough to use and the database shouldn't be changed anymore. However, my bookmarks at most is only several hundred entries, therefore I haven't test whether it able to process or imports huge amount of bookmarks. If you would, please do try it. ### Is this app actively maintained ? Yes, however the development pace might be really slow. @fmartingr is the current active maintainer though @RadhiFadlillah or @deanishe may step and work on stuff from time to time or in other [go-shiori projects](https://github.com/go-shiori) ### How to make a contribution ? Just like other open source projects, you can make a contribution by submitting issues or pull requests. ### How to make a donation ? If you like this project, you can donate to maintainers via: - **fmartingr** [PayPal](https://www.paypal.me/fmartingr), [Ko-Fi](https://ko-fi.com/fmartingr) - **RadhiFadlillah** [PayPal](https://www.paypal.me/RadhiFadlillah), [Ko-Fi](https://ko-fi.com/radhifadlillah) ## Common Issues ### What is the default account to login at the first time ? A default account is created with the credentials: - Username: `shiori` - Password: `gopher` ### Why my old accounts can't do anything after upgrading Shiori to v1.5.0 ? This issue happened because in Shiori v1.0.0 there are no account level, which means everyone is treated as owner. However, in Shiori v1.5.0 there are two account levels i.e. owner and visitor. The level difference is stored in [database](https://github.com/go-shiori/shiori/blob/master/internal/database/sqlite.go#L42-L48) as boolean value in column `owner` with default value false (which means by default all account is visitor, unless specified otherwise). Because in v1.5.0 by default all account is visitor, when updating from v1.0 to v1.5 all of the old accounts by default will be marked as visitor. Fortunately, when there are no owner registered in database, we can login as owner using default account. So, as workaround for this issue, you should : - Login as default account. - Go to options page. - Remove your old accounts. - Recreate them, but now as owner. For more details see [#148](https://github.com/go-shiori/shiori/issues/148). ### `Failed to get bookmarks: failed to fetch data: no such module: fts4` ? This happens to SQLite users that upgrade from 1.5.0 to 1.5.1 because of a breaking change. Please check the [announcement](https://github.com/go-shiori/shiori/discussions/383) to understand how to migrate your database and move forward. ## Advanced ### How to run `shiori` on start up (Linux)? There are several methods to run `shiori` on start up, however the most recommended is running it as a service. 1. Create a service unit for `systemd` at `/etc/systemd/system/shiori.service`. * Shiori is run via `docker` : ```ini [Unit] Description=Shiori container After=docker.service [Service] Restart=always ExecStartPre=-/usr/bin/docker rm shiori-1 ExecStart=/usr/bin/docker run \ --rm \ --name shiori-1 \ -p 8080:8080 \ -v /srv/machines/shiori:/shiori \ ghcr.io/go-shiori/shiori ExecStop=/usr/bin/docker stop -t 2 shiori-1 [Install] WantedBy=multi-user.target ``` * Shiori without `docker`. Set absolute path to `shiori` binary. `--portable` sets the data directory to be alongside the executable. ```ini [Unit] Description=Shiori service [Service] ExecStart=/home/user/go/bin/shiori server --portable Restart=always [Install] WantedBy=multi-user.target ``` * Shiori without `docker` and without `--portable` but secure. ```ini [Unit] Description=shiori service Requires=network-online.target After=network-online.target [Service] Type=simple ExecStart=/usr/bin/shiori server Restart=always User=shiori Group=shiori Environment="SHIORI_DIR=/var/lib/shiori" DynamicUser=true PrivateUsers=true ProtectHome=true ProtectKernelLogs=true RestrictAddressFamilies=AF_INET AF_INET6 StateDirectory=shiori SystemCallErrorNumber=EPERM SystemCallFilter=@system-service SystemCallFilter=~@chown SystemCallFilter=~@keyring SystemCallFilter=~@memlock SystemCallFilter=~@setuid DeviceAllow= CapabilityBoundingSet= LockPersonality=true MemoryDenyWriteExecute=true NoNewPrivileges=true PrivateDevices=true PrivateTmp=true ProtectControlGroups=true ProtectKernelTunables=true ProtectSystem=full ProtectClock=true ProtectKernelModules=true ProtectProc=noaccess ProtectHostname=true ProcSubset=pid RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true SystemCallArchitectures=native SystemCallFilter=~@clock SystemCallFilter=~@debug SystemCallFilter=~@module SystemCallFilter=~@mount SystemCallFilter=~@raw-io SystemCallFilter=~@reboot SystemCallFilter=~@privileged SystemCallFilter=~@resources SystemCallFilter=~@cpu-emulation SystemCallFilter=~@obsolete UMask=0077 [Install] WantedBy=multi-user.target ``` 2. Set up data directory if Shiori with `docker` This assumes, that the Shiori container has a runtime directory to store their database, which is at `/srv/machines/shiori`. If you want to modify that, make sure, to fix your `shiori.service` as well. ```sh install -d /srv/machines/shiori ``` 3. Enable and start the service ```sh systemctl enable --now shiori ``` ### How to run `shiori` on start up (macOS)? Create `local.app.shiori.plist` file in `~/Library/LaunchAgents` and use the template below. Add your own secret key and paths. The filename can be anything but it's a good practice to start it with `local`: ```xml Label local.app.shiori EnvironmentVariables SHIORI_HTTP_SECRET_KEY somerandomvalue123489 ProgramArguments /absolute/path/to/shiori/binary server --storage-directory /absolute/path/to/shiori/storage/directory RunAtLoad ServiceDescription Shiori Bookmarking Service ``` You also need to update your Mac's `System Settings > General > Login Items & Extensions > Allow in the background`. Next time you log in to your Mac, the Shiori server will automatically start and the Shiori login state will persist. To remove the service, delete the `plist` file. ================================================ FILE: docs/index.md ================================================ # Documentation Shiori is a simple bookmarks manager written in Go language. Intended as a simple clone of [Pocket](https://getpocket.com/), it can be used as both a command line and web application. Features include: - Basic bookmarks management (add, edit, delete and search) - Import/export bookmarks from Netscape Bookmark file - Import from Pocket - Simple web interface - Offline webpage archiving - Support for SQLite, PostgreSQL and MySQL ================================================ FILE: docs/postman/shiori.postman_collection.json ================================================ { "info": { "_postman_id": "aeadb2db-90b7-40f3-87d2-de76f8e8972a", "name": "shiori", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Auth", "item": [ { "name": "/api/login", "request": { "method": "POST", "header": [ { "key": "Content-Type", "name": "Content-Type", "value": "application/json", "type": "text" } ], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"shiori\",\n\t\"password\": \"gopher\",\n\t\"remember\": true,\n\t\"owner\": true\n}" }, "url": { "raw": "{{host}}/api/login", "host": [ "{{host}}" ], "path": [ "api", "login" ] } }, "response": [] }, { "name": "/api/logout", "request": { "method": "POST", "header": [ { "key": "X-Session-Id", "value": "{{sessionId}}", "type": "text" } ], "url": { "raw": "{{host}}/api/logout", "host": [ "{{host}}" ], "path": [ "api", "logout" ] } }, "response": [] } ] }, { "name": "Tags", "item": [ { "name": "/api/tags", "request": { "method": "GET", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" } ], "url": { "raw": "{{host}}/api/tags", "host": [ "{{host}}" ], "path": [ "api", "tags" ] } }, "response": [] }, { "name": "/api/tag", "request": { "method": "PUT", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "name": "Content-Type", "value": "application/json", "type": "text" } ], "body": { "mode": "raw", "raw": "{\n\t\"id\": 1,\n \"name\": \"renamed_tag_7\"\n}" }, "url": { "raw": "{{host}}/api/tag", "host": [ "{{host}}" ], "path": [ "api", "tag" ] } }, "response": [] } ] }, { "name": "Bookmarks", "item": [ { "name": "/api/bookmarks", "request": { "method": "GET", "header": [ { "key": "X-Session-Id", "value": "{{sessionId}}", "type": "text" } ], "url": { "raw": "{{host}}/api/bookmarks", "host": [ "{{host}}" ], "path": [ "api", "bookmarks" ] } }, "response": [] }, { "name": "/api/bookmarks", "request": { "method": "POST", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "name": "Content-Type", "value": "application/json", "type": "text" } ], "body": { "mode": "raw", "raw": "{\n\t\"url\": \"https://hckrnews.com\",\n\t\"createArchive\": false,\n\t\"public\": 1,\n\t\"tags\": [],\n\t\"title\": \"\",\n\t\"excerpt\": \"\"\n}" }, "url": { "raw": "{{host}}/api/bookmarks", "host": [ "{{host}}" ], "path": [ "api", "bookmarks" ] } }, "response": [] }, { "name": "/api/bookmarks", "request": { "method": "PUT", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": 3,\n \"url\": \"https://hckrnews.com\",\n \"title\": \"Hacker News sorted by time\",\n \"excerpt\": \"An unofficial, alternative interface to Hacker News\",\n \"author\": \"Wayne Larsen\",\n \"public\": 1,\n \"modified\": \"2019-09-22 06:05:54\",\n \"imageURL\": \"/bookmark/3/thumb\",\n \"hasContent\": false,\n \"hasArchive\": false,\n \"tags\": [],\n \"createArchive\": false\n}" }, "url": { "raw": "{{host}}/api/bookmarks", "host": [ "{{host}}" ], "path": [ "api", "bookmarks" ] } }, "response": [] }, { "name": "/api/bookmarks", "request": { "method": "DELETE", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "[1]" }, "url": { "raw": "{{host}}/api/bookmarks", "host": [ "{{host}}" ], "path": [ "api", "bookmarks" ] } }, "response": [] } ] }, { "name": "BFF", "item": [ { "name": "/api/cache", "request": { "method": "PUT", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n\t\"ids\": [1, 2],\n\t\"keepMetadata\": false,\n\t\"createArchive\": false\n}" }, "url": { "raw": "{{host}}/api/cache", "host": [ "{{host}}" ], "path": [ "api", "cache" ] } }, "response": [] }, { "name": "/api/bookmarks/tags", "request": { "method": "PUT", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"ids\": [\n 1\n ],\n \"tags\": [\n {\n \"id\": 1,\n \"name\": \"new_tag\"\n }\n ]\n}" }, "url": { "raw": "{{host}}/api/bookmarks/tags", "host": [ "{{host}}" ], "path": [ "api", "bookmarks", "tags" ] }, "description": "Performs bulk insertion of new tags into the specified bookmarks" }, "response": [] } ] }, { "name": "Accounts", "item": [ { "name": "/api/accounts", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "X-Session-Id", "value": "{{sessionId}}", "type": "text" }, { "key": "Content-Type", "value": "application/json", "type": "text" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{host}}/api/accounts", "host": [ "{{host}}" ], "path": [ "api", "accounts" ] } }, "response": [] }, { "name": "/api/accounts", "request": { "method": "PUT", "header": [ { "key": "X-Session-Id", "value": "{{sessionId}}", "type": "text" }, { "key": "Content-Type", "value": "application/json", "type": "text" } ], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"shiori\",\n\t\"oldPassword\": \"gopher\",\n\t\"newPassword\": \"gopher\",\n\t\"owner\": true\n}" }, "url": { "raw": "{{host}}/api/accounts", "host": [ "{{host}}" ], "path": [ "api", "accounts" ] } }, "response": [] }, { "name": "/api/accounts", "request": { "method": "POST", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"shiori3\",\n\t\"password\": \"gopher\",\n\t\"owner\": false\n}" }, "url": { "raw": "{{host}}/api/accounts", "host": [ "{{host}}" ], "path": [ "api", "accounts" ] } }, "response": [] }, { "name": "/api/accounts", "request": { "method": "DELETE", "header": [ { "key": "X-Session-Id", "type": "text", "value": "{{sessionId}}" }, { "key": "Content-Type", "type": "text", "value": "application/json" } ], "body": { "mode": "raw", "raw": "[\"shiori\"]" }, "url": { "raw": "{{host}}/api/accounts", "host": [ "{{host}}" ], "path": [ "api", "accounts" ] } }, "response": [] } ] } ], "event": [ { "listen": "prerequest", "script": { "id": "d17b19de-37c1-472d-b919-d56e0f05f311", "type": "text/javascript", "exec": [ "" ] } }, { "listen": "test", "script": { "id": "a14c27ed-a4aa-4171-b5eb-ade9dd6d9dfb", "type": "text/javascript", "exec": [ "" ] } } ], "variable": [ { "id": "822ed4ee-d050-46c7-b30e-eb16335e4de6", "key": "host", "value": "localhost:8080", "type": "string" }, { "id": "89ec47f1-aae0-4872-86b1-4a721967c502", "key": "sessionId", "value": "a4cbd539-e54b-40a8-833a-58885f8397ba", "type": "string" } ] } ================================================ FILE: docs/swagger/docs.go ================================================ // Package swagger Code generated by swaggo/swag. DO NOT EDIT package swagger import "github.com/swaggo/swag" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, "swagger": "2.0", "info": { "description": "{{escape .Description}}", "title": "{{.Title}}", "contact": {}, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { "/api/v1/accounts": { "get": { "description": "List accounts", "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "List accounts", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.AccountDTO" } } }, "500": { "description": "Internal Server Error", "schema": { "type": "string" } } } }, "post": { "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "Create an account", "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/model.AccountDTO" } }, "400": { "description": "Bad Request" }, "409": { "description": "Account already exists" }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/accounts/{id}": { "delete": { "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "Delete an account", "parameters": [ { "type": "integer", "description": "Account ID", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No content" }, "400": { "description": "Invalid ID" }, "404": { "description": "Account not found" }, "500": { "description": "Internal Server Error" } } }, "patch": { "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "Update an account", "parameters": [ { "type": "integer", "description": "Account ID", "name": "id", "in": "path", "required": true }, { "description": "Account data", "name": "account", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.updateAccountPayload" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.AccountDTO" } }, "400": { "description": "Invalid ID/data" }, "404": { "description": "Account not found" }, "409": { "description": "Account already exists" }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/auth/account": { "patch": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Update account information", "parameters": [ { "description": "Account data", "name": "payload", "in": "body", "schema": { "$ref": "#/definitions/api_v1.updateAccountPayload" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.Account" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/auth/login": { "post": { "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Login to an account using username and password", "parameters": [ { "description": "Login data", "name": "payload", "in": "body", "schema": { "$ref": "#/definitions/api_v1.loginRequestPayload" } } ], "responses": { "200": { "description": "Login successful", "schema": { "$ref": "#/definitions/api_v1.loginResponseMessage" } }, "400": { "description": "Invalid login data" } } } }, "/api/v1/auth/logout": { "post": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Logout from the current session", "responses": { "200": { "description": "Logout successful" }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/auth/me": { "get": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Get information for the current logged in user", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.Account" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/auth/refresh": { "post": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Refresh a token for an account", "responses": { "200": { "description": "Refresh successful", "schema": { "$ref": "#/definitions/api_v1.loginResponseMessage" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/bookmarks/bulk/tags": { "put": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Bulk update tags for multiple bookmarks.", "parameters": [ { "description": "Bulk Update Bookmark Tags Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.bulkUpdateBookmarkTagsPayload" } } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.BookmarkDTO" } } }, "400": { "description": "Invalid request payload" }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "No bookmarks found" } } } }, "/api/v1/bookmarks/cache": { "put": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Update Cache and Ebook on server.", "parameters": [ { "description": "Update Cache Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.updateCachePayload" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.BookmarkDTO" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/bookmarks/id/readable": { "get": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Get readable version of bookmark.", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/api_v1.readableResponseMessage" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/bookmarks/{id}/tags": { "get": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Get tags for a bookmark.", "parameters": [ { "type": "integer", "description": "Bookmark ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.TagDTO" } } }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "Bookmark not found" } } }, "post": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Add a tag to a bookmark.", "parameters": [ { "type": "integer", "description": "Bookmark ID", "name": "id", "in": "path", "required": true }, { "description": "Add Tag Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.bookmarkTagPayload" } } ], "responses": { "200": { "description": "OK" }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "Bookmark or tag not found" } } }, "delete": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Remove a tag from a bookmark.", "parameters": [ { "type": "integer", "description": "Bookmark ID", "name": "id", "in": "path", "required": true }, { "description": "Remove Tag Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.bookmarkTagPayload" } } ], "responses": { "200": { "description": "OK" }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "Bookmark not found" } } } }, "/api/v1/system/info": { "get": { "description": "Get general system information like Shiori version, database, and OS", "produces": [ "application/json" ], "tags": [ "System" ], "summary": "Get general system information", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/api_v1.infoResponse" } }, "403": { "description": "Only owners can access this endpoint" } } } }, "/api/v1/tags": { "get": { "description": "List all tags", "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "List tags", "parameters": [ { "type": "boolean", "description": "Include bookmark count for each tag", "name": "with_bookmark_count", "in": "query" }, { "type": "integer", "description": "Filter tags by bookmark ID", "name": "bookmark_id", "in": "query" }, { "type": "string", "description": "Search tags by name", "name": "search", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.TagDTO" } } }, "403": { "description": "Authentication required" }, "500": { "description": "Internal server error" } } }, "post": { "description": "Create a new tag", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "Create tag", "parameters": [ { "description": "Tag data", "name": "tag", "in": "body", "required": true, "schema": { "$ref": "#/definitions/model.TagDTO" } } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/model.TagDTO" } }, "400": { "description": "Invalid request" }, "403": { "description": "Authentication required" }, "500": { "description": "Internal server error" } } } }, "/api/v1/tags/{id}": { "get": { "description": "Get a tag by ID", "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "Get tag", "parameters": [ { "type": "integer", "description": "Tag ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.TagDTO" } }, "403": { "description": "Authentication required" }, "404": { "description": "Tag not found" }, "500": { "description": "Internal server error" } } }, "put": { "description": "Update an existing tag", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "Update tag", "parameters": [ { "type": "integer", "description": "Tag ID", "name": "id", "in": "path", "required": true }, { "description": "Tag data", "name": "tag", "in": "body", "required": true, "schema": { "$ref": "#/definitions/model.TagDTO" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.TagDTO" } }, "400": { "description": "Invalid request" }, "403": { "description": "Authentication required" }, "404": { "description": "Tag not found" }, "500": { "description": "Internal server error" } } }, "delete": { "description": "Delete a tag", "tags": [ "Tags" ], "summary": "Delete tag", "parameters": [ { "type": "integer", "description": "Tag ID", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" }, "403": { "description": "Authentication required" }, "404": { "description": "Tag not found" }, "500": { "description": "Internal server error" } } } } }, "definitions": { "api_v1.bookmarkTagPayload": { "type": "object", "required": [ "tag_id" ], "properties": { "tag_id": { "type": "integer" } } }, "api_v1.bulkUpdateBookmarkTagsPayload": { "type": "object", "required": [ "bookmark_ids", "tag_ids" ], "properties": { "bookmark_ids": { "type": "array", "items": { "type": "integer" } }, "tag_ids": { "type": "array", "items": { "type": "integer" } } } }, "api_v1.infoResponse": { "type": "object", "properties": { "database": { "type": "string" }, "os": { "type": "string" }, "version": { "type": "object", "properties": { "commit": { "type": "string" }, "date": { "type": "string" }, "tag": { "type": "string" } } } } }, "api_v1.loginRequestPayload": { "type": "object", "properties": { "password": { "type": "string" }, "remember_me": { "type": "boolean" }, "username": { "type": "string" } } }, "api_v1.loginResponseMessage": { "type": "object", "properties": { "expires": { "type": "integer" }, "token": { "type": "string" } } }, "api_v1.readableResponseMessage": { "type": "object", "properties": { "content": { "type": "string" }, "html": { "type": "string" } } }, "api_v1.updateAccountPayload": { "type": "object", "properties": { "config": { "$ref": "#/definitions/model.UserConfig" }, "new_password": { "type": "string" }, "old_password": { "type": "string" }, "owner": { "type": "boolean" }, "username": { "type": "string" } } }, "api_v1.updateCachePayload": { "type": "object", "required": [ "ids" ], "properties": { "create_archive": { "type": "boolean" }, "create_ebook": { "type": "boolean" }, "ids": { "type": "array", "items": { "type": "integer" } }, "keep_metadata": { "type": "boolean" }, "skip_exist": { "type": "boolean" } } }, "model.Account": { "type": "object", "properties": { "config": { "$ref": "#/definitions/model.UserConfig" }, "id": { "type": "integer" }, "owner": { "type": "boolean" }, "password": { "type": "string" }, "username": { "type": "string" } } }, "model.AccountDTO": { "type": "object", "properties": { "config": { "$ref": "#/definitions/model.UserConfig" }, "id": { "type": "integer" }, "owner": { "type": "boolean" }, "passowrd": { "description": "Used only to store, not to retrieve", "type": "string" }, "username": { "type": "string" } } }, "model.BookmarkDTO": { "type": "object", "properties": { "author": { "type": "string" }, "create_archive": { "description": "TODO: migrate outside the DTO", "type": "boolean" }, "create_ebook": { "description": "TODO: migrate outside the DTO", "type": "boolean" }, "createdAt": { "type": "string" }, "excerpt": { "type": "string" }, "hasArchive": { "type": "boolean" }, "hasContent": { "type": "boolean" }, "hasEbook": { "type": "boolean" }, "html": { "type": "string" }, "id": { "type": "integer" }, "imageURL": { "type": "string" }, "modifiedAt": { "type": "string" }, "public": { "type": "integer" }, "tags": { "type": "array", "items": { "$ref": "#/definitions/model.TagDTO" } }, "title": { "type": "string" }, "url": { "type": "string" } } }, "model.TagDTO": { "type": "object", "properties": { "bookmark_count": { "description": "Number of bookmarks with this tag", "type": "integer" }, "deleted": { "description": "Marks when a tag is deleted from a bookmark", "type": "boolean" }, "id": { "type": "integer" }, "name": { "type": "string" } } }, "model.UserConfig": { "type": "object", "properties": { "createEbook": { "type": "boolean" }, "hideExcerpt": { "type": "boolean" }, "hideThumbnail": { "type": "boolean" }, "keepMetadata": { "type": "boolean" }, "listMode": { "type": "boolean" }, "makePublic": { "type": "boolean" }, "showId": { "type": "boolean" }, "theme": { "type": "string" }, "useArchive": { "type": "boolean" } } } } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "", Host: "", BasePath: "", Schemes: []string{}, Title: "", Description: "", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", RightDelim: "}}", } func init() { swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) } ================================================ FILE: docs/swagger/swagger.json ================================================ { "swagger": "2.0", "info": { "contact": {} }, "paths": { "/api/v1/accounts": { "get": { "description": "List accounts", "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "List accounts", "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.AccountDTO" } } }, "500": { "description": "Internal Server Error", "schema": { "type": "string" } } } }, "post": { "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "Create an account", "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/model.AccountDTO" } }, "400": { "description": "Bad Request" }, "409": { "description": "Account already exists" }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/accounts/{id}": { "delete": { "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "Delete an account", "parameters": [ { "type": "integer", "description": "Account ID", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No content" }, "400": { "description": "Invalid ID" }, "404": { "description": "Account not found" }, "500": { "description": "Internal Server Error" } } }, "patch": { "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "accounts" ], "summary": "Update an account", "parameters": [ { "type": "integer", "description": "Account ID", "name": "id", "in": "path", "required": true }, { "description": "Account data", "name": "account", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.updateAccountPayload" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.AccountDTO" } }, "400": { "description": "Invalid ID/data" }, "404": { "description": "Account not found" }, "409": { "description": "Account already exists" }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/auth/account": { "patch": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Update account information", "parameters": [ { "description": "Account data", "name": "payload", "in": "body", "schema": { "$ref": "#/definitions/api_v1.updateAccountPayload" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.Account" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/auth/login": { "post": { "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Login to an account using username and password", "parameters": [ { "description": "Login data", "name": "payload", "in": "body", "schema": { "$ref": "#/definitions/api_v1.loginRequestPayload" } } ], "responses": { "200": { "description": "Login successful", "schema": { "$ref": "#/definitions/api_v1.loginResponseMessage" } }, "400": { "description": "Invalid login data" } } } }, "/api/v1/auth/logout": { "post": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Logout from the current session", "responses": { "200": { "description": "Logout successful" }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/auth/me": { "get": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Get information for the current logged in user", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.Account" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/auth/refresh": { "post": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Refresh a token for an account", "responses": { "200": { "description": "Refresh successful", "schema": { "$ref": "#/definitions/api_v1.loginResponseMessage" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/bookmarks/bulk/tags": { "put": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Bulk update tags for multiple bookmarks.", "parameters": [ { "description": "Bulk Update Bookmark Tags Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.bulkUpdateBookmarkTagsPayload" } } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.BookmarkDTO" } } }, "400": { "description": "Invalid request payload" }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "No bookmarks found" } } } }, "/api/v1/bookmarks/cache": { "put": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Update Cache and Ebook on server.", "parameters": [ { "description": "Update Cache Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.updateCachePayload" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.BookmarkDTO" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/bookmarks/id/readable": { "get": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Get readable version of bookmark.", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/api_v1.readableResponseMessage" } }, "403": { "description": "Token not provided/invalid" } } } }, "/api/v1/bookmarks/{id}/tags": { "get": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Get tags for a bookmark.", "parameters": [ { "type": "integer", "description": "Bookmark ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.TagDTO" } } }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "Bookmark not found" } } }, "post": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Add a tag to a bookmark.", "parameters": [ { "type": "integer", "description": "Bookmark ID", "name": "id", "in": "path", "required": true }, { "description": "Add Tag Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.bookmarkTagPayload" } } ], "responses": { "200": { "description": "OK" }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "Bookmark or tag not found" } } }, "delete": { "produces": [ "application/json" ], "tags": [ "Auth" ], "summary": "Remove a tag from a bookmark.", "parameters": [ { "type": "integer", "description": "Bookmark ID", "name": "id", "in": "path", "required": true }, { "description": "Remove Tag Payload", "name": "payload", "in": "body", "required": true, "schema": { "$ref": "#/definitions/api_v1.bookmarkTagPayload" } } ], "responses": { "200": { "description": "OK" }, "403": { "description": "Token not provided/invalid" }, "404": { "description": "Bookmark not found" } } } }, "/api/v1/system/info": { "get": { "description": "Get general system information like Shiori version, database, and OS", "produces": [ "application/json" ], "tags": [ "System" ], "summary": "Get general system information", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/api_v1.infoResponse" } }, "403": { "description": "Only owners can access this endpoint" } } } }, "/api/v1/tags": { "get": { "description": "List all tags", "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "List tags", "parameters": [ { "type": "boolean", "description": "Include bookmark count for each tag", "name": "with_bookmark_count", "in": "query" }, { "type": "integer", "description": "Filter tags by bookmark ID", "name": "bookmark_id", "in": "query" }, { "type": "string", "description": "Search tags by name", "name": "search", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/model.TagDTO" } } }, "403": { "description": "Authentication required" }, "500": { "description": "Internal server error" } } }, "post": { "description": "Create a new tag", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "Create tag", "parameters": [ { "description": "Tag data", "name": "tag", "in": "body", "required": true, "schema": { "$ref": "#/definitions/model.TagDTO" } } ], "responses": { "201": { "description": "Created", "schema": { "$ref": "#/definitions/model.TagDTO" } }, "400": { "description": "Invalid request" }, "403": { "description": "Authentication required" }, "500": { "description": "Internal server error" } } } }, "/api/v1/tags/{id}": { "get": { "description": "Get a tag by ID", "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "Get tag", "parameters": [ { "type": "integer", "description": "Tag ID", "name": "id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.TagDTO" } }, "403": { "description": "Authentication required" }, "404": { "description": "Tag not found" }, "500": { "description": "Internal server error" } } }, "put": { "description": "Update an existing tag", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Tags" ], "summary": "Update tag", "parameters": [ { "type": "integer", "description": "Tag ID", "name": "id", "in": "path", "required": true }, { "description": "Tag data", "name": "tag", "in": "body", "required": true, "schema": { "$ref": "#/definitions/model.TagDTO" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/model.TagDTO" } }, "400": { "description": "Invalid request" }, "403": { "description": "Authentication required" }, "404": { "description": "Tag not found" }, "500": { "description": "Internal server error" } } }, "delete": { "description": "Delete a tag", "tags": [ "Tags" ], "summary": "Delete tag", "parameters": [ { "type": "integer", "description": "Tag ID", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" }, "403": { "description": "Authentication required" }, "404": { "description": "Tag not found" }, "500": { "description": "Internal server error" } } } } }, "definitions": { "api_v1.bookmarkTagPayload": { "type": "object", "required": [ "tag_id" ], "properties": { "tag_id": { "type": "integer" } } }, "api_v1.bulkUpdateBookmarkTagsPayload": { "type": "object", "required": [ "bookmark_ids", "tag_ids" ], "properties": { "bookmark_ids": { "type": "array", "items": { "type": "integer" } }, "tag_ids": { "type": "array", "items": { "type": "integer" } } } }, "api_v1.infoResponse": { "type": "object", "properties": { "database": { "type": "string" }, "os": { "type": "string" }, "version": { "type": "object", "properties": { "commit": { "type": "string" }, "date": { "type": "string" }, "tag": { "type": "string" } } } } }, "api_v1.loginRequestPayload": { "type": "object", "properties": { "password": { "type": "string" }, "remember_me": { "type": "boolean" }, "username": { "type": "string" } } }, "api_v1.loginResponseMessage": { "type": "object", "properties": { "expires": { "type": "integer" }, "token": { "type": "string" } } }, "api_v1.readableResponseMessage": { "type": "object", "properties": { "content": { "type": "string" }, "html": { "type": "string" } } }, "api_v1.updateAccountPayload": { "type": "object", "properties": { "config": { "$ref": "#/definitions/model.UserConfig" }, "new_password": { "type": "string" }, "old_password": { "type": "string" }, "owner": { "type": "boolean" }, "username": { "type": "string" } } }, "api_v1.updateCachePayload": { "type": "object", "required": [ "ids" ], "properties": { "create_archive": { "type": "boolean" }, "create_ebook": { "type": "boolean" }, "ids": { "type": "array", "items": { "type": "integer" } }, "keep_metadata": { "type": "boolean" }, "skip_exist": { "type": "boolean" } } }, "model.Account": { "type": "object", "properties": { "config": { "$ref": "#/definitions/model.UserConfig" }, "id": { "type": "integer" }, "owner": { "type": "boolean" }, "password": { "type": "string" }, "username": { "type": "string" } } }, "model.AccountDTO": { "type": "object", "properties": { "config": { "$ref": "#/definitions/model.UserConfig" }, "id": { "type": "integer" }, "owner": { "type": "boolean" }, "passowrd": { "description": "Used only to store, not to retrieve", "type": "string" }, "username": { "type": "string" } } }, "model.BookmarkDTO": { "type": "object", "properties": { "author": { "type": "string" }, "create_archive": { "description": "TODO: migrate outside the DTO", "type": "boolean" }, "create_ebook": { "description": "TODO: migrate outside the DTO", "type": "boolean" }, "createdAt": { "type": "string" }, "excerpt": { "type": "string" }, "hasArchive": { "type": "boolean" }, "hasContent": { "type": "boolean" }, "hasEbook": { "type": "boolean" }, "html": { "type": "string" }, "id": { "type": "integer" }, "imageURL": { "type": "string" }, "modifiedAt": { "type": "string" }, "public": { "type": "integer" }, "tags": { "type": "array", "items": { "$ref": "#/definitions/model.TagDTO" } }, "title": { "type": "string" }, "url": { "type": "string" } } }, "model.TagDTO": { "type": "object", "properties": { "bookmark_count": { "description": "Number of bookmarks with this tag", "type": "integer" }, "deleted": { "description": "Marks when a tag is deleted from a bookmark", "type": "boolean" }, "id": { "type": "integer" }, "name": { "type": "string" } } }, "model.UserConfig": { "type": "object", "properties": { "createEbook": { "type": "boolean" }, "hideExcerpt": { "type": "boolean" }, "hideThumbnail": { "type": "boolean" }, "keepMetadata": { "type": "boolean" }, "listMode": { "type": "boolean" }, "makePublic": { "type": "boolean" }, "showId": { "type": "boolean" }, "theme": { "type": "string" }, "useArchive": { "type": "boolean" } } } } } ================================================ FILE: docs/swagger/swagger.yaml ================================================ definitions: api_v1.bookmarkTagPayload: properties: tag_id: type: integer required: - tag_id type: object api_v1.bulkUpdateBookmarkTagsPayload: properties: bookmark_ids: items: type: integer type: array tag_ids: items: type: integer type: array required: - bookmark_ids - tag_ids type: object api_v1.infoResponse: properties: database: type: string os: type: string version: properties: commit: type: string date: type: string tag: type: string type: object type: object api_v1.loginRequestPayload: properties: password: type: string remember_me: type: boolean username: type: string type: object api_v1.loginResponseMessage: properties: expires: type: integer token: type: string type: object api_v1.readableResponseMessage: properties: content: type: string html: type: string type: object api_v1.updateAccountPayload: properties: config: $ref: '#/definitions/model.UserConfig' new_password: type: string old_password: type: string owner: type: boolean username: type: string type: object api_v1.updateCachePayload: properties: create_archive: type: boolean create_ebook: type: boolean ids: items: type: integer type: array keep_metadata: type: boolean skip_exist: type: boolean required: - ids type: object model.Account: properties: config: $ref: '#/definitions/model.UserConfig' id: type: integer owner: type: boolean password: type: string username: type: string type: object model.AccountDTO: properties: config: $ref: '#/definitions/model.UserConfig' id: type: integer owner: type: boolean passowrd: description: Used only to store, not to retrieve type: string username: type: string type: object model.BookmarkDTO: properties: author: type: string create_archive: description: 'TODO: migrate outside the DTO' type: boolean create_ebook: description: 'TODO: migrate outside the DTO' type: boolean createdAt: type: string excerpt: type: string hasArchive: type: boolean hasContent: type: boolean hasEbook: type: boolean html: type: string id: type: integer imageURL: type: string modifiedAt: type: string public: type: integer tags: items: $ref: '#/definitions/model.TagDTO' type: array title: type: string url: type: string type: object model.TagDTO: properties: bookmark_count: description: Number of bookmarks with this tag type: integer deleted: description: Marks when a tag is deleted from a bookmark type: boolean id: type: integer name: type: string type: object model.UserConfig: properties: createEbook: type: boolean hideExcerpt: type: boolean hideThumbnail: type: boolean keepMetadata: type: boolean listMode: type: boolean makePublic: type: boolean showId: type: boolean theme: type: string useArchive: type: boolean type: object info: contact: {} paths: /api/v1/accounts: get: description: List accounts produces: - application/json responses: "200": description: OK schema: items: $ref: '#/definitions/model.AccountDTO' type: array "500": description: Internal Server Error schema: type: string summary: List accounts tags: - accounts post: consumes: - application/json produces: - application/json responses: "201": description: Created schema: $ref: '#/definitions/model.AccountDTO' "400": description: Bad Request "409": description: Account already exists "500": description: Internal Server Error summary: Create an account tags: - accounts /api/v1/accounts/{id}: delete: parameters: - description: Account ID in: path name: id required: true type: integer produces: - application/json responses: "204": description: No content "400": description: Invalid ID "404": description: Account not found "500": description: Internal Server Error summary: Delete an account tags: - accounts patch: consumes: - application/json parameters: - description: Account ID in: path name: id required: true type: integer - description: Account data in: body name: account required: true schema: $ref: '#/definitions/api_v1.updateAccountPayload' produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/model.AccountDTO' "400": description: Invalid ID/data "404": description: Account not found "409": description: Account already exists "500": description: Internal Server Error summary: Update an account tags: - accounts /api/v1/auth/account: patch: parameters: - description: Account data in: body name: payload schema: $ref: '#/definitions/api_v1.updateAccountPayload' produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/model.Account' "403": description: Token not provided/invalid summary: Update account information tags: - Auth /api/v1/auth/login: post: consumes: - application/json parameters: - description: Login data in: body name: payload schema: $ref: '#/definitions/api_v1.loginRequestPayload' produces: - application/json responses: "200": description: Login successful schema: $ref: '#/definitions/api_v1.loginResponseMessage' "400": description: Invalid login data summary: Login to an account using username and password tags: - Auth /api/v1/auth/logout: post: produces: - application/json responses: "200": description: Logout successful "403": description: Token not provided/invalid summary: Logout from the current session tags: - Auth /api/v1/auth/me: get: produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/model.Account' "403": description: Token not provided/invalid summary: Get information for the current logged in user tags: - Auth /api/v1/auth/refresh: post: produces: - application/json responses: "200": description: Refresh successful schema: $ref: '#/definitions/api_v1.loginResponseMessage' "403": description: Token not provided/invalid summary: Refresh a token for an account tags: - Auth /api/v1/bookmarks/{id}/tags: delete: parameters: - description: Bookmark ID in: path name: id required: true type: integer - description: Remove Tag Payload in: body name: payload required: true schema: $ref: '#/definitions/api_v1.bookmarkTagPayload' produces: - application/json responses: "200": description: OK "403": description: Token not provided/invalid "404": description: Bookmark not found summary: Remove a tag from a bookmark. tags: - Auth get: parameters: - description: Bookmark ID in: path name: id required: true type: integer produces: - application/json responses: "200": description: OK schema: items: $ref: '#/definitions/model.TagDTO' type: array "403": description: Token not provided/invalid "404": description: Bookmark not found summary: Get tags for a bookmark. tags: - Auth post: parameters: - description: Bookmark ID in: path name: id required: true type: integer - description: Add Tag Payload in: body name: payload required: true schema: $ref: '#/definitions/api_v1.bookmarkTagPayload' produces: - application/json responses: "200": description: OK "403": description: Token not provided/invalid "404": description: Bookmark or tag not found summary: Add a tag to a bookmark. tags: - Auth /api/v1/bookmarks/bulk/tags: put: parameters: - description: Bulk Update Bookmark Tags Payload in: body name: payload required: true schema: $ref: '#/definitions/api_v1.bulkUpdateBookmarkTagsPayload' produces: - application/json responses: "200": description: OK schema: items: $ref: '#/definitions/model.BookmarkDTO' type: array "400": description: Invalid request payload "403": description: Token not provided/invalid "404": description: No bookmarks found summary: Bulk update tags for multiple bookmarks. tags: - Auth /api/v1/bookmarks/cache: put: parameters: - description: Update Cache Payload in: body name: payload required: true schema: $ref: '#/definitions/api_v1.updateCachePayload' produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/model.BookmarkDTO' "403": description: Token not provided/invalid summary: Update Cache and Ebook on server. tags: - Auth /api/v1/bookmarks/id/readable: get: produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/api_v1.readableResponseMessage' "403": description: Token not provided/invalid summary: Get readable version of bookmark. tags: - Auth /api/v1/system/info: get: description: Get general system information like Shiori version, database, and OS produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/api_v1.infoResponse' "403": description: Only owners can access this endpoint summary: Get general system information tags: - System /api/v1/tags: get: description: List all tags parameters: - description: Include bookmark count for each tag in: query name: with_bookmark_count type: boolean - description: Filter tags by bookmark ID in: query name: bookmark_id type: integer - description: Search tags by name in: query name: search type: string produces: - application/json responses: "200": description: OK schema: items: $ref: '#/definitions/model.TagDTO' type: array "403": description: Authentication required "500": description: Internal server error summary: List tags tags: - Tags post: consumes: - application/json description: Create a new tag parameters: - description: Tag data in: body name: tag required: true schema: $ref: '#/definitions/model.TagDTO' produces: - application/json responses: "201": description: Created schema: $ref: '#/definitions/model.TagDTO' "400": description: Invalid request "403": description: Authentication required "500": description: Internal server error summary: Create tag tags: - Tags /api/v1/tags/{id}: delete: description: Delete a tag parameters: - description: Tag ID in: path name: id required: true type: integer responses: "204": description: No Content "403": description: Authentication required "404": description: Tag not found "500": description: Internal server error summary: Delete tag tags: - Tags get: description: Get a tag by ID parameters: - description: Tag ID in: path name: id required: true type: integer produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/model.TagDTO' "403": description: Authentication required "404": description: Tag not found "500": description: Internal server error summary: Get tag tags: - Tags put: consumes: - application/json description: Update an existing tag parameters: - description: Tag ID in: path name: id required: true type: integer - description: Tag data in: body name: tag required: true schema: $ref: '#/definitions/model.TagDTO' produces: - application/json responses: "200": description: OK schema: $ref: '#/definitions/model.TagDTO' "400": description: Invalid request "403": description: Authentication required "404": description: Tag not found "500": description: Internal server error summary: Update tag tags: - Tags swagger: "2.0" ================================================ FILE: e2e/e2eutil/containers.go ================================================ package e2eutil import ( "context" "io" "os" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) const ( shioriPort = "8080/tcp" shioriExpectedStartupMessage = "started http server" shioriExpectedStartupSeconds = 5 ) var testContainersProviderType testcontainers.ProviderType = testcontainers.ProviderDocker func init() { // If TESTCONTAINERS_PROVIDER is set to podman, use podman // NOTE: This is EXPERIMENTAL since there are some issues running the e2e tests using podman, // testcontainers implies that it supports podman but I couldn't make it run in my tests. // YMMV. // More info: https://golang.testcontainers.org/system_requirements/using_podman/ if os.Getenv("TESTCONTAINERS_PROVIDER") == "podman" { testContainersProviderType = testcontainers.ProviderPodman } } func newBuildArg(value string) *string { return &value } type ShioriContainer struct { t *testing.T Container testcontainers.Container } func (sc *ShioriContainer) GetPort() string { mappedPort, err := sc.Container.MappedPort(context.Background(), shioriPort) require.NoError(sc.t, err) return mappedPort.Port() } // NewShioriContainer creates a new ShioriContainer which is a wrapper around a testcontainers.Container // with some helpers for using while running Shiori E2E tests. func NewShioriContainer(t *testing.T, tag string) ShioriContainer { containerDefinition := testcontainers.GenericContainerRequest{ ProviderType: testContainersProviderType, ContainerRequest: testcontainers.ContainerRequest{ Cmd: []string{"server", "--log-level", "debug"}, ExposedPorts: []string{shioriPort}, WaitingFor: wait.ForLog(shioriExpectedStartupMessage).WithStartupTimeout(shioriExpectedStartupSeconds * time.Second), }, Started: true, } if tag != "" { containerDefinition.Image = "ghcr.io/go-shiori/shiori:" + tag } else { containerDefinition.FromDockerfile = testcontainers.FromDockerfile{ PrintBuildLog: false, Context: "../..", Dockerfile: "Dockerfile.e2e", KeepImage: true, BuildArgs: map[string]*string{ "ALPINE_VERSION": newBuildArg(os.Getenv("CONTAINER_ALPINE_VERSION")), "GOLANG_VERSION": newBuildArg(os.Getenv("GOLANG_VERSION")), }, } } container, err := testcontainers.GenericContainer(context.Background(), containerDefinition) require.NoError(t, err) t.Cleanup(func() { // Print container logs on test failure for debugging if t.Failed() { printContainerLogs(t, container, "Container logs on test failure:") } // Terminate container with error handling ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := container.Terminate(ctx); err != nil { // Log the error but don't fail the test cleanup t.Logf("Warning: Failed to terminate container: %v", err) } }) return ShioriContainer{ t: t, Container: container, } } // printContainerLogs prints the container logs for debugging purposes func printContainerLogs(t *testing.T, container testcontainers.Container, prefix string) { if container == nil { t.Logf("%s Container is nil, cannot retrieve logs", prefix) return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() logs, err := container.Logs(ctx) if err != nil { t.Logf("%s Failed to get container logs: %v", prefix, err) return } defer logs.Close() logBytes, err := io.ReadAll(logs) if err != nil { t.Logf("%s Failed to read container logs: %v", prefix, err) return } if len(logBytes) == 0 { t.Logf("%s No container logs available", prefix) return } // Split logs into lines and add prefix logLines := strings.Split(strings.TrimSpace(string(logBytes)), "\n") t.Logf("%s", prefix) for i, line := range logLines { if line != "" { t.Logf(" [%d] %s", i+1, line) } } } ================================================ FILE: e2e/playwright/accounts_test.go ================================================ package playwright import ( "fmt" "testing" "time" "github.com/go-shiori/shiori/e2e/e2eutil" "github.com/playwright-community/playwright-go" "github.com/stretchr/testify/require" ) func TestE2EAccounts(t *testing.T) { // Start a new Shiori container container := e2eutil.NewShioriContainer(t, "") baseURL := fmt.Sprintf("http://localhost:%s", container.GetPort()) mainTestHelper, err := NewTestHelper(t, "main") require.NoError(t, err) defer mainTestHelper.Close() t.Run("001 login as admin", func(t *testing.T) { // Navigate to the login page _, err = mainTestHelper.page.Goto(baseURL) mainTestHelper.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := mainTestHelper.page.Locator("#username") passwordLocator := mainTestHelper.page.Locator("#password") buttonLocator := mainTestHelper.page.Locator(".button") // Wait for and fill the login form mainTestHelper.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") mainTestHelper.Require().NoError(t, usernameLocator.Fill("shiori"), "Fill username field") mainTestHelper.Require().NoError(t, passwordLocator.Fill("gopher"), "Fill password field") // Click login and wait for success mainTestHelper.Require().NoError(t, buttonLocator.Click(), "Click login button") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator("#bookmarks-grid").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for bookmarks section to show up") }) t.Run("002 create new admin account", func(t *testing.T) { // Navigate to settings page mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`[title="Settings"]`).Click(), "Click on settings button") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(".setting-container").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for settings page to show up") // Click on "Add new account" element mainTestHelper.page.Locator(`[title="Add new account"]`).Click() mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, }) // Fill modal mainTestHelper.page.Locator(`[name="username"]`).Fill("admin2") mainTestHelper.page.Locator(`[name="password"]`).Fill("admin2") mainTestHelper.page.Locator(`[name="repeat_password"]`).Fill("admin2") mainTestHelper.page.Locator(`[name="admin"]`).Check() // Click on "Ok" button mainTestHelper.page.Locator(`.custom-dialog-button.main`).Click() // Wait for modal to disappear mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }), "Wait for modal to disappear") // Refresh account list mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`a[title="Refresh accounts"]`).Click(), "Click on refresh accounts button") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(".loading-overlay").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }), "Wait for loading overlay to disappear") // Check if new account is created accountsCount, err := mainTestHelper.page.Locator(".accounts-list li").Count() mainTestHelper.Require().NoError(t, err, "Count accounts in list") mainTestHelper.Require().Equal(t, 2, accountsCount, "Verify 2 accounts present after creating new admin account") }) t.Run("003 create new user account", func(t *testing.T) { // Click on "Add new account" element mainTestHelper.page.Locator(`[title="Add new account"]`).Click() mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }) // Fill modal mainTestHelper.page.Locator(`[name="username"]`).Fill("user1") mainTestHelper.page.Locator(`[name="password"]`).Fill("user1") mainTestHelper.page.Locator(`[name="repeat_password"]`).Fill("user1") // Click on "Ok" button mainTestHelper.page.Locator(`.custom-dialog-button.main`).Click() // Wait for modal to disappear mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }) // Refresh account list mainTestHelper.page.Locator(`a[title="Refresh accounts"]`).Click() mainTestHelper.page.Locator(".loading-overlay").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }) // Check if new account is created accountsCount, err := mainTestHelper.page.Locator(".accounts-list li").Count() mainTestHelper.Require().NoError(t, err, "Failed to count accounts in list") mainTestHelper.Require().Equal(t, 3, accountsCount, "Expected 3 accounts after creating user account") }) t.Run("004 check admin account created successfully", func(t *testing.T) { th, err := NewTestHelper(t, t.Name()) require.NoError(t, err, "Create test helper") defer th.Close() // Navigate to the login page _, err = th.page.Goto(baseURL) th.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := th.page.Locator("#username") passwordLocator := th.page.Locator("#password") buttonLocator := th.page.Locator(".button") // Wait for and fill the login form th.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") th.Require().NoError(t, usernameLocator.Fill("admin2"), "Fill username field") th.Require().NoError(t, passwordLocator.Fill("admin2"), "Fill password field") // Click login and wait for success th.Require().NoError(t, buttonLocator.Click(), "Click login button") th.Require().NoError(t, th.page.Locator("#bookmarks-grid").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for bookmarks section to show up") // Navigate to settings th.Require().NoError(t, th.page.Locator(`[title="Settings"]`).Click(), "Click on settings button") th.Require().NoError(t, th.page.Locator(".setting-container").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for settings page to show up") // Check if can see system info (admin only) visible, err := th.page.Locator(`#setting-system-info`).IsVisible() th.Require().NoError(t, err, "Check visibility of system info section") th.Require().True(t, visible, "Verify system info section visibility for admin user") }) t.Run("005 check user account created successfully", func(t *testing.T) { th, err := NewTestHelper(t, t.Name()) require.NoError(t, err, "Create test helper") defer th.Close() // Navigate to the login page _, err = th.page.Goto(baseURL) th.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := th.page.Locator("#username") passwordLocator := th.page.Locator("#password") buttonLocator := th.page.Locator(".button") // Wait for and fill the login form th.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") th.Require().NoError(t, usernameLocator.Fill("user1"), "Fill username field") th.Require().NoError(t, passwordLocator.Fill("user1"), "Fill password field") // Click login and wait for success th.Require().NoError(t, buttonLocator.Click(), "Click login button") th.Require().NoError(t, th.page.Locator("#bookmarks-grid").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for bookmarks section to show up") // Navigate to settings th.Require().NoError(t, th.page.Locator(`[title="Settings"]`).Click(), "Click on settings button") th.Require().NoError(t, th.page.Locator(".setting-container").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for settings page to show up") // Check if can see system info (admin only) visible, err := th.page.Locator(`#setting-system-info`).IsVisible() th.Require().NoError(t, err, "Check visibility of system info section") th.Require().False(t, visible, "Verify system info section not visible for regular user") // My account settings is visible visible, err = th.page.Locator(`#setting-my-account`).IsVisible() th.Require().NoError(t, err, "Check visibility of account settings") th.Require().True(t, visible, "Verify account settings visibility for user") // Check change password requires current password th.Require().NoError(t, th.page.Locator(`li[shiori-username="user1"] a[title="Change password"]`).Click(), "Click on change password button") th.Require().NoError(t, th.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for change password modal to show up") visible, err = th.page.Locator(`[name="old_password"]`).IsVisible() th.Require().NoError(t, err, "Check visibility of old password field") th.Require().True(t, visible, "Verify old password field visibility when changing password") // Fill modal th.Require().NoError(t, th.page.Locator(`[name="old_password"]`).Fill("user1"), "Fill old password field") th.Require().NoError(t, th.page.Locator(`[name="new_password"]`).Fill("new_user1"), "Fill new password field") th.Require().NoError(t, th.page.Locator(`[name="repeat_password"]`).Fill("new_user1"), "Fill repeat password field") // Click on "Ok" button th.Require().NoError(t, th.page.Locator(`.custom-dialog-button.main`).Click(), "Click on ok button") // Wait for modal to display text: "Password has been changed." dialogContent := th.page.Locator(".custom-dialog-content") th.Require().NoError(t, dialogContent.WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for dialog content to show up") contentText, err := dialogContent.TextContent() th.Require().NoError(t, err, "Get dialog content text") th.Require().Equal(t, "Password has been changed.", contentText, "Verify password change confirmation message") }) t.Run("006 delete user account", func(t *testing.T) { // Click on "Delete" button mainTestHelper.page.Locator(`li[shiori-username="user1"] a[title="Delete account"]`).Click() mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }) // Click on "Ok" button mainTestHelper.page.Locator(`.custom-dialog-button.main`).Click() // Wait for modal to disappear mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }) // Refresh account list mainTestHelper.page.Locator(`a[title="Refresh accounts"]`).Click() mainTestHelper.page.Locator(".loading-overlay").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }) // Check if account is deleted accountsCount, err := mainTestHelper.page.Locator(".accounts-list li").Count() mainTestHelper.Require().NoError(t, err, "Count accounts in list") mainTestHelper.Require().Equal(t, 2, accountsCount, "Verify 2 accounts present after creating admin account") time.Sleep(5 * time.Second) }) t.Run("007 change password for admin account", func(t *testing.T) { // Click on "Change password" button mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`li[shiori-username="admin2"] a[title="Change password"]`).Click(), "Click change password button") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for password dialog to appear") // Fill modal mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`[name="new_password"]`).Fill("admin3"), "Fill new password") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`[name="repeat_password"]`).Fill("admin3"), "Fill repeat password") // Click on "Ok" button mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`.custom-dialog-button.main`).Click(), "Click ok button") // Wait for modal to display text: "Password has been changed." dialogContent := mainTestHelper.page.Locator(".custom-dialog-content") mainTestHelper.Require().NoError(t, dialogContent.WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for dialog content to show up") contentText, err := dialogContent.TextContent() mainTestHelper.Require().NoError(t, err, "Get dialog content text") mainTestHelper.Require().Equal(t, "Password has been changed.", contentText, "Verify password change confirmation message") // Click on "Ok" button mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`.custom-dialog-button.main`).Click(), "Click ok button") // Wait for modal to disappear mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(".custom-dialog").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(2000), }), "Wait for dialog to close") // Refresh account list mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`a[title="Refresh accounts"]`).Click(), "Click refresh accounts") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(".loading-overlay").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateHidden, Timeout: playwright.Float(1000), }), "Wait for refresh to complete") t.Run("0071 login with new password", func(t *testing.T) { th, err := NewTestHelper(t, t.Name()) require.NoError(t, err, "Failed to create test helper") defer th.Close() // Navigate to the login page _, err = th.page.Goto(baseURL) th.Require().NoError(t, err, "Navigate to base URL") // Wait for login page th.Require().NoError(t, th.page.Locator("#username").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for login page") th.Require().NoError(t, th.page.Locator("#username").Fill("admin2"), "Fill username field") th.Require().NoError(t, th.page.Locator("#password").Fill("admin3"), "Fill password field") th.Require().NoError(t, th.page.Locator(".button").Click(), "Click login button") th.Require().NoError(t, th.page.Locator("#bookmarks-grid").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for bookmarks section to show up") }) }) t.Run("008 logout", func(t *testing.T) { // Click on "Logout" button mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`a[title="Logout"]`).Click(), "Click on logout button") // Wait for modal to display text dialogContent := mainTestHelper.page.Locator(".custom-dialog-content") mainTestHelper.Require().NoError(t, dialogContent.WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for dialog content to show up") contentText, err := dialogContent.TextContent() mainTestHelper.Require().NoError(t, err, "Get dialog content text") mainTestHelper.Require().Equal(t, "Are you sure you want to log out ?", contentText, "Verify logout confirmation message") // Click on "Yes" button mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator(`.custom-dialog-button.main`).Click(), "Click Yes button") // Wait for login page err = mainTestHelper.page.Locator("#login-scene").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }) mainTestHelper.Require().NoError(t, err, "Wait for login page") }) } ================================================ FILE: e2e/playwright/auth_test.go ================================================ package playwright import ( "fmt" "testing" "github.com/go-shiori/shiori/e2e/e2eutil" "github.com/playwright-community/playwright-go" "github.com/stretchr/testify/require" ) func TestAuth(t *testing.T) { // Start a new Shiori container container := e2eutil.NewShioriContainer(t, "") baseURL := fmt.Sprintf("http://localhost:%s", container.GetPort()) mainTestHelper, err := NewTestHelper(t, "main") require.NoError(t, err) defer mainTestHelper.Close() t.Run("successful login with default credentials", func(t *testing.T) { // Navigate to the login page _, err = mainTestHelper.page.Goto(baseURL) mainTestHelper.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := mainTestHelper.page.Locator("#username") passwordLocator := mainTestHelper.page.Locator("#password") buttonLocator := mainTestHelper.page.Locator(".button") // Wait for and fill the login form mainTestHelper.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") mainTestHelper.Require().NoError(t, usernameLocator.Fill("shiori"), "Fill username field") mainTestHelper.Require().NoError(t, passwordLocator.Fill("gopher"), "Fill password field") // Click login and wait for success mainTestHelper.Require().NoError(t, buttonLocator.Click(), "Click login button") mainTestHelper.Require().NoError(t, mainTestHelper.page.Locator("#bookmarks-grid").WaitFor(playwright.LocatorWaitForOptions{ State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(1000), }), "Wait for bookmarks section to show up") }) t.Run("failed login with wrong username", func(t *testing.T) { th, err := NewTestHelper(t, t.Name()) require.NoError(t, err) defer th.Close() // Navigate to the login page _, err = th.page.Goto(baseURL) th.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := th.page.Locator("#username") passwordLocator := th.page.Locator("#password") buttonLocator := th.page.Locator(".button") errorLocator := th.page.Locator(".error-message") // Wait for and fill the login form th.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") th.Require().NoError(t, usernameLocator.Fill("wrong_user"), "Fill username field") th.Require().NoError(t, passwordLocator.Fill("gopher"), "Fill password field") // Click login and verify error th.Require().NoError(t, buttonLocator.Click(), "Click login button") errorText, err := errorLocator.TextContent() th.Require().NoError(t, err, "Get error message text") th.Require().Contains(t, errorText, "username or password do not match") }) t.Run("failed login with wrong password", func(t *testing.T) { th, err := NewTestHelper(t, t.Name()) require.NoError(t, err) defer th.Close() // Navigate to the login page _, err = th.page.Goto(baseURL) th.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := th.page.Locator("#username") passwordLocator := th.page.Locator("#password") buttonLocator := th.page.Locator(".button") errorLocator := th.page.Locator(".error-message") // Wait for and fill the login form th.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") th.Require().NoError(t, usernameLocator.Fill("shiori"), "Fill username field") th.Require().NoError(t, passwordLocator.Fill("wrong_password"), "Fill password field") // Click login and verify error th.Require().NoError(t, buttonLocator.Click(), "Click login button") errorText, err := errorLocator.TextContent() th.Require().NoError(t, err, "Get error message text") th.Require().Contains(t, errorText, "username or password do not match") }) t.Run("empty username validation", func(t *testing.T) { th, err := NewTestHelper(t, t.Name()) require.NoError(t, err) defer th.Close() // Navigate to the login page _, err = th.page.Goto(baseURL) th.Require().NoError(t, err, "Navigate to base URL") // Get locators for form elements usernameLocator := th.page.Locator("#username") passwordLocator := th.page.Locator("#password") buttonLocator := th.page.Locator(".button") errorLocator := th.page.Locator(".error-message") // Wait for form and fill only password th.Require().NoError(t, usernameLocator.WaitFor(), "Wait for username field") th.Require().NoError(t, passwordLocator.Fill("gopher"), "Fill password field") // Click login and verify error th.Require().NoError(t, buttonLocator.Click(), "Click login button") errorText, err := errorLocator.TextContent() th.Require().NoError(t, err, "Get error message text") th.Require().Contains(t, errorText, "Username must not empty") }) } ================================================ FILE: e2e/playwright/playwright_test.go ================================================ package playwright import "github.com/playwright-community/playwright-go" func init() { playwright.Install() } ================================================ FILE: e2e/playwright/reporter.go ================================================ package playwright import ( "encoding/base64" "fmt" "html/template" "io" "os" "path/filepath" "strings" "time" ) type AssertionResult struct { Message string Status string Error string Screenshot string // Base64 screenshot, only for failures } type TestResult struct { Name string Status string Timestamp time.Time Assertions []AssertionResult } type TestReporter struct { Results map[string]*TestResult } var globalReporter = &TestReporter{ Results: make(map[string]*TestResult), } func GetReporter() *TestReporter { return globalReporter } func (r *TestReporter) AddResult(testName string, passed bool, screenshotPath string, message, errorMessage string) { status := "Passed" if !passed { status = "Failed" } var screenshot string if !passed && screenshotPath != "" { imageFile, err := os.Open(screenshotPath) if err == nil { defer imageFile.Close() if data, err := io.ReadAll(imageFile); err == nil { screenshot = "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) } else { fmt.Printf("Failed to read screenshot %s: %v\n", screenshotPath, err) } } else { fmt.Printf("Failed to open screenshot %s: %v\n", screenshotPath, err) } } // Get or create test result testResult, exists := r.Results[testName] if !exists { testResult = &TestResult{ Name: testName, Status: "Passed", Timestamp: time.Now(), Assertions: make([]AssertionResult, 0), } r.Results[testName] = testResult } // Add assertion result testResult.Assertions = append(testResult.Assertions, AssertionResult{ Message: message, Error: errorMessage, Status: status, Screenshot: screenshot, }) // Update test status if any assertion failed if !passed { testResult.Status = "Failed" } } func (r *TestReporter) GenerateHTML() error { const tmpl = ` Test Results

Test Results

{{range .Results}}

{{.Name}}

Status: {{.Status}}

{{if eq .Status "Failed"}}
    {{range .Assertions}}
  • {{if eq .Status "Passed"}}✓ {{end}}{{.Message}}

    {{.Error}}

    {{if .Screenshot}}

    Failure screenshot

    {{end}}
  • {{end}}
{{end}}
{{end}} ` t := template.New("report") t = t.Funcs(template.FuncMap{ "toLowerCase": strings.ToLower, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "safeURL": func(s string) template.URL { return template.URL(s) }, }) t, err := t.Parse(tmpl) if err != nil { return fmt.Errorf("failed to parse template: %v", err) } if err := os.MkdirAll("test-results", 0755); err != nil { return fmt.Errorf("failed to create results directory: %v", err) } basePath := os.Getenv("CONTEXT_PATH") if basePath == "" { basePath = "." } f, err := os.Create(filepath.Join(basePath, "e2e-report.html")) if err != nil { return fmt.Errorf("failed to create report file: %v", err) } defer f.Close() return t.Execute(f, r) } ================================================ FILE: e2e/playwright/testhelper.go ================================================ package playwright import ( "fmt" "os" "path" "path/filepath" "strings" "testing" "time" "github.com/playwright-community/playwright-go" "github.com/stretchr/testify/require" ) // TestHelper wraps common test functionality type TestHelper struct { name string page playwright.Page browser playwright.Browser context playwright.BrowserContext t require.TestingT } // NewTestHelper creates a new test helper instance func NewTestHelper(t require.TestingT, name string) (*TestHelper, error) { pw, err := playwright.Run() if err != nil { return nil, fmt.Errorf("could not start playwright: %v", err) } browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ Headless: playwright.Bool(true), }) if err != nil { return nil, fmt.Errorf("could not launch browser: %v", err) } context, err := browser.NewContext() if err != nil { return nil, fmt.Errorf("could not create context: %v", err) } page, err := context.NewPage() if err != nil { return nil, fmt.Errorf("could not create page: %v", err) } return &TestHelper{ name: name, page: page, browser: browser, context: context, t: t, }, nil } // Require returns a custom assertion object that takes screenshots on failure func (th *TestHelper) Require() *PlaywrightRequire { return &PlaywrightRequire{ Assertions: require.New(th.t), helper: th, } } func (th *TestHelper) HandleError(t *testing.T, screenshotPath string, msg, err string) { GetReporter().AddResult(t.Name(), false, screenshotPath, msg, err) t.Error(msg) // Also log the error to the test output } func (th *TestHelper) HandleSuccess(t *testing.T, message string) { GetReporter().AddResult(t.Name(), true, "", message, "") } // PlaywrightRequire wraps require.Assertions to add screenshot capability type PlaywrightRequire struct { *require.Assertions helper *TestHelper } // captureScreenshot saves a screenshot to the screenshots directory func (th *TestHelper) captureScreenshot(testName string) string { timestamp := time.Now().Format("20060102-150405") tmpDir, err := os.MkdirTemp("", "playwright-screenshots") if err != nil { th.t.Errorf("Failed to create temporary directory: %v\n", err) return "" } filePath := filepath.Join(tmpDir, fmt.Sprintf("%s-%s.png", testName, timestamp)) // Get the full path without the filename from `filename` and create the directories if err := os.MkdirAll(path.Dir(filePath), 0755); err != nil { th.t.Errorf("Failed to create screenshots directory: %v\n", err) return "" } // Create screenshots directory if it doesn't exist if err := os.MkdirAll("screenshots", 0755); err != nil { th.t.Errorf("Failed to create screenshots directory: %v\n", err) return "" } // Take screenshot if _, err := th.page.Screenshot(playwright.PageScreenshotOptions{ Path: playwright.String(filePath), FullPage: playwright.Bool(true), }); err != nil { th.t.Errorf("Failed to capture screenshot: %v\n", err) return "" } fmt.Printf("Screenshot saved: %s\n", filePath) return filePath } func (pr *PlaywrightRequire) Assert(t *testing.T, assertFn func() error, msgAndArgs ...interface{}) { err := assertFn() var msg string if len(msgAndArgs) > 0 { if format, ok := msgAndArgs[0].(string); ok && len(msgAndArgs) > 1 { msg = fmt.Sprintf(format, msgAndArgs[1:]...) } else { msg = fmt.Sprint(msgAndArgs...) } } if err == nil { pr.helper.HandleSuccess(t, msg) } else { screenshotPath := pr.helper.captureScreenshot(t.Name()) pr.helper.HandleError(t, screenshotPath, msg, err.Error()) } } // True asserts that the specified value is true and takes a screenshot on failure func (pr *PlaywrightRequire) True(t *testing.T, value bool, msgAndArgs ...interface{}) { pr.Assert(t, func() error { var err error if !value { err = fmt.Errorf("expected value to be true but got false in test '%s'", t.Name()) } return err }, msgAndArgs...) pr.Assertions.True(value, msgAndArgs...) } // False asserts that the specified value is false and takes a screenshot on failure func (pr *PlaywrightRequire) False(t *testing.T, value bool, msgAndArgs ...interface{}) { pr.Assert(t, func() error { var err error if value { err = fmt.Errorf("expected value to be false but got true in test '%s'", t.Name()) } return err }, msgAndArgs...) pr.Assertions.False(value, msgAndArgs...) } // Equal asserts that two objects are equal and takes a screenshot on failure func (pr *PlaywrightRequire) Equal(t *testing.T, expected, actual interface{}, msgAndArgs ...interface{}) { pr.Assert(t, func() error { var err error if expected != actual { err = fmt.Errorf("expected values to be equal in test '%s':\nexpected: %v\nactual: %v", t.Name(), expected, actual) } return err }, msgAndArgs...) pr.Assertions.Equal(expected, actual, msgAndArgs...) } // NoError asserts that a function returned no error and takes a screenshot on failure func (pr *PlaywrightRequire) NoError(t *testing.T, err error, msgAndArgs ...interface{}) { pr.Assert(t, func() error { var assertErr error if err != nil { assertErr = fmt.Errorf("expected no error but got error in test '%s': %v", t.Name(), err) } return assertErr }, msgAndArgs...) pr.Assertions.NoError(err, msgAndArgs...) } // Error asserts that a function returned an error and takes a screenshot on failure func (pr *PlaywrightRequire) Error(t *testing.T, err error, msgAndArgs ...interface{}) { pr.Assert(t, func() error { var assertErr error if err == nil { assertErr = fmt.Errorf("expected error but got none in test '%s'", t.Name()) } return assertErr }, msgAndArgs...) pr.Assertions.Error(err, msgAndArgs...) } func (pr *PlaywrightRequire) Contains(t *testing.T, text, expected string, msgAndArgs ...interface{}) { pr.Assert(t, func() error { if !strings.Contains(text, expected) { return fmt.Errorf("expected text to contain '%s' but got '%s'", expected, text) } return nil }, msgAndArgs...) } // Close cleans up resources and generates the report func (th *TestHelper) Close() { if err := GetReporter().GenerateHTML(); err != nil { fmt.Printf("Failed to generate HTML report: %v\n", err) } if th.page != nil { th.page.Close() } if th.context != nil { th.context.Close() } if th.browser != nil { th.browser.Close() } } ================================================ FILE: e2e/server/auth_test.go ================================================ package e2e import ( "bytes" "net/http" "testing" "github.com/go-shiori/shiori/e2e/e2eutil" "github.com/stretchr/testify/require" ) func TestAuthLogin(t *testing.T) { container := e2eutil.NewShioriContainer(t, "") t.Run("login ok", func(t *testing.T) { req, err := http.Post( "http://localhost:"+container.GetPort()+"/api/v1/auth/login", "application/json", bytes.NewReader([]byte(`{"username": "shiori", "password": "gopher"}`)), ) require.NoError(t, err) require.Equal(t, http.StatusOK, req.StatusCode) }) t.Run("wrong credentials", func(t *testing.T) { req, err := http.Post( "http://localhost:"+container.GetPort()+"/api/v1/auth/login", "application/json", bytes.NewReader([]byte(`{"username": "wrong", "password": "wrong"}`)), ) require.NoError(t, err) require.Equal(t, http.StatusBadRequest, req.StatusCode) }) } ================================================ FILE: e2e/server/basic_test.go ================================================ package e2e import ( "net/http" "testing" "github.com/go-shiori/shiori/e2e/e2eutil" "github.com/stretchr/testify/require" ) func TestServerBasic(t *testing.T) { container := e2eutil.NewShioriContainer(t, "") t.Run("liveness endpoint", func(t *testing.T) { req, err := http.Get("http://localhost:" + container.GetPort() + "/system/liveness") require.NoError(t, err) require.Equal(t, http.StatusOK, req.StatusCode) }) } ================================================ FILE: go.mod ================================================ module github.com/go-shiori/shiori // +heroku goVersion go1.25.1 go 1.25.1 require ( git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20250706113457-213d0e8755e5 github.com/PuerkitoBio/goquery v1.10.3 github.com/blang/semver v3.5.1+incompatible github.com/disintegration/imaging v1.6.2 github.com/fatih/color v1.18.0 github.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94 github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612 github.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d github.com/go-sql-driver/mysql v1.9.3 github.com/gofrs/uuid/v5 v5.3.2 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/huandu/go-sqlbuilder v1.37.0 github.com/jmoiron/sqlx v1.4.0 github.com/julienschmidt/httprouter v1.3.0 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.32 github.com/muesli/go-app-paths v0.2.2 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/playwright-community/playwright-go v0.5200.0 github.com/sethvargo/go-envconfig v1.3.0 github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger/v2 v2.0.2 github.com/swaggo/swag v1.16.4 github.com/testcontainers/testcontainers-go v0.37.0 golang.org/x/crypto v0.42.0 golang.org/x/image v0.31.0 golang.org/x/net v0.44.0 golang.org/x/term v0.35.0 modernc.org/sqlite v1.39.0 ) require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.2.2+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect github.com/go-openapi/jsonreference v0.21.1 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.25.1 // indirect github.com/go-openapi/swag/cmdutils v0.25.1 // indirect github.com/go-openapi/swag/conv v0.25.1 // indirect github.com/go-openapi/swag/fileutils v0.25.1 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-openapi/swag/jsonutils v0.25.1 // indirect github.com/go-openapi/swag/loading v0.25.1 // indirect github.com/go-openapi/swag/mangling v0.25.1 // indirect github.com/go-openapi/swag/netutils v0.25.1 // indirect github.com/go-openapi/swag/stringutils v0.25.1 // indirect github.com/go-openapi/swag/typeutils v0.25.1 // indirect github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/go-clone v1.7.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/shirou/gopsutil/v4 v4.25.1 // indirect github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect github.com/swaggo/files/v2 v2.0.2 // indirect github.com/tdewolff/parse v2.3.4+incompatible // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/vincent-petithory/dataurl v1.0.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect go.opentelemetry.io/otel/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.36.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect golang.org/x/tools v0.37.0 // indirect golang.org/x/tools/godoc v0.1.0-deprecated // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/grpc v1.73.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.9 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20250706113457-213d0e8755e5 h1:1p/YpbpaXZFUg/519qWxpWLvCX4uMuWaisP8DKCHPyc= git.sr.ht/~emersion/go-sqlite3-fts5 v0.0.0-20250706113457-213d0e8755e5/go.mod h1:W+na+JMhhelFn525wvV3enh0zvvhtZF8kndnRanLLq0= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-shiori/dom v0.0.0-20190930082056-9d974a4f8b25/go.mod h1:360KoNl36ftFYhjLHuEty78kWUGw8i1opEicvIDLfRk= github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= github.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94 h1:fDswMm2PrEwdnbVvz4QI/Hjm5eZ8HROzuCRYZd/Wung= github.com/go-shiori/go-epub v1.2.2-0.20241010194245-bd691046db94/go.mod h1:3q72SS/xhacgTr51ykGWJGSh3/l2lpB10CcLW+gO3Rw= github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612 h1:BYLNYdZaepitbZreRIa9xeCQZocWmy/wj4cGIH0qyw0= github.com/go-shiori/go-readability v0.0.0-20250217085726-9f5bf5ca7612/go.mod h1:wgqthQa8SAYs0yyljVeCOQlZ027VW5CmLsbi9jWC08c= github.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d h1:+SEf4hYDaAt2eyq8Xu3YyWCpnMsK8sZfbYsDRFCUgBM= github.com/go-shiori/warc v0.0.0-20200621032813-359908319d1d/go.mod h1:uaK5DAxFig7atOzy+aqLzhs6qJacMDfs8NxHV5+shzc= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/huandu/go-sqlbuilder v1.37.0 h1:hXgk2rTnlgFgKsmFpizhe6g/oz1wxef4qk3ixFhK6a0= github.com/huandu/go-sqlbuilder v1.37.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/playwright-community/playwright-go v0.5200.0 h1:z/5LGuX2tBrg3ug1HupMXLjIG93f1d2MWdDsNhkMQ9c= github.com/playwright-community/playwright-go v0.5200.0/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs= github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg= github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38= github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ= github.com/tdewolff/test v1.0.0 h1:jOwzqCXr5ePXEPGJaq2ivoR6HOCi+D5TPfpoyg8yvmU= github.com/tdewolff/test v1.0.0/go.mod h1:DiQUlutnqlEvdvhSn2LPGy4TFwRauAaYDsL+683RNX4= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190926025831-c00fd9afed17/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk= golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk= modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0= modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ================================================ FILE: internal/assets.go ================================================ package internal import "embed" //go:embed view var Assets embed.FS ================================================ FILE: internal/cmd/add.go ================================================ package cmd import ( "fmt" "os" "strings" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func addCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add url", Short: "Bookmark the specified URL", Args: cobra.ExactArgs(1), Run: addHandler, } cmd.Flags().StringP("title", "i", "", "Custom title for this bookmark") cmd.Flags().StringP("excerpt", "e", "", "Custom excerpt for this bookmark") cmd.Flags().StringSliceP("tags", "t", []string{}, "Comma-separated tags for this bookmark") cmd.Flags().BoolP("offline", "o", false, "Save bookmark without fetching data from internet") cmd.Flags().BoolP("no-archival", "a", false, "Save bookmark without creating offline archive") cmd.Flags().Bool("log-archival", false, "Log the archival process") return cmd } func addHandler(cmd *cobra.Command, args []string) { cfg, deps := initShiori(cmd.Context(), cmd) // Read flag and arguments url := args[0] title, _ := cmd.Flags().GetString("title") excerpt, _ := cmd.Flags().GetString("excerpt") tags, _ := cmd.Flags().GetStringSlice("tags") offline, _ := cmd.Flags().GetBool("offline") noArchival, _ := cmd.Flags().GetBool("no-archival") logArchival, _ := cmd.Flags().GetBool("log-archival") // Normalize input title = validateTitle(title, "") excerpt = normalizeSpace(excerpt) // Create bookmark item book := model.BookmarkDTO{ URL: url, Title: title, Excerpt: excerpt, CreateArchive: !noArchival, } // Set bookmark tags book.Tags = make([]model.TagDTO, len(tags)) for i, tag := range tags { book.Tags[i].Name = strings.TrimSpace(tag) } // Clean up bookmark URL var err error book.URL, err = core.RemoveUTMParams(book.URL) if err != nil { cError.Printf("Failed to clean URL: %v\n", err) os.Exit(1) } // Make sure bookmark's title not empty if book.Title == "" { book.Title = book.URL } // Save bookmark to database books, err := deps.Database().SaveBookmarks(cmd.Context(), true, book) if err != nil { cError.Printf("Failed to save bookmark: %v\n", err) os.Exit(1) } book = books[0] // If it's not offline mode, fetch data from internet. if !offline { cInfo.Println("Downloading article...") var isFatalErr bool content, contentType, err := core.DownloadBookmark(book.URL) if err != nil { cError.Printf("Failed to download: %v\n", err) } if err == nil && content != nil { request := core.ProcessRequest{ DataDir: cfg.Storage.DataDir, Bookmark: book, Content: content, ContentType: contentType, LogArchival: logArchival, KeepTitle: title != "", KeepExcerpt: excerpt != "", } book, isFatalErr, err = core.ProcessBookmark(deps, request) content.Close() if err != nil { cError.Printf("Failed: %v\n", err) } if isFatalErr { os.Exit(1) } } // Save bookmark to database _, err = deps.Database().SaveBookmarks(cmd.Context(), false, book) if err != nil { cError.Printf("Failed to save bookmark with content: %v\n", err) os.Exit(1) } } // Print added bookmark fmt.Println() printBookmarks(book) } ================================================ FILE: internal/cmd/check.go ================================================ package cmd import ( "fmt" "net/http" "os" "sort" "sync" "time" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func checkCmd() *cobra.Command { cmd := &cobra.Command{ Use: "check", Short: "Find bookmarked sites that no longer exists on the internet", Long: "Check all bookmarks and find bookmarked sites that no longer exists on the internet. " + "It might take a long time depending on how many bookmarks that you have and want to check. " + "If there are no arguments, it will check ALL of your bookmarks.", Run: checkHandler, } cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and check ALL bookmarks") return cmd } func checkHandler(cmd *cobra.Command, args []string) { _, deps := initShiori(cmd.Context(), cmd) // Parse flags skipConfirm, _ := cmd.Flags().GetBool("yes") // If no arguments (i.e all bookmarks going to be checked), confirm to user if len(args) == 0 && !skipConfirm { confirmCheck := "" fmt.Print("Check ALL bookmarks? (y/N): ") fmt.Scanln(&confirmCheck) if confirmCheck != "y" { fmt.Println("No bookmarks checked") return } } // Convert args to ids ids, err := parseStrIndices(args) if err != nil { cError.Printf("Failed to parse args: %v\n", err) os.Exit(1) } // Fetch bookmarks from database filterOptions := model.DBGetBookmarksOptions{IDs: ids} bookmarks, err := deps.Database().GetBookmarks(cmd.Context(), filterOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) } // Create HTTP client httpClient := &http.Client{Timeout: time.Minute} // Test each bookmark item unreachableIDs := []int{} wg := sync.WaitGroup{} chDone := make(chan struct{}) chProblem := make(chan int, 10) chMessage := make(chan interface{}, 10) semaphore := make(chan struct{}, 10) for i, book := range bookmarks { wg.Add(1) go func(i int, book model.BookmarkDTO) { // Make sure to finish the WG defer wg.Done() // Register goroutine to semaphore semaphore <- struct{}{} defer func() { <-semaphore }() // Ping bookmark's URL _, err := httpClient.Get(book.URL) if err != nil { chProblem <- book.ID chMessage <- fmt.Errorf("failed to reach %s: %v", book.URL, err) return } // Send success message chMessage <- fmt.Sprintf("Reached %s", book.URL) }(i, book) } // Watch messages from channels go func(nBookmark int) { logIndex := 0 for { select { case <-chDone: cInfo.Println("Check finished") return case id := <-chProblem: unreachableIDs = append(unreachableIDs, id) case msg := <-chMessage: logIndex++ switch msg.(type) { case error: cError.Printf("[%d/%d] %v\n", logIndex, nBookmark, msg) case string: cInfo.Printf("[%d/%d] %s\n", logIndex, nBookmark, msg) } } } }(len(bookmarks)) // Wait until all download finished wg.Wait() close(chDone) // Print the unreachable bookmarks fmt.Println() var code int if len(unreachableIDs) == 0 { cInfo.Println("All bookmarks are reachable.") } else { sort.Ints(unreachableIDs) code = 1 cError.Println("Encountered some unreachable bookmarks:") for _, id := range unreachableIDs { cError.Printf("%d ", id) } fmt.Println() } os.Exit(code) } ================================================ FILE: internal/cmd/delete.go ================================================ package cmd import ( "fmt" "os" fp "path/filepath" "strconv" "strings" "github.com/spf13/cobra" ) func deleteCmd() *cobra.Command { cmd := &cobra.Command{ Use: "delete [indices]", Short: "Delete the saved bookmarks", Long: "Delete bookmarks. " + "When a record is deleted, the last record is moved to the removed index. " + "Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), " + "hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). " + "If no arguments, ALL records will be deleted.", Aliases: []string{"rm"}, Run: deleteHandler, } cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and delete ALL bookmarks") return cmd } func deleteHandler(cmd *cobra.Command, args []string) { cfg, deps := initShiori(cmd.Context(), cmd) // Parse flags skipConfirm, _ := cmd.Flags().GetBool("yes") // If no arguments (i.e all bookmarks going to be deleted), confirm to user if len(args) == 0 && !skipConfirm { confirmDelete := "" fmt.Print("Remove ALL bookmarks? (y/N): ") fmt.Scanln(&confirmDelete) if confirmDelete != "y" { fmt.Println("No bookmarks deleted") return } } // Convert args to ids ids, err := parseStrIndices(args) if err != nil { cError.Printf("Failed to parse args: %v\n", err) os.Exit(1) } // Delete bookmarks from database err = deps.Database().DeleteBookmarks(cmd.Context(), ids...) if err != nil { cError.Printf("Failed to delete bookmarks: %v\n", err) os.Exit(1) } // Delete thumbnail image and archives from local disk if len(ids) == 0 { thumbDir := fp.Join(cfg.Storage.DataDir, "thumb") archiveDir := fp.Join(cfg.Storage.DataDir, "archive") os.RemoveAll(thumbDir) os.RemoveAll(archiveDir) } else { for _, id := range ids { strID := strconv.Itoa(id) imgPath := fp.Join(cfg.Storage.DataDir, "thumb", strID) archivePath := fp.Join(cfg.Storage.DataDir, "archive", strID) os.Remove(imgPath) os.Remove(archivePath) } } // Show finish message switch len(args) { case 0: fmt.Println("All bookmarks have been deleted") case 1, 2, 3, 4, 5: fmt.Printf("Bookmark(s) %s have been deleted\n", strings.Join(args, ", ")) default: fmt.Println("Bookmark(s) have been deleted") } } ================================================ FILE: internal/cmd/export.go ================================================ package cmd import ( "fmt" "os" fp "path/filepath" "strings" "time" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func exportCmd() *cobra.Command { cmd := &cobra.Command{ Use: "export target-file", Short: "Export bookmarks into HTML file in Netscape Bookmark format", Args: cobra.ExactArgs(1), Run: exportHandler, } return cmd } func exportHandler(cmd *cobra.Command, args []string) { _, deps := initShiori(cmd.Context(), cmd) // Fetch bookmarks from database bookmarks, err := deps.Database().GetBookmarks(cmd.Context(), model.DBGetBookmarksOptions{}) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) } if len(bookmarks) == 0 { cError.Println("No saved bookmarks yet") return } // Make sure destination directory exist dstDir := fp.Dir(args[0]) if err := os.MkdirAll(dstDir, model.DataDirPerm); err != nil { cError.Printf("Error crating destination directory: %s", err) } // Create destination file dstFile, err := os.Create(args[0]) if err != nil { cError.Printf("Failed to create destination file: %v\n", err) os.Exit(1) } defer dstFile.Close() // Write exported bookmark to file fmt.Fprintln(dstFile, ``+ ``+ ``+ `Bookmarks`+ `

Bookmarks

`+ `
`) for _, book := range bookmarks { // Create Unix timestamp for bookmark modifiedTime, err := time.Parse(model.DatabaseDateFormat, book.ModifiedAt) if err != nil { modifiedTime = time.Now() } unixTimestamp := modifiedTime.Unix() // Create tags for bookmarks tags := []string{} for _, tag := range book.Tags { tags = append(tags, tag.Name) } strTags := strings.Join(tags, ",") // Make sure title is valid book.Title = validateTitle(book.Title, book.URL) // Write to file exportLine := fmt.Sprintf(`
%s`, book.URL, unixTimestamp, unixTimestamp, strTags, book.Title) fmt.Fprintln(dstFile, exportLine) } fmt.Fprintln(dstFile, "
") // Flush data to storage err = dstFile.Sync() if err != nil { cError.Printf("Failed to export the bookmarks: %v\n", err) os.Exit(1) } fmt.Println("Export finished") } ================================================ FILE: internal/cmd/import.go ================================================ package cmd import ( "database/sql" "errors" "fmt" "os" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func importCmd() *cobra.Command { cmd := &cobra.Command{ Use: "import source-file", Short: "Import bookmarks from HTML file in Netscape Bookmark format", Args: cobra.ExactArgs(1), Run: importHandler, } cmd.Flags().BoolP("generate-tag", "t", false, "Auto generate tag from bookmark's category") return cmd } func importHandler(cmd *cobra.Command, args []string) { _, deps := initShiori(cmd.Context(), cmd) // Parse flags generateTag := cmd.Flags().Changed("generate-tag") // If user doesn't specify, ask if tag need to be generated if !generateTag { var submit string fmt.Print("Add parents folder as tag? (y/N): ") fmt.Scanln(&submit) generateTag = submit == "y" } // Open bookmark's file srcFile, err := os.Open(args[0]) if err != nil { cError.Printf("Failed to open %s: %v\n", args[0], err) os.Exit(1) } defer srcFile.Close() // Parse bookmark's file bookmarks := []model.BookmarkDTO{} mapURL := make(map[string]struct{}) doc, err := goquery.NewDocumentFromReader(srcFile) if err != nil { cError.Printf("Failed to parse bookmark: %v\n", err) os.Exit(1) } doc.Find("dt>a").Each(func(_ int, a *goquery.Selection) { // Get related elements dt := a.Parent() dl := dt.Parent() h3 := dl.Parent().Find("h3").First() // Get metadata title := a.Text() url, _ := a.Attr("href") strTags, _ := a.Attr("tags") dateStr, fieldExists := a.Attr("last_modified") if !fieldExists { dateStr, _ = a.Attr("add_date") } // Using now as default date in case no last_modified nor add_date are present modifiedDate := time.Now() if dateStr != "" { modifiedTsInt, err := strconv.Atoi(dateStr) if err != nil { cError.Printf("Skip %s: date field is not valid: %s", url, err) return } modifiedDate = time.Unix(int64(modifiedTsInt), 0) } // Clean up URL url, err = core.RemoveUTMParams(url) if err != nil { cError.Printf("Skip %s: URL is not valid\n", url) return } // Make sure title is valid Utf-8 title = validateTitle(title, url) // Check if the URL already exist before, both in bookmark // file or in database if _, exist := mapURL[url]; exist { cError.Printf("Skip %s: URL already exists\n", url) return } _, exist, err := deps.Database().GetBookmark(cmd.Context(), 0, url) if err != nil && !errors.Is(err, sql.ErrNoRows) { cError.Printf("Skip %s: Get Bookmark fail, %v", url, err) return } if exist { cError.Printf("Skip %s: URL already exists\n", url) mapURL[url] = struct{}{} return } // Get bookmark tags tags := []model.TagDTO{} for _, strTag := range strings.Split(strTags, ",") { strTag = normalizeSpace(strTag) if strTag != "" { tags = append(tags, model.TagDTO{ Tag: model.Tag{Name: strTag}, }) } } // Get category name for this bookmark // and add it as tags (if necessary) category := normalizeSpace(h3.Text()) if category != "" && generateTag { tags = append(tags, model.TagDTO{ Tag: model.Tag{Name: category}, }) } // Add item to list bookmark := model.BookmarkDTO{ URL: url, Title: title, Tags: tags, ModifiedAt: modifiedDate.Format(model.DatabaseDateFormat), } mapURL[url] = struct{}{} bookmarks = append(bookmarks, bookmark) }) // Save bookmark to database bookmarks, err = deps.Database().SaveBookmarks(cmd.Context(), true, bookmarks...) if err != nil { cError.Printf("Failed to save bookmarks: %v\n", err) os.Exit(1) } // Print imported bookmark fmt.Println() printBookmarks(bookmarks...) } ================================================ FILE: internal/cmd/open.go ================================================ package cmd import ( "fmt" "net" "net/http" "os" fp "path/filepath" "strconv" "strings" "time" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/warc" "github.com/julienschmidt/httprouter" "github.com/spf13/cobra" ) func openCmd() *cobra.Command { cmd := &cobra.Command{ Use: "open [indices]", Short: "Open the saved bookmarks", Long: "Open bookmarks in browser. " + "Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), " + "hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). " + "If no arguments, ALL bookmarks will be opened.", Run: openHandler, } cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and open ALL bookmarks") cmd.Flags().BoolP("archive", "a", false, "Open the bookmark's archived content") cmd.Flags().IntP("archive-port", "p", 0, "Port number that used to serve archive") cmd.Flags().BoolP("text-cache", "t", false, "Open the bookmark's text cache in terminal") return cmd } func openHandler(cmd *cobra.Command, args []string) { cfg, deps := initShiori(cmd.Context(), cmd) // Parse flags skipConfirm, _ := cmd.Flags().GetBool("yes") archiveMode, _ := cmd.Flags().GetBool("archive") archivePort, _ := cmd.Flags().GetInt("archive-port") textCacheMode, _ := cmd.Flags().GetBool("text-cache") // Convert args to ids ids, err := parseStrIndices(args) if err != nil { cError.Println(err) os.Exit(1) } // If in archive mode, only one bookmark allowed if len(ids) > 1 && archiveMode { cError.Println("In archive mode, only one bookmark allowed") os.Exit(1) } // If no arguments (i.e all bookmarks will be opened), // confirm to user if len(args) == 0 && !skipConfirm { confirmOpen := "" fmt.Print("Open ALL bookmarks? (y/N): ") fmt.Scanln(&confirmOpen) if confirmOpen != "y" { return } } // Read bookmarks from database getOptions := model.DBGetBookmarksOptions{ IDs: ids, WithContent: true, } bookmarks, err := deps.Database().GetBookmarks(cmd.Context(), getOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) } if len(bookmarks) == 0 { if len(ids) > 0 { cError.Println("No matching index found") os.Exit(1) } else { cError.Println("No bookmarks saved yet") os.Exit(1) } return } // If not text cache mode nor archive mode, open bookmarks in browser if !textCacheMode && !archiveMode { var code int for _, book := range bookmarks { err = openBrowser(book.URL) if err != nil { cError.Printf("Failed to open %s: %v\n", book.URL, err) code = 1 } } os.Exit(code) } // Show bookmarks content in terminal if textCacheMode { termWidth := getTerminalWidth() var code int for _, book := range bookmarks { cIndex.Printf("%d. ", book.ID) cTitle.Println(book.Title) fmt.Println() if book.Content == "" { cError.Println("This bookmark doesn't have any cached content") code = 1 } else { book.Content = strings.Join(strings.Fields(book.Content), " ") fmt.Println(book.Content) } fmt.Println() cSymbol.Println(strings.Repeat("=", termWidth)) fmt.Println() } os.Exit(code) } // Open archive id := strconv.Itoa(bookmarks[0].ID) archivePath := fp.Join(cfg.Storage.DataDir, "archive", id) archive, err := warc.Open(archivePath) if err != nil { cError.Printf("Failed to open archive: %v\n", err) os.Exit(1) } defer archive.Close() // Create simple server router := httprouter.New() router.GET("/*filename", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { filename := ps.ByName("filename") resourceName := fp.Base(filename) if resourceName == "/" { resourceName = "" } content, contentType, err := archive.Read(resourceName) if err != nil { panic(err) } w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Encoding", "gzip") if _, err = w.Write(content); err != nil { panic(err) } }) router.PanicHandler = func(w http.ResponseWriter, r *http.Request, arg interface{}) { http.Error(w, fmt.Sprint(arg), 500) } // Choose random port listenerAddr := fmt.Sprintf(":%d", archivePort) listener, err := net.Listen("tcp", listenerAddr) if err != nil { cError.Printf("Failed to serve archive: %v\n", err) os.Exit(1) } portNumber := listener.Addr().(*net.TCPAddr).Port localhostAddr := fmt.Sprintf("http://localhost:%d", portNumber) cInfo.Printf("Archive served in %s\n", localhostAddr) // Open browser go func() { time.Sleep(time.Second) err := openBrowser(localhostAddr) if err != nil { cError.Printf("Failed to open browser: %v\n", err) os.Exit(1) } }() // Serve archive err = http.Serve(listener, router) if err != nil { cError.Printf("Failed to serve archive: %v\n", err) os.Exit(1) } } ================================================ FILE: internal/cmd/pocket.go ================================================ package cmd import ( "context" "encoding/csv" "errors" "fmt" "os" "path/filepath" "regexp" "slices" "strconv" "time" "github.com/PuerkitoBio/goquery" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func pocketCmd() *cobra.Command { cmd := &cobra.Command{ Use: "pocket source-file", Short: "Import bookmarks from Pocket's data export file", Args: cobra.ExactArgs(1), Run: pocketHandler, } return cmd } func pocketHandler(cmd *cobra.Command, args []string) { ctx := cmd.Context() _, deps := initShiori(ctx, cmd) // Open pocket's file filePath := args[0] srcFile, err := os.Open(filePath) if err != nil { cError.Println(err) os.Exit(1) } defer srcFile.Close() var bookmarks []model.BookmarkDTO switch filepath.Ext(filePath) { case ".html": bookmarks = parseHtmlExport(ctx, deps.Database(), srcFile) case ".csv": bookmarks = parseCsvExport(ctx, deps.Database(), srcFile) default: cError.Println("Invalid file format. Only HTML and CSV are supported.") os.Exit(1) } // Save bookmark to database bookmarks, err = deps.Database().SaveBookmarks(ctx, true, bookmarks...) if err != nil { cError.Printf("Failed to save bookmarks: %v\n", err) os.Exit(1) } // Print imported bookmarks fmt.Println() printBookmarks(bookmarks...) } // Parse bookmarks from HTML file func parseHtmlExport(ctx context.Context, db model.DB, srcFile *os.File) []model.BookmarkDTO { bookmarks := []model.BookmarkDTO{} mapURL := make(map[string]struct{}) doc, err := goquery.NewDocumentFromReader(srcFile) if err != nil { cError.Println(err) os.Exit(1) } doc.Find("a").Each(func(_ int, a *goquery.Selection) { // Get metadata title := a.Text() url, _ := a.Attr("href") tagsStr, _ := a.Attr("tags") timeAddedStr, _ := a.Attr("time_added") title, url, timeAdded, tags, err := verifyMetadata(title, url, timeAddedStr, tagsStr) if err != nil { cError.Printf("Skip %s: %v\n", url, err) return } if err = handleDuplicates(ctx, db, mapURL, url); err != nil { cError.Printf("Skip %s: %v\n", url, err) return } // Add item to list bookmark := model.BookmarkDTO{ URL: url, Title: title, ModifiedAt: timeAdded.Format(model.DatabaseDateFormat), CreatedAt: timeAdded.Format(model.DatabaseDateFormat), Tags: tags, } mapURL[url] = struct{}{} bookmarks = append(bookmarks, bookmark) }) return bookmarks } // Parse bookmarks from CSV file func parseCsvExport(ctx context.Context, db model.DB, srcFile *os.File) []model.BookmarkDTO { bookmarks := []model.BookmarkDTO{} mapURL := make(map[string]struct{}) reader := csv.NewReader(srcFile) records, err := reader.ReadAll() if err != nil { cError.Println(err) os.Exit(1) } var titleIdx, urlIdx, timeAddedIdx, tagsIdx int for i, cols := range records { // Check and skip header if i == 0 { titleIdx = slices.Index(cols, "title") urlIdx = slices.Index(cols, "url") timeAddedIdx = slices.Index(cols, "time_added") tagsIdx = slices.Index(cols, "tags") if titleIdx == -1 || urlIdx == -1 || timeAddedIdx == -1 || tagsIdx == -1 { cError.Printf("Invalid CSV format. Header must contain: title, url, time_added, tags\n") os.Exit(1) } continue } // Get metadata title, url, timeAdded, tags, err := verifyMetadata(cols[titleIdx], cols[urlIdx], cols[timeAddedIdx], cols[tagsIdx]) if err != nil { cError.Printf("Skip %s: %v\n", url, err) continue } if err = handleDuplicates(ctx, db, mapURL, url); err != nil { cError.Printf("Skip %s: %v\n", url, err) continue } // Add item to list bookmark := model.BookmarkDTO{ URL: url, Title: title, ModifiedAt: timeAdded.Format(model.DatabaseDateFormat), CreatedAt: timeAdded.Format(model.DatabaseDateFormat), Tags: tags, } mapURL[url] = struct{}{} bookmarks = append(bookmarks, bookmark) } return bookmarks } // Parse metadata and verify it's validity func verifyMetadata(title, url, timeAddedStr, tags string) (string, string, time.Time, []model.TagDTO, error) { // Clean up URL var err error url, err = core.RemoveUTMParams(url) if err != nil { err = fmt.Errorf("URL is not valid, %w", err) return "", "", time.Time{}, nil, err } // Make sure title is valid Utf-8 title = validateTitle(title, url) // Parse time added timeAddedInt, err := strconv.ParseInt(timeAddedStr, 10, 64) if err != nil { err = fmt.Errorf("invalid time added, %w", err) return "", "", time.Time{}, nil, err } timeAdded := time.Unix(timeAddedInt, 0) // Get bookmark tags tagsList := []model.TagDTO{} // We need to split tags by both comma or pipe, // because Pocket's CSV export use pipe as separator, // while HTML export use comma. for _, tag := range regexp.MustCompile(`[,|]`).Split(tags, -1) { if tag != "" { tagsList = append(tagsList, model.TagDTO{ Tag: model.Tag{Name: tag}, }) } } return title, url, timeAdded, tagsList, nil } // Checks if the URL already exist, both in bookmark // file or in database func handleDuplicates(ctx context.Context, db model.DB, mapURL map[string]struct{}, url string) error { if _, exists := mapURL[url]; exists { return errors.New("URL already exists") } _, exists, err := db.GetBookmark(ctx, 0, url) if err != nil { return fmt.Errorf("failed getting bookmark, %w", err) } if exists { return errors.New("URL already exists") } return nil } ================================================ FILE: internal/cmd/pocket_test.go ================================================ package cmd import ( "context" "os" "path/filepath" "testing" "github.com/go-shiori/shiori/internal/database" ) func Test_parseCsvExport_old_format(t *testing.T) { tests := []struct { name string fileName string }{ { name: "Test old file format", fileName: "pocket-old.csv", }, { name: "Test new file format", fileName: "pocket-new.csv", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { file, err := os.Open("../../testdata/" + tt.fileName) if err != nil { t.Error(err.Error()) } defer file.Close() ctx := context.TODO() tmpDir, err := os.MkdirTemp("", "shiori-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) dbPath := filepath.Join(tmpDir, "shiori.db") db, err := database.OpenSQLiteDatabase(ctx, dbPath) if err != nil { t.Fatalf("failed to open sqlite database: %v", err) } if err := db.Migrate(ctx); err != nil { t.Fatalf("failed to migrate sqlite database: %v", err) } bookmarks := parseCsvExport(ctx, db, file) if len(bookmarks) != 1 { t.Errorf("Expected 1 bookmarks, got %d", len(bookmarks)) } bm := bookmarks[0] if bm.Title != "Shiori" { t.Errorf("Expected Title Shiori got %s", bm.URL) } if bm.URL != "https://github.com/go-shiori/shiori" { t.Errorf("Expected URL https://github.com/go-shiori/shiori, got %s", bm.URL) } if len(bm.Tags) != 1 { t.Errorf("Expected 1 tags, got %d", len(bm.Tags)) } if bm.Tags[0].Name != "shiori" { t.Errorf("Expected tag shiori, got %s", bm.Tags[0].Name) } if bm.CreatedAt == "" { t.Error("Expected CreatedAt to be not empty") } if bm.ModifiedAt == "" { t.Error("Expected CreatedAt to be not empty") } }) } } ================================================ FILE: internal/cmd/print.go ================================================ package cmd import ( "encoding/json" "fmt" "os" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func printCmd() *cobra.Command { cmd := &cobra.Command{ Use: "print [indices]", Short: "Print the saved bookmarks", Long: "Show the saved bookmarks by its database index. " + "Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), " + "hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). " + "If no arguments, all records with actual index from database are shown.", Aliases: []string{"list", "ls"}, Run: printHandler, } cmd.Flags().BoolP("json", "j", false, "Output data in JSON format") cmd.Flags().BoolP("latest", "l", false, "Sort bookmark by latest instead of ID") cmd.Flags().BoolP("index-only", "i", false, "Only print the index of bookmarks") cmd.Flags().StringP("search", "s", "", "Search bookmark with specified keyword") cmd.Flags().StringSliceP("tags", "t", []string{}, "Print bookmarks with matching tag(s)") cmd.Flags().StringSliceP("exclude-tags", "e", []string{}, "Print bookmarks without these tag(s)") return cmd } func printHandler(cmd *cobra.Command, args []string) { _, deps := initShiori(cmd.Context(), cmd) // Read flags tags, _ := cmd.Flags().GetStringSlice("tags") keyword, _ := cmd.Flags().GetString("search") useJSON, _ := cmd.Flags().GetBool("json") indexOnly, _ := cmd.Flags().GetBool("index-only") orderLatest, _ := cmd.Flags().GetBool("latest") excludedTags, _ := cmd.Flags().GetStringSlice("exclude-tags") // Convert args to ids ids, err := parseStrIndices(args) if err != nil { cError.Printf("Failed to parse args: %v\n", err) return } // Read bookmarks from database orderMethod := model.DefaultOrder if orderLatest { orderMethod = model.ByLastModified } searchOptions := model.DBGetBookmarksOptions{ IDs: ids, Tags: tags, ExcludedTags: excludedTags, Keyword: keyword, OrderMethod: orderMethod, } bookmarks, err := deps.Database().GetBookmarks(cmd.Context(), searchOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) return } if len(bookmarks) == 0 { switch { case len(ids) > 0: cError.Println("No matching index found") case keyword != "", len(tags) > 0: cError.Println("No matching bookmarks found") default: cError.Println("No bookmarks saved yet") } return } // Print data if useJSON { bt, err := json.MarshalIndent(&bookmarks, "", " ") if err != nil { cError.Println(err) os.Exit(1) } fmt.Println(string(bt)) return } if indexOnly { for _, bookmark := range bookmarks { fmt.Printf("%d ", bookmark.ID) } fmt.Println() return } printBookmarks(bookmarks...) } ================================================ FILE: internal/cmd/root.go ================================================ package cmd import ( "fmt" "os" fp "path/filepath" "time" "github.com/go-shiori/shiori/internal/config" "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/dependencies" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/model" "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/spf13/cobra" "golang.org/x/net/context" ) // ShioriCmd returns the root command for shiori func ShioriCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "shiori", Short: "Simple command-line bookmark manager built with Go", } rootCmd.PersistentFlags().Bool("portable", false, "run shiori in portable mode") rootCmd.PersistentFlags().String("storage-directory", "", "path to store shiori data") rootCmd.MarkFlagsMutuallyExclusive("portable", "storage-directory") rootCmd.PersistentFlags().String("log-level", logrus.InfoLevel.String(), "set logrus loglevel") rootCmd.PersistentFlags().Bool("log-caller", false, "logrus report caller or not") rootCmd.AddCommand( addCmd(), printCmd(), updateCmd(), deleteCmd(), openCmd(), importCmd(), exportCmd(), pocketCmd(), serveCmd(), checkCmd(), newVersionCommand(), newServerCommand(), ) return rootCmd } func initShiori(ctx context.Context, cmd *cobra.Command) (*config.Config, *dependencies.Dependencies) { logger := logrus.New() portableMode, _ := cmd.Flags().GetBool("portable") logLevel, _ := cmd.Flags().GetString("log-level") logCaller, _ := cmd.Flags().GetBool("log-caller") storageDirectory, _ := cmd.Flags().GetString("storage-directory") logger.SetReportCaller(logCaller) logger.SetFormatter(&logrus.TextFormatter{ FullTimestamp: true, TimestampFormat: time.RFC3339, CallerPrettyfier: SFCallerPrettyfier, }) if lvl, err := logrus.ParseLevel(logLevel); err != nil { logger.WithError(err).Panic("failed to set log level") } else { logger.SetLevel(lvl) } cfg := config.ParseServerConfiguration(ctx, logger) cfg.LogLevel = logger.Level.String() if storageDirectory != "" { logger.Warn("--storage-directory is set, overriding SHIORI_DIR.") cfg.Storage.DataDir = storageDirectory } cfg.SetDefaults(logger, portableMode) if err := cfg.IsValid(); err != nil { logger.WithError(err).Fatal("invalid configuration detected") } err := os.MkdirAll(cfg.Storage.DataDir, model.DataDirPerm) if err != nil { logger.WithError(err).Fatal("error creating data directory") } db, err := openDatabase(logger, ctx, cfg) if err != nil { logger.WithError(err).Fatal("error opening database") } // Migrate if err := db.Migrate(ctx); err != nil { logger.WithError(err).Fatalf("Error running migration") } if cfg.Development { logger.Warn("Development mode is ENABLED, this will enable some helpers for local development, unsuitable for production environments") } dependencies := dependencies.NewDependencies(logger, db, cfg) dependencies.Domains().SetAuth(domains.NewAuthDomain(dependencies)) dependencies.Domains().SetAccounts(domains.NewAccountsDomain(dependencies)) dependencies.Domains().SetArchiver(domains.NewArchiverDomain(dependencies)) dependencies.Domains().SetBookmarks(domains.NewBookmarksDomain(dependencies)) dependencies.Domains().SetStorage(domains.NewStorageDomain(dependencies, afero.NewBasePathFs(afero.NewOsFs(), cfg.Storage.DataDir))) dependencies.Domains().SetTags(domains.NewTagsDomain(dependencies)) // Workaround: Get accounts to make sure at least one is present in the database. // If there's no accounts in the database, create the shiori/gopher account the legacy api // hardcoded in the login handler. accounts, err := dependencies.Domains().Accounts().ListAccounts(cmd.Context()) if err != nil { cError.Printf("Failed to get owner account: %v\n", err) os.Exit(1) } if len(accounts) == 0 { account := model.AccountDTO{ Username: "shiori", Password: "gopher", Owner: model.Ptr(true), } if _, err := dependencies.Domains().Accounts().CreateAccount(cmd.Context(), account); err != nil { logger.WithError(err).Fatal("error ensuring owner account") } } cfg.DebugConfiguration(logger) return cfg, dependencies } func openDatabase(logger *logrus.Logger, ctx context.Context, cfg *config.Config) (model.DB, error) { if cfg.Database.URL != "" { return database.Connect(ctx, cfg.Database.URL) } if cfg.Database.DBMS != "" { logger.Warnf("The use of SHIORI_DBMS is deprecated and will be removed in the future. Please migrate to SHIORI_DATABASE_URL instead.") } // TODO remove this the moment DBMS is deprecated if cfg.Database.DBMS == "mysql" { return openMySQLDatabase(ctx) } if cfg.Database.DBMS == "postgresql" { return openPostgreSQLDatabase(ctx) } return database.OpenSQLiteDatabase(ctx, fp.Join(cfg.Storage.DataDir, "shiori.db")) } func openMySQLDatabase(ctx context.Context) (model.DB, error) { user, _ := os.LookupEnv("SHIORI_MYSQL_USER") password, _ := os.LookupEnv("SHIORI_MYSQL_PASS") dbName, _ := os.LookupEnv("SHIORI_MYSQL_NAME") dbAddress, _ := os.LookupEnv("SHIORI_MYSQL_ADDRESS") connString := fmt.Sprintf("%s:%s@%s/%s?charset=utf8mb4", user, password, dbAddress, dbName) return database.OpenMySQLDatabase(ctx, connString) } func openPostgreSQLDatabase(ctx context.Context) (model.DB, error) { host, _ := os.LookupEnv("SHIORI_PG_HOST") port, _ := os.LookupEnv("SHIORI_PG_PORT") user, _ := os.LookupEnv("SHIORI_PG_USER") password, _ := os.LookupEnv("SHIORI_PG_PASS") dbName, _ := os.LookupEnv("SHIORI_PG_NAME") sslmode, _ := os.LookupEnv("SHIORI_PG_SSLMODE") if sslmode == "" { sslmode = "disable" } connString := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbName, sslmode) return database.OpenPGDatabase(ctx, connString) } ================================================ FILE: internal/cmd/serve.go ================================================ package cmd import ( "github.com/spf13/cobra" ) func serveCmd() *cobra.Command { cmd := &cobra.Command{ Use: "serve", Short: "Serve web interface for managing bookmarks", Long: "Run a simple and performant web server which " + "serves the site for managing bookmarks. If --port " + "flag is not used, it will use port 8080 by default.", Deprecated: "use server instead", Run: newServerCommandHandler(), } cmd.Flags().IntP("port", "p", 8080, "Port used by the server") cmd.Flags().StringP("address", "a", "", "Address the server listens to") cmd.Flags().StringP("webroot", "r", "/", "Root path that used by server") cmd.Flags().Bool("log", true, "Print out a non-standard access log") cmd.Flags().Bool("serve-web-ui", true, "Serve static files from the webroot path") return cmd } ================================================ FILE: internal/cmd/server.go ================================================ package cmd import ( "context" "strings" "github.com/go-shiori/shiori/internal/config" "github.com/go-shiori/shiori/internal/http" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" "github.com/spf13/pflag" ) func newServerCommand() *cobra.Command { cmd := &cobra.Command{ Use: "server", Short: "Starts the Shiori webserver", Long: "Serves the Shiori web interface and API.", Run: newServerCommandHandler(), } cmd.Flags().IntP("port", "p", 8080, "Port used by the server") cmd.Flags().StringP("address", "a", "", "Address the server listens to") cmd.Flags().StringP("webroot", "r", "/", "Root path that used by server") cmd.Flags().Bool("access-log", false, "Print out a non-standard access log") cmd.Flags().Bool("serve-web-ui", true, "Serve static files from the webroot path") cmd.Flags().Bool("experimental-serve-web-ui-v2", false, "Serve static files from the webapp path") cmd.Flags().String("secret-key", "", "Secret key used for encrypting session data") return cmd } func setIfFlagChanged(flagName string, flags *pflag.FlagSet, cfg *config.Config, fn func(cfg *config.Config)) { if flags.Changed(flagName) { fn(cfg) } } func newServerCommandHandler() func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) { ctx := context.Background() // Get flags values port, _ := cmd.Flags().GetInt("port") address, _ := cmd.Flags().GetString("address") rootPath, _ := cmd.Flags().GetString("webroot") accessLog, _ := cmd.Flags().GetBool("access-log") serveWebUI, _ := cmd.Flags().GetBool("serve-web-ui") serveWebUIV2, _ := cmd.Flags().GetBool("experimental-serve-web-ui-v2") secretKey, _ := cmd.Flags().GetBytesHex("secret-key") cfg, dependencies := initShiori(ctx, cmd) // Validate root path if rootPath == "" { rootPath = "/" } if !strings.HasPrefix(rootPath, "/") { rootPath = "/" + rootPath } if !strings.HasSuffix(rootPath, "/") { rootPath += "/" } // Override configuration from flags if needed setIfFlagChanged("port", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.Port = port }) setIfFlagChanged("address", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.Address = address + ":" }) setIfFlagChanged("webroot", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.RootPath = rootPath }) setIfFlagChanged("access-log", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.AccessLog = accessLog }) setIfFlagChanged("serve-web-ui", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.ServeWebUI = serveWebUI }) setIfFlagChanged("secret-key", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.SecretKey = secretKey }) setIfFlagChanged("experimental-serve-web-ui-v2", cmd.Flags(), cfg, func(cfg *config.Config) { cfg.Http.ServeWebUIV2 = serveWebUIV2 }) dependencies.Logger().Infof("Starting Shiori v%s", model.BuildVersion) server, err := http.NewHttpServer(dependencies.Logger()).Setup(cfg, dependencies) if err != nil { dependencies.Logger().WithError(err).Fatal("error setting up server") } if err := server.Start(ctx); err != nil { dependencies.Logger().WithError(err).Fatal("error starting server") } dependencies.Logger().Debug("started http server") server.WaitStop(ctx) } } ================================================ FILE: internal/cmd/server_test.go ================================================ package cmd import ( "testing" "github.com/go-shiori/shiori/internal/config" "github.com/spf13/pflag" "github.com/stretchr/testify/require" ) func Test_setIfFlagChanged(t *testing.T) { type args struct { flagName string flags func() *pflag.FlagSet cfg *config.Config fn func(cfg *config.Config) } tests := []struct { name string args args assertFn func(t *testing.T, cfg *config.Config) }{ { name: "Flag didn't change", args: args{ flagName: "port", flags: func() *pflag.FlagSet { return &pflag.FlagSet{} }, cfg: &config.Config{ Http: &config.HttpConfig{ Port: 8080, }, }, fn: func(cfg *config.Config) { cfg.Http.Port = 9999 }, }, assertFn: func(t *testing.T, cfg *config.Config) { require.Equal(t, cfg.Http.Port, 8080) }, }, { name: "Flag changed", args: args{ flagName: "port", flags: func() *pflag.FlagSet { pf := &pflag.FlagSet{} pf.IntP("port", "p", 8080, "Port used by the server") pf.Set("port", "9999") return pf }, cfg: &config.Config{ Http: &config.HttpConfig{ Port: 8080, }, }, fn: func(cfg *config.Config) { cfg.Http.Port = 9999 }, }, assertFn: func(t *testing.T, cfg *config.Config) { require.Equal(t, cfg.Http.Port, 9999) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setIfFlagChanged(tt.args.flagName, tt.args.flags(), tt.args.cfg, tt.args.fn) }) } } ================================================ FILE: internal/cmd/update.go ================================================ package cmd import ( "fmt" "os" "sort" "strings" "sync" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func updateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update [indices]", Short: "Update the saved bookmarks", Long: "Update fields and archive of an existing bookmark. " + "Accepts space-separated list of indices (e.g. 5 6 23 4 110 45), " + "hyphenated range (e.g. 100-200) or both (e.g. 1-3 7 9). " + "If no arguments, ALL bookmarks will be updated. Update works differently depending on the flags:\n" + "- If indices are passed without any flags (--url, --title, --tag and --excerpt), read the URLs from database and update titles from web.\n" + "- If --url is passed (and --title is omitted), update the title from web using the URL. While using this flag, update only accept EXACTLY one index.\n" + "While updating bookmark's tags, you can use - to remove tag (e.g. -nature to remove nature tag from this bookmark).", Run: updateHandler, } cmd.Flags().StringP("url", "u", "", "New URL for this bookmark") cmd.Flags().StringP("title", "i", "", "New title for this bookmark") cmd.Flags().StringP("excerpt", "e", "", "New excerpt for this bookmark") cmd.Flags().StringSliceP("tags", "t", []string{}, "Comma-separated tags for this bookmark") cmd.Flags().BoolP("offline", "o", false, "Update bookmark without fetching data from internet") cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt and update ALL bookmarks") cmd.Flags().Bool("keep-metadata", false, "Keep existing metadata. Useful when only want to update bookmark's content") cmd.Flags().BoolP("no-archival", "a", false, "Update bookmark without updating offline archive") cmd.Flags().Bool("log-archival", false, "Log the archival process") return cmd } func updateHandler(cmd *cobra.Command, args []string) { cfg, deps := initShiori(cmd.Context(), cmd) // Parse flags url, _ := cmd.Flags().GetString("url") title, _ := cmd.Flags().GetString("title") excerpt, _ := cmd.Flags().GetString("excerpt") tags, _ := cmd.Flags().GetStringSlice("tags") offline, _ := cmd.Flags().GetBool("offline") skipConfirm, _ := cmd.Flags().GetBool("yes") noArchival, _ := cmd.Flags().GetBool("no-archival") logArchival, _ := cmd.Flags().GetBool("log-archival") keep_metadata := cmd.Flags().Changed("keep-metadata") // If no arguments (i.e all bookmarks going to be updated), confirm to user if len(args) == 0 && !skipConfirm { confirmUpdate := "" fmt.Print("Update ALL bookmarks? (y/N): ") fmt.Scanln(&confirmUpdate) if confirmUpdate != "y" { fmt.Println("No bookmarks updated") return } } // Convert args to ids ids, err := parseStrIndices(args) if err != nil { cError.Printf("Failed to parse args: %v\n", err) os.Exit(1) } // Clean up new parameter from flags title = validateTitle(title, "") excerpt = normalizeSpace(excerpt) if cmd.Flags().Changed("url") { // Clean up bookmark URL url, err = core.RemoveUTMParams(url) if err != nil { panic(fmt.Errorf("failed to clean URL: %v", err)) } // Since user uses custom URL, make sure there is only one ID to update if len(ids) != 1 { cError.Println("Update only accepts one index while using --url flag") os.Exit(1) } } // Fetch bookmarks from database filterOptions := model.DBGetBookmarksOptions{ IDs: ids, } bookmarks, err := deps.Database().GetBookmarks(cmd.Context(), filterOptions) if err != nil { cError.Printf("Failed to get bookmarks: %v\n", err) os.Exit(1) } if len(bookmarks) == 0 { cError.Println("No matching index found") os.Exit(1) } // Check if user really want to batch update archive if nBook := len(bookmarks); nBook > 5 && !offline && !noArchival && !skipConfirm { fmt.Printf("This update will generate offline archive for %d bookmark(s).\n", nBook) fmt.Println("This might take a long time and uses lot of your network bandwidth.") confirmUpdate := "" fmt.Printf("Continue update and archival process ? (y/N): ") fmt.Scanln(&confirmUpdate) if confirmUpdate != "y" { fmt.Println("No bookmarks updated") return } } // If it's not offline mode, fetch data from internet idWithProblems := []int{} if !offline { mx := sync.RWMutex{} wg := sync.WaitGroup{} chDone := make(chan struct{}) chProblem := make(chan int, 10) chMessage := make(chan interface{}, 10) semaphore := make(chan struct{}, 10) cInfo.Println("Downloading article(s)...") for i, book := range bookmarks { wg.Add(1) // Mark whether book will be archived book.CreateArchive = !noArchival // If used, use submitted URL if url != "" { book.URL = url } go func(i int, book model.BookmarkDTO) { // Make sure to finish the WG defer wg.Done() // Register goroutine to semaphore semaphore <- struct{}{} defer func() { <-semaphore }() // Download data from internet content, contentType, err := core.DownloadBookmark(book.URL) if err != nil { chProblem <- book.ID chMessage <- fmt.Errorf("failed to download %s: %v", book.URL, err) return } request := core.ProcessRequest{ DataDir: cfg.Storage.DataDir, Bookmark: book, Content: content, ContentType: contentType, KeepTitle: keep_metadata, KeepExcerpt: keep_metadata, LogArchival: logArchival, } book, _, err = core.ProcessBookmark(deps, request) content.Close() if err != nil { chProblem <- book.ID chMessage <- fmt.Errorf("failed to process %s: %v", book.URL, err) return } // Send success message chMessage <- fmt.Sprintf("Downloaded %s", book.URL) // Save parse result to bookmark mx.Lock() bookmarks[i] = book mx.Unlock() }(i, book) } // Print log message go func(nBookmark int) { logIndex := 0 for { select { case <-chDone: cInfo.Println("Download finished") return case id := <-chProblem: idWithProblems = append(idWithProblems, id) case msg := <-chMessage: logIndex++ switch msg.(type) { case error: cError.Printf("[%d/%d] %v\n", logIndex, nBookmark, msg) case string: cInfo.Printf("[%d/%d] %s\n", logIndex, nBookmark, msg) } } } }(len(bookmarks)) // Wait until all download finished wg.Wait() close(chDone) } // Map which tags is new or deleted from flag --tags addedTags := make(map[string]struct{}) deletedTags := make(map[string]struct{}) for _, tag := range tags { tagName := strings.ToLower(tag) tagName = strings.TrimSpace(tagName) if strings.HasPrefix(tagName, "-") { tagName = strings.TrimPrefix(tagName, "-") deletedTags[tagName] = struct{}{} } else { addedTags[tagName] = struct{}{} } } // Attach user submitted value to the bookmarks for i, book := range bookmarks { // If user submit his own title or excerpt, use it if title != "" { book.Title = title } if excerpt != "" { book.Excerpt = excerpt } // If user submits url, use it if url != "" { book.URL = url } // Make sure title is valid and not empty book.Title = validateTitle(book.Title, book.URL) // Generate new tags tmpAddedTags := make(map[string]struct{}) for key, value := range addedTags { tmpAddedTags[key] = value } newTags := []model.TagDTO{} for _, tag := range book.Tags { if _, isDeleted := deletedTags[tag.Name]; isDeleted { tag.Deleted = true } if _, alreadyExist := addedTags[tag.Name]; alreadyExist { delete(tmpAddedTags, tag.Name) } newTags = append(newTags, tag) } for tag := range tmpAddedTags { newTags = append(newTags, model.TagDTO{ Tag: model.Tag{Name: tag}, }) } book.Tags = newTags // Set bookmark's new data bookmarks[i] = book } // Save bookmarks to database bookmarks, err = deps.Database().SaveBookmarks(cmd.Context(), false, bookmarks...) if err != nil { cError.Printf("Failed to save bookmark: %v\n", err) os.Exit(1) } // Print updated bookmarks fmt.Println() printBookmarks(bookmarks...) var code int if len(idWithProblems) > 0 { code = 1 sort.Ints(idWithProblems) cError.Println("Encountered error while downloading some bookmark(s):") for _, id := range idWithProblems { cError.Printf("%d ", id) } fmt.Println() } os.Exit(code) } ================================================ FILE: internal/cmd/utils.go ================================================ package cmd import ( "errors" "fmt" nurl "net/url" "os" "os/exec" "path" "runtime" "strconv" "strings" "unicode/utf8" "github.com/fatih/color" "github.com/go-shiori/shiori/internal/model" "golang.org/x/term" ) var ( cIndex = color.New(color.FgHiCyan) cSymbol = color.New(color.FgHiMagenta) cTitle = color.New(color.FgHiGreen).Add(color.Bold) cURL = color.New(color.FgHiYellow) cExcerpt = color.New(color.FgHiWhite) cTag = color.New(color.FgHiBlue) cInfo = color.New(color.FgHiCyan) cError = color.New(color.FgHiRed) errInvalidIndex = errors.New("index is not valid") ) func normalizeSpace(str string) string { str = strings.TrimSpace(str) return strings.Join(strings.Fields(str), " ") } func isURLValid(s string) bool { tmp, err := nurl.Parse(s) return err == nil && tmp.Scheme != "" && tmp.Hostname() != "" } func printBookmarks(bookmarks ...model.BookmarkDTO) { for _, bookmark := range bookmarks { // Create bookmark index strBookmarkIndex := fmt.Sprintf("%d. ", bookmark.ID) strSpace := strings.Repeat(" ", len(strBookmarkIndex)) // Print bookmark title cIndex.Print(strBookmarkIndex) cTitle.Println(bookmark.Title) // Print bookmark URL cSymbol.Print(strSpace + "> ") cURL.Println(bookmark.URL) // Print bookmark excerpt if bookmark.Excerpt != "" { cSymbol.Print(strSpace + "+ ") cExcerpt.Println(bookmark.Excerpt) } // Print bookmark tags if len(bookmark.Tags) > 0 { cSymbol.Print(strSpace + "# ") for i, tag := range bookmark.Tags { if i == len(bookmark.Tags)-1 { cTag.Println(tag.Name) } else { cTag.Print(tag.Name + ", ") } } } // Append new line fmt.Println() } } // parseStrIndices converts a list of indices to their integer values func parseStrIndices(indices []string) ([]int, error) { var listIndex []int for _, strIndex := range indices { if !strings.Contains(strIndex, "-") { index, err := strconv.Atoi(strIndex) if err != nil || index < 1 { return nil, errInvalidIndex } listIndex = append(listIndex, index) continue } parts := strings.Split(strIndex, "-") if len(parts) != 2 { return nil, errInvalidIndex } minIndex, errMin := strconv.Atoi(parts[0]) maxIndex, errMax := strconv.Atoi(parts[1]) if errMin != nil || errMax != nil || minIndex < 1 || minIndex > maxIndex { return nil, errInvalidIndex } for i := minIndex; i <= maxIndex; i++ { listIndex = append(listIndex, i) } } return listIndex, nil } // openBrowser tries to open the URL in a browser, // and returns any error if it happened. func openBrowser(url string) error { var args []string switch runtime.GOOS { case "darwin": args = []string{"open"} case "windows": args = []string{"cmd", "/c", "start"} default: args = []string{"xdg-open"} } cmd := exec.Command(args[0], append(args[1:], url)...) return cmd.Run() } func getTerminalWidth() int { width, _, _ := term.GetSize(int(os.Stdin.Fd())) return width } func validateTitle(title, fallback string) string { // Normalize spaces before we begin title = normalizeSpace(title) title = strings.TrimSpace(title) // If at this point title already empty, just uses fallback if title == "" { return fallback } // Check if it's already valid UTF-8 string if valid := utf8.ValidString(title); valid { return title } // Remove invalid runes to get the valid UTF-8 title fixUtf := func(r rune) rune { if r == utf8.RuneError { return -1 } return r } validUtf := strings.Map(fixUtf, title) // If it's empty use fallback string validUtf = strings.TrimSpace(validUtf) if validUtf == "" { return fallback } return validUtf } func SFCallerPrettyfier(frame *runtime.Frame) (string, string) { return "", fmt.Sprintf("%s:%d", path.Base(frame.File), frame.Line) } ================================================ FILE: internal/cmd/utils_test.go ================================================ package cmd import ( "reflect" "testing" ) func Test_normalizeSpace(t *testing.T) { tests := []struct { name string args string want string }{{ name: "normal sentence", args: "What a perfect, beautiful sentence", want: "What a perfect, beautiful sentence", }, { name: "has unnecessary space before and after sentence", args: " I'm surrounded with spaces ", want: "I'm surrounded with spaces", }, { name: "has unnecessary spaces in middle of sentence", args: "I'm hollow inside", want: "I'm hollow inside", }, { name: "has unnecessary new line in middle of sentence", args: "I'm broken \n\n\ninside", want: "I'm broken inside", }, { name: "has unnecessary new line and spaces everywhere", args: " I'm hollow broken\n\n\n\nand surrounded by spaces ", want: "I'm hollow broken and surrounded by spaces", }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := normalizeSpace(tt.args); got != tt.want { t.Errorf("normalizeSpace() = %v, want %v", got, tt.want) } }) } } func Test_isURLValid(t *testing.T) { tests := []struct { name string args string want bool }{{ name: "valid URL", args: "https://www.google.com", want: true, }, { name: "valid localhost URL", args: "http://localhost:8080", want: true, }, { name: "valid non-HTTP URL", args: "ftp://www.example.com/storage", want: true, }, { name: "invalid URL", args: "https:/www.google.com", want: false, }, { name: "hash URL", args: "#some-awesome-heading", want: false, }, { name: "relative URL", args: "/page/contact", want: false, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isURLValid(tt.args); got != tt.want { t.Errorf("isURLValid() = %v, want %v", got, tt.want) } }) } } func Test_parseStrIndices(t *testing.T) { tests := []struct { name string args []string want []int wantErr bool }{{ name: "single number", args: []string{"1"}, want: []int{1}, wantErr: false, }, { name: "multiple number", args: []string{"1", "2", "3"}, want: []int{1, 2, 3}, wantErr: false, }, { name: "single ranged number", args: []string{"1-5"}, want: []int{1, 2, 3, 4, 5}, wantErr: false, }, { name: "multiple ranged number", args: []string{"1-5", "8-9"}, want: []int{1, 2, 3, 4, 5, 8, 9}, wantErr: false, }, { name: "mixed single and ranged number", args: []string{"1-5", "8-9", "11", "12"}, want: []int{1, 2, 3, 4, 5, 8, 9, 11, 12}, wantErr: false, }, { name: "invalid number", args: []string{"AAA"}, want: nil, wantErr: true, }, { name: "mixed number and string", args: []string{"1", "2", "A"}, want: nil, wantErr: true, }, { name: "reversed ranged number", args: []string{"5-1"}, want: nil, wantErr: true, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseStrIndices(tt.args) if (err != nil) != tt.wantErr { t.Errorf("parseStrIndices() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("parseStrIndices() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: internal/cmd/version.go ================================================ package cmd import ( "github.com/go-shiori/shiori/internal/model" "github.com/spf13/cobra" ) func newVersionCommand() *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Output the shiori version", Run: newVersionCommandHandler(), } return cmd } func newVersionCommandHandler() func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) { cmd.Printf("Shiori version %s (build %s) at %s\n", model.BuildVersion, model.BuildCommit, model.BuildDate) } } ================================================ FILE: internal/config/config.go ================================================ package config import ( "bufio" "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/gofrs/uuid/v5" "github.com/sethvargo/go-envconfig" "github.com/sirupsen/logrus" ) // readDotEnv reads the configuration from variables in a .env file (only for contributing) func readDotEnv(logger *logrus.Logger) map[string]string { result := make(map[string]string) file, err := os.Open(".env") if err != nil { return result } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "#") { continue } keyval := strings.SplitN(line, "=", 2) if len(keyval) != 2 { logger.WithField("line", line).Warn("invalid line in .env file") continue } result[keyval[0]] = keyval[1] } if err := scanner.Err(); err != nil { logger.WithError(err).Fatal("error reading dotenv") } return result } type HttpConfig struct { Enabled bool `env:"HTTP_ENABLED,default=True"` Port int `env:"HTTP_PORT,default=8080"` Address string `env:"HTTP_ADDRESS,default=:"` RootPath string `env:"HTTP_ROOT_PATH,default=/"` AccessLog bool `env:"HTTP_ACCESS_LOG,default=True"` ServeWebUI bool `env:"HTTP_SERVE_WEB_UI,default=True"` ServeWebUIV2 bool `env:"HTTP_SERVE_WEB_UI_V2,default=False"` ServeSwagger bool `env:"HTTP_SERVE_SWAGGER,default=False"` SecretKey []byte `env:"HTTP_SECRET_KEY"` // Fiber Specific BodyLimit int `env:"HTTP_BODY_LIMIT,default=1024"` ReadTimeout time.Duration `env:"HTTP_READ_TIMEOUT,default=10s"` WriteTimeout time.Duration `env:"HTTP_WRITE_TIMEOUT,default=10s"` IDLETimeout time.Duration `env:"HTTP_IDLE_TIMEOUT,default=10s"` DisableKeepAlive bool `env:"HTTP_DISABLE_KEEP_ALIVE,default=true"` DisablePreParseMultipartForm bool `env:"HTTP_DISABLE_PARSE_MULTIPART_FORM,default=true"` SSOProxyAuth bool `env:"SSO_PROXY_AUTH_ENABLED,default=false"` SSOProxyAuthHeaderName string `env:"SSO_PROXY_AUTH_HEADER_NAME,default=Remote-User"` SSOProxyAuthTrusted []string `env:"SSO_PROXY_AUTH_TRUSTED,default=10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7"` } // SetDefaults sets the default values for the configuration func (c *HttpConfig) SetDefaults(logger *logrus.Logger) { // Set a random secret key if not set if len(c.SecretKey) == 0 { logger.Warn("SHIORI_HTTP_SECRET_KEY is not set, using random value. This means that all sessions will be invalidated on server restart.") randomUUID, err := uuid.NewV4() if err != nil { logger.WithError(err).Fatal("couldn't generate a random UUID") } c.SecretKey = []byte(randomUUID.String()) } } func (c *HttpConfig) IsValid() error { if !strings.HasSuffix(c.RootPath, "/") { return fmt.Errorf("root path should end with a slash") } if c.ServeWebUIV2 && !c.ServeWebUI { return fmt.Errorf("you need to enable serving the Web UI to use the experimental Web UI v2") } return nil } type DatabaseConfig struct { DBMS string `env:"DBMS"` // Deprecated // DBMS requires more environment variables. Check the database package for more information. URL string `env:"DATABASE_URL"` } type StorageConfig struct { DataDir string `env:"DIR"` // Using DIR to be backwards compatible with the old config } type Config struct { Hostname string `env:"HOSTNAME,required"` Development bool `env:"DEVELOPMENT,default=False"` LogLevel string // Set only from the CLI flag Database *DatabaseConfig Storage *StorageConfig Http *HttpConfig } // SetDefaults sets the default values for the configuration func (c Config) SetDefaults(logger *logrus.Logger, portableMode bool) { // Set the default storage directory if not set, setting also the database url for // sqlite3 if that engine is used if c.Storage.DataDir == "" { var err error c.Storage.DataDir, err = getStorageDirectory(portableMode) if err != nil { logger.WithError(err).Fatal("couldn't determine the data directory") } } // Set default database url if not set if c.Database.DBMS == "" && c.Database.URL == "" { c.Database.URL = fmt.Sprintf("sqlite:///%s?_txlock=immediate", filepath.Join(c.Storage.DataDir, "shiori.db")) } c.Http.SetDefaults(logger) } func (c *Config) DebugConfiguration(logger *logrus.Logger) { logger.Debug("Configuration:") logger.Debugf(" SHIORI_HOSTNAME: %s", c.Hostname) logger.Debugf(" SHIORI_DEVELOPMENT: %t", c.Development) logger.Debugf(" SHIORI_DATABASE_URL: %s", c.Database.URL) logger.Debugf(" SHIORI_DBMS: %s", c.Database.DBMS) logger.Debugf(" SHIORI_DIR: %s", c.Storage.DataDir) logger.Debugf(" SHIORI_HTTP_ENABLED: %t", c.Http.Enabled) logger.Debugf(" SHIORI_HTTP_PORT: %d", c.Http.Port) logger.Debugf(" SHIORI_HTTP_ADDRESS: %s", c.Http.Address) logger.Debugf(" SHIORI_HTTP_ROOT_PATH: %s", c.Http.RootPath) logger.Debugf(" SHIORI_HTTP_ACCESS_LOG: %t", c.Http.AccessLog) logger.Debugf(" SHIORI_HTTP_SERVE_WEB_UI: %t", c.Http.ServeWebUI) logger.Debugf(" SHIORI_HTTP_SERVE_WEB_UI_V2: %t", c.Http.ServeWebUIV2) logger.Debugf(" SHIORI_HTTP_SECRET_KEY: %d characters", len(c.Http.SecretKey)) logger.Debugf(" SHIORI_HTTP_BODY_LIMIT: %d", c.Http.BodyLimit) logger.Debugf(" SHIORI_HTTP_READ_TIMEOUT: %s", c.Http.ReadTimeout) logger.Debugf(" SHIORI_HTTP_WRITE_TIMEOUT: %s", c.Http.WriteTimeout) logger.Debugf(" SHIORI_HTTP_IDLE_TIMEOUT: %s", c.Http.IDLETimeout) logger.Debugf(" SHIORI_HTTP_DISABLE_KEEP_ALIVE: %t", c.Http.DisableKeepAlive) logger.Debugf(" SHIORI_HTTP_DISABLE_PARSE_MULTIPART_FORM: %t", c.Http.DisablePreParseMultipartForm) logger.Debugf(" SHIORI_SSO_PROXY_AUTH_ENABLED: %t", c.Http.SSOProxyAuth) logger.Debugf(" SHIORI_SSO_PROXY_AUTH_HEADER_NAME: %s", c.Http.SSOProxyAuthHeaderName) logger.Debugf(" SHIORI_SSO_PROXY_AUTH_TRUSTED: %v", c.Http.SSOProxyAuthTrusted) } func (c *Config) IsValid() error { if err := c.Http.IsValid(); err != nil { return fmt.Errorf("http configuration is invalid: %w", err) } return nil } // ParseServerConfiguration parses the configuration from the enabled lookupers func ParseServerConfiguration(ctx context.Context, logger *logrus.Logger) *Config { var cfg Config lookupers := envconfig.MultiLookuper( envconfig.MapLookuper(map[string]string{"HOSTNAME": os.Getenv("HOSTNAME")}), envconfig.MapLookuper(readDotEnv(logger)), envconfig.PrefixLookuper("SHIORI_", envconfig.OsLookuper()), ) if err := envconfig.ProcessWith(ctx, &envconfig.Config{ Target: &cfg, Lookuper: lookupers, }); err != nil { logger.WithError(err).Fatal("Error parsing configuration") } return &cfg } ================================================ FILE: internal/config/config_test.go ================================================ package config import ( "context" "os" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHostnameVariable(t *testing.T) { os.Setenv("HOSTNAME", "test_hostname") defer os.Unsetenv("HOSTNAME") log := logrus.New() cfg := ParseServerConfiguration(context.TODO(), log) require.Equal(t, "test_hostname", cfg.Hostname) } // TestBackwardsCompatibility tests that the old environment variables changed from 1.5.5 onwards // are still supported and working with the new configuration system. func TestBackwardsCompatibility(t *testing.T) { for _, env := range []struct { env string want string eval func(t *testing.T, cfg *Config) }{ {"HOSTNAME", "test_hostname", func(t *testing.T, cfg *Config) { require.Equal(t, "test_hostname", cfg.Hostname) }}, {"SHIORI_DIR", "test", func(t *testing.T, cfg *Config) { require.Equal(t, "test", cfg.Storage.DataDir) }}, {"SHIORI_DBMS", "test", func(t *testing.T, cfg *Config) { require.Equal(t, "test", cfg.Database.DBMS) }}, } { t.Run(env.env, func(t *testing.T) { os.Setenv(env.env, env.want) t.Cleanup(func() { os.Unsetenv(env.env) }) log := logrus.New() cfg := ParseServerConfiguration(context.Background(), log) env.eval(t, cfg) }) } } func TestReadDotEnv(t *testing.T) { log := logrus.New() for _, testCase := range []struct { name string line string env map[string]string }{ {"empty", "", map[string]string{}}, {"comment", "# comment", map[string]string{}}, {"ignore invalid lines", "invalid line", map[string]string{}}, {"single variable", "SHIORI_HTTP_PORT=9999", map[string]string{"SHIORI_HTTP_PORT": "9999"}}, {"multiple variable", "SHIORI_HTTP_PORT=9999\nSHIORI_HTTP_SECRET_KEY=123123", map[string]string{"SHIORI_HTTP_PORT": "9999", "SHIORI_HTTP_SECRET_KEY": "123123"}}, } { t.Run(testCase.name, func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) require.NoError(t, os.Chdir(tmpDir)) // Write the .env file in the temporary directory handler, err := os.OpenFile(".env", os.O_CREATE|os.O_WRONLY, 0655) require.NoError(t, err) handler.Write([]byte(testCase.line + "\n")) handler.Close() e := readDotEnv(log) require.Equal(t, testCase.env, e) }) } t.Run("no file", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) require.NoError(t, os.Chdir(tmpDir)) e := readDotEnv(log) require.Equal(t, map[string]string{}, e) }) } func TestConfigSetDefaults(t *testing.T) { log := logrus.New() cfg := ParseServerConfiguration(context.TODO(), log) cfg.SetDefaults(log, false) require.NotEmpty(t, cfg.Http.SecretKey) require.NotEmpty(t, cfg.Storage.DataDir) require.NotEmpty(t, cfg.Database.URL) } func TestConfigIsValid(t *testing.T) { log := logrus.New() t.Run("valid configuration", func(t *testing.T) { cfg := ParseServerConfiguration(context.TODO(), log) cfg.SetDefaults(log, false) require.NoError(t, cfg.IsValid()) }) t.Run("invalid http root path", func(t *testing.T) { cfg := ParseServerConfiguration(context.TODO(), log) cfg.Http.RootPath = "/invalid" require.Error(t, cfg.IsValid()) }) } ================================================ FILE: internal/config/storage.go ================================================ package config import ( "fmt" "os" "path/filepath" gap "github.com/muesli/go-app-paths" ) func getStorageDirectory(portableMode bool) (string, error) { // If in portable mode, uses directory of executable if portableMode { exePath, err := os.Executable() if err != nil { return "", err } exeDir := filepath.Dir(exePath) return filepath.Join(exeDir, "shiori-data"), nil } // Try to use platform specific app path userScope := gap.NewScope(gap.User, "shiori") dataDir, err := userScope.DataPath("") if err == nil { return dataDir, nil } return "", fmt.Errorf("couldn't determine the data directory") } ================================================ FILE: internal/core/core.go ================================================ package core import "github.com/go-shiori/shiori/internal/model" var userAgent = "Shiori/" + model.BuildVersion + " (" + model.BuildCommit + ") (+https://github.com/go-shiori/shiori)" ================================================ FILE: internal/core/download.go ================================================ package core import ( "io" "net/http" "time" ) var httpClient = &http.Client{Timeout: time.Minute} // DownloadBookmark downloads bookmarked page from specified URL. // Return response body, make sure to close it later. func DownloadBookmark(url string) (io.ReadCloser, string, error) { // Prepare download request req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, "", err } // Send download request req.Header.Set("User-Agent", userAgent) resp, err := httpClient.Do(req) if err != nil { return nil, "", err } // Get content type contentType := resp.Header.Get("Content-Type") return resp.Body, contentType, nil } ================================================ FILE: internal/core/ebook.go ================================================ package core import ( "os" fp "path/filepath" "strconv" "strings" epub "github.com/go-shiori/go-epub" "github.com/go-shiori/shiori/internal/model" "github.com/pkg/errors" ) // GenerateEbook receives a `ProcessRequest` and generates an ebook file in the destination path specified. // The destination path `dstPath` should include file name with ".epub" extension // The bookmark model will be used to update the UI based on whether this function is successful or not. func GenerateEbook(deps model.Dependencies, req ProcessRequest, dstPath string) (book model.BookmarkDTO, err error) { book = req.Bookmark // Make sure bookmark ID is defined if book.ID == 0 { return book, errors.New("bookmark ID is not valid") } // Get current state of bookmark cheak archive and thumb strID := strconv.Itoa(book.ID) bookmarkThumbnailPath := model.GetThumbnailPath(&book) bookmarkArchivePath := model.GetArchivePath(&book) if deps.Domains().Storage().FileExists(bookmarkThumbnailPath) { book.ImageURL = fp.Join("/", "bookmark", strID, "thumb") } if deps.Domains().Storage().FileExists(bookmarkArchivePath) { book.HasArchive = true } // This function create ebook from reader mode of bookmark so // we can't create ebook from PDF so we return error here if bookmark is a pdf contentType := req.ContentType if strings.Contains(contentType, "application/pdf") { return book, errors.New("can't create ebook for pdf") } // Create temporary epub file tmpFile, err := os.CreateTemp("", "ebook") if err != nil { return book, errors.Wrap(err, "can't create temporary EPUB file") } defer os.Remove(tmpFile.Name()) // Create last line of ebook lastline := `

Generated By Shiori From This Page

` // Create ebook ebook, err := epub.NewEpub(book.Title) if err != nil { return book, errors.Wrap(err, "can't create EPUB") } ebook.SetTitle(book.Title) ebook.SetAuthor(book.Author) if deps.Domains().Storage().FileExists(bookmarkThumbnailPath) { // TODO: Use `deps.Domains.Storage` to retrieve the file. absoluteCoverPath := fp.Join(deps.Config().Storage.DataDir, bookmarkThumbnailPath) coverPath, _ := ebook.AddImage(absoluteCoverPath, "cover.jpg") ebook.SetCover(coverPath, "") } ebook.SetDescription(book.Excerpt) _, err = ebook.AddSection(`

`+book.Title+`

`+book.HTML+lastline, book.Title, "", "") if err != nil { return book, errors.Wrap(err, "can't add ebook Section") } ebook.EmbedImages() err = ebook.Write(tmpFile.Name()) if err != nil { return book, errors.Wrap(err, "can't create ebook file") } defer tmpFile.Close() // If everything go well we move ebook to dstPath err = deps.Domains().Storage().WriteFile(dstPath, tmpFile) if err != nil { return book, errors.Wrap(err, "failed move ebook to destination") } book.HasEbook = true return book, nil } ================================================ FILE: internal/core/ebook_test.go ================================================ package core_test import ( "context" "os" fp "path/filepath" "testing" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/stretchr/testify/assert" ) func TestGenerateEbook(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("Successful ebook generate", func(t *testing.T) { t.Run("valid bookmarkId that return HasEbook true", func(t *testing.T) { dstFile := "/ebook/1.epub" tmpDir := t.TempDir() deps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir))) mockRequest := core.ProcessRequest{ Bookmark: model.BookmarkDTO{ ID: 1, Title: "Example Bookmark", HTML: "Example HTML", HasEbook: false, }, DataDir: tmpDir, ContentType: "text/html", } bookmark, err := core.GenerateEbook(deps, mockRequest, dstFile) assert.True(t, bookmark.HasEbook) assert.NoError(t, err) }) t.Run("ebook generate with valid BookmarkID EbookExist ImagePathExist ReturnWithHasEbookTrue", func(t *testing.T) { dstFile := "/ebook/2.epub" tmpDir := t.TempDir() deps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir))) bookmark := model.BookmarkDTO{ ID: 2, HasEbook: false, } mockRequest := core.ProcessRequest{ Bookmark: bookmark, DataDir: tmpDir, ContentType: "text/html", } // Create the thumbnail file imagePath := model.GetThumbnailPath(&bookmark) imagedirPath := fp.Dir(imagePath) deps.Domains().Storage().FS().MkdirAll(imagedirPath, os.ModePerm) file, err := deps.Domains().Storage().FS().Create(imagePath) if err != nil { t.Fatal(err) } defer file.Close() bookmark, err = core.GenerateEbook(deps, mockRequest, dstFile) expectedImagePath := string(fp.Separator) + fp.Join("bookmark", "2", "thumb") assert.NoError(t, err) assert.True(t, bookmark.HasEbook) assert.Equalf(t, expectedImagePath, bookmark.ImageURL, "Expected imageURL %s, but got %s", expectedImagePath, bookmark.ImageURL) }) t.Run("generate ebook valid BookmarkID EbookExist ReturnHasArchiveTrue", func(t *testing.T) { dstFile := "/ebook/3.epub" tmpDir := t.TempDir() deps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir))) bookmark := model.BookmarkDTO{ ID: 3, HasEbook: false, } mockRequest := core.ProcessRequest{ Bookmark: bookmark, DataDir: tmpDir, ContentType: "text/html", } // Create the archive file archivePath := model.GetArchivePath(&bookmark) archiveDirPath := fp.Dir(archivePath) deps.Domains().Storage().FS().MkdirAll(archiveDirPath, os.ModePerm) file, err := deps.Domains().Storage().FS().Create(archivePath) if err != nil { t.Fatal(err) } defer file.Close() bookmark, err = core.GenerateEbook(deps, mockRequest, fp.Join(dstFile, "1")) assert.True(t, bookmark.HasArchive) assert.NoError(t, err) }) }) t.Run("specific ebook generate case", func(t *testing.T) { t.Run("invalid bookmarkId that return Error", func(t *testing.T) { dstFile := "/ebook/0.epub" tmpDir := t.TempDir() mockRequest := core.ProcessRequest{ Bookmark: model.BookmarkDTO{ ID: 0, HasEbook: false, }, DataDir: tmpDir, ContentType: "text/html", } bookmark, err := core.GenerateEbook(deps, mockRequest, dstFile) assert.Equal(t, model.BookmarkDTO{ ID: 0, HasEbook: false, }, bookmark) assert.EqualError(t, err, "bookmark ID is not valid") }) t.Run("ebook exist return HasEbook true", func(t *testing.T) { dstFile := "/ebook/1.epub" tmpDir := t.TempDir() deps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), tmpDir))) bookmark := model.BookmarkDTO{ ID: 1, HasEbook: false, } mockRequest := core.ProcessRequest{ Bookmark: bookmark, DataDir: tmpDir, ContentType: "text/html", } // Create the ebook file ebookPath := model.GetEbookPath(&bookmark) ebookDirPath := fp.Dir(ebookPath) deps.Domains().Storage().FS().MkdirAll(ebookDirPath, os.ModePerm) file, err := deps.Domains().Storage().FS().Create(ebookPath) if err != nil { t.Fatal(err) } defer file.Close() bookmark, err = core.GenerateEbook(deps, mockRequest, dstFile) assert.True(t, bookmark.HasEbook) assert.NoError(t, err) }) t.Run("generate ebook valid BookmarkID RetuenError for PDF file", func(t *testing.T) { dstFile := "/ebook/1.epub" tmpDir := t.TempDir() mockRequest := core.ProcessRequest{ Bookmark: model.BookmarkDTO{ ID: 1, HasEbook: false, }, DataDir: tmpDir, ContentType: "application/pdf", } bookmark, err := core.GenerateEbook(deps, mockRequest, dstFile) assert.False(t, bookmark.HasEbook) assert.Error(t, err) assert.EqualError(t, err, "can't create ebook for pdf") }) }) } ================================================ FILE: internal/core/processing.go ================================================ package core import ( "bytes" "fmt" "image" "image/color" "image/draw" "image/jpeg" "io" "log" "math" "net/url" "os" fp "path/filepath" "strconv" "strings" "github.com/disintegration/imaging" "github.com/go-shiori/go-readability" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/warc" "github.com/pkg/errors" _ "golang.org/x/image/webp" // Add support for png _ "image/png" ) // ProcessRequest is the request for processing bookmark. type ProcessRequest struct { DataDir string Bookmark model.BookmarkDTO Content io.Reader ContentType string KeepTitle bool KeepExcerpt bool LogArchival bool } var ErrNoSupportedImageType = errors.New("unsupported image type") // ProcessBookmark process the bookmark and archive it if needed. // Return three values, is error fatal, and error value. func ProcessBookmark(deps model.Dependencies, req ProcessRequest) (book model.BookmarkDTO, isFatalErr bool, err error) { book = req.Bookmark contentType := req.ContentType // Make sure bookmark ID is defined if book.ID == 0 { return book, true, fmt.Errorf("bookmark ID is not valid") } // Split bookmark content so it can be processed several times archivalInput := bytes.NewBuffer(nil) readabilityInput := bytes.NewBuffer(nil) readabilityCheckInput := bytes.NewBuffer(nil) var multiWriter io.Writer if !strings.Contains(contentType, "text/html") { multiWriter = io.MultiWriter(archivalInput) } else { multiWriter = io.MultiWriter(archivalInput, readabilityInput, readabilityCheckInput) } _, err = io.Copy(multiWriter, req.Content) if err != nil { return book, false, fmt.Errorf("failed to process article: %v", err) } // If this is HTML, parse for readable content strID := strconv.Itoa(book.ID) imgPath := model.GetThumbnailPath(&book) var imageURLs []string if strings.Contains(contentType, "text/html") { isReadable := readability.Check(readabilityCheckInput) nurl, err := url.Parse(book.URL) if err != nil { return book, true, fmt.Errorf("failed to parse url: %v", err) } article, err := readability.FromReader(readabilityInput, nurl) if err != nil { return book, false, fmt.Errorf("failed to parse article: %v", err) } book.Author = article.Byline book.Content = article.TextContent book.HTML = article.Content // If title and excerpt doesnt have submitted value, use from article if !req.KeepTitle || book.Title == "" { book.Title = article.Title } if !req.KeepExcerpt || book.Excerpt == "" { book.Excerpt = article.Excerpt } // Sometimes article doesn't have any title, so make sure it is not empty if book.Title == "" { book.Title = book.URL } // Get image URL if article.Image != "" { imageURLs = append(imageURLs, article.Image) } else { deps.Domains().Storage().FS().Remove(imgPath) } if article.Favicon != "" { imageURLs = append(imageURLs, article.Favicon) } if !isReadable { book.Content = "" } book.HasContent = book.Content != "" book.ModifiedAt = "" } // Save article image to local disk for i, imageURL := range imageURLs { err = DownloadBookImage(deps, imageURL, imgPath) if err != nil && errors.Is(err, ErrNoSupportedImageType) { log.Printf("%s: %s", err, imageURL) if i == len(imageURLs)-1 { deps.Domains().Storage().FS().Remove(imgPath) } } if err != nil { log.Printf("File download not successful for image URL: %s", imageURL) continue } if err == nil { book.ImageURL = fp.Join("/", "bookmark", strID, "thumb") book.ModifiedAt = "" break } } // If needed, create ebook as well if book.CreateEbook { ebookPath := model.GetEbookPath(&book) req.Bookmark = book if strings.Contains(contentType, "application/pdf") { return book, false, errors.Wrap(err, "can't create ebook from pdf") } else { _, err = GenerateEbook(deps, req, ebookPath) if err != nil { return book, true, errors.Wrap(err, "failed to create ebook") } book.HasEbook = true book.ModifiedAt = "" } } // If needed, create offline archive as well if book.CreateArchive { tmpFile, err := os.CreateTemp("", "archive") if err != nil { return book, false, fmt.Errorf("failed to create temp archive: %v", err) } defer os.Remove(tmpFile.Name()) archivalRequest := warc.ArchivalRequest{ URL: book.URL, Reader: archivalInput, ContentType: contentType, UserAgent: userAgent, LogEnabled: req.LogArchival, } err = warc.NewArchive(archivalRequest, tmpFile.Name()) if err != nil { return book, false, fmt.Errorf("failed to create archive: %v", err) } dstPath := model.GetArchivePath(&book) err = deps.Domains().Storage().WriteFile(dstPath, tmpFile) if err != nil { return book, false, fmt.Errorf("failed move archive to destination `: %v", err) } book.HasArchive = true book.ModifiedAt = "" } return book, false, nil } func DownloadBookImage(deps model.Dependencies, url, dstPath string) error { // Fetch data from URL resp, err := httpClient.Get(url) if err != nil { return err } defer resp.Body.Close() // Make sure it's JPG or PNG image cp := resp.Header.Get("Content-Type") if !strings.Contains(cp, "image/jpeg") && !strings.Contains(cp, "image/pjpeg") && !strings.Contains(cp, "image/jpg") && !strings.Contains(cp, "image/webp") && !strings.Contains(cp, "image/png") { return ErrNoSupportedImageType } // At this point, the download has finished successfully. // Create tmpFile tmpFile, err := os.CreateTemp("", "image") if err != nil { return fmt.Errorf("failed to create temporary image file: %v", err) } defer os.Remove(tmpFile.Name()) // Parse image and process it. // If image is smaller than 600x400 or its ratio is less than 4:3, resize. // Else, save it as it is. img, _, err := image.Decode(resp.Body) if err != nil { return fmt.Errorf("failed to parse image %s: %v", url, err) } imgRect := img.Bounds() imgWidth := imgRect.Dx() imgHeight := imgRect.Dy() imgRatio := float64(imgWidth) / float64(imgHeight) if imgWidth >= 600 && imgHeight >= 400 && imgRatio > 1.3 { err = jpeg.Encode(tmpFile, img, nil) } else { // Create background bg := image.NewNRGBA(imgRect) draw.Draw(bg, imgRect, image.NewUniform(color.White), image.Point{}, draw.Src) draw.Draw(bg, imgRect, img, image.Point{}, draw.Over) bg = imaging.Fill(bg, 600, 400, imaging.Center, imaging.Lanczos) bg = imaging.Blur(bg, 150) bg = imaging.AdjustBrightness(bg, 30) // Create foreground fg := imaging.Fit(img, 600, 400, imaging.Lanczos) // Merge foreground and background bgRect := bg.Bounds() fgRect := fg.Bounds() fgPosition := image.Point{ X: bgRect.Min.X - int(math.Round(float64(bgRect.Dx()-fgRect.Dx())/2)), Y: bgRect.Min.Y - int(math.Round(float64(bgRect.Dy()-fgRect.Dy())/2)), } draw.Draw(bg, bgRect, fg, fgPosition, draw.Over) // Save to file err = jpeg.Encode(tmpFile, bg, nil) } if err != nil { return fmt.Errorf("failed to save image %s: %v", url, err) } err = deps.Domains().Storage().WriteFile(dstPath, tmpFile) if err != nil { return err } return nil } ================================================ FILE: internal/core/processing_test.go ================================================ package core_test import ( "bytes" "context" "os" fp "path/filepath" "testing" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDownloadBookImage(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("Download Images", func(t *testing.T) { t.Run("fails", func(t *testing.T) { // images is too small with unsupported format with a valid URL imageURL := "https://github.com/go-shiori/shiori/blob/master/internal/view/assets/res/apple-touch-icon-152x152.png" tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) dstFile := fp.Join(tmpDir, "image.png") // Act err = core.DownloadBookImage(deps, imageURL, dstFile) // Assert assert.EqualError(t, err, "unsupported image type") assert.False(t, deps.Domains().Storage().FileExists(dstFile)) }) t.Run("successful download image", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) require.NoError(t, os.Chdir(tmpDir)) // Arrange imageURL := "https://raw.githubusercontent.com/go-shiori/shiori/master/docs/assets/screenshots/cover.png" dstFile := "." + string(fp.Separator) + "cover.png" // Act err = core.DownloadBookImage(deps, imageURL, dstFile) // Assert assert.NoError(t, err) assert.True(t, deps.Domains().Storage().FileExists(dstFile)) }) t.Run("successful download medium size image", func(t *testing.T) { tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) require.NoError(t, os.Chdir(tmpDir)) // Arrange imageURL := "https://raw.githubusercontent.com/go-shiori/shiori/master/testdata/medium_image.png" dstFile := "." + string(fp.Separator) + "medium_image.png" // Act err = core.DownloadBookImage(deps, imageURL, dstFile) // Assert assert.NoError(t, err) assert.True(t, deps.Domains().Storage().FileExists(dstFile)) }) }) } func TestProcessBookmark(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("ProcessRequest with sucssesful result", func(t *testing.T) { tmpDir := t.TempDir() t.Run("Normal without image", func(t *testing.T) { bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "Example", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString("

This is an example article

") request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "text/html", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: true, } expected, _, _ := core.ProcessBookmark(deps, request) if expected.ID != bookmark.ID { t.Errorf("Unexpected ID: got %v, want %v", expected.ID, bookmark.ID) } if expected.URL != bookmark.URL { t.Errorf("Unexpected URL: got %v, want %v", expected.URL, bookmark.URL) } if expected.Title != bookmark.Title { t.Errorf("Unexpected Title: got %v, want %v", expected.Title, bookmark.Title) } if expected.Excerpt != bookmark.Excerpt { t.Errorf("Unexpected Excerpt: got %v, want %v", expected.Excerpt, bookmark.Excerpt) } }) t.Run("Normal with multipleimage", func(t *testing.T) { tmpDir := t.TempDir() html := `html

This is an example article

` bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "Example", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString(html) request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "text/html", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: true, } expected, _, _ := core.ProcessBookmark(deps, request) if expected.ID != bookmark.ID { t.Errorf("Unexpected ID: got %v, want %v", expected.ID, bookmark.ID) } if expected.URL != bookmark.URL { t.Errorf("Unexpected URL: got %v, want %v", expected.URL, bookmark.URL) } if expected.Title != bookmark.Title { t.Errorf("Unexpected Title: got %v, want %v", expected.Title, bookmark.Title) } if expected.Excerpt != bookmark.Excerpt { t.Errorf("Unexpected Excerpt: got %v, want %v", expected.Excerpt, bookmark.Excerpt) } }) t.Run("ProcessRequest sucssesful with multipleimage included favicon and Thumbnail ", func(t *testing.T) { tmpDir := t.TempDir() html := `html

This is an example article

` bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "Example", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString(html) request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "text/html", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: true, } expected, _, _ := core.ProcessBookmark(deps, request) assert.True(t, deps.Domains().Storage().FileExists(fp.Join("thumb", "1"))) if expected.ID != bookmark.ID { t.Errorf("Unexpected ID: got %v, want %v", expected.ID, bookmark.ID) } if expected.URL != bookmark.URL { t.Errorf("Unexpected URL: got %v, want %v", expected.URL, bookmark.URL) } if expected.Title != bookmark.Title { t.Errorf("Unexpected Title: got %v, want %v", expected.Title, bookmark.Title) } if expected.Excerpt != bookmark.Excerpt { t.Errorf("Unexpected Excerpt: got %v, want %v", expected.Excerpt, bookmark.Excerpt) } }) t.Run("ProcessRequest sucssesful with empty title ", func(t *testing.T) { tmpDir := t.TempDir() bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString("

This is an example article

") request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "text/html", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: true, } expected, _, _ := core.ProcessBookmark(deps, request) if expected.ID != bookmark.ID { t.Errorf("Unexpected ID: got %v, want %v", expected.ID, bookmark.ID) } if expected.URL != bookmark.URL { t.Errorf("Unexpected URL: got %v, want %v", expected.URL, bookmark.URL) } if expected.Title != bookmark.URL { t.Errorf("Unexpected Title: got %v, want %v", expected.Title, bookmark.Title) } if expected.Excerpt != bookmark.Excerpt { t.Errorf("Unexpected Excerpt: got %v, want %v", expected.Excerpt, bookmark.Excerpt) } }) t.Run("ProcessRequest sucssesful with empty Excerpt", func(t *testing.T) { tmpDir := t.TempDir() bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString("

This is an example article

") request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "text/html", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: false, } expected, _, _ := core.ProcessBookmark(deps, request) if expected.ID != bookmark.ID { t.Errorf("Unexpected ID: got %v, want %v", expected.ID, bookmark.ID) } if expected.URL != bookmark.URL { t.Errorf("Unexpected URL: got %v, want %v", expected.URL, bookmark.URL) } if expected.Title != bookmark.URL { t.Errorf("Unexpected Title: got %v, want %v", expected.Title, bookmark.Title) } if expected.Excerpt != bookmark.Excerpt { t.Errorf("Unexpected Excerpt: got %v, want %v", expected.Excerpt, bookmark.Excerpt) } }) t.Run("Specific case", func(t *testing.T) { tmpDir := t.TempDir() t.Run("ProcessRequest with ID zero", func(t *testing.T) { bookmark := model.BookmarkDTO{ ID: 0, URL: "https://example.com", Title: "Example", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString("

This is an example article

") request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "text/html", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: true, } _, isFatal, err := core.ProcessBookmark(deps, request) assert.Error(t, err) assert.Contains(t, err.Error(), "bookmark ID is not valid") assert.True(t, isFatal) }) t.Run("ProcessRequest that content type not zero", func(t *testing.T) { tmpDir := t.TempDir() bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "Example", Excerpt: "This is an example article", CreateEbook: true, CreateArchive: true, } content := bytes.NewBufferString("

This is an example article

") request := core.ProcessRequest{ Bookmark: bookmark, Content: content, ContentType: "application/pdf", DataDir: tmpDir, KeepTitle: true, KeepExcerpt: true, } _, _, err := core.ProcessBookmark(deps, request) assert.NoError(t, err) }) }) }) } ================================================ FILE: internal/core/url.go ================================================ package core import ( "fmt" nurl "net/url" "sort" "strings" ) // queryEncodeWithoutEmptyValues is a copy of `values.Encode` but checking if the queryparam // value is empty to prevent sending the = symbol empty which breaks in some servers. func queryEncodeWithoutEmptyValues(v nurl.Values) string { if v == nil { return "" } var buf strings.Builder keys := make([]string, 0, len(v)) for k := range v { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { vs := v[k] keyEscaped := nurl.QueryEscape(k) for _, v := range vs { if buf.Len() > 0 { buf.WriteByte('&') } buf.WriteString(keyEscaped) if v != "" { buf.WriteByte('=') buf.WriteString(nurl.QueryEscape(v)) } } } return buf.String() } // RemoveUTMParams removes the UTM parameters from URL. func RemoveUTMParams(url string) (string, error) { // Parse string URL tmp, err := nurl.Parse(url) if err != nil || tmp.Scheme == "" || tmp.Hostname() == "" { return url, fmt.Errorf("URL is not valid") } // Remove UTM queries queries := tmp.Query() for key := range queries { if strings.HasPrefix(key, "utm_") { queries.Del(key) } } tmp.RawQuery = queryEncodeWithoutEmptyValues(queries) return tmp.String(), nil } ================================================ FILE: internal/database/database.go ================================================ package database import ( "context" "database/sql" "fmt" "log" "net/url" "strings" "github.com/go-shiori/shiori/internal/model" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) // ErrNotFound is error returned when record is not found in database. var ErrNotFound = errors.New("not found") // ErrAlreadyExists is error returned when record already exists in database. var ErrAlreadyExists = errors.New("already exists") // Connect connects to database based on submitted database URL. func Connect(ctx context.Context, dbURL string) (model.DB, error) { dbU, err := url.Parse(dbURL) if err != nil { return nil, errors.Wrap(err, "failed to parse database URL") } switch dbU.Scheme { case "mysql": urlNoSchema := strings.Split(dbURL, "://")[1] return OpenMySQLDatabase(ctx, urlNoSchema) case "postgres": return OpenPGDatabase(ctx, dbURL) case "sqlite": return OpenSQLiteDatabase(ctx, dbU.Path[1:]) } return nil, fmt.Errorf("unsupported database scheme: %s", dbU.Scheme) } type dbbase struct { flavor sqlbuilder.Flavor reader *sqlx.DB writer *sqlx.DB } func (db *dbbase) Flavor() sqlbuilder.Flavor { return db.flavor } func (db *dbbase) ReaderDB() *sqlx.DB { return db.reader } func (db *dbbase) WriterDB() *sqlx.DB { return db.writer } func (db *dbbase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error { tx, err := db.writer.BeginTxx(ctx, nil) if err != nil { return errors.WithStack(err) } defer func() { if err := tx.Commit(); err != nil { log.Printf("error during commit: %s", err) } }() err = fn(tx) if err != nil { if err := tx.Rollback(); err != nil { log.Printf("error during rollback: %s", err) } return errors.WithStack(err) } return err } func (db *dbbase) GetContext(ctx context.Context, dest any, query string, args ...any) error { return db.reader.GetContext(ctx, dest, query, args...) } // Deprecated: Use SelectContext instead. func (db *dbbase) Select(dest any, query string, args ...any) error { return db.reader.Select(dest, query, args...) } func (db *dbbase) SelectContext(ctx context.Context, dest any, query string, args ...any) error { return db.reader.SelectContext(ctx, dest, query, args...) } func (db *dbbase) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { return db.writer.ExecContext(ctx, query, args...) } func (db *dbbase) MustBegin() *sqlx.Tx { return db.writer.MustBegin() } func NewDBBase(reader, writer *sqlx.DB, flavor sqlbuilder.Flavor) dbbase { return dbbase{ reader: reader, writer: writer, flavor: flavor, } } ================================================ FILE: internal/database/database_tags.go ================================================ package database import ( "context" "database/sql" "fmt" "github.com/go-shiori/shiori/internal/model" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" ) // GetTags returns a list of tags from the database. // If opts.WithBookmarkCount is true, the result will include the number of bookmarks for each tag. // If opts.BookmarkID is not 0, the result will include only the tags for the specified bookmark. // If opts.OrderBy is set, the result will be ordered by the specified column. func (db *dbbase) GetTags(ctx context.Context, opts model.DBListTagsOptions) ([]model.TagDTO, error) { sb := db.Flavor().NewSelectBuilder() sb.Select("t.id", "t.name") sb.From("tag t") // Treat the case where we want the bookmark count and filter by bookmark ID as a special case: // If we only want one of them, we can use a JOIN and GROUP BY. // If we want both, we need to use a subquery to get the count of bookmarks for each tag filtered // by bookmark ID. if opts.WithBookmarkCount && opts.BookmarkID == 0 { // Join with bookmark_tag and group by tag ID to get the count of bookmarks for each tag sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") sb.SelectMore("COUNT(bt.tag_id) AS bookmark_count") sb.GroupBy("t.id") } else if opts.BookmarkID > 0 { // If we want the bookmark count, we need to use a subquery to get the count of bookmarks for each tag if opts.WithBookmarkCount { sb.SelectMore( sb.BuilderAs( db.Flavor().NewSelectBuilder().Select("COUNT(bt2.tag_id)").From("bookmark_tag bt2").Where("bt2.tag_id = t.id"), "bookmark_count", ), ) } // Join with bookmark_tag and filter by bookmark ID to get the tags for a specific bookmark sb.JoinWithOption(sqlbuilder.RightJoin, "bookmark_tag bt", sb.And( "bt.tag_id = t.id", sb.Equal("bt.bookmark_id", opts.BookmarkID), ), ) sb.Where(sb.IsNotNull("t.id")) } // Add search condition if search term is provided if opts.Search != "" { // Note: Search and BookmarkID filtering are mutually exclusive as per requirements sb.Where(sb.Like("t.name", "%"+opts.Search+"%")) } if opts.OrderBy == model.DBTagOrderByTagName { sb.OrderBy("t.name") } query, args := sb.Build() query = db.ReaderDB().Rebind(query) tags := []model.TagDTO{} err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to get tags: %w", err) } return tags, nil } // AddTagToBookmark adds a tag to a bookmark func (db *dbbase) AddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error { // Insert the bookmark-tag association insertSb := db.Flavor().NewInsertBuilder() insertSb.InsertInto("bookmark_tag") insertSb.Cols("bookmark_id", "tag_id") insertSb.Values(bookmarkID, tagID) insertQuery, insertArgs := insertSb.Build() insertQuery = db.WriterDB().Rebind(insertQuery) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // First check if the association already exists using sqlbuilder selectSb := db.Flavor().NewSelectBuilder() selectSb.Select("1") selectSb.From("bookmark_tag") selectSb.Where( selectSb.And( selectSb.Equal("bookmark_id", bookmarkID), selectSb.Equal("tag_id", tagID), ), ) selectQuery, selectArgs := selectSb.Build() selectQuery = db.ReaderDB().Rebind(selectQuery) var exists int err := tx.QueryRowContext(ctx, selectQuery, selectArgs...).Scan(&exists) if err != nil && err != sql.ErrNoRows { return fmt.Errorf("failed to check if tag is already associated: %w", err) } // If it doesn't exist, insert it if err == sql.ErrNoRows { _, err = tx.ExecContext(ctx, insertQuery, insertArgs...) if err != nil { return fmt.Errorf("failed to add tag to bookmark: %w", err) } } return nil }); err != nil { return err } return nil } // RemoveTagFromBookmark removes a tag from a bookmark func (db *dbbase) RemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error { // Delete the bookmark-tag association deleteSb := db.Flavor().NewDeleteBuilder() deleteSb.DeleteFrom("bookmark_tag") deleteSb.Where( deleteSb.And( deleteSb.Equal("bookmark_id", bookmarkID), deleteSb.Equal("tag_id", tagID), ), ) query, args := deleteSb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to remove tag from bookmark: %w", err) } return nil }); err != nil { return err } return nil } // TagExists checks if a tag with the given ID exists in the database func (db *dbbase) TagExists(ctx context.Context, tagID int) (bool, error) { sb := db.Flavor().NewSelectBuilder() sb.Select("1") sb.From("tag") sb.Where(sb.Equal("id", tagID)) sb.Limit(1) query, args := sb.Build() query = db.ReaderDB().Rebind(query) var exists int err := db.ReaderDB().QueryRowContext(ctx, query, args...).Scan(&exists) if err != nil { if err == sql.ErrNoRows { return false, nil } return false, fmt.Errorf("failed to check if tag exists: %w", err) } return true, nil } // BookmarkExists checks if a bookmark with the given ID exists in the database func (db *dbbase) BookmarkExists(ctx context.Context, bookmarkID int) (bool, error) { sb := db.Flavor().NewSelectBuilder() sb.Select("1") sb.From("bookmark") sb.Where(sb.Equal("id", bookmarkID)) sb.Limit(1) query, args := sb.Build() query = db.ReaderDB().Rebind(query) var exists int err := db.ReaderDB().QueryRowContext(ctx, query, args...).Scan(&exists) if err != nil { if err == sql.ErrNoRows { return false, nil } return false, fmt.Errorf("failed to check if bookmark exists: %w", err) } return true, nil } ================================================ FILE: internal/database/database_tags_test.go ================================================ package database import ( "context" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testGetTagsFunction tests the GetTags function with various options func testGetTagsFunction(t *testing.T, db model.DB) { ctx := context.TODO() // Create test tags tags := []model.Tag{ {Name: "golang"}, {Name: "database"}, {Name: "testing"}, {Name: "web"}, } createdTags, err := db.CreateTags(ctx, tags...) require.NoError(t, err) require.Len(t, createdTags, 4) // Map tag names to IDs for easier reference tagIDsByName := make(map[string]int) for _, tag := range createdTags { tagIDsByName[tag.Name] = tag.ID } // Create bookmarks with different tag combinations bookmarks := []model.BookmarkDTO{ { URL: "https://golang.org", Title: "Go Language", Tags: []model.TagDTO{ {Tag: model.Tag{Name: "golang"}}, {Tag: model.Tag{Name: "web"}}, }, }, { URL: "https://postgresql.org", Title: "PostgreSQL", Tags: []model.TagDTO{ {Tag: model.Tag{Name: "database"}}, }, }, { URL: "https://sqlite.org", Title: "SQLite", Tags: []model.TagDTO{ {Tag: model.Tag{Name: "database"}}, {Tag: model.Tag{Name: "testing"}}, }, }, } // Save bookmarks var savedBookmarks []model.BookmarkDTO for _, bookmark := range bookmarks { result, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, result, 1) savedBookmarks = append(savedBookmarks, result[0]) } // Verify test data setup t.Run("VerifyTestData", func(t *testing.T) { // Check that all bookmarks were saved with their tags for i, bookmark := range savedBookmarks { assert.NotZero(t, bookmark.ID) assert.Len(t, bookmark.Tags, len(bookmarks[i].Tags)) } // Verify that the first bookmark has golang and web tags assert.Len(t, savedBookmarks[0].Tags, 2) tagNames := []string{savedBookmarks[0].Tags[0].Name, savedBookmarks[0].Tags[1].Name} assert.Contains(t, tagNames, "golang") assert.Contains(t, tagNames, "web") }) // Test 1: Get all tags without any options t.Run("GetAllTags", func(t *testing.T) { fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) // Should return all 4 tags assert.Len(t, fetchedTags, 4) // Verify all tag names are present tagNames := make(map[string]bool) for _, tag := range fetchedTags { tagNames[tag.Name] = true } for _, expectedTag := range tags { assert.True(t, tagNames[expectedTag.Name], "Tag %s should be present", expectedTag.Name) } }) // Test 2: Get tags with bookmark count t.Run("GetTagsWithBookmarkCount", func(t *testing.T) { fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ WithBookmarkCount: true, }) require.NoError(t, err) // Should return all 4 tags assert.Len(t, fetchedTags, 4) // Create a map of tag name to bookmark count tagCounts := make(map[string]int64) for _, tag := range fetchedTags { tagCounts[tag.Name] = tag.BookmarkCount } // Verify counts assert.Equal(t, int64(1), tagCounts["golang"]) assert.Equal(t, int64(2), tagCounts["database"]) assert.Equal(t, int64(1), tagCounts["testing"]) assert.Equal(t, int64(1), tagCounts["web"]) }) // Test 3: Get tags for a specific bookmark t.Run("GetTagsForBookmark", func(t *testing.T) { // Get tags for the first bookmark (Go Language with golang and web tags) fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: savedBookmarks[0].ID, }) require.NoError(t, err) // Should return 2 tags assert.Len(t, fetchedTags, 2) // Verify tag names tagNames := make(map[string]bool) for _, tag := range fetchedTags { tagNames[tag.Name] = true } assert.True(t, tagNames["golang"], "Tag 'golang' should be present") assert.True(t, tagNames["web"], "Tag 'web' should be present") }) // Test 4: Get tags for a specific bookmark with bookmark count t.Run("GetTagsForBookmarkWithCount", func(t *testing.T) { // Get tags for the third bookmark (SQLite with database and testing tags) fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: savedBookmarks[2].ID, WithBookmarkCount: true, }) require.NoError(t, err) // Should return 2 tags assert.Len(t, fetchedTags, 2) // Create a map of tag name to bookmark count tagCounts := make(map[string]int64) for _, tag := range fetchedTags { tagCounts[tag.Name] = tag.BookmarkCount } // Verify counts - database should have 2 bookmarks, testing should have 1 assert.Equal(t, int64(2), tagCounts["database"]) assert.Equal(t, int64(1), tagCounts["testing"]) }) // Test 5: Get tags ordered by name t.Run("GetTagsOrderedByName", func(t *testing.T) { fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ OrderBy: model.DBTagOrderByTagName, }) require.NoError(t, err) // Should return all 4 tags in alphabetical order assert.Len(t, fetchedTags, 4) // Verify order assert.Equal(t, "database", fetchedTags[0].Name) assert.Equal(t, "golang", fetchedTags[1].Name) assert.Equal(t, "testing", fetchedTags[2].Name) assert.Equal(t, "web", fetchedTags[3].Name) }) // Test 6: Get tags with search term t.Run("GetTagsWithSearch", func(t *testing.T) { // Search for tags containing "go" fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ Search: "go", }) require.NoError(t, err) // Should return only the golang tag assert.Len(t, fetchedTags, 1) assert.Equal(t, "golang", fetchedTags[0].Name) // Search for tags containing "a" fetchedTags, err = db.GetTags(ctx, model.DBListTagsOptions{ Search: "a", }) require.NoError(t, err) // Should return database and possibly other tags containing "a" assert.GreaterOrEqual(t, len(fetchedTags), 1) // Create a map of tag names for easier checking tagNames := make(map[string]bool) for _, tag := range fetchedTags { tagNames[tag.Name] = true } // Verify database is in the results assert.True(t, tagNames["database"], "Tag 'database' should be present") // Search for non-existent tag fetchedTags, err = db.GetTags(ctx, model.DBListTagsOptions{ Search: "nonexistent", }) require.NoError(t, err) assert.Len(t, fetchedTags, 0) }) // Test 7: Search and bookmark ID are mutually exclusive t.Run("SearchAndBookmarkIDMutuallyExclusive", func(t *testing.T) { // This test is just to document the behavior, as the validation happens at the model level // The database layer will prioritize the bookmark ID filter if both are provided fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ Search: "go", BookmarkID: savedBookmarks[0].ID, }) require.NoError(t, err) // Should return tags for the bookmark, not the search // The number of tags may vary depending on the database implementation assert.NotEmpty(t, fetchedTags, "Should return at least one tag for the bookmark") // Create a map of tag names for easier checking tagNames := make(map[string]bool) for _, tag := range fetchedTags { tagNames[tag.Name] = true } // Verify golang is in the results (it's associated with the first bookmark) assert.True(t, tagNames["golang"], "Tag 'golang' should be present") }) // Test 8: Get tags for a non-existent bookmark t.Run("GetTagsForNonExistentBookmark", func(t *testing.T) { fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: 9999, // Non-existent ID }) require.NoError(t, err) // Should return empty result assert.Empty(t, fetchedTags) }) // Test 9: Get tags for a bookmark with no tags t.Run("GetTagsForBookmarkWithNoTags", func(t *testing.T) { // Create a bookmark with no tags bookmarkWithNoTags := model.BookmarkDTO{ URL: "https://example.com", Title: "Example with no tags", } result, err := db.SaveBookmarks(ctx, true, bookmarkWithNoTags) require.NoError(t, err) require.Len(t, result, 1) // Get tags for this bookmark fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: result[0].ID, }) require.NoError(t, err) // Should return empty result assert.Empty(t, fetchedTags) }) // Test 10: Get tags with combined options (order + count) t.Run("GetTagsWithCombinedOptions", func(t *testing.T) { fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{ WithBookmarkCount: true, OrderBy: model.DBTagOrderByTagName, }) require.NoError(t, err) // Should return all 4 tags in alphabetical order with counts assert.Len(t, fetchedTags, 4) // Verify order and counts assert.Equal(t, "database", fetchedTags[0].Name) assert.Equal(t, int64(2), fetchedTags[0].BookmarkCount) assert.Equal(t, "golang", fetchedTags[1].Name) assert.Equal(t, int64(1), fetchedTags[1].BookmarkCount) assert.Equal(t, "testing", fetchedTags[2].Name) assert.Equal(t, int64(1), fetchedTags[2].BookmarkCount) assert.Equal(t, "web", fetchedTags[3].Name) assert.Equal(t, int64(1), fetchedTags[3].BookmarkCount) }) } // testTagBookmarkOperations tests the tag-bookmark relationship operations func testTagBookmarkOperations(t *testing.T, db model.DB) { ctx := context.TODO() // Create test data // 1. Create a test bookmark bookmark := model.BookmarkDTO{ URL: "https://example.com/tag-operations-test", Title: "Tag Operations Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID // 2. Create a test tag tag := model.Tag{ Name: "tag-operations-test", } createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Test BookmarkExists function t.Run("BookmarkExists", func(t *testing.T) { // Test with existing bookmark exists, err := db.BookmarkExists(ctx, bookmarkID) require.NoError(t, err) assert.True(t, exists, "Bookmark should exist") // Test with non-existent bookmark exists, err = db.BookmarkExists(ctx, 9999) require.NoError(t, err) assert.False(t, exists, "Non-existent bookmark should return false") }) // Test TagExists function t.Run("TagExists", func(t *testing.T) { // Test with existing tag exists, err := db.TagExists(ctx, tagID) require.NoError(t, err) assert.True(t, exists, "Tag should exist") // Test with non-existent tag exists, err = db.TagExists(ctx, 9999) require.NoError(t, err) assert.False(t, exists, "Non-existent tag should return false") }) // Test AddTagToBookmark function t.Run("AddTagToBookmark", func(t *testing.T) { // Add tag to bookmark err := db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was added by fetching tags for the bookmark tags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1) assert.Equal(t, tagID, tags[0].ID) assert.Equal(t, "tag-operations-test", tags[0].Name) // Test adding the same tag again (should not error) err = db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify no duplicate was created tags, err = db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1) }) // Test RemoveTagFromBookmark function t.Run("RemoveTagFromBookmark", func(t *testing.T) { // First ensure the tag is associated with the bookmark tags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1, "Tag should be associated with bookmark before removal test") // Remove tag from bookmark err = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was removed tags, err = db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) assert.Len(t, tags, 0, "Tag should be removed from bookmark") // Test removing a tag that's not associated (should not error) err = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Test removing a tag from a non-existent bookmark (should not error) err = db.RemoveTagFromBookmark(ctx, 9999, tagID) require.NoError(t, err) // Test removing a non-existent tag from a bookmark (should not error) err = db.RemoveTagFromBookmark(ctx, bookmarkID, 9999) require.NoError(t, err) }) // Test edge cases t.Run("EdgeCases", func(t *testing.T) { // Test adding a tag to a non-existent bookmark // This should not error at the database layer since we're not checking existence there err := db.AddTagToBookmark(ctx, 9999, tagID) // The test might fail depending on foreign key constraints in the database // If it fails, that's acceptable behavior, but we're not explicitly testing for it if err != nil { t.Logf("Adding tag to non-existent bookmark failed as expected: %v", err) } // Test adding a non-existent tag to a bookmark // This should not error at the database layer since we're not checking existence there err = db.AddTagToBookmark(ctx, bookmarkID, 9999) // The test might fail depending on foreign key constraints in the database // If it fails, that's acceptable behavior, but we're not explicitly testing for it if err != nil { t.Logf("Adding non-existent tag to bookmark failed as expected: %v", err) } }) } // testTagExists tests the TagExists function func testTagExists(t *testing.T, db model.DB) { ctx := context.TODO() // Create a test tag tag := model.Tag{ Name: "tag-exists-test", } createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Test with existing tag exists, err := db.TagExists(ctx, tagID) require.NoError(t, err) assert.True(t, exists, "Tag should exist") // Test with non-existent tag exists, err = db.TagExists(ctx, 9999) require.NoError(t, err) assert.False(t, exists, "Non-existent tag should return false") } // testBookmarkExists tests the BookmarkExists function func testBookmarkExists(t *testing.T, db model.DB) { ctx := context.TODO() // Create a test bookmark bookmark := model.BookmarkDTO{ URL: "https://example.com/bookmark-exists-test", Title: "Bookmark Exists Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID // Test with existing bookmark exists, err := db.BookmarkExists(ctx, bookmarkID) require.NoError(t, err) assert.True(t, exists, "Bookmark should exist") // Test with non-existent bookmark exists, err = db.BookmarkExists(ctx, 9999) require.NoError(t, err) assert.False(t, exists, "Non-existent bookmark should return false") } // testAddTagToBookmark tests the AddTagToBookmark function func testAddTagToBookmark(t *testing.T, db model.DB) { ctx := context.TODO() // Create test data bookmark := model.BookmarkDTO{ URL: "https://example.com/add-tag-test", Title: "Add Tag Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID tag := model.Tag{ Name: "add-tag-test", } createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Add tag to bookmark err = db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was added by fetching tags for the bookmark tags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1) assert.Equal(t, tagID, tags[0].ID) assert.Equal(t, "add-tag-test", tags[0].Name) // Test adding the same tag again (should not error) err = db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify no duplicate was created tags, err = db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1) } // testRemoveTagFromBookmark tests the RemoveTagFromBookmark function func testRemoveTagFromBookmark(t *testing.T, db model.DB) { ctx := context.TODO() // Create test data bookmark := model.BookmarkDTO{ URL: "https://example.com/remove-tag-test", Title: "Remove Tag Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID tag := model.Tag{ Name: "remove-tag-test", } createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Add tag to bookmark first err = db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was added tags, err := db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1, "Tag should be associated with bookmark before removal test") // Remove tag from bookmark err = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was removed tags, err = db.GetTags(ctx, model.DBListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) assert.Len(t, tags, 0, "Tag should be removed from bookmark") // Test removing a tag that's not associated (should not error) err = db.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Test removing a tag from a non-existent bookmark (should not error) err = db.RemoveTagFromBookmark(ctx, 9999, tagID) require.NoError(t, err) // Test removing a non-existent tag from a bookmark (should not error) err = db.RemoveTagFromBookmark(ctx, bookmarkID, 9999) require.NoError(t, err) } // testTagBookmarkEdgeCases tests edge cases for tag-bookmark operations func testTagBookmarkEdgeCases(t *testing.T, db model.DB) { ctx := context.TODO() // Create test data bookmark := model.BookmarkDTO{ URL: "https://example.com/edge-cases-test", Title: "Edge Cases Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID tag := model.Tag{ Name: "edge-cases-test", } createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Test adding a tag to a non-existent bookmark // This should not error at the database layer since we're not checking existence there err = db.AddTagToBookmark(ctx, 9999, tagID) // The test might fail depending on foreign key constraints in the database // If it fails, that's acceptable behavior, but we're not explicitly testing for it if err != nil { t.Logf("Adding tag to non-existent bookmark failed as expected: %v", err) } // Test adding a non-existent tag to a bookmark // This should not error at the database layer since we're not checking existence there err = db.AddTagToBookmark(ctx, bookmarkID, 9999) // The test might fail depending on foreign key constraints in the database // If it fails, that's acceptable behavior, but we're not explicitly testing for it if err != nil { t.Logf("Adding non-existent tag to bookmark failed as expected: %v", err) } } ================================================ FILE: internal/database/database_test.go ================================================ package database import ( "context" "testing" "time" "github.com/go-shiori/shiori/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type databaseTestCase func(t *testing.T, db model.DB) type testDatabaseFactory func(t *testing.T, ctx context.Context) (model.DB, error) func testDatabase(t *testing.T, dbFactory testDatabaseFactory) { tests := map[string]databaseTestCase{ // Bookmarks "testBookmarkAutoIncrement": testBookmarkAutoIncrement, "testCreateBookmark": testCreateBookmark, "testCreateBookmarkWithContent": testCreateBookmarkWithContent, "testCreateBookmarkTwice": testCreateBookmarkTwice, "testCreateBookmarkWithTag": testCreateBookmarkWithTag, "testCreateTwoDifferentBookmarks": testCreateTwoDifferentBookmarks, "testUpdateBookmark": testUpdateBookmark, "testUpdateBookmarkUpdatesModifiedTime": testUpdateBookmarkUpdatesModifiedTime, "testGetBoomarksWithTimeFilters": testGetBoomarksWithTimeFilters, "testUpdateBookmarkWithContent": testUpdateBookmarkWithContent, "testGetBookmark": testGetBookmark, "testGetBookmarkNotExistent": testGetBookmarkNotExistent, "testGetBookmarks": testGetBookmarks, "testGetBookmarksWithTags": testGetBookmarksWithTags, "testGetBookmarksWithSQLCharacters": testGetBookmarksWithSQLCharacters, "testGetBookmarksCount": testGetBookmarksCount, "testSaveBookmark": testSaveBookmark, "testBulkUpdateBookmarkTags": testBulkUpdateBookmarkTags, "testBookmarkExists": testBookmarkExists, // Tags "testCreateTag": testCreateTag, "testCreateTags": testCreateTags, "testTagExists": testTagExists, "testGetTags": testGetTags, "testGetTagsFunction": testGetTagsFunction, "testGetTag": testGetTag, "testGetTagNotExistent": testGetTagNotExistent, "testUpdateTag": testUpdateTag, "testRenameTag": testRenameTag, "testDeleteTag": testDeleteTag, "testDeleteTagNotExistent": testDeleteTagNotExistent, "testAddTagToBookmark": testAddTagToBookmark, "testRemoveTagFromBookmark": testRemoveTagFromBookmark, "testTagBookmarkEdgeCases": testTagBookmarkEdgeCases, "testTagBookmarkOperations": testTagBookmarkOperations, // Accounts "testCreateAccount": testCreateAccount, "testCreateDuplicateAccount": testCreateDuplicateAccount, "testDeleteAccount": testDeleteAccount, "testDeleteNonExistantAccount": testDeleteNonExistantAccount, "testUpdateAccount": testUpdateAccount, "testUpdateAccountDuplicateUser": testUpdateAccountDuplicateUser, "testGetAccount": testGetAccount, "testListAccounts": testListAccounts, "testListAccountsWithPassword": testListAccountsWithPassword, } for testName, testCase := range tests { t.Run(testName, func(tInner *testing.T) { ctx := context.TODO() db, err := dbFactory(t, ctx) require.NoError(tInner, err, "Error recreating database") testCase(tInner, db) }) } } func testBookmarkAutoIncrement(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") assert.Equal(t, 1, result[0].ID, "Saved bookmark must have ID %d", 1) book = model.BookmarkDTO{ URL: "https://github.com/go-shiori/obelisk", Title: "obelisk", } result, err = db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") assert.Equal(t, 2, result[0].ID, "Saved bookmark must have ID %d", 2) } func testCreateBookmark(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/obelisk", Title: "shiori", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") assert.Equal(t, 1, result[0].ID, "Saved bookmark must have an ID set") } func testCreateBookmarkWithContent(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/obelisk", Title: "shiori", Content: "Some content", HTML: "Some HTML content", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") books, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ IDs: []int{result[0].ID}, WithContent: true, }) assert.NoError(t, err, "Get bookmarks must not fail") assert.Len(t, books, 1) assert.Equal(t, 1, books[0].ID, "Saved bookmark must have an ID set") assert.Equal(t, book.Content, books[0].Content, "Saved bookmark must have content") assert.Equal(t, book.HTML, books[0].HTML, "Saved bookmark must have HTML") } func testCreateBookmarkWithTag(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/obelisk", Title: "shiori", Tags: []model.TagDTO{ { Tag: model.Tag{ Name: "test-tag", }, }, }, } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") assert.Equal(t, book.URL, result[0].URL) assert.Equal(t, book.Tags[0].Name, result[0].Tags[0].Name) } func testCreateBookmarkTwice(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") savedBookmark := result[0] savedBookmark.Title = "modified" _, err = db.SaveBookmarks(ctx, true, savedBookmark) assert.Error(t, err, "Save bookmarks must fail") } func testCreateTwoDifferentBookmarks(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } _, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save first bookmark must not fail") book = model.BookmarkDTO{ URL: "https://github.com/go-shiori/go-readability", Title: "go-readability", } _, err = db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save second bookmark must not fail") } func testUpdateBookmark(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") savedBookmark := result[0] savedBookmark.Title = "modified" result, err = db.SaveBookmarks(ctx, false, savedBookmark) assert.NoError(t, err, "Save bookmarks must not fail") assert.Equal(t, "modified", result[0].Title) assert.Equal(t, savedBookmark.ID, result[0].ID) } func testUpdateBookmarkWithContent(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/obelisk", Title: "shiori", Content: "Some content", HTML: "Some HTML content", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") updatedBook := result[0] updatedBook.Content = "Some updated content" updatedBook.HTML = "Some updated HTML content" _, err = db.SaveBookmarks(ctx, false, updatedBook) assert.NoError(t, err, "Save bookmarks must not fail") books, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ IDs: []int{result[0].ID}, WithContent: true, }) assert.NoError(t, err, "Get bookmarks must not fail") assert.Len(t, books, 1) assert.Equal(t, 1, books[0].ID, "Saved bookmark must have an ID set") assert.Equal(t, updatedBook.Content, books[0].Content, "Saved bookmark must have updated content") assert.Equal(t, updatedBook.HTML, books[0].HTML, "Saved bookmark must have updated HTML") } func testGetBookmark(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") savedBookmark, exists, err := db.GetBookmark(ctx, result[0].ID, "") assert.NoError(t, err, "Get bookmark should not fail") assert.True(t, exists, "Bookmark should exist") assert.Equal(t, result[0].ID, savedBookmark.ID, "Retrieved bookmark should be the same") assert.Equal(t, book.URL, savedBookmark.URL, "Retrieved bookmark should be the same") } func testGetBookmarkNotExistent(t *testing.T, db model.DB) { ctx := context.TODO() savedBookmark, exists, err := db.GetBookmark(ctx, 1, "") assert.NoError(t, err, "Get bookmark should not fail") assert.False(t, exists, "Bookmark should not exist") assert.Equal(t, model.BookmarkDTO{}, savedBookmark) } func testGetBookmarks(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } bookmarks, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") savedBookmark := bookmarks[0] results, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ Keyword: "go-shiori", }) assert.NoError(t, err, "Get bookmarks should not fail") assert.Len(t, results, 1, "results should contain one item") assert.Equal(t, savedBookmark.ID, results[0].ID, "bookmark should be the one saved") } func testGetBookmarksWithSQLCharacters(t *testing.T, db model.DB) { ctx := context.TODO() // _ := 0 book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } _, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") characters := []string{";", "%", "_", "\\", "\"", ":"} for _, char := range characters { t.Run("GetBookmarks/"+char, func(t *testing.T) { _, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ Keyword: char, }) assert.NoError(t, err, "Get bookmarks should not fail") }) t.Run("GetBookmarksCount/"+char, func(t *testing.T) { _, err := db.GetBookmarksCount(ctx, model.DBGetBookmarksOptions{ Keyword: char, }) assert.NoError(t, err, "Get bookmarks count should not fail") }) } } func testGetBookmarksWithTags(t *testing.T, db model.DB) { ctx := context.TODO() // Create test tags tags := []model.Tag{ {Name: "programming"}, {Name: "golang"}, {Name: "database"}, {Name: "testing"}, } createdTags, err := db.CreateTags(ctx, tags...) require.NoError(t, err) require.Len(t, createdTags, 4) // Create bookmarks with different tag combinations bookmarks := []model.BookmarkDTO{ { URL: "https://golang.org", Title: "Go Language", Tags: []model.TagDTO{ {Tag: model.Tag{Name: "programming"}}, {Tag: model.Tag{Name: "golang"}}, }, }, { URL: "https://postgresql.org", Title: "PostgreSQL", Tags: []model.TagDTO{ {Tag: model.Tag{Name: "programming"}}, {Tag: model.Tag{Name: "database"}}, }, }, { URL: "https://sqlite.org", Title: "SQLite", Tags: []model.TagDTO{ {Tag: model.Tag{Name: "database"}}, }, }, { URL: "https://example.com", Title: "No Tags Example", }, } // Save all bookmarks for _, bookmark := range bookmarks { results, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, results, 1) } tests := []struct { name string opts model.DBGetBookmarksOptions expectedCount int expectedTitles []string }{ { name: "single tag - programming", opts: model.DBGetBookmarksOptions{ Tags: []string{"programming"}, }, expectedCount: 2, expectedTitles: []string{"Go Language", "PostgreSQL"}, }, { name: "multiple tags - programming AND golang", opts: model.DBGetBookmarksOptions{ Tags: []string{"programming", "golang"}, }, expectedCount: 1, expectedTitles: []string{"Go Language"}, }, { name: "all tags using *", opts: model.DBGetBookmarksOptions{ Tags: []string{"*"}, }, expectedCount: 3, expectedTitles: []string{"Go Language", "PostgreSQL", "SQLite"}, }, { name: "exclude database tag", opts: model.DBGetBookmarksOptions{ ExcludedTags: []string{"database"}, }, expectedCount: 2, expectedTitles: []string{"Go Language", "No Tags Example"}, }, { name: "no tags only", opts: model.DBGetBookmarksOptions{ ExcludedTags: []string{"*"}, }, expectedCount: 1, expectedTitles: []string{"No Tags Example"}, }, { name: "non-existent tag", opts: model.DBGetBookmarksOptions{ Tags: []string{"nonexistent"}, }, expectedCount: 0, expectedTitles: []string{}, }, } t.Run("ensure tags are present", func(t *testing.T) { tags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) assert.Len(t, tags, 4) }) t.Run("ensure test data is correct", func(t *testing.T) { results, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{}) require.NoError(t, err) require.Len(t, results, 4) for _, book := range results { if book.Title == "No Tags Example" { assert.Empty(t, book.Tags) } else { assert.NotEmpty(t, book.Tags) } // Ensure tags contain their ID and name for _, tag := range book.Tags { assert.NotZero(t, tag.ID) assert.NotEmpty(t, tag.Name) } } }) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results, err := db.GetBookmarks(ctx, tt.opts) require.NoError(t, err) assert.Len(t, results, tt.expectedCount) // Check if all expected titles are present titles := make([]string, len(results)) for i, result := range results { titles[i] = result.Title } assert.ElementsMatch(t, tt.expectedTitles, titles) }) } } func testGetBookmarksCount(t *testing.T, db model.DB) { ctx := context.TODO() expectedCount := 1 book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } _, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") count, err := db.GetBookmarksCount(ctx, model.DBGetBookmarksOptions{ Keyword: "go-shiori", }) assert.NoError(t, err, "Get bookmarks count should not fail") assert.Equal(t, count, expectedCount, "count should be %d", expectedCount) } func testCreateTag(t *testing.T, db model.DB) { ctx := context.TODO() tag := model.Tag{Name: "shiori"} createdTags, err := db.CreateTags(ctx, tag) assert.NoError(t, err, "Save tag must not fail") assert.Len(t, createdTags, 1, "Should return one created tag") assert.Greater(t, createdTags[0].ID, 0, "Created tag should have a valid ID") assert.Equal(t, "shiori", createdTags[0].Name, "Created tag should have the correct name") } func testCreateTags(t *testing.T, db model.DB) { ctx := context.TODO() createdTags, err := db.CreateTags(ctx, model.Tag{Name: "shiori"}, model.Tag{Name: "shiori2"}) assert.NoError(t, err, "Save tag must not fail") assert.Len(t, createdTags, 2, "Should return two created tags") assert.Greater(t, createdTags[0].ID, 0, "First created tag should have a valid ID") assert.Greater(t, createdTags[1].ID, 0, "Second created tag should have a valid ID") assert.Equal(t, "shiori", createdTags[0].Name, "First created tag should have the correct name") assert.Equal(t, "shiori2", createdTags[1].Name, "Second created tag should have the correct name") } // ----------------- ACCOUNTS ----------------- func testCreateAccount(t *testing.T, db model.DB) { ctx := context.TODO() acc := model.Account{ Username: "testuser", Password: "testpass", Owner: true, } insertedAccount, err := db.CreateAccount(ctx, acc) assert.NoError(t, err, "Save account must not fail") assert.Equal(t, acc.Username, insertedAccount.Username, "Saved account must have an username set") assert.Equal(t, acc.Password, insertedAccount.Password, "Saved account must have a password set") assert.Equal(t, acc.Owner, insertedAccount.Owner, "Saved account must have an owner set") assert.NotEmpty(t, insertedAccount.ID, "Saved account must have an ID set") } func testDeleteAccount(t *testing.T, db model.DB) { ctx := context.TODO() acc := model.Account{ Username: "testuser", Password: "testpass", Owner: true, } storedAccount, err := db.CreateAccount(ctx, acc) assert.NoError(t, err, "Save account must not fail") err = db.DeleteAccount(ctx, storedAccount.ID) assert.NoError(t, err, "Delete account must not fail") _, exists, err := db.GetAccount(ctx, storedAccount.ID) assert.False(t, exists, "Account must not exist") assert.ErrorIs(t, err, ErrNotFound, "Get account must return not found error") } func testDeleteNonExistantAccount(t *testing.T, db model.DB) { ctx := context.TODO() err := db.DeleteAccount(ctx, model.DBID(99)) assert.ErrorIs(t, err, ErrNotFound, "Delete account must fail") } func testUpdateAccount(t *testing.T, db model.DB) { ctx := context.TODO() acc := model.Account{ Username: "testuser", Password: "testpass", Owner: true, Config: model.UserConfig{ ShowId: true, }, } account, err := db.CreateAccount(ctx, acc) require.Nil(t, err) require.NotNil(t, account) require.NotEmpty(t, account.ID) account, _, err = db.GetAccount(ctx, account.ID) require.Nil(t, err) t.Run("update", func(t *testing.T) { acc := model.Account{ ID: account.ID, Username: "asdlasd", Owner: false, Password: "another", Config: model.UserConfig{ ShowId: false, }, } err := db.UpdateAccount(ctx, acc) require.Nil(t, err) updatedAccount, exists, err := db.GetAccount(ctx, account.ID) require.NoError(t, err) require.True(t, exists) require.Equal(t, acc.Username, updatedAccount.Username) require.Equal(t, acc.Owner, updatedAccount.Owner) require.Equal(t, acc.Config, updatedAccount.Config) require.NotEqual(t, acc.Password, account.Password) }) } func testGetAccount(t *testing.T, db model.DB) { ctx := context.TODO() // Insert test accounts testAccounts := []model.Account{ {Username: "foo", Password: "bar", Owner: false}, {Username: "hello", Password: "world", Owner: false}, {Username: "foo_bar", Password: "foobar", Owner: true}, } for _, acc := range testAccounts { storedAcc, err := db.CreateAccount(ctx, acc) assert.Nil(t, err) // Successful case account, exists, err := db.GetAccount(ctx, storedAcc.ID) assert.Nil(t, err) assert.True(t, exists, "Expected account to exist") assert.Equal(t, storedAcc.Username, account.Username) } // Failed case account, exists, err := db.GetAccount(ctx, 99) assert.ErrorIs(t, err, ErrNotFound) assert.False(t, exists, "Expected account to exist") assert.Empty(t, account.Username) } func testListAccounts(t *testing.T, db model.DB) { ctx := context.TODO() // prepare database testAccounts := []model.Account{ {Username: "foo", Password: "bar", Owner: false}, {Username: "hello", Password: "world", Owner: false}, {Username: "foo_bar", Password: "foobar", Owner: true}, } for _, acc := range testAccounts { _, err := db.CreateAccount(ctx, acc) assert.Nil(t, err) } tests := []struct { name string options model.DBListAccountsOptions expected int }{ {"default", model.DBListAccountsOptions{}, 3}, {"with owner", model.DBListAccountsOptions{Owner: true}, 1}, {"with keyword", model.DBListAccountsOptions{Keyword: "foo"}, 2}, {"with keyword and owner", model.DBListAccountsOptions{Keyword: "hello", Owner: false}, 1}, {"with no result", model.DBListAccountsOptions{Keyword: "shiori"}, 0}, {"with username", model.DBListAccountsOptions{Username: "foo"}, 1}, {"with non-existent username", model.DBListAccountsOptions{Username: "non-existant"}, 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { accounts, err := db.ListAccounts(ctx, tt.options) assert.NoError(t, err) assert.Equal(t, tt.expected, len(accounts)) }) } } func testCreateDuplicateAccount(t *testing.T, db model.DB) { ctx := context.TODO() acc := model.Account{ Username: "testuser", Password: "testpass", Owner: false, } // Create first account _, err := db.CreateAccount(ctx, acc) assert.NoError(t, err, "First account creation must not fail") // Try to create account with same username _, err = db.CreateAccount(ctx, acc) assert.ErrorIs(t, err, ErrAlreadyExists, "Creating duplicate account must return ErrAlreadyExists") } func testUpdateAccountDuplicateUser(t *testing.T, db model.DB) { ctx := context.TODO() // Create first account acc1 := model.Account{ Username: "testuser1", Password: "testpass", Owner: false, } storedAcc1, err := db.CreateAccount(ctx, acc1) assert.NoError(t, err, "First account creation must not fail") // Create second account acc2 := model.Account{ Username: "testuser2", Password: "testpass", Owner: false, } storedAcc2, err := db.CreateAccount(ctx, acc2) assert.NoError(t, err, "Second account creation must not fail") // Try to update second account to have same username as first storedAcc2.Username = storedAcc1.Username err = db.UpdateAccount(ctx, *storedAcc2) assert.ErrorIs(t, err, ErrAlreadyExists, "Updating to duplicate username must return ErrAlreadyExists") } func testListAccountsWithPassword(t *testing.T, db model.DB) { ctx := context.TODO() _, err := db.CreateAccount(ctx, model.Account{ Username: "gopher", Password: "shiori", }) assert.Nil(t, err) storedAccounts, err := db.ListAccounts(ctx, model.DBListAccountsOptions{ WithPassword: true, }) require.NoError(t, err) for _, acc := range storedAccounts { require.NotEmpty(t, acc.Password) } } // TODO: Consider using `t.Parallel()` once we have automated database tests spawning databases using testcontainers. func testUpdateBookmarkUpdatesModifiedTime(t *testing.T, db model.DB) { ctx := context.TODO() book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } resultBook, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") updatedBook := resultBook[0] updatedBook.Title = "modified" updatedBook.ModifiedAt = "" time.Sleep(1 * time.Second) resultUpdatedBooks, err := db.SaveBookmarks(ctx, false, updatedBook) assert.NoError(t, err, "Save bookmarks must not fail") assert.NotEqual(t, resultBook[0].ModifiedAt, resultUpdatedBooks[0].ModifiedAt) assert.Equal(t, resultBook[0].CreatedAt, resultUpdatedBooks[0].CreatedAt) assert.Equal(t, resultBook[0].CreatedAt, resultBook[0].ModifiedAt) assert.NoError(t, err, "Get bookmarks must not fail") assert.Equal(t, updatedBook.Title, resultUpdatedBooks[0].Title, "Saved bookmark must have updated Title") } // TODO: Consider using `t.Parallel()` once we have automated database tests spawning databases using testcontainers. func testGetBoomarksWithTimeFilters(t *testing.T, db model.DB) { ctx := context.TODO() book1 := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori/one", Title: "Added First but Modified Last", } book2 := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori/second", Title: "Added Last but Modified First", } // create two new bookmark resultBook1, err := db.SaveBookmarks(ctx, true, book1) assert.NoError(t, err, "Save bookmarks must not fail") time.Sleep(1 * time.Second) resultBook2, err := db.SaveBookmarks(ctx, true, book2) assert.NoError(t, err, "Save bookmarks must not fail") // update those bookmarks updatedBook1 := resultBook1[0] updatedBook1.Title = "Added First but Modified Last Updated Title" updatedBook1.ModifiedAt = "" updatedBook2 := resultBook2[0] updatedBook2.Title = "Last Added but modified First Updated Title" updatedBook2.ModifiedAt = "" // modified bookmark2 first after one second modified bookmark1 resultUpdatedBook2, err := db.SaveBookmarks(ctx, false, updatedBook2) assert.NoError(t, err, "Save bookmarks must not fail") time.Sleep(1 * time.Second) resultUpdatedBook1, err := db.SaveBookmarks(ctx, false, updatedBook1) assert.NoError(t, err, "Save bookmarks must not fail") // get diffrent filteter combination booksOrderByLastAdded, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ IDs: []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID}, OrderMethod: 1, }) assert.NoError(t, err, "Get bookmarks must not fail") booksOrderByLastModified, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ IDs: []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID}, OrderMethod: 2, }) assert.NoError(t, err, "Get bookmarks must not fail") booksOrderById, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ IDs: []int{resultUpdatedBook1[0].ID, resultUpdatedBook2[0].ID}, OrderMethod: 0, }) assert.NoError(t, err, "Get bookmarks must not fail") // Check Last Added assert.Equal(t, booksOrderByLastAdded[0].Title, updatedBook2.Title) // Check Last Modified assert.Equal(t, booksOrderByLastModified[0].Title, updatedBook1.Title) // Second id should be 2 if order them by id assert.Equal(t, booksOrderById[1].ID, 2) } // Additional tag test functions func testGetTags(t *testing.T, db model.DB) { ctx := context.TODO() // Create initial tag to ensure there's at least one tag initialTag := model.Tag{Name: "initial-test-tag"} _, err := db.CreateTags(ctx, initialTag) require.NoError(t, err) // Create additional tags tags := []model.Tag{ {Name: "tag1"}, {Name: "tag2"}, {Name: "tag3"}, } createdTags, err := db.CreateTags(ctx, tags...) require.NoError(t, err) require.Len(t, createdTags, 3) // Fetch all tags fetchedTags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) require.GreaterOrEqual(t, len(fetchedTags), 4) // At least 3 new tags + 1 initial tag // Check that all expected tags are present tagNames := make(map[string]bool) for _, tag := range fetchedTags { tagNames[tag.Name] = true } assert.True(t, tagNames["tag1"], "Tag 'tag1' should be present") assert.True(t, tagNames["tag2"], "Tag 'tag2' should be present") assert.True(t, tagNames["tag3"], "Tag 'tag3' should be present") assert.True(t, tagNames["initial-test-tag"], "Tag 'initial-test-tag' should be present") } func testGetTag(t *testing.T, db model.DB) { ctx := context.TODO() // Create a tag tag := model.Tag{Name: "get-tag-test"} createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Get the tag fetchedTag, exists, err := db.GetTag(ctx, tagID) require.NoError(t, err) require.True(t, exists) assert.Equal(t, tagID, fetchedTag.ID) assert.Equal(t, tag.Name, fetchedTag.Name) } func testGetTagNotExistent(t *testing.T, db model.DB) { ctx := context.TODO() // Test non-existent tag nonExistentTag, exists, err := db.GetTag(ctx, 9999) require.NoError(t, err) require.False(t, exists) assert.Empty(t, nonExistentTag.Name) } func testUpdateTag(t *testing.T, db model.DB) { ctx := context.TODO() // Create a tag tag := model.Tag{Name: "update-tag-test"} createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) // Update the tag tagToUpdate := model.Tag{ ID: createdTags[0].ID, Name: "updated-tag", } err = db.UpdateTag(ctx, tagToUpdate) require.NoError(t, err) // Verify the tag was updated updatedTag, exists, err := db.GetTag(ctx, tagToUpdate.ID) require.NoError(t, err) require.True(t, exists) assert.Equal(t, "updated-tag", updatedTag.Name) } func testRenameTag(t *testing.T, db model.DB) { ctx := context.TODO() // Create a tag tag := model.Tag{Name: "rename-tag-test"} createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Rename the tag err = db.RenameTag(ctx, tagID, "renamed-tag") require.NoError(t, err) // Verify the tag was renamed renamedTag, exists, err := db.GetTag(ctx, tagID) require.NoError(t, err) require.True(t, exists) assert.Equal(t, "renamed-tag", renamedTag.Name) } func testDeleteTag(t *testing.T, db model.DB) { ctx := context.TODO() // Create a tag tag := model.Tag{Name: "delete-tag-test"} createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Delete the tag err = db.DeleteTag(ctx, tagID) require.NoError(t, err) // Verify the tag was deleted _, exists, err := db.GetTag(ctx, tagID) require.NoError(t, err) require.False(t, exists) } func testDeleteTagNotExistent(t *testing.T, db model.DB) { ctx := context.TODO() // Test deleting a non-existent tag err := db.DeleteTag(ctx, 9999) require.Error(t, err) assert.ErrorIs(t, err, ErrNotFound, "Error should be ErrNotFound") } func testSaveBookmark(t *testing.T, db model.DB) { ctx := context.TODO() t.Run("invalid_bookmark_id", func(t *testing.T) { bookmark := model.Bookmark{ ID: 0, // Invalid ID URL: "https://example.com", Title: "Example", } err := db.SaveBookmark(ctx, bookmark) require.Error(t, err) assert.Contains(t, err.Error(), "bookmark ID must be greater than 0") }) t.Run("empty_url", func(t *testing.T) { bookmark := model.Bookmark{ ID: 1, URL: "", // Empty URL Title: "Example", } err := db.SaveBookmark(ctx, bookmark) require.Error(t, err) assert.Contains(t, err.Error(), "URL must not be empty") }) t.Run("empty_title", func(t *testing.T) { bookmark := model.Bookmark{ ID: 1, URL: "https://example.com", Title: "", // Empty title } err := db.SaveBookmark(ctx, bookmark) require.Error(t, err) assert.Contains(t, err.Error(), "title must not be empty") }) t.Run("successful_update", func(t *testing.T) { // First create a bookmark bookmark := model.BookmarkDTO{ URL: "https://example.com", Title: "Example", } results, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) bookmarkID := results[0].ID // Now update it updatedBookmark := model.Bookmark{ ID: bookmarkID, URL: "https://updated-example.com", Title: "Updated Example", Excerpt: "Updated excerpt", Author: "Updated Author", Public: 1, // Use 1 for SQLite, should work for other DBs too } err = db.SaveBookmark(ctx, updatedBookmark) require.NoError(t, err) // Verify the bookmark was updated retrievedBookmark, exists, err := db.GetBookmark(ctx, bookmarkID, "") require.NoError(t, err) require.True(t, exists) assert.Equal(t, updatedBookmark.URL, retrievedBookmark.URL) assert.Equal(t, updatedBookmark.Title, retrievedBookmark.Title) assert.Equal(t, updatedBookmark.Excerpt, retrievedBookmark.Excerpt) assert.Equal(t, updatedBookmark.Author, retrievedBookmark.Author) assert.Equal(t, updatedBookmark.Public, retrievedBookmark.Public) }) } func testBulkUpdateBookmarkTags(t *testing.T, db model.DB) { ctx := context.TODO() // Create test bookmarks bookmark1 := model.BookmarkDTO{ URL: "https://example1.com", Title: "Example 1", } bookmark2 := model.BookmarkDTO{ URL: "https://example2.com", Title: "Example 2", } bookmark3 := model.BookmarkDTO{ URL: "https://example3.com", Title: "Example 3", } results1, err := db.SaveBookmarks(ctx, true, bookmark1) require.NoError(t, err) bookmark1ID := results1[0].ID results2, err := db.SaveBookmarks(ctx, true, bookmark2) require.NoError(t, err) bookmark2ID := results2[0].ID results3, err := db.SaveBookmarks(ctx, true, bookmark3) require.NoError(t, err) bookmark3ID := results3[0].ID // Create test tags tag1 := model.Tag{Name: "tag1-bulk-test"} tag2 := model.Tag{Name: "tag2-bulk-test"} tag3 := model.Tag{Name: "tag3-bulk-test"} tag4 := model.Tag{Name: "tag4-bulk-test"} createdTags, err := db.CreateTags(ctx, tag1, tag2, tag3, tag4) require.NoError(t, err) require.Len(t, createdTags, 4) tag1ID := createdTags[0].ID tag2ID := createdTags[1].ID tag3ID := createdTags[2].ID tag4ID := createdTags[3].ID t.Run("empty_bookmark_ids", func(t *testing.T) { err := db.BulkUpdateBookmarkTags(ctx, []int{}, []int{tag1ID, tag2ID}) require.NoError(t, err, "Empty bookmark IDs should not cause an error") }) t.Run("empty_tag_ids", func(t *testing.T) { err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{}) require.NoError(t, err, "Empty tag IDs should not cause an error") // Verify tags were removed bookmark, exists, err := db.GetBookmark(ctx, bookmark1ID, "") require.NoError(t, err) require.True(t, exists) assert.Empty(t, bookmark.Tags, "Tags should be empty after update with empty tag IDs") }) t.Run("non_existent_bookmark", func(t *testing.T) { nonExistentID := 9999 err := db.BulkUpdateBookmarkTags(ctx, []int{nonExistentID}, []int{tag1ID}) require.Error(t, err, "Non-existent bookmark ID should cause an error") assert.Contains(t, err.Error(), "some bookmarks do not exist") }) t.Run("non_existent_tag", func(t *testing.T) { nonExistentID := 9999 err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{nonExistentID}) require.Error(t, err, "Non-existent tag ID should cause an error") assert.Contains(t, err.Error(), "some tags do not exist") }) t.Run("multiple_non_existent_bookmarks", func(t *testing.T) { err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, 9998, 9999}, []int{tag1ID}) require.Error(t, err, "Multiple non-existent bookmark IDs should cause an error") assert.Contains(t, err.Error(), "some bookmarks do not exist") }) t.Run("multiple_non_existent_tags", func(t *testing.T) { err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID, 9998, 9999}) require.Error(t, err, "Multiple non-existent tag IDs should cause an error") assert.Contains(t, err.Error(), "some tags do not exist") }) t.Run("successful_update", func(t *testing.T) { // Update both bookmarks with both tags err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{tag1ID, tag2ID}) require.NoError(t, err, "Bulk update should succeed") // Verify bookmark1 has both tags bookmark1, exists, err := db.GetBookmark(ctx, bookmark1ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark1.Tags, 2, "Bookmark 1 should have 2 tags") // Verify bookmark2 has both tags bookmark2, exists, err := db.GetBookmark(ctx, bookmark2ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should have 2 tags") // Verify tag names tagNames := make(map[string]bool) for _, tag := range bookmark1.Tags { tagNames[tag.Name] = true } assert.True(t, tagNames[tag1.Name], "Bookmark 1 should have tag1") assert.True(t, tagNames[tag2.Name], "Bookmark 1 should have tag2") // Update with a single tag err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID}) require.NoError(t, err, "Update with single tag should succeed") // Verify bookmark1 now has only one tag bookmark1, exists, err = db.GetBookmark(ctx, bookmark1ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark1.Tags, 1, "Bookmark 1 should have 1 tag after update") assert.Equal(t, tag1.Name, bookmark1.Tags[0].Name, "Bookmark 1 should have tag1") // Verify bookmark2 still has both tags bookmark2, exists, err = db.GetBookmark(ctx, bookmark2ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should still have 2 tags") }) t.Run("multiple_updates", func(t *testing.T) { // First update err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark3ID}, []int{tag1ID, tag2ID}) require.NoError(t, err, "First update should succeed") // Verify bookmark3 has both tags bookmark3, exists, err := db.GetBookmark(ctx, bookmark3ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark3.Tags, 2, "Bookmark 3 should have 2 tags after first update") // Second update with different tags err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark3ID}, []int{tag3ID, tag4ID}) require.NoError(t, err, "Second update should succeed") // Verify bookmark3 now has the new tags and not the old ones bookmark3, exists, err = db.GetBookmark(ctx, bookmark3ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark3.Tags, 2, "Bookmark 3 should have 2 tags after second update") // Check tag names tagNames := make(map[string]bool) for _, tag := range bookmark3.Tags { tagNames[tag.Name] = true } assert.False(t, tagNames[tag1.Name], "Bookmark 3 should not have tag1 after second update") assert.False(t, tagNames[tag2.Name], "Bookmark 3 should not have tag2 after second update") assert.True(t, tagNames[tag3.Name], "Bookmark 3 should have tag3 after second update") assert.True(t, tagNames[tag4.Name], "Bookmark 3 should have tag4 after second update") }) t.Run("update_multiple_bookmarks_with_different_initial_tags", func(t *testing.T) { // Setup: bookmark1 has tag1, bookmark2 has tag1 and tag2 err := db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID}, []int{tag1ID}) require.NoError(t, err) err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark2ID}, []int{tag1ID, tag2ID}) require.NoError(t, err) // Verify initial state bookmark1, exists, err := db.GetBookmark(ctx, bookmark1ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark1.Tags, 1, "Bookmark 1 should have 1 tag initially") bookmark2, exists, err := db.GetBookmark(ctx, bookmark2ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should have 2 tags initially") // Update both bookmarks with tag3 and tag4 err = db.BulkUpdateBookmarkTags(ctx, []int{bookmark1ID, bookmark2ID}, []int{tag3ID, tag4ID}) require.NoError(t, err, "Bulk update should succeed") // Verify both bookmarks now have tag3 and tag4 only bookmark1, exists, err = db.GetBookmark(ctx, bookmark1ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark1.Tags, 2, "Bookmark 1 should have 2 tags after update") bookmark2, exists, err = db.GetBookmark(ctx, bookmark2ID, "") require.NoError(t, err) require.True(t, exists) assert.Len(t, bookmark2.Tags, 2, "Bookmark 2 should have 2 tags after update") // Check tag names for bookmark1 tagNames1 := make(map[string]bool) for _, tag := range bookmark1.Tags { tagNames1[tag.Name] = true } assert.False(t, tagNames1[tag1.Name], "Bookmark 1 should not have tag1 after update") assert.False(t, tagNames1[tag2.Name], "Bookmark 1 should not have tag2 after update") assert.True(t, tagNames1[tag3.Name], "Bookmark 1 should have tag3 after update") assert.True(t, tagNames1[tag4.Name], "Bookmark 1 should have tag4 after update") // Check tag names for bookmark2 tagNames2 := make(map[string]bool) for _, tag := range bookmark2.Tags { tagNames2[tag.Name] = true } assert.False(t, tagNames2[tag1.Name], "Bookmark 2 should not have tag1 after update") assert.False(t, tagNames2[tag2.Name], "Bookmark 2 should not have tag2 after update") assert.True(t, tagNames2[tag3.Name], "Bookmark 2 should have tag3 after update") assert.True(t, tagNames2[tag4.Name], "Bookmark 2 should have tag4 after update") }) } ================================================ FILE: internal/database/migrations/mysql/0000_system_create.up.sql ================================================ CREATE TABLE IF NOT EXISTS shiori_system( database_schema_version VARCHAR(12) NOT NULL DEFAULT '0.0.0' ); ================================================ FILE: internal/database/migrations/mysql/0000_system_insert.up.sql ================================================ INSERT INTO shiori_system(database_schema_version) VALUES('0.0.0'); ================================================ FILE: internal/database/migrations/mysql/0001_initial_account.up.sql ================================================ CREATE TABLE IF NOT EXISTS account( id INT(11) NOT NULL AUTO_INCREMENT, username VARCHAR(250) NOT NULL, password BINARY(80) NOT NULL, owner TINYINT(1) NOT NULL DEFAULT '0', config JSON NOT NULL DEFAULT ('{}'), PRIMARY KEY (id), UNIQUE KEY account_username_UNIQUE (username)) CHARACTER SET utf8mb4; ================================================ FILE: internal/database/migrations/mysql/0002_initial_bookmark.up.sql ================================================ CREATE TABLE IF NOT EXISTS bookmark( id INT(11) NOT NULL AUTO_INCREMENT, url TEXT NOT NULL, title TEXT NOT NULL, excerpt TEXT NOT NULL DEFAULT (''), author TEXT NOT NULL DEFAULT (''), public BOOLEAN NOT NULL DEFAULT 0, content MEDIUMTEXT NOT NULL DEFAULT (''), html MEDIUMTEXT NOT NULL DEFAULT (''), modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, has_content BOOLEAN NOT NULL DEFAULT 0, PRIMARY KEY(id), UNIQUE KEY bookmark_url_UNIQUE (url(255)), FULLTEXT (title, excerpt, content)) CHARACTER SET utf8mb4; ================================================ FILE: internal/database/migrations/mysql/0003_initial_tag.up.sql ================================================ CREATE TABLE IF NOT EXISTS tag( id INT(11) NOT NULL AUTO_INCREMENT, name VARCHAR(250) NOT NULL, PRIMARY KEY (id), UNIQUE KEY tag_name_UNIQUE (name)) CHARACTER SET utf8mb4; ================================================ FILE: internal/database/migrations/mysql/0004_initial_bookmark_tag.up.sql ================================================ CREATE TABLE IF NOT EXISTS bookmark_tag( bookmark_id INT(11) NOT NULL, tag_id INT(11) NOT NULL, PRIMARY KEY(bookmark_id, tag_id), KEY bookmark_tag_bookmark_id_FK (bookmark_id), KEY bookmark_tag_tag_id_FK (tag_id), CONSTRAINT bookmark_tag_bookmark_id_FK FOREIGN KEY (bookmark_id) REFERENCES bookmark (id), CONSTRAINT bookmark_tag_tag_id_FK FOREIGN KEY (tag_id) REFERENCES tag (id)) CHARACTER SET utf8mb4; ================================================ FILE: internal/database/migrations/mysql/0005_rename_to_created_at.up.sql ================================================ ALTER TABLE bookmark RENAME COLUMN modified to created_at; ================================================ FILE: internal/database/migrations/mysql/0006_change_created_at_settings.up.sql ================================================ ALTER TABLE bookmark MODIFY created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; ================================================ FILE: internal/database/migrations/mysql/0007_add_modified_at.up.sql ================================================ ALTER TABLE bookmark ADD COLUMN modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; ================================================ FILE: internal/database/migrations/mysql/0008_set_modified_at_equal_created_at.up.sql ================================================ UPDATE bookmark SET modified_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE created_at IS NOT NULL; ================================================ FILE: internal/database/migrations/mysql/0009_index_for_created_at.up.sql ================================================ CREATE INDEX idx_created_at ON bookmark (created_at); ================================================ FILE: internal/database/migrations/mysql/0010_index_for_modified_at.up.sql ================================================ CREATE INDEX idx_modified_at ON bookmark (modified_at); ================================================ FILE: internal/database/migrations/postgres/0000_system.up.sql ================================================ CREATE TABLE IF NOT EXISTS shiori_system( database_schema_version TEXT NOT NULL DEFAULT '0.0.0' ); INSERT INTO shiori_system(database_schema_version) VALUES('0.0.0'); ================================================ FILE: internal/database/migrations/postgres/0001_initial.up.sql ================================================ CREATE TABLE IF NOT EXISTS account( id SERIAL, username VARCHAR(250) NOT NULL, password BYTEA NOT NULL, owner BOOLEAN NOT NULL DEFAULT FALSE, config JSONB NOT NULL DEFAULT '{}', PRIMARY KEY (id), CONSTRAINT account_username_UNIQUE UNIQUE (username)); CREATE TABLE IF NOT EXISTS bookmark( id SERIAL, url TEXT NOT NULL, title TEXT NOT NULL, excerpt TEXT NOT NULL DEFAULT '', author TEXT NOT NULL DEFAULT '', public SMALLINT NOT NULL DEFAULT 0, content TEXT NOT NULL DEFAULT '', html TEXT NOT NULL DEFAULT '', modified TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, has_content BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY(id), CONSTRAINT bookmark_url_UNIQUE UNIQUE (url)); CREATE TABLE IF NOT EXISTS tag( id SERIAL, name VARCHAR(250) NOT NULL, PRIMARY KEY (id), CONSTRAINT tag_name_UNIQUE UNIQUE (name)); CREATE TABLE IF NOT EXISTS bookmark_tag( bookmark_id INT NOT NULL, tag_id INT NOT NULL, PRIMARY KEY(bookmark_id, tag_id), CONSTRAINT bookmark_tag_bookmark_id_FK FOREIGN KEY (bookmark_id) REFERENCES bookmark (id), CONSTRAINT bookmark_tag_tag_id_FK FOREIGN KEY (tag_id) REFERENCES tag (id)); CREATE INDEX IF NOT EXISTS bookmark_tag_bookmark_id_FK ON bookmark_tag (bookmark_id); CREATE INDEX IF NOT EXISTS bookmark_tag_tag_id_FK ON bookmark_tag (tag_id); ================================================ FILE: internal/database/migrations/postgres/0002_created_time.up.sql ================================================ -- Rename "modified" column to "created_at" ALTER TABLE bookmark RENAME COLUMN modified to created_at; -- Add the "modified_at" column to the bookmark table ALTER TABLE bookmark ADD COLUMN modified_at TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP; -- Update the "modified_at" column with the value from the "created_at" column if it is not null UPDATE bookmark SET modified_at = COALESCE(created_at, CURRENT_TIMESTAMP) WHERE created_at IS NOT NULL; -- Index for "created_at" "modified_at"" CREATE INDEX idx_created_at ON bookmark(created_at); CREATE INDEX idx_modified_at ON bookmark(modified_at); ================================================ FILE: internal/database/migrations/sqlite/0000_system.up.sql ================================================ CREATE TABLE IF NOT EXISTS shiori_system( database_schema_version TEXT NOT NULL DEFAULT '0.0.0' ); INSERT INTO shiori_system(database_schema_version) VALUES('0.0.0'); ================================================ FILE: internal/database/migrations/sqlite/0001_initial.up.sql ================================================ CREATE TABLE IF NOT EXISTS account( id INTEGER NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL, owner INTEGER NOT NULL DEFAULT 0, config JSON NOT NULL DEFAULT '{}', CONSTRAINT account_PK PRIMARY KEY(id), CONSTRAINT account_username_UNIQUE UNIQUE(username) ); CREATE TABLE IF NOT EXISTS bookmark( id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, excerpt TEXT NOT NULL DEFAULT "", author TEXT NOT NULL DEFAULT "", public INTEGER NOT NULL DEFAULT 0, modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, has_content BOOLEAN DEFAULT FALSE NOT NULL, CONSTRAINT bookmark_PK PRIMARY KEY(id), CONSTRAINT bookmark_url_UNIQUE UNIQUE(url) ); CREATE TABLE IF NOT EXISTS tag( id INTEGER NOT NULL, name TEXT NOT NULL, CONSTRAINT tag_PK PRIMARY KEY(id), CONSTRAINT tag_name_UNIQUE UNIQUE(name) ); CREATE TABLE IF NOT EXISTS bookmark_tag( bookmark_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, CONSTRAINT bookmark_tag_PK PRIMARY KEY(bookmark_id, tag_id), CONSTRAINT bookmark_id_FK FOREIGN KEY(bookmark_id) REFERENCES bookmark(id), CONSTRAINT tag_id_FK FOREIGN KEY(tag_id) REFERENCES tag(id) ); CREATE VIRTUAL TABLE IF NOT EXISTS bookmark_content USING fts5(title, content, html, docid); ================================================ FILE: internal/database/migrations/sqlite/0002_denormalize_content.up.sql ================================================ UPDATE bookmark SET has_content = bc.has_content FROM (SELECT docid, content <> '' AS has_content FROM bookmark_content) AS bc WHERE bookmark.id = bc.docid; ================================================ FILE: internal/database/migrations/sqlite/0003_uniq_id.up.sql ================================================ -- Create a temporary table CREATE TABLE IF NOT EXISTS bookmark_temp( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, title TEXT NOT NULL, excerpt TEXT NOT NULL DEFAULT "", author TEXT NOT NULL DEFAULT "", public INTEGER NOT NULL DEFAULT 0, modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, has_content BOOLEAN DEFAULT FALSE NOT NULL, CONSTRAINT bookmark_url_UNIQUE UNIQUE(url) ); -- Copy data from the original table to the temporary table INSERT INTO bookmark_temp (id, url, title, excerpt, author, public, modified, has_content) SELECT id, url, title, excerpt, author, public, modified, has_content FROM bookmark; -- Drop the original table DROP TABLE bookmark; -- Rename the temporary table to the original table name ALTER TABLE bookmark_temp RENAME TO bookmark; ================================================ FILE: internal/database/migrations/sqlite/0004_created_time.up.sql ================================================ ALTER TABLE bookmark RENAME COLUMN modified to created_at; ALTER TABLE bookmark ADD COLUMN modified_at TEXT NULL; UPDATE bookmark SET modified_at = bookmark.created_at WHERE created_at IS NOT NULL; CREATE INDEX idx_created_at ON bookmark(created_at); CREATE INDEX idx_modified_at ON bookmark(modified_at); ================================================ FILE: internal/database/migrations.go ================================================ // Package database implements database operations and migrations package database import ( "context" "database/sql" "embed" "fmt" "path" "github.com/blang/semver" "github.com/go-shiori/shiori/internal/model" ) //go:embed migrations/* var migrationFiles embed.FS // migration represents a database schema migration type migration struct { fromVersion semver.Version toVersion semver.Version migrationFunc func(db *sql.DB) error } // txFn is a function that runs in a transaction. type txFn func(tx *sql.Tx) error // runInTransaction runs the given function in a transaction. func runInTransaction(db *sql.DB, fn txFn) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() if err := fn(tx); err != nil { return fmt.Errorf("failed to run transaction: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // newFuncMigration creates a new migration from a function. func newFuncMigration(fromVersion, toVersion string, migrationFunc func(db *sql.DB) error) migration { return migration{ fromVersion: semver.MustParse(fromVersion), toVersion: semver.MustParse(toVersion), migrationFunc: migrationFunc, } } // newFileMigration creates a new migration from a file. func newFileMigration(fromVersion, toVersion, filename string) migration { return newFuncMigration(fromVersion, toVersion, func(db *sql.DB) error { return runInTransaction(db, func(tx *sql.Tx) error { migrationSQL, err := migrationFiles.ReadFile(path.Join("migrations", filename+".up.sql")) if err != nil { return fmt.Errorf("failed to read migration file: %w", err) } if _, err := tx.Exec(string(migrationSQL)); err != nil { return fmt.Errorf("failed to execute migration %s to %s: %w", fromVersion, toVersion, err) } return nil }) }) } // runMigrations runs the given migrations. func runMigrations(ctx context.Context, db model.DB, migrations []migration) error { currentVersion := semver.Version{} // Get current database version dbVersion, err := db.GetDatabaseSchemaVersion(ctx) if err == nil && dbVersion != "" { currentVersion = semver.MustParse(dbVersion) } for _, migration := range migrations { if !currentVersion.EQ(migration.fromVersion) { continue } if err := migration.migrationFunc(db.WriterDB().DB); err != nil { return fmt.Errorf("failed to run migration from %s to %s: %w", migration.fromVersion, migration.toVersion, err) } currentVersion = migration.toVersion if err := db.SetDatabaseSchemaVersion(ctx, currentVersion.String()); err != nil { return fmt.Errorf("failed to store database version %s from %s to %s: %w", currentVersion.String(), migration.fromVersion, migration.toVersion, err) } } return nil } ================================================ FILE: internal/database/mysql.go ================================================ package database import ( "context" "database/sql" "fmt" "slices" "strings" "time" "github.com/go-shiori/shiori/internal/model" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" _ "github.com/go-sql-driver/mysql" ) var mysqlMigrations = []migration{ newFileMigration("0.0.0", "0.1.0", "mysql/0000_system_create"), newFileMigration("0.1.0", "0.2.0", "mysql/0000_system_insert"), newFileMigration("0.2.0", "0.3.0", "mysql/0001_initial_account"), newFileMigration("0.3.0", "0.4.0", "mysql/0002_initial_bookmark"), newFileMigration("0.4.0", "0.5.0", "mysql/0003_initial_tag"), newFileMigration("0.5.0", "0.6.0", "mysql/0004_initial_bookmark_tag"), newFuncMigration("0.6.0", "0.7.0", func(db *sql.DB) error { // Ensure that bookmark table has `has_content` column and account table has `config` column // for users upgrading from <1.5.4 directly into this version. tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() _, err = tx.Exec(`ALTER TABLE bookmark ADD COLUMN has_content BOOLEAN DEFAULT 0`) if err != nil && strings.Contains(err.Error(), `Duplicate column name`) { tx.Rollback() } else if err != nil { return fmt.Errorf("failed to add has_content column to bookmark table: %w", err) } else if err == nil { if errCommit := tx.Commit(); errCommit != nil { return fmt.Errorf("failed to commit transaction: %w", errCommit) } } tx, err = db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() _, err = tx.Exec(`ALTER TABLE account ADD COLUMN config JSON NOT NULL DEFAULT ('{}')`) if err != nil && strings.Contains(err.Error(), `Duplicate column name`) { tx.Rollback() } else if err != nil { return fmt.Errorf("failed to add config column to account table: %w", err) } else if err == nil { if errCommit := tx.Commit(); errCommit != nil { return fmt.Errorf("failed to commit transaction: %w", errCommit) } } return nil }), newFileMigration("0.7.0", "0.8.0", "mysql/0005_rename_to_created_at"), newFileMigration("0.8.0", "0.8.1", "mysql/0006_change_created_at_settings"), newFileMigration("0.8.1", "0.8.2", "mysql/0007_add_modified_at"), newFileMigration("0.8.2", "0.8.3", "mysql/0008_set_modified_at_equal_created_at"), newFileMigration("0.8.3", "0.8.4", "mysql/0009_index_for_created_at"), newFileMigration("0.8.4", "0.8.5", "mysql/0010_index_for_modified_at"), } // MySQLDatabase is implementation of Database interface // for connecting to MySQL or MariaDB database. type MySQLDatabase struct { dbbase } // OpenMySQLDatabase creates and opens connection to a MySQL Database. func OpenMySQLDatabase(ctx context.Context, connString string) (mysqlDB *MySQLDatabase, err error) { // Open database and start transaction db, err := sqlx.ConnectContext(ctx, "mysql", connString) if err != nil { return nil, errors.WithStack(err) } db.SetMaxOpenConns(100) db.SetConnMaxLifetime(time.Second) // in case mysql client has longer timeout (driver issue #674) mysqlDB = &MySQLDatabase{dbbase: NewDBBase(db, db, sqlbuilder.MySQL)} return mysqlDB, err } // Init initializes the database func (db *MySQLDatabase) Init(ctx context.Context) error { return nil } // Migrate runs migrations for this database engine func (db *MySQLDatabase) Migrate(ctx context.Context) error { if err := runMigrations(ctx, db, mysqlMigrations); err != nil { return errors.WithStack(err) } return nil } // GetDatabaseSchemaVersion fetches the current migrations version of the database func (db *MySQLDatabase) GetDatabaseSchemaVersion(ctx context.Context) (string, error) { var version string err := db.GetContext(ctx, &version, "SELECT database_schema_version FROM shiori_system") if err != nil { return "", errors.WithStack(err) } return version, nil } // SetDatabaseSchemaVersion sets the current migrations version of the database func (db *MySQLDatabase) SetDatabaseSchemaVersion(ctx context.Context, version string) error { tx := db.MustBegin() defer tx.Rollback() _, err := tx.Exec("UPDATE shiori_system SET database_schema_version = ?", version) if err != nil { return errors.WithStack(err) } return tx.Commit() } // SaveBookmarks saves new or updated bookmarks to database. // Returns the saved ID and error message if any happened. func (db *MySQLDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) ([]model.BookmarkDTO, error) { var result []model.BookmarkDTO if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Prepare statement stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark (url, title, excerpt, author, public, content, html, modified_at, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return errors.WithStack(err) } stmtUpdateBook, err := tx.Preparex(`UPDATE bookmark SET url = ?, title = ?, excerpt = ?, author = ?, public = ?, content = ?, html = ?, modified_at = ? WHERE id = ?`) if err != nil { return errors.WithStack(err) } stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = ?`) if err != nil { return errors.WithStack(err) } stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES (?)`) if err != nil { return errors.WithStack(err) } stmtInsertBookTag, err := tx.Preparex(`INSERT IGNORE INTO bookmark_tag (tag_id, bookmark_id) VALUES (?, ?)`) if err != nil { return errors.WithStack(err) } stmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag WHERE bookmark_id = ? AND tag_id = ?`) if err != nil { return errors.WithStack(err) } // Prepare modified time modifiedTime := time.Now().UTC().Format(model.DatabaseDateFormat) // Execute statements for _, book := range bookmarks { // Check URL and title if book.URL == "" { return errors.New("URL must not be empty") } if book.Title == "" { return errors.New("title must not be empty") } // Set modified time if book.ModifiedAt == "" { book.ModifiedAt = modifiedTime } // Save bookmark var err error if create { book.CreatedAt = modifiedTime var res sql.Result res, err = stmtInsertBook.ExecContext(ctx, book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Content, book.HTML, book.ModifiedAt, book.CreatedAt) if err != nil { return errors.WithStack(err) } bookID, err := res.LastInsertId() if err != nil { return errors.WithStack(err) } book.ID = int(bookID) } else { _, err = stmtUpdateBook.ExecContext(ctx, book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Content, book.HTML, book.ModifiedAt, book.ID) } if err != nil { return errors.WithStack(err) } // Save book tags newTags := []model.TagDTO{} for _, tag := range book.Tags { t := tag.ToDTO() // If it's deleted tag, delete and continue if t.Deleted { _, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, t.ID) if err != nil { return errors.WithStack(err) } continue } // Normalize tag name tagName := strings.ToLower(tag.Name) tagName = strings.Join(strings.Fields(tagName), " ") // If tag doesn't have any ID, fetch it from database if tag.ID == 0 { if err := stmtGetTag.GetContext(ctx, &tag.ID, tagName); err != nil && err != sql.ErrNoRows { return errors.WithStack(err) } // If tag doesn't exist in database, save it if tag.ID == 0 { res, err := stmtInsertTag.ExecContext(ctx, tagName) if err != nil { return errors.WithStack(err) } tagID64, err := res.LastInsertId() if err != nil { return errors.WithStack(err) } tag.ID = int(tagID64) t.ID = int(tagID64) } } // Always insert the tag-bookmark association if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { return errors.WithStack(err) } newTags = append(newTags, t) } book.Tags = newTags result = append(result, book) } return nil }); err != nil { return result, errors.WithStack(err) } return result, nil } // GetBookmarks fetch list of bookmarks based on submitted options. func (db *MySQLDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmarksOptions) ([]model.BookmarkDTO, error) { // Create initial query columns := []string{ `id`, `url`, `title`, `excerpt`, `author`, `public`, `created_at`, `modified_at`, `content <> "" as has_content`} if opts.WithContent { columns = append(columns, `content`, `html`) } query := `SELECT ` + strings.Join(columns, ",") + ` FROM bookmark WHERE 1` // Add where clause args := []interface{}{} // Add where clause for IDs if len(opts.IDs) > 0 { query += ` AND id IN (?)` args = append(args, opts.IDs) } // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( url LIKE ? OR MATCH(title, excerpt, content) AGAINST (? IN BOOLEAN MODE) )` args = append(args, "%"+opts.Keyword+"%", opts.Keyword) } // Add where clause for tags. // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false if slices.Contains(opts.ExcludedTags, "*") { excludeAllTags = true opts.ExcludedTags = []string{} } includeAllTags := false if slices.Contains(opts.Tags, "*") { includeAllTags = true opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. // In other hand, if all tags included, we will only show bookmark with tags. if excludeAllTags { query += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } else if includeAllTags { query += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } // Now we only need to find the normal tags if len(opts.Tags) > 0 { query += ` AND id IN ( SELECT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?) GROUP BY bt.bookmark_id HAVING COUNT(bt.bookmark_id) = ?)` args = append(args, opts.Tags, len(opts.Tags)) } if len(opts.ExcludedTags) > 0 { query += ` AND id NOT IN ( SELECT DISTINCT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?))` args = append(args, opts.ExcludedTags) } // Add order clause switch opts.OrderMethod { case model.ByLastAdded: query += ` ORDER BY id DESC` case model.ByLastModified: query += ` ORDER BY modified_at DESC` default: query += ` ORDER BY id` } if opts.Limit > 0 && opts.Offset >= 0 { query += ` LIMIT ? OFFSET ?` args = append(args, opts.Limit, opts.Offset) } // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { return nil, errors.WithStack(err) } // Fetch bookmarks bookmarks := []model.BookmarkDTO{} err = db.Select(&bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return nil, errors.WithStack(err) } // Fetch tags for each bookmark for i, book := range bookmarks { tags, err := db.getTagsForBookmark(ctx, book.ID) if err != nil { return nil, fmt.Errorf("failed to get tags: %w", err) } bookmarks[i].Tags = tags } return bookmarks, nil } func (db *MySQLDatabase) getTagsForBookmark(ctx context.Context, bookmarkID int) ([]model.TagDTO, error) { sb := sqlbuilder.MySQL.NewSelectBuilder() sb.Select("t.id", "t.name") sb.From("bookmark_tag bt") sb.JoinWithOption(sqlbuilder.LeftJoin, "tag t", "bt.tag_id = t.id") sb.Where(sb.Equal("bt.bookmark_id", bookmarkID)) sb.OrderBy("t.name") query, args := sb.Build() query = db.ReaderDB().Rebind(query) tags := []model.TagDTO{} err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to get tags: %w", err) } return tags, nil } // GetBookmarksCount fetch count of bookmarks based on submitted options. func (db *MySQLDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) { // Create initial query query := `SELECT COUNT(id) FROM bookmark WHERE 1` // Add where clause args := []interface{}{} // Add where clause for IDs if len(opts.IDs) > 0 { query += ` AND id IN (?)` args = append(args, opts.IDs) } // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( url LIKE ? OR MATCH(title, excerpt, content) AGAINST (? IN BOOLEAN MODE) )` args = append(args, "%"+opts.Keyword+"%", opts.Keyword) } // Add where clause for tags. // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false if slices.Contains(opts.ExcludedTags, "*") { excludeAllTags = true opts.ExcludedTags = []string{} } includeAllTags := false if slices.Contains(opts.Tags, "*") { includeAllTags = true opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. // In other hand, if all tags included, we will only show bookmark with tags. if excludeAllTags { query += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } else if includeAllTags { query += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } // Now we only need to find the normal tags if len(opts.Tags) > 0 { query += ` AND id IN ( SELECT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?) GROUP BY bt.bookmark_id HAVING COUNT(bt.bookmark_id) = ?)` args = append(args, opts.Tags, len(opts.Tags)) } if len(opts.ExcludedTags) > 0 { query += ` AND id NOT IN ( SELECT DISTINCT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?))` args = append(args, opts.ExcludedTags) } // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { return 0, errors.WithStack(err) } // Fetch count var nBookmarks int err = db.GetContext(ctx, &nBookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return 0, errors.WithStack(err) } return nBookmarks, nil } // DeleteBookmarks removes all record with matching ids from database. func (db *MySQLDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err error) { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Prepare queries delBookmark := `DELETE FROM bookmark` delBookmarkTag := `DELETE FROM bookmark_tag` // Delete bookmark(s) if len(ids) == 0 { _, err := tx.ExecContext(ctx, delBookmarkTag) if err != nil { return errors.WithStack(err) } _, err = tx.ExecContext(ctx, delBookmark) if err != nil { return errors.WithStack(err) } } else { delBookmark += ` WHERE id = ?` delBookmarkTag += ` WHERE bookmark_id = ?` stmtDelBookmark, _ := tx.Preparex(delBookmark) stmtDelBookmarkTag, _ := tx.Preparex(delBookmarkTag) for _, id := range ids { _, err := stmtDelBookmarkTag.ExecContext(ctx, id) if err != nil { return errors.WithStack(err) } _, err = stmtDelBookmark.ExecContext(ctx, id) if err != nil { return errors.WithStack(err) } } } return nil }); err != nil { return errors.WithStack(err) } return nil } // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. func (db *MySQLDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) { // Create the main query builder for bookmark data sb := sqlbuilder.NewSelectBuilder() sb.Select( "id", "url", "title", "excerpt", "author", `public`, "modified_at", "content", "html", "created_at", "has_content") sb.From("bookmark") // Add conditions if id != 0 { sb.Where(sb.Equal("id", id)) } else if url != "" { sb.Where(sb.Equal("url", url)) } else { return model.BookmarkDTO{}, false, fmt.Errorf("id or url is required") } // Build the query query, args := sb.Build() query = db.ReaderDB().Rebind(query) // Execute the query book := model.BookmarkDTO{} err := db.ReaderDB().GetContext(ctx, &book, query, args...) if err != nil { if err == sql.ErrNoRows { return book, false, nil } return book, false, fmt.Errorf("failed to get bookmark: %w", err) } // If bookmark exists, fetch its tags if book.ID != 0 { // Create query builder for tags tagSb := sqlbuilder.NewSelectBuilder() tagSb.Select("t.id", "t.name") tagSb.From("tag t") tagSb.JoinWithOption(sqlbuilder.InnerJoin, "bookmark_tag bt", "bt.tag_id = t.id") tagSb.Where(tagSb.Equal("bt.bookmark_id", book.ID)) // Build the query tagQuery, tagArgs := tagSb.Build() tagQuery = db.ReaderDB().Rebind(tagQuery) // Execute the query tags := []model.TagDTO{} if err := db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...); err != nil && err != sql.ErrNoRows { return book, false, fmt.Errorf("failed to get tags: %w", err) } book.Tags = tags } return book, true, nil } // CreateAccount saves new account to database. Returns error if any happened. func (db *MySQLDatabase) CreateAccount(ctx context.Context, account model.Account) (*model.Account, error) { var accountID int64 if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Check for existing username var exists bool err := tx.QueryRowContext( ctx, "SELECT EXISTS(SELECT 1 FROM account WHERE username = ?)", account.Username, ).Scan(&exists) if err != nil { return fmt.Errorf("error checking username: %w", err) } if exists { return ErrAlreadyExists } // Create the account result, err := tx.ExecContext(ctx, `INSERT INTO account (username, password, owner, config) VALUES (?, ?, ?, ?)`, account.Username, account.Password, account.Owner, account.Config) if err != nil { return fmt.Errorf("error executing query: %w", err) } id, err := result.LastInsertId() if err != nil { return fmt.Errorf("error getting last insert id: %w", err) } accountID = id return nil }); err != nil { return nil, fmt.Errorf("error running transaction: %w", err) } account.ID = model.DBID(accountID) return &account, nil } // UpdateAccount update account in database func (db *MySQLDatabase) UpdateAccount(ctx context.Context, account model.Account) error { if account.ID == 0 { return ErrNotFound } if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Check for existing username var exists bool err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM account WHERE username = ? AND id != ?)", account.Username, account.ID).Scan(&exists) if err != nil { return fmt.Errorf("error checking username: %w", err) } if exists { return ErrAlreadyExists } result, err := tx.ExecContext(ctx, `UPDATE account SET username = ?, password = ?, owner = ?, config = ? WHERE id = ?`, account.Username, account.Password, account.Owner, account.Config, account.ID) if err != nil { return fmt.Errorf("error updating account: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound } return nil }); err != nil { return fmt.Errorf("error running transaction: %w", err) } return nil } // ListAccounts fetch list of account (without its password) based on submitted options. func (db *MySQLDatabase) ListAccounts(ctx context.Context, opts model.DBListAccountsOptions) ([]model.Account, error) { // Create query args := []interface{}{} fields := []string{"id", "username", "owner", "config"} if opts.WithPassword { fields = append(fields, "password") } query := fmt.Sprintf(`SELECT %s FROM account WHERE 1`, strings.Join(fields, ", ")) if opts.Keyword != "" { query += " AND username LIKE ?" args = append(args, "%"+opts.Keyword+"%") } if opts.Username != "" { query += " AND username = ?" args = append(args, opts.Username) } if opts.Owner { query += " AND owner = 1" } // Fetch list account accounts := []model.Account{} err := db.SelectContext(ctx, &accounts, query, args...) if err != nil && err != sql.ErrNoRows { return nil, errors.WithStack(err) } return accounts, nil } // GetAccount fetch account with matching ID. // Returns the account and boolean whether it's exist or not. func (db *MySQLDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) { account := model.Account{} err := db.GetContext(ctx, &account, `SELECT id, username, password, owner, config FROM account WHERE id = ?`, id, ) if err != nil { if err == sql.ErrNoRows { return &account, false, ErrNotFound } return &account, false, fmt.Errorf("error getting account: %w", err) } return &account, true, nil } // DeleteAccount removes record with matching ID. func (db *MySQLDatabase) DeleteAccount(ctx context.Context, id model.DBID) error { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = ?`, id) if err != nil { return fmt.Errorf("error deleting account: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound } return nil }); err != nil { return fmt.Errorf("error running transaction: %w", err) } return nil } // CreateTags creates new tags from submitted objects. func (db *MySQLDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) { if len(tags) == 0 { return []model.Tag{}, nil } // Create a slice to hold the created tags createdTags := make([]model.Tag, len(tags)) copy(createdTags, tags) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // For MySQL, we need to insert tags one by one to get their IDs stmtInsertTag, err := tx.PrepareContext(ctx, "INSERT INTO tag (name) VALUES (?)") if err != nil { return fmt.Errorf("failed to prepare tag insertion statement: %w", err) } defer stmtInsertTag.Close() // Insert each tag and get its ID for i, tag := range createdTags { result, err := stmtInsertTag.ExecContext(ctx, tag.Name) if err != nil { return fmt.Errorf("failed to insert tag: %w", err) } // Get the last inserted ID tagID, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert ID: %w", err) } createdTags[i].ID = int(tagID) } return nil }); err != nil { return nil, fmt.Errorf("failed to run tag creation transaction: %w", err) } return createdTags, nil } // CreateTag creates a new tag in database. func (db *MySQLDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) { // Use CreateTags to implement this method createdTags, err := db.CreateTags(ctx, tag) if err != nil { return model.Tag{}, err } if len(createdTags) == 0 { return model.Tag{}, fmt.Errorf("failed to create tag") } return createdTags[0], nil } // RenameTag change the name of a tag. func (db *MySQLDatabase) RenameTag(ctx context.Context, id int, newName string) error { sb := sqlbuilder.NewUpdateBuilder() sb.Update("tag") sb.Set(sb.Assign("name", newName)) sb.Where(sb.Equal("id", id)) query, args := sb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to rename tag: %w", err) } return nil }); err != nil { return err } return nil } // GetTag fetch a tag by its ID. func (db *MySQLDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) { sb := sqlbuilder.MySQL.NewSelectBuilder() sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") sb.From("tag t") sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") sb.Where(sb.Equal("t.id", id)) sb.GroupBy("t.id") sb.OrderBy("t.name") query, args := sb.Build() query = db.ReaderDB().Rebind(query) var tag model.TagDTO err := db.ReaderDB().GetContext(ctx, &tag, query, args...) if err == sql.ErrNoRows { return model.TagDTO{}, false, nil } if err != nil { return model.TagDTO{}, false, fmt.Errorf("failed to get tag: %w", err) } return tag, true, nil } // UpdateTag updates a tag in the database. func (db *MySQLDatabase) UpdateTag(ctx context.Context, tag model.Tag) error { sb := sqlbuilder.NewUpdateBuilder() sb.Update("tag") sb.Set(sb.Assign("name", tag.Name)) sb.Where(sb.Equal("id", tag.ID)) query, args := sb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update tag: %w", err) } return nil }); err != nil { return err } return nil } // DeleteTag removes a tag from the database. func (db *MySQLDatabase) DeleteTag(ctx context.Context, id int) error { // First, check if the tag exists _, exists, err := db.GetTag(ctx, id) if err != nil { return fmt.Errorf("failed to check if tag exists: %w", err) } if !exists { return ErrNotFound } // Delete all bookmark_tag associations deleteAssocSb := sqlbuilder.NewDeleteBuilder() deleteAssocSb.DeleteFrom("bookmark_tag") deleteAssocSb.Where(deleteAssocSb.Equal("tag_id", id)) deleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build() deleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery) // Then, delete the tag itself deleteTagSb := sqlbuilder.NewDeleteBuilder() deleteTagSb.DeleteFrom("tag") deleteTagSb.Where(deleteTagSb.Equal("id", id)) deleteTagQuery, deleteTagArgs := deleteTagSb.Build() deleteTagQuery = db.WriterDB().Rebind(deleteTagQuery) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Delete bookmark_tag associations _, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...) if err != nil { return fmt.Errorf("failed to delete tag associations: %w", err) } // Delete the tag _, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...) if err != nil { return fmt.Errorf("failed to delete tag: %w", err) } return nil }); err != nil { return err } return nil } // SaveBookmark saves a single bookmark to database without handling tags. // It only updates the bookmark data in the database. func (db *MySQLDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error { if bookmark.ID <= 0 { return fmt.Errorf("bookmark ID must be greater than 0") } // Prepare modified time if not set if bookmark.ModifiedAt == "" { bookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat) } // Check URL and title if bookmark.URL == "" { return errors.New("URL must not be empty") } if bookmark.Title == "" { return errors.New("title must not be empty") } // Use sqlbuilder to build the update query sb := sqlbuilder.NewUpdateBuilder() sb.Update("bookmark") sb.Set( sb.Assign("url", bookmark.URL), sb.Assign("title", bookmark.Title), sb.Assign("excerpt", bookmark.Excerpt), sb.Assign("author", bookmark.Author), sb.Assign("public", bookmark.Public), sb.Assign("modified_at", bookmark.ModifiedAt), sb.Assign("has_content", bookmark.HasContent), ) sb.Where(sb.Equal("id", bookmark.ID)) query, args := sb.Build() query = db.WriterDB().Rebind(query) return db.withTx(ctx, func(tx *sqlx.Tx) error { // Update bookmark _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update bookmark: %w", err) } return nil }) } func (db *MySQLDatabase) SaveBookmarkTags(ctx context.Context, bookmarkID int, tagIDs []int) error { return db.withTx(ctx, func(tx *sqlx.Tx) error { // Prepare statements stmtDeleteAllBookmarkTags, err := tx.PreparexContext(ctx, `DELETE FROM bookmark_tag WHERE bookmark_id = ?`) if err != nil { return fmt.Errorf("failed to prepare delete all bookmark tags statement: %w", err) } stmtInsertBookTag, err := tx.PreparexContext(ctx, `INSERT IGNORE INTO bookmark_tag (tag_id, bookmark_id) VALUES (?, ?)`) if err != nil { return fmt.Errorf("failed to prepare insert book tag statement: %w", err) } // Delete all existing tags for this bookmark _, err = stmtDeleteAllBookmarkTags.ExecContext(ctx, bookmarkID) if err != nil { return fmt.Errorf("failed to delete existing bookmark tags: %w", err) } // Insert new tags for _, tagID := range tagIDs { _, err := stmtInsertBookTag.ExecContext(ctx, tagID, bookmarkID) if err != nil { return fmt.Errorf("failed to insert bookmark tag: %w", err) } } return nil }) } // BulkUpdateBookmarkTags updates tags for multiple bookmarks. // It ensures that all bookmarks and tags exist before proceeding. func (db *MySQLDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { if len(bookmarkIDs) == 0 || len(tagIDs) == 0 { return nil } // Convert int slices to interface slices for sqlbuilder bookmarkIDsIface := make([]interface{}, len(bookmarkIDs)) for i, id := range bookmarkIDs { bookmarkIDsIface[i] = id } tagIDsIface := make([]interface{}, len(tagIDs)) for i, id := range tagIDs { tagIDsIface[i] = id } // Verify all bookmarks exist bookmarkSb := sqlbuilder.NewSelectBuilder() bookmarkSb.Select("id") bookmarkSb.From("bookmark") bookmarkSb.Where(bookmarkSb.In("id", bookmarkIDsIface...)) bookmarkQuery, bookmarkArgs := bookmarkSb.Build() bookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery) var existingBookmarkIDs []int err := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...) if err != nil { return fmt.Errorf("failed to check bookmarks: %w", err) } if len(existingBookmarkIDs) != len(bookmarkIDs) { // Find which bookmarks don't exist missingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs) return fmt.Errorf("some bookmarks do not exist: %v", missingBookmarkIDs) } // Verify all tags exist tagSb := sqlbuilder.NewSelectBuilder() tagSb.Select("id") tagSb.From("tag") tagSb.Where(tagSb.In("id", tagIDsIface...)) tagQuery, tagArgs := tagSb.Build() tagQuery = db.ReaderDB().Rebind(tagQuery) var existingTagIDs []int err = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...) if err != nil { return fmt.Errorf("failed to check tags: %w", err) } if len(existingTagIDs) != len(tagIDs) { // Find which tags don't exist missingTagIDs := model.SliceDifference(tagIDs, existingTagIDs) return fmt.Errorf("some tags do not exist: %v", missingTagIDs) } return db.withTx(ctx, func(tx *sqlx.Tx) error { // Delete existing bookmark-tag associations deleteSb := sqlbuilder.NewDeleteBuilder() deleteSb.DeleteFrom("bookmark_tag") deleteSb.Where(deleteSb.In("bookmark_id", bookmarkIDsIface...)) deleteQuery, deleteArgs := deleteSb.Build() deleteQuery = tx.Rebind(deleteQuery) _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...) if err != nil { return fmt.Errorf("failed to delete existing bookmark tags: %w", err) } // Insert new bookmark-tag associations if len(tagIDs) > 0 { // Build values for bulk insert insertSb := sqlbuilder.NewInsertBuilder() insertSb.InsertInto("bookmark_tag") // Fix column order to match database schema insertSb.Cols("bookmark_id", "tag_id") for _, bookmarkID := range bookmarkIDs { for _, tagID := range tagIDs { // Match the column order in Values insertSb.Values(bookmarkID, tagID) } } insertQuery, insertArgs := insertSb.Build() // Add MySQL-specific INSERT IGNORE INTO syntax insertQuery = strings.Replace(insertQuery, "INSERT INTO", "INSERT IGNORE INTO", 1) insertQuery = tx.Rebind(insertQuery) _, err = tx.ExecContext(ctx, insertQuery, insertArgs...) if err != nil { return fmt.Errorf("failed to insert bookmark tags: %w", err) } } return nil }) } ================================================ FILE: internal/database/mysql_test.go ================================================ //go:build !test_sqlite_only // +build !test_sqlite_only package database import ( "context" "log" "os" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/jmoiron/sqlx" ) func init() { connString := os.Getenv("SHIORI_TEST_MYSQL_URL") if connString == "" { log.Fatal("mysql tests can't run without a MysQL database, set SHIORI_TEST_MYSQL_URL environment variable") } connStringMariaDB := os.Getenv("SHIORI_TEST_MARIADB_URL") if connStringMariaDB == "" { log.Fatal("mysql tests can't run without a MariaDB database, set SHIORI_TEST_MARIADB_URL environment variable") } } func mysqlTestDatabaseFactory(envKey string) testDatabaseFactory { return func(_ *testing.T, ctx context.Context) (model.DB, error) { connString := os.Getenv(envKey) db, err := OpenMySQLDatabase(ctx, connString) if err != nil { return nil, err } var dbname string err = db.withTx(ctx, func(tx *sqlx.Tx) error { err := tx.QueryRow("SELECT DATABASE()").Scan(&dbname) if err != nil { return err } _, err = tx.ExecContext(ctx, "DROP DATABASE IF EXISTS "+dbname) if err != nil { return err } _, err = tx.ExecContext(ctx, "CREATE DATABASE "+dbname) return err }) if err != nil { return nil, err } if _, err := db.ExecContext(ctx, "USE "+dbname); err != nil { return nil, err } if err = db.Migrate(context.TODO()); err != nil { return nil, err } return db, err } } func TestMysqlsDatabase(t *testing.T) { testDatabase(t, mysqlTestDatabaseFactory("SHIORI_TEST_MYSQL_URL")) } func TestMariaDBDatabase(t *testing.T) { testDatabase(t, mysqlTestDatabaseFactory("SHIORI_TEST_MARIADB_URL")) } ================================================ FILE: internal/database/pg.go ================================================ package database import ( "context" "database/sql" "fmt" "slices" "strconv" "strings" "time" "github.com/go-shiori/shiori/internal/model" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/lib/pq" ) var postgresMigrations = []migration{ newFileMigration("0.0.0", "0.1.0", "postgres/0000_system"), newFileMigration("0.1.0", "0.2.0", "postgres/0001_initial"), newFuncMigration("0.2.0", "0.3.0", func(db *sql.DB) error { // Ensure that bookmark table has `has_content` column and account table has `config` column // for users upgrading from <1.5.4 directly into this version. tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } _, err = tx.Exec(`ALTER TABLE bookmark ADD COLUMN has_content BOOLEAN DEFAULT FALSE NOT NULL`) if err != nil { // Check if this is a "column already exists" error (PostgreSQL error code 42701) // If it's not, return error. // This is needed for users upgrading from >1.5.4 directly into this version. pqErr, ok := err.(*pq.Error) if ok && pqErr.Code == "42701" { tx.Rollback() } else { return fmt.Errorf("failed to add has_content column to bookmark table: %w", err) } } else { if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } } tx, err = db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } _, err = tx.Exec(`ALTER TABLE account ADD COLUMN config JSONB NOT NULL DEFAULT '{}'`) if err != nil { // Check if this is a "column already exists" error (PostgreSQL error code 42701) // If it's not, return error // This is needed for users upgrading from >1.5.4 directly into this version. pqErr, ok := err.(*pq.Error) if ok && pqErr.Code == "42701" { tx.Rollback() } else { return fmt.Errorf("failed to add config column to account table: %w", err) } } else { if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } } return nil }), newFileMigration("0.3.0", "0.4.0", "postgres/0002_created_time"), } // PGDatabase is implementation of Database interface // for connecting to PostgreSQL database. type PGDatabase struct { dbbase } // OpenPGDatabase creates and opens connection to a PostgreSQL Database. func OpenPGDatabase(ctx context.Context, connString string) (pgDB *PGDatabase, err error) { // Open database and start transaction db, err := sqlx.ConnectContext(ctx, "postgres", connString) if err != nil { return nil, errors.WithStack(err) } db.SetMaxOpenConns(100) db.SetConnMaxLifetime(time.Second) pgDB = &PGDatabase{dbbase: NewDBBase(db, db, sqlbuilder.PostgreSQL)} return pgDB, err } // Init initializes the database func (db *PGDatabase) Init(ctx context.Context) error { return nil } // Migrate runs migrations for this database engine func (db *PGDatabase) Migrate(ctx context.Context) error { if err := runMigrations(ctx, db, postgresMigrations); err != nil { return errors.WithStack(err) } return nil } // GetDatabaseSchemaVersion fetches the current migrations version of the database func (db *PGDatabase) GetDatabaseSchemaVersion(ctx context.Context) (string, error) { var version string err := db.GetContext(ctx, &version, "SELECT database_schema_version FROM shiori_system") if err != nil { return "", errors.WithStack(err) } return version, nil } // SetDatabaseSchemaVersion sets the current migrations version of the database func (db *PGDatabase) SetDatabaseSchemaVersion(ctx context.Context, version string) error { tx := db.MustBegin() defer tx.Rollback() return db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.Exec("UPDATE shiori_system SET database_schema_version = $1", version) if err != nil { return errors.WithStack(err) } return tx.Commit() }) } // SaveBookmarks saves new or updated bookmarks to database. // Returns the saved ID and error message if any happened. func (db *PGDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) (result []model.BookmarkDTO, err error) { result = []model.BookmarkDTO{} if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Prepare statement stmtInsertBook, err := tx.Preparex(`INSERT INTO bookmark (url, title, excerpt, author, public, content, html, modified_at, created_at) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`) if err != nil { return errors.WithStack(err) } stmtUpdateBook, err := tx.Preparex(`UPDATE bookmark SET url = $1, title = $2, excerpt = $3, author = $4, public = $5, content = $6, html = $7, modified_at = $8 WHERE id = $9`) if err != nil { return errors.WithStack(err) } stmtGetTag, err := tx.Preparex(`SELECT id FROM tag WHERE name = $1`) if err != nil { return errors.WithStack(err) } stmtInsertTag, err := tx.Preparex(`INSERT INTO tag (name) VALUES ($1) RETURNING id`) if err != nil { return errors.WithStack(err) } stmtInsertBookTag, err := tx.Preparex(`INSERT INTO bookmark_tag (tag_id, bookmark_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`) if err != nil { return errors.WithStack(err) } stmtDeleteBookTag, err := tx.Preparex(`DELETE FROM bookmark_tag WHERE bookmark_id = $1 AND tag_id = $2`) if err != nil { return errors.WithStack(err) } // Prepare modified time modifiedTime := time.Now().UTC().Format(model.DatabaseDateFormat) // Execute statements result = []model.BookmarkDTO{} for _, book := range bookmarks { // URL and title if book.URL == "" { return errors.New("URL must not be empty") } if book.Title == "" { return errors.New("title must not be empty") } // Set modified time if book.ModifiedAt == "" { book.ModifiedAt = modifiedTime } // Save bookmark var err error if create { book.CreatedAt = modifiedTime err = stmtInsertBook.QueryRowContext(ctx, book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Content, book.HTML, book.ModifiedAt, book.CreatedAt).Scan(&book.ID) } else { _, err = stmtUpdateBook.ExecContext(ctx, book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.Content, book.HTML, book.ModifiedAt, book.ID) } if err != nil { return errors.WithStack(err) } // Save book tags newTags := []model.TagDTO{} for _, tag := range book.Tags { t := tag.ToDTO() // If it's deleted tag, delete and continue if t.Deleted { _, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, t.ID) if err != nil { return errors.WithStack(err) } continue } // Normalize tag name tagName := strings.ToLower(tag.Name) tagName = strings.Join(strings.Fields(tagName), " ") // If tag doesn't have any ID, fetch it from database if tag.ID == 0 { err = stmtGetTag.GetContext(ctx, &tag.ID, tagName) if err != nil && !errors.Is(err, sql.ErrNoRows) { return errors.WithStack(err) } // If tag doesn't exist in database, save it if tag.ID == 0 { var tagID64 int64 err = stmtInsertTag.GetContext(ctx, &tagID64, tagName) if err != nil { return errors.WithStack(err) } tag.ID = int(tagID64) t.ID = int(tagID64) } if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { return errors.WithStack(err) } } newTags = append(newTags, t) } book.Tags = newTags result = append(result, book) } return nil }); err != nil { return nil, errors.WithStack(err) } return result, nil } // GetBookmarks fetch list of bookmarks based on submitted options. func (db *PGDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmarksOptions) ([]model.BookmarkDTO, error) { // Create initial query columns := []string{ `id`, `url`, `title`, `excerpt`, `author`, `public`, `created_at`, `modified_at`, `content <> '' has_content`} if opts.WithContent { columns = append(columns, `content`, `html`) } query := `SELECT ` + strings.Join(columns, ",") + ` FROM bookmark WHERE TRUE` // Add where clause arg := map[string]interface{}{} // Add where clause for IDs if len(opts.IDs) > 0 { query += ` AND id IN (:ids)` arg["ids"] = opts.IDs } // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( url LIKE '%' || :kw || '%' OR title LIKE '%' || :kw || '%' OR excerpt LIKE '%' || :kw || '%' OR content LIKE '%' || :kw || '%' )` arg["kw"] = opts.Keyword } // Add where clause for tags. // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false if slices.Contains(opts.ExcludedTags, "*") { excludeAllTags = true opts.ExcludedTags = []string{} } includeAllTags := false if slices.Contains(opts.Tags, "*") { includeAllTags = true opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. // In other hand, if all tags included, we will only show bookmark with tags. if excludeAllTags { query += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } else if includeAllTags { query += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } // Now we only need to find the normal tags if len(opts.Tags) > 0 { query += ` AND id IN ( SELECT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(:tags) GROUP BY bt.bookmark_id HAVING COUNT(bt.bookmark_id) = :ltags)` arg["tags"] = opts.Tags arg["ltags"] = len(opts.Tags) } if len(opts.ExcludedTags) > 0 { query += ` AND id NOT IN ( SELECT DISTINCT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(:extags))` arg["extags"] = opts.ExcludedTags } // Add order clause switch opts.OrderMethod { case model.ByLastAdded: query += ` ORDER BY id DESC` case model.ByLastModified: query += ` ORDER BY modified_at DESC` default: query += ` ORDER BY id` } if opts.Limit > 0 && opts.Offset >= 0 { query += ` LIMIT :limit OFFSET :offset` arg["limit"] = opts.Limit arg["offset"] = opts.Offset } // Expand query, because some of the args might be an array var err error query, args, _ := sqlx.Named(query, arg) query, args, err = sqlx.In(query, args...) if err != nil { return nil, fmt.Errorf("failed to expand query: %v", err) } query = db.ReaderDB().Rebind(query) // Fetch bookmarks bookmarks := []model.BookmarkDTO{} err = db.SelectContext(ctx, &bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to fetch data: %v", err) } // Fetch tags for each bookmarks stmtGetTags, err := db.ReaderDB().PreparexContext(ctx, `SELECT t.id, t.name FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE bt.bookmark_id = $1 ORDER BY t.name`) if err != nil { return nil, fmt.Errorf("failed to prepare tag query: %v", err) } defer stmtGetTags.Close() for i, book := range bookmarks { book.Tags = []model.TagDTO{} err = stmtGetTags.SelectContext(ctx, &book.Tags, book.ID) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to fetch tags: %v", err) } bookmarks[i] = book } return bookmarks, nil } // GetBookmarksCount fetch count of bookmarks based on submitted options. func (db *PGDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) { // Create initial query query := `SELECT COUNT(id) FROM bookmark WHERE TRUE` arg := map[string]interface{}{} // Add where clause for IDs if len(opts.IDs) > 0 { query += ` AND id IN (:ids)` arg["ids"] = opts.IDs } // Add where clause for search keyword if opts.Keyword != "" { query += ` AND ( url LIKE '%' || :kw || '%' OR title LIKE '%' || :kw || '%' OR excerpt LIKE '%' || :kw || '%' OR content LIKE '%' || :kw || '%' )` arg["lurl"] = "%" + opts.Keyword + "%" arg["kw"] = opts.Keyword } // Add where clause for tags. // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false for _, excludedTag := range opts.ExcludedTags { if excludedTag == "*" { excludeAllTags = true opts.ExcludedTags = []string{} break } } includeAllTags := false for _, includedTag := range opts.Tags { if includedTag == "*" { includeAllTags = true opts.Tags = []string{} break } } // If all tags excluded, we will only show bookmark without tags. // In other hand, if all tags included, we will only show bookmark with tags. if excludeAllTags { query += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } else if includeAllTags { query += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } // Now we only need to find the normal tags if len(opts.Tags) > 0 { query += ` AND id IN ( SELECT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(:tags) GROUP BY bt.bookmark_id HAVING COUNT(bt.bookmark_id) = :ltags)` arg["tags"] = opts.Tags arg["ltags"] = len(opts.Tags) } if len(opts.ExcludedTags) > 0 { query += ` AND id NOT IN ( SELECT DISTINCT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(:etags))` arg["etags"] = opts.ExcludedTags } // Expand query, because some of the args might be an array var err error query, args, err := sqlx.Named(query, arg) if err != nil { return 0, errors.WithStack(err) } query, args, err = sqlx.In(query, args...) if err != nil { return 0, errors.WithStack(err) } query = db.ReaderDB().Rebind(query) // Fetch count var nBookmarks int err = db.GetContext(ctx, &nBookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return 0, errors.WithStack(err) } return nBookmarks, nil } // DeleteBookmarks removes all record with matching ids from database. func (db *PGDatabase) DeleteBookmarks(ctx context.Context, ids ...int) (err error) { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Prepare queries delBookmark := `DELETE FROM bookmark` delBookmarkTag := `DELETE FROM bookmark_tag` // Delete bookmark(s) if len(ids) == 0 { _, err := tx.ExecContext(ctx, delBookmarkTag) if err != nil { return errors.WithStack(err) } _, err = tx.ExecContext(ctx, delBookmark) if err != nil { return errors.WithStack(err) } } else { delBookmark += ` WHERE id = $1` delBookmarkTag += ` WHERE bookmark_id = $1` stmtDelBookmark, err := tx.Preparex(delBookmark) if err != nil { return errors.WithStack(err) } stmtDelBookmarkTag, err := tx.Preparex(delBookmarkTag) if err != nil { return errors.WithStack(err) } for _, id := range ids { _, err = stmtDelBookmarkTag.ExecContext(ctx, id) if err != nil { return errors.WithStack(err) } _, err = stmtDelBookmark.ExecContext(ctx, id) if err != nil { return errors.WithStack(err) } } } return nil }); err != nil { return errors.WithStack(err) } return nil } // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. func (db *PGDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) { // Create the main query builder for bookmark data sb := sqlbuilder.PostgreSQL.NewSelectBuilder() sb.Select( "id", "url", "title", "excerpt", "author", `"public"`, "modified_at", "content", "html", "created_at", "has_content") sb.From("bookmark") // Add conditions if id != 0 { sb.Where(sb.Equal("id", id)) } else if url != "" { sb.Where(sb.Equal("url", url)) } else { return model.BookmarkDTO{}, false, fmt.Errorf("id or url is required") } // Build the query query, args := sb.Build() // Execute the query book := model.BookmarkDTO{} query = db.ReaderDB().Rebind(query) err := db.ReaderDB().GetContext(ctx, &book, query, args...) if err != nil { if err == sql.ErrNoRows { return book, false, nil } return book, false, fmt.Errorf("failed to get bookmark: %w", err) } // If bookmark exists, fetch its tags if book.ID != 0 { // Create query builder for tags tagSb := sqlbuilder.PostgreSQL.NewSelectBuilder() tagSb.Select("t.id", "t.name") tagSb.From("tag t") tagSb.JoinWithOption(sqlbuilder.InnerJoin, "bookmark_tag bt", "bt.tag_id = t.id") tagSb.Where(tagSb.Equal("bt.bookmark_id", book.ID)) // Build the query tagQuery, tagArgs := tagSb.Build() tagQuery = db.ReaderDB().Rebind(tagQuery) // Execute the query tags := []model.TagDTO{} if err := db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...); err != nil && err != sql.ErrNoRows { return book, false, fmt.Errorf("failed to get tags: %w", err) } book.Tags = tags } return book, true, nil } // CreateAccount saves new account to database. Returns error if any happened. func (db *PGDatabase) CreateAccount(ctx context.Context, account model.Account) (*model.Account, error) { var accountID int64 if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Check for existing username var exists bool err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM account WHERE username = $1)", account.Username).Scan(&exists) if err != nil { return fmt.Errorf("error checking username: %w", err) } if exists { return ErrAlreadyExists } // Create the account query, err := tx.PrepareContext(ctx, `INSERT INTO account (username, password, owner, config) VALUES ($1, $2, $3, $4) RETURNING id`) if err != nil { return fmt.Errorf("error preparing query: %w", err) } err = query.QueryRowContext(ctx, account.Username, account.Password, account.Owner, account.Config).Scan(&accountID) if err != nil { return fmt.Errorf("error executing query: %w", err) } return nil }); err != nil { return nil, fmt.Errorf("error running transaction: %w", err) } account.ID = model.DBID(accountID) return &account, nil } // UpdateAccount updates account in database. func (db *PGDatabase) UpdateAccount(ctx context.Context, account model.Account) error { if account.ID == 0 { return ErrNotFound } if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Check for existing username var exists bool err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM account WHERE username = $1 AND id != $2)", account.Username, account.ID).Scan(&exists) if err != nil { return fmt.Errorf("error checking username: %w", err) } if exists { return ErrAlreadyExists } result, err := tx.ExecContext(ctx, `UPDATE account SET username = $1, password = $2, owner = $3, config = $4 WHERE id = $5`, account.Username, account.Password, account.Owner, account.Config, account.ID) if err != nil { return fmt.Errorf("error updating account: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound } return nil }); err != nil { return fmt.Errorf("error running transaction: %w", err) } return nil } // ListAccounts fetch list of account (without its password) based on submitted options. func (db *PGDatabase) ListAccounts(ctx context.Context, opts model.DBListAccountsOptions) ([]model.Account, error) { // Create query args := []interface{}{} fields := []string{"id", "username", "owner", "config"} if opts.WithPassword { fields = append(fields, "password") } query := fmt.Sprintf(`SELECT %s FROM account WHERE TRUE`, strings.Join(fields, ", ")) if opts.Keyword != "" { query += " AND username LIKE $" + strconv.Itoa(len(args)+1) args = append(args, "%"+opts.Keyword+"%") } if opts.Username != "" { query += " AND username = $" + strconv.Itoa(len(args)+1) args = append(args, opts.Username) } if opts.Owner { query += " AND owner = TRUE" } // Fetch list account accounts := []model.Account{} err := db.SelectContext(ctx, &accounts, query, args...) if err != nil && err != sql.ErrNoRows { return nil, errors.WithStack(err) } return accounts, nil } // GetAccount fetch account with matching ID. // Returns the account and boolean whether it's exist or not. func (db *PGDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) { account := model.Account{} err := db.GetContext(ctx, &account, `SELECT id, username, password, owner, config FROM account WHERE id = $1`, id, ) if err != nil { if err == sql.ErrNoRows { return &account, false, ErrNotFound } return &account, false, fmt.Errorf("error getting account: %w", err) } return &account, true, nil } // DeleteAccount removes record with matching ID. func (db *PGDatabase) DeleteAccount(ctx context.Context, id model.DBID) error { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = $1`, id) if err != nil { return fmt.Errorf("error deleting account: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound } return nil }); err != nil { return fmt.Errorf("error running transaction: %w", err) } return nil } // CreateTags creates new tags from submitted objects. func (db *PGDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) { if len(tags) == 0 { return []model.Tag{}, nil } // Create insert builder with RETURNING clause sb := sqlbuilder.NewInsertBuilder() sb.InsertInto("tag") sb.Cols("name") // Add values for each tag for _, tag := range tags { sb.Values(tag.Name) } // Build query with RETURNING id query, args := sb.Build() query = query + " RETURNING id" query = db.WriterDB().Rebind(query) // Create a slice to hold the created tags createdTags := make([]model.Tag, len(tags)) copy(createdTags, tags) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Execute the query and scan the returned IDs rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to execute tag creation query: %w", err) } defer rows.Close() // Scan the returned IDs into the tags i := 0 for rows.Next() { if i >= len(createdTags) { break } if err := rows.Scan(&createdTags[i].ID); err != nil { return fmt.Errorf("failed to scan tag ID: %w", err) } i++ } if err := rows.Err(); err != nil { return fmt.Errorf("error iterating over result rows: %w", err) } return nil }); err != nil { return nil, fmt.Errorf("failed to run tag creation transaction: %w", err) } return createdTags, nil } // CreateTag creates a new tag in database. func (db *PGDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) { // Use CreateTags to implement this method createdTags, err := db.CreateTags(ctx, tag) if err != nil { return model.Tag{}, err } if len(createdTags) == 0 { return model.Tag{}, fmt.Errorf("failed to create tag") } return createdTags[0], nil } // RenameTag change the name of a tag. func (db *PGDatabase) RenameTag(ctx context.Context, id int, newName string) error { sb := sqlbuilder.NewUpdateBuilder() sb.Update("tag") sb.Set(sb.Assign("name", newName)) sb.Where(sb.Equal("id", id)) query, args := sb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to rename tag: %w", err) } return nil }); err != nil { return err } return nil } // GetTag fetch a tag by its ID. func (db *PGDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) { sb := sqlbuilder.NewSelectBuilder() sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") sb.From("tag t") sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") sb.Where(sb.Equal("t.id", id)) sb.GroupBy("t.id") sb.OrderBy("t.name") query, args := sb.Build() query = db.ReaderDB().Rebind(query) var tag model.TagDTO err := db.ReaderDB().GetContext(ctx, &tag, query, args...) if err == sql.ErrNoRows { return model.TagDTO{}, false, nil } if err != nil { return model.TagDTO{}, false, fmt.Errorf("failed to get tag: %w", err) } return tag, true, nil } // UpdateTag updates a tag in the database. func (db *PGDatabase) UpdateTag(ctx context.Context, tag model.Tag) error { sb := sqlbuilder.NewUpdateBuilder() sb.Update("tag") sb.Set(sb.Assign("name", tag.Name)) sb.Where(sb.Equal("id", tag.ID)) query, args := sb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update tag: %w", err) } return nil }); err != nil { return err } return nil } // DeleteTag removes a tag from the database. func (db *PGDatabase) DeleteTag(ctx context.Context, id int) error { // First, check if the tag exists _, exists, err := db.GetTag(ctx, id) if err != nil { return fmt.Errorf("failed to check if tag exists: %w", err) } if !exists { return ErrNotFound } // Delete all bookmark_tag associations deleteAssocSb := sqlbuilder.NewDeleteBuilder() deleteAssocSb.DeleteFrom("bookmark_tag") deleteAssocSb.Where(deleteAssocSb.Equal("tag_id", id)) deleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build() deleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery) // Then, delete the tag itself deleteTagSb := sqlbuilder.NewDeleteBuilder() deleteTagSb.DeleteFrom("tag") deleteTagSb.Where(deleteTagSb.Equal("id", id)) deleteTagQuery, deleteTagArgs := deleteTagSb.Build() deleteTagQuery = db.WriterDB().Rebind(deleteTagQuery) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Delete bookmark_tag associations _, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...) if err != nil { return fmt.Errorf("failed to delete tag associations: %w", err) } // Delete the tag _, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...) if err != nil { return fmt.Errorf("failed to delete tag: %w", err) } return nil }); err != nil { return err } return nil } // SaveBookmark saves a single bookmark to database without handling tags. // It only updates the bookmark data in the database. func (db *PGDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error { if bookmark.ID <= 0 { return fmt.Errorf("bookmark ID must be greater than 0") } bookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat) // Check URL and title if bookmark.URL == "" { return errors.New("URL must not be empty") } if bookmark.Title == "" { return errors.New("title must not be empty") } // Use sqlbuilder to build the update query sb := sqlbuilder.NewUpdateBuilder() sb.Update("bookmark") sb.Set( sb.Assign("url", bookmark.URL), sb.Assign("title", bookmark.Title), sb.Assign("excerpt", bookmark.Excerpt), sb.Assign("author", bookmark.Author), sb.Assign("public", bookmark.Public), sb.Assign("modified_at", bookmark.ModifiedAt), sb.Assign("has_content", bookmark.HasContent), ) sb.Where(sb.Equal("id", bookmark.ID)) query, args := sb.Build() query = db.WriterDB().Rebind(query) return db.withTx(ctx, func(tx *sqlx.Tx) error { // Update bookmark _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update bookmark: %w", err) } return nil }) } // BulkUpdateBookmarkTags updates tags for multiple bookmarks. // It ensures that all bookmarks and tags exist before proceeding. func (db *PGDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { if len(bookmarkIDs) == 0 || len(tagIDs) == 0 { return nil } // Convert int slices to interface slices for sqlbuilder bookmarkIDsIface := make([]interface{}, len(bookmarkIDs)) for i, id := range bookmarkIDs { bookmarkIDsIface[i] = id } tagIDsIface := make([]interface{}, len(tagIDs)) for i, id := range tagIDs { tagIDsIface[i] = id } // Verify all bookmarks exist bookmarkSb := sqlbuilder.NewSelectBuilder() bookmarkSb.Select("id") bookmarkSb.From("bookmark") bookmarkSb.Where(bookmarkSb.In("id", bookmarkIDsIface...)) bookmarkQuery, bookmarkArgs := bookmarkSb.Build() bookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery) var existingBookmarkIDs []int err := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...) if err != nil { return fmt.Errorf("failed to check bookmarks: %w", err) } if len(existingBookmarkIDs) != len(bookmarkIDs) { // Find which bookmarks don't exist missingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs) return fmt.Errorf("some bookmarks do not exist: %v", missingBookmarkIDs) } // Verify all tags exist tagSb := sqlbuilder.NewSelectBuilder() tagSb.Select("id") tagSb.From("tag") tagSb.Where(tagSb.In("id", tagIDsIface...)) tagQuery, tagArgs := tagSb.Build() tagQuery = db.ReaderDB().Rebind(tagQuery) var existingTagIDs []int err = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...) if err != nil { return fmt.Errorf("failed to check tags: %w", err) } if len(existingTagIDs) != len(tagIDs) { // Find which tags don't exist missingTagIDs := model.SliceDifference(tagIDs, existingTagIDs) return fmt.Errorf("some tags do not exist: %v", missingTagIDs) } return db.withTx(ctx, func(tx *sqlx.Tx) error { // Delete existing bookmark-tag associations deleteSb := sqlbuilder.NewDeleteBuilder() deleteSb.DeleteFrom("bookmark_tag") deleteSb.Where(deleteSb.In("bookmark_id", bookmarkIDsIface...)) deleteQuery, deleteArgs := deleteSb.Build() deleteQuery = tx.Rebind(deleteQuery) _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...) if err != nil { return fmt.Errorf("failed to delete existing bookmark tags: %w", err) } // Insert new bookmark-tag associations if len(tagIDs) > 0 { // Build values for bulk insert insertSb := sqlbuilder.NewInsertBuilder() insertSb.InsertInto("bookmark_tag") insertSb.Cols("bookmark_id", "tag_id") for _, bookmarkID := range bookmarkIDs { for _, tagID := range tagIDs { insertSb.Values(bookmarkID, tagID) } } insertQuery, insertArgs := insertSb.Build() insertQuery = tx.Rebind(insertQuery) _, err = tx.ExecContext(ctx, insertQuery, insertArgs...) if err != nil { return fmt.Errorf("failed to insert bookmark tags: %w", err) } } return nil }) } ================================================ FILE: internal/database/pg_test.go ================================================ //go:build !test_sqlite_only // +build !test_sqlite_only package database import ( "context" "log" "os" "testing" "github.com/go-shiori/shiori/internal/model" ) func init() { connString := os.Getenv("SHIORI_TEST_PG_URL") if connString == "" { log.Fatal("psql tests can't run without a PSQL database, set SHIORI_TEST_PG_URL environment variable") } } func postgresqlTestDatabaseFactory(_ *testing.T, ctx context.Context) (model.DB, error) { db, err := OpenPGDatabase(ctx, os.Getenv("SHIORI_TEST_PG_URL")) if err != nil { return nil, err } _, err = db.ExecContext(ctx, "DROP SCHEMA public CASCADE; CREATE SCHEMA public;") if err != nil { return nil, err } if err := db.Migrate(context.TODO()); err != nil { return nil, err } return db, nil } func TestPostgresDatabase(t *testing.T) { testDatabase(t, postgresqlTestDatabaseFactory) } ================================================ FILE: internal/database/sqlite.go ================================================ package database import ( "context" "database/sql" "fmt" "log" "runtime" "strings" "time" "github.com/go-shiori/shiori/internal/model" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pkg/errors" "slices" _ "modernc.org/sqlite" ) var sqliteMigrations = []migration{ newFileMigration("0.0.0", "0.1.0", "sqlite/0000_system"), newFileMigration("0.1.0", "0.2.0", "sqlite/0001_initial"), newFuncMigration("0.2.0", "0.3.0", func(db *sql.DB) error { // Ensure that bookmark table has `has_content` column and account table has `config` column // for users upgrading from <1.5.4 directly into this version. tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() _, err = tx.Exec(`ALTER TABLE bookmark ADD COLUMN has_content BOOLEAN DEFAULT FALSE NOT NULL`) if err != nil && strings.Contains(err.Error(), `duplicate column name`) { tx.Rollback() } else if err != nil { return fmt.Errorf("failed to add has_content column to bookmark table: %w", err) } else if err == nil { if errCommit := tx.Commit(); errCommit != nil { return fmt.Errorf("failed to commit transaction: %w", errCommit) } } tx, err = db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() _, err = tx.Exec(`ALTER TABLE account ADD COLUMN config JSON NOT NULL DEFAULT '{}'`) if err != nil && strings.Contains(err.Error(), `duplicate column name`) { tx.Rollback() } else if err != nil { return fmt.Errorf("failed to add config column to account table: %w", err) } else if err == nil { if errCommit := tx.Commit(); errCommit != nil { return fmt.Errorf("failed to commit transaction: %w", errCommit) } } return nil }), newFileMigration("0.3.0", "0.4.0", "sqlite/0002_denormalize_content"), newFileMigration("0.4.0", "0.5.0", "sqlite/0003_uniq_id"), newFileMigration("0.5.0", "0.6.0", "sqlite/0004_created_time"), } // SQLiteDatabase is implementation of Database interface // for connecting to SQLite3 database. type SQLiteDatabase struct { dbbase } // withTx executes the given function within a transaction. // If the function returns an error, the transaction is rolled back. // Otherwise, the transaction is committed. func (db *SQLiteDatabase) withTx(ctx context.Context, fn func(tx *sqlx.Tx) error) error { tx, err := db.writer.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() // Will be a no-op if tx.Commit() is called if err := fn(tx); err != nil { // Return the error directly without wrapping return fmt.Errorf("transaction failed: %w", err) } if err := tx.Commit(); err != nil { return err } return nil } // withTxRetry executes the given function within a transaction with retry logic. // It will retry up to 3 times if the database is locked, with exponential backoff. // For other errors, it returns immediately. func (db *SQLiteDatabase) withTxRetry(ctx context.Context, fn func(tx *sqlx.Tx) error) error { maxRetries := 3 var lastErr error for i := 0; i < maxRetries; i++ { err := db.withTx(ctx, fn) if err == nil { return nil } if strings.Contains(err.Error(), "database is locked") { lastErr = err time.Sleep(time.Duration(i+1) * 100 * time.Millisecond) continue } return fmt.Errorf("transaction failed after retry: %w", err) } return fmt.Errorf("transaction failed after max retries, last error: %w", lastErr) } // Init sets up the SQLite database with optimal settings for both reader and writer connections func (db *SQLiteDatabase) Init(ctx context.Context) error { // Initialize both connections with appropriate settings for _, conn := range []*sqlx.DB{db.WriterDB(), db.ReaderDB()} { // Reuse connections for up to one hour conn.SetConnMaxLifetime(time.Hour) // Enable WAL mode for better concurrency if _, err := conn.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil { return fmt.Errorf("failed to set journal mode: %w", err) } // Set busy timeout to avoid "database is locked" errors if _, err := conn.ExecContext(ctx, `PRAGMA busy_timeout=5000`); err != nil { return fmt.Errorf("failed to set busy timeout: %w", err) } // Other performance and reliability settings pragmas := []string{ `PRAGMA synchronous=NORMAL`, `PRAGMA cache_size=-2000`, // Use 2MB of memory for cache `PRAGMA foreign_keys=ON`, } for _, pragma := range pragmas { if _, err := conn.ExecContext(ctx, pragma); err != nil { return fmt.Errorf("failed to set pragma %s: %w", pragma, err) } } } // Use a single connection on the writer to avoid database is locked errors db.writer.SetMaxOpenConns(1) // Set maximum idle connections for the reader to number of CPUs (maxing at 4) db.reader.SetMaxIdleConns(max(4, runtime.NumCPU())) return nil } type bookmarkContent struct { ID int `db:"docid"` Content string `db:"content"` HTML string `db:"html"` } // DBX returns the underlying sqlx.DB object for writes func (db *SQLiteDatabase) WriterDB() *sqlx.DB { return db.dbbase.WriterDB() } // ReaderDBx returns the underlying sqlx.DB object for reading func (db *SQLiteDatabase) ReaderDB() *sqlx.DB { return db.dbbase.ReaderDB() } // Migrate runs migrations for this database engine func (db *SQLiteDatabase) Migrate(ctx context.Context) error { if err := runMigrations(ctx, db, sqliteMigrations); err != nil { return fmt.Errorf("failed to run migrations: %w", err) } return nil } // GetDatabaseSchemaVersion fetches the current migrations version of the database func (db *SQLiteDatabase) GetDatabaseSchemaVersion(ctx context.Context) (string, error) { var version string err := db.reader.GetContext(ctx, &version, "SELECT database_schema_version FROM shiori_system") if err != nil { return "", fmt.Errorf("failed to get database schema version: %w", err) } return version, nil } // SetDatabaseSchemaVersion sets the current migrations version of the database func (db *SQLiteDatabase) SetDatabaseSchemaVersion(ctx context.Context, version string) error { if err := db.withTxRetry(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, "UPDATE shiori_system SET database_schema_version = ?", version) if err != nil { return err } return nil }); err != nil { return fmt.Errorf("failed to set database schema version: %w", err) } return nil } // SaveBookmarks saves new or updated bookmarks to database. // Returns the saved ID and error message if any happened. func (db *SQLiteDatabase) SaveBookmarks(ctx context.Context, create bool, bookmarks ...model.BookmarkDTO) ([]model.BookmarkDTO, error) { var result []model.BookmarkDTO if err := db.withTxRetry(ctx, func(tx *sqlx.Tx) error { // Prepare statement stmtInsertBook, err := tx.PreparexContext(ctx, `INSERT INTO bookmark (url, title, excerpt, author, public, modified_at, has_content, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare insert book statement: %w", err) } stmtUpdateBook, err := tx.PreparexContext(ctx, `UPDATE bookmark SET url = ?, title = ?, excerpt = ?, author = ?, public = ?, modified_at = ?, has_content = ? WHERE id = ?`) if err != nil { return fmt.Errorf("failed to prepare update book statement: %w", err) } stmtInsertBookContent, err := tx.PreparexContext(ctx, `INSERT OR REPLACE INTO bookmark_content (docid, title, content, html) VALUES (?, ?, ?, ?)`) if err != nil { return fmt.Errorf("failed to prepare insert book content statement: %w", err) } stmtUpdateBookContent, err := tx.PreparexContext(ctx, `UPDATE bookmark_content SET title = ?, content = ?, html = ? WHERE docid = ?`) if err != nil { return fmt.Errorf("failed to prepare update book content statement: %w", err) } stmtGetTag, err := tx.PreparexContext(ctx, `SELECT id FROM tag WHERE name = ?`) if err != nil { return fmt.Errorf("failed to prepare get tag statement: %w", err) } stmtInsertTag, err := tx.PreparexContext(ctx, `INSERT INTO tag (name) VALUES (?)`) if err != nil { return fmt.Errorf("failed to prepare insert tag statement: %w", err) } stmtInsertBookTag, err := tx.PreparexContext(ctx, `INSERT OR IGNORE INTO bookmark_tag (tag_id, bookmark_id) VALUES (?, ?)`) if err != nil { return fmt.Errorf("failed to prepare insert book tag statement: %w", err) } stmtDeleteBookTag, err := tx.PreparexContext(ctx, `DELETE FROM bookmark_tag WHERE bookmark_id = ? AND tag_id = ?`) if err != nil { return fmt.Errorf("failed to execute delete statement: %w", err) } // Prepare modified time modifiedTime := time.Now().UTC().Format(model.DatabaseDateFormat) // Execute statements for _, book := range bookmarks { // Check URL and title if book.URL == "" { return errors.New("URL must not be empty") } if book.Title == "" { return errors.New("title must not be empty") } // Set modified time if book.ModifiedAt == "" { book.ModifiedAt = modifiedTime } hasContent := book.Content != "" // Create or update bookmark var err error if create { book.CreatedAt = modifiedTime err = stmtInsertBook.QueryRowContext(ctx, book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.ModifiedAt, hasContent, book.CreatedAt).Scan(&book.ID) } else { _, err = stmtUpdateBook.ExecContext(ctx, book.URL, book.Title, book.Excerpt, book.Author, book.Public, book.ModifiedAt, hasContent, book.ID) } if err != nil { return fmt.Errorf("failed to delete bookmark content: %w", err) } // Try to update it first to check for existence, we can't do an UPSERT here because // bookmant_content is a virtual table res, err := stmtUpdateBookContent.ExecContext(ctx, book.Title, book.Content, book.HTML, book.ID) if err != nil { return fmt.Errorf("failed to delete bookmark tag: %w", err) } rows, err := res.RowsAffected() if err != nil { return fmt.Errorf("failed to delete bookmark: %w", err) } if rows == 0 { _, err = stmtInsertBookContent.ExecContext(ctx, book.ID, book.Title, book.Content, book.HTML) if err != nil { return fmt.Errorf("failed to execute delete bookmark tag statement: %w", err) } } // Save book tags newTags := []model.TagDTO{} for _, tag := range book.Tags { t := tag.ToDTO() // If it's deleted tag, delete and continue if t.Deleted { _, err = stmtDeleteBookTag.ExecContext(ctx, book.ID, tag.ID) if err != nil { return fmt.Errorf("failed to execute delete bookmark statement: %w", err) } continue } // Normalize tag name tagName := strings.ToLower(tag.Name) tagName = strings.Join(strings.Fields(tagName), " ") // If tag doesn't have any ID, fetch it from database if tag.ID == 0 { if err := stmtGetTag.GetContext(ctx, &tag.ID, tagName); err != nil && err != sql.ErrNoRows { return fmt.Errorf("failed to get tag ID: %w", err) } // If tag doesn't exist in database, save it if tag.ID == 0 { res, err := stmtInsertTag.ExecContext(ctx, tagName) if err != nil { return fmt.Errorf("failed to get last insert ID for tag: %w", err) } tagID64, err := res.LastInsertId() if err != nil && err != sql.ErrNoRows { return fmt.Errorf("failed to insert bookmark tag: %w", err) } tag.ID = int(tagID64) t.ID = int(tagID64) } if _, err := stmtInsertBookTag.ExecContext(ctx, tag.ID, book.ID); err != nil { return fmt.Errorf("failed to execute bookmark tag statement: %w", err) } } newTags = append(newTags, t) } book.Tags = newTags result = append(result, book) } return nil }); err != nil { return nil, fmt.Errorf("failed to execute select query for bookmark content: %w", err) } return result, nil } // GetBookmarks fetch list of bookmarks based on submitted options. func (db *SQLiteDatabase) GetBookmarks(ctx context.Context, opts model.DBGetBookmarksOptions) ([]model.BookmarkDTO, error) { // Create initial query query := `SELECT b.id, b.url, b.title, b.excerpt, b.author, b.public, b.created_at, b.modified_at, b.has_content FROM bookmark b WHERE 1` // Add where clause args := []interface{}{} // Add where clause for IDs if len(opts.IDs) > 0 { query += ` AND b.id IN (?)` args = append(args, opts.IDs) } // Add where clause for search keyword if opts.Keyword != "" { query += ` AND (b.url LIKE '%' || ? || '%' OR b.excerpt LIKE '%' || ? || '%' OR b.id IN ( SELECT docid id FROM bookmark_content WHERE title MATCH ? OR content MATCH ?))` args = append(args, opts.Keyword, opts.Keyword) // Replace dash with spaces since FTS5 uses `-name` as column identifier and double quote // since FTS5 uses double quote as string identifier // Reference: https://sqlite.org/fts5.html#fts5_strings ftsKeyword := strings.ReplaceAll(opts.Keyword, "-", " ") // Properly set double quotes for string literals in sqlite's fts ftsKeyword = strings.ReplaceAll(ftsKeyword, "\"", "\"\"") args = append(args, "\""+ftsKeyword+"\"", "\""+ftsKeyword+"\"") } // Add where clause for tags. // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false if slices.Contains(opts.ExcludedTags, "*") { excludeAllTags = true opts.ExcludedTags = []string{} } includeAllTags := false if slices.Contains(opts.Tags, "*") { includeAllTags = true opts.Tags = []string{} } // If all tags excluded, we will only show bookmark without tags. // In other hand, if all tags included, we will only show bookmark with tags. if excludeAllTags { query += ` AND b.id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } else if includeAllTags { query += ` AND b.id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } // Now we only need to find the normal tags if len(opts.Tags) > 0 { query += ` AND b.id IN ( SELECT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?) GROUP BY bt.bookmark_id HAVING COUNT(bt.bookmark_id) = ?)` args = append(args, opts.Tags, len(opts.Tags)) } if len(opts.ExcludedTags) > 0 { query += ` AND b.id NOT IN ( SELECT DISTINCT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?))` args = append(args, opts.ExcludedTags) } // Add order clause switch opts.OrderMethod { case model.ByLastAdded: query += ` ORDER BY b.id DESC` case model.ByLastModified: query += ` ORDER BY b.modified_at DESC` default: query += ` ORDER BY b.id` } if opts.Limit > 0 && opts.Offset >= 0 { query += ` LIMIT ? OFFSET ?` args = append(args, opts.Limit, opts.Offset) } // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { return nil, fmt.Errorf("failed to execute select query for tags: %w", err) } // Fetch bookmarks bookmarks := []model.BookmarkDTO{} err = db.reader.SelectContext(ctx, &bookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to fetch accounts: %w", err) } // store bookmark IDs for further enrichment var bookmarkIds = make([]int, 0, len(bookmarks)) for _, book := range bookmarks { bookmarkIds = append(bookmarkIds, book.ID) } if len(bookmarkIds) == 0 { return bookmarks, nil } // If content needed, fetch it separately // It's faster than join with virtual table if opts.WithContent { contents := make([]bookmarkContent, 0, len(bookmarks)) contentMap := make(map[int]bookmarkContent, len(bookmarks)) contentQuery, args, err := sqlx.In(`SELECT docid, content, html FROM bookmark_content WHERE docid IN (?)`, bookmarkIds) contentQuery = db.reader.Rebind(contentQuery) if err != nil { return nil, fmt.Errorf("failed to expand tags query with IN clause: %w", err) } err = db.reader.Select(&contents, contentQuery, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to get tags: %w", err) } for _, content := range contents { contentMap[content.ID] = content } for i := range bookmarks[:] { book := &bookmarks[i] if bookmarkContent, found := contentMap[book.ID]; found { book.Content = bookmarkContent.Content book.HTML = bookmarkContent.HTML } else { log.Printf("not found content for bookmark %d, but it should be; check DB consistency", book.ID) } } } // Fetch tags for each bookmark for i, book := range bookmarks { tags, err := db.getTagsForBookmark(ctx, book.ID) if err != nil { return nil, fmt.Errorf("failed to get tags: %w", err) } bookmarks[i].Tags = tags } return bookmarks, nil } func (db *SQLiteDatabase) getTagsForBookmark(ctx context.Context, bookmarkID int) ([]model.TagDTO, error) { sb := sqlbuilder.SQLite.NewSelectBuilder() sb.Select("t.id", "t.name") sb.From("bookmark_tag bt") sb.JoinWithOption(sqlbuilder.LeftJoin, "tag t", "bt.tag_id = t.id") sb.Where(sb.Equal("bt.bookmark_id", bookmarkID)) sb.OrderBy("t.name") query, args := sb.Build() query = db.ReaderDB().Rebind(query) tags := []model.TagDTO{} err := db.ReaderDB().SelectContext(ctx, &tags, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to get tags: %w", err) } return tags, nil } // GetBookmarksCount fetch count of bookmarks based on submitted options. func (db *SQLiteDatabase) GetBookmarksCount(ctx context.Context, opts model.DBGetBookmarksOptions) (int, error) { // Create initial query query := `SELECT COUNT(b.id) FROM bookmark b WHERE 1` // Add where clause args := []interface{}{} // Add where clause for IDs if len(opts.IDs) > 0 { query += ` AND b.id IN (?)` args = append(args, opts.IDs) } // Add where clause for search keyword if opts.Keyword != "" { query += ` AND (b.url LIKE '%' || ? || '%' OR b.excerpt LIKE '%' || ? || '%' OR b.id IN ( SELECT docid id FROM bookmark_content WHERE title MATCH ? OR content MATCH ?))` args = append(args, opts.Keyword, opts.Keyword) // Replace dash with spaces since FTS5 uses `-name` as column identifier and double quote // since FTS5 uses double quote as string identifier // Reference: https://sqlite.org/fts5.html#fts5_strings ftsKeyword := strings.ReplaceAll(opts.Keyword, "-", " ") // Properly set double quotes for string literals in sqlite's fts ftsKeyword = strings.ReplaceAll(ftsKeyword, "\"", "\"\"") args = append(args, "\""+ftsKeyword+"\"", "\""+ftsKeyword+"\"") } // Add where clause for tags. // First we check for * in excluded and included tags, // which means all tags will be excluded and included, respectively. excludeAllTags := false for _, excludedTag := range opts.ExcludedTags { if excludedTag == "*" { excludeAllTags = true opts.ExcludedTags = []string{} break } } includeAllTags := false for _, includedTag := range opts.Tags { if includedTag == "*" { includeAllTags = true opts.Tags = []string{} break } } // If all tags excluded, we will only show bookmark without tags. // In other hand, if all tags included, we will only show bookmark with tags. if excludeAllTags { query += ` AND b.id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } else if includeAllTags { query += ` AND b.id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)` } // Now we only need to find the normal tags if len(opts.Tags) > 0 { query += ` AND b.id IN ( SELECT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?) GROUP BY bt.bookmark_id HAVING COUNT(bt.bookmark_id) = ?)` args = append(args, opts.Tags, len(opts.Tags)) } if len(opts.ExcludedTags) > 0 { query += ` AND b.id NOT IN ( SELECT DISTINCT bt.bookmark_id FROM bookmark_tag bt LEFT JOIN tag t ON bt.tag_id = t.id WHERE t.name IN(?))` args = append(args, opts.ExcludedTags) } // Expand query, because some of the args might be an array query, args, err := sqlx.In(query, args...) if err != nil { return 0, fmt.Errorf("failed to expand query with IN clause: %w", err) } // Fetch count var nBookmarks int err = db.reader.GetContext(ctx, &nBookmarks, query, args...) if err != nil && err != sql.ErrNoRows { return 0, fmt.Errorf("failed to get bookmark count: %w", err) } return nBookmarks, nil } // DeleteBookmarks removes all record with matching ids from database. func (db *SQLiteDatabase) DeleteBookmarks(ctx context.Context, ids ...int) error { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Prepare queries delBookmark := `DELETE FROM bookmark` delBookmarkTag := `DELETE FROM bookmark_tag` delBookmarkContent := `DELETE FROM bookmark_content` // Delete bookmark(s) if len(ids) == 0 { _, err := tx.ExecContext(ctx, delBookmarkContent) if err != nil { return fmt.Errorf("failed to prepare delete statement: %w", err) } _, err = tx.ExecContext(ctx, delBookmarkTag) if err != nil { return fmt.Errorf("failed to execute delete account statement: %w", err) } _, err = tx.ExecContext(ctx, delBookmark) if err != nil { return fmt.Errorf("failed to execute delete bookmark statement: %w", err) } } else { delBookmark += ` WHERE id = ?` delBookmarkTag += ` WHERE bookmark_id = ?` delBookmarkContent += ` WHERE docid = ?` stmtDelBookmark, err := tx.Preparex(delBookmark) if err != nil { return fmt.Errorf("failed to get bookmark: %w", err) } stmtDelBookmarkTag, err := tx.Preparex(delBookmarkTag) if err != nil { return fmt.Errorf("failed to expand query with IN clause: %w", err) } stmtDelBookmarkContent, err := tx.Preparex(delBookmarkContent) if err != nil { return fmt.Errorf("failed to delete bookmark content: %w", err) } for _, id := range ids { _, err = stmtDelBookmarkContent.ExecContext(ctx, id) if err != nil { return fmt.Errorf("failed to delete bookmark: %w", err) } _, err = stmtDelBookmarkTag.ExecContext(ctx, id) if err != nil { return fmt.Errorf("failed to delete bookmark tag: %w", err) } _, err = stmtDelBookmark.ExecContext(ctx, id) if err != nil { return fmt.Errorf("failed to delete bookmark: %w", err) } } } return nil }); err != nil { return fmt.Errorf("failed to update database schema version: %w", err) } return nil } // GetBookmark fetches bookmark based on its ID or URL. // Returns the bookmark and boolean whether it's exist or not. func (db *SQLiteDatabase) GetBookmark(ctx context.Context, id int, url string) (model.BookmarkDTO, bool, error) { // Create the main query builder for bookmark data sb := sqlbuilder.NewSelectBuilder() sb.Select( "b.id", "b.url", "b.title", "b.excerpt", "b.author", "b.public", "b.modified_at", "bc.content", "bc.html", "b.has_content", "b.created_at") sb.From("bookmark b") sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_content bc", "bc.docid = b.id") // Add conditions if id != 0 { sb.Where(sb.Equal("b.id", id)) } else if url != "" { sb.Where(sb.Equal("b.url", url)) } else { return model.BookmarkDTO{}, false, fmt.Errorf("id or url is required") } // Build the query query, args := sb.Build() query = db.ReaderDB().Rebind(query) // Execute the query book := model.BookmarkDTO{} err := db.ReaderDB().GetContext(ctx, &book, query, args...) if err != nil { if err == sql.ErrNoRows { return book, false, nil } return book, false, fmt.Errorf("failed to get bookmark: %w", err) } // If bookmark exists, fetch its tags if book.ID != 0 { // Create query builder for tags tagSb := sqlbuilder.NewSelectBuilder() tagSb.Select("t.id", "t.name") tagSb.From("tag t") tagSb.JoinWithOption(sqlbuilder.InnerJoin, "bookmark_tag bt", "bt.tag_id = t.id") tagSb.Where(tagSb.Equal("bt.bookmark_id", book.ID)) // Build the query tagQuery, tagArgs := tagSb.Build() tagQuery = db.ReaderDB().Rebind(tagQuery) // Execute the query tags := []model.TagDTO{} err = db.ReaderDB().SelectContext(ctx, &tags, tagQuery, tagArgs...) if err != nil && err != sql.ErrNoRows { return book, false, fmt.Errorf("failed to get bookmark tags: %w", err) } book.Tags = tags } return book, true, nil } // CreateAccount saves new account to database. Returns error if any happened. func (db *SQLiteDatabase) CreateAccount(ctx context.Context, account model.Account) (*model.Account, error) { var accountID int64 if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Check if username already exists var exists bool err := tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM account WHERE username = ?)", account.Username) if err != nil { return fmt.Errorf("error checking username existence: %w", err) } if exists { return ErrAlreadyExists } // Insert new account query, err := tx.PrepareContext(ctx, `INSERT INTO account (username, password, owner, config) VALUES (?, ?, ?, ?) RETURNING id`) if err != nil { return fmt.Errorf("error preparing query: %w", err) } err = query.QueryRowContext(ctx, account.Username, account.Password, account.Owner, account.Config).Scan(&accountID) if err != nil { return fmt.Errorf("error executing query: %w", err) } return nil }); err != nil { return nil, fmt.Errorf("error running transaction: %w", err) } account.ID = model.DBID(accountID) return &account, nil } // UpdateAccount updates account in database. func (db *SQLiteDatabase) UpdateAccount(ctx context.Context, account model.Account) error { if account.ID == 0 { return ErrNotFound } if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Check if username already exists var exists bool err := tx.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM account WHERE username = ? AND id != ?)", account.Username, account.ID) if err != nil { return fmt.Errorf("error checking username existence: %w", err) } if exists { return ErrAlreadyExists } result, err := tx.ExecContext(ctx, `UPDATE account SET username = ?, password = ?, owner = ?, config = ? WHERE id = ?`, account.Username, account.Password, account.Owner, account.Config, account.ID) if err != nil { return fmt.Errorf("error updating account: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound } return nil }); err != nil { return fmt.Errorf("error running transaction: %w", err) } return nil } // ListAccounts fetch list of account (without its password) based on submitted options. func (db *SQLiteDatabase) ListAccounts(ctx context.Context, opts model.DBListAccountsOptions) ([]model.Account, error) { // Create query args := []interface{}{} fields := []string{"id", "username", "owner", "config"} if opts.WithPassword { fields = append(fields, "password") } query := fmt.Sprintf(`SELECT %s FROM account WHERE 1`, strings.Join(fields, ", ")) if opts.Keyword != "" { query += " AND username LIKE ?" args = append(args, "%"+opts.Keyword+"%") } if opts.Username != "" { query += " AND username = ?" args = append(args, opts.Username) } if opts.Owner { query += " AND owner = 1" } // Fetch list account accounts := []model.Account{} err := db.reader.SelectContext(ctx, &accounts, query, args...) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("failed to execute select query: %w", err) } return accounts, nil } // GetAccount fetch account with matching ID. // Returns the account and boolean whether it's exist or not. func (db *SQLiteDatabase) GetAccount(ctx context.Context, id model.DBID) (*model.Account, bool, error) { account := model.Account{} err := db.ReaderDB().GetContext(ctx, &account, `SELECT id, username, password, owner, config FROM account WHERE id = ?`, id, ) if err != nil { if err == sql.ErrNoRows { return &account, false, ErrNotFound } return &account, false, fmt.Errorf("error getting account: %w", err) } return &account, true, nil } // DeleteAccount removes record with matching ID. func (db *SQLiteDatabase) DeleteAccount(ctx context.Context, id model.DBID) error { if err := db.withTx(ctx, func(tx *sqlx.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM account WHERE id = ?`, id) if err != nil { return fmt.Errorf("error deleting account: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("error getting rows affected: %w", err) } if rows == 0 { return ErrNotFound } return nil }); err != nil { return fmt.Errorf("error running transaction: %w", err) } return nil } // CreateTags creates new tags from submitted objects. func (db *SQLiteDatabase) CreateTags(ctx context.Context, tags ...model.Tag) ([]model.Tag, error) { if len(tags) == 0 { return []model.Tag{}, nil } // Create a slice to hold the created tags createdTags := make([]model.Tag, len(tags)) copy(createdTags, tags) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // For SQLite, we need to insert tags one by one to get their IDs stmtInsertTag, err := tx.PrepareContext(ctx, "INSERT INTO tag (name) VALUES (?)") if err != nil { return fmt.Errorf("failed to prepare tag insertion statement: %w", err) } defer stmtInsertTag.Close() // Insert each tag and get its ID for i, tag := range createdTags { result, err := stmtInsertTag.ExecContext(ctx, tag.Name) if err != nil { return fmt.Errorf("failed to insert tag: %w", err) } // Get the last inserted ID tagID, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get last insert ID: %w", err) } createdTags[i].ID = int(tagID) } return nil }); err != nil { return nil, fmt.Errorf("failed to run tag creation transaction: %w", err) } return createdTags, nil } // CreateTag creates a new tag in database. func (db *SQLiteDatabase) CreateTag(ctx context.Context, tag model.Tag) (model.Tag, error) { // Use CreateTags to implement this method createdTags, err := db.CreateTags(ctx, tag) if err != nil { return model.Tag{}, err } if len(createdTags) == 0 { return model.Tag{}, fmt.Errorf("failed to create tag") } return createdTags[0], nil } // RenameTag change the name of a tag. func (db *SQLiteDatabase) RenameTag(ctx context.Context, id int, newName string) error { sb := sqlbuilder.NewUpdateBuilder() sb.Update("tag") sb.Set(sb.Assign("name", newName)) sb.Where(sb.Equal("id", id)) query, args := sb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to rename tag: %w", err) } return nil }); err != nil { return err } return nil } // GetTag fetch a tag by its ID. func (db *SQLiteDatabase) GetTag(ctx context.Context, id int) (model.TagDTO, bool, error) { sb := sqlbuilder.SQLite.NewSelectBuilder() sb.Select("t.id", "t.name", "COUNT(bt.tag_id) bookmark_count") sb.From("tag t") sb.JoinWithOption(sqlbuilder.LeftJoin, "bookmark_tag bt", "bt.tag_id = t.id") sb.Where(sb.Equal("t.id", id)) sb.GroupBy("t.id") sb.OrderBy("t.name") query, args := sb.Build() query = db.ReaderDB().Rebind(query) var tag model.TagDTO err := db.ReaderDB().GetContext(ctx, &tag, query, args...) if err == sql.ErrNoRows { return model.TagDTO{}, false, nil } if err != nil { return model.TagDTO{}, false, fmt.Errorf("failed to get tag: %w", err) } return tag, true, nil } // UpdateTag updates a tag in the database. func (db *SQLiteDatabase) UpdateTag(ctx context.Context, tag model.Tag) error { sb := sqlbuilder.NewUpdateBuilder() sb.Update("tag") sb.Set(sb.Assign("name", tag.Name)) sb.Where(sb.Equal("id", tag.ID)) query, args := sb.Build() query = db.WriterDB().Rebind(query) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update tag: %w", err) } return nil }); err != nil { return err } return nil } // DeleteTag removes a tag from the database. func (db *SQLiteDatabase) DeleteTag(ctx context.Context, id int) error { // First, check if the tag exists _, exists, err := db.GetTag(ctx, id) if err != nil { return fmt.Errorf("failed to check if tag exists: %w", err) } if !exists { return ErrNotFound } // Delete all bookmark_tag associations deleteAssocSb := sqlbuilder.NewDeleteBuilder() deleteAssocSb.DeleteFrom("bookmark_tag") deleteAssocSb.Where(deleteAssocSb.Equal("tag_id", id)) deleteAssocQuery, deleteAssocArgs := deleteAssocSb.Build() deleteAssocQuery = db.WriterDB().Rebind(deleteAssocQuery) // Then, delete the tag itself deleteTagSb := sqlbuilder.NewDeleteBuilder() deleteTagSb.DeleteFrom("tag") deleteTagSb.Where(deleteTagSb.Equal("id", id)) deleteTagQuery, deleteTagArgs := deleteTagSb.Build() deleteTagQuery = db.WriterDB().Rebind(deleteTagQuery) if err := db.withTx(ctx, func(tx *sqlx.Tx) error { // Delete bookmark_tag associations _, err := tx.ExecContext(ctx, deleteAssocQuery, deleteAssocArgs...) if err != nil { return fmt.Errorf("failed to delete tag associations: %w", err) } // Delete the tag _, err = tx.ExecContext(ctx, deleteTagQuery, deleteTagArgs...) if err != nil { return fmt.Errorf("failed to delete tag: %w", err) } return nil }); err != nil { return err } return nil } // SaveBookmark saves a single bookmark to database without handling tags. // It only updates the bookmark data in the database. func (db *SQLiteDatabase) SaveBookmark(ctx context.Context, bookmark model.Bookmark) error { if bookmark.ID <= 0 { return fmt.Errorf("bookmark ID must be greater than 0") } bookmark.ModifiedAt = time.Now().UTC().Format(model.DatabaseDateFormat) // Check URL and title if bookmark.URL == "" { return errors.New("URL must not be empty") } if bookmark.Title == "" { return errors.New("title must not be empty") } // Use sqlbuilder to build the update query sb := sqlbuilder.NewUpdateBuilder() sb.Update("bookmark") sb.Set( sb.Assign("url", bookmark.URL), sb.Assign("title", bookmark.Title), sb.Assign("excerpt", bookmark.Excerpt), sb.Assign("author", bookmark.Author), sb.Assign("public", bookmark.Public), sb.Assign("modified_at", bookmark.ModifiedAt), sb.Assign("has_content", bookmark.HasContent), ) sb.Where(sb.Equal("id", bookmark.ID)) query, args := sb.Build() query = db.WriterDB().Rebind(query) return db.withTx(ctx, func(tx *sqlx.Tx) error { // Update bookmark _, err := tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update bookmark: %w", err) } return nil }) } // BulkUpdateBookmarkTags updates tags for multiple bookmarks. // It ensures that all bookmarks and tags exist before proceeding. func (db *SQLiteDatabase) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { if len(bookmarkIDs) == 0 || len(tagIDs) == 0 { return nil } // Convert int slices to any slices for sqlbuilder bookmarkIDsIface := make([]any, len(bookmarkIDs)) for i, id := range bookmarkIDs { bookmarkIDsIface[i] = id } // Verify all bookmarks exist bookmarkSb := sqlbuilder.NewSelectBuilder() bookmarkSb.Select("id") bookmarkSb.From("bookmark") bookmarkSb.Where(bookmarkSb.In("id", bookmarkIDsIface...)) bookmarkQuery, bookmarkArgs := bookmarkSb.Build() bookmarkQuery = db.ReaderDB().Rebind(bookmarkQuery) var existingBookmarkIDs []int err := db.ReaderDB().SelectContext(ctx, &existingBookmarkIDs, bookmarkQuery, bookmarkArgs...) if err != nil { return fmt.Errorf("failed to check bookmarks: %w", err) } if len(existingBookmarkIDs) != len(bookmarkIDs) { // Find which bookmarks don't exist missingBookmarkIDs := model.SliceDifference(bookmarkIDs, existingBookmarkIDs) return fmt.Errorf("some bookmarks do not exist: %v", missingBookmarkIDs) } tagIDsIface := make([]any, len(tagIDs)) for i, id := range tagIDs { tagIDsIface[i] = id } // Verify all tags exist tagSb := sqlbuilder.NewSelectBuilder() tagSb.Select("id") tagSb.From("tag") tagSb.Where(tagSb.In("id", tagIDsIface...)) tagQuery, tagArgs := tagSb.Build() tagQuery = db.ReaderDB().Rebind(tagQuery) var existingTagIDs []int err = db.ReaderDB().SelectContext(ctx, &existingTagIDs, tagQuery, tagArgs...) if err != nil { return fmt.Errorf("failed to check tags: %w", err) } if len(existingTagIDs) != len(tagIDs) { // Find which tags don't exist missingTagIDs := model.SliceDifference(tagIDs, existingTagIDs) return fmt.Errorf("some tags do not exist: %v", missingTagIDs) } return db.withTx(ctx, func(tx *sqlx.Tx) error { // Delete existing bookmark-tag associations deleteSb := sqlbuilder.NewDeleteBuilder() deleteSb.DeleteFrom("bookmark_tag") deleteSb.Where(deleteSb.In("bookmark_id", bookmarkIDsIface...)) deleteQuery, deleteArgs := deleteSb.Build() deleteQuery = tx.Rebind(deleteQuery) _, err := tx.ExecContext(ctx, deleteQuery, deleteArgs...) if err != nil { return fmt.Errorf("failed to delete existing bookmark tags: %w", err) } // Insert new bookmark-tag associations if len(tagIDs) > 0 { // Build insert statement for bookmark tags insertSb := sqlbuilder.NewInsertBuilder() // SQLite syntax for INSERT OR IGNORE insertSb.SQL("INSERT OR IGNORE INTO") insertSb.SQL("bookmark_tag") insertSb.SQL("(bookmark_id, tag_id)") insertSb.SQL("VALUES (?, ?)") insertQuery := insertSb.String() insertQuery = tx.Rebind(insertQuery) stmtInsertBookTag, err := tx.PreparexContext(ctx, insertQuery) if err != nil { return fmt.Errorf("failed to prepare insert book tag statement: %w", err) } defer stmtInsertBookTag.Close() // Insert new tags for _, bookmarkID := range bookmarkIDs { for _, tagID := range tagIDs { _, err := stmtInsertBookTag.ExecContext(ctx, bookmarkID, tagID) if err != nil { return fmt.Errorf("failed to insert bookmark tag: %w", err) } } } } return nil }) } ================================================ FILE: internal/database/sqlite_noncgo.go ================================================ //go:build linux || windows || darwin || freebsd // +build linux windows darwin freebsd package database import ( "context" "fmt" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" _ "modernc.org/sqlite" ) // OpenSQLiteDatabase creates and open connection to new SQLite3 database. func OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQLiteDatabase, err error) { // Open database rwDB, err := sqlx.ConnectContext(ctx, "sqlite", databasePath) if err != nil { return nil, fmt.Errorf("error opening writer database: %w", err) } rDB, err := sqlx.ConnectContext(ctx, "sqlite", databasePath) if err != nil { return nil, fmt.Errorf("error opening reader database: %w", err) } sqliteDB = &SQLiteDatabase{ dbbase: dbbase{ writer: rwDB, reader: rDB, flavor: sqlbuilder.SQLite, }, } if err := sqliteDB.Init(ctx); err != nil { return nil, fmt.Errorf("error initializing database: %w", err) } return sqliteDB, nil } ================================================ FILE: internal/database/sqlite_openbsd.go ================================================ //go:build openbsd // +build openbsd package database import ( "context" "fmt" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" _ "git.sr.ht/~emersion/go-sqlite3-fts5" _ "github.com/mattn/go-sqlite3" ) // OpenSQLiteDatabase creates and open connection to new SQLite3 database. func OpenSQLiteDatabase(ctx context.Context, databasePath string) (sqliteDB *SQLiteDatabase, err error) { // Open database rwDB, err := sqlx.ConnectContext(ctx, "sqlite", databasePath) if err != nil { return nil, fmt.Errorf("error opening writer database: %w", err) } rDB, err := sqlx.ConnectContext(ctx, "sqlite", databasePath) if err != nil { return nil, fmt.Errorf("error opening reader database: %w", err) } sqliteDB = &SQLiteDatabase{ dbbase: dbbase{ writer: rwDB, reader: rDB, flavor: sqlbuilder.SQLite, }, } if err := sqliteDB.Init(ctx); err != nil { return nil, fmt.Errorf("error initializing database: %w", err) } return sqliteDB, nil } ================================================ FILE: internal/database/sqlite_test.go ================================================ package database import ( "context" "os" "path/filepath" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func sqliteTestDatabaseFactory(t *testing.T, ctx context.Context) (model.DB, error) { tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) db, err := OpenSQLiteDatabase(ctx, filepath.Join(tmpDir, "shiori.db")) if err != nil { return nil, err } if err := db.Migrate(context.TODO()); err != nil { return nil, err } return db, nil } func TestSqliteDatabase(t *testing.T) { testDatabase(t, sqliteTestDatabaseFactory) testSqliteGetBookmarksWithDash(t) } // testSqliteGetBookmarksWithDash ad-hoc test for SQLite that checks that a match search against // the FTS5 engine does not fail by using dashes, making sqlite think that we are trying to avoid // matching a column name. This works in a fun way and it seems that it depends on the tokens // already scanned by the database, since trying to match for `go-shiori` with no bookmarks or only // the shiori bookmark does not fail, but it fails if we add any other bookmark to the database, hence // this test. func testSqliteGetBookmarksWithDash(t *testing.T) { ctx := context.TODO() db, err := sqliteTestDatabaseFactory(t, ctx) assert.NoError(t, err) book := model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori", Title: "shiori", } _, err = db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") book = model.BookmarkDTO{ URL: "https://github.com/jamiehannaford/what-happens-when-k8s", Title: "what-happens-when-k8s", } result, err := db.SaveBookmarks(ctx, true, book) assert.NoError(t, err, "Save bookmarks must not fail") savedBookmark := result[0] results, err := db.GetBookmarks(ctx, model.DBGetBookmarksOptions{ Keyword: "what-happens-when", }) assert.NoError(t, err, "Get bookmarks should not fail") assert.Len(t, results, 1, "results should contain one item") assert.Equal(t, savedBookmark.ID, results[0].ID, "bookmark should be the one saved") } ================================================ FILE: internal/dependencies/dependencies.go ================================================ package dependencies import ( "github.com/go-shiori/shiori/internal/config" "github.com/go-shiori/shiori/internal/model" "github.com/sirupsen/logrus" ) type Dependencies struct { log *logrus.Logger domains *domains config *config.Config database model.DB } func (d *Dependencies) Logger() *logrus.Logger { return d.log } func (d *Dependencies) Domains() model.DomainDependencies { return d.domains } func (d *Dependencies) Config() *config.Config { return d.config } func (d *Dependencies) Database() model.DB { return d.database } type domains struct { auth model.AuthDomain accounts model.AccountsDomain bookmarks model.BookmarksDomain archiver model.ArchiverDomain storage model.StorageDomain tags model.TagsDomain } func (d *domains) Auth() model.AuthDomain { return d.auth } func (d *domains) SetAuth(auth model.AuthDomain) { d.auth = auth } func (d *domains) Accounts() model.AccountsDomain { return d.accounts } func (d *domains) SetAccounts(accounts model.AccountsDomain) { d.accounts = accounts } func (d *domains) Bookmarks() model.BookmarksDomain { return d.bookmarks } func (d *domains) SetBookmarks(bookmarks model.BookmarksDomain) { d.bookmarks = bookmarks } func (d *domains) Archiver() model.ArchiverDomain { return d.archiver } func (d *domains) SetArchiver(archiver model.ArchiverDomain) { d.archiver = archiver } func (d *domains) Storage() model.StorageDomain { return d.storage } func (d *domains) SetStorage(storage model.StorageDomain) { d.storage = storage } func (d *domains) Tags() model.TagsDomain { return d.tags } func (d *domains) SetTags(tags model.TagsDomain) { d.tags = tags } var _ model.DomainDependencies = (*domains)(nil) func NewDependencies(log *logrus.Logger, db model.DB, cfg *config.Config) *Dependencies { return &Dependencies{ log: log, config: cfg, database: db, domains: &domains{}, } } ================================================ FILE: internal/domains/accounts.go ================================================ package domains import ( "context" "errors" "fmt" "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/dependencies" "github.com/go-shiori/shiori/internal/model" "golang.org/x/crypto/bcrypt" ) type AccountsDomain struct { deps *dependencies.Dependencies } func (d *AccountsDomain) ListAccounts(ctx context.Context) ([]model.AccountDTO, error) { accounts, err := d.deps.Database().ListAccounts(ctx, model.DBListAccountsOptions{}) if err != nil { return nil, fmt.Errorf("error getting accounts: %v", err) } accountDTOs := []model.AccountDTO{} for _, account := range accounts { accountDTOs = append(accountDTOs, account.ToDTO()) } return accountDTOs, nil } func (d *AccountsDomain) GetAccountByUsername(ctx context.Context, username string) (*model.AccountDTO, error) { if username == "" { return nil, errors.New("empty username") } accounts, err := d.deps.Database().ListAccounts(ctx, model.DBListAccountsOptions{ Username: username, }) if err != nil { return nil, fmt.Errorf("error getting accounts: %v", err) } if len(accounts) != 1 { return nil, fmt.Errorf("got none or more than one account by username: %s", username) } return model.Ptr(accounts[0].ToDTO()), nil } func (d *AccountsDomain) CreateAccount(ctx context.Context, account model.AccountDTO) (*model.AccountDTO, error) { if err := account.IsValidCreate(); err != nil { return nil, err } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("error hashing provided password: %w", err) } acc := model.Account{ Username: account.Username, Password: string(hashedPassword), } if account.Owner != nil { acc.Owner = *account.Owner } if account.Config != nil { acc.Config = *account.Config } storedAccount, err := d.deps.Database().CreateAccount(ctx, acc) if errors.Is(err, database.ErrAlreadyExists) { return nil, model.ErrAlreadyExists } if err != nil { return nil, fmt.Errorf("error creating account: %v", err) } result := storedAccount.ToDTO() return &result, nil } func (d *AccountsDomain) DeleteAccount(ctx context.Context, id int) error { err := d.deps.Database().DeleteAccount(ctx, model.DBID(id)) if errors.Is(err, database.ErrNotFound) { return model.ErrNotFound } if err != nil { return fmt.Errorf("error deleting account: %v", err) } return nil } func (d *AccountsDomain) UpdateAccount(ctx context.Context, account model.AccountDTO) (*model.AccountDTO, error) { if err := account.IsValidUpdate(); err != nil { return nil, err } // Get account from database storedAccount, _, err := d.deps.Database().GetAccount(ctx, account.ID) if errors.Is(err, database.ErrNotFound) { return nil, model.ErrNotFound } if err != nil { return nil, fmt.Errorf("error getting account for update: %w", err) } if account.Password != "" { // Hash password with bcrypt hashedPassword, err := bcrypt.GenerateFromPassword([]byte(account.Password), 10) if err != nil { return nil, fmt.Errorf("error hashing provided password: %w", err) } storedAccount.Password = string(hashedPassword) } if account.Username != "" { storedAccount.Username = account.Username } if account.Owner != nil { storedAccount.Owner = *account.Owner } if account.Config != nil { storedAccount.Config = *account.Config } // Save updated account err = d.deps.Database().UpdateAccount(ctx, *storedAccount) if errors.Is(err, database.ErrAlreadyExists) { return nil, model.ErrAlreadyExists } if err != nil { return nil, fmt.Errorf("error updating account: %w", err) } // Get updated account from database updatedAccount, _, err := d.deps.Database().GetAccount(ctx, account.ID) if err != nil { return nil, fmt.Errorf("error getting updated account: %w", err) } account = updatedAccount.ToDTO() return &account, nil } func NewAccountsDomain(deps *dependencies.Dependencies) model.AccountsDomain { return &AccountsDomain{ deps: deps, } } ================================================ FILE: internal/domains/accounts_test.go ================================================ package domains_test import ( "context" "fmt" "testing" "time" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestAccountDomainsListAccounts(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("empty", func(t *testing.T) { accounts, err := deps.Domains().Accounts().ListAccounts(context.Background()) require.NoError(t, err) require.Empty(t, accounts) }) t.Run("some accounts", func(t *testing.T) { for i := 0; i < 3; i++ { _, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: fmt.Sprintf("user%d", i), Password: fmt.Sprintf("password%d", i), }) require.NoError(t, err) } accounts, err := deps.Domains().Accounts().ListAccounts(context.Background()) require.NoError(t, err) require.Len(t, accounts, 3) require.Equal(t, "", accounts[0].Password) }) } func TestAccountDomainsGetAccountByUsername(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("empty", func(t *testing.T) { account, err := deps.Domains().Accounts().GetAccountByUsername(context.Background(), "") require.Error(t, err) require.Nil(t, account) }) t.Run("account found", func(t *testing.T) { _, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "user1", Password: "password1", }) require.NoError(t, err) account, err := deps.Domains().Accounts().GetAccountByUsername(context.Background(), "user1") require.NoError(t, err) require.NotNil(t, account) require.Equal(t, "user1", account.Username) }) } func TestAccountDomainCreateAccount(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("create account", func(t *testing.T) { acc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "user", Password: "password", Owner: model.Ptr(true), Config: &model.UserConfig{ Theme: "dark", }, }) require.NoError(t, err) require.NotZero(t, acc.ID) require.Equal(t, "user", acc.Username) require.Equal(t, "dark", acc.Config.Theme) }) t.Run("create account with empty username", func(t *testing.T) { _, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "", Password: "password", }) require.Error(t, err) _, isValidationErr := err.(model.ValidationError) require.True(t, isValidationErr) }) t.Run("create account with empty password", func(t *testing.T) { _, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "user", Password: "", }) require.Error(t, err) _, isValidationErr := err.(model.ValidationError) require.True(t, isValidationErr) }) } func TestAccountDomainUpdateAccount(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("update account", func(t *testing.T) { acc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "user", Password: "password", }) require.NoError(t, err) acc, err = deps.Domains().Accounts().UpdateAccount(context.TODO(), model.AccountDTO{ ID: acc.ID, Username: "user2", Password: "password2", Owner: model.Ptr(true), Config: &model.UserConfig{ Theme: "light", }, }) require.NoError(t, err) require.Equal(t, "user2", acc.Username) require.Equal(t, "light", acc.Config.Theme) }) t.Run("update non-existing account", func(t *testing.T) { _, err := deps.Domains().Accounts().UpdateAccount(context.TODO(), model.AccountDTO{ ID: 999, Username: "user", Password: "password", }) require.Error(t, err) require.ErrorIs(t, err, model.ErrNotFound) }) t.Run("try to update with no changes", func(t *testing.T) { acc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "user", Password: "password", }) require.NoError(t, err) _, err = deps.Domains().Accounts().UpdateAccount(context.TODO(), model.AccountDTO{ ID: acc.ID, }) require.Error(t, err) _, isValidationErr := err.(model.ValidationError) require.True(t, isValidationErr) }) } func TestAccountDomainDeleteAccount(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("delete account", func(t *testing.T) { acc, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "user", Password: "password", }) require.NoError(t, err) err = deps.Domains().Accounts().DeleteAccount(context.TODO(), int(acc.ID)) require.NoError(t, err) accounts, err := deps.Domains().Accounts().ListAccounts(context.Background()) require.NoError(t, err) require.Empty(t, accounts) }) t.Run("delete non-existing account", func(t *testing.T) { err := deps.Domains().Accounts().DeleteAccount(context.TODO(), 999) require.Error(t, err) require.ErrorIs(t, err, model.ErrNotFound) }) t.Run("valid account", func(t *testing.T) { account := testutil.GetValidAccount().ToDTO() token, err := deps.Domains().Auth().CreateTokenForAccount( &account, time.Now().Add(time.Hour*1), ) require.NoError(t, err) require.NotEmpty(t, token) }) t.Run("nil account", func(t *testing.T) { token, err := deps.Domains().Auth().CreateTokenForAccount( nil, time.Now().Add(time.Hour*1), ) require.Error(t, err) require.Empty(t, token) }) t.Run("token expiration is valid", func(t *testing.T) { ctx := context.TODO() account := testutil.GetValidAccount().ToDTO() expiration := time.Now().Add(time.Hour * 9) token, err := deps.Domains().Auth().CreateTokenForAccount( &account, expiration, ) require.NoError(t, err) require.NotEmpty(t, token) tokenAccount, err := deps.Domains().Auth().CheckToken(ctx, token) require.NoError(t, err) require.NotNil(t, tokenAccount) }) } ================================================ FILE: internal/domains/archiver.go ================================================ package domains import ( "fmt" "path/filepath" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/dependencies" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/warc" ) type ArchiverDomain struct { deps *dependencies.Dependencies } func (d *ArchiverDomain) DownloadBookmarkArchive(book model.BookmarkDTO) (*model.BookmarkDTO, error) { content, contentType, err := core.DownloadBookmark(book.URL) if err != nil { return nil, fmt.Errorf("error downloading url: %s", err) } processRequest := core.ProcessRequest{ DataDir: d.deps.Config().Storage.DataDir, Bookmark: book, Content: content, ContentType: contentType, } result, isFatalErr, err := core.ProcessBookmark(d.deps, processRequest) content.Close() if err != nil && isFatalErr { return nil, fmt.Errorf("failed to process: %v", err) } return &result, nil } func (d *ArchiverDomain) GetBookmarkArchive(book *model.BookmarkDTO) (*warc.Archive, error) { archivePath := model.GetArchivePath(book) if !d.deps.Domains().Storage().FileExists(archivePath) { return nil, fmt.Errorf("archive for bookmark %d doesn't exist", book.ID) } // FIXME: This only works in local filesystem return warc.Open(filepath.Join(d.deps.Config().Storage.DataDir, archivePath)) } func NewArchiverDomain(deps *dependencies.Dependencies) *ArchiverDomain { return &ArchiverDomain{ deps: deps, } } ================================================ FILE: internal/domains/auth.go ================================================ package domains import ( "context" "fmt" "time" "github.com/go-shiori/shiori/internal/dependencies" "github.com/go-shiori/shiori/internal/model" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) type AuthDomain struct { deps *dependencies.Dependencies } type JWTClaim struct { jwt.RegisteredClaims Account *model.AccountDTO } func (d *AuthDomain) CheckToken(ctx context.Context, userJWT string) (*model.AccountDTO, error) { token, err := jwt.ParseWithClaims(userJWT, &JWTClaim{}, func(token *jwt.Token) (interface{}, error) { // Validate algorithm if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return d.deps.Config().Http.SecretKey, nil }) if err != nil { return nil, fmt.Errorf("error parsing token: %w", err) } if claims, ok := token.Claims.(*JWTClaim); ok && token.Valid { if claims.Account.ID > 0 { return claims.Account, nil } return claims.Account, nil } return nil, fmt.Errorf("error obtaining user from JWT claims") } func (d *AuthDomain) GetAccountFromCredentials(ctx context.Context, username, password string) (*model.AccountDTO, error) { accounts, err := d.deps.Database().ListAccounts(ctx, model.DBListAccountsOptions{ Username: username, WithPassword: true, }) if err != nil { return nil, fmt.Errorf("username or password do not match") } if len(accounts) != 1 { return nil, fmt.Errorf("username or password do not match") } account := accounts[0] if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil { return nil, fmt.Errorf("username or password do not match") } return model.Ptr(account.ToDTO()), nil } func (d *AuthDomain) CreateTokenForAccount(account *model.AccountDTO, expiration time.Time) (string, error) { if account == nil { return "", fmt.Errorf("account is nil") } claims := jwt.MapClaims{ "account": account, "exp": expiration.UTC().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) t, err := token.SignedString(d.deps.Config().Http.SecretKey) if err != nil { d.deps.Logger().WithError(err).Error("error signing token") } return t, err } func NewAuthDomain(deps *dependencies.Dependencies) *AuthDomain { return &AuthDomain{ deps: deps, } } ================================================ FILE: internal/domains/auth_test.go ================================================ package domains_test import ( "context" "testing" "time" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/golang-jwt/jwt/v5" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestAuthDomainCheckToken(t *testing.T) { ctx := context.TODO() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) domain := domains.NewAuthDomain(deps) t.Run("valid token", func(t *testing.T) { // Create a valid token account := testutil.GetValidAccount().ToDTO() token, err := domain.CreateTokenForAccount( &account, time.Now().Add(time.Hour*1), ) require.NoError(t, err) acc, err := domain.CheckToken(ctx, token) require.NoError(t, err) require.NotNil(t, acc) require.Equal(t, model.DBID(99), acc.ID) }) t.Run("expired token", func(t *testing.T) { // Create an expired token account := testutil.GetValidAccount().ToDTO() token, err := domain.CreateTokenForAccount( &account, time.Now().Add(time.Hour*-1), ) require.NoError(t, err) acc, err := domain.CheckToken(ctx, token) require.Error(t, err) require.Nil(t, acc) }) t.Run("invalid token", func(t *testing.T) { claims, err := domain.CheckToken(ctx, "invalid-token") require.Error(t, err) require.Nil(t, claims) }) t.Run("nil account", func(t *testing.T) { token, err := domain.CreateTokenForAccount(nil, time.Now().Add(time.Hour)) require.Error(t, err) require.Empty(t, token) require.Contains(t, err.Error(), "account is nil") }) } func TestAuthDomainCheckTokenInvalidMethod(t *testing.T) { ctx := context.TODO() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) domain := domains.NewAuthDomain(deps) // Create a token with an unsupported signing method account := testutil.GetValidAccount().ToDTO() claims := jwt.MapClaims{ "account": account, "exp": time.Now().Add(time.Hour).UTC().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) require.NoError(t, err) // Try to verify the token acc, err := domain.CheckToken(ctx, tokenString) require.Error(t, err) require.Nil(t, acc) require.Contains(t, err.Error(), "unexpected signing method") } func TestAuthDomainGetAccountFromCredentials(t *testing.T) { ctx := context.TODO() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) domain := domains.NewAuthDomain(deps) _, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "test", Password: "test", }) require.NoError(t, err) t.Run("valid credentials", func(t *testing.T) { acc, err := domain.GetAccountFromCredentials(ctx, "test", "test") require.NoError(t, err) require.NotNil(t, acc) require.Equal(t, "test", acc.Username) }) t.Run("invalid credentials", func(t *testing.T) { acc, err := domain.GetAccountFromCredentials(ctx, "test", "invalid") require.Error(t, err) require.Nil(t, acc) }) t.Run("invalid username", func(t *testing.T) { acc, err := domain.GetAccountFromCredentials(ctx, "nope", "invalid") require.Error(t, err) require.Nil(t, acc) }) } ================================================ FILE: internal/domains/bookmark_tags_test.go ================================================ package domains_test import ( "context" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBookmarkTagOperations(t *testing.T) { ctx := context.Background() logger := logrus.New() // Setup using the test configuration and dependencies _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) bookmarksDomain := deps.Domains().Bookmarks() tagsDomain := deps.Domains().Tags() db := deps.Database() // Create a test bookmark bookmark := model.BookmarkDTO{ URL: "https://example.com/bookmark-tags-test", Title: "Bookmark Tags Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID // Create a test tag tagDTO := model.TagDTO{ Tag: model.Tag{ Name: "test-tag", }, } createdTag, err := tagsDomain.CreateTag(ctx, tagDTO) require.NoError(t, err) tagID := createdTag.ID // Test BookmarkExists t.Run("BookmarkExists", func(t *testing.T) { // Test with existing bookmark exists, err := bookmarksDomain.BookmarkExists(ctx, bookmarkID) require.NoError(t, err) assert.True(t, exists, "Bookmark should exist") // Test with non-existent bookmark exists, err = bookmarksDomain.BookmarkExists(ctx, 9999) require.NoError(t, err) assert.False(t, exists, "Non-existent bookmark should not exist") }) // Test TagExists t.Run("TagExists", func(t *testing.T) { // Test with existing tag exists, err := tagsDomain.TagExists(ctx, tagID) require.NoError(t, err) assert.True(t, exists, "Tag should exist") // Test with non-existent tag exists, err = tagsDomain.TagExists(ctx, 9999) require.NoError(t, err) assert.False(t, exists, "Non-existent tag should not exist") }) // Test AddTagToBookmark t.Run("AddTagToBookmark", func(t *testing.T) { // Add tag to bookmark err := bookmarksDomain.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was added by listing tags for the bookmark tags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1, "Should have exactly one tag") assert.Equal(t, tagID, tags[0].ID, "Tag ID should match") assert.Equal(t, "test-tag", tags[0].Name, "Tag name should match") // Test adding the same tag again (should not error) err = bookmarksDomain.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err, "Adding the same tag again should not error") // Test adding tag to non-existent bookmark err = bookmarksDomain.AddTagToBookmark(ctx, 9999, tagID) require.Error(t, err) assert.ErrorIs(t, err, model.ErrBookmarkNotFound, "Should return bookmark not found error") // Test adding non-existent tag to bookmark err = bookmarksDomain.AddTagToBookmark(ctx, bookmarkID, 9999) require.Error(t, err) assert.ErrorIs(t, err, model.ErrTagNotFound, "Should return tag not found error") }) // Test RemoveTagFromBookmark t.Run("RemoveTagFromBookmark", func(t *testing.T) { // Remove tag from bookmark err := bookmarksDomain.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Verify tag was removed by listing tags for the bookmark tags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 0, "Should have no tags after removal") // Test removing a tag that's not associated with the bookmark (should not error) err = bookmarksDomain.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err, "Removing a tag that's not associated should not error") // Test removing tag from non-existent bookmark err = bookmarksDomain.RemoveTagFromBookmark(ctx, 9999, tagID) require.Error(t, err) assert.ErrorIs(t, err, model.ErrBookmarkNotFound, "Should return bookmark not found error") // Test removing non-existent tag from bookmark err = bookmarksDomain.RemoveTagFromBookmark(ctx, bookmarkID, 9999) require.Error(t, err) assert.ErrorIs(t, err, model.ErrTagNotFound, "Should return tag not found error") }) } ================================================ FILE: internal/domains/bookmarks.go ================================================ package domains import ( "context" "fmt" "github.com/go-shiori/shiori/internal/core" "github.com/go-shiori/shiori/internal/model" ) type BookmarksDomain struct { deps model.Dependencies } func (d *BookmarksDomain) HasEbook(b *model.BookmarkDTO) bool { ebookPath := model.GetEbookPath(b) return d.deps.Domains().Storage().FileExists(ebookPath) } func (d *BookmarksDomain) HasArchive(b *model.BookmarkDTO) bool { archivePath := model.GetArchivePath(b) return d.deps.Domains().Storage().FileExists(archivePath) } func (d *BookmarksDomain) HasThumbnail(b *model.BookmarkDTO) bool { thumbnailPath := model.GetThumbnailPath(b) return d.deps.Domains().Storage().FileExists(thumbnailPath) } func (d *BookmarksDomain) GetBookmark(ctx context.Context, id model.DBID) (*model.BookmarkDTO, error) { bookmark, exists, err := d.deps.Database().GetBookmark(ctx, int(id), "") if err != nil { return nil, fmt.Errorf("failed to get bookmark: %w", err) } if !exists { return nil, model.ErrBookmarkNotFound } // Check if it has ebook and archive. bookmark.HasEbook = d.HasEbook(&bookmark) bookmark.HasArchive = d.HasArchive(&bookmark) return &bookmark, nil } func (d *BookmarksDomain) GetBookmarks(ctx context.Context, ids []int) ([]model.BookmarkDTO, error) { var bookmarks []model.BookmarkDTO for _, id := range ids { bookmark, exists, err := d.deps.Database().GetBookmark(ctx, id, "") if err != nil { return nil, fmt.Errorf("failed to get bookmark %d: %w", id, err) } if !exists { continue } // Check if it has ebook and archive bookmark.HasEbook = d.HasEbook(&bookmark) bookmark.HasArchive = d.HasArchive(&bookmark) bookmarks = append(bookmarks, bookmark) } return bookmarks, nil } func (d *BookmarksDomain) UpdateBookmarkCache(ctx context.Context, bookmark model.BookmarkDTO, keepMetadata bool, skipExist bool) (*model.BookmarkDTO, error) { // Download data from internet content, contentType, err := core.DownloadBookmark(bookmark.URL) if err != nil { return nil, fmt.Errorf("failed to download bookmark: %w", err) } defer content.Close() // Check if we should skip existing ebook if skipExist && bookmark.CreateEbook { ebookPath := model.GetEbookPath(&bookmark) if d.deps.Domains().Storage().FileExists(ebookPath) { bookmark.CreateEbook = false bookmark.HasEbook = true } } // Process the bookmark request := core.ProcessRequest{ DataDir: d.deps.Config().Storage.DataDir, Bookmark: bookmark, Content: content, ContentType: contentType, KeepTitle: keepMetadata, KeepExcerpt: keepMetadata, } processedBookmark, _, err := core.ProcessBookmark(d.deps, request) if err != nil { return nil, fmt.Errorf("failed to process bookmark: %w", err) } return &processedBookmark, nil } // BulkUpdateBookmarkTags updates tags for multiple bookmarks using tag IDs func (d *BookmarksDomain) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error { if len(bookmarkIDs) == 0 { return nil } // Call the database method directly err := d.deps.Database().BulkUpdateBookmarkTags(ctx, bookmarkIDs, tagIDs) if err != nil { return fmt.Errorf("failed to update bookmark tags: %w", err) } return nil } // AddTagToBookmark adds a tag to a bookmark func (d *BookmarksDomain) AddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error { // Check if bookmark exists exists, err := d.BookmarkExists(ctx, bookmarkID) if err != nil { return err } if !exists { return model.ErrBookmarkNotFound } // Check if tag exists exists, err = d.deps.Domains().Tags().TagExists(ctx, tagID) if err != nil { return err } if !exists { return model.ErrTagNotFound } // Add tag to bookmark return d.deps.Database().AddTagToBookmark(ctx, bookmarkID, tagID) } // RemoveTagFromBookmark removes a tag from a bookmark func (d *BookmarksDomain) RemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error { // Check if bookmark exists exists, err := d.BookmarkExists(ctx, bookmarkID) if err != nil { return err } if !exists { return model.ErrBookmarkNotFound } // Check if tag exists exists, err = d.deps.Domains().Tags().TagExists(ctx, tagID) if err != nil { return err } if !exists { return model.ErrTagNotFound } // Remove tag from bookmark return d.deps.Database().RemoveTagFromBookmark(ctx, bookmarkID, tagID) } // BookmarkExists checks if a bookmark with the given ID exists func (d *BookmarksDomain) BookmarkExists(ctx context.Context, id int) (bool, error) { return d.deps.Database().BookmarkExists(ctx, id) } func NewBookmarksDomain(deps model.Dependencies) *BookmarksDomain { return &BookmarksDomain{ deps: deps, } } ================================================ FILE: internal/domains/bookmarks_test.go ================================================ package domains_test import ( "context" "errors" "testing" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBookmarkDomain(t *testing.T) { fs := afero.NewMemMapFs() ctx := context.Background() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) deps.Domains().SetStorage(domains.NewStorageDomain(deps, fs)) fs.MkdirAll("thumb", 0755) fs.Create("thumb/1") fs.MkdirAll("ebook", 0755) fs.Create("ebook/1.epub") fs.MkdirAll("archive", 0755) // TODO: write a valid archive file fs.Create("archive/1") domain := domains.NewBookmarksDomain(deps) t.Run("HasEbook", func(t *testing.T) { t.Run("Yes", func(t *testing.T) { require.True(t, domain.HasEbook(&model.BookmarkDTO{ID: 1})) }) t.Run("No", func(t *testing.T) { require.False(t, domain.HasEbook(&model.BookmarkDTO{ID: 2})) }) }) t.Run("HasArchive", func(t *testing.T) { t.Run("Yes", func(t *testing.T) { require.True(t, domain.HasArchive(&model.BookmarkDTO{ID: 1})) }) t.Run("No", func(t *testing.T) { require.False(t, domain.HasArchive(&model.BookmarkDTO{ID: 2})) }) }) t.Run("HasThumbnail", func(t *testing.T) { t.Run("Yes", func(t *testing.T) { require.True(t, domain.HasThumbnail(&model.BookmarkDTO{ID: 1})) }) t.Run("No", func(t *testing.T) { require.False(t, domain.HasThumbnail(&model.BookmarkDTO{ID: 2})) }) }) t.Run("GetBookmark", func(t *testing.T) { t.Run("Success", func(t *testing.T) { _, err := deps.Database().SaveBookmarks(context.TODO(), true, *testutil.GetValidBookmark()) require.NoError(t, err) bookmark, err := domain.GetBookmark(context.Background(), 1) require.NoError(t, err) require.Equal(t, 1, bookmark.ID) // Check DTO attributes require.True(t, bookmark.HasEbook) require.True(t, bookmark.HasArchive) }) t.Run("NotFound", func(t *testing.T) { bookmark, err := domain.GetBookmark(context.Background(), 999) require.Error(t, err) require.Nil(t, bookmark) require.Equal(t, model.ErrBookmarkNotFound, err) }) t.Run("DatabaseError", func(t *testing.T) { // Create a new context with a timeout to force an error cancelCtx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately to force error bookmark, err := domain.GetBookmark(cancelCtx, 1) require.Error(t, err) require.Nil(t, bookmark) require.Contains(t, err.Error(), "failed to get bookmark") }) }) t.Run("GetBookmarks", func(t *testing.T) { t.Run("Success", func(t *testing.T) { // Create multiple bookmarks bookmark1 := testutil.GetValidBookmark() bookmark1.ID = 1 bookmark2 := testutil.GetValidBookmark() bookmark2.ID = 2 bookmark2.URL = "https://example.com" _, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark1, *bookmark2) require.NoError(t, err) // Test getting multiple bookmarks bookmarks, err := domain.GetBookmarks(context.Background(), []int{1, 2}) require.NoError(t, err) require.Len(t, bookmarks, 2) // Verify the bookmarks have the correct properties assert.Equal(t, 1, bookmarks[0].ID) assert.True(t, bookmarks[0].HasEbook) assert.True(t, bookmarks[0].HasArchive) assert.Equal(t, 2, bookmarks[1].ID) assert.False(t, bookmarks[1].HasEbook) assert.False(t, bookmarks[1].HasArchive) }) t.Run("PartialResults", func(t *testing.T) { // Test with a mix of existing and non-existing IDs bookmarks, err := domain.GetBookmarks(context.Background(), []int{1, 999}) require.NoError(t, err) require.Len(t, bookmarks, 1) assert.Equal(t, 1, bookmarks[0].ID) }) t.Run("EmptyResults", func(t *testing.T) { // Test with non-existing IDs bookmarks, err := domain.GetBookmarks(context.Background(), []int{998, 999}) require.NoError(t, err) require.Len(t, bookmarks, 0) }) t.Run("DatabaseError", func(t *testing.T) { // Create a new context with a timeout to force an error cancelCtx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately to force error bookmarks, err := domain.GetBookmarks(cancelCtx, []int{1}) require.Error(t, err) require.Nil(t, bookmarks) require.Contains(t, err.Error(), "failed to get bookmark") }) }) t.Run("UpdateBookmarkCache", func(t *testing.T) { // Create a new test environment for this specific test fs := afero.NewMemMapFs() ctx := context.Background() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) deps.Domains().SetStorage(domains.NewStorageDomain(deps, fs)) // Create necessary directories fs.MkdirAll("thumb", 0755) fs.MkdirAll("ebook", 0755) fs.MkdirAll("archive", 0755) domain := domains.NewBookmarksDomain(deps) // Create a test bookmark bookmark := model.BookmarkDTO{ ID: 1, URL: "https://example.com", Title: "Example", CreateEbook: true, CreateArchive: true, } // Save the bookmark to the database _, err := deps.Database().SaveBookmarks(context.TODO(), true, bookmark) require.NoError(t, err) // Mock the core.DownloadBookmark function using monkey patching // Since we can't directly mock it, we'll test the error case t.Run("DownloadError", func(t *testing.T) { // Use an invalid URL to trigger a download error bookmark.URL = "invalid://url" result, err := domain.UpdateBookmarkCache(ctx, bookmark, true, false) require.Error(t, err) require.Nil(t, result) require.Contains(t, err.Error(), "failed to download bookmark") }) // Test the skip existing functionality t.Run("SkipExistingEbook", func(t *testing.T) { // Create an ebook file ebookPath := model.GetEbookPath(&bookmark) _, err := fs.Create(ebookPath) require.NoError(t, err) // Set a valid URL bookmark.URL = "https://example.com" bookmark.CreateEbook = true // This test will still fail because we can't mock the HTTP client // But we can verify the logic for skipping existing ebooks _, err = domain.UpdateBookmarkCache(ctx, bookmark, true, true) // The test will fail at the download step, but we can check if the CreateEbook flag was set correctly if err != nil && !errors.Is(err, context.Canceled) { // This is expected since we can't mock the HTTP client // But we can check if the bookmark was modified correctly before the error assert.False(t, bookmark.CreateEbook) assert.True(t, bookmark.HasEbook) } }) }) } func TestBookmarksDomain_BulkUpdateBookmarkTags(t *testing.T) { ctx := context.Background() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) domain := domains.NewBookmarksDomain(deps) t.Run("empty_bookmark_ids", func(t *testing.T) { err := domain.BulkUpdateBookmarkTags(ctx, []int{}, []int{1, 2, 3}) require.NoError(t, err) // Should not return an error for empty bookmark IDs }) t.Run("empty_tag_ids", func(t *testing.T) { err := domain.BulkUpdateBookmarkTags(ctx, []int{1, 2, 3}, []int{}) require.NoError(t, err) // Should not return an error for empty tag IDs }) t.Run("non_existent_bookmarks", func(t *testing.T) { err := domain.BulkUpdateBookmarkTags(ctx, []int{999, 1000}, []int{1, 2, 3}) require.Error(t, err) }) t.Run("successful_update", func(t *testing.T) { // Create test bookmarks bookmark1 := testutil.GetValidBookmark() bookmark2 := testutil.GetValidBookmark() bookmark2.URL = "https://example.com/different" savedBookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark1, *bookmark2) require.NoError(t, err) require.Len(t, savedBookmarks, 2) // Create test tags tag1 := model.Tag{Name: "test-tag-1"} tag2 := model.Tag{Name: "test-tag-2"} createdTags, err := deps.Database().CreateTags(ctx, tag1, tag2) require.NoError(t, err) require.Len(t, createdTags, 2) // Get the bookmark and tag IDs bookmarkIDs := []int{savedBookmarks[0].ID, savedBookmarks[1].ID} tagIDs := []int{createdTags[0].ID, createdTags[1].ID} // Update the bookmarks with the tags err = domain.BulkUpdateBookmarkTags(ctx, bookmarkIDs, tagIDs) require.NoError(t, err) // Verify the bookmarks have the tags for _, bookmarkID := range bookmarkIDs { bookmark, err := domain.GetBookmark(ctx, model.DBID(bookmarkID)) require.NoError(t, err) // Check that the bookmark has both tags require.Len(t, bookmark.Tags, 2) // Verify tag IDs match tagIDsMap := make(map[int]bool) for _, tag := range bookmark.Tags { tagIDsMap[tag.ID] = true } assert.True(t, tagIDsMap[createdTags[0].ID], "Bookmark should have the first tag") assert.True(t, tagIDsMap[createdTags[1].ID], "Bookmark should have the second tag") } }) } ================================================ FILE: internal/domains/storage.go ================================================ package domains import ( "fmt" "io" "io/fs" "os" "path/filepath" "github.com/go-shiori/shiori/internal/model" "github.com/spf13/afero" ) type StorageDomain struct { deps model.Dependencies fs afero.Fs } func NewStorageDomain(deps model.Dependencies, fs afero.Fs) *StorageDomain { return &StorageDomain{ deps: deps, fs: fs, } } // Stat returns the FileInfo structure describing file. func (d *StorageDomain) Stat(name string) (fs.FileInfo, error) { return d.fs.Stat(name) } // FS returns the filesystem used by this domain. func (d *StorageDomain) FS() afero.Fs { return d.fs } // FileExists checks if a file exists in storage. func (d *StorageDomain) FileExists(name string) bool { info, err := d.Stat(name) return err == nil && !info.IsDir() } // DirExists checks if a directory exists in storage. func (d *StorageDomain) DirExists(name string) bool { info, err := d.Stat(name) return err == nil && info.IsDir() } // WriteData writes bytes data to a file in storage. // CAUTION: This function will overwrite existing file. func (d *StorageDomain) WriteData(dst string, data []byte) error { // Create directory if not exist dir := filepath.Dir(dst) if !d.DirExists(dir) { err := d.fs.MkdirAll(dir, os.ModePerm) if err != nil { return err } } // Create file file, err := d.fs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) if err != nil { return err } defer file.Close() // Write data _, err = file.Write(data) return err } // WriteFile writes a file to storage. func (d *StorageDomain) WriteFile(dst string, tmpFile *os.File) error { if dst != "" && !d.DirExists(dst) { err := d.fs.MkdirAll(filepath.Dir(dst), model.DataDirPerm) if err != nil { return fmt.Errorf("failed to create destination dir: %v", err) } } dstFile, err := d.fs.Create(dst) if err != nil { return fmt.Errorf("failed to create destination file: %v", err) } defer dstFile.Close() _, err = tmpFile.Seek(0, io.SeekStart) if err != nil { return fmt.Errorf("failed to rewind temporary file: %v", err) } _, err = io.Copy(dstFile, tmpFile) if err != nil { return fmt.Errorf("failed to copy file to the destination") } return nil } ================================================ FILE: internal/domains/storage_test.go ================================================ package domains_test import ( "context" "os" "testing" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) func TestDirExists(t *testing.T) { fs := afero.NewMemMapFs() fs.MkdirAll("foo", 0755) logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) domain := domains.NewStorageDomain( deps, fs, ) require.True(t, domain.DirExists("foo")) require.False(t, domain.DirExists("foo/file")) require.False(t, domain.DirExists("bar")) } func TestFileExists(t *testing.T) { fs := afero.NewMemMapFs() fs.MkdirAll("foo", 0755) fs.Create("foo/file") logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) domain := domains.NewStorageDomain( deps, fs, ) require.True(t, domain.FileExists("foo/file")) require.False(t, domain.FileExists("bar")) } func TestWriteFile(t *testing.T) { fs := afero.NewMemMapFs() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) domain := domains.NewStorageDomain( deps, fs, ) err := domain.WriteData("foo/file.ext", []byte("foo")) require.NoError(t, err) require.True(t, domain.FileExists("foo/file.ext")) require.True(t, domain.DirExists("foo")) handler, err := domain.FS().Open("foo/file.ext") require.NoError(t, err) defer handler.Close() data, err := afero.ReadAll(handler) require.NoError(t, err) require.Equal(t, "foo", string(data)) } func TestSaveFile(t *testing.T) { fs := afero.NewMemMapFs() logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) domain := domains.NewStorageDomain( deps, fs, ) tempFile, err := os.CreateTemp("", "") require.NoError(t, err) defer os.Remove(tempFile.Name()) _, err = tempFile.WriteString("foo") require.NoError(t, err) err = domain.WriteFile("foo/file.ext", tempFile) require.NoError(t, err) require.True(t, domain.FileExists("foo/file.ext")) require.True(t, domain.DirExists("foo")) handler, err := domain.FS().Open("foo/file.ext") require.NoError(t, err) defer handler.Close() data, err := afero.ReadAll(handler) require.NoError(t, err) require.Equal(t, "foo", string(data)) } ================================================ FILE: internal/domains/tags.go ================================================ package domains import ( "context" "errors" "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/model" ) type tagsDomain struct { deps model.Dependencies } func NewTagsDomain(deps model.Dependencies) model.TagsDomain { return &tagsDomain{deps: deps} } func (d *tagsDomain) ListTags(ctx context.Context, opts model.ListTagsOptions) ([]model.TagDTO, error) { tags, err := d.deps.Database().GetTags(ctx, model.DBListTagsOptions(opts)) if err != nil { return nil, err } return tags, nil } func (d *tagsDomain) CreateTag(ctx context.Context, tagDTO model.TagDTO) (model.TagDTO, error) { tag := tagDTO.ToTag() createdTag, err := d.deps.Database().CreateTag(ctx, tag) if err != nil { return model.TagDTO{}, err } return createdTag.ToDTO(), nil } func (d *tagsDomain) GetTag(ctx context.Context, id int) (model.TagDTO, error) { tag, exists, err := d.deps.Database().GetTag(ctx, id) if err != nil { return model.TagDTO{}, err } if !exists { return model.TagDTO{}, model.ErrNotFound } return tag, nil } func (d *tagsDomain) UpdateTag(ctx context.Context, tagDTO model.TagDTO) (model.TagDTO, error) { tag := tagDTO.ToTag() err := d.deps.Database().UpdateTag(ctx, tag) if err != nil { if errors.Is(err, database.ErrNotFound) { return model.TagDTO{}, model.ErrNotFound } return model.TagDTO{}, err } // Fetch the updated tag to return updatedTag, err := d.GetTag(ctx, tag.ID) if err != nil { return model.TagDTO{}, err } return updatedTag, nil } func (d *tagsDomain) DeleteTag(ctx context.Context, id int) error { if err := d.deps.Database().DeleteTag(ctx, id); err != nil { if errors.Is(err, database.ErrNotFound) { return model.ErrNotFound } return err } return nil } // TagExists checks if a tag with the given ID exists func (d *tagsDomain) TagExists(ctx context.Context, id int) (bool, error) { return d.deps.Database().TagExists(ctx, id) } ================================================ FILE: internal/domains/tags_test.go ================================================ package domains_test import ( "context" "errors" "strings" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Tests for the tagsDomain implementation func TestTagsDomain(t *testing.T) { ctx := context.Background() logger := logrus.New() // Setup using the test configuration and dependencies _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) tagsDomain := deps.Domains().Tags() db := deps.Database() // Test ListTags t.Run("ListTags", func(t *testing.T) { // Create some test tags first testTags := []model.Tag{ {Name: "tag1"}, {Name: "tag2"}, } createdTags, err := db.CreateTags(ctx, testTags...) require.NoError(t, err) require.Len(t, createdTags, 2) // List the tags tags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{}) require.NoError(t, err) require.Len(t, tags, 2) // Verify the tags assert.Equal(t, "tag1", tags[0].Name) assert.Equal(t, "tag2", tags[1].Name) }) // Test ListTags with WithBookmarkCount t.Run("ListTags_WithBookmarkCount", func(t *testing.T) { // Create a test tag tag := model.Tag{Name: "tag-with-count"} createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) // Create a bookmark with this tag bookmark := model.BookmarkDTO{ URL: "https://example-count.com", Title: "Example for Count", Tags: []model.TagDTO{ {Tag: model.Tag{Name: tag.Name}}, }, } _, err = db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) // List tags with bookmark count tags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{ WithBookmarkCount: true, }) require.NoError(t, err) require.NotEmpty(t, tags) // Find our test tag and verify it has a bookmark count var foundTag model.TagDTO for _, t := range tags { if t.Name == tag.Name { foundTag = t break } } require.NotZero(t, foundTag.ID, "Should find the test tag") assert.Equal(t, int64(1), foundTag.BookmarkCount, "Tag should have a bookmark count of 1") }) // Test ListTags with BookmarkID t.Run("ListTags_WithBookmarkID", func(t *testing.T) { // Create test tags testTags := []model.Tag{ {Name: "tag-for-bookmark1"}, {Name: "tag-for-bookmark2"}, } createdTags, err := db.CreateTags(ctx, testTags...) require.NoError(t, err) require.Len(t, createdTags, 2) // Create bookmarks with different tags bookmark1 := model.BookmarkDTO{ URL: "https://example-bookmark1.com", Title: "Example Bookmark 1", Tags: []model.TagDTO{ {Tag: model.Tag{Name: testTags[0].Name}}, }, } bookmark2 := model.BookmarkDTO{ URL: "https://example-bookmark2.com", Title: "Example Bookmark 2", Tags: []model.TagDTO{ {Tag: model.Tag{Name: testTags[1].Name}}, }, } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark1, bookmark2) require.NoError(t, err) require.Len(t, savedBookmarks, 2) // Get tags for the first bookmark tags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{ BookmarkID: savedBookmarks[0].ID, }) require.NoError(t, err) require.Len(t, tags, 1, "Should return exactly one tag for the bookmark") assert.Equal(t, testTags[0].Name, tags[0].Name, "Should return the correct tag for the bookmark") // Get tags for the second bookmark tags, err = tagsDomain.ListTags(ctx, model.ListTagsOptions{ BookmarkID: savedBookmarks[1].ID, }) require.NoError(t, err) require.Len(t, tags, 1, "Should return exactly one tag for the bookmark") assert.Equal(t, testTags[1].Name, tags[0].Name, "Should return the correct tag for the bookmark") }) // Test ListTags with both options t.Run("ListTags_WithBothOptions", func(t *testing.T) { // Create a test tag tag := model.Tag{Name: "tag-with-both-options"} createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) // Create a bookmark with this tag bookmark := model.BookmarkDTO{ URL: "https://example-both-options.com", Title: "Example for Both Options", Tags: []model.TagDTO{ {Tag: model.Tag{Name: tag.Name}}, }, } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) // List tags with both options tags, err := tagsDomain.ListTags(ctx, model.ListTagsOptions{ BookmarkID: savedBookmarks[0].ID, WithBookmarkCount: true, }) require.NoError(t, err) require.Len(t, tags, 1, "Should return exactly one tag") assert.Equal(t, tag.Name, tags[0].Name, "Should return the correct tag") assert.Equal(t, int64(1), tags[0].BookmarkCount, "Tag should have a bookmark count of 1") }) // Test CreateTag t.Run("CreateTag", func(t *testing.T) { // Create a new tag tagDTO := model.TagDTO{ Tag: model.Tag{ Name: "new-tag", }, } createdTag, err := tagsDomain.CreateTag(ctx, tagDTO) require.NoError(t, err) assert.Equal(t, "new-tag", createdTag.Name) assert.Greater(t, createdTag.ID, 0, "The created tag should have a valid ID") // Verify the tag was created in the database allTags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) require.GreaterOrEqual(t, len(allTags), 1) // At least our new tag // Find the created tag in the list var found bool for _, tag := range allTags { if tag.Name == "new-tag" { found = true assert.Greater(t, tag.ID, 0, "The tag in the database should have a valid ID") break } } assert.True(t, found, "The created tag should be found in the database") }) // Test GetTag - Success t.Run("GetTag_Success", func(t *testing.T) { // Get all tags to find an ID allTags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) require.NotEmpty(t, allTags) tagID := allTags[0].ID // Get the tag by ID tag, err := tagsDomain.GetTag(ctx, tagID) require.NoError(t, err) assert.Equal(t, tagID, tag.ID) assert.Equal(t, allTags[0].Name, tag.Name) }) // Test GetTag - Not Found t.Run("GetTag_NotFound", func(t *testing.T) { // Try to get a non-existent tag _, err := tagsDomain.GetTag(ctx, 9999) require.Error(t, err) assert.Equal(t, model.ErrNotFound, err) }) // Test UpdateTag t.Run("UpdateTag", func(t *testing.T) { // Get all tags to find an ID allTags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) require.NotEmpty(t, allTags) tagID := allTags[0].ID // Update the tag tagDTO := model.TagDTO{ Tag: model.Tag{ ID: tagID, Name: "updated-tag", }, } updatedTag, err := tagsDomain.UpdateTag(ctx, tagDTO) require.NoError(t, err) assert.Equal(t, tagID, updatedTag.ID) assert.Equal(t, "updated-tag", updatedTag.Name) // Verify the tag was updated in the database dbTag, exists, err := db.GetTag(ctx, tagID) require.NoError(t, err) require.True(t, exists) assert.Equal(t, "updated-tag", dbTag.Name) }) // Test DeleteTag t.Run("DeleteTag", func(t *testing.T) { // Get all tags to find an ID allTags, err := db.GetTags(ctx, model.DBListTagsOptions{}) require.NoError(t, err) require.NotEmpty(t, allTags) tagID := allTags[1].ID // Delete the tag err = tagsDomain.DeleteTag(ctx, tagID) require.NoError(t, err) // Verify the tag was deleted from the database _, exists, err := db.GetTag(ctx, tagID) require.NoError(t, err) require.False(t, exists) }) // Test DeleteTag - Not Found t.Run("DeleteTag_NotFound", func(t *testing.T) { // Try to delete a non-existent tag err := tagsDomain.DeleteTag(ctx, 9999) require.Error(t, err) // Use errors.Is to check if the error is or wraps model.ErrNotFound assert.True(t, errors.Is(err, model.ErrNotFound) || strings.Contains(err.Error(), "not found"), "Expected error to be or contain 'not found', got: %v", err) }) } ================================================ FILE: internal/http/handlers/api/v1/accounts.go ================================================ package api_v1 import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) type createAccountPayload struct { Username string `json:"username"` Password string `json:"password"` Owner bool `json:"owner"` } func (p *createAccountPayload) ToAccountDTO() model.AccountDTO { return model.AccountDTO{ Username: p.Username, Password: p.Password, Owner: &p.Owner, } } // @Summary List accounts // @Description List accounts // @Tags accounts // @Produce json // @Success 200 {array} model.AccountDTO // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/accounts [get] func HandleListAccounts(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { return } accounts, err := deps.Domains().Accounts().ListAccounts(c.Request().Context()) if err != nil { deps.Logger().WithError(err).Error("error getting accounts") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, accounts) } // @Summary Create an account // @Tags accounts // @Accept json // @Produce json // @Success 201 {object} model.AccountDTO // @Failure 400 {object} nil "Bad Request" // @Failure 409 {object} nil "Account already exists" // @Failure 500 {object} nil "Internal Server Error" // @Router /api/v1/accounts [post] func HandleCreateAccount(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { return } var payload createAccountPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "invalid json") return } account, err := deps.Domains().Accounts().CreateAccount(c.Request().Context(), payload.ToAccountDTO()) if err, isValidationErr := err.(model.ValidationError); isValidationErr { response.SendError(c, http.StatusBadRequest, err.Error()) return } if errors.Is(err, model.ErrAlreadyExists) { response.SendError(c, http.StatusConflict, "account already exists") return } if err != nil { deps.Logger().WithError(err).Error("error creating account") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusCreated, account) } // @Summary Delete an account // @Tags accounts // @Produce json // @Param id path int true "Account ID" // @Success 204 {object} nil "No content" // @Failure 400 {object} nil "Invalid ID" // @Failure 404 {object} nil "Account not found" // @Failure 500 {object} nil "Internal Server Error" // @Router /api/v1/accounts/{id} [delete] func HandleDeleteAccount(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { return } id, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { response.SendError(c, http.StatusBadRequest, "invalid id") return } err = deps.Domains().Accounts().DeleteAccount(c.Request().Context(), id) if errors.Is(err, model.ErrNotFound) { response.SendError(c, http.StatusNotFound, "account not found") return } if err != nil { deps.Logger().WithError(err).Error("error deleting account") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusNoContent, nil) } // @Summary Update an account // @Tags accounts // @Accept json // @Produce json // @Param id path int true "Account ID" // @Param account body updateAccountPayload true "Account data" // @Success 200 {object} model.AccountDTO // @Failure 400 {object} nil "Invalid ID/data" // @Failure 404 {object} nil "Account not found" // @Failure 409 {object} nil "Account already exists" // @Failure 500 {object} nil "Internal Server Error" // @Router /api/v1/accounts/{id} [patch] func HandleUpdateAccount(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { return } accountID, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { response.SendError(c, http.StatusBadRequest, "invalid id") return } var payload updateAccountPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "invalid json") return } updatedAccount := payload.ToAccountDTO() updatedAccount.ID = model.DBID(accountID) account, err := deps.Domains().Accounts().UpdateAccount(c.Request().Context(), updatedAccount) if errors.Is(err, model.ErrNotFound) { response.SendError(c, http.StatusNotFound, "account not found") return } if errors.Is(err, model.ErrAlreadyExists) { response.SendError(c, http.StatusConflict, "account already exists") return } if err, isValidationErr := err.(model.ValidationError); isValidationErr { response.SendError(c, http.StatusBadRequest, err.Error()) return } if err != nil { deps.Logger().WithError(err).Error("error updating account") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, account) } ================================================ FILE: internal/http/handlers/api/v1/accounts_test.go ================================================ package api_v1 import ( "context" "net/http" "strconv" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleListAccounts(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() HandleListAccounts(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin access", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleListAccounts(deps, c) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("database error", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) // Force DB error by closing connection deps.Database().ReaderDB().Close() HandleListAccounts(deps, c) require.Equal(t, http.StatusInternalServerError, w.Code) }) t.Run("returns accounts list", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create test account _, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "gopher", Password: "shiori", }) require.NoError(t, err) c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) HandleListAccounts(deps, c) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageIsListLength(t, 1) // Admin + created account }) } func TestHandleCreateAccount(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() HandleCreateAccount(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin access", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleCreateAccount(deps, c) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("invalid json payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `invalid json` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetFakeAdmin(c) HandleCreateAccount(deps, c) }, "POST", "/api/v1/accounts", testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("database error", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Force DB error deps.Database().WriterDB().Close() body := `{ "username": "gopher", "password": "shiori" }` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetFakeAdmin(c) HandleCreateAccount(deps, c) }, "POST", "/api/v1/accounts", testutil.WithBody(body)) require.Equal(t, http.StatusInternalServerError, w.Code) }) t.Run("account already exists", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create first account _, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "gopher", Password: "shiori", }) require.NoError(t, err) // Try to create duplicate account body := `{ "username": "gopher", "password": "shiori" }` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetFakeAdmin(c) HandleCreateAccount(deps, c) }, "POST", "/api/v1/accounts", testutil.WithBody(body)) require.Equal(t, http.StatusConflict, w.Code) }) t.Run("successful creation", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `{ "username": "newuser", "password": "password", "owner": false }` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetFakeAdmin(c) HandleCreateAccount(deps, c) }, "POST", "/api/v1/accounts", testutil.WithBody(body)) require.Equal(t, http.StatusCreated, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "id", func(t *testing.T, value any) { require.NotZero(t, value) }) }) } func TestHandleDeleteAccount(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() HandleDeleteAccount(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin access", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleDeleteAccount(deps, c) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("invalid id", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) testutil.SetRequestPathValue(c, "id", "invalid") HandleDeleteAccount(deps, c) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("account not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) testutil.SetRequestPathValue(c, "id", "999") HandleDeleteAccount(deps, c) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("successful deletion", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create account to delete account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "todelete", Password: "password", }) require.NoError(t, err) c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) HandleDeleteAccount(deps, c) require.Equal(t, http.StatusNoContent, w.Code) }) } func TestHandleUpdateAccount(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() HandleUpdateAccount(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin access", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleUpdateAccount(deps, c) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("invalid id", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) testutil.SetRequestPathValue(c, "id", "invalid") HandleUpdateAccount(deps, c) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("invalid json payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `invalid json` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/1", testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("account not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `{"username": "newname"}` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", "999") testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/999", testutil.WithBody(body)) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("successful update", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create account to update account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", }) require.NoError(t, err) body := `{ "username": "updated", "owner": true }` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "owner", func(t *testing.T, value any) { require.True(t, value.(bool)) }) }) t.Run("update with empty payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", Owner: model.Ptr(false), Config: model.Ptr(model.UserConfig{ ShowId: true, ListMode: true, HideThumbnail: true, }), }) require.NoError(t, err) body := `{}` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) // Verify no changes were made response := testutil.NewTestResponseFromRecorder(w) response.AssertNotOk(t) }) t.Run("update username only", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", }) require.NoError(t, err) body := `{"username": "newname"}` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "username", func(t *testing.T, value any) { require.Equal(t, "newname", value) }) }) t.Run("update password only", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", }) require.NoError(t, err) body := `{"new_password": "newpass"}` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusOK, w.Code) // Verify we can login with new password loginBody := `{"username": "shiori", "password": "newpass"}` w = testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(loginBody)) require.Equal(t, http.StatusOK, w.Code) }) t.Run("only admin can update other's passwords", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", }) require.NoError(t, err) body := `{"new_password": "newpass"}` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeUser(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("update config only", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", Config: model.Ptr(model.UserConfig{ ShowId: false, ListMode: false, }), }) require.NoError(t, err) body := `{ "config": { "ShowId": true, "ListMode": true, "HideThumbnail": true, "HideExcerpt": true, "Theme": "dark", "KeepMetadata": true, "UseArchive": true, "CreateEbook": true, "MakePublic": true } }` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "config", func(t *testing.T, value any) { config := value.(map[string]any) require.True(t, config["ShowId"].(bool)) require.True(t, config["ListMode"].(bool)) require.True(t, config["HideThumbnail"].(bool)) require.True(t, config["HideExcerpt"].(bool)) require.Equal(t, "dark", config["Theme"]) require.True(t, config["KeepMetadata"].(bool)) require.True(t, config["UseArchive"].(bool)) require.True(t, config["CreateEbook"].(bool)) require.True(t, config["MakePublic"].(bool)) }) }) t.Run("update all fields", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account, err := deps.Domains().Accounts().CreateAccount(ctx, model.AccountDTO{ Username: "shiori", Password: "gopher", Owner: model.Ptr(false), Config: model.Ptr(model.UserConfig{ ShowId: false, ListMode: false, }), }) require.NoError(t, err) body := `{ "username": "updated", "new_password": "newpass", "owner": true, "config": { "ShowId": true, "ListMode": true, "HideThumbnail": true, "HideExcerpt": true, "Theme": "dark" } }` w := testutil.PerformRequest(deps, func(deps model.Dependencies, c model.WebContext) { testutil.SetRequestPathValue(c, "id", strconv.Itoa(int(account.ID))) testutil.SetFakeAdmin(c) HandleUpdateAccount(deps, c) }, "PATCH", "/api/v1/accounts/"+strconv.Itoa(int(account.ID)), testutil.WithBody(body)) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "username", func(t *testing.T, value any) { require.Equal(t, "updated", value) }) response.AssertMessageJSONKeyValue(t, "owner", func(t *testing.T, value any) { require.True(t, value.(bool)) }) response.AssertMessageJSONKeyValue(t, "config", func(t *testing.T, value any) { config := value.(map[string]any) require.True(t, config["ShowId"].(bool)) require.True(t, config["ListMode"].(bool)) require.True(t, config["HideThumbnail"].(bool)) require.True(t, config["HideExcerpt"].(bool)) require.Equal(t, "dark", config["Theme"]) }) // Verify password change loginBody := `{"username": "updated", "password": "newpass"}` w = testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(loginBody)) require.Equal(t, http.StatusOK, w.Code) }) } ================================================ FILE: internal/http/handlers/api/v1/auth.go ================================================ package api_v1 import ( "encoding/json" "fmt" "net/http" "time" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) type loginRequestPayload struct { Username string `json:"username"` Password string `json:"password"` RememberMe bool `json:"remember_me"` } func (p *loginRequestPayload) IsValid() error { if p.Username == "" { return fmt.Errorf("username should not be empty") } if p.Password == "" { return fmt.Errorf("password should not be empty") } return nil } type loginResponseMessage struct { Token string `json:"token"` Expiration int64 `json:"expires"` } // @Summary Login to an account using username and password // @Tags Auth // @Accept json // @Produce json // @Param payload body loginRequestPayload false "Login data" // @Success 200 {object} loginResponseMessage "Login successful" // @Failure 400 {object} nil "Invalid login data" // @Router /api/v1/auth/login [post] func HandleLogin(deps model.Dependencies, c model.WebContext) { var payload loginRequestPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "Invalid JSON payload") return } if err := payload.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } account, err := deps.Domains().Auth().GetAccountFromCredentials(c.Request().Context(), payload.Username, payload.Password) if err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } expiration := time.Hour if payload.RememberMe { expiration = time.Hour * 24 * 30 } expirationTime := time.Now().Add(expiration) token, err := deps.Domains().Auth().CreateTokenForAccount(account, expirationTime) if err != nil { response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, loginResponseMessage{ Token: token, Expiration: expirationTime.Unix(), }) } // @Summary Refresh a token for an account // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Success 200 {object} loginResponseMessage "Refresh successful" // @Failure 403 {object} nil "Token not provided/invalid" // @Router /api/v1/auth/refresh [post] func HandleRefreshToken(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } expiration := time.Now().UTC().Add(time.Hour * 24 * 30) account := c.GetAccount() token, err := deps.Domains().Auth().CreateTokenForAccount(account, expiration) if err != nil { response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusAccepted, loginResponseMessage{ Token: token, Expiration: expiration.Unix(), }) } // @Summary Get information for the current logged in user // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Success 200 {object} model.Account // @Failure 403 {object} nil "Token not provided/invalid" // @Router /api/v1/auth/me [get] func HandleGetMe(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } response.SendJSON(c, http.StatusOK, c.GetAccount()) } type updateAccountPayload struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` Username string `json:"username"` Owner *bool `json:"owner"` Config *model.UserConfig `json:"config"` } func (p *updateAccountPayload) IsValid() error { if p.NewPassword != "" && p.OldPassword == "" { return fmt.Errorf("to update the password the old one must be provided") } return nil } func (p *updateAccountPayload) ToAccountDTO() model.AccountDTO { account := model.AccountDTO{ Config: p.Config, } if p.NewPassword != "" { account.Password = p.NewPassword } if p.Owner != nil { account.Owner = p.Owner } if p.Config != nil { account.Config = p.Config } if p.Username != "" { account.Username = p.Username } return account } // @Summary Update account information // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Param payload body updateAccountPayload false "Account data" // @Produce json // @Success 200 {object} model.Account // @Failure 403 {object} nil "Token not provided/invalid" // @Router /api/v1/auth/account [patch] func HandleUpdateLoggedAccount(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } var payload updateAccountPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendInternalServerError(c) return } if err := payload.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } account := c.GetAccount() if payload.NewPassword != "" { _, err := deps.Domains().Auth().GetAccountFromCredentials(c.Request().Context(), account.Username, payload.OldPassword) if err != nil { response.SendError(c, http.StatusBadRequest, "Old password is incorrect") return } } // TODO: Use a method in the AccountDTO to apply the updates directly: // account := domains.Accounts().GetAccount(...) // account.ApplyUpdates(payload) updatedAccount := payload.ToAccountDTO() updatedAccount.ID = account.ID account, err := deps.Domains().Accounts().UpdateAccount(c.Request().Context(), updatedAccount) if err != nil { deps.Logger().WithError(err).Error("failed to update account") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, account) } // @Summary Logout from the current session // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Success 200 {object} nil "Logout successful" // @Failure 403 {object} nil "Token not provided/invalid" // @Router /api/v1/auth/logout [post] func HandleLogout(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } // Remove token cookie c.Request().AddCookie(&http.Cookie{ Name: "token", Value: "", }) response.SendJSON(c, http.StatusOK, nil) } ================================================ FILE: internal/http/handlers/api/v1/auth_test.go ================================================ package api_v1 import ( "context" "net/http" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleLogin(t *testing.T) { logger := logrus.New() // _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("invalid json payload", func(t *testing.T) { ctx := context.Background() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `{"username":}` w := testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("missing username", func(t *testing.T) { ctx := context.Background() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `{"password": "test"}` w := testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("missing password", func(t *testing.T) { ctx := context.Background() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `{"username": "test"}` w := testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("invalid credentials", func(t *testing.T) { ctx := context.Background() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) body := `{"username": "test", "password": "wrong"}` w := testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(body)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("successful login", func(t *testing.T) { ctx := context.Background() _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) account := testutil.GetValidAccount().ToDTO() account.Password = "test" _, err := deps.Domains().Accounts().CreateAccount(context.Background(), account) require.NoError(t, err) body := `{ "username": "test", "password": "test", "remember_me": true }` w := testutil.PerformRequest(deps, HandleLogin, "POST", "/login", testutil.WithBody(body)) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "token", func(t *testing.T, value any) { require.NotEmpty(t, value) }) response.AssertMessageJSONKeyValue(t, "expires", func(t *testing.T, value any) { require.NotEmpty(t, value) }) }) } func TestHandleRefreshToken(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("requires authentication", func(t *testing.T) { w := testutil.PerformRequest(deps, HandleRefreshToken, "POST", "/refresh") require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("successful refresh", func(t *testing.T) { account := testutil.GetValidAccount().ToDTO() account.Password = "test" _, err := deps.Domains().Accounts().CreateAccount(context.Background(), account) require.NoError(t, err) w := testutil.PerformRequest(deps, HandleRefreshToken, "POST", "/refresh", testutil.WithAccount(&account)) require.Equal(t, http.StatusAccepted, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "token", func(t *testing.T, value any) { require.NotEmpty(t, value) }) response.AssertMessageJSONKeyValue(t, "expires", func(t *testing.T, value any) { require.NotZero(t, value) }) }) } func TestHandleGetMe(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("requires authentication", func(t *testing.T) { c, w := testutil.NewTestWebContext() HandleGetMe(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("returns user info", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleGetMe(deps, c) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "username", func(t *testing.T, value any) { require.Equal(t, "user", value) }) response.AssertMessageJSONKeyValue(t, "owner", func(t *testing.T, value any) { require.False(t, value.(bool)) }) }) t.Run("returns admin info", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) HandleGetMe(deps, c) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "username", func(t *testing.T, value any) { require.Equal(t, "user", value) }) response.AssertMessageJSONKeyValue(t, "owner", func(t *testing.T, value any) { require.True(t, value.(bool)) }) }) } func TestHandleUpdateLoggedAccount(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) account, err := deps.Domains().Accounts().CreateAccount(context.Background(), model.AccountDTO{ Username: "shiori", Password: "gopher", Owner: model.Ptr(true), Config: model.Ptr(model.UserConfig{ ShowId: true, ListMode: true, HideThumbnail: true, HideExcerpt: true, KeepMetadata: true, UseArchive: true, CreateEbook: true, MakePublic: true, }), }) require.NoError(t, err) t.Run("requires authentication", func(t *testing.T) { c, w := testutil.NewTestWebContext() HandleUpdateLoggedAccount(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("invalid json payload", func(t *testing.T) { body := `invalid json` w := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, "PATCH", "/account", testutil.WithBody(body), testutil.WithAccount(account)) require.Equal(t, http.StatusInternalServerError, w.Code) }) t.Run("missing old password", func(t *testing.T) { body := `{"new_password": "newpass"}` w := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, "PATCH", "/account", testutil.WithBody(body), testutil.WithAccount(account)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("incorrect old password", func(t *testing.T) { body := `{ "old_password": "wrong", "new_password": "newpass" }` w := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, "PATCH", "/account", testutil.WithBody(body), testutil.WithAccount(account)) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("successful update", func(t *testing.T) { body := `{ "old_password": "gopher", "new_password": "newpass", "config": { "ShowId": true, "ListMode": true } }` w := testutil.PerformRequest(deps, HandleUpdateLoggedAccount, "PATCH", "/account", testutil.WithBody(body), testutil.WithAccount(account)) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "username", func(t *testing.T, value any) { require.Equal(t, "shiori", value) }) response.AssertMessageJSONKeyValue(t, "config", func(t *testing.T, value any) { config := value.(map[string]any) require.True(t, config["ShowId"].(bool)) require.True(t, config["ListMode"].(bool)) }) }) } func TestHandleLogout(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("requires authentication", func(t *testing.T) { c, w := testutil.NewTestWebContext() HandleLogout(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("successful logout", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleLogout(deps, c) require.Equal(t, http.StatusOK, w.Code) }) } ================================================ FILE: internal/http/handlers/api/v1/bookmark_tags_test.go ================================================ package api_v1_test import ( "context" "encoding/json" "net/http" "strconv" "testing" api_v1 "github.com/go-shiori/shiori/internal/http/handlers/api/v1" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Define the BookmarkTagPayload struct to match the one in the API type bookmarkTagPayload struct { TagID int `json:"tag_id"` } func TestBookmarkTagsAPI(t *testing.T) { ctx := context.Background() logger := logrus.New() // Setup using the test configuration and dependencies _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) db := deps.Database() // Create a test bookmark bookmark := model.BookmarkDTO{ URL: "https://example.com/api-tags-test", Title: "API Tags Test", } savedBookmarks, err := db.SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) require.Len(t, savedBookmarks, 1) bookmarkID := savedBookmarks[0].ID // Create a test tag tag := model.Tag{ Name: "api-test-tag", } createdTags, err := db.CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID // Test authentication requirements t.Run("AuthenticationRequirements", func(t *testing.T) { // Test unauthenticated user for GetBookmarkTags t.Run("UnauthenticatedUserGetTags", func(t *testing.T) { rec := testutil.PerformRequest( deps, api_v1.HandleGetBookmarkTags, http.MethodGet, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), ) require.Equal(t, http.StatusUnauthorized, rec.Code) }) // Test unauthenticated user for AddTagToBookmark t.Run("UnauthenticatedUserAddTag", func(t *testing.T) { payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusUnauthorized, rec.Code) }) // Test non-admin user for AddTagToBookmark (which requires admin) t.Run("NonAdminUserAddTag", func(t *testing.T) { payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeUser(), // Regular user, not admin testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Just check the status code since the response might vary require.Equal(t, http.StatusForbidden, rec.Code) }) // Test unauthenticated user for RemoveTagFromBookmark t.Run("UnauthenticatedUserRemoveTag", func(t *testing.T) { payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleRemoveTagFromBookmark, http.MethodDelete, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusUnauthorized, rec.Code) }) }) // Test BulkUpdateBookmarkTags t.Run("BulkUpdateBookmarkTags", func(t *testing.T) { // Define the payload struct type bulkUpdatePayload struct { BookmarkIDs []int `json:"bookmark_ids"` TagIDs []int `json:"tag_ids"` } // Test successful bulk update t.Run("SuccessfulBulkUpdate", func(t *testing.T) { payload := bulkUpdatePayload{ BookmarkIDs: []int{bookmarkID}, TagIDs: []int{tagID}, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleBulkUpdateBookmarkTags, http.MethodPut, "/api/v1/bookmarks/bulk/tags", testutil.WithFakeAdmin(), testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusOK, rec.Code) testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertOk(t) }) // Test unauthenticated user t.Run("UnauthenticatedUser", func(t *testing.T) { payload := bulkUpdatePayload{ BookmarkIDs: []int{bookmarkID}, TagIDs: []int{tagID}, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleBulkUpdateBookmarkTags, http.MethodPut, "/api/v1/bookmarks/bulk/tags", testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusUnauthorized, rec.Code) }) // Test invalid request payload t.Run("InvalidRequestPayload", func(t *testing.T) { invalidPayload := []byte(`{"bookmark_ids": "invalid", "tag_ids": [1]}`) rec := testutil.PerformRequest( deps, api_v1.HandleBulkUpdateBookmarkTags, http.MethodPut, "/api/v1/bookmarks/bulk/tags", testutil.WithFakeAdmin(), testutil.WithBody(string(invalidPayload)), ) require.Equal(t, http.StatusBadRequest, rec.Code) testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Invalid request payload", value) }) }) // Test empty bookmark IDs t.Run("EmptyBookmarkIDs", func(t *testing.T) { payload := bulkUpdatePayload{ BookmarkIDs: []int{}, TagIDs: []int{tagID}, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleBulkUpdateBookmarkTags, http.MethodPut, "/api/v1/bookmarks/bulk/tags", testutil.WithFakeAdmin(), testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusBadRequest, rec.Code) testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "bookmark_ids should not be empty", value) }) }) // Test empty tag IDs t.Run("EmptyTagIDs", func(t *testing.T) { payload := bulkUpdatePayload{ BookmarkIDs: []int{bookmarkID}, TagIDs: []int{}, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleBulkUpdateBookmarkTags, http.MethodPut, "/api/v1/bookmarks/bulk/tags", testutil.WithFakeAdmin(), testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusBadRequest, rec.Code) testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "tag_ids should not be empty", value) }) }) // Test bookmark not found t.Run("BookmarkNotFound", func(t *testing.T) { payload := bulkUpdatePayload{ BookmarkIDs: []int{9999}, // Non-existent bookmark ID TagIDs: []int{tagID}, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleBulkUpdateBookmarkTags, http.MethodPut, "/api/v1/bookmarks/bulk/tags", testutil.WithFakeAdmin(), testutil.WithBody(string(payloadBytes)), ) require.Equal(t, http.StatusInternalServerError, rec.Code) testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Failed to update bookmarks", value) }) }) }) // Test GetBookmarkTags t.Run("GetBookmarkTags", func(t *testing.T) { // Add a tag to the bookmark first err := db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Create a request to get the tags rec := testutil.PerformRequest( deps, api_v1.HandleGetBookmarkTags, http.MethodGet, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), ) // Check the response require.Equal(t, http.StatusOK, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertOk(t) testResp.AssertMessageIsNotEmptyList(t) testResp.ForEach(t, func(item map[string]any) { require.NotZero(t, item["id"]) require.NotEmpty(t, item["name"]) }) }) // Test AddTagToBookmark t.Run("AddTagToBookmark", func(t *testing.T) { // Remove the tag first to ensure a clean state err := db.RemoveTagFromBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Create a request to add the tag payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusCreated, rec.Code) // Verify the tag was added tags, err := deps.Domains().Tags().ListTags(ctx, model.ListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 1) assert.Equal(t, tagID, tags[0].ID) }) // Test RemoveTagFromBookmark t.Run("RemoveTagFromBookmark", func(t *testing.T) { // Add the tag first to ensure it exists err := db.AddTagToBookmark(ctx, bookmarkID, tagID) require.NoError(t, err) // Create a request to remove the tag payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleRemoveTagFromBookmark, http.MethodDelete, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusOK, rec.Code) // Verify the tag was removed tags, err := deps.Domains().Tags().ListTags(ctx, model.ListTagsOptions{ BookmarkID: bookmarkID, }) require.NoError(t, err) require.Len(t, tags, 0) }) // Test error cases t.Run("ErrorCases", func(t *testing.T) { // Test non-existent bookmark t.Run("NonExistentBookmark", func(t *testing.T) { // Create a request to get tags for a non-existent bookmark rec := testutil.PerformRequest( deps, api_v1.HandleGetBookmarkTags, http.MethodGet, "/api/v1/bookmarks/9999/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "9999"), ) // Check the response require.Equal(t, http.StatusNotFound, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Bookmark not found", value) }) }) // Test non-existent tag t.Run("NonExistentTag", func(t *testing.T) { // Create a request to add a non-existent tag payload := bookmarkTagPayload{ TagID: 9999, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusNotFound, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Tag not found", value) }) }) // Test non-existent bookmark for AddTagToBookmark t.Run("NonExistentBookmarkForAddTag", func(t *testing.T) { // Create a request to add a tag to a non-existent bookmark payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/9999/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "9999"), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusNotFound, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Bookmark not found", value) }) }) // Test non-existent bookmark for RemoveTagFromBookmark t.Run("NonExistentBookmarkForRemoveTag", func(t *testing.T) { // Create a request to remove a tag from a non-existent bookmark payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleRemoveTagFromBookmark, http.MethodDelete, "/api/v1/bookmarks/9999/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "9999"), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusNotFound, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Bookmark not found", value) }) }) // Test non-existent tag for RemoveTagFromBookmark t.Run("NonExistentTagForRemoveTag", func(t *testing.T) { // Create a request to remove a non-existent tag payload := bookmarkTagPayload{ TagID: 9999, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleRemoveTagFromBookmark, http.MethodDelete, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusNotFound, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Tag not found", value) }) }) // Test invalid bookmark ID t.Run("InvalidBookmarkID", func(t *testing.T) { // Create a request with an invalid bookmark ID rec := testutil.PerformRequest( deps, api_v1.HandleGetBookmarkTags, http.MethodGet, "/api/v1/bookmarks/invalid/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "invalid"), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Invalid bookmark ID", value) }) }) // Test invalid bookmark ID for AddTagToBookmark t.Run("InvalidBookmarkIDForAddTag", func(t *testing.T) { // Create a request with an invalid bookmark ID payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/invalid/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "invalid"), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Invalid bookmark ID", value) }) }) // Test invalid bookmark ID for RemoveTagFromBookmark t.Run("InvalidBookmarkIDForRemoveTag", func(t *testing.T) { // Create a request with an invalid bookmark ID payload := bookmarkTagPayload{ TagID: tagID, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleRemoveTagFromBookmark, http.MethodDelete, "/api/v1/bookmarks/invalid/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "invalid"), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Invalid bookmark ID", value) }) }) // Test invalid payload t.Run("InvalidPayload", func(t *testing.T) { // Create a request with an invalid payload invalidPayload := []byte(`{"tag_id": "invalid"}`) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(invalidPayload)), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "Invalid request payload", value) }) }) // Test zero tag ID t.Run("ZeroTagID", func(t *testing.T) { // Create a request with a zero tag ID payload := bookmarkTagPayload{ TagID: 0, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "tag_id should be a positive integer", value) }) }) // Test negative tag ID t.Run("NegativeTagID", func(t *testing.T) { // Create a request with a negative tag ID payload := bookmarkTagPayload{ TagID: -1, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleAddTagToBookmark, http.MethodPost, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "tag_id should be a positive integer", value) }) }) // Test validation for RemoveTagFromBookmark t.Run("RemoveTagValidation", func(t *testing.T) { // Create a request with a zero tag ID payload := bookmarkTagPayload{ TagID: 0, } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) rec := testutil.PerformRequest( deps, api_v1.HandleRemoveTagFromBookmark, http.MethodDelete, "/api/v1/bookmarks/"+strconv.Itoa(bookmarkID)+"/tags", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(bookmarkID)), testutil.WithBody(string(payloadBytes)), ) // Check the response require.Equal(t, http.StatusBadRequest, rec.Code) // Parse the response testResp := testutil.NewTestResponseFromRecorder(rec) testResp.AssertNotOk(t) testResp.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "tag_id should be a positive integer", value) }) }) }) } ================================================ FILE: internal/http/handlers/api/v1/bookmarks.go ================================================ package api_v1 import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "sync" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) type updateCachePayload struct { Ids []int `json:"ids" validate:"required"` KeepMetadata bool `json:"keep_metadata"` CreateArchive bool `json:"create_archive"` CreateEbook bool `json:"create_ebook"` SkipExist bool `json:"skip_exist"` } func (p *updateCachePayload) IsValid() error { if len(p.Ids) == 0 { return fmt.Errorf("id should not be empty") } for _, id := range p.Ids { if id <= 0 { return fmt.Errorf("id should not be 0 or negative") } } return nil } type readableResponseMessage struct { Content string `json:"content"` HTML string `json:"html"` } // HandleBookmarkReadable returns the readable version of a bookmark // // @Summary Get readable version of bookmark. // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Success 200 {object} readableResponseMessage // @Failure 403 {object} nil "Token not provided/invalid" // @Router /api/v1/bookmarks/id/readable [get] func HandleBookmarkReadable(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { response.SendError(c, http.StatusForbidden, err.Error()) return } bookmarkID, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid bookmark ID") return } bookmark, err := deps.Domains().Bookmarks().GetBookmark(c.Request().Context(), model.DBID(bookmarkID)) if err != nil { response.SendError(c, http.StatusNotFound, "Bookmark not found") return } response.SendJSON(c, http.StatusOK, readableResponseMessage{ Content: bookmark.Content, HTML: bookmark.HTML, }) } // HandleUpdateCache updates the cache and ebook for bookmarks // // @Summary Update Cache and Ebook on server. // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Param payload body updateCachePayload true "Update Cache Payload" // @Produce json // @Success 200 {object} model.BookmarkDTO // @Failure 403 {object} nil "Token not provided/invalid" // @Router /api/v1/bookmarks/cache [put] func HandleUpdateCache(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { response.SendError(c, http.StatusForbidden, err.Error()) return } // Parse request payload var payload updateCachePayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "Invalid request payload") return } if err := payload.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } // Get bookmarks from database bookmarks, err := deps.Domains().Bookmarks().GetBookmarks(c.Request().Context(), payload.Ids) if err != nil { response.SendError(c, http.StatusInternalServerError, "Failed to get bookmarks") return } if len(bookmarks) == 0 { response.SendError(c, http.StatusNotFound, "No bookmarks found") return } // Process bookmarks concurrently mx := sync.RWMutex{} wg := sync.WaitGroup{} chDone := make(chan struct{}) chProblem := make(chan int, 10) semaphore := make(chan struct{}, 10) for i, book := range bookmarks { wg.Add(1) book.CreateArchive = payload.CreateArchive book.CreateEbook = payload.CreateEbook go func(i int, book model.BookmarkDTO) { defer wg.Done() defer func() { <-semaphore }() semaphore <- struct{}{} // Download and process bookmark updatedBook, err := deps.Domains().Bookmarks().UpdateBookmarkCache(c.Request().Context(), book, payload.KeepMetadata, payload.SkipExist) if err != nil { deps.Logger().WithError(err).Error("error updating bookmark cache") chProblem <- book.ID return } mx.Lock() bookmarks[i] = *updatedBook mx.Unlock() }(i, book) } // Collect problematic bookmarks idWithProblems := []int{} go func() { for { select { case <-chDone: return case id := <-chProblem: idWithProblems = append(idWithProblems, id) } } }() wg.Wait() close(chDone) response.SendJSON(c, http.StatusOK, bookmarks) } type bulkUpdateBookmarkTagsPayload struct { BookmarkIDs []int `json:"bookmark_ids" validate:"required"` TagIDs []int `json:"tag_ids" validate:"required"` } func (p *bulkUpdateBookmarkTagsPayload) IsValid() error { if len(p.BookmarkIDs) == 0 { return fmt.Errorf("bookmark_ids should not be empty") } if len(p.TagIDs) == 0 { return fmt.Errorf("tag_ids should not be empty") } return nil } // HandleGetBookmarkTags gets the tags for a bookmark // // @Summary Get tags for a bookmark. // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Param id path int true "Bookmark ID" // @Success 200 {array} model.TagDTO // @Failure 403 {object} nil "Token not provided/invalid" // @Failure 404 {object} nil "Bookmark not found" // @Router /api/v1/bookmarks/{id}/tags [get] func HandleGetBookmarkTags(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { response.SendError(c, http.StatusForbidden, err.Error()) return } bookmarkID, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid bookmark ID") return } // Check if bookmark exists exists, err := deps.Domains().Bookmarks().BookmarkExists(c.Request().Context(), bookmarkID) if err != nil { response.SendError(c, http.StatusInternalServerError, "Failed to check if bookmark exists") return } if !exists { response.SendError(c, http.StatusNotFound, "Bookmark not found") return } // Get bookmark to retrieve its tags tags, err := deps.Domains().Tags().ListTags(c.Request().Context(), model.ListTagsOptions{ BookmarkID: bookmarkID, }) if err != nil { response.SendError(c, http.StatusInternalServerError, "Failed to get bookmark tags") return } response.SendJSON(c, http.StatusOK, tags) } // bookmarkTagPayload is used for both adding and removing tags from bookmarks type bookmarkTagPayload struct { TagID int `json:"tag_id" validate:"required"` } func (p *bookmarkTagPayload) IsValid() error { if p.TagID <= 0 { return fmt.Errorf("tag_id should be a positive integer") } return nil } // HandleAddTagToBookmark adds a tag to a bookmark // // @Summary Add a tag to a bookmark. // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Param id path int true "Bookmark ID" // @Param payload body bookmarkTagPayload true "Add Tag Payload" // @Produce json // @Success 200 {object} nil // @Failure 403 {object} nil "Token not provided/invalid" // @Failure 404 {object} nil "Bookmark or tag not found" // @Router /api/v1/bookmarks/{id}/tags [post] func HandleAddTagToBookmark(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { response.SendError(c, http.StatusForbidden, err.Error()) return } bookmarkID, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid bookmark ID") return } // Parse request payload var payload bookmarkTagPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "Invalid request payload") return } if err := payload.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } // Add tag to bookmark err = deps.Domains().Bookmarks().AddTagToBookmark(c.Request().Context(), bookmarkID, payload.TagID) if err != nil { if errors.Is(err, model.ErrBookmarkNotFound) { response.SendError(c, http.StatusNotFound, "Bookmark not found") return } if errors.Is(err, model.ErrTagNotFound) { response.SendError(c, http.StatusNotFound, "Tag not found") return } response.SendError(c, http.StatusInternalServerError, "Failed to add tag to bookmark") return } response.SendJSON(c, http.StatusCreated, nil) } // HandleRemoveTagFromBookmark removes a tag from a bookmark // // @Summary Remove a tag from a bookmark. // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Param id path int true "Bookmark ID" // @Param payload body bookmarkTagPayload true "Remove Tag Payload" // @Produce json // @Success 200 {object} nil // @Failure 403 {object} nil "Token not provided/invalid" // @Failure 404 {object} nil "Bookmark not found" // @Router /api/v1/bookmarks/{id}/tags [delete] func HandleRemoveTagFromBookmark(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { response.SendError(c, http.StatusForbidden, err.Error()) return } bookmarkID, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid bookmark ID") return } // Parse request payload var payload bookmarkTagPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "Invalid request payload") return } if err := payload.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } // Remove tag from bookmark err = deps.Domains().Bookmarks().RemoveTagFromBookmark(c.Request().Context(), bookmarkID, payload.TagID) if err != nil { if errors.Is(err, model.ErrBookmarkNotFound) { response.SendError(c, http.StatusNotFound, "Bookmark not found") return } if errors.Is(err, model.ErrTagNotFound) { response.SendError(c, http.StatusNotFound, "Tag not found") return } response.SendError(c, http.StatusInternalServerError, "Failed to remove tag from bookmark") return } response.SendJSON(c, http.StatusOK, nil) } // HandleBulkUpdateBookmarkTags updates the tags for multiple bookmarks // // @Summary Bulk update tags for multiple bookmarks. // @Tags Auth // @securityDefinitions.apikey ApiKeyAuth // @Param payload body bulkUpdateBookmarkTagsPayload true "Bulk Update Bookmark Tags Payload" // @Produce json // @Success 200 {object} []model.BookmarkDTO // @Failure 403 {object} nil "Token not provided/invalid" // @Failure 400 {object} nil "Invalid request payload" // @Failure 404 {object} nil "No bookmarks found" // @Router /api/v1/bookmarks/bulk/tags [put] func HandleBulkUpdateBookmarkTags(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { response.SendError(c, http.StatusForbidden, err.Error()) return } // Parse request payload var payload bulkUpdateBookmarkTagsPayload if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil { response.SendError(c, http.StatusBadRequest, "Invalid request payload") return } if err := payload.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } // Use the domain method to update bookmark tags err := deps.Domains().Bookmarks().BulkUpdateBookmarkTags(c.Request().Context(), payload.BookmarkIDs, payload.TagIDs) if err != nil { if errors.Is(err, model.ErrBookmarkNotFound) { response.SendError(c, http.StatusNotFound, "No bookmarks found") return } response.SendError(c, http.StatusInternalServerError, "Failed to update bookmarks") return } response.SendJSON(c, http.StatusOK, nil) } ================================================ FILE: internal/http/handlers/api/v1/bookmarks_test.go ================================================ package api_v1 import ( "context" "encoding/json" "io" "net/http" "strconv" "testing" "time" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleBookmarkReadable(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleBookmarkReadable, http.MethodGet, "/api/v1/bookmarks/1/readable", testutil.WithRequestPathValue("id", "1"), ) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("invalid bookmark id", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleBookmarkReadable, http.MethodGet, "/api/v1/bookmarks/invalid/readable", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "invalid"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("bookmark not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleBookmarkReadable, http.MethodGet, "/api/v1/bookmarks/999/readable", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "999"), ) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("success", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create test bookmark bookmark := testutil.GetValidBookmark() bookmark.Content = "test content" bookmark.HTML = "

test content

" savedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark) require.NoError(t, err) require.Len(t, savedBookmark, 1) w := testutil.PerformRequest( deps, HandleBookmarkReadable, http.MethodGet, "/api/v1/bookmarks/"+strconv.Itoa(savedBookmark[0].ID)+"/readable", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", strconv.Itoa(savedBookmark[0].ID)), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "content", func(t *testing.T, value any) { require.Equal(t, bookmark.Content, value) }) response.AssertMessageJSONKeyValue(t, "html", func(t *testing.T, value any) { require.Equal(t, bookmark.HTML, value) }) }) } func TestHandleUpdateCache(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateCache, http.MethodPut, "/api/v1/bookmarks/cache", ) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin access", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateCache, http.MethodPut, "/api/v1/bookmarks/cache", testutil.WithFakeUser(), ) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("invalid json payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateCache, http.MethodPut, "/api/v1/bookmarks/cache", testutil.WithFakeAdmin(), testutil.WithBody("invalid json"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("empty bookmark ids", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateCache, http.MethodPut, "/api/v1/bookmarks/cache", testutil.WithFakeAdmin(), testutil.WithBody(`{"ids": []}`), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("bookmarks not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateCache, http.MethodPut, "/api/v1/bookmarks/cache", testutil.WithFakeAdmin(), testutil.WithBody(`{"ids": [999]}`), ) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("successful update", func(t *testing.T) { t.Skip("skipping due to concurrent execution and no easy way to test it") _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create test bookmark bookmark := testutil.GetValidBookmark() savedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark) require.NoError(t, err) require.Len(t, savedBookmark, 1) body := `{ "ids": [` + strconv.Itoa(savedBookmark[0].ID) + `], "keep_metadata": true, "create_archive": true, "create_ebook": true }` w := testutil.PerformRequest( deps, HandleUpdateCache, http.MethodPut, "/api/v1/bookmarks/cache", testutil.WithFakeAdmin(), testutil.WithBody(body), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) // TODO: remove this sleep after refactoring into a job system time.Sleep(1 * time.Second) // Verify bookmark was updated updatedBookmark, exists, err := deps.Database().GetBookmark(ctx, savedBookmark[0].ID, "") require.NoError(t, err) require.True(t, exists) require.True(t, updatedBookmark.HasEbook) require.True(t, updatedBookmark.HasArchive) }) } func TestHandleUpdateBookmarkTags(t *testing.T) { ctx := context.Background() logger := logrus.New() logger.SetOutput(io.Discard) t.Run("requires_authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleBulkUpdateBookmarkTags, "PUT", "/api/v1/bookmarks/tags", ) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("invalid_json_payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleBulkUpdateBookmarkTags, "PUT", "/api/v1/bookmarks/tags", testutil.WithFakeUser(), testutil.WithBody("invalid json"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("empty_ids", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) payload := map[string]interface{}{ "ids": []int{}, "tags": []model.Tag{{Name: "test"}}, } body, _ := json.Marshal(payload) w := testutil.PerformRequest( deps, HandleBulkUpdateBookmarkTags, "PUT", "/api/v1/bookmarks/tags", testutil.WithFakeUser(), testutil.WithBody(string(body)), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("empty_tags", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) payload := map[string]interface{}{ "ids": []int{1}, "tags": []model.Tag{}, } body, _ := json.Marshal(payload) w := testutil.PerformRequest( deps, HandleBulkUpdateBookmarkTags, "PUT", "/api/v1/bookmarks/tags", testutil.WithFakeUser(), testutil.WithBody(string(body)), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("bookmark_not_found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) payload := map[string]interface{}{ "ids": []int{999}, "tags": []model.Tag{{Name: "test"}}, } body, _ := json.Marshal(payload) w := testutil.PerformRequest( deps, HandleBulkUpdateBookmarkTags, "PUT", "/api/v1/bookmarks/tags", testutil.WithFakeUser(), testutil.WithBody(string(body)), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("successful_update", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a bookmark first bookmark := testutil.GetValidBookmark() savedBookmark, err := deps.Database().SaveBookmarks(ctx, true, *bookmark) require.NoError(t, err) require.Len(t, savedBookmark, 1) // Create a tag tag := model.TagDTO{Tag: model.Tag{Name: "newtag"}} createdTag, err := deps.Database().CreateTag(ctx, tag.Tag) require.NoError(t, err) // Update the bookmark tags payload := map[string]interface{}{ "bookmark_ids": []int{savedBookmark[0].ID}, "tag_ids": []int{createdTag.ID}, } body, _ := json.Marshal(payload) w := testutil.PerformRequest( deps, HandleBulkUpdateBookmarkTags, "PUT", "/api/v1/bookmarks/tags", testutil.WithFakeUser(), testutil.WithBody(string(body)), ) t.Log(w.Body.String()) require.Equal(t, http.StatusOK, w.Code) // Verify the response response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) }) } ================================================ FILE: internal/http/handlers/api/v1/system.go ================================================ package api_v1 import ( "net/http" "runtime" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) type infoResponse struct { Version struct { Tag string `json:"tag"` Commit string `json:"commit"` Date string `json:"date"` } `json:"version"` Database string `json:"database"` OS string `json:"os"` } // @Summary Get general system information // @Description Get general system information like Shiori version, database, and OS // @Tags System // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Success 200 {object} infoResponse // @Failure 403 {object} nil "Only owners can access this endpoint" // @Router /api/v1/system/info [get] func HandleSystemInfo(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { return } response.SendJSON(c, http.StatusOK, infoResponse{ Version: struct { Tag string `json:"tag"` Commit string `json:"commit"` Date string `json:"date"` }{ Tag: model.BuildVersion, Commit: model.BuildCommit, Date: model.BuildDate, }, Database: deps.Database().ReaderDB().DriverName(), OS: runtime.GOOS + " (" + runtime.GOARCH + ")", }) } ================================================ FILE: internal/http/handlers/api/v1/system_test.go ================================================ package api_v1 import ( "context" "net/http" "testing" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleSystemInfo(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("requires authentication", func(t *testing.T) { c, w := testutil.NewTestWebContext() HandleSystemInfo(deps, c) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin access", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) HandleSystemInfo(deps, c) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("returns system info for admin", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) HandleSystemInfo(deps, c) require.Equal(t, http.StatusOK, w.Code) require.Equal(t, "application/json", w.Header().Get("Content-Type")) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONKeyValue(t, "version", func(t *testing.T, value any) { require.NotEmpty(t, value) }) response.AssertMessageJSONKeyValue(t, "database", func(t *testing.T, value any) { require.NotEmpty(t, value) }) response.AssertMessageJSONKeyValue(t, "os", func(t *testing.T, value any) { require.NotEmpty(t, value) }) }) } ================================================ FILE: internal/http/handlers/api/v1/tags.go ================================================ package api_v1 import ( "encoding/json" "net/http" "strconv" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) // @Summary List tags // @Description List all tags // @Tags Tags // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Param with_bookmark_count query boolean false "Include bookmark count for each tag" // @Param bookmark_id query integer false "Filter tags by bookmark ID" // @Param search query string false "Search tags by name" // @Success 200 {array} model.TagDTO // @Failure 403 {object} nil "Authentication required" // @Failure 500 {object} nil "Internal server error" // @Router /api/v1/tags [get] func HandleListTags(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } // Parse query parameters withBookmarkCount := c.Request().URL.Query().Get("with_bookmark_count") == "true" search := c.Request().URL.Query().Get("search") var bookmarkID int if bookmarkIDStr := c.Request().URL.Query().Get("bookmark_id"); bookmarkIDStr != "" { var err error bookmarkID, err = strconv.Atoi(bookmarkIDStr) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid bookmark ID") return } } // Create options and validate opts := model.ListTagsOptions{ WithBookmarkCount: withBookmarkCount, BookmarkID: bookmarkID, OrderBy: model.DBTagOrderByTagName, Search: search, } if err := opts.IsValid(); err != nil { response.SendError(c, http.StatusBadRequest, err.Error()) return } tags, err := deps.Domains().Tags().ListTags(c.Request().Context(), opts) if err != nil { deps.Logger().WithError(err).Error("failed to get tags") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, tags) } // @Summary Get tag // @Description Get a tag by ID // @Tags Tags // @securityDefinitions.apikey ApiKeyAuth // @Produce json // @Param id path int true "Tag ID" // @Success 200 {object} model.TagDTO // @Failure 403 {object} nil "Authentication required" // @Failure 404 {object} nil "Tag not found" // @Failure 500 {object} nil "Internal server error" // @Router /api/v1/tags/{id} [get] func HandleGetTag(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } idParam := c.Request().PathValue("id") id, err := strconv.Atoi(idParam) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid tag ID") return } tag, err := deps.Domains().Tags().GetTag(c.Request().Context(), id) if err != nil { if err == model.ErrNotFound { response.NotFound(c) return } deps.Logger().WithError(err).Error("failed to get tag") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, tag) } // @Summary Create tag // @Description Create a new tag // @Tags Tags // @securityDefinitions.apikey ApiKeyAuth // @Accept json // @Produce json // @Param tag body model.TagDTO true "Tag data" // @Success 201 {object} model.TagDTO // @Failure 400 {object} nil "Invalid request" // @Failure 403 {object} nil "Authentication required" // @Failure 500 {object} nil "Internal server error" // @Router /api/v1/tags [post] func HandleCreateTag(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } var tag model.TagDTO err := json.NewDecoder(c.Request().Body).Decode(&tag) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid request body") return } if tag.Name == "" { response.SendError(c, http.StatusBadRequest, "Tag name is required") return } createdTag, err := deps.Domains().Tags().CreateTag(c.Request().Context(), tag) if err != nil { deps.Logger().WithError(err).Error("failed to create tag") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusCreated, createdTag) } // @Summary Update tag // @Description Update an existing tag // @Tags Tags // @securityDefinitions.apikey ApiKeyAuth // @Accept json // @Produce json // @Param id path int true "Tag ID" // @Param tag body model.TagDTO true "Tag data" // @Success 200 {object} model.TagDTO // @Failure 400 {object} nil "Invalid request" // @Failure 403 {object} nil "Authentication required" // @Failure 404 {object} nil "Tag not found" // @Failure 500 {object} nil "Internal server error" // @Router /api/v1/tags/{id} [put] func HandleUpdateTag(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInUser(deps, c); err != nil { return } idParam := c.Request().PathValue("id") id, err := strconv.Atoi(idParam) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid tag ID") return } var tag model.TagDTO err = json.NewDecoder(c.Request().Body).Decode(&tag) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid request body") return } if tag.Name == "" { response.SendError(c, http.StatusBadRequest, "Tag name is required") return } // Ensure the ID in the URL matches the ID in the body tag.ID = id updatedTag, err := deps.Domains().Tags().UpdateTag(c.Request().Context(), tag) if err != nil { if err == model.ErrNotFound { response.NotFound(c) return } deps.Logger().WithError(err).Error("failed to update tag") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusOK, updatedTag) } // @Summary Delete tag // @Description Delete a tag // @Tags Tags // @securityDefinitions.apikey ApiKeyAuth // @Param id path int true "Tag ID" // @Success 204 {object} nil // @Failure 403 {object} nil "Authentication required" // @Failure 404 {object} nil "Tag not found" // @Failure 500 {object} nil "Internal server error" // @Router /api/v1/tags/{id} [delete] func HandleDeleteTag(deps model.Dependencies, c model.WebContext) { if err := middleware.RequireLoggedInAdmin(deps, c); err != nil { return } idParam := c.Request().PathValue("id") id, err := strconv.Atoi(idParam) if err != nil { response.SendError(c, http.StatusBadRequest, "Invalid tag ID") return } err = deps.Domains().Tags().DeleteTag(c.Request().Context(), id) if err != nil { if err == model.ErrNotFound { response.NotFound(c) return } deps.Logger().WithError(err).Error("failed to delete tag") response.SendInternalServerError(c) return } response.SendJSON(c, http.StatusNoContent, nil) } ================================================ FILE: internal/http/handlers/api/v1/tags_test.go ================================================ package api_v1 import ( "context" "net/http" "strconv" "strings" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleListTags(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest(deps, HandleListTags, "GET", "/api/v1/tags") require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("returns tags list", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test tag tag := model.Tag{Name: "test-tag"} createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) w := testutil.PerformRequest(deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser()) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageIsNotEmptyList(t) }) t.Run("with_bookmark_count parameter", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test tag tag := model.Tag{Name: "test-tag-with-count"} createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) // Create a bookmark with this tag bookmark := model.BookmarkDTO{ URL: "https://example.com/test", Title: "Test Bookmark", Tags: []model.TagDTO{{Tag: model.Tag{Name: tag.Name}}}, } _, err = deps.Database().SaveBookmarks(ctx, true, bookmark) require.NoError(t, err) w := testutil.PerformRequest( deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithRequestQueryParam("with_bookmark_count", "true"), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageIsNotEmptyList(t) response.ForEach(t, func(item map[string]any) { t.Logf("item: %+v", item) if tag, ok := item["name"].(string); ok { if tag == "test-tag-with-count" { require.NotZero(t, item["bookmark_count"]) } } }) }) t.Run("invalid bookmark_id parameter", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithRequestQueryParam("bookmark_id", "invalid"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("bookmark_id parameter", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test bookmark bookmark := testutil.GetValidBookmark() bookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark) require.NoError(t, err) require.Len(t, bookmarks, 1) bookmarkID := bookmarks[0].ID // Create a test tag tag := model.Tag{Name: "test-tag-for-bookmark"} createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) // Associate the tag with the bookmark err = deps.Database().BulkUpdateBookmarkTags(ctx, []int{bookmarkID}, []int{createdTags[0].ID}) require.NoError(t, err) w := testutil.PerformRequest( deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithRequestQueryParam("bookmark_id", strconv.Itoa(bookmarkID)), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) // Verify the response contains the tag associated with the bookmark found := false response.ForEach(t, func(item map[string]any) { if tag, ok := item["name"].(string); ok { if tag == "test-tag-for-bookmark" { found = true } } }) require.True(t, found, "The tag associated with the bookmark should be in the response") }) t.Run("search parameter", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create test tags with different names tags := []model.Tag{ {Name: "golang"}, {Name: "python"}, {Name: "javascript"}, } createdTags, err := deps.Database().CreateTags(ctx, tags...) require.NoError(t, err) require.Len(t, createdTags, 3) // Test searching for "go" w := testutil.PerformRequest( deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithRequestQueryParam("search", "go"), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageIsNotEmptyList(t) found := false response.ForEach(t, func(item map[string]any) { if tag, ok := item["name"].(string); ok { if tag == "golang" { found = true } } }) require.True(t, found, "Tag 'golang' should be present") // Test searching for "on" w = testutil.PerformRequest( deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithRequestQueryParam("search", "on"), ) require.Equal(t, http.StatusOK, w.Code) response = testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageIsNotEmptyList(t) found = false response.ForEach(t, func(item map[string]any) { if tag, ok := item["name"].(string); ok { if strings.Contains(tag, "python") { found = true } } }) require.True(t, found, "Tag 'python' should be present") }) t.Run("search and bookmark_id parameters together", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test bookmark bookmark := testutil.GetValidBookmark() bookmarks, err := deps.Database().SaveBookmarks(ctx, true, *bookmark) require.NoError(t, err) require.Len(t, bookmarks, 1) bookmarkID := bookmarks[0].ID // Test using both search and bookmark_id parameters w := testutil.PerformRequest( deps, HandleListTags, "GET", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithRequestQueryParam("search", "go"), testutil.WithRequestQueryParam("bookmark_id", strconv.Itoa(bookmarkID)), ) require.Equal(t, http.StatusBadRequest, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertNotOk(t) // Verify the error message response.AssertMessageJSONKeyValue(t, "error", func(t *testing.T, value any) { require.Equal(t, "search and bookmark ID filtering cannot be used together", value) }) }) } func TestHandleGetTag(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleGetTag, "GET", "/api/v1/tags/1", testutil.WithRequestPathValue("id", "1"), ) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("invalid tag id", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleGetTag, "GET", "/api/v1/tags/invalid", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "invalid"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("tag not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleGetTag, "GET", "/api/v1/tags/999", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "999"), ) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("success", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test tag tag := model.Tag{Name: "test-tag"} createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID w := testutil.PerformRequest( deps, HandleGetTag, "GET", "/api/v1/tags/"+strconv.Itoa(tagID), testutil.WithFakeUser(), testutil.WithRequestPathValue("id", strconv.Itoa(tagID)), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) // Verify the tag data response.AssertMessageJSONKeyValue(t, "id", func(t *testing.T, value any) { require.Equal(t, tagID, int(value.(float64))) // TODO: Float64?? }) response.AssertMessageJSONKeyValue(t, "name", func(t *testing.T, value any) { require.Equal(t, "test-tag", value) }) }) } func TestHandleCreateTag(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest(deps, HandleCreateTag, "POST", "/api/v1/tags") require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("invalid json payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleCreateTag, "POST", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithBody("invalid json"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("empty tag name", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleCreateTag, "POST", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithBody(`{"name": ""}`), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("successful creation", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleCreateTag, "POST", "/api/v1/tags", testutil.WithFakeUser(), testutil.WithBody(`{"name": "new-test-tag"}`), ) require.Equal(t, http.StatusCreated, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) // Verify the created tag response.AssertMessageJSONKeyValue(t, "name", func(t *testing.T, value any) { require.Equal(t, "new-test-tag", value) }) response.AssertMessageJSONKeyValue(t, "id", func(t *testing.T, value any) { require.Greater(t, value.(float64), float64(0)) // TODO: Float64?? }) }) } func TestHandleUpdateTag(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateTag, "PUT", "/api/v1/tags/1", testutil.WithRequestPathValue("id", "1"), ) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("invalid tag id", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateTag, "PUT", "/api/v1/tags/invalid", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "invalid"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("invalid json payload", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateTag, "PUT", "/api/v1/tags/1", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "1"), testutil.WithBody("invalid json"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("empty tag name", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateTag, "PUT", "/api/v1/tags/1", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "1"), testutil.WithBody(`{"name": ""}`), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("tag not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleUpdateTag, "PUT", "/api/v1/tags/999", testutil.WithFakeUser(), testutil.WithRequestPathValue("id", "999"), testutil.WithBody(`{"name": "updated-tag"}`), ) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("successful update", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test tag tag := model.Tag{Name: "test-tag-for-update"} createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID w := testutil.PerformRequest( deps, HandleUpdateTag, "PUT", "/api/v1/tags/"+strconv.Itoa(tagID), testutil.WithFakeUser(), testutil.WithRequestPathValue("id", strconv.Itoa(tagID)), testutil.WithBody(`{"name": "updated-test-tag"}`), ) require.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) // Verify the updated tag response.AssertMessageJSONKeyValue(t, "name", func(t *testing.T, value any) { require.Equal(t, "updated-test-tag", value) }) // Ensure database was updated updatedTag, exists, err := deps.Database().GetTag(ctx, tagID) require.NoError(t, err) require.True(t, exists) require.Equal(t, "updated-test-tag", updatedTag.Name) }) } func TestHandleDeleteTag(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("requires authentication", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleDeleteTag, "DELETE", "/api/v1/tags/1", testutil.WithRequestPathValue("id", "1"), ) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("requires admin privileges", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleDeleteTag, "DELETE", "/api/v1/tags/1", testutil.WithFakeUser(), // Regular user, not admin testutil.WithRequestPathValue("id", "1"), ) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("invalid tag id", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleDeleteTag, "DELETE", "/api/v1/tags/invalid", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "invalid"), ) require.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("tag not found", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) w := testutil.PerformRequest( deps, HandleDeleteTag, "DELETE", "/api/v1/tags/999", testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", "999"), ) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("successful deletion", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Create a test tag tag := model.Tag{Name: "test-tag-for-deletion"} createdTags, err := deps.Database().CreateTags(ctx, tag) require.NoError(t, err) require.Len(t, createdTags, 1) tagID := createdTags[0].ID w := testutil.PerformRequest( deps, HandleDeleteTag, "DELETE", "/api/v1/tags/"+strconv.Itoa(tagID), testutil.WithFakeAdmin(), testutil.WithRequestPathValue("id", strconv.Itoa(tagID)), ) require.Equal(t, http.StatusNoContent, w.Code) // Verify the tag was deleted _, exists, err := deps.Database().GetTag(ctx, tagID) require.NoError(t, err) require.False(t, exists) }) } ================================================ FILE: internal/http/handlers/api.go ================================================ package handlers import ( "net/http" "github.com/go-shiori/shiori/internal/dependencies" "github.com/sirupsen/logrus" ) type APIHandler struct { logger *logrus.Logger deps *dependencies.Dependencies } func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: h.handleGet(w, r) case http.MethodPost: h.handlePost(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func (h *APIHandler) handleGet(w http.ResponseWriter, r *http.Request) { // Implementation } func (h *APIHandler) handlePost(w http.ResponseWriter, r *http.Request) { // Implementation } func NewAPIHandler(logger *logrus.Logger, deps *dependencies.Dependencies) *APIHandler { return &APIHandler{ logger: logger, deps: deps, } } ================================================ FILE: internal/http/handlers/bookmark.go ================================================ package handlers import ( "fmt" "html/template" "net/http" "strconv" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" "github.com/gofrs/uuid/v5" ) // getBookmark retrieves and validates a bookmark by ID from the request func getBookmark(deps model.Dependencies, c model.WebContext) (*model.BookmarkDTO, error) { bookmarkID, err := strconv.Atoi(c.Request().PathValue("id")) if err != nil { return nil, response.SendError(c, http.StatusNotFound, "Invalid bookmark ID") } if bookmarkID == 0 { return nil, response.SendError(c, http.StatusNotFound, "Bookmark not found") } // Get bookmark from database bookmark, err := deps.Domains().Bookmarks().GetBookmark(c.Request().Context(), model.DBID(bookmarkID)) if err != nil { return nil, response.SendError(c, http.StatusNotFound, "Bookmark not found") } // Check access permissions if bookmark.Public != 1 && !c.UserIsLogged() { response.RedirectToLogin(c, deps.Config().Http.RootPath, c.Request().URL.String()) return nil, nil } return bookmark, nil } // HandleBookmarkContent serves the bookmark content page func HandleBookmarkContent(deps model.Dependencies, c model.WebContext) { bookmark, err := getBookmark(deps, c) if err != nil || bookmark == nil { return } data := map[string]any{ "RootPath": deps.Config().Http.RootPath, "Version": model.BuildVersion, "Book": bookmark, "HTML": template.HTML(bookmark.HTML), } if err := response.SendTemplate(c, "content.html", data); err != nil { deps.Logger().WithError(err).Error("failed to render content template") } } // HandleBookmarkArchive serves the bookmark archive page func HandleBookmarkArchive(deps model.Dependencies, c model.WebContext) { bookmark, err := getBookmark(deps, c) if err != nil || bookmark == nil { return } if !deps.Domains().Bookmarks().HasArchive(bookmark) { response.NotFound(c) return } data := map[string]any{ "RootPath": deps.Config().Http.RootPath, "Version": model.BuildVersion, "Book": bookmark, } if err := response.SendTemplate(c, "archive.html", data); err != nil { deps.Logger().WithError(err).Error("failed to render archive template") } } // HandleBookmarkArchiveFile serves files from the bookmark archive func HandleBookmarkArchiveFile(deps model.Dependencies, c model.WebContext) { bookmark, err := getBookmark(deps, c) if err != nil || bookmark == nil { return } if !deps.Domains().Bookmarks().HasArchive(bookmark) { response.NotFound(c) return } resourcePath := c.Request().PathValue("path") archive, err := deps.Domains().Archiver().GetBookmarkArchive(bookmark) if err != nil { deps.Logger().WithError(err).Error("error opening archive") response.SendInternalServerError(c) return } defer archive.Close() if !archive.HasResource(resourcePath) { response.NotFound(c) return } content, resourceContentType, err := archive.Read(resourcePath) if err != nil { deps.Logger().WithError(err).Error("error reading archive file") response.SendInternalServerError(c) return } // Generate weak ETAG shioriUUID := uuid.NewV5(uuid.NamespaceURL, model.ShioriURLNamespace) etag := fmt.Sprintf("W/%s", uuid.NewV5(shioriUUID, fmt.Sprintf("%x-%x-%x", bookmark.ID, resourcePath, len(content)))) c.ResponseWriter().Header().Set("Etag", etag) c.ResponseWriter().Header().Set("Cache-Control", "max-age=31536000") c.ResponseWriter().Header().Set("Content-Encoding", "gzip") c.ResponseWriter().Header().Set("Content-Type", resourceContentType) c.ResponseWriter().WriteHeader(http.StatusOK) c.ResponseWriter().Write(content) } // HandleBookmarkThumbnail serves the bookmark thumbnail func HandleBookmarkThumbnail(deps model.Dependencies, c model.WebContext) { bookmark, err := getBookmark(deps, c) if err != nil || bookmark == nil { return } if !deps.Domains().Bookmarks().HasThumbnail(bookmark) { response.NotFound(c) return } etag := "w/" + model.GetThumbnailPath(bookmark) + "-" + bookmark.ModifiedAt // Check if the client's ETag matches if c.Request().Header.Get("If-None-Match") == etag { c.ResponseWriter().WriteHeader(http.StatusNotModified) return } options := &response.SendFileOptions{ Headers: []http.Header{ {"Cache-Control": {"no-cache, must-revalidate"}}, {"Last-Modified": {bookmark.ModifiedAt}}, {"ETag": {etag}}, }, } response.SendFile(c, deps.Domains().Storage(), model.GetThumbnailPath(bookmark), options) } // HandleBookmarkEbook serves the bookmark's ebook file func HandleBookmarkEbook(deps model.Dependencies, c model.WebContext) { bookmark, err := getBookmark(deps, c) if err != nil || bookmark == nil { return } ebookPath := model.GetEbookPath(bookmark) if !deps.Domains().Storage().FileExists(ebookPath) { response.SendError(c, http.StatusNotFound, "Ebook not found") return } c.ResponseWriter().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, bookmark.Title)) response.SendFile(c, deps.Domains().Storage(), ebookPath, nil) } ================================================ FILE: internal/http/handlers/bookmark_test.go ================================================ package handlers import ( "context" "net/http" "strconv" "testing" "github.com/go-shiori/shiori/internal/http/templates" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestGetBookmark(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) err := templates.SetupTemplates(deps.Config()) require.NoError(t, err) // Create a private and a public bookmark to use in tests publicBookmark := testutil.GetValidBookmark() publicBookmark.Public = 1 bookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, []model.BookmarkDTO{ *testutil.GetValidBookmark(), *publicBookmark, }...) require.NoError(t, err) t.Run("bookmark ID is not parsable number", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/notanumber") testutil.SetRequestPathValue(c, "id", "notanumber") bookmark, _ := getBookmark(deps, c) require.Nil(t, bookmark) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("bookmark ID does not exist", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/99999") testutil.SetRequestPathValue(c, "id", "99999") bookmark, _ := getBookmark(deps, c) require.Nil(t, bookmark) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("bookmark ID exists but user is not logged in", func(t *testing.T) { c, _ := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmarks[0].ID)) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmarks[0].ID)) bookmark, _ := getBookmark(deps, c) require.NoError(t, err) // No error because it redirects require.Nil(t, bookmark) }) t.Run("bookmark ID exists and its public and user is not logged in", func(t *testing.T) { c, _ := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmarks[1].ID)) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmarks[1].ID)) bookmark, _ := getBookmark(deps, c) require.NoError(t, err) require.NotNil(t, bookmark) }) t.Run("bookmark ID exists and user is logged in", func(t *testing.T) { c, _ := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmarks[0].ID)+"/content") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmarks[0].ID)) bookmark, _ := getBookmark(deps, c) require.NoError(t, err) require.NotNil(t, bookmark) }) } func TestBookmarkContentHandler(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) err := templates.SetupTemplates(deps.Config()) require.NoError(t, err) bookmark := testutil.GetValidBookmark() bookmark.HTML = "

Bookmark HTML content

" bookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark) require.NoError(t, err) bookmark = &bookmarks[0] t.Run("not logged in", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmark.ID)+"/content") testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmark.ID)) HandleBookmarkContent(deps, c) require.Equal(t, http.StatusFound, w.Code) // Redirects to login }) t.Run("get existing bookmark content", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmark.ID)+"/content") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmark.ID)) HandleBookmarkContent(deps, c) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), bookmark.HTML) }) } func TestBookmarkFileHandlers(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) err := templates.SetupTemplates(deps.Config()) require.NoError(t, err) bookmark := testutil.GetValidBookmark() bookmark.HTML = "

Bookmark HTML content

" bookmark.HasArchive = true bookmark.CreateArchive = true bookmark.CreateEbook = true bookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark) require.NoError(t, err) bookmark, err = deps.Domains().Archiver().DownloadBookmarkArchive(bookmarks[0]) require.NoError(t, err) bookmarks, err = deps.Database().SaveBookmarks(context.TODO(), false, *bookmark) require.NoError(t, err) bookmark = &bookmarks[0] t.Run("get existing bookmark archive", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmark.ID)+"/archive") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmark.ID)) HandleBookmarkArchive(deps, c) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), "iframe") }) t.Run("get existing bookmark thumbnail", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmark.ID)+"/thumb") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmark.ID)) HandleBookmarkThumbnail(deps, c) require.Equal(t, http.StatusOK, w.Code) }) t.Run("bookmark without archive", func(t *testing.T) { bookmark := testutil.GetValidBookmark() bookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark) require.NoError(t, err) c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmarks[0].ID)+"/archive") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmarks[0].ID)) HandleBookmarkArchive(deps, c) require.Equal(t, http.StatusNotFound, w.Code) }) t.Run("get existing bookmark archive file", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmark.ID)+"/archive/file/") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmark.ID)) HandleBookmarkArchiveFile(deps, c) require.Equal(t, http.StatusOK, w.Code) }) t.Run("bookmark with ebook", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmark.ID)+"/ebook") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmark.ID)) HandleBookmarkEbook(deps, c) require.Equal(t, http.StatusOK, w.Code) }) t.Run("bookmark without ebook", func(t *testing.T) { bookmark := testutil.GetValidBookmark() bookmarks, err := deps.Database().SaveBookmarks(context.TODO(), true, *bookmark) require.NoError(t, err) c, w := testutil.NewTestWebContextWithMethod("GET", "/bookmark/"+strconv.Itoa(bookmarks[0].ID)+"/ebook") testutil.SetFakeUser(c) testutil.SetRequestPathValue(c, "id", strconv.Itoa(bookmarks[0].ID)) HandleBookmarkEbook(deps, c) require.Equal(t, http.StatusNotFound, w.Code) }) } ================================================ FILE: internal/http/handlers/frontend.go ================================================ package handlers import ( "embed" "net/http" "path" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" views "github.com/go-shiori/shiori/internal/view" webapp "github.com/go-shiori/shiori/webapp" ) type assetsFS struct { http.FileSystem serveWebUIV2 bool } func (fs assetsFS) Open(name string) (http.File, error) { pathJoin := "assets" if fs.serveWebUIV2 { pathJoin = "dist/assets" } return fs.FileSystem.Open(path.Join(pathJoin, name)) } func newAssetsFS(fs embed.FS, serveWebUIV2 bool) http.FileSystem { return assetsFS{ FileSystem: http.FS(fs), serveWebUIV2: serveWebUIV2, } } // HandleFrontend serves the main frontend page func HandleFrontend(deps model.Dependencies, c model.WebContext) { data := map[string]any{ "RootPath": deps.Config().Http.RootPath, "Version": model.BuildVersion, } if err := response.SendTemplate(c, "index.html", data); err != nil { deps.Logger().WithError(err).Error("failed to render template") } } // HandleAssets serves static assets func HandleAssets(deps model.Dependencies, c model.WebContext) { fs := views.Assets if deps.Config().Http.ServeWebUIV2 { fs = webapp.Assets } http.StripPrefix("/assets/", http.FileServer(newAssetsFS(fs, deps.Config().Http.ServeWebUIV2))).ServeHTTP(c.ResponseWriter(), c.Request()) } ================================================ FILE: internal/http/handlers/frontend_test.go ================================================ package handlers import ( "context" "net/http" "testing" "github.com/go-shiori/shiori/internal/http/templates" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleFrontend(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) err := templates.SetupTemplates(deps.Config()) require.NoError(t, err) t.Run("serves index page", func(t *testing.T) { c, w := testutil.NewTestWebContext() HandleFrontend(deps, c) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Header().Get("Content-Type"), "text/html") }) } func TestHandleAssets(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("serves css file", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/assets/css/style.css") HandleAssets(deps, c) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Header().Get("Content-Type"), "text/css") }) t.Run("returns 404 for missing file", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/assets/not-found.txt") HandleAssets(deps, c) require.Equal(t, http.StatusNotFound, w.Code) }) } ================================================ FILE: internal/http/handlers/legacy.go ================================================ package handlers import ( "net/http" "time" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/webserver" "github.com/gofrs/uuid/v5" "github.com/julienschmidt/httprouter" "github.com/pkg/errors" ) type LegacyHandler struct { legacyHandler *webserver.Handler } func NewLegacyHandler(deps model.Dependencies) *LegacyHandler { handler := webserver.GetLegacyHandler(webserver.Config{ DB: deps.Database(), DataDir: deps.Config().Storage.DataDir, RootPath: deps.Config().Http.RootPath, Log: false, // Already handled by middleware }, deps) handler.PrepareSessionCache() return &LegacyHandler{ legacyHandler: handler, } } // convertParams converts standard URL parameters to httprouter.Params func (h *LegacyHandler) convertParams(r *http.Request) httprouter.Params { routerParams := httprouter.Params{} for key, value := range r.URL.Query() { routerParams = append(routerParams, httprouter.Param{ Key: key, Value: value[0], }) } return routerParams } // HandleLogin handles the legacy login endpoint func (h *LegacyHandler) HandleLogin(account *model.AccountDTO, expTime time.Duration) (string, error) { sessionID, err := uuid.NewV4() if err != nil { return "", errors.Wrap(err, "failed to create session ID") } strSessionID := sessionID.String() return strSessionID, nil } // HandleLogout handles the legacy logout endpoint func (h *LegacyHandler) HandleLogout(deps model.Dependencies, c model.WebContext) { // TODO: Leave cookie handling to API consumer or middleware? // Remove token cookie c.Request().AddCookie(&http.Cookie{ Name: "token", Value: "", }) } // HandleGetTags handles GET /api/tags func (h *LegacyHandler) HandleGetTags(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiGetTags(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleRenameTag handles PUT /api/tags func (h *LegacyHandler) HandleRenameTag(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiRenameTag(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleGetBookmarks handles GET /api/bookmarks func (h *LegacyHandler) HandleGetBookmarks(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiGetBookmarks(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleInsertBookmark handles POST /api/bookmarks func (h *LegacyHandler) HandleInsertBookmark(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiInsertBookmark(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleDeleteBookmark handles DELETE /api/bookmarks func (h *LegacyHandler) HandleDeleteBookmark(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiDeleteBookmark(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleUpdateBookmark handles PUT /api/bookmarks func (h *LegacyHandler) HandleUpdateBookmark(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiUpdateBookmark(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleUpdateBookmarkTags handles PUT /api/bookmarks/tags func (h *LegacyHandler) HandleUpdateBookmarkTags(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiUpdateBookmarkTags(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleInsertViaExtension handles POST /api/bookmarks/ext func (h *LegacyHandler) HandleInsertViaExtension(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiInsertViaExtension(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } // HandleDeleteViaExtension handles DELETE /api/bookmarks/ext func (h *LegacyHandler) HandleDeleteViaExtension(deps model.Dependencies, c model.WebContext) { h.legacyHandler.ApiDeleteViaExtension(c.ResponseWriter(), c.Request(), h.convertParams(c.Request())) } ================================================ FILE: internal/http/handlers/legacy_test.go ================================================ package handlers import ( "context" "fmt" "net/http" "testing" "time" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) // SetFakeAuthorizationHeader sets a fake authorization header for the request in order to have // a valid session. If we don't set this the `validateSession` function will return an error. func SetFakeAuthorizationHeader(t *testing.T, deps model.Dependencies, c model.WebContext) { token, err := deps.Domains().Auth().CreateTokenForAccount(c.GetAccount(), time.Now().Add(time.Hour)) require.NoError(t, err) c.Request().Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) } func TestLegacyHandler(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) handler := NewLegacyHandler(deps) t.Run("HandleLogin", func(t *testing.T) { account := &model.AccountDTO{ ID: 1, Username: "test", Owner: model.Ptr(false), } sessionID, err := handler.HandleLogin(account, time.Hour) require.NoError(t, err) require.NotEmpty(t, sessionID) }) t.Run("HandleGetTags", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) SetFakeAuthorizationHeader(t, deps, c) handler.HandleGetTags(deps, c) require.Equal(t, http.StatusOK, w.Code) }) t.Run("HandleGetBookmarks", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) SetFakeAuthorizationHeader(t, deps, c) handler.HandleGetBookmarks(deps, c) require.Equal(t, http.StatusOK, w.Code) }) t.Run("convertParams", func(t *testing.T) { r, _ := http.NewRequest(http.MethodGet, "/api/bookmarks?page=1&tags=test,dev", http.NoBody) params := handler.convertParams(r) require.Len(t, params, 2) // Create a map to check for parameters regardless of order paramMap := make(map[string]string) for _, param := range params { paramMap[param.Key] = param.Value } // Check that both parameters exist with the correct values require.Contains(t, paramMap, "page") require.Equal(t, "1", paramMap["page"]) require.Contains(t, paramMap, "tags") require.Equal(t, "test,dev", paramMap["tags"]) }) } ================================================ FILE: internal/http/handlers/swagger.go ================================================ package handlers import ( "net/http" "github.com/go-shiori/shiori/internal/model" httpSwagger "github.com/swaggo/http-swagger/v2" _ "github.com/go-shiori/shiori/docs/swagger" // swagger docs ) // HandleSwagger serves the swagger documentation UI func HandleSwagger(deps model.Dependencies, c model.WebContext) { // Redirect /swagger to /swagger/ path := c.Request().URL.Path if path == "/swagger" { http.Redirect(c.ResponseWriter(), c.Request(), "/swagger/index.html", http.StatusPermanentRedirect) return } // Strip /swagger prefix and serve swagger UI handler := httpSwagger.Handler( httpSwagger.URL("/swagger/doc.json"), // URL pointing to API definition ) http.StripPrefix("/swagger", handler).ServeHTTP(c.ResponseWriter(), c.Request()) } ================================================ FILE: internal/http/handlers/swagger_test.go ================================================ package handlers import ( "context" "net/http" "testing" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestHandleSwagger(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("serves swagger doc.json", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/swagger/doc.json") HandleSwagger(deps, c) require.Equal(t, http.StatusOK, w.Code) }) t.Run("redirects /swagger/ to index", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/swagger/") HandleSwagger(deps, c) require.Equal(t, 301, w.Code) require.Equal(t, "/swagger/index.html", w.Header().Get("Location")) }) t.Run("redirects /swagger to index", func(t *testing.T) { c, w := testutil.NewTestWebContextWithMethod("GET", "/swagger") HandleSwagger(deps, c) require.Equal(t, http.StatusPermanentRedirect, w.Code) require.Equal(t, "/swagger/index.html", w.Header().Get("Location")) }) } ================================================ FILE: internal/http/handlers/system.go ================================================ package handlers import ( "net/http" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) // HandleLiveness handles the liveness check endpoint func HandleLiveness(deps model.Dependencies, c model.WebContext) { response.SendJSON(c, http.StatusOK, struct { Version string `json:"version"` Commit string `json:"commit"` Date string `json:"date"` }{ Version: model.BuildVersion, Commit: model.BuildCommit, Date: model.BuildDate, }) } ================================================ FILE: internal/http/handlers/system_test.go ================================================ package handlers import ( "context" "testing" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" ) func TestHandleLiveness(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.Background(), logger) t.Run("returns build info", func(t *testing.T) { c, w := testutil.NewTestWebContext() HandleLiveness(deps, c) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageJSONContains(t, `{"version":"dev","commit":"none","date":"unknown"}`) }) t.Run("handles without auth", func(t *testing.T) { // Test that liveness check works without authentication c, w := testutil.NewTestWebContext() HandleLiveness(deps, c) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) }) } ================================================ FILE: internal/http/http.go ================================================ package http import ( "net/http" "github.com/go-shiori/shiori/internal/http/webcontext" "github.com/go-shiori/shiori/internal/model" ) // ToHTTPHandler converts a model.HttpHandler to http.HandlerFunc with dependencies and middlewares func ToHTTPHandler(deps model.Dependencies, h model.HttpHandler, middlewares ...model.HttpMiddleware) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { c := webcontext.NewWebContext(w, r) // Execute OnRequest middlewares for _, m := range middlewares { if err := m.OnRequest(deps, c); err != nil { // Handle middleware error deps.Logger().WithError(err).Error("middleware error in request") http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } // Execute handler h(deps, c) // Execute OnResponse middlewares in reverse order for i := len(middlewares) - 1; i >= 0; i-- { m := middlewares[i] if err := m.OnResponse(deps, c); err != nil { deps.Logger().WithError(err).Error("middleware error in response") return } } } } ================================================ FILE: internal/http/http_test.go ================================================ package http import ( "context" "errors" "net/http" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) type testMiddleware struct { onRequestCalled bool onResponseCalled bool returnError bool } func (m *testMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { m.onRequestCalled = true if m.returnError { return errors.New("test error") } return nil } func (m *testMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { m.onResponseCalled = true if m.returnError { return errors.New("test error") } return nil } func TestToHTTPHandler(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("executes handler without middleware", func(t *testing.T) { handlerCalled := false handler := func(deps model.Dependencies, c model.WebContext) { handlerCalled = true c.ResponseWriter().WriteHeader(http.StatusOK) } c, w := testutil.NewTestWebContext() httpHandler := ToHTTPHandler(deps, handler) httpHandler.ServeHTTP(w, c.Request()) require.True(t, handlerCalled) require.Equal(t, http.StatusOK, w.Code) }) t.Run("executes middleware chain", func(t *testing.T) { middleware1 := &testMiddleware{} middleware2 := &testMiddleware{} handlerCalled := false handler := func(deps model.Dependencies, c model.WebContext) { handlerCalled = true c.ResponseWriter().WriteHeader(http.StatusOK) } c, w := testutil.NewTestWebContext() httpHandler := ToHTTPHandler(deps, handler, middleware1, middleware2) httpHandler.ServeHTTP(w, c.Request()) require.True(t, handlerCalled) require.True(t, middleware1.onRequestCalled) require.True(t, middleware1.onResponseCalled) require.True(t, middleware2.onRequestCalled) require.True(t, middleware2.onResponseCalled) require.Equal(t, http.StatusOK, w.Code) }) t.Run("stops on middleware request error", func(t *testing.T) { middleware1 := &testMiddleware{returnError: true} middleware2 := &testMiddleware{} handlerCalled := false handler := func(deps model.Dependencies, c model.WebContext) { handlerCalled = true } c, w := testutil.NewTestWebContext() httpHandler := ToHTTPHandler(deps, handler, middleware1, middleware2) httpHandler.ServeHTTP(w, c.Request()) require.False(t, handlerCalled) require.True(t, middleware1.onRequestCalled) require.False(t, middleware1.onResponseCalled) require.False(t, middleware2.onRequestCalled) require.False(t, middleware2.onResponseCalled) require.Equal(t, http.StatusInternalServerError, w.Code) }) } ================================================ FILE: internal/http/middleware/auth.go ================================================ package middleware import ( "fmt" "net/http" "strings" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" ) // AuthMiddleware handles authentication for incoming request by checking the token // from the Authorization header or the token cookie and setting the account in the // request context. type AuthMiddleware struct { deps model.Dependencies } func NewAuthMiddleware(deps model.Dependencies) *AuthMiddleware { return &AuthMiddleware{deps: deps} } func (m *AuthMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { if c.UserIsLogged() { return nil } token := getTokenFromHeader(c.Request()) if token == "" { token = getTokenFromCookie(c.Request()) } if token == "" { return nil } account, err := deps.Domains().Auth().CheckToken(c.Request().Context(), token) if err != nil { // If we fail to check token, remove the token cookie and redirect to login deps.Logger().WithError(err).WithField("request_id", c.GetRequestID()).Error("Failed to check token") http.SetCookie(c.ResponseWriter(), &http.Cookie{ Name: "token", Value: "", MaxAge: -1, }) return nil } c.SetAccount(account) return nil } func (m *AuthMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { return nil } // RequireLoggedInUser ensures a user is authenticated func RequireLoggedInUser(deps model.Dependencies, c model.WebContext) error { if !c.UserIsLogged() { response.SendError(c, http.StatusUnauthorized, "Authentication required") return fmt.Errorf("authentication required") } return nil } // RequireLoggedInAdmin ensures a user is authenticated and is an admin func RequireLoggedInAdmin(deps model.Dependencies, c model.WebContext) error { account := c.GetAccount() if err := RequireLoggedInUser(deps, c); err != nil { return err } if !account.IsOwner() { response.SendError(c, http.StatusForbidden, "Admin access required") return fmt.Errorf("admin access required") } return nil } // getTokenFromHeader returns the token from the Authorization header func getTokenFromHeader(r *http.Request) string { authorization := r.Header.Get(model.AuthorizationHeader) if authorization == "" { return "" } authParts := strings.SplitN(authorization, " ", 2) if len(authParts) != 2 || authParts[0] != model.AuthorizationTokenType { return "" } return authParts[1] } // getTokenFromCookie returns the token from the token cookie func getTokenFromCookie(r *http.Request) string { cookie, err := r.Cookie("token") if err != nil { return "" } return cookie.Value } ================================================ FILE: internal/http/middleware/auth_sso_proxy.go ================================================ package middleware import ( "errors" "net" "github.com/go-shiori/shiori/internal/model" ) // AuthMiddleware handles authentication for incoming request by checking the token // from the Authorization header or the token cookie and setting the account in the // request context. type AuthSSOProxyMiddleware struct { deps model.Dependencies trustedIPs []*net.IPNet } func NewAuthSSOProxyMiddleware(deps model.Dependencies) *AuthSSOProxyMiddleware { plainIPs := deps.Config().Http.SSOProxyAuthTrusted trustedIPs := make([]*net.IPNet, len(plainIPs)) for i, ip := range plainIPs { _, ipNet, err := net.ParseCIDR(ip) if err != nil { deps.Logger().WithError(err).WithField("ip", ip).Error("Failed to parse trusted ip cidr") continue } trustedIPs[i] = ipNet } return &AuthSSOProxyMiddleware{ deps: deps, trustedIPs: trustedIPs, } } func (m *AuthSSOProxyMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { if c.UserIsLogged() { return nil } account, err := m.ssoAccount(deps, c) if err != nil { deps.Logger(). WithError(err). WithField("remote_addr", c.Request().RemoteAddr). WithField("request_id", c.GetRequestID()). Error("getting sso account") return nil } if account != nil { c.SetAccount(account) return nil } return nil } func (m *AuthSSOProxyMiddleware) ssoAccount(deps model.Dependencies, c model.WebContext) (*model.AccountDTO, error) { if !deps.Config().Http.SSOProxyAuth { return nil, nil } remoteAddr := c.Request().RemoteAddr ip, _, err := net.SplitHostPort(remoteAddr) if err != nil { var addrErr *net.AddrError if errors.As(err, &addrErr) && addrErr.Err == "missing port in address" { ip = remoteAddr } else { return nil, err } } requestIP := net.ParseIP(ip) if !m.isTrustedIP(requestIP) { return nil, errors.New("remoteAddr is not a trusted ip") } headerName := deps.Config().Http.SSOProxyAuthHeaderName userName := c.Request().Header.Get(headerName) if userName == "" { return nil, nil } account, err := deps.Domains().Accounts().GetAccountByUsername(c.Request().Context(), userName) if err != nil { return nil, err } return account, nil } func (m *AuthSSOProxyMiddleware) isTrustedIP(ip net.IP) bool { for _, net := range m.trustedIPs { if ok := net.Contains(ip); ok { return true } } return false } func (m *AuthSSOProxyMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { return nil } ================================================ FILE: internal/http/middleware/auth_sso_proxy_test.go ================================================ package middleware import ( "context" "net/http" "net/http/httptest" "testing" "github.com/go-shiori/shiori/internal/http/webcontext" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestAuthMiddlewareWithSSO(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) deps.Config().Http.SSOProxyAuth = true account, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ ID: model.DBID(98), Username: "test_username", Password: "super_secure_password", }) require.NoError(t, err) t.Run("test no authorization method", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := webcontext.NewWebContext(w, r) middleware := NewAuthSSOProxyMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.Nil(t, c.GetAccount()) }) t.Run("test untrusted ip", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "invalid-ip" c := webcontext.NewWebContext(w, r) middleware := NewAuthSSOProxyMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.Nil(t, c.GetAccount()) }) t.Run("test empty header", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.3" c := webcontext.NewWebContext(w, r) middleware := NewAuthSSOProxyMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.Nil(t, c.GetAccount()) }) t.Run("test invalid sso username", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.3" r.Header.Add("Remote-User", "username") c := webcontext.NewWebContext(w, r) middleware := NewAuthSSOProxyMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.Nil(t, c.GetAccount()) }) t.Run("test sso login", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.3" r.Header.Add("Remote-User", account.Username) c := webcontext.NewWebContext(w, r) middleware := NewAuthSSOProxyMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.NotNil(t, c.GetAccount()) }) t.Run("test sso login ip:port", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.3:65342" r.Header.Add("Remote-User", account.Username) c := webcontext.NewWebContext(w, r) middleware := NewAuthSSOProxyMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.NotNil(t, c.GetAccount()) }) } ================================================ FILE: internal/http/middleware/auth_test.go ================================================ package middleware import ( "context" "net/http" "net/http/httptest" "testing" "time" "github.com/go-shiori/shiori/internal/http/webcontext" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestAuthMiddleware(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("test no authorization method", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := webcontext.NewWebContext(w, r) middleware := NewAuthMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.Nil(t, c.GetAccount()) }) t.Run("test authorization header", func(t *testing.T) { account := testutil.GetValidAccount().ToDTO() token, err := deps.Domains().Auth().CreateTokenForAccount(&account, time.Now().Add(time.Minute)) require.NoError(t, err) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.Header.Set(model.AuthorizationHeader, model.AuthorizationTokenType+" "+token) c := webcontext.NewWebContext(w, r) middleware := NewAuthMiddleware(deps) err = middleware.OnRequest(deps, c) require.NoError(t, err) require.NotNil(t, c.GetAccount()) }) t.Run("test authorization cookie", func(t *testing.T) { account := model.AccountDTO{Username: "shiori"} token, err := deps.Domains().Auth().CreateTokenForAccount(&account, time.Now().Add(time.Minute)) require.NoError(t, err) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{ Name: "token", Value: token, MaxAge: int(time.Now().Add(time.Minute).Unix()), }) c := webcontext.NewWebContext(w, r) middleware := NewAuthMiddleware(deps) err = middleware.OnRequest(deps, c) require.NoError(t, err) require.NotNil(t, c.GetAccount()) }) t.Run("test invalid token cookie is removed", func(t *testing.T) { // Create an invalid token invalidToken := "invalid-token" w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{ Name: "token", Value: invalidToken, MaxAge: int(time.Now().Add(time.Minute).Unix()), }) c := webcontext.NewWebContext(w, r) middleware := NewAuthMiddleware(deps) err := middleware.OnRequest(deps, c) require.NoError(t, err) require.Nil(t, c.GetAccount()) // Check that the token cookie was removed in the response responseCookies := w.Result().Cookies() var tokenCookie *http.Cookie for _, cookie := range responseCookies { if cookie.Name == "token" { tokenCookie = cookie break } } require.NotNil(t, tokenCookie, "Token cookie should exist in response") require.Empty(t, tokenCookie.Value, "Token cookie value should be empty") }) } func TestRequireLoggedInUser(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("returns error when user not logged in", func(t *testing.T) { c, w := testutil.NewTestWebContext() err := RequireLoggedInUser(deps, c) require.Error(t, err) require.Equal(t, "authentication required", err.Error()) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("succeeds when user is logged in", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) err := RequireLoggedInUser(deps, c) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) }) t.Run("succeeds when admin is logged in", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) err := RequireLoggedInUser(deps, c) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) }) } func TestRequireLoggedInAdmin(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("returns error when user not logged in", func(t *testing.T) { c, w := testutil.NewTestWebContext() err := RequireLoggedInAdmin(deps, c) require.Error(t, err) require.Equal(t, http.StatusUnauthorized, w.Code) }) t.Run("returns error when non-admin user is logged in", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeUser(c) err := RequireLoggedInAdmin(deps, c) require.Error(t, err) require.Equal(t, http.StatusForbidden, w.Code) }) t.Run("succeeds when admin is logged in", func(t *testing.T) { c, w := testutil.NewTestWebContext() testutil.SetFakeAdmin(c) err := RequireLoggedInAdmin(deps, c) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) }) } ================================================ FILE: internal/http/middleware/cors.go ================================================ package middleware import ( "strings" "github.com/go-shiori/shiori/internal/model" ) type CORSMiddleware struct { allowedOrigins []string } func (m *CORSMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { c.ResponseWriter().Header().Set("Access-Control-Allow-Origin", strings.Join(m.allowedOrigins, ", ")) c.ResponseWriter().Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.ResponseWriter().Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Shiori-Response-Format") return nil } func (m *CORSMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { c.ResponseWriter().Header().Set("Access-Control-Allow-Origin", strings.Join(m.allowedOrigins, ", ")) c.ResponseWriter().Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.ResponseWriter().Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Shiori-Response-Format") return nil } func NewCORSMiddleware(allowedOrigins []string) *CORSMiddleware { return &CORSMiddleware{allowedOrigins: allowedOrigins} } ================================================ FILE: internal/http/middleware/cors_test.go ================================================ package middleware import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/go-shiori/shiori/internal/http/webcontext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCORSMiddleware(t *testing.T) { t.Run("test single origin", func(t *testing.T) { allowedOrigins := []string{"http://localhost:8080"} middleware := NewCORSMiddleware(allowedOrigins) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := webcontext.NewWebContext(w, r) err := middleware.OnRequest(nil, c) require.NoError(t, err) headers := w.Header() assert.Equal(t, "http://localhost:8080", headers.Get("Access-Control-Allow-Origin")) assert.Equal(t, "GET, POST, PUT, DELETE, OPTIONS", headers.Get("Access-Control-Allow-Methods")) assert.Equal(t, "Content-Type, Authorization, X-Shiori-Response-Format", headers.Get("Access-Control-Allow-Headers")) }) t.Run("test multiple origins", func(t *testing.T) { allowedOrigins := []string{"http://localhost:8080", "http://example.com"} middleware := NewCORSMiddleware(allowedOrigins) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := webcontext.NewWebContext(w, r) err := middleware.OnRequest(nil, c) require.NoError(t, err) headers := w.Header() assert.Equal(t, strings.Join(allowedOrigins, ", "), headers.Get("Access-Control-Allow-Origin")) }) t.Run("test empty origins", func(t *testing.T) { middleware := NewCORSMiddleware([]string{}) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := webcontext.NewWebContext(w, r) err := middleware.OnRequest(nil, c) require.NoError(t, err) headers := w.Header() assert.Equal(t, "", headers.Get("Access-Control-Allow-Origin")) }) t.Run("test OnResponse headers", func(t *testing.T) { allowedOrigins := []string{"http://localhost:8080"} middleware := NewCORSMiddleware(allowedOrigins) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := webcontext.NewWebContext(w, r) err := middleware.OnResponse(nil, c) require.NoError(t, err) headers := w.Header() assert.Equal(t, "http://localhost:8080", headers.Get("Access-Control-Allow-Origin")) assert.Equal(t, "GET, POST, PUT, DELETE, OPTIONS", headers.Get("Access-Control-Allow-Methods")) assert.Equal(t, "Content-Type, Authorization, X-Shiori-Response-Format", headers.Get("Access-Control-Allow-Headers")) }) } ================================================ FILE: internal/http/middleware/logging.go ================================================ package middleware import ( "time" "github.com/go-shiori/shiori/internal/model" "github.com/sirupsen/logrus" ) var _ model.HttpMiddleware = &LoggingMiddleware{} // LoggingMiddleware is a middleware that logs the request and response type LoggingMiddleware struct { startTime time.Time } func (m *LoggingMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { m.startTime = time.Now() return nil } func (m *LoggingMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { duration := time.Since(m.startTime) deps.Logger().WithFields(logrus.Fields{ "path": c.Request().URL.Path, "duration": duration, "request_id": c.GetRequestID(), }).Info("request completed") return nil } func NewLoggingMiddleware() *LoggingMiddleware { return &LoggingMiddleware{} } ================================================ FILE: internal/http/middleware/message_response.go ================================================ package middleware import ( "bytes" "encoding/json" "net/http" "strings" "github.com/go-shiori/shiori/internal/model" ) type responseMiddlewareBody struct { Ok bool `json:"ok"` Message any `json:"message"` } type MessageResponseMiddleware struct { deps model.Dependencies } func (m *MessageResponseMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { if c.Request().Header.Get("X-Shiori-Response-Format") == "new" { return nil } // Create a response recorder and wrap the original ResponseWriter recorder := newResponseRecorder(c.ResponseWriter()) c.SetResponseWriter(recorder) return nil } func (m *MessageResponseMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { if c.Request().Header.Get("X-Shiori-Response-Format") == "new" { return nil } writer := c.ResponseWriter() // Get the response recorder recorder, ok := writer.(*responseRecorder) if !ok { return nil } // Copy all headers to the original response writer for k, v := range recorder.header { if k != "Content-Length" { recorder.ResponseWriter.Header().Set(k, strings.Join(v, "")) } } // Write the status code recorder.ResponseWriter.WriteHeader(recorder.statusCode) // If it's not a JSON response, write the original response and return if ct := recorder.header.Get("Content-Type"); ct != "application/json" { _, err := recorder.ResponseWriter.Write(recorder.body.Bytes()) return err } // For JSON responses, wrap them in our format wrappedResponse := responseMiddlewareBody{ Ok: recorder.statusCode < 400, Message: nil, } // If there's a response body and status code allows body, parse it if recorder.body.Len() > 0 && recorder.statusCode != http.StatusNoContent { var originalBody any if err := json.NewDecoder(&recorder.body).Decode(&originalBody); err != nil { return err } wrappedResponse.Message = originalBody // Write the status code and wrapped response if err := json.NewEncoder(recorder.ResponseWriter).Encode(wrappedResponse); err != nil { return err } } return nil } func NewMessageResponseMiddleware(deps model.Dependencies) *MessageResponseMiddleware { return &MessageResponseMiddleware{deps: deps} } // responseRecorder is a custom ResponseWriter that captures the response type responseRecorder struct { http.ResponseWriter statusCode int body bytes.Buffer header http.Header } func newResponseRecorder(original http.ResponseWriter) *responseRecorder { return &responseRecorder{ ResponseWriter: original, statusCode: http.StatusOK, header: make(http.Header), body: bytes.Buffer{}, } } func (r *responseRecorder) Header() http.Header { return r.header } func (r *responseRecorder) WriteHeader(statusCode int) { r.statusCode = statusCode } func (r *responseRecorder) Write(b []byte) (int, error) { // Only write to the buffer, we'll write to the actual ResponseWriter in OnResponse return r.body.Write(b) } ================================================ FILE: internal/http/middleware/message_response_test.go ================================================ package middleware import ( "context" "encoding/json" "net/http" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMessageResponseMiddleware(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) t.Run("wraps JSON response with success status", func(t *testing.T) { // Create test handler that returns JSON handler := func(deps model.Dependencies, c model.WebContext) { response := map[string]string{"data": "test"} c.ResponseWriter().Header().Set("Content-Type", "application/json") c.ResponseWriter().WriteHeader(http.StatusOK) json.NewEncoder(c.ResponseWriter()).Encode(response) } // Create test context c, w := testutil.NewTestWebContext() // Create and apply middleware middleware := NewMessageResponseMiddleware(deps) require.NoError(t, middleware.OnRequest(deps, c)) // Execute handler handler(deps, c) // Apply response middleware require.NoError(t, middleware.OnResponse(deps, c)) // Verify response var response responseMiddlewareBody err := json.NewDecoder(w.Body).Decode(&response) require.NoError(t, err) assert.True(t, response.Ok) assert.Equal(t, map[string]any{"data": "test"}, response.Message) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) }) t.Run("wraps JSON response with error status", func(t *testing.T) { // Create test handler that returns JSON error handler := func(deps model.Dependencies, c model.WebContext) { response := map[string]string{"error": "test error"} c.ResponseWriter().Header().Set("Content-Type", "application/json") c.ResponseWriter().WriteHeader(http.StatusBadRequest) json.NewEncoder(c.ResponseWriter()).Encode(response) } // Create test context c, w := testutil.NewTestWebContext() // Create and apply middleware middleware := NewMessageResponseMiddleware(deps) require.NoError(t, middleware.OnRequest(deps, c)) // Execute handler handler(deps, c) // Apply response middleware require.NoError(t, middleware.OnResponse(deps, c)) // Verify response var response responseMiddlewareBody err := json.NewDecoder(w.Body).Decode(&response) require.NoError(t, err) assert.False(t, response.Ok) assert.Equal(t, map[string]any{"error": "test error"}, response.Message) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) }) t.Run("does not modify non-JSON response", func(t *testing.T) { // Create test handler that returns plain text handler := func(deps model.Dependencies, c model.WebContext) { c.ResponseWriter().Header().Set("Content-Type", "text/plain") c.ResponseWriter().WriteHeader(http.StatusOK) c.ResponseWriter().Write([]byte("test message")) } // Create test context c, w := testutil.NewTestWebContext() // Create and apply middleware middleware := NewMessageResponseMiddleware(deps) require.NoError(t, middleware.OnRequest(deps, c)) // Execute handler handler(deps, c) // Apply response middleware require.NoError(t, middleware.OnResponse(deps, c)) // Verify response is unchanged assert.Equal(t, "test message", w.Body.String()) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "text/plain", w.Header().Get("Content-Type")) }) t.Run("handles empty JSON response", func(t *testing.T) { // Create test handler that returns empty JSON handler := func(deps model.Dependencies, c model.WebContext) { c.ResponseWriter().Header().Set("Content-Type", "application/json") c.ResponseWriter().WriteHeader(http.StatusOK) c.ResponseWriter().Write([]byte("{}")) } // Create test context c, w := testutil.NewTestWebContext() // Create and apply middleware middleware := NewMessageResponseMiddleware(deps) require.NoError(t, middleware.OnRequest(deps, c)) // Execute handler handler(deps, c) // Apply response middleware require.NoError(t, middleware.OnResponse(deps, c)) // Verify response var response responseMiddlewareBody err := json.NewDecoder(w.Body).Decode(&response) require.NoError(t, err) assert.True(t, response.Ok) assert.Equal(t, map[string]any{}, response.Message) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) }) t.Run("preserves custom headers", func(t *testing.T) { // Create test handler that sets custom headers handler := func(deps model.Dependencies, c model.WebContext) { c.ResponseWriter().Header().Set("Content-Type", "application/json") c.ResponseWriter().Header().Set("X-Custom-Header", "test-value") c.ResponseWriter().WriteHeader(http.StatusOK) json.NewEncoder(c.ResponseWriter()).Encode(map[string]string{"data": "test"}) } // Create test context c, w := testutil.NewTestWebContext() // Create and apply middleware middleware := NewMessageResponseMiddleware(deps) require.NoError(t, middleware.OnRequest(deps, c)) // Execute handler handler(deps, c) // Apply response middleware require.NoError(t, middleware.OnResponse(deps, c)) // Verify headers are preserved assert.Equal(t, "test-value", w.Header().Get("X-Custom-Header")) }) } ================================================ FILE: internal/http/middleware/request_id.go ================================================ package middleware import ( "github.com/go-shiori/shiori/internal/model" "github.com/gofrs/uuid/v5" ) const ( // RequestIDHeader is the header key for the request ID RequestIDHeader = "X-Request-ID" ) // RequestIDMiddleware adds a unique request ID to each request type RequestIDMiddleware struct { deps model.Dependencies } // NewRequestIDMiddleware creates a new RequestIDMiddleware func NewRequestIDMiddleware(deps model.Dependencies) *RequestIDMiddleware { return &RequestIDMiddleware{deps: deps} } // OnRequest adds a request ID to the request context and response headers func (m *RequestIDMiddleware) OnRequest(deps model.Dependencies, c model.WebContext) error { // Generate request ID requestID, err := uuid.NewV4() if err != nil { deps.Logger().WithError(err).Error("Failed to generate request ID") return err } // Add request ID to response headers c.ResponseWriter().Header().Set(RequestIDHeader, requestID.String()) // Add request ID to context c.SetRequestID(requestID.String()) return nil } // OnResponse is a no-op for this middleware func (m *RequestIDMiddleware) OnResponse(deps model.Dependencies, c model.WebContext) error { return nil } ================================================ FILE: internal/http/middleware/request_id_test.go ================================================ package middleware import ( "context" "testing" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestRequestIDMiddleware(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("adds request ID to context and headers", func(t *testing.T) { _, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) middleware := NewRequestIDMiddleware(deps) c, w := testutil.NewTestWebContext() err := middleware.OnRequest(deps, c) require.NoError(t, err) // Check that request ID was added to context requestID := c.GetRequestID() require.NotEmpty(t, requestID) // Check that request ID was added to headers headerRequestID := w.Header().Get(RequestIDHeader) require.Equal(t, requestID, headerRequestID) }) } ================================================ FILE: internal/http/response/file.go ================================================ package response import ( "fmt" "io" "mime" "net/http" "path/filepath" "github.com/go-shiori/shiori/internal/model" ) // SendFileOptions contains options for sending files type SendFileOptions struct { Headers []http.Header } // SendFile sends a file from storage to the response writer func SendFile(c model.WebContext, storage model.StorageDomain, path string, options *SendFileOptions) error { if !storage.FileExists(path) { return SendError(c, http.StatusNotFound, "File not found") } file, err := storage.FS().Open(path) if err != nil { return SendInternalServerError(c) } defer file.Close() // First try to get content type from extension contentType := mime.TypeByExtension(filepath.Ext(path)) if contentType == "" { // If no extension or unknown, try to detect from content // Only the first 512 bytes are used to sniff the content type buffer := make([]byte, 512) n, err := file.Read(buffer) if err != nil && err != io.EOF { return fmt.Errorf("failed to read file header: %w", err) } contentType = http.DetectContentType(buffer[:n]) // Seek back to start since we read some bytes if _, err := file.Seek(0, 0); err != nil { return fmt.Errorf("failed to seek file: %w", err) } } // Set content type c.ResponseWriter().Header().Set("Content-Type", contentType) // Set additional headers if provided if options != nil { for _, header := range options.Headers { for key, values := range header { for _, value := range values { c.ResponseWriter().Header().Add(key, value) } } } } // Copy file to response writer _, err = io.Copy(c.ResponseWriter(), file) if err != nil { return fmt.Errorf("failed to send file: %w", err) } return nil } ================================================ FILE: internal/http/response/file_test.go ================================================ package response_test import ( "bytes" "context" "io" "net/http" "testing" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) func newMockStorage(deps model.Dependencies, fs afero.Fs) model.StorageDomain { return domains.NewStorageDomain(deps, fs) } func TestSendFile(t *testing.T) { logger := logrus.New() _, deps := testutil.GetTestConfigurationAndDependencies(t, context.TODO(), logger) storage := newMockStorage(deps, afero.NewMemMapFs()) t.Run("sends file with correct content type from extension", func(t *testing.T) { // Create test file content := []byte("body { color: red; }") err := storage.WriteData("test.css", content) require.NoError(t, err) c, w := testutil.NewTestWebContext() err = response.SendFile(c, storage, "test.css", nil) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) require.Equal(t, "text/css; charset=utf-8", w.Header().Get("Content-Type")) require.Equal(t, content, w.Body.Bytes()) }) t.Run("sends file with detected content type", func(t *testing.T) { // Create test file without extension content := []byte("Hello") err := storage.WriteData("test", content) require.NoError(t, err) c, w := testutil.NewTestWebContext() err = response.SendFile(c, storage, "test", nil) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) require.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) require.Equal(t, content, w.Body.Bytes()) }) t.Run("handles non-existent file", func(t *testing.T) { c, w := testutil.NewTestWebContext() _ = response.SendFile(c, storage, "nonexistent.txt", nil) require.Equal(t, http.StatusNotFound, w.Code) require.Contains(t, w.Body.String(), "File not found") }) t.Run("sets custom headers", func(t *testing.T) { // Create test file content := []byte("test content") err := storage.WriteData("test.txt", content) require.NoError(t, err) options := &response.SendFileOptions{ Headers: []http.Header{ {"Cache-Control": {"no-cache"}}, {"X-Custom": {"value1", "value2"}}, }, } c, w := testutil.NewTestWebContext() err = response.SendFile(c, storage, "test.txt", options) require.NoError(t, err) require.Equal(t, "no-cache", w.Header().Get("Cache-Control")) require.Equal(t, []string{"value1", "value2"}, w.Header().Values("X-Custom")) }) t.Run("handles large files", func(t *testing.T) { // Create large test file (>512 bytes to test content type detection) binaryData := bytes.Repeat([]byte{0xFF, 0x00}, 1024*1024) err := storage.WriteData("large.bin", binaryData) require.NoError(t, err) c, w := testutil.NewTestWebContext() err = response.SendFile(c, storage, "large.bin", nil) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) require.Equal(t, binaryData, w.Body.Bytes()) }) t.Run("handles empty files", func(t *testing.T) { err := storage.WriteData("empty.txt", []byte{}) require.NoError(t, err) c, w := testutil.NewTestWebContext() err = response.SendFile(c, storage, "empty.txt", nil) require.NoError(t, err) require.Equal(t, http.StatusOK, w.Code) require.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) require.Empty(t, w.Body.Bytes()) }) t.Run("handles read errors", func(t *testing.T) { // Create mock file that returns error on read errorFs := &errorFs{ Fs: afero.NewMemMapFs(), err: io.ErrClosedPipe, } storage := newMockStorage(deps, errorFs) err := storage.WriteData("test.txt", []byte("test")) require.NoError(t, err) c, w := testutil.NewTestWebContext() _ = response.SendFile(c, storage, "test.txt", nil) require.Equal(t, http.StatusInternalServerError, w.Code) }) } // errorFs is a mock filesystem that returns errors type errorFs struct { afero.Fs err error } func (e *errorFs) Open(name string) (afero.File, error) { return nil, e.err } ================================================ FILE: internal/http/response/response.go ================================================ package response import ( "encoding/json" "net/http" "github.com/go-shiori/shiori/internal/model" ) type Response struct { // Data the payload of the response, depending on the endpoint/response status Data any `json:"message"` // statusCode used for the http response status code statusCode int } // GetData returns the data of the response func (r *Response) GetData() any { return r.Data } // IsError returns true if the response is an error func (r *Response) IsError() bool { return r.statusCode >= http.StatusBadRequest } // Send sends the response to the client func (r *Response) Send(c model.WebContext, contentType string) error { c.ResponseWriter().Header().Set("Content-Type", contentType) c.ResponseWriter().WriteHeader(r.statusCode) _, err := c.ResponseWriter().Write([]byte(r.GetData().(string))) return err } // SendJSON sends the response to the client func (r *Response) SendJSON(c model.WebContext) error { c.ResponseWriter().Header().Set("Content-Type", "application/json") c.ResponseWriter().WriteHeader(r.statusCode) return json.NewEncoder(c.ResponseWriter()).Encode(r.GetData()) } // NewResponse creates a new response func NewResponse(message any, statusCode int) *Response { return &Response{ Data: message, statusCode: statusCode, } } ================================================ FILE: internal/http/response/response_test.go ================================================ package response import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-shiori/shiori/internal/http/webcontext" "github.com/stretchr/testify/assert" ) func TestNewResponse(t *testing.T) { tests := []struct { name string ok bool message any errParams map[string]string statusCode int }{ { name: "successful response", ok: true, message: "success", errParams: nil, statusCode: http.StatusOK, }, { name: "error response", ok: false, message: "error occurred", errParams: map[string]string{"field": "invalid"}, statusCode: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := NewResponse(tt.message, tt.statusCode) assert.Equal(t, tt.message, resp.GetData()) assert.Equal(t, tt.statusCode, resp.statusCode) }) } } func TestResponse_IsError(t *testing.T) { tests := []struct { name string response *Response want bool }{ { name: "successful response", response: NewResponse("success", http.StatusOK), want: false, }, { name: "error response", response: NewResponse("error", http.StatusBadRequest), want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, tt.response.IsError()) }) } } func TestResponse_GetMessage(t *testing.T) { tests := []struct { name string response *Response want any }{ { name: "string message", response: NewResponse("test message", http.StatusOK), want: "test message", }, { name: "struct message", response: NewResponse(struct{ Data string }{Data: "test"}, http.StatusOK), want: struct{ Data string }{Data: "test"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, tt.response.GetData()) }) } } func TestResponse_Send(t *testing.T) { tests := []struct { name string response *Response expectedStatus int expectedBody any }{ { name: "plain response", response: NewResponse("success", http.StatusOK), expectedStatus: http.StatusOK, expectedBody: "success", }, { name: "json response", response: NewResponse(map[string]any{ "message": "success", }, http.StatusOK), expectedStatus: http.StatusOK, expectedBody: map[string]any{ "message": "success", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) ctx := webcontext.NewWebContext(w, r) err := tt.response.SendJSON(ctx) assert.NoError(t, err) assert.Equal(t, tt.expectedStatus, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) var responseBody any err = json.NewDecoder(w.Body).Decode(&responseBody) assert.NoError(t, err) assert.Equal(t, tt.expectedBody, responseBody) }) } } ================================================ FILE: internal/http/response/shortcuts.go ================================================ package response import ( "net/http" "net/url" "github.com/go-shiori/shiori/internal/http/templates" "github.com/go-shiori/shiori/internal/model" ) const internalServerErrorMessage = "Internal server error, please contact an administrator" // New provides a shortcut to a successful response object func New(statusCode int, data any) *Response { return NewResponse(data, statusCode) } // Send provides a shortcut to send a (potentially) successful response func Send(c model.WebContext, statusCode int, message any, contentType string) error { return NewResponse(message, statusCode).Send(c, contentType) } // SendError provides a shortcut to send an unsuccessful response func SendError(c model.WebContext, statusCode int, message any) error { resp := NewResponse(struct { Error string `json:"error"` }{Error: message.(string)}, statusCode) return resp.SendJSON(c) } // SendErrorWithParams the same as above but for errors that require error parameters func SendErrorWithParams(c model.WebContext, statusCode int, data any, errorParams map[string]string) error { return NewResponse(data, statusCode).SendJSON(c) } // SendInternalServerError directly sends an internal server error response func SendInternalServerError(c model.WebContext) error { return SendError(c, http.StatusInternalServerError, internalServerErrorMessage) } // RedirectToLogin redirects to the login page with an optional destination func RedirectToLogin(c model.WebContext, webroot, dst string) { redirectURL := url.URL{ Path: webroot, RawQuery: url.Values{ "dst": []string{dst}, }.Encode(), } http.Redirect(c.ResponseWriter(), c.Request(), redirectURL.String(), http.StatusFound) } // NotFound sends a not found response func NotFound(c model.WebContext) { http.NotFound(c.ResponseWriter(), c.Request()) } // SendJSON is a helper function to send JSON responses func SendJSON(c model.WebContext, statusCode int, data any) error { response := NewResponse(data, statusCode) return response.SendJSON(c) } // SendTemplate renders and sends an HTML template func SendTemplate(c model.WebContext, name string, data any) error { c.ResponseWriter().Header().Set("Content-Type", "text/html; charset=utf-8") if err := templates.RenderTemplate(c.ResponseWriter(), name, data); err != nil { return SendInternalServerError(c) } return nil } ================================================ FILE: internal/http/response/shortcuts_test.go ================================================ package response_test import ( "encoding/json" "net/http" "testing" "github.com/go-shiori/shiori/internal/http/response" "github.com/go-shiori/shiori/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNew(t *testing.T) { t.Run("creates successful response", func(t *testing.T) { resp := response.New(http.StatusOK, "test data") assert.False(t, resp.IsError()) assert.Equal(t, "test data", resp.GetData()) }) t.Run("creates error response", func(t *testing.T) { resp := response.New(http.StatusBadRequest, "error data") assert.True(t, resp.IsError()) assert.Equal(t, "error data", resp.GetData()) }) } func TestSend(t *testing.T) { t.Run("sends successful response", func(t *testing.T) { c, w := testutil.NewTestWebContext() err := response.Send(c, http.StatusOK, "success message", "text/plain") require.NoError(t, err) assert.Equal(t, http.StatusOK, w.Code) response := testutil.NewTestResponseFromRecorder(w) response.AssertOk(t) response.AssertMessageIsBytes(t, []byte("success message")) }) t.Run("sends error response for status >= 400", func(t *testing.T) { message := "error message" c, w := testutil.NewTestWebContext() err := response.Send(c, http.StatusBadRequest, message, "text/plain") require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, w.Code) response := response.NewResponse(message, http.StatusBadRequest) assert.True(t, response.IsError()) assert.Equal(t, message, response.GetData()) }) } func TestSendError(t *testing.T) { t.Run("sends error response without params", func(t *testing.T) { c, w := testutil.NewTestWebContext() err := response.SendError(c, http.StatusBadRequest, "error message") require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, w.Code) responseBody := struct { Error string `json:"error"` }{Error: "error message"} response := response.NewResponse(responseBody, http.StatusBadRequest) assert.True(t, response.IsError()) assert.Equal(t, responseBody, response.GetData()) }) t.Run("sends error response with params", func(t *testing.T) { c, w := testutil.NewTestWebContext() err := response.SendError(c, http.StatusBadRequest, "error message") require.NoError(t, err) assert.Equal(t, http.StatusBadRequest, w.Code) responseBody := struct { Error string `json:"error"` }{Error: "error message"} response := response.NewResponse(responseBody, http.StatusBadRequest) assert.True(t, response.IsError()) assert.Equal(t, responseBody, response.GetData()) }) } func TestSendInternalServerError(t *testing.T) { c, w := testutil.NewTestWebContext() err := response.SendInternalServerError(c) require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, w.Code) responseBody := struct { Error string `json:"error"` }{Error: "Internal server error, please contact an administrator"} response := response.NewResponse(responseBody, http.StatusInternalServerError) assert.True(t, response.IsError()) assert.Equal(t, responseBody, response.GetData()) } func TestRedirectToLogin(t *testing.T) { t.Run("redirects to login without destination", func(t *testing.T) { c, w := testutil.NewTestWebContext() response.RedirectToLogin(c, "/", "") assert.Equal(t, http.StatusFound, w.Code) assert.Equal(t, "/?dst=", w.Header().Get("Location")) }) t.Run("redirects to login with destination", func(t *testing.T) { c, w := testutil.NewTestWebContext() response.RedirectToLogin(c, "/", "/dashboard") assert.Equal(t, http.StatusFound, w.Code) assert.Equal(t, "/?dst=%2Fdashboard", w.Header().Get("Location")) }) } func TestNotFound(t *testing.T) { c, w := testutil.NewTestWebContext() response.NotFound(c) assert.Equal(t, http.StatusNotFound, w.Code) assert.Contains(t, w.Body.String(), "404 page not found") } func TestSendJSON(t *testing.T) { t.Run("sends JSON response", func(t *testing.T) { c, w := testutil.NewTestWebContext() data := map[string]string{"key": "value"} err := response.SendJSON(c, http.StatusOK, data) require.NoError(t, err) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "application/json", w.Header().Get("Content-Type")) var result map[string]string err = json.Unmarshal(w.Body.Bytes(), &result) require.NoError(t, err) assert.Equal(t, data, result) }) t.Run("handles encoding error", func(t *testing.T) { c, _ := testutil.NewTestWebContext() // Create a value that can't be marshaled to JSON data := map[string]any{"fn": func() {}} err := response.SendJSON(c, http.StatusOK, data) assert.Error(t, err) }) } ================================================ FILE: internal/http/server.go ================================================ package http import ( "context" "fmt" "net/http" "os" "os/signal" "syscall" "github.com/go-shiori/shiori/internal/config" "github.com/go-shiori/shiori/internal/dependencies" "github.com/go-shiori/shiori/internal/http/handlers" api_v1 "github.com/go-shiori/shiori/internal/http/handlers/api/v1" "github.com/go-shiori/shiori/internal/http/middleware" "github.com/go-shiori/shiori/internal/http/templates" "github.com/go-shiori/shiori/internal/model" "github.com/sirupsen/logrus" ) type HttpServer struct { mux *http.ServeMux server *http.Server logger *logrus.Logger } func (s *HttpServer) Setup(cfg *config.Config, deps *dependencies.Dependencies) (*HttpServer, error) { s.mux = http.NewServeMux() if err := templates.SetupTemplates(cfg); err != nil { return nil, fmt.Errorf("failed to setup templates: %w", err) } globalMiddleware := []model.HttpMiddleware{} if cfg.Http.SSOProxyAuth { globalMiddleware = append(globalMiddleware, middleware.NewAuthSSOProxyMiddleware(deps)) } // Add message response middleware if legacy message response is enabled globalMiddleware = append(globalMiddleware, []model.HttpMiddleware{ middleware.NewMessageResponseMiddleware(deps), middleware.NewAuthMiddleware(deps), middleware.NewRequestIDMiddleware(deps), middleware.NewCORSMiddleware([]string{"*"}), }...) if cfg.Http.AccessLog { globalMiddleware = append(globalMiddleware, middleware.NewLoggingMiddleware()) } // System routes with logging middleware s.mux.HandleFunc("GET /system/liveness", ToHTTPHandler(deps, handlers.HandleLiveness, globalMiddleware..., )) // Bookmark routes s.mux.HandleFunc("GET /bookmark/{id}/content", ToHTTPHandler(deps, handlers.HandleBookmarkContent, globalMiddleware...)) s.mux.HandleFunc("GET /bookmark/{id}/archive", ToHTTPHandler(deps, handlers.HandleBookmarkArchive, globalMiddleware...)) s.mux.HandleFunc("GET /bookmark/{id}/archive/file/{path...}", ToHTTPHandler(deps, handlers.HandleBookmarkArchiveFile, globalMiddleware...)) s.mux.HandleFunc("GET /bookmark/{id}/thumb", ToHTTPHandler(deps, handlers.HandleBookmarkThumbnail, globalMiddleware...)) s.mux.HandleFunc("GET /bookmark/{id}/ebook", ToHTTPHandler(deps, handlers.HandleBookmarkEbook, globalMiddleware...)) // Add this inside Setup() where other routes are registered if cfg.Http.ServeSwagger { s.mux.HandleFunc("/swagger/", ToHTTPHandler(deps, handlers.HandleSwagger, globalMiddleware..., )) } // API v1 routes s.mux.HandleFunc("GET /api/v1/system/info", ToHTTPHandler(deps, api_v1.HandleSystemInfo, globalMiddleware..., )) // Legacy API routes // TODO: Remove this once the legacy API is removed legacyHandler := handlers.NewLegacyHandler(deps) s.mux.HandleFunc("GET /api/tags", ToHTTPHandler(deps, legacyHandler.HandleGetTags, globalMiddleware...)) s.mux.HandleFunc("PUT /api/tags", ToHTTPHandler(deps, legacyHandler.HandleRenameTag, globalMiddleware...)) s.mux.HandleFunc("GET /api/bookmarks", ToHTTPHandler(deps, legacyHandler.HandleGetBookmarks, globalMiddleware...)) s.mux.HandleFunc("POST /api/bookmarks", ToHTTPHandler(deps, legacyHandler.HandleInsertBookmark, globalMiddleware...)) s.mux.HandleFunc("DELETE /api/bookmarks", ToHTTPHandler(deps, legacyHandler.HandleDeleteBookmark, globalMiddleware...)) s.mux.HandleFunc("PUT /api/bookmarks", ToHTTPHandler(deps, legacyHandler.HandleUpdateBookmark, globalMiddleware...)) s.mux.HandleFunc("PUT /api/bookmarks/tags", ToHTTPHandler(deps, legacyHandler.HandleUpdateBookmarkTags, globalMiddleware...)) s.mux.HandleFunc("POST /api/bookmarks/ext", ToHTTPHandler(deps, legacyHandler.HandleInsertViaExtension, globalMiddleware...)) s.mux.HandleFunc("DELETE /api/bookmarks/ext", ToHTTPHandler(deps, legacyHandler.HandleDeleteViaExtension, globalMiddleware...)) // Register routes using standard http handlers if cfg.Http.ServeWebUI { // Frontend routes s.mux.HandleFunc("/", ToHTTPHandler(deps, handlers.HandleFrontend, globalMiddleware..., )) s.mux.HandleFunc("GET /assets/", ToHTTPHandler(deps, handlers.HandleAssets, globalMiddleware..., )) } // API v1 routes // Auth s.mux.HandleFunc("POST /api/v1/auth/login", ToHTTPHandler(deps, api_v1.HandleLogin, globalMiddleware..., )) s.mux.HandleFunc("POST /api/v1/auth/refresh", ToHTTPHandler(deps, api_v1.HandleRefreshToken, globalMiddleware..., )) s.mux.HandleFunc("GET /api/v1/auth/me", ToHTTPHandler(deps, api_v1.HandleGetMe, globalMiddleware..., )) s.mux.HandleFunc("PATCH /api/v1/auth/account", ToHTTPHandler(deps, api_v1.HandleUpdateLoggedAccount, globalMiddleware..., )) s.mux.HandleFunc("POST /api/v1/auth/logout", ToHTTPHandler(deps, api_v1.HandleLogout, globalMiddleware..., )) // Accounts s.mux.HandleFunc("GET /api/v1/accounts", ToHTTPHandler(deps, api_v1.HandleListAccounts, globalMiddleware..., )) s.mux.HandleFunc("POST /api/v1/accounts", ToHTTPHandler(deps, api_v1.HandleCreateAccount, globalMiddleware..., )) s.mux.HandleFunc("DELETE /api/v1/accounts/{id}", ToHTTPHandler(deps, api_v1.HandleDeleteAccount, globalMiddleware..., )) s.mux.HandleFunc("PATCH /api/v1/accounts/{id}", ToHTTPHandler(deps, api_v1.HandleUpdateAccount, globalMiddleware..., )) // Tags s.mux.HandleFunc("GET /api/v1/tags", ToHTTPHandler(deps, api_v1.HandleListTags, globalMiddleware..., )) s.mux.HandleFunc("GET /api/v1/tags/{id}", ToHTTPHandler(deps, api_v1.HandleGetTag, globalMiddleware..., )) s.mux.HandleFunc("POST /api/v1/tags", ToHTTPHandler(deps, api_v1.HandleCreateTag, globalMiddleware..., )) s.mux.HandleFunc("PUT /api/v1/tags/{id}", ToHTTPHandler(deps, api_v1.HandleUpdateTag, globalMiddleware..., )) s.mux.HandleFunc("DELETE /api/v1/tags/{id}", ToHTTPHandler(deps, api_v1.HandleDeleteTag, globalMiddleware..., )) // Bookmarks s.mux.HandleFunc("PUT /api/v1/bookmarks/cache", ToHTTPHandler(deps, api_v1.HandleUpdateCache, globalMiddleware..., )) s.mux.HandleFunc("GET /api/v1/bookmarks/{id}/readable", ToHTTPHandler(deps, api_v1.HandleBookmarkReadable, globalMiddleware..., )) s.mux.HandleFunc("PUT /api/v1/bookmarks/bulk/tags", ToHTTPHandler(deps, api_v1.HandleBulkUpdateBookmarkTags, globalMiddleware..., )) // Bookmark tags endpoints s.mux.HandleFunc("GET /api/v1/bookmarks/{id}/tags", ToHTTPHandler(deps, api_v1.HandleGetBookmarkTags, globalMiddleware..., )) s.mux.HandleFunc("POST /api/v1/bookmarks/{id}/tags", ToHTTPHandler(deps, api_v1.HandleAddTagToBookmark, globalMiddleware..., )) s.mux.HandleFunc("DELETE /api/v1/bookmarks/{id}/tags", ToHTTPHandler(deps, api_v1.HandleRemoveTagFromBookmark, globalMiddleware..., )) s.server = &http.Server{ Addr: fmt.Sprintf("%s%d", cfg.Http.Address, cfg.Http.Port), Handler: s.mux, } return s, nil } func (s *HttpServer) Start(_ context.Context) error { s.logger.WithField("addr", s.server.Addr).Info("starting http server") go func() { if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.logger.Fatalf("listen and serve error: %s\n", err) } }() return nil } func (s *HttpServer) Stop(ctx context.Context) error { s.logger.WithField("addr", s.server.Addr).Info("stopping http server") return s.server.Shutdown(ctx) } func (s *HttpServer) WaitStop(ctx context.Context) { signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) sig := <-signals s.logger.WithField("signal", sig.String()).Info("signal received, shutting down") if err := s.Stop(ctx); err != nil { s.logger.WithError(err).Error("error stopping server") } } func NewHttpServer(logger *logrus.Logger) *HttpServer { return &HttpServer{ logger: logger, } } ================================================ FILE: internal/http/server_test.go ================================================ package http import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-shiori/shiori/internal/model" "github.com/go-shiori/shiori/internal/testutil" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestNewHttpServer(t *testing.T) { logger := logrus.New() server := NewHttpServer(logger) require.NotNil(t, server) require.Equal(t, logger, server.logger) } func TestHttpServer_Setup(t *testing.T) { logger := logrus.New() ctx := context.Background() t.Run("successful setup", func(t *testing.T) { cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) require.NotNil(t, s) require.NotNil(t, s.mux) require.NotNil(t, s.server) require.Equal(t, fmt.Sprintf("%s%d", cfg.Http.Address, cfg.Http.Port), s.server.Addr) }) t.Run("routes are registered correctly", func(t *testing.T) { cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) // Test some key routes routes := []struct { method string path string want int }{ {"GET", "/system/liveness", http.StatusOK}, {"GET", "/api/v1/system/info", http.StatusUnauthorized}, // Requires auth {"GET", "/api/v1/accounts", http.StatusUnauthorized}, // Requires auth {"POST", "/api/v1/auth/login", http.StatusBadRequest}, // Bad request because no body } for _, tt := range routes { t.Run(fmt.Sprintf("%s %s", tt.method, tt.path), func(t *testing.T) { req := httptest.NewRequest(tt.method, tt.path, nil) w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, tt.want, w.Code) }) } }) t.Run("swagger routes when enabled", func(t *testing.T) { cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) cfg.Http.ServeSwagger = true server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) // Test swagger doc endpoint req := httptest.NewRequest("GET", "/swagger/doc.json", nil) w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) // Test swagger UI endpoint (should redirect) req = httptest.NewRequest("GET", "/swagger/", nil) w = httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, http.StatusMovedPermanently, w.Code) require.Equal(t, "/swagger/index.html", w.Header().Get("Location")) }) t.Run("web UI routes when enabled", func(t *testing.T) { cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) cfg.Http.ServeWebUI = true server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) routes := []struct { path string want int }{ {"/", http.StatusOK}, {"/assets/style.css", http.StatusNotFound}, // 404 because no actual assets in test } for _, tt := range routes { t.Run(tt.path, func(t *testing.T) { req := httptest.NewRequest("GET", tt.path, nil) w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, tt.want, w.Code) }) } }) } func TestHttpServer_StartStop(t *testing.T) { logger := logrus.New() ctx := context.Background() cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) // Use a random port to avoid conflicts cfg.Http.Port = 0 server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) // Start the server err = s.Start(ctx) require.NoError(t, err) // Give it a moment to start time.Sleep(100 * time.Millisecond) // Stop the server err = s.Stop(ctx) require.NoError(t, err) } func TestHttpServer_Middleware(t *testing.T) { logger := logrus.New() ctx := context.Background() cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) t.Run("logging middleware", func(t *testing.T) { // Capture log output var logBuf strings.Builder logger.SetOutput(&logBuf) logger.SetLevel(logrus.InfoLevel) req := httptest.NewRequest("GET", "/system/liveness", nil) w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) // Verify log contains request info logOutput := logBuf.String() require.Contains(t, logOutput, "request completed") require.Contains(t, logOutput, "path=/system/liveness") }) t.Run("auth middleware", func(t *testing.T) { protectedRoutes := []struct { method string path string want int auth bool }{ {"GET", "/api/v1/accounts", http.StatusUnauthorized, false}, {"GET", "/api/v1/auth/me", http.StatusUnauthorized, false}, {"PUT", "/api/v1/bookmarks/cache", http.StatusForbidden, true}, // Requires admin access } for _, route := range protectedRoutes { t.Run(route.path, func(t *testing.T) { req := httptest.NewRequest(route.method, route.path, nil) if route.auth { // Create a non-admin user token account := testutil.GetValidAccount() account.Owner = false // Ensure not admin accountDTO := account.ToDTO() token, err := deps.Domains().Auth().CreateTokenForAccount(&accountDTO, time.Now().Add(time.Hour)) require.NoError(t, err) req.Header.Set(model.AuthorizationHeader, model.AuthorizationTokenType+" "+token) } w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, route.want, w.Code) }) } }) } func TestHttpServer_APIEndpoints(t *testing.T) { logger := logrus.New() ctx := context.Background() cfg, deps := testutil.GetTestConfigurationAndDependencies(t, ctx, logger) server := NewHttpServer(logger) s, err := server.Setup(cfg, deps) require.NoError(t, err) t.Run("login endpoint", func(t *testing.T) { body := strings.NewReader(`{"username": "test", "password": "test"}`) req := httptest.NewRequest("POST", "/api/v1/auth/login", body) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) respBody, _ := io.ReadAll(w.Body) require.Contains(t, string(respBody), "username or password do not match") }) t.Run("system info endpoint", func(t *testing.T) { req := httptest.NewRequest("GET", "/api/v1/system/info", nil) w := httptest.NewRecorder() s.mux.ServeHTTP(w, req) require.Equal(t, http.StatusUnauthorized, w.Code) respBody, _ := io.ReadAll(w.Body) require.Contains(t, string(respBody), "Authentication required") }) } ================================================ FILE: internal/http/templates/templates.go ================================================ package templates import ( "fmt" "html/template" "io" "github.com/go-shiori/shiori/internal/config" views "github.com/go-shiori/shiori/internal/view" webapp "github.com/go-shiori/shiori/webapp" ) const ( leftTemplateDelim = "$$" rightTemplateDelim = "$$" ) var templates *template.Template // SetupTemplates initializes the templates for the webserver func SetupTemplates(config *config.Config) error { var err error fs := views.Templates globs := []string{"*.html"} if config.Http.ServeWebUIV2 { fs = webapp.Templates globs = []string{"**/*.html"} } templates, err = template.New("html"). Delims(leftTemplateDelim, rightTemplateDelim). ParseFS(fs, globs...) if err != nil { return fmt.Errorf("failed to parse templates: %w", err) } return nil } // RenderTemplate renders a template with the given data func RenderTemplate(w io.Writer, name string, data any) error { if templates == nil { return fmt.Errorf("templates not initialized") } return templates.ExecuteTemplate(w, name, data) } ================================================ FILE: internal/http/webcontext/auth.go ================================================ package webcontext import ( "context" "github.com/go-shiori/shiori/internal/model" ) // UserIsLogged returns a boolean indicating if the user is authenticated or not func (c *WebContext) UserIsLogged() bool { return c.GetAccount() != nil } // GetAccount retrieves the account from the request context func (c *WebContext) GetAccount() *model.AccountDTO { if acc := c.request.Context().Value(accountKey); acc != nil { return acc.(*model.AccountDTO) } return nil } // SetAccount stores the account in the request context func (c *WebContext) SetAccount(account *model.AccountDTO) { ctx := WithAccount(c.request.Context(), account) c.request = c.request.WithContext(ctx) } // WithAccount creates a new context with the account func WithAccount(ctx context.Context, account *model.AccountDTO) context.Context { return context.WithValue(ctx, accountKey, account) } ================================================ FILE: internal/http/webcontext/auth_test.go ================================================ package webcontext import ( "net/http" "net/http/httptest" "testing" "github.com/go-shiori/shiori/internal/model" "github.com/stretchr/testify/require" ) func TestUserIsLogged(t *testing.T) { t.Run("test user is logged", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := NewWebContext(w, r) c.SetAccount(&model.AccountDTO{Username: "test"}) require.True(t, c.UserIsLogged()) account := c.GetAccount() require.NotNil(t, account) require.Equal(t, "test", account.Username) }) t.Run("test user is not logged", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := NewWebContext(w, r) require.False(t, c.UserIsLogged()) require.Nil(t, c.GetAccount()) }) } func TestGetAccount(t *testing.T) { t.Run("test get account (logged in)", func(t *testing.T) { account := model.AccountDTO{ Username: "shiori", } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := NewWebContext(w, r) c.SetAccount(&account) gotAccount := c.GetAccount() require.NotNil(t, gotAccount) require.Equal(t, account, *gotAccount) }) t.Run("test get account (not logged in)", func(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := NewWebContext(w, r) require.Nil(t, c.GetAccount()) }) } func TestWithAccount(t *testing.T) { account := &model.AccountDTO{ Username: "shiori", } w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/", nil) c := NewWebContext(w, r) c.SetAccount(account) gotAccount := c.GetAccount() require.Equal(t, account, gotAccount) } ================================================ FILE: internal/http/webcontext/context.go ================================================ package webcontext import ( "context" "net/http" ) // WebContext wraps the standard request and response writer type WebContext struct { request *http.Request responseWriter http.ResponseWriter } // NewWebContext creates a new WebContext from http.ResponseWriter and *http.Request func NewWebContext(w http.ResponseWriter, r *http.Request) *WebContext { return &WebContext{ request: r, responseWriter: w, } } // Context returns the request's context func (c *WebContext) Context() context.Context { return c.request.Context() } // WithContext returns a shallow copy of c with its context changed to ctx func (c *WebContext) WithContext(ctx context.Context) *WebContext { c2 := new(WebContext) *c2 = *c c2.request = c2.request.WithContext(ctx) return c2 } func (c *WebContext) ResponseWriter() http.ResponseWriter { return c.responseWriter } // SetResponseWriter sets the response writer for the context func (c *WebContext) SetResponseWriter(w http.ResponseWriter) { c.responseWriter = w } func (c *WebContext) Request() *http.Request { return c.request } // GetRequestID returns the request ID from the context func (c *WebContext) GetRequestID() string { if id := c.request.Context().Value(requestIDKey); id != nil { return id.(string) } return "" } // SetRequestID stores the request ID in the context func (c *WebContext) SetRequestID(id string) { ctx := context.WithValue(c.request.Context(), requestIDKey, id) c.request = c.request.WithContext(ctx) } ================================================ FILE: internal/http/webcontext/keys.go ================================================ package webcontext type contextKey string const ( accountKey contextKey = "account" requestIDKey contextKey = "requestID" ) ================================================ FILE: internal/model/account.go ================================================ package model import ( "database/sql/driver" "encoding/json" "fmt" "github.com/golang-jwt/jwt/v5" ) // Account is the database representation for account. type Account struct { ID DBID `db:"id" json:"id"` Username string `db:"username" json:"username"` Password string `db:"password" json:"password,omitempty"` Owner bool `db:"owner" json:"owner"` Config UserConfig `db:"config" json:"config"` } type UserConfig struct { ShowId bool ListMode bool HideThumbnail bool HideExcerpt bool Theme string KeepMetadata bool UseArchive bool CreateEbook bool MakePublic bool } func (c *UserConfig) Scan(value interface{}) error { switch v := value.(type) { case []byte: json.Unmarshal(v, &c) return nil case string: json.Unmarshal([]byte(v), &c) return nil default: return fmt.Errorf("unsupported type: %T", v) } } func (c UserConfig) Value() (driver.Value, error) { return json.Marshal(c) } // ToDTO converts Account to AccountDTO. func (a Account) ToDTO() AccountDTO { owner := a.Owner config := a.Config return AccountDTO{ ID: a.ID, Username: a.Username, Owner: &owner, Config: &config, } } // AccountDTO is data transfer object for Account. type AccountDTO struct { ID DBID `json:"id"` Username string `json:"username"` Password string `json:"passowrd,omitempty"` // Used only to store, not to retrieve Owner *bool `json:"owner"` Config *UserConfig `json:"config"` } func (adto *AccountDTO) IsOwner() bool { return adto.Owner != nil && *adto.Owner } func (adto *AccountDTO) IsValidCreate() error { if adto.Username == "" { return NewValidationError("username", "username should not be empty") } if adto.Password == "" { return NewValidationError("password", "password should not be empty") } return nil } func (adto *AccountDTO) IsValidUpdate() error { if adto.Username == "" && adto.Password == "" && adto.Owner == nil && adto.Config == nil { return NewValidationError("account", "no fields to update") } return nil } type JWTClaim struct { jwt.RegisteredClaims Account *Account } ================================================ FILE: internal/model/bookmark.go ================================================ package model import ( "path/filepath" "strconv" ) // Bookmark is the database representation of a bookmark type Bookmark struct { ID int `db:"id"` URL string `db:"url"` Title string `db:"title"` Excerpt string `db:"excerpt"` Author string `db:"author"` Public int `db:"public"` CreatedAt string `db:"created_at"` ModifiedAt string `db:"modified_at"` HasContent bool `db:"has_content"` } // BookmarkDTO is the bookmark object representation in database and the data transfer object // at the same time, pending a refactor to two separate object to represent each role. type BookmarkDTO struct { ID int `db:"id" json:"id"` URL string `db:"url" json:"url"` Title string `db:"title" json:"title"` Excerpt string `db:"excerpt" json:"excerpt"` Author string `db:"author" json:"author"` Public int `db:"public" json:"public"` CreatedAt string `db:"created_at" json:"createdAt"` ModifiedAt string `db:"modified_at" json:"modifiedAt"` Content string `db:"content" json:"-"` HTML string `db:"html" json:"html,omitempty"` ImageURL string `db:"image_url" json:"imageURL"` HasContent bool `db:"has_content" json:"hasContent"` Tags []TagDTO `json:"tags"` HasArchive bool `json:"hasArchive"` HasEbook bool `json:"hasEbook"` CreateArchive bool `json:"create_archive"` // TODO: migrate outside the DTO CreateEbook bool `json:"create_ebook"` // TODO: migrate outside the DTO } // ToBookmark converts a BookmarkDTO to a Bookmark func (dto *BookmarkDTO) ToBookmark() Bookmark { return Bookmark{ ID: dto.ID, URL: dto.URL, Title: dto.Title, Excerpt: dto.Excerpt, Author: dto.Author, Public: dto.Public, CreatedAt: dto.CreatedAt, ModifiedAt: dto.ModifiedAt, HasContent: dto.HasContent, } } // ToDTO converts a Bookmark to a BookmarkDTO func (b *Bookmark) ToDTO() BookmarkDTO { return BookmarkDTO{ ID: b.ID, URL: b.URL, Title: b.Title, Excerpt: b.Excerpt, Author: b.Author, Public: b.Public, CreatedAt: b.CreatedAt, ModifiedAt: b.ModifiedAt, HasContent: b.HasContent, Tags: []TagDTO{}, } } // GetTumnbailPath returns the relative path to the thumbnail of a bookmark in the filesystem func GetThumbnailPath(bookmark *BookmarkDTO) string { return filepath.Join("thumb", strconv.Itoa(bookmark.ID)) } // GetEbookPath returns the relative path to the ebook of a bookmark in the filesystem func GetEbookPath(bookmark *BookmarkDTO) string { return filepath.Join("ebook", strconv.Itoa(bookmark.ID)+".epub") } // GetArchivePath returns the relative path to the archive of a bookmark in the filesystem func GetArchivePath(bookmark *BookmarkDTO) string { return filepath.Join("archive", strconv.Itoa(bookmark.ID)) } ================================================ FILE: internal/model/bookmark_test.go ================================================ package model import ( "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestBookmarkToDTO(t *testing.T) { // Create a test bookmark bookmark := Bookmark{ ID: 123, URL: "https://example.com", Title: "Example Title", Excerpt: "This is an excerpt", Author: "John Doe", Public: 1, CreatedAt: "2023-01-01 12:00:00", ModifiedAt: "2023-01-02 12:00:00", HasContent: true, } // Convert to DTO dto := bookmark.ToDTO() // Verify all fields are correctly transferred assert.Equal(t, bookmark.ID, dto.ID, "ID should match") assert.Equal(t, bookmark.URL, dto.URL, "URL should match") assert.Equal(t, bookmark.Title, dto.Title, "Title should match") assert.Equal(t, bookmark.Excerpt, dto.Excerpt, "Excerpt should match") assert.Equal(t, bookmark.Author, dto.Author, "Author should match") assert.Equal(t, bookmark.Public, dto.Public, "Public should match") assert.Equal(t, bookmark.CreatedAt, dto.CreatedAt, "CreatedAt should match") assert.Equal(t, bookmark.ModifiedAt, dto.ModifiedAt, "ModifiedAt should match") assert.Equal(t, bookmark.HasContent, dto.HasContent, "HasContent should match") // Verify default values for fields not in Bookmark assert.Empty(t, dto.Content, "Content should be empty") assert.Empty(t, dto.HTML, "HTML should be empty") assert.Empty(t, dto.ImageURL, "ImageURL should be empty") assert.Empty(t, dto.Tags, "Tags should be empty") assert.False(t, dto.HasArchive, "HasArchive should be false") assert.False(t, dto.HasEbook, "HasEbook should be false") assert.False(t, dto.CreateArchive, "CreateArchive should be false") assert.False(t, dto.CreateEbook, "CreateEbook should be false") } func TestBookmarkDTOToBookmark(t *testing.T) { // Create a test BookmarkDTO with all fields populated dto := BookmarkDTO{ ID: 123, URL: "https://example.com", Title: "Example Title", Excerpt: "This is an excerpt", Author: "John Doe", Public: 1, CreatedAt: "2023-01-01 12:00:00", ModifiedAt: "2023-01-02 12:00:00", Content: "This is the content", HTML: "

This is HTML

", ImageURL: "https://example.com/image.jpg", HasContent: true, Tags: []TagDTO{{Tag: Tag{ID: 1, Name: "tag1"}}, {Tag: Tag{ID: 2, Name: "tag2"}}}, HasArchive: true, HasEbook: true, CreateArchive: true, CreateEbook: true, } // Convert to Bookmark bookmark := dto.ToBookmark() // Verify all fields are correctly transferred assert.Equal(t, dto.ID, bookmark.ID, "ID should match") assert.Equal(t, dto.URL, bookmark.URL, "URL should match") assert.Equal(t, dto.Title, bookmark.Title, "Title should match") assert.Equal(t, dto.Excerpt, bookmark.Excerpt, "Excerpt should match") assert.Equal(t, dto.Author, bookmark.Author, "Author should match") assert.Equal(t, dto.Public, bookmark.Public, "Public should match") assert.Equal(t, dto.CreatedAt, bookmark.CreatedAt, "CreatedAt should match") assert.Equal(t, dto.ModifiedAt, bookmark.ModifiedAt, "ModifiedAt should match") assert.Equal(t, dto.HasContent, bookmark.HasContent, "HasContent should match") // Fields that should not be transferred // These fields are only in BookmarkDTO and not in Bookmark } func TestGetThumbnailPath(t *testing.T) { // Test cases testCases := []struct { name string bookmark BookmarkDTO expected string }{ { name: "With ID", bookmark: BookmarkDTO{ ID: 123, }, expected: filepath.Join("thumb", "123"), }, { name: "With zero ID", bookmark: BookmarkDTO{ ID: 0, }, expected: filepath.Join("thumb", "0"), }, } // Run test cases for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { path := GetThumbnailPath(&tc.bookmark) assert.Equal(t, tc.expected, path, "Thumbnail path should match expected value") }) } } func TestGetEbookPath(t *testing.T) { // Test cases testCases := []struct { name string bookmark BookmarkDTO expected string }{ { name: "With ID", bookmark: BookmarkDTO{ ID: 123, }, expected: filepath.Join("ebook", "123.epub"), }, { name: "With zero ID", bookmark: BookmarkDTO{ ID: 0, }, expected: filepath.Join("ebook", "0.epub"), }, } // Run test cases for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { path := GetEbookPath(&tc.bookmark) assert.Equal(t, tc.expected, path, "Ebook path should match expected value") }) } } func TestGetArchivePath(t *testing.T) { // Test cases testCases := []struct { name string bookmark BookmarkDTO expected string }{ { name: "With ID", bookmark: BookmarkDTO{ ID: 123, }, expected: filepath.Join("archive", "123"), }, { name: "With zero ID", bookmark: BookmarkDTO{ ID: 0, }, expected: filepath.Join("archive", "0"), }, } // Run test cases for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { path := GetArchivePath(&tc.bookmark) assert.Equal(t, tc.expected, path, "Archive path should match expected value") }) } } func TestBookmarkRoundTrip(t *testing.T) { // Test that converting from Bookmark to DTO and back preserves data original := Bookmark{ ID: 123, URL: "https://example.com", Title: "Example Title", Excerpt: "This is an excerpt", Author: "John Doe", Public: 1, CreatedAt: "2023-01-01 12:00:00", ModifiedAt: "2023-01-02 12:00:00", HasContent: true, } // Convert to DTO and back dto := original.ToDTO() roundTrip := dto.ToBookmark() // Verify all fields are preserved assert.Equal(t, original.ID, roundTrip.ID, "ID should be preserved") assert.Equal(t, original.URL, roundTrip.URL, "URL should be preserved") assert.Equal(t, original.Title, roundTrip.Title, "Title should be preserved") assert.Equal(t, original.Excerpt, roundTrip.Excerpt, "Excerpt should be preserved") assert.Equal(t, original.Author, roundTrip.Author, "Author should be preserved") assert.Equal(t, original.Public, roundTrip.Public, "Public should be preserved") assert.Equal(t, original.CreatedAt, roundTrip.CreatedAt, "CreatedAt should be preserved") assert.Equal(t, original.ModifiedAt, roundTrip.ModifiedAt, "ModifiedAt should be preserved") assert.Equal(t, original.HasContent, roundTrip.HasContent, "HasContent should be preserved") } ================================================ FILE: internal/model/const.go ================================================ package model // DataDirPerm the default filesystem permissions for the data directory/archives const DataDirPerm = 0744 // DatabaseDateFormat the string formatting of datetimes for the database const DatabaseDateFormat = "2006-01-02 15:04:05" ================================================ FILE: internal/model/database.go ================================================ package model import ( "context" "github.com/jmoiron/sqlx" ) type DBID int // DB is interface for accessing and manipulating data in database. type DB interface { // WriterDB is the underlying sqlx.DB WriterDB() *sqlx.DB // ReaderDB is the underlying sqlx.DB ReaderDB() *sqlx.DB // Flavor is the flavor of the database // Flavor() sqlbuilder.Flavor // Init initializes the database Init(ctx context.Context) error // Migrate runs migrations for this database Migrate(ctx context.Context) error // GetDatabaseSchemaVersion gets the version of the database GetDatabaseSchemaVersion(ctx context.Context) (string, error) // SetDatabaseSchemaVersion sets the version of the database SetDatabaseSchemaVersion(ctx context.Context, version string) error // SaveBookmarks saves bookmarks data to database. SaveBookmarks(ctx context.Context, create bool, bookmarks ...BookmarkDTO) ([]BookmarkDTO, error) // SaveBookmark saves a single bookmark to database without handling tags. // It only updates the bookmark data in the database. SaveBookmark(ctx context.Context, bookmark Bookmark) error // GetBookmarks fetch list of bookmarks based on submitted options. GetBookmarks(ctx context.Context, opts DBGetBookmarksOptions) ([]BookmarkDTO, error) // GetBookmarksCount get count of bookmarks in database. GetBookmarksCount(ctx context.Context, opts DBGetBookmarksOptions) (int, error) // DeleteBookmarks removes all record with matching ids from database. DeleteBookmarks(ctx context.Context, ids ...int) error // GetBookmark fetches bookmark based on its ID or URL. GetBookmark(ctx context.Context, id int, url string) (BookmarkDTO, bool, error) // CreateAccount saves new account in database CreateAccount(ctx context.Context, a Account) (*Account, error) // UpdateAccount updates account in database UpdateAccount(ctx context.Context, a Account) error // ListAccounts fetch list of account (without its password) with matching keyword. ListAccounts(ctx context.Context, opts DBListAccountsOptions) ([]Account, error) // GetAccount fetch account with matching username. GetAccount(ctx context.Context, id DBID) (*Account, bool, error) // DeleteAccount removes account with matching id DeleteAccount(ctx context.Context, id DBID) error // CreateTags creates new tags in database. CreateTags(ctx context.Context, tags ...Tag) ([]Tag, error) // CreateTag creates a new tag in database. CreateTag(ctx context.Context, tag Tag) (Tag, error) // GetTags fetch list of tags and its frequency from database. GetTags(ctx context.Context, opts DBListTagsOptions) ([]TagDTO, error) // RenameTag change the name of a tag. RenameTag(ctx context.Context, id int, newName string) error // GetTag fetch a tag by its ID. GetTag(ctx context.Context, id int) (TagDTO, bool, error) // UpdateTag updates a tag in the database. UpdateTag(ctx context.Context, tag Tag) error // DeleteTag removes a tag from the database. DeleteTag(ctx context.Context, id int) error // BulkUpdateBookmarkTags updates tags for multiple bookmarks. // It ensures that all bookmarks and tags exist before proceeding. BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error // AddTagToBookmark adds a tag to a bookmark AddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error // RemoveTagFromBookmark removes a tag from a bookmark RemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error // TagExists checks if a tag with the given ID exists in the database TagExists(ctx context.Context, tagID int) (bool, error) // BookmarkExists checks if a bookmark with the given ID exists in the database BookmarkExists(ctx context.Context, bookmarkID int) (bool, error) } // DBOrderMethod is the order method for getting bookmarks type DBOrderMethod int const ( // DefaultOrder is oldest to newest. DefaultOrder DBOrderMethod = iota // ByLastAdded is from newest addition to the oldest. ByLastAdded // ByLastModified is from latest modified to the oldest. ByLastModified ) // DBGetBookmarksOptions is options for fetching bookmarks from database. type DBGetBookmarksOptions struct { IDs []int Tags []string ExcludedTags []string Keyword string WithContent bool OrderMethod DBOrderMethod Limit int Offset int } // DBListAccountsOptions is options for fetching accounts from database. type DBListAccountsOptions struct { // Filter accounts by a keyword Keyword string // Filter accounts by exact useranme Username string // Return owner accounts only Owner bool // Retrieve password content WithPassword bool } type DBTagOrderBy string const ( DBTagOrderByTagName DBTagOrderBy = "name" ) // DBListTagsOptions is options for fetching tags from database. type DBListTagsOptions struct { BookmarkID int WithBookmarkCount bool OrderBy DBTagOrderBy Search string } ================================================ FILE: internal/model/dependencies.go ================================================ package model import ( "github.com/go-shiori/shiori/internal/config" "github.com/sirupsen/logrus" ) // Dependencies represents the interface for application dependencies type Dependencies interface { Logger() *logrus.Logger Domains() DomainDependencies Config() *config.Config Database() DB } // DomainDependencies represents the interface for domain-specific dependencies type DomainDependencies interface { Auth() AuthDomain SetAuth(auth AuthDomain) Accounts() AccountsDomain SetAccounts(accounts AccountsDomain) Bookmarks() BookmarksDomain SetBookmarks(bookmarks BookmarksDomain) Archiver() ArchiverDomain SetArchiver(archiver ArchiverDomain) Storage() StorageDomain SetStorage(storage StorageDomain) Tags() TagsDomain SetTags(tags TagsDomain) } ================================================ FILE: internal/model/domains.go ================================================ package model import ( "context" "io/fs" "os" "time" "github.com/go-shiori/warc" "github.com/spf13/afero" ) type BookmarksDomain interface { HasEbook(b *BookmarkDTO) bool HasArchive(b *BookmarkDTO) bool HasThumbnail(b *BookmarkDTO) bool GetBookmark(ctx context.Context, id DBID) (*BookmarkDTO, error) GetBookmarks(ctx context.Context, ids []int) ([]BookmarkDTO, error) UpdateBookmarkCache(ctx context.Context, bookmark BookmarkDTO, keepMetadata bool, skipExist bool) (*BookmarkDTO, error) BulkUpdateBookmarkTags(ctx context.Context, bookmarkIDs []int, tagIDs []int) error AddTagToBookmark(ctx context.Context, bookmarkID int, tagID int) error RemoveTagFromBookmark(ctx context.Context, bookmarkID int, tagID int) error BookmarkExists(ctx context.Context, id int) (bool, error) } type AuthDomain interface { CheckToken(ctx context.Context, userJWT string) (*AccountDTO, error) GetAccountFromCredentials(ctx context.Context, username, password string) (*AccountDTO, error) CreateTokenForAccount(account *AccountDTO, expiration time.Time) (string, error) } type AccountsDomain interface { ListAccounts(ctx context.Context) ([]AccountDTO, error) GetAccountByUsername(ctx context.Context, username string) (*AccountDTO, error) CreateAccount(ctx context.Context, account AccountDTO) (*AccountDTO, error) UpdateAccount(ctx context.Context, account AccountDTO) (*AccountDTO, error) DeleteAccount(ctx context.Context, id int) error } type ArchiverDomain interface { DownloadBookmarkArchive(book BookmarkDTO) (*BookmarkDTO, error) GetBookmarkArchive(book *BookmarkDTO) (*warc.Archive, error) } type StorageDomain interface { Stat(name string) (fs.FileInfo, error) FS() afero.Fs FileExists(path string) bool DirExists(path string) bool WriteData(dst string, data []byte) error WriteFile(dst string, src *os.File) error } type TagsDomain interface { ListTags(ctx context.Context, opts ListTagsOptions) ([]TagDTO, error) CreateTag(ctx context.Context, tag TagDTO) (TagDTO, error) GetTag(ctx context.Context, id int) (TagDTO, error) UpdateTag(ctx context.Context, tag TagDTO) (TagDTO, error) DeleteTag(ctx context.Context, id int) error TagExists(ctx context.Context, id int) (bool, error) } ================================================ FILE: internal/model/errors.go ================================================ package model import "errors" var ( ErrBookmarkNotFound = errors.New("bookmark not found") ErrBookmarkInvalidID = errors.New("invalid bookmark ID") ErrTagNotFound = errors.New("tag not found") ErrUnauthorized = errors.New("unauthorized user") ErrNotFound = errors.New("not found") ErrAlreadyExists = errors.New("already exists") ) ================================================ FILE: internal/model/http.go ================================================ package model import "net/http" const ( // ContextAccountKey is the key used to store the account model in the gin context. ContextAccountKey = "account" // AuthorizationHeader is the name of the header used to send the token. AuthorizationHeader = "Authorization" // AuthorizationTokenType is the type of token used in the Authorization header. AuthorizationTokenType = "Bearer" ) // WebContext represents the context of an HTTP request type WebContext interface { Request() *http.Request ResponseWriter() http.ResponseWriter SetResponseWriter(w http.ResponseWriter) GetAccount() *AccountDTO SetAccount(*AccountDTO) UserIsLogged() bool GetRequestID() string SetRequestID(id string) } // Handler is a custom handler function that receives dependencies and web context type HttpHandler func(deps Dependencies, c WebContext) // Middleware defines the interface for request/response customization type HttpMiddleware interface { OnRequest(deps Dependencies, c WebContext) error OnResponse(deps Dependencies, c WebContext) error } ================================================ FILE: internal/model/legacy.go ================================================ package model import "time" type LegacyLoginHandler func(account *AccountDTO, expTime time.Duration) (string, error) ================================================ FILE: internal/model/main.go ================================================ package model // Variables set my the main package coming from ldflags var ( BuildVersion = "dev" BuildCommit = "none" BuildDate = "unknown" ) const ( // ShioriNamespace ShioriURLNamespace = "https://github.com/go-shiori/shiori" ) ================================================ FILE: internal/model/ptr.go ================================================ package model // Ptr returns a pointer to the value passed as argument. func Ptr[t any](a t) *t { return &a } ================================================ FILE: internal/model/slices.go ================================================ package model // SliceDifference returns the elements that are in haystack but not in needle. // It's a generic function that works with any comparable type. func SliceDifference[T comparable](haystack, needle []T) []T { // Create a map of needle elements for quick lookup needleMap := make(map[T]bool) for _, item := range needle { needleMap[item] = true } // Find elements in haystack that are not in needle var difference []T for _, item := range haystack { if !needleMap[item] { difference = append(difference, item) } } return difference } ================================================ FILE: internal/model/slices_test.go ================================================ package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestSliceDifference(t *testing.T) { t.Run("empty_slices", func(t *testing.T) { result := SliceDifference([]int{}, []int{}) assert.Empty(t, result, "Difference of empty slices should be empty") }) t.Run("empty_haystack", func(t *testing.T) { result := SliceDifference([]int{}, []int{1, 2, 3}) assert.Empty(t, result, "Difference with empty haystack should be empty") }) t.Run("empty_needle", func(t *testing.T) { result := SliceDifference([]int{1, 2, 3}, []int{}) assert.Equal(t, []int{1, 2, 3}, result, "Difference with empty needle should be the haystack") }) t.Run("no_difference", func(t *testing.T) { result := SliceDifference([]int{1, 2, 3}, []int{1, 2, 3}) assert.Empty(t, result, "Difference of identical slices should be empty") }) t.Run("partial_difference", func(t *testing.T) { result := SliceDifference([]int{1, 2, 3, 4}, []int{2, 4}) assert.Equal(t, []int{1, 3}, result, "Should return elements in haystack but not in needle") }) t.Run("complete_difference", func(t *testing.T) { result := SliceDifference([]int{1, 2, 3}, []int{4, 5, 6}) assert.Equal(t, []int{1, 2, 3}, result, "Should return all elements from haystack when needle has no common elements") }) t.Run("with_duplicates", func(t *testing.T) { result := SliceDifference([]int{1, 2, 2, 3, 3, 3}, []int{2, 3}) assert.Equal(t, []int{1}, result, "Should handle duplicates correctly") }) t.Run("string_type", func(t *testing.T) { result := SliceDifference([]string{"a", "b", "c"}, []string{"b"}) assert.Equal(t, []string{"a", "c"}, result, "Should work with string type") }) } ================================================ FILE: internal/model/tag.go ================================================ package model import ( "errors" ) // BookmarkTag is the relationship between a bookmark and a tag. type BookmarkTag struct { BookmarkID int `db:"bookmark_id"` TagID int `db:"tag_id"` } // Tag is the tag for a bookmark. type Tag struct { ID int `db:"id" json:"id"` Name string `db:"name" json:"name"` } // TagDTO represents a tag in the application type TagDTO struct { Tag BookmarkCount int64 `db:"bookmark_count" json:"bookmark_count"` // Number of bookmarks with this tag Deleted bool `json:"deleted"` // Marks when a tag is deleted from a bookmark } func (t *Tag) ToDTO() TagDTO { return TagDTO{ Tag: Tag{ ID: t.ID, Name: t.Name, }, } } func (t *TagDTO) ToTag() Tag { return Tag{ ID: t.ID, Name: t.Name, } } // ListTagsOptions is options for fetching tags from database. type ListTagsOptions struct { BookmarkID int WithBookmarkCount bool OrderBy DBTagOrderBy Search string } // IsValid validates the ListTagsOptions. // Returns an error if the options are invalid, nil otherwise. // Currently, it checks that Search and BookmarkID are not used together. func (o ListTagsOptions) IsValid() error { if o.Search != "" && o.BookmarkID > 0 { return errors.New("search and bookmark ID filtering cannot be used together") } return nil } ================================================ FILE: internal/model/tag_test.go ================================================ package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestListTagsOptions_IsValid(t *testing.T) { tests := []struct { name string options ListTagsOptions wantErr bool }{ { name: "valid options with search", options: ListTagsOptions{ Search: "test", WithBookmarkCount: true, OrderBy: DBTagOrderByTagName, }, wantErr: false, }, { name: "valid options with bookmark ID", options: ListTagsOptions{ BookmarkID: 123, WithBookmarkCount: true, OrderBy: DBTagOrderByTagName, }, wantErr: false, }, { name: "invalid options with both search and bookmark ID", options: ListTagsOptions{ Search: "test", BookmarkID: 123, WithBookmarkCount: true, OrderBy: DBTagOrderByTagName, }, wantErr: true, }, { name: "valid options with neither search nor bookmark ID", options: ListTagsOptions{ WithBookmarkCount: true, OrderBy: DBTagOrderByTagName, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.options.IsValid() if tt.wantErr { assert.Error(t, err) assert.Contains(t, err.Error(), "search and bookmark ID filtering cannot be used together") } else { assert.NoError(t, err) } }) } } ================================================ FILE: internal/model/validation.go ================================================ package model // ValidationError represents a validation error. // This errors are used in the domain layer to indicate an error that is caused generally // by the user and has to be sent back via the API or appropriate channel. type ValidationError struct { Field string `json:"field"` Message string `json:"message"` } func (v ValidationError) Error() string { return v.Message } func NewValidationError(field, message string) ValidationError { return ValidationError{ Field: field, Message: message, } } ================================================ FILE: internal/testutil/accounts.go ================================================ package testutil import ( "context" "time" "github.com/go-shiori/shiori/internal/model" ) // NewAdminUser creates a new admin user and returns its account and token. // Use this when testing the API endpoints that require admin authentication to // generate the user and obtain a token that can be easily added as `WithAuthToken()` // option in the request. func NewAdminUser(deps model.Dependencies) (*model.AccountDTO, string, error) { account, err := deps.Domains().Accounts().CreateAccount(context.TODO(), model.AccountDTO{ Username: "admin", Password: "admin", Owner: model.Ptr(true), }) if err != nil { return nil, "", err } token, err := deps.Domains().Auth().CreateTokenForAccount(account, time.Now().Add(time.Hour*24*365)) if err != nil { return nil, "", err } return account, token, nil } ================================================ FILE: internal/testutil/accounts_test.go ================================================ package testutil import ( "context" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) func TestNewAdminUser(t *testing.T) { ctx := context.Background() logger := logrus.New() _, deps := GetTestConfigurationAndDependencies(t, ctx, logger) t.Run("successful admin user creation", func(t *testing.T) { account, token, err := NewAdminUser(deps) require.NoError(t, err) require.NotEmpty(t, token) require.NotNil(t, account) require.Equal(t, "admin", account.Username) require.True(t, *account.Owner) // Verify the token works tokenAccount, err := deps.Domains().Auth().CheckToken(ctx, token) require.NoError(t, err) require.NotNil(t, tokenAccount) require.Equal(t, account.ID, tokenAccount.ID) require.Equal(t, account.Username, tokenAccount.Username) require.True(t, *tokenAccount.Owner) }) t.Run("duplicate admin user creation", func(t *testing.T) { // Try to create another admin user account, token, err := NewAdminUser(deps) require.Error(t, err) require.Empty(t, token) require.Nil(t, account) }) } ================================================ FILE: internal/testutil/http.go ================================================ package testutil import ( "io" "net/http/httptest" "strings" "github.com/go-shiori/shiori/internal/http/webcontext" "github.com/go-shiori/shiori/internal/model" ) type Option = func(c model.WebContext) // NewTestWebContext creates a new WebContext with test recorder and request func NewTestWebContext() (model.WebContext, *httptest.ResponseRecorder) { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) return webcontext.NewWebContext(w, r), w } // NewTestWebContextWithMethod creates a new WebContext with specified method func NewTestWebContextWithMethod(method, path string, opts ...Option) (model.WebContext, *httptest.ResponseRecorder) { w := httptest.NewRecorder() r := httptest.NewRequest(method, path, nil) c := webcontext.NewWebContext(w, r) for _, opt := range opts { opt(c) } return c, w } func WithBody(body string) Option { return func(c model.WebContext) { c.Request().Body = io.NopCloser(strings.NewReader(body)) } } func WithHeader(name, value string) Option { return func(c model.WebContext) { c.Request().Header.Add(name, value) } } // WithAuthToken adds an authorization token to the request func WithAuthToken(token string) Option { return func(c model.WebContext) { c.Request().Header.Add(model.AuthorizationHeader, model.AuthorizationTokenType+" "+token) } } func WithAccount(account *model.AccountDTO) Option { return func(c model.WebContext) { c.SetAccount(account) } } // WithFakeAccount adds a fake account to the request context func WithFakeAccount(isAdmin bool) Option { return func(c model.WebContext) { c.SetAccount(FakeAccount(isAdmin)) } } // WithRequestPathValue adds a path value to the request func WithRequestPathValue(key, value string) Option { return func(c model.WebContext) { c.Request().SetPathValue(key, value) } } // WithRequestQueryParam adds a query parameter to the request func WithRequestQueryParam(key, value string) Option { return func(c model.WebContext) { q := c.Request().URL.Query() q.Add(key, value) c.Request().URL.RawQuery = q.Encode() } } // PerformRequest executes a request against a handler func PerformRequest(deps model.Dependencies, handler model.HttpHandler, method, path string, options ...Option) *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest(method, path, nil) c := webcontext.NewWebContext(w, r) for _, opt := range options { opt(c) } handler(deps, c) return w } // PerformRequestOnRecorder executes a request against a handler and returns the response recorder func PerformRequestOnRecorder(deps model.Dependencies, w *httptest.ResponseRecorder, handler model.HttpHandler, method, path string, options ...Option) { r := httptest.NewRequest(method, path, nil) c := webcontext.NewWebContext(w, r) for _, opt := range options { opt(c) } handler(deps, c) } // FakeAccount creates a fake account for testing func FakeAccount(isAdmin bool) *model.AccountDTO { return &model.AccountDTO{ ID: 1, Username: "user", Owner: model.Ptr(isAdmin), } } // SetFakeUser sets a fake user account in the WebContext func SetFakeUser(c model.WebContext) { c.SetAccount(&model.AccountDTO{ ID: 1, Username: "user", Owner: model.Ptr(false), }) } // SetFakeAdmin sets a fake admin account in the WebContext func SetFakeAdmin(c model.WebContext) { c.SetAccount(&model.AccountDTO{ ID: 1, Username: "user", Owner: model.Ptr(true), }) } // WithFakeUser returns an Option that sets a fake user account func WithFakeUser() Option { return WithFakeAccount(false) } // WithFakeAdmin returns an Option that sets a fake admin account func WithFakeAdmin() Option { return WithFakeAccount(true) } // SetRequestPathValue sets a path value for the request func SetRequestPathValue(c model.WebContext, key, value string) { c.Request().SetPathValue(key, value) } ================================================ FILE: internal/testutil/response.go ================================================ package testutil import ( "encoding/json" "net/http/httptest" "testing" "github.com/go-shiori/shiori/internal/http/response" "github.com/stretchr/testify/require" ) type testResponse struct { Response response.Response } func (r *testResponse) AssertMessageIsEmptyList(t *testing.T) { var jsonData []any err := json.Unmarshal(r.Response.GetData().([]byte), &jsonData) require.NoError(t, err) require.Equal(t, []any{}, jsonData) } func (r *testResponse) AssertMessageIsNotEmptyList(t *testing.T) { var jsonData []any err := json.Unmarshal(r.Response.GetData().([]byte), &jsonData) require.NoError(t, err) require.Greater(t, len(jsonData), 0) } func (r *testResponse) AssertMessageIsListLength(t *testing.T, length int) { var jsonData []any err := json.Unmarshal(r.Response.GetData().([]byte), &jsonData) require.NoError(t, err) require.Len(t, jsonData, length) } // ForEach iterates over the items in the response and calls the provided function // with each item. func (r *testResponse) ForEach(t *testing.T, fn func(item map[string]any)) { var jsonData []any err := json.Unmarshal(r.Response.GetData().([]byte), &jsonData) require.NoError(t, err) for _, item := range jsonData { fn(item.(map[string]any)) } } func (r *testResponse) AssertNilMessage(t *testing.T) { require.Equal(t, nil, r.Response.GetData()) } func (r testResponse) AssertMessageEquals(t *testing.T, expected any) { require.Equal(t, expected, r.Response.GetData()) } func (r testResponse) AssertMessageJSONContains(t *testing.T, expected string) { require.JSONEq(t, expected, string(r.Response.GetData().([]byte))) } // AssertMessageJSONContainsKey asserts that the response message contains a key // and returns the value of the key to be used in other comparisons depending on the // value type. func (r testResponse) AssertMessageJSONContainsKey(t *testing.T, key string) any { var jsonData map[string]any err := json.Unmarshal(r.Response.GetData().([]byte), &jsonData) require.NoError(t, err) require.Contains(t, jsonData, key) return jsonData[key] } // AssertMessageJSONKeyValue asserts that the response message contains a key // and calls the provided function with the value of the key to be used in other // comparisons depending on the value type. func (r *testResponse) AssertMessageJSONKeyValue(t *testing.T, key string, valueAssertFunc func(t *testing.T, value any)) { value := r.AssertMessageJSONContainsKey(t, key) valueAssertFunc(t, value) } func (r *testResponse) AssertMessageContains(t *testing.T, expected string) { require.Contains(t, r.Response.GetData(), expected) } func (r *testResponse) AssertMessageIsBytes(t *testing.T, expected []byte) { require.Equal(t, expected, r.Response.GetData().([]byte)) } func (r *testResponse) AssertOk(t *testing.T) { require.False(t, r.Response.IsError()) } func (r *testResponse) AssertNotOk(t *testing.T) { require.True(t, r.Response.IsError()) } func NewTestResponseFromRecorder(w *httptest.ResponseRecorder) *testResponse { return &testResponse{Response: *response.NewResponse(w.Body.Bytes(), w.Code)} } ================================================ FILE: internal/testutil/shiori.go ================================================ package testutil import ( "context" "os" "testing" "github.com/go-shiori/shiori/internal/config" "github.com/go-shiori/shiori/internal/database" "github.com/go-shiori/shiori/internal/dependencies" "github.com/go-shiori/shiori/internal/domains" "github.com/go-shiori/shiori/internal/model" "github.com/gofrs/uuid/v5" "github.com/sirupsen/logrus" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) func GetTestConfigurationAndDependencies(t *testing.T, ctx context.Context, logger *logrus.Logger) (*config.Config, *dependencies.Dependencies) { t.Helper() tmp, err := os.CreateTemp("", "") require.NoError(t, err) t.Cleanup(func() { os.Remove(tmp.Name()) }) cfg := config.ParseServerConfiguration(ctx, logger) cfg.Http.SecretKey = []byte("test") tmpDir, err := os.MkdirTemp("", "") require.NoError(t, err) db, err := database.OpenSQLiteDatabase(ctx, tmp.Name()) require.NoError(t, err) require.NoError(t, db.Migrate(context.TODO())) cfg.Storage.DataDir = tmpDir deps := dependencies.NewDependencies(logger, db, cfg) deps.Domains().SetAccounts(domains.NewAccountsDomain(deps)) deps.Domains().SetArchiver(domains.NewArchiverDomain(deps)) deps.Domains().SetAuth(domains.NewAuthDomain(deps)) deps.Domains().SetBookmarks(domains.NewBookmarksDomain(deps)) deps.Domains().SetStorage(domains.NewStorageDomain(deps, afero.NewBasePathFs(afero.NewOsFs(), cfg.Storage.DataDir))) deps.Domains().SetTags(domains.NewTagsDomain(deps)) return cfg, deps } func GetValidBookmark() *model.BookmarkDTO { uuidV4, _ := uuid.NewV4() return &model.BookmarkDTO{ URL: "https://github.com/go-shiori/shiori#" + uuidV4.String(), Title: "Shiori repository", } } // GetValidAccount returns a valid account for testing // It includes an ID to properly use the account when testing authentication methods // without interacting with the database. func GetValidAccount() *model.Account { return &model.Account{ ID: 99, Username: "test", Password: "test", } } ================================================ FILE: internal/view/404.html ================================================ Shiori Not found ================================================ FILE: internal/view/archive.html ================================================ $$.Book.Title$$
View Original $$if .Book.HasContent$$ View Readable $$end$$
================================================ FILE: internal/view/assets/css/archive.css ================================================ :root{--main:#f44336;--border:#e5e5e5;--colorLink:#999;--archiveHeaderBg:rgba(255, 255, 255, 0.95)}@media (prefers-color-scheme:dark){:root{--border:#191919;--archiveHeaderBg:rgba(41, 41, 41, 0.95)}}body{padding:0;margin:0}*{box-sizing:border-box}body.archive{display:grid;grid-template-rows:minmax(1px,auto) 1fr;height:100dvh;width:100%}body.archive .header{display:flex;flex-flow:row wrap;height:60px;box-sizing:border-box;padding:0 16px;align-items:center;font-size:16px;border-bottom:1px solid var(--border);background-color:var(--archiveHeaderBg);grid-row:1}body.archive .header *{border-width:0;box-sizing:border-box;font-family:"Source Sans Pro",sans-serif;margin:0;padding:0}body.archive .header>:not(:last-child){margin-right:8px}body.archive .header>.spacer{flex:1}body.archive .header #shiori-logo{font-size:2em;font-weight:100;color:var(--main)}body.archive .header #shiori-logo span{margin-right:8px}body.archive .header a{display:block;color:var(--colorLink);text-decoration:underline}body.archive .header a:focus,body.archive .header a:hover{color:var(--main)}@media (max-width:600px){body.archive .header{font-size:14px;height:50px}body.archive .header #shiori-logo{font-size:1.5em}}body.archive iframe{width:100%;height:100%;border:none;grid-row:2} ================================================ FILE: internal/view/assets/css/style.css ================================================ @font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:200;src:local('Source Sans Pro ExtraLight'),local('SourceSansPro-ExtraLight'),url(libs/fonts/source-sans-pro-v13-latin-200.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-200.woff) format('woff')}@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:400;src:local('Source Sans Pro Regular'),local('SourceSansPro-Regular'),url(libs/fonts/source-sans-pro-v13-latin-regular.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-regular.woff) format('woff')}@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:600;src:local('Source Sans Pro SemiBold'),local('SourceSansPro-SemiBold'),url(libs/fonts/source-sans-pro-v13-latin-600.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-600.woff) format('woff')}@font-face{font-family:'Source Sans Pro';font-style:normal;font-weight:700;src:local('Source Sans Pro Bold'),local('SourceSansPro-Bold'),url(libs/fonts/source-sans-pro-v13-latin-700.woff2) format('woff2'),url(libs/fonts/source-sans-pro-v13-latin-700.woff) format('woff')}.fa,.fab,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{animation:fa-spin 2s infinite linear}.fa-pulse{animation:fa-spin 1s infinite steps(8)}@keyframes fa-spin{0%{transform:rotate(0)}to{transform:rotate(1turn)}}.fa-rotate-90{transform:rotate(90deg)}.fa-rotate-180{transform:rotate(180deg)}.fa-rotate-270{transform:rotate(270deg)}.fa-flip-horizontal{transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adobe:before{content:"\f778"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-balance-scale:before{content:"\f24e"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-handshake:before{content:"\f2b5"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-wizard:before{content:"\f6e8"}.fa-haykal:before{content:"\f666"}.fa-hdd:before{content:"\f0a0"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-nintendo-switch:before{content:"\f418"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-volume:before{content:"\f2a0"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:auto;src:url(libs/fonts/fa-brands-400.eot);src:url(libs/fonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(libs/fonts/fa-brands-400.woff2) format("woff2"),url(libs/fonts/fa-brands-400.woff) format("woff"),url(libs/fonts/fa-brands-400.ttf) format("truetype"),url(libs/fonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:auto;src:url(libs/fonts/fa-regular-400.eot);src:url(libs/fonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(libs/fonts/fa-regular-400.woff2) format("woff2"),url(libs/fonts/fa-regular-400.woff) format("woff"),url(libs/fonts/fa-regular-400.ttf) format("truetype"),url(libs/fonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url(libs/fonts/fa-solid-900.eot);src:url(libs/fonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(libs/fonts/fa-solid-900.woff2) format("woff2"),url(libs/fonts/fa-solid-900.woff) format("woff"),url(libs/fonts/fa-solid-900.ttf) format("truetype"),url(libs/fonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900}:root{--colorLink:#999;--colorSidebar:#fff;--errorColor:#f44336;--main:#f44336;--sidebarBg:#292929;--sidebarHoverBg:#232323;--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}.night-colors{--bg:#1f1f1f;--border:#191919;--color:#fff;--contentBg:#292929;--headerBg:#292929;--selectedBg:#261918;--bgqoute:#1f1f1f5e}.light-colors{--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}body.dark{--bg:#1f1f1f;--border:#191919;--color:#fff;--contentBg:#292929;--headerBg:#292929;--selectedBg:#261918;--bgqoute:#1f1f1f5e}body.light{--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}@media (prefers-color-scheme:dark){:root{--bg:#1f1f1f;--border:#191919;--color:#fff;--contentBg:#292929;--headerBg:#292929;--selectedBg:#261918;--bgqoute:#1f1f1f5e}}@media (prefers-color-scheme:light){:root{--bg:#eee;--border:#e5e5e5;--color:#232323;--contentBg:#fff;--headerBg:#fff;--selectedBg:#ffe7e5;--bgqoute:#eee}}select{background:var(--contentBg);border:1px solid var(--border);border-radius:4px;color:var(--color)}.login-footer{color:var(--color)}.content-footer{width:100%;padding:20px;max-width:840px;margin-bottom:16px;background-color:var(--contentBg);border:1px solid var(--border);display:flex;flex-flow:column;align-items:center}@media (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){.content-footer{padding-bottom:calc(20px + env(safe-area-inset-bottom))}}.metadata{display:flex;flex-flow:row wrap;text-align:center;font-size:16px;color:var(--colorLink)}.metadata:first-child{justify-content:flex-start}.metadata:nth-child(2){justify-content:flex-end}.metadata[v-cloak]{visibility:hidden}.links{display:flex;flex-flow:row wrap}.links a{padding:0 4px;color:var(--color);text-decoration:underline}.links a:focus,.links a:hover{color:var(--main)}*{border-width:0;box-sizing:border-box;font-family:"Source Sans Pro",sans-serif;margin:0;padding:0;text-decoration:none}body{background-color:var(--bg)}a{cursor:pointer}.spacer{flex:1}#login-scene{height:100%;height:100dvh;padding:16px;overflow:auto;display:flex;align-items:center;flex-flow:column nowrap;background-color:var(--bg)}#login-scene>.error-message{width:100%;max-width:400px;font-size:1em;background-color:var(--contentBg);border:1px solid var(--border);padding:16px;margin-top:auto;margin-bottom:16px;text-align:center;color:var(--errorColor)}#login-scene #login-box{width:100%;max-width:400px;margin-bottom:auto;background-color:var(--contentBg);display:flex;flex-flow:column nowrap;border:1px solid var(--border);flex-shrink:0}#login-scene #login-box:first-child{margin-top:auto}#login-scene #login-box #logo-area{display:flex;align-items:center;flex-flow:column nowrap;padding:16px;background-color:var(--main);border-bottom:1px solid var(--border);flex-shrink:0}#login-scene #login-box #logo-area #logo{font-size:3em;font-weight:100;color:var(--contentBg)}#login-scene #login-box #logo-area #logo span{margin-right:8px}#login-scene #login-box #logo-area #tagline{font-weight:500;margin-top:4px;color:var(--contentBg);text-align:center}#login-scene #login-box #input-area{padding:16px;display:grid;grid-gap:16px;grid-template-columns:auto 1fr;justify-content:baseline;align-items:center;border-bottom:1px solid var(--border)}#login-scene #login-box #input-area>label{color:var(--color)}#login-scene #login-box #input-area>input{color:var(--color);padding:8px;background-color:var(--contentBg);border:1px solid var(--border);min-width:0;font-size:1em}#login-scene #login-box #input-area .checkbox-field{grid-column:1/span 2;display:flex;flex-flow:row nowrap;align-items:center;justify-content:center;cursor:pointer}#login-scene #login-box #input-area .checkbox-field:focus,#login-scene #login-box #input-area .checkbox-field:hover{text-decoration:underline;text-decoration-color:var(--main)}#login-scene #login-box #input-area .checkbox-field>input[type=checkbox]{margin-right:8px}#login-scene #login-box #button-area{display:flex;flex-flow:row nowrap;padding:16px;justify-content:center}#login-scene #login-box #button-area a{color:var(--color);text-transform:uppercase;text-align:center;font-weight:600;cursor:default}#login-scene #login-box #button-area a.button{cursor:pointer}#login-scene #login-box #button-area a.button:focus,#login-scene #login-box #button-area a.button:hover{color:var(--main)}#main-scene{min-height:100%;min-height:100dvh;padding-top:60px;padding-left:60px;background-color:var(--bg)}@media (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){#main-scene{padding-bottom:env(safe-area-inset-bottom)}}#main-scene #main-sidebar{top:0;left:0;width:60px;height:100%;height:100dvh;position:fixed;display:flex;flex-flow:column nowrap;background-color:var(--sidebarBg);z-index:1}#main-scene #main-sidebar a{flex-shrink:0;display:block;width:60px;line-height:60px;text-align:center;font-size:1em;color:var(--colorSidebar)}#main-scene #main-sidebar a.active{cursor:default;color:var(--colorSidebar);background-color:var(--main)}#main-scene #main-sidebar a:focus,#main-scene #main-sidebar a:hover{color:var(--main);background-color:var(--sidebarHoverBg)}#main-scene .page-header{top:0;left:60px;right:0;height:60px;position:fixed;color:var(--color);background-color:var(--headerBg);border-bottom:1px solid var(--border);padding:0 16px;z-index:10}#main-scene h1.page-header{line-height:60px;font-size:1.3em;font-weight:600}#main-scene div.page-header{display:flex;flex-flow:row nowrap;align-items:center}#main-scene div.page-header p{flex:1 0;font-size:1.3em;font-weight:600;line-height:60px;color:var(--color)}#main-scene div.page-header input[type=text]{flex:1 0;min-width:0;margin-right:8px;font-size:1.1em;font-weight:500;line-height:calc(60px - 1px);color:var(--color);background-color:var(--contentBg)}#main-scene div.page-header input[type=text]::placeholder{color:var(--colorLink)}#main-scene div.page-header a{display:block;width:24px;line-height:24px;color:var(--colorLink);text-align:center}#main-scene div.page-header a:not(:last-child){margin-right:8px}#main-scene div.page-header a:hover{color:var(--main)}#main-scene .loading-overlay{display:flex;flex-flow:column nowrap;align-items:center;justify-content:center;overflow:hidden;position:fixed;top:0;left:0;width:100%;width:100dvw;height:100%;height:100dvh;z-index:10001;background-color:rgba(0,0,0,.6)}#main-scene .loading-overlay i{color:var(--colorSidebar);font-size:4em;text-align:center;width:80px;line-height:80px;position:absolute}@media (max-width:600px){#main-scene{padding-top:50px;padding-left:0;padding-bottom:50px}#main-scene #main-sidebar{top:auto;right:0;bottom:0;width:100%;width:100dvw;height:50px;flex-flow:row nowrap;border-top:1px solid var(--border)}#main-scene #main-sidebar .spacer{display:none}#main-scene #main-sidebar a{width:auto;flex:1 0;line-height:50px}#main-scene #main-sidebar a:focus,#main-scene #main-sidebar a:hover{color:var(--colorSidebar);background-color:var(--main)}#main-scene .page-header{left:0;height:50px}#main-scene h1.page-header{text-align:center;font-size:1em;line-height:50px;text-transform:uppercase}#main-scene div.page-header{flex-flow:row wrap}#main-scene div.page-header p{flex:1 0;font-size:1em;font-weight:500;line-height:3em;padding:0}#main-scene div.page-header input[type=text]{flex:1 0;font-size:1em;font-weight:500;line-height:3em}#main-scene div.page-header a{display:block;width:24px;line-height:100%}}@media (max-width:600px) and (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){#main-scene #main-sidebar{height:calc(50px + 20px)}}#content-scene{padding:20px;display:flex;color:var(--color);background-color:var(--bg);flex-flow:column nowrap;align-items:center}#content-scene #header{width:100%;padding:20px;max-width:840px;margin-bottom:16px;background-color:var(--contentBg);border:1px solid var(--border);display:flex;flex-flow:column;align-items:center}#content-scene #header #title{padding:8px 0;grid-column-start:1;grid-column-end:-1;font-size:36px;font-weight:700;word-break:break-word;hyphens:none;text-align:center}#content-scene #content{width:100%;padding:20px;max-width:840px;background-color:var(--contentBg);border:1px solid var(--border)}#content-scene #content *{font-size:18px;line-height:180%}#content-scene #content :not(:last-child){margin-bottom:20px}#content-scene #content a{color:var(--color);text-decoration:underline}#content-scene #content a:focus,#content-scene #content a:hover{color:var(--main)}#content-scene #content code,#content-scene #content pre{overflow:auto;border:1px solid var(--border);font-family:"Ubuntu Mono","Courier New",Courier,monospace;font-size:16px}#content-scene #content pre{padding:8px}#content-scene #content pre>code{border:0}#content-scene #content ol,#content-scene #content ul{padding-left:16px}#content-scene #content img{height:auto;max-width:100%}#content-scene #content table{border:1px solid var(--border);border-collapse:collapse}#content-scene #content table td,#content-scene #content table th,#content-scene #content table tr{border:1px solid var(--border)}#content-scene #content blockquote{margin:15px;padding:15px;font-style:italic;background:var(--bgqoute)}#page-home>.empty-message{max-width:400px;font-size:1em;background-color:var(--contentBg);border:1px solid var(--border);padding:16px;margin:16px;color:var(--errorColor)}#page-home #edit-box{background-color:var(--selectedBg);border-bottom:1px solid var(--main)}#page-home #bookmarks-grid{display:grid;grid-template-rows:min-content;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));grid-gap:16px;padding:16px;overflow:auto}#page-home #bookmarks-grid .bookmark{align-self:start}#page-home #bookmarks-grid .pagination-box{grid-column-end:-1;grid-column-start:1;display:flex;flex-flow:row nowrap;align-self:start}#page-home #bookmarks-grid .pagination-box a{padding:8px;color:var(--colorLink)}#page-home #bookmarks-grid .pagination-box a:focus,#page-home #bookmarks-grid .pagination-box a:hover{color:var(--main)}#page-home #bookmarks-grid .pagination-box input{width:40px;padding:8px;text-align:center;font-size:.9em;color:var(--color);border:1px solid var(--border);background-color:var(--contentBg);margin:0 8px}#page-home #bookmarks-grid .pagination-box p{font-size:.9em;color:var(--colorLink);line-height:37px;font-weight:600}#page-home #bookmarks-grid .pagination-box p:last-of-type::before{content:"/";margin-right:8px}#page-home #bookmarks-grid.list{grid-gap:0;padding-bottom:0;grid-template-columns:auto}#page-home #bookmarks-grid.list .pagination-box{padding:16px 0}#page-home #bookmarks-grid.list .pagination-box:first-child{padding-top:0}@media (max-width:600px){#page-home #bookmarks-grid.list{padding:16px 0 0}#page-home #bookmarks-grid.list .pagination-box{padding:16px}}#page-home #dialog-tags .custom-dialog-body{grid-template-columns:repeat(2,minmax(0,1fr))}@media (max-width:600px){#page-home #dialog-tags .custom-dialog-body{grid-template-columns:minmax(0,1fr)}}#page-home #dialog-tags .custom-dialog-body a{font-size:1em;color:var(--color)}#page-home #dialog-tags .custom-dialog-body a span:last-child{font-size:1em;color:var(--colorLink);margin-left:4px}#page-home #dialog-tags .custom-dialog-body a span:last-child::before{content:"(";margin-right:2px}#page-home #dialog-tags .custom-dialog-body a span:last-child::after{content:")";margin-left:2px}#page-home #dialog-tags .custom-dialog-body a:focus,#page-home #dialog-tags .custom-dialog-body a:hover{color:var(--main)}#page-setting{min-height:0;max-height:100%;display:flex;flex-flow:column nowrap}#page-setting .setting-container{padding:8px;display:flex;overflow:auto;flex-flow:column nowrap;flex:1 0}#page-setting .setting-container::after{content:"";display:block;min-height:1px}#page-setting .setting-container details.setting-group{margin:8px;display:block;max-width:350px;color:var(--color);background-color:var(--contentBg);border:1px solid var(--border)}@media (max-width:600px){#page-setting .setting-container details.setting-group{max-width:100%}}#page-setting .setting-container details.setting-group summary{list-style:none;font-weight:600;width:100%;padding:12px 8px;font-size:1.1em;cursor:pointer}#page-setting .setting-container details.setting-group summary:hover{color:var(--main)}#page-setting .setting-container details.setting-group summary::-webkit-details-marker{display:none}#page-setting .setting-container details.setting-group summary::after{content:"+";margin-left:8px;font-weight:600}#page-setting .setting-container details.setting-group[open] summary{border-bottom:1px solid var(--border)}#page-setting .setting-container details.setting-group[open] summary::after{content:"-"!important}#page-setting .setting-container details.setting-group ul{list-style:none}#page-setting .setting-container details.setting-group ul li{padding:4px 8px;color:var(--color);display:flex;flex-flow:row nowrap;align-items:center}#page-setting .setting-container details.setting-group div.setting-group-footer{padding:4px 8px;display:flex;flex-flow:column nowrap;align-items:flex-end;border-top:1px solid var(--border)}#page-setting .setting-container details.setting-group div.setting-group-footer>a{text-transform:uppercase;padding:8px 4px;font-size:.9em;font-weight:600}#page-setting .setting-container details.setting-group div.setting-group-footer>a:hover{color:var(--main)}#page-setting .setting-container details.setting-group div.setting-group-footer>a:focus{outline:0;color:var(--main);border-bottom:1px dashed var(--main)}#page-setting #setting-bookmarks,#page-setting #setting-display{display:flex;flex-flow:column nowrap}#page-setting #setting-bookmarks[open],#page-setting #setting-display[open]{padding-bottom:8px}#page-setting #setting-bookmarks[open] summary,#page-setting #setting-display[open] summary{margin-bottom:8px}#page-setting #setting-bookmarks label,#page-setting #setting-display label{padding:4px 8px;color:var(--color);display:flex;flex-flow:row nowrap;align-items:center;cursor:pointer}#page-setting #setting-bookmarks label:focus,#page-setting #setting-bookmarks label:hover,#page-setting #setting-display label:focus,#page-setting #setting-display label:hover{text-decoration:underline;text-decoration-color:var(--main)}#page-setting #setting-bookmarks label>input[type=checkbox],#page-setting #setting-display label>input[type=checkbox]{margin-right:8px}#page-setting .setting-accounts summary{margin-bottom:0}#page-setting .setting-accounts ul{list-style:none}#page-setting .setting-accounts ul li{padding:8px;display:flex;flex-flow:row nowrap;align-items:center}#page-setting .setting-accounts ul li:not(:last-child){border-bottom:1px solid var(--border)}#page-setting .setting-accounts ul li p{font-size:1em;color:var(--color);flex:1 0}#page-setting .setting-accounts ul li p span{color:var(--colorLink)}#page-setting .setting-accounts ul li a{margin-left:8px;color:var(--colorLink)}#page-setting .setting-accounts ul li a:hover{color:var(--main)}#page-setting #setting-system-info ul{padding-top:4px;padding-bottom:4px}#page-setting #setting-system-info ul li span{margin-left:8px}:root{--dialogHeaderBg:#292929;--colorDialogHeader:#fff}.custom-dialog-overlay{display:flex;flex-flow:column nowrap;align-items:center;justify-content:center;min-width:0;min-height:0;overflow:hidden;position:fixed;top:0;left:0;width:100%;width:100dvw;height:100%;height:100dvh;z-index:10001;background-color:rgba(0,0,0,.6);padding:20px}.custom-dialog-overlay .custom-dialog{display:flex;flex-flow:column nowrap;min-height:0;max-height:100%;max-width:100%;width:400px;overflow:auto;background-color:var(--contentBg);font-size:16px;resize:both}.custom-dialog-overlay .custom-dialog .custom-dialog-header{padding:16px;color:var(--colorDialogHeader);background-color:var(--dialogHeaderBg);font-weight:600;font-size:1em;text-transform:uppercase;border-bottom:1px solid var(--border)}.custom-dialog-overlay .custom-dialog .custom-dialog-body{padding:16px 16px 0;display:grid;max-height:100%;min-height:80px;min-width:0;overflow:auto;font-size:1em;grid-template-columns:max-content 1fr;align-content:start;align-items:baseline;grid-gap:16px;flex-grow:1}.custom-dialog-overlay .custom-dialog .custom-dialog-body::after{content:"";display:block;min-height:1px;grid-column-end:-1;grid-column-start:1}.custom-dialog-overlay .custom-dialog .custom-dialog-body .custom-dialog-content{grid-column-end:-1;grid-column-start:1;color:var(--color);align-self:baseline}.custom-dialog-overlay .custom-dialog .custom-dialog-body>label{color:var(--color);padding:8px 0;font-size:1em}.custom-dialog-overlay .custom-dialog .custom-dialog-body>input[type=password],.custom-dialog-overlay .custom-dialog .custom-dialog-body>input[type=text],.custom-dialog-overlay .custom-dialog .custom-dialog-body>textarea{color:var(--color);padding:8px;font-size:1em;border:1px solid var(--border);background-color:var(--contentBg);min-width:0}.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field{color:var(--color);font-size:1em;display:flex;flex-flow:row nowrap;padding:0;grid-column-start:1;grid-column-end:-1;cursor:pointer}.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field:focus,.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field:hover{text-decoration:underline;text-decoration-color:var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-body .checkbox-field>input[type=checkbox]{margin-right:8px}.custom-dialog-overlay .custom-dialog .custom-dialog-body>textarea{height:6em;min-height:37px;resize:vertical}.custom-dialog-overlay .custom-dialog .custom-dialog-body>.suggestion{position:absolute;display:block;padding:8px;background-color:var(--contentBg);border:1px solid var(--border);color:var(--color);font-size:.9em}.custom-dialog-overlay .custom-dialog .custom-dialog-footer{padding:16px;display:flex;flex-flow:row wrap;justify-content:flex-end;border-top:1px solid var(--border)}@media (display-mode:standalone),(display-mode:fullscreen),(display-mode:minimal-ui){.custom-dialog-overlay .custom-dialog .custom-dialog-footer{padding-bottom:calc(16px + env(safe-area-inset-bottom))}}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a{padding:0 8px;font-size:.9em;font-weight:600;color:var(--color);text-transform:uppercase}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:focus,.custom-dialog-overlay .custom-dialog .custom-dialog-footer>a:hover{outline:0;color:var(--main)}.custom-dialog-overlay .custom-dialog .custom-dialog-footer>i.fa-spinner.fa-spin{width:19px;line-height:19px;text-align:center;color:var(--color)}@media only screen and (max-width:768px){.custom-dialog-overlay{padding:0}.custom-dialog{width:100%!important;height:100%!important;resize:none!important}}.bookmark{display:flex;flex-flow:column nowrap;min-width:0;border:1px solid var(--border);background-color:var(--contentBg);height:100%;position:relative}.bookmark:focus .bookmark-menu>a,.bookmark:hover .bookmark-menu>a{display:block}.bookmark.selected{background-color:var(--selectedBg)}.bookmark .bookmark-selector{position:absolute;top:0;left:0;width:100%;height:100%;z-index:9}.bookmark .bookmark-link{display:block;cursor:default}.bookmark .bookmark-link[href]{cursor:pointer}.bookmark .bookmark-link[href]:focus .title,.bookmark .bookmark-link[href]:hover .title{color:var(--main)}.bookmark .bookmark-link span.thumbnail{width:100%;height:200px;display:block;background-size:cover;background-repeat:no-repeat;background-position:center center;margin-bottom:8px;border-bottom:1px solid var(--border)}.bookmark .bookmark-link .id{color:var(--color);border:1px solid var(--border);background-color:var(--contentBg);font-size:.7em;font-weight:700;left:-1px;top:-1px;position:absolute;padding:0 .3em;opacity:.7}.bookmark .bookmark-link .title{text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:1.2em;line-height:1.3em;max-height:5.2em;font-weight:600;padding:0 16px;color:var(--color)}.bookmark .bookmark-link .title:first-child{margin-top:16px}.bookmark .bookmark-link .title i{color:var(--colorLink);margin-left:4px;font-size:14px}.bookmark .bookmark-link .excerpt{color:var(--color);margin-top:8px;padding:0 16px;text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:.9em;line-height:1.5em;max-height:10.5em}.bookmark .bookmark-tags{display:flex;flex-flow:row wrap;margin:8px 0 -4px;padding:0 8px}.bookmark .bookmark-tags a{margin:4px;padding:4px 8px;font-size:.8em;font-weight:600;border:1px solid var(--border);border-radius:4px;color:var(--colorLink);background-color:var(--contentBg)}.bookmark .bookmark-tags a:focus,.bookmark .bookmark-tags a:hover{color:var(--main)}.bookmark .bookmark-menu{padding:8px 16px 16px;display:flex;flex-flow:row nowrap;min-width:0;min-height:0;align-items:center}.bookmark .bookmark-menu a{color:var(--colorLink);flex-shrink:0;opacity:.8;display:none;font-size:.9em}.bookmark .bookmark-menu a:not(:last-child){margin-right:12px}.bookmark .bookmark-menu a:focus,.bookmark .bookmark-menu a:hover{color:var(--main);opacity:1}.bookmark .bookmark-menu .url{flex:1 0;opacity:1;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:21px}.bookmark .bookmark-menu .url:not([href]){cursor:default;color:var(--colorLink)}@media (max-width:1024px){.bookmark .bookmark-menu a{display:block}}.bookmark.list{border-top-width:0;border-bottom-width:1px;padding:16px 24px 16px 100px}.bookmark.list:first-child{border-top-width:1px}.bookmark.list .bookmark-link span.thumbnail{position:absolute;top:0;left:0;width:100px;height:100%;margin-bottom:0;border-bottom:0;border-right:1px solid var(--border)}.bookmark.list .bookmark-link .title{margin:0;padding-left:24px}.bookmark.list .excerpt,.bookmark.list>.spacer{display:none}.bookmark.list .bookmark-tags{padding-left:16px;padding-right:0}.bookmark.list .bookmark-menu{padding:8px 0 0 24px;align-items:flex-end}.bookmark.list.no-thumbnail{padding-left:16px;padding-right:16px}.bookmark.list.no-thumbnail .bookmark-link .title{padding:0;margin-bottom:4px}.bookmark.list.no-thumbnail .excerpt{margin-top:0;margin-bottom:4px;padding:0;display:block}.bookmark.list.no-thumbnail .bookmark-tags{padding-left:0;margin:0 -4px 0}.bookmark.list.no-thumbnail .bookmark-menu{padding-top:0;padding-left:0}@media (max-width:600px){.bookmark.list{padding:8px 16px 8px 70px;border-width:0!important;border-bottom-width:1px!important}.bookmark.list .bookmark-link span.thumbnail{width:70px}.bookmark.list .bookmark-link .title{font-size:1.1em;font-weight:500;padding-left:16px}.bookmark.list .bookmark-tags{padding-left:8px}.bookmark.list .bookmark-menu{padding-left:16px}} ================================================ FILE: internal/view/assets/js/component/bookmark.js ================================================ var template = ` `; export default { template: template, props: { id: Number, url: String, title: String, excerpt: String, public: Number, imageURL: String, hasContent: Boolean, hasArchive: Boolean, hasEbook: Boolean, modifiedAt: String, index: Number, ShowId: Boolean, editMode: Boolean, ListMode: Boolean, HideThumbnail: Boolean, HideExcerpt: Boolean, selected: Boolean, menuVisible: Boolean, tags: { type: Array, default() { return []; }, }, }, computed: { mainURL() { if (this.hasContent) { return new URL(`bookmark/${this.id}/content`, document.baseURI); } else if (this.hasArchive) { return new URL(`bookmark/${this.id}/archive`, document.baseURI); } else { return this.url; } }, ebookURL() { if (this.hasEbook) { return new URL(`bookmark/${this.id}/ebook`, document.baseURI); } else { return null; } }, hostnameURL() { var url = new URL(this.url); return url.hostname.replace(/^www\./, ""); }, thumbnailVisible() { return this.imageURL !== "" && !this.HideThumbnail; }, excerptVisible() { return this.excerpt !== "" && !this.thumbnailVisible && !this.HideExcerpt; }, thumbnailStyleURL() { return { backgroundImage: `url("${this.imageURL}?modifiedAt=${this.modifiedAt}")`, }; }, eventItem() { return { id: this.id, index: this.index, }; }, }, methods: { tagClicked(name, event) { this.$emit("tag-clicked", name, event); }, selectBookmark() { this.$emit("select", this.eventItem); }, editBookmark() { this.$emit("edit", this.eventItem); }, deleteBookmark() { this.$emit("delete", this.eventItem); }, updateBookmark() { this.$emit("update", this.eventItem); }, downloadebook() { const id = this.id; const ebook_url = new URL(`bookmark/${id}/ebook`, document.baseURI); const downloadLink = document.createElement("a"); downloadLink.href = ebook_url.toString(); downloadLink.download = `${this.title}.epub`; downloadLink.click(); }, }, }; ================================================ FILE: internal/view/assets/js/component/dialog.js ================================================ var template = `

{{title}}

{{content}}

`; export default { template: template, props: { title: String, loading: Boolean, visible: Boolean, content: { type: String, default: "", }, fields: { type: Array, default() { return []; }, }, showLabel: { type: Boolean, default: false, }, mainText: { type: String, default: "OK", }, secondText: String, mainClick: { type: Function, default() { this.visible = false; }, }, secondClick: { type: Function, default() { this.visible = false; }, }, escPressed: { type: Function, default() { this.visible = false; }, }, }, data() { return { formFields: [], }; }, computed: { btnTabIndex() { return this.fields.length + 1; }, }, watch: { fields: { immediate: true, handler() { this.formFields = this.fields.map((field) => { if (typeof field === "string") return { name: field, label: field, value: "", type: "text", dictionary: [], separator: " ", suggestion: undefined, }; if (typeof field === "object") return { name: field.name || "", label: field.label || "", value: field.value || "", type: field.type || "text", dictionary: field.dictionary instanceof Array ? field.dictionary : [], separator: field.separator || " ", suggestion: undefined, }; }); }, }, "fields.length"() { this.focus(); }, visible: { immediate: true, handler() { this.focus(); }, }, }, methods: { fieldType(f) { var type = f.type || "text"; if (type !== "text" && type !== "password") return "text"; else return type; }, handleMainClick() { var data = {}; this.formFields.forEach((field) => { var value = field.value; if (field.type === "number") value = parseInt(value, 10) || 0; else if (field.type === "float") value = parseFloat(value) || 0.0; else if (field.type === "check") value = Boolean(value); data[field.name] = value; }); this.mainClick(data); }, handleSecondClick() { this.secondClick(); }, handleEscPressed() { this.escPressed(); }, handleInput(index) { // Create initial variable var field = this.formFields[index], dictionary = field.dictionary; // Make sure dictionary is not empty if (dictionary.length === 0) return; // Fetch suggestion from dictionary var words = field.value.split(field.separator), lastWord = words[words.length - 1].toLowerCase(), suggestion; if (lastWord !== "") { suggestion = dictionary.find((word) => { return word.toLowerCase().startsWith(lastWord); }); } this.formFields[index].suggestion = suggestion; // Make sure suggestion exist if (suggestion == null) return; // Display suggestion this.$nextTick(() => { var input = this.$refs.input[index], suggestionNode = this.$refs["suggestion-" + index][0], inputRect = input.getBoundingClientRect(); suggestionNode.style.top = inputRect.bottom - 1 + "px"; suggestionNode.style.left = inputRect.left + "px"; }); }, handleInputEnter(index) { var suggestion = this.formFields[index].suggestion; if (suggestion == null) { this.handleMainClick(); return; } var separator = this.formFields[index].separator, words = this.formFields[index].value.split(separator); words.pop(); words.push(suggestion); this.formFields[index].value = words.join(separator) + separator; this.formFields[index].suggestion = undefined; // Focus input again after suggestion is accepted this.$refs.input[index].focus(); }, focus() { this.$nextTick(() => { if (!this.visible) return; var fields = this.$refs.input, otherInput = this.$el.querySelectorAll("input"), button = this.$refs.mainButton; if (fields && fields.length > 0) { this.$refs.input[0].focus(); this.$refs.input[0].select(); } else if (otherInput && otherInput.length > 0) { otherInput[0].focus(); otherInput[0].select(); } else if (button) { button.focus(); } }); }, }, }; ================================================ FILE: internal/view/assets/js/component/eventBus.js ================================================ // Create a new Vue instance as the EventBus const EventBus = new Vue(); export default EventBus; ================================================ FILE: internal/view/assets/js/component/login.js ================================================ import { apiRequest } from "../utils/api.js"; const template = `

{{error}}

simple bookmark manager

`; export default { name: "login-view", template, data() { return { error: "", loading: false, username: "", password: "", remember: false, destination: "/", // Default destination }; }, emits: ["login-success"], methods: { sanitizeDestination(dst) { try { // Remove any leading/trailing whitespace dst = dst.trim(); // Decode the URL to handle any encoded characters dst = decodeURIComponent(dst); // Create a URL object to parse the destination const url = new URL(dst, window.location.origin); // Only allow paths from the same origin if (url.origin !== window.location.origin) { return "/"; } // Only return the pathname and search params return url.pathname + url.search + url.hash; } catch (e) { // If any error occurs during parsing, return root return "/"; } }, parseJWT(token) { try { return JSON.parse(atob(token.split(".")[1])); } catch (e) { return null; } }, async login() { // Get values directly from the form const usernameInput = document.querySelector("#username"); const passwordInput = document.querySelector("#password"); this.username = usernameInput ? usernameInput.value : this.username; this.password = passwordInput ? passwordInput.value : this.password; // Validate input if (this.username === "") { this.error = "Username must not empty"; return; } // Remove old cookie document.cookie = `token=; Path=${ new URL(document.baseURI).pathname }; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`; // Send request this.loading = true; try { const json = await apiRequest( new URL("api/v1/auth/login", document.baseURI), { method: "post", body: JSON.stringify({ username: this.username, password: this.password, remember_me: this.remember == 1 ? true : false, }), }, ); // Save session id document.cookie = `token=${json.token}; Path=${ new URL(document.baseURI).pathname }; Expires=${new Date(json.expires * 1000).toUTCString()}`; // Save account data localStorage.setItem("shiori-token", json.token); localStorage.setItem( "shiori-account", JSON.stringify(this.parseJWT(json.token).account), ); this.visible = false; this.$emit("login-success"); // Redirect to sanitized destination if (this.destination !== "/") window.location.href = this.destination; } catch (err) { this.error = err.message; } finally { this.loading = false; } }, async checkSession() { const token = localStorage.getItem("shiori-token"); if (!token) return false; try { await apiRequest(new URL("api/v1/auth/me", document.baseURI)); return true; } catch (err) { return false; } }, }, async mounted() { // Get and sanitize destination from URL parameters const urlParams = new URLSearchParams(window.location.search); const dst = urlParams.get("dst"); this.destination = dst ? this.sanitizeDestination(dst) : "/"; // Check if there's a valid session if (await this.checkSession()) { this.$emit("login-success"); return; } // Clear session data if we reach here document.cookie = `token=; Path=${ new URL(document.baseURI).pathname }; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`; localStorage.removeItem("shiori-account"); localStorage.removeItem("shiori-token"); // Focus username input this.$nextTick(() => { const usernameInput = document.querySelector("#username"); if (usernameInput) { usernameInput.focus(); } }); }, }; ================================================ FILE: internal/view/assets/js/component/pagination.js ================================================ var template = `

Page

{{maxPage}}

`; export default { template: template, props: { page: Number, maxPage: Number, editMode: Boolean, }, methods: { changePage(page) { page = parseInt(page, 10) || 0; if (page >= this.maxPage) page = this.maxPage; else if (page <= 1) page = 1; this.$emit("change", page); }, }, }; ================================================ FILE: internal/view/assets/js/page/base.js ================================================ import { apiRequest } from "../utils/api.js"; export default { props: { activeAccount: { type: Object, default() { return { id: 0, username: "", owner: false, }; }, }, appOptions: { type: Object, default() { return { ShowId: false, ListMode: false, HideThumbnail: false, HideExcerpt: false, KeepMetadata: false, UseArchive: false, CreateEbook: false, MakePublic: false, }; }, }, }, data() { return { dialog: {}, }; }, methods: { defaultDialog() { return { visible: false, loading: false, title: "", content: "", fields: [], showLabel: false, mainText: "Yes", secondText: "", mainClick: () => { this.dialog.visible = false; }, secondClick: () => { this.dialog.visible = false; }, escPressed: () => { if (!this.loading) this.dialog.visible = false; }, }; }, showDialog(opt) { this.dialog = { visible: true, ...opt, }; }, async getErrorMessage(err) { switch (err.constructor) { case Error: return err.message; case Response: var text = await err.text(); // Handle new error messages if (text[0] == "{") { var json = JSON.parse(text); return json.error; } return `${text} (${err.status})`; default: return err; } }, isSessionError(err) { switch ( err .toString() .replace(/\(\d+\)/g, "") .trim() .toLowerCase() ) { case "session is not exist": case "session has been expired": return true; default: return false; } }, themeSwitch(theme) { switch (theme) { case "light": document.body.classList.remove("dark"); document.body.classList.add("light"); break; case "dark": document.body.classList.remove("light"); document.body.classList.add("dark"); break; case "follow": document.body.classList.remove("light", "dark"); break; default: console.error("Invalid theme selected"); } }, showErrorDialog(msg) { this.showDialog({ title: "Error", content: msg, mainText: "OK", mainClick: () => { this.dialog.visible = false; }, escPressed: () => { this.dialog.visible = false; }, }); }, async saveSetting(key, value) { try { await apiRequest(new URL("api/v1/settings", document.baseURI), { method: "PUT", body: JSON.stringify({ [key]: value }), }); } catch (err) { this.showErrorDialog(err.message); } }, async logout() { try { await apiRequest(new URL("api/v1/auth/logout", document.baseURI), { method: "POST", }); // Clear session data document.cookie = `token=; Path=${ new URL(document.baseURI).pathname }; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`; localStorage.removeItem("shiori-account"); localStorage.removeItem("shiori-token"); // Reload page window.location.reload(); } catch (err) { this.showErrorDialog(err.message); } }, }, }; ================================================ FILE: internal/view/assets/js/page/home.js ================================================ var template = `

No saved bookmarks yet :(

(all tagged) (all untagged) #{{tag.name}}{{tag.bookmark_count}}
`; import paginationBox from "../component/pagination.js"; import bookmarkItem from "../component/bookmark.js"; import customDialog from "../component/dialog.js"; import basePage from "./base.js"; import EventBus from "../component/eventBus.js"; import { apiRequest } from "../utils/api.js"; Vue.prototype.$bus = EventBus; export default { template: template, mixins: [basePage], components: { bookmarkItem, paginationBox, customDialog, }, data() { return { loading: false, editMode: false, selection: [], search: "", page: 0, maxPage: 0, bookmarks: [], tags: [], dialogTags: { visible: false, editMode: false, title: "Existing Tags", mainText: "OK", secondText: "Rename Tags", mainClick: () => { if (this.dialogTags.editMode) { this.dialogTags.editMode = false; } else { this.dialogTags.visible = false; } }, secondClick: () => { this.dialogTags.editMode = true; }, escPressed: () => { this.dialogTags.visible = false; this.dialogTags.editMode = false; }, }, }; }, computed: { listIsEmpty() { return this.bookmarks.length <= 0; }, }, watch: { "dialogTags.editMode"(editMode) { if (editMode) { this.dialogTags.title = "Rename Tags"; this.dialogTags.mainText = "Cancel"; this.dialogTags.secondText = ""; } else { this.dialogTags.title = "Existing Tags"; this.dialogTags.mainText = "OK"; this.dialogTags.secondText = "Rename Tags"; } }, }, methods: { clearHomePage() { this.search = ""; this.searchBookmarks(); }, reloadData() { if (this.loading) return; this.page = 1; this.search = ""; this.loadData(true, true); }, async loadData(saveState, fetchTags) { if (this.loading) return; // Set default args saveState = typeof saveState === "boolean" ? saveState : true; fetchTags = typeof fetchTags === "boolean" ? fetchTags : false; // Parse search query var keyword = this.search, rxExcludeTagA = /(^|\s)-tag:["']([^"']+)["']/i, // -tag:"with space" rxExcludeTagB = /(^|\s)-tag:(\S+)/i, // -tag:without-space rxIncludeTagA = /(^|\s)tag:["']([^"']+)["']/i, // tag:"with space" rxIncludeTagB = /(^|\s)tag:(\S+)/i, // tag:without-space tags = [], excludedTags = [], rxResult; // Get excluded tag first, while also removing it from keyword while ((rxResult = rxExcludeTagA.exec(keyword))) { keyword = keyword.replace(rxResult[0], ""); excludedTags.push(rxResult[2]); } while ((rxResult = rxExcludeTagB.exec(keyword))) { keyword = keyword.replace(rxResult[0], ""); excludedTags.push(rxResult[2]); } // Get included tags while ((rxResult = rxIncludeTagA.exec(keyword))) { keyword = keyword.replace(rxResult[0], ""); tags.push(rxResult[2]); } while ((rxResult = rxIncludeTagB.exec(keyword))) { keyword = keyword.replace(rxResult[0], ""); tags.push(rxResult[2]); } // Trim keyword keyword = keyword.trim().replace(/\s+/g, " "); // Prepare URL for API var url = new URL("api/bookmarks", document.baseURI); url.search = new URLSearchParams({ keyword: keyword, tags: tags.join(","), exclude: excludedTags.join(","), page: this.page, }); // Fetch data from API var skipFetchTags = Error("skip fetching tags"); this.loading = true; try { const json = await apiRequest(url); // Set data this.page = json.page; this.maxPage = json.maxPage; this.bookmarks = json.bookmarks; // Save state and change URL if needed if (saveState) { var history = { activePage: "page-home", search: this.search, page: this.page, }; var url = new Url(document.baseURI); url.hash = "home"; url.query = new URLSearchParams({ page: this.page, search: this.search, }).toString(); window.history.pushState(history, null, url.toString()); } // Fetch tags if needed if (!fetchTags) throw skipFetchTags; const tagsUrl = new URL("api/tags", document.baseURI); const tagsJson = await apiRequest(tagsUrl); this.tags = tagsJson; } catch (err) { if (err !== skipFetchTags) { this.showErrorDialog(err.message); } } finally { this.loading = false; } }, searchBookmarks() { this.page = 1; this.loadData(); }, changePage(page) { this.page = page; this.$refs.bookmarksGrid.scrollTop = 0; this.loadData(); }, toggleEditMode() { this.selection = []; this.editMode = !this.editMode; }, toggleSelection(item) { var idx = this.selection.findIndex((el) => el.id === item.id); if (idx === -1) this.selection.push(item); else this.selection.splice(idx, 1); }, isSelected(bookId) { return this.selection.findIndex((el) => el.id === bookId) > -1; }, dialogTagClicked(event, tag) { if (!this.dialogTags.editMode) { this.filterTag(tag.name, event.altKey); } else { this.dialogTags.visible = false; this.showDialogRenameTag(tag); } }, bookmarkTagClicked(event, tagName) { this.filterTag(tagName, event.altKey); }, filterTag(tagName, excludeMode) { // Set default parameter excludeMode = typeof excludeMode === "boolean" ? excludeMode : false; if (this.dialogTags.editMode) { return; } if (tagName === "*") { this.search = excludeMode ? "-tag:*" : "tag:*"; this.page = 1; this.loadData(); return; } var rxSpace = /\s+/g, includeTag = rxSpace.test(tagName) ? `tag:"${tagName}"` : `tag:${tagName}`, excludeTag = "-" + includeTag, rxIncludeTag = new RegExp(`(^|\\s)${includeTag}`, "ig"), rxExcludeTag = new RegExp(`(^|\\s)${excludeTag}`, "ig"), search = this.search; search = search.replace("-tag:*", ""); search = search.replace("tag:*", ""); search = search.trim(); if (excludeMode) { if (rxExcludeTag.test(search)) { return; } if (rxIncludeTag.test(search)) { this.search = search.replace(rxIncludeTag, "$1" + excludeTag); } else { search += ` ${excludeTag}`; this.search = search.trim(); } } else { if (rxIncludeTag.test(search)) { return; } if (rxExcludeTag.test(search)) { this.search = search.replace(rxExcludeTag, "$1" + includeTag); } else { search += ` ${includeTag}`; this.search = search.trim(); } } this.page = 1; this.loadData(); }, showDialogAdd(values) { if (values === undefined) { values = {}; } this.showDialog({ title: "New Bookmark", content: "Create a new bookmark", fields: [ { name: "url", label: "Url, start with http://...", value: values.url || "", }, { name: "title", label: "Custom title (optional)", value: values.title || "", }, { name: "excerpt", label: "Custom excerpt (optional)", type: "area", value: values.excerpt || "", }, { name: "tags", label: "Comma separated tags (optional)", separator: ",", dictionary: this.tags.map((tag) => tag.name), }, { name: "create_archive", label: "Create archive", type: "check", value: this.appOptions.UseArchive, }, { name: "create_ebook", label: "Create Ebook", type: "check", value: this.appOptions.CreateEbook, }, { name: "makePublic", label: "Make bookmark publicly available", type: "check", value: this.appOptions.MakePublic, }, ], mainText: "OK", secondText: "Cancel", mainClick: async (data) => { // Make sure URL is not empty if (data.url.trim() === "") { this.showErrorDialog("URL must not empty"); return; } // Prepare tags var tags = data.tags .toLowerCase() .replace(/\s+/g, " ") .split(/\s*,\s*/g) .filter((tag) => tag.trim() !== "") .map((tag) => ({ name: tag.trim(), })); // Send data var requestData = { url: data.url.trim(), title: data.title.trim(), excerpt: data.excerpt.trim(), public: data.makePublic ? 1 : 0, tags: tags, create_archive: data.create_archive, create_ebook: data.create_ebook, }; this.dialog.loading = true; try { const json = await apiRequest( new URL("api/bookmarks", document.baseURI), { method: "post", body: JSON.stringify(requestData), }, ); this.dialog.loading = false; this.dialog.visible = false; this.bookmarks.splice(0, 0, json); } catch (err) { this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, showDialogEdit(item) { // Check the item if (typeof item !== "object") return; var id = typeof item.id === "number" ? item.id : 0, index = typeof item.index === "number" ? item.index : -1; if (id < 1 || index < 0) return; // Get the existing bookmark value var book = JSON.parse(JSON.stringify(this.bookmarks[index])), strTags = book.tags.map((tag) => tag.name).join(", "); this.showDialog({ title: "Edit Bookmark", content: "Edit the bookmark's data", showLabel: true, fields: [ { name: "url", label: "Url", value: book.url, }, { name: "title", label: "Title", value: book.title, }, { name: "excerpt", label: "Excerpt", type: "area", value: book.excerpt, }, { name: "tags", label: "Tags", value: strTags, separator: ",", dictionary: this.tags.map((tag) => tag.name), }, { name: "makePublic", label: "Make bookmark publicly available", type: "check", value: book.public >= 1, }, ], mainText: "OK", secondText: "Cancel", mainClick: async (data) => { // Validate input if (data.title.trim() === "") return; // Prepare tags var tags = data.tags .toLowerCase() .replace(/\s+/g, " ") .split(/\s*,\s*/g) .filter((tag) => tag.trim() !== "") .map((tag) => ({ name: tag.trim(), })); // Set new data book.url = data.url.trim(); book.title = data.title.trim(); book.excerpt = data.excerpt.trim(); book.public = data.makePublic ? 1 : 0; book.tags = tags; // Send data this.dialog.loading = true; try { const json = await apiRequest( new URL("api/bookmarks", document.baseURI), { method: "put", body: JSON.stringify(book), }, ); this.dialog.loading = false; this.dialog.visible = false; this.bookmarks.splice(index, 1, json); } catch (err) { this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, showDialogDelete(items) { // Check and filter items if (typeof items !== "object") return; if (!Array.isArray(items)) items = [items]; items = items.filter((item) => { var id = typeof item.id === "number" ? item.id : 0, index = typeof item.index === "number" ? item.index : -1; return id > 0 && index > -1; }); if (items.length === 0) return; // Split ids and indices var ids = items.map((item) => item.id), indices = items.map((item) => item.index).sort((a, b) => b - a); // Create title and content var title = "Delete Bookmarks", content = "Delete the selected bookmarks ? This action is irreversible."; if (items.length === 1) { title = "Delete Bookmark"; content = "Are you sure ? This action is irreversible."; } // Show dialog this.showDialog({ title: title, content: content, mainText: "Yes", secondText: "No", mainClick: async () => { this.dialog.loading = true; try { await apiRequest(new URL("api/bookmarks", document.baseURI), { method: "delete", body: JSON.stringify(ids), }); this.selection = []; this.editMode = false; this.dialog.loading = false; this.dialog.visible = false; indices.forEach((index) => this.bookmarks.splice(index, 1)); if (this.bookmarks.length < 20) { this.loadData(false); } } catch (err) { this.selection = []; this.editMode = false; this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, ebookGenerate(items) { // Check and filter items if (typeof items !== "object") return; if (!Array.isArray(items)) items = [items]; items = items.filter((item) => { var id = typeof item.id === "number" ? item.id : 0, index = typeof item.index === "number" ? item.index : -1; return id > 0 && index > -1; }); if (items.length === 0) return; // define variable and send request var ids = items.map((item) => item.id); var data = { ids: ids, create_archive: false, keep_metadata: true, create_ebook: true, skip_exist: true, }; this.loading = true; fetch(new URL("api/v1/bookmarks/cache", document.baseURI), { method: "put", body: JSON.stringify(data), headers: { "Content-Type": "application/json", Authorization: "Bearer " + localStorage.getItem("shiori-token"), }, }) .then((response) => { if (!response.ok) throw response; return response.json(); }) .then((json) => { this.selection = []; this.editMode = false; json.forEach((book) => { // download ebooks const id = book.id; if (book.hasEbook) { const ebook_url = new URL( `bookmark/${id}/ebook`, document.baseURI, ); const downloadLink = document.createElement("a"); downloadLink.href = ebook_url.toString(); downloadLink.download = `${book.title}.epub`; downloadLink.click(); } var item = items.find((el) => el.id === book.id); this.bookmarks.splice(item.index, 1, book); }); }) .catch((err) => { this.selection = []; this.editMode = false; this.getErrorMessage(err).then((msg) => { this.showErrorDialog(msg); }); }) .finally(() => { this.loading = false; }); }, showDialogUpdateCache(items) { // Check and filter items if (typeof items !== "object") return; if (!Array.isArray(items)) items = [items]; items = items.filter((item) => { var id = typeof item.id === "number" ? item.id : 0, index = typeof item.index === "number" ? item.index : -1; return id > 0 && index > -1; }); if (items.length === 0) return; // Show dialog var ids = items.map((item) => item.id); this.showDialog({ title: "Update Cache", content: "Update cache for selected bookmarks ? This action is irreversible.", fields: [ { name: "keep_metadata", label: "Keep the old title and excerpt", type: "check", value: this.appOptions.KeepMetadata, }, { name: "create_archive", label: "Update archive as well", type: "check", value: this.appOptions.UseArchive, }, { name: "create_ebook", label: "Update Ebook as well", type: "check", value: this.appOptions.CreateEbook, }, ], mainText: "Yes", secondText: "No", mainClick: async (data) => { var requestData = { ids: ids, create_archive: data.create_archive, keep_metadata: data.keep_metadata, create_ebook: data.create_ebook, skip_exist: false, }; this.dialog.loading = true; try { const json = await apiRequest( new URL("api/v1/bookmarks/cache", document.baseURI), { method: "put", body: JSON.stringify(requestData), }, ); this.selection = []; this.editMode = false; this.dialog.loading = false; this.dialog.visible = false; let faildedUpdateArchives = []; let faildedCreateEbook = []; json.forEach((book) => { var item = items.find((el) => el.id === book.id); this.bookmarks.splice(item.index, 1, book); if (data.create_archive && !book.hasArchive) { faildedUpdateArchives.push(book.id); console.error("can't update archive for bookmark id", book.id); } if (data.create_ebook && !book.hasEbook) { faildedCreateEbook.push(book.id); console.error("can't update ebook for bookmark id:", book.id); } }); if ( faildedCreateEbook.length > 0 || faildedUpdateArchives.length > 0 ) { this.showDialog({ title: `Bookmarks Id that Update Action Faild`, content: `Not all bookmarks could have their contents updated, but no files were overwritten.`, mainText: "OK", mainClick: () => { this.dialog.visible = false; }, }); } } catch (err) { this.selection = []; this.editMode = false; this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, showDialogAddTags(items) { // Check and filter items if (typeof items !== "object") return; if (!Array.isArray(items)) items = [items]; items = items.filter((item) => { var id = typeof item.id === "number" ? item.id : 0, index = typeof item.index === "number" ? item.index : -1; return id > 0 && index > -1; }); if (items.length === 0) return; // Show dialog this.showDialog({ title: "Add New Tags", content: "Add new tags to selected bookmarks", fields: [ { name: "tags", label: "Comma separated tags", value: "", separator: ",", dictionary: this.tags.map((tag) => tag.name), }, ], mainText: "OK", secondText: "Cancel", mainClick: async (data) => { // Validate input var tags = data.tags .toLowerCase() .replace(/\s+/g, " ") .split(/\s*,\s*/g) .filter((tag) => tag.trim() !== "") .map((tag) => ({ name: tag.trim(), })); if (tags.length === 0) return; // Send data var request = { ids: items.map((item) => item.id), tags: tags, }; this.dialog.loading = true; try { const json = await apiRequest( new URL("api/v1/bookmarks/tags", document.baseURI), { method: "put", body: JSON.stringify(request), }, ); this.selection = []; this.editMode = false; this.dialog.loading = false; this.dialog.visible = false; json.forEach((book) => { var item = items.find((el) => el.id === book.id); this.bookmarks.splice(item.index, 1, book); }); } catch (err) { this.selection = []; this.editMode = false; this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, showDialogTags() { this.dialogTags.visible = true; this.dialogTags.editMode = false; this.dialogTags.secondText = this.activeAccount.owner ? "Rename Tags" : ""; }, showDialogRenameTag(tag) { this.showDialog({ title: "Rename Tag", content: `Change the name for tag "#${tag.name}"`, fields: [ { name: "newName", label: "New tag name", value: tag.name, }, ], mainText: "OK", secondText: "Cancel", secondClick: () => { this.dialog.visible = false; this.dialogTags.visible = true; }, escPressed: () => { this.dialog.visible = false; this.dialogTags.visible = true; }, mainClick: async (data) => { // Save the old query var rxSpace = /\s+/g, oldTagQuery = rxSpace.test(tag.name) ? `"#${tag.name}"` : `#${tag.name}`, newTagQuery = rxSpace.test(data.newName) ? `"#${data.newName}"` : `#${data.newName}`; this.dialog.loading = true; try { await apiRequest( new URL("api/v1/tags/" + tag.id, document.baseURI), { method: "PUT", body: JSON.stringify({ name: data.newName }), }, ); tag.name = data.newName; this.dialog.loading = false; this.dialog.visible = false; this.dialogTags.visible = true; this.dialogTags.editMode = false; this.tags.sort((a, b) => { var aName = a.name.toLowerCase(), bName = b.name.toLowerCase(); if (aName < bName) return -1; else if (aName > bName) return 1; else return 0; }); if (this.search.includes(oldTagQuery)) { this.search = this.search.replace(oldTagQuery, newTagQuery); this.loadData(); } } catch (err) { this.dialog.loading = false; this.dialogTags.visible = false; this.dialogTags.editMode = false; this.showErrorDialog(err.message); } }, }); }, }, mounted() { this.$bus.$on("clearHomePage", () => { this.clearHomePage(); }); // Prepare history state watcher var stateWatcher = (e) => { var state = e.state || {}, activePage = state.activePage || "page-home", search = state.search || "", page = state.page || 1; if (activePage !== "page-home") return; this.page = page; this.search = search; this.loadData(false); }; window.addEventListener("popstate", stateWatcher); this.$once("hook:beforeDestroy", () => { window.removeEventListener("popstate", stateWatcher); }); // Set initial parameter var url = new Url(); this.search = url.query.search || ""; this.page = url.query.page || 1; var isSharing = url.query.url !== undefined || url.query.excerpt !== undefined; if (isSharing) { // this is what the spec says var shareData = { url: url.query.url, excerpt: url.query.excerpt, title: url.query.title, }; // In my testing sharing from chrome and ff focus, this is how data arrives if (shareData.url === undefined) { shareData.url = url.query.excerpt; shareData.title = url.query.title; shareData.excerpt = ""; } this.showDialogAdd(shareData); var history = { activePage: "page-home", search: this.search, page: this.page, }; var url = new Url(document.baseURI); url.hash = "home"; url.clearQuery(); window.history.replaceState(history, "page-home", url); } this.loadData(false, true); }, }; ================================================ FILE: internal/view/assets/js/page/setting.js ================================================ var template = `

Settings

Display
Bookmarks
Accounts
  • No accounts registered
  • {{account.username}}

My account
  • {{account.username}}

System info
  • Shiori version: {{system.version?.tag}}
  • Database engine: {{system.database}}
  • Operating system: {{system.os}}
`; import customDialog from "../component/dialog.js"; import basePage from "./base.js"; import { apiRequest } from "../utils/api.js"; export default { template: template, mixins: [basePage], components: { customDialog, }, data() { return { loading: false, accounts: [], system: {}, }; }, methods: { saveSetting() { let options = { ShowId: this.appOptions.ShowId, ListMode: this.appOptions.ListMode, HideThumbnail: this.appOptions.HideThumbnail, HideExcerpt: this.appOptions.HideExcerpt, Theme: this.appOptions.Theme, }; if (this.activeAccount.owner) { options = { ...options, KeepMetadata: this.appOptions.KeepMetadata, UseArchive: this.appOptions.UseArchive, CreateEbook: this.appOptions.CreateEbook, MakePublic: this.appOptions.MakePublic, }; } this.$emit("setting-changed", options); //request fetch(new URL("api/v1/auth/account", document.baseURI), { method: "PATCH", body: JSON.stringify({ config: this.appOptions, }), headers: { "Content-Type": "application/json", Authorization: "Bearer " + localStorage.getItem("shiori-token"), }, }) .then((response) => { if (!response.ok) throw response; return response.json(); }) .then((responseData) => { const responseString = JSON.stringify(responseData); localStorage.setItem("shiori-account", responseString); }) .catch((err) => { this.getErrorMessage(err).then((msg) => { this.showErrorDialog(msg); }); }); }, async loadAccounts() { if (this.loading) return; this.loading = true; try { const json = await apiRequest( new URL("api/v1/accounts", document.baseURI), ); this.loading = false; this.accounts = json; } catch (err) { this.loading = false; this.showErrorDialog(err.message); } }, async loadSystemInfo() { if (this.system.version !== undefined) return; try { const json = await apiRequest( new URL("api/v1/system/info", document.baseURI), ); this.system = json; } catch (err) { this.showErrorDialog(err.message); } }, showDialogNewAccount() { this.showDialog({ title: "New Account", content: "Input new account's data :", fields: [ { name: "username", label: "Username", value: "", }, { name: "password", label: "Password", type: "password", value: "", }, { name: "repeat_password", label: "Repeat password", type: "password", value: "", }, { name: "admin", label: "This account is an admin account", type: "check", value: false, }, ], mainText: "OK", secondText: "Cancel", mainClick: async (data) => { if (data.username === "") { return; } var request = { username: data.username, password: data.password, owner: data.admin, }; this.dialog.loading = true; try { const json = await apiRequest( new URL("api/v1/accounts", document.baseURI), { method: "post", body: JSON.stringify(request), }, ); this.dialog.loading = false; this.dialog.visible = false; this.accounts.push(json); this.accounts.sort((a, b) => { var nameA = a.username.toLowerCase(), nameB = b.username.toLowerCase(); if (nameA < nameB) { return -1; } if (nameA > nameB) { return 1; } return 0; }); } catch (err) { this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, showDialogChangePassword(account) { let fields = [ { name: "new_password", label: "New password", type: "password", value: "", }, { name: "repeat_password", label: "Repeat password", type: "password", value: "", }, ]; const requiresOldPassword = !this.activeAccount.owner || this.activeAccount.id === account.id; // Only owners can update user passwords without // providing the old password if (requiresOldPassword) { fields.unshift({ name: "old_password", label: "The current password", type: "password", value: "", }); } this.showDialog({ title: "Change Password", content: "", fields: fields, mainText: "OK", secondText: "Cancel", mainClick: async (data) => { if (requiresOldPassword) { if (data.old_password === "") { this.showErrorDialog("You must provide the current password."); return; } } if (data.new_password === "") { this.showErrorDialog("New password must not empt."); return; } if (data.new_password !== data.repeat_password) { this.showErrorDialog("Password does not match."); return; } var request = { old_password: data.old_password, new_password: data.new_password, }; // Determine which URL to use depending if the user is updating its own // account or another user's account. let url = `api/v1/accounts/${account.id}`; if (this.activeAccount.id === account.id) { url = "api/v1/auth/account"; } this.dialog.loading = true; try { await apiRequest(new URL(url, document.baseURI), { method: "PATCH", body: JSON.stringify(request), }); this.showDialog({ title: "Password Changed", content: "Password has been changed.", mainText: "OK", mainClick: () => { this.dialog.visible = false; }, }); } catch (err) { this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, showDialogDeleteAccount(account, idx) { this.showDialog({ title: "Delete Account", content: `Delete account "${account.username}" ?`, mainText: "Yes", secondText: "No", mainClick: async () => { this.dialog.loading = true; try { await apiRequest(`api/v1/accounts/${account.id}`, { method: "DELETE", }); this.dialog.loading = false; this.dialog.visible = false; this.accounts.splice(idx, 1); } catch (err) { this.dialog.loading = false; this.showErrorDialog(err.message); } }, }); }, }, mounted() { if (this.activeAccount.owner) { this.loadAccounts(); this.loadSystemInfo(); } }, }; ================================================ FILE: internal/view/assets/js/url.js ================================================ /*! * Lightweight URL manipulation with JavaScript * This library is independent of any other libraries and has pretty simple * interface and lightweight code-base. * Some ideas of query string parsing had been taken from Jan Wolter * @see http://unixpapa.com/js/querystring.html * * @license MIT * @author Mykhailo Stadnyk */ (function (ns) { 'use strict'; var RX_PROTOCOL = /^[a-z]+:/; var RX_PORT = /[-a-z0-9]+(\.[-a-z0-9])*:\d+/i; var RX_CREDS = /\/\/(.*?)(?::(.*?))?@/; var RX_WIN = /^win/i; var RX_PROTOCOL_REPL = /:$/; var RX_QUERY_REPL = /^\?/; var RX_HASH_REPL = /^#/; var RX_PATH = /(.*\/)/; var RX_PATH_FIX = /^\/{2,}/; var RX_SINGLE_QUOTE = /'/g; var RX_DECODE_1 = /%([ef][0-9a-f])%([89ab][0-9a-f])%([89ab][0-9a-f])/gi; var RX_DECODE_2 = /%([cd][0-9a-f])%([89ab][0-9a-f])/gi; var RX_DECODE_3 = /%([0-7][0-9a-f])/gi; var RX_PLUS = /\+/g; var RX_PATH_SEMI = /^\w:$/; var RX_URL_TEST = /[^/#?]/; // configure given url options function urlConfig(url) { var config = { path: true, query: true, hash: true }; if (!url) { return config; } if (RX_PROTOCOL.test(url)) { config.protocol = true; config.host = true; if (RX_PORT.test(url)) { config.port = true; } if (RX_CREDS.test(url)) { config.user = true; config.pass = true; } } return config; } var isNode = typeof window === 'undefined' && typeof global !== 'undefined' && typeof require === 'function'; // Trick to bypass Webpack's require at compile time var nodeRequire = isNode ? ns['require'] : null; // mapping between what we want and element properties var map = { protocol: 'protocol', host: 'hostname', port: 'port', path: 'pathname', query: 'search', hash: 'hash' }; // jscs: disable /** * default ports as defined by http://url.spec.whatwg.org/#default-port * We need them to fix IE behavior, @see https://github.com/Mikhus/jsurl/issues/2 */ // jscs: enable var defaultPorts = { ftp: 21, gopher: 70, http: 80, https: 443, ws: 80, wss: 443 }; var _currNodeUrl; function getCurrUrl() { if (isNode) { if (!_currNodeUrl) { _currNodeUrl = ('file://' + (process.platform.match(RX_WIN) ? '/' : '') + nodeRequire('fs').realpathSync('.') ); } return _currNodeUrl; } else { return document.location.href; } } function parse(self, url, absolutize) { var link, i, auth; if (!url) { url = getCurrUrl(); } if (isNode) { link = nodeRequire('url').parse(url); } else { link = document.createElement('a'); link.href = url; } var config = urlConfig(url); auth = url.match(RX_CREDS) || []; for (i in map) { if (config[i]) { self[i] = link[map[i]] || ''; } else { self[i] = ''; } } // fix-up some parts self.protocol = self.protocol.replace(RX_PROTOCOL_REPL, ''); self.query = self.query.replace(RX_QUERY_REPL, ''); self.hash = decode(self.hash.replace(RX_HASH_REPL, '')); self.user = decode(auth[1] || ''); self.pass = decode(auth[2] || ''); /* jshint ignore:start */ self.port = ( // loosely compare because port can be a string defaultPorts[self.protocol] == self.port || self.port == 0 ) ? '' : self.port; // IE fix, Android browser fix /* jshint ignore:end */ if (!config.protocol && RX_URL_TEST.test(url.charAt(0))) { self.path = url.split('?')[0].split('#')[0]; } if (!config.protocol && absolutize) { // is IE and path is relative var base = new Url(getCurrUrl().match(RX_PATH)[0]); var basePath = base.path.split('/'); var selfPath = self.path.split('/'); var props = ['protocol', 'user', 'pass', 'host', 'port']; var s = props.length; basePath.pop(); for (i = 0; i < s; i++) { self[props[i]] = base[props[i]]; } while (selfPath[0] === '..') { // skip all "../ basePath.pop(); selfPath.shift(); } self.path = (url.charAt(0) !== '/' ? basePath.join('/') : '') + '/' + selfPath.join('/') ; } self.path = self.path.replace(RX_PATH_FIX, '/'); self.paths(self.paths()); self.query = new QueryString(self.query); } function encode(s) { return encodeURIComponent(s).replace(RX_SINGLE_QUOTE, '%27'); } function decode(s) { s = s.replace(RX_PLUS, ' '); s = s.replace(RX_DECODE_1, function (code, hex1, hex2, hex3) { var n1 = parseInt(hex1, 16) - 0xE0; var n2 = parseInt(hex2, 16) - 0x80; if (n1 === 0 && n2 < 32) { return code; } var n3 = parseInt(hex3, 16) - 0x80; var n = (n1 << 12) + (n2 << 6) + n3; if (n > 0xFFFF) { return code; } return String.fromCharCode(n); }); s = s.replace(RX_DECODE_2, function (code, hex1, hex2) { var n1 = parseInt(hex1, 16) - 0xC0; if (n1 < 2) { return code; } var n2 = parseInt(hex2, 16) - 0x80; return String.fromCharCode((n1 << 6) + n2); }); return s.replace(RX_DECODE_3, function (code, hex) { return String.fromCharCode(parseInt(hex, 16)); }); } /** * Class QueryString * * @param {string} qs - string representation of QueryString * @constructor */ function QueryString(qs) { var parts = qs.split('&'); for (var i = 0, s = parts.length; i < s; i++) { var keyVal = parts[i].split('='); var key = decodeURIComponent(keyVal[0].replace(RX_PLUS, ' ')); if (!key) { continue; } var value = keyVal[1] !== undefined ? decode(keyVal[1]) : null; if (typeof this[key] === 'undefined') { this[key] = value; } else { if (!(this[key] instanceof Array)) { this[key] = [this[key]]; } this[key].push(value); } } } /** * Converts QueryString object back to string representation * * @returns {string} */ QueryString.prototype.toString = function () { var s = ''; var e = encode; var i, ii; for (i in this) { var w = this[i]; if (w instanceof Function || w === null) { continue; } if (w instanceof Array) { var len = w.length; if (len) { for (ii = 0; ii < len; ii++) { var v = w[ii]; s += s ? '&' : ''; s += e(i) + (v === undefined || v === null ? '' : '=' + e(v)); } } else { // parameter is an empty array, so treat as // an empty argument s += (s ? '&' : '') + e(i) + '='; } } else { s += s ? '&' : ''; s += e(i) + (w === undefined ? '' : '=' + e(w)); } } return s; }; /** * Class Url * * @param {string} [url] - string URL representation * @param {boolean} [noTransform] - do not transform to absolute URL * @constructor */ function Url(url, noTransform) { parse(this, url, !noTransform); } /** * Clears QueryString, making it contain no params at all * * @returns {Url} */ Url.prototype.clearQuery = function () { for (var key in this.query) { if (!(this.query[key] instanceof Function)) { delete this.query[key]; } } return this; }; /** * Returns total number of parameters in QueryString * * @returns {number} */ Url.prototype.queryLength = function () { var count = 0; for (var key in this.query) { if (!(this.query[key] instanceof Function)) { count++; } } return count; }; /** * Returns true if QueryString contains no parameters, false otherwise * * @returns {boolean} */ Url.prototype.isEmptyQuery = function () { return this.queryLength() === 0; }; /** * * @param {Array} [paths] - an array pf path parts (if given will modify * Url.path property * @returns {Array} - an array representation of the Url.path property */ Url.prototype.paths = function (paths) { var prefix = ''; var i = 0; var s; if (paths && paths.length && paths + '' !== paths) { if (this.isAbsolute()) { prefix = '/'; } for (s = paths.length; i < s; i++) { paths[i] = !i && RX_PATH_SEMI.test(paths[i]) ? paths[i] : encode(paths[i]); } this.path = prefix + paths.join('/'); } paths = (this.path.charAt(0) === '/' ? this.path.slice(1) : this.path).split('/'); for (i = 0, s = paths.length; i < s; i++) { paths[i] = decode(paths[i]); } return paths; }; /** * Performs URL-specific encoding of the given string * * @method Url#encode * @param {string} s - string to encode * @returns {string} */ Url.prototype.encode = encode; /** * Performs URL-specific decoding of the given encoded string * * @method Url#decode * @param {string} s - string to decode * @returns {string} */ Url.prototype.decode = decode; /** * Checks if current URL is an absolute resource locator (globally absolute * or absolute path to current server) * * @returns {boolean} */ Url.prototype.isAbsolute = function () { return this.protocol || this.path.charAt(0) === '/'; }; /** * Returns string representation of current Url object * * @returns {string} */ Url.prototype.toString = function () { return ( (this.protocol && (this.protocol + '://')) + (this.user && ( encode(this.user) + (this.pass && (':' + encode(this.pass)) ) + '@')) + (this.host && this.host) + (this.port && (':' + this.port)) + (this.path && this.path) + (this.query.toString() && ('?' + this.query)) + (this.hash && ('#' + encode(this.hash))) ); }; ns[ns.exports ? 'exports' : 'Url'] = Url; }(typeof module !== 'undefined' && module.exports ? module : window)); ================================================ FILE: internal/view/assets/js/utils/api.js ================================================ // Handles API responses in both legacy and new message formats export async function handleApiResponse(response) { if (!response.ok) throw response; // Return early for 204 No Content responses if (response.status === 204) { return null; } const contentType = response.headers.get("Content-Type"); if (!contentType || !contentType.includes("application/json")) { return response; } const data = await response.json(); // Check if response is in the new message format if (data && typeof data === "object" && "ok" in data && "message" in data) { if (!data.ok) { throw new Error(data.message?.error || "Unknown error"); } return data.message; } // Legacy format - return as is return data; } // Handles API errors and returns a user-friendly error message export async function handleApiError(error) { if (error instanceof Response) { const data = await error.json(); if (data && typeof data === "object" && "error" in data) { return data.error; } else if ( data && typeof data === "object" && "message" in data && "error" in data.message ) { return data.message.error; } else { return error.statusText; } } return "Unknown error occurred"; } // Makes an API request with proper error handling export async function apiRequest(url, options = {}) { try { const response = await fetch(url, { ...options, headers: { "Content-Type": "application/json", Authorization: "Bearer " + localStorage.getItem("shiori-token"), ...(options.headers || {}), }, }); return await handleApiResponse(response); } catch (error) { throw new Error(await handleApiError(error)); } } ================================================ FILE: internal/view/assets/js/vue.js ================================================ /*! * Vue.js v2.6.8 * (c) 2014-2019 Evan You * Released under the MIT License. */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, global.Vue = factory()); }(this, function () { 'use strict'; /* */ var emptyObject = Object.freeze({}); // These helpers produce better VM code in JS engines due to their // explicitness and function inlining. function isUndef (v) { return v === undefined || v === null } function isDef (v) { return v !== undefined && v !== null } function isTrue (v) { return v === true } function isFalse (v) { return v === false } /** * Check if value is primitive. */ function isPrimitive (value) { return ( typeof value === 'string' || typeof value === 'number' || // $flow-disable-line typeof value === 'symbol' || typeof value === 'boolean' ) } /** * Quick object check - this is primarily used to tell * Objects from primitive values when we know the value * is a JSON-compliant type. */ function isObject (obj) { return obj !== null && typeof obj === 'object' } /** * Get the raw type string of a value, e.g., [object Object]. */ var _toString = Object.prototype.toString; function toRawType (value) { return _toString.call(value).slice(8, -1) } /** * Strict object type check. Only returns true * for plain JavaScript objects. */ function isPlainObject (obj) { return _toString.call(obj) === '[object Object]' } function isRegExp (v) { return _toString.call(v) === '[object RegExp]' } /** * Check if val is a valid array index. */ function isValidArrayIndex (val) { var n = parseFloat(String(val)); return n >= 0 && Math.floor(n) === n && isFinite(val) } function isPromise (val) { return ( isDef(val) && typeof val.then === 'function' && typeof val.catch === 'function' ) } /** * Convert a value to a string that is actually rendered. */ function toString (val) { return val == null ? '' : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString) ? JSON.stringify(val, null, 2) : String(val) } /** * Convert an input value to a number for persistence. * If the conversion fails, return original string. */ function toNumber (val) { var n = parseFloat(val); return isNaN(n) ? val : n } /** * Make a map and return a function for checking if a key * is in that map. */ function makeMap ( str, expectsLowerCase ) { var map = Object.create(null); var list = str.split(','); for (var i = 0; i < list.length; i++) { map[list[i]] = true; } return expectsLowerCase ? function (val) { return map[val.toLowerCase()]; } : function (val) { return map[val]; } } /** * Check if a tag is a built-in tag. */ var isBuiltInTag = makeMap('slot,component', true); /** * Check if an attribute is a reserved attribute. */ var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is'); /** * Remove an item from an array. */ function remove (arr, item) { if (arr.length) { var index = arr.indexOf(item); if (index > -1) { return arr.splice(index, 1) } } } /** * Check whether an object has the property. */ var hasOwnProperty = Object.prototype.hasOwnProperty; function hasOwn (obj, key) { return hasOwnProperty.call(obj, key) } /** * Create a cached version of a pure function. */ function cached (fn) { var cache = Object.create(null); return (function cachedFn (str) { var hit = cache[str]; return hit || (cache[str] = fn(str)) }) } /** * Camelize a hyphen-delimited string. */ var camelizeRE = /-(\w)/g; var camelize = cached(function (str) { return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; }) }); /** * Capitalize a string. */ var capitalize = cached(function (str) { return str.charAt(0).toUpperCase() + str.slice(1) }); /** * Hyphenate a camelCase string. */ var hyphenateRE = /\B([A-Z])/g; var hyphenate = cached(function (str) { return str.replace(hyphenateRE, '-$1').toLowerCase() }); /** * Simple bind polyfill for environments that do not support it, * e.g., PhantomJS 1.x. Technically, we don't need this anymore * since native bind is now performant enough in most browsers. * But removing it would mean breaking code that was able to run in * PhantomJS 1.x, so this must be kept for backward compatibility. */ /* istanbul ignore next */ function polyfillBind (fn, ctx) { function boundFn (a) { var l = arguments.length; return l ? l > 1 ? fn.apply(ctx, arguments) : fn.call(ctx, a) : fn.call(ctx) } boundFn._length = fn.length; return boundFn } function nativeBind (fn, ctx) { return fn.bind(ctx) } var bind = Function.prototype.bind ? nativeBind : polyfillBind; /** * Convert an Array-like object to a real Array. */ function toArray (list, start) { start = start || 0; var i = list.length - start; var ret = new Array(i); while (i--) { ret[i] = list[i + start]; } return ret } /** * Mix properties into target object. */ function extend (to, _from) { for (var key in _from) { to[key] = _from[key]; } return to } /** * Merge an Array of Objects into a single Object. */ function toObject (arr) { var res = {}; for (var i = 0; i < arr.length; i++) { if (arr[i]) { extend(res, arr[i]); } } return res } /* eslint-disable no-unused-vars */ /** * Perform no operation. * Stubbing args to make Flow happy without leaving useless transpiled code * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/). */ function noop (a, b, c) {} /** * Always return false. */ var no = function (a, b, c) { return false; }; /* eslint-enable no-unused-vars */ /** * Return the same value. */ var identity = function (_) { return _; }; /** * Generate a string containing static keys from compiler modules. */ function genStaticKeys (modules) { return modules.reduce(function (keys, m) { return keys.concat(m.staticKeys || []) }, []).join(',') } /** * Check if two values are loosely equal - that is, * if they are plain objects, do they have the same shape? */ function looseEqual (a, b) { if (a === b) { return true } var isObjectA = isObject(a); var isObjectB = isObject(b); if (isObjectA && isObjectB) { try { var isArrayA = Array.isArray(a); var isArrayB = Array.isArray(b); if (isArrayA && isArrayB) { return a.length === b.length && a.every(function (e, i) { return looseEqual(e, b[i]) }) } else if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime() } else if (!isArrayA && !isArrayB) { var keysA = Object.keys(a); var keysB = Object.keys(b); return keysA.length === keysB.length && keysA.every(function (key) { return looseEqual(a[key], b[key]) }) } else { /* istanbul ignore next */ return false } } catch (e) { /* istanbul ignore next */ return false } } else if (!isObjectA && !isObjectB) { return String(a) === String(b) } else { return false } } /** * Return the first index at which a loosely equal value can be * found in the array (if value is a plain object, the array must * contain an object of the same shape), or -1 if it is not present. */ function looseIndexOf (arr, val) { for (var i = 0; i < arr.length; i++) { if (looseEqual(arr[i], val)) { return i } } return -1 } /** * Ensure a function is called only once. */ function once (fn) { var called = false; return function () { if (!called) { called = true; fn.apply(this, arguments); } } } var SSR_ATTR = 'data-server-rendered'; var ASSET_TYPES = [ 'component', 'directive', 'filter' ]; var LIFECYCLE_HOOKS = [ 'beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed', 'activated', 'deactivated', 'errorCaptured', 'serverPrefetch' ]; /* */ var config = ({ /** * Option merge strategies (used in core/util/options) */ // $flow-disable-line optionMergeStrategies: Object.create(null), /** * Whether to suppress warnings. */ silent: false, /** * Show production mode tip message on boot? */ productionTip: "development" !== 'production', /** * Whether to enable devtools */ devtools: "development" !== 'production', /** * Whether to record perf */ performance: false, /** * Error handler for watcher errors */ errorHandler: null, /** * Warn handler for watcher warns */ warnHandler: null, /** * Ignore certain custom elements */ ignoredElements: [], /** * Custom user key aliases for v-on */ // $flow-disable-line keyCodes: Object.create(null), /** * Check if a tag is reserved so that it cannot be registered as a * component. This is platform-dependent and may be overwritten. */ isReservedTag: no, /** * Check if an attribute is reserved so that it cannot be used as a component * prop. This is platform-dependent and may be overwritten. */ isReservedAttr: no, /** * Check if a tag is an unknown element. * Platform-dependent. */ isUnknownElement: no, /** * Get the namespace of an element */ getTagNamespace: noop, /** * Parse the real tag name for the specific platform. */ parsePlatformTagName: identity, /** * Check if an attribute must be bound using property, e.g. value * Platform-dependent. */ mustUseProp: no, /** * Perform updates asynchronously. Intended to be used by Vue Test Utils * This will significantly reduce performance if set to false. */ async: true, /** * Exposed for legacy reasons */ _lifecycleHooks: LIFECYCLE_HOOKS }); /* */ /** * unicode letters used for parsing html tags, component names and property paths. * using https://www.w3.org/TR/html53/semantics-scripting.html#potentialcustomelementname * skipping \u10000-\uEFFFF due to it freezing up PhantomJS */ var unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/; /** * Check if a string starts with $ or _ */ function isReserved (str) { var c = (str + '').charCodeAt(0); return c === 0x24 || c === 0x5F } /** * Define a property. */ function def (obj, key, val, enumerable) { Object.defineProperty(obj, key, { value: val, enumerable: !!enumerable, writable: true, configurable: true }); } /** * Parse simple path. */ var bailRE = new RegExp(("[^" + (unicodeRegExp.source) + ".$_\\d]")); function parsePath (path) { if (bailRE.test(path)) { return } var segments = path.split('.'); return function (obj) { for (var i = 0; i < segments.length; i++) { if (!obj) { return } obj = obj[segments[i]]; } return obj } } /* */ // can we use __proto__? var hasProto = '__proto__' in {}; // Browser environment sniffing var inBrowser = typeof window !== 'undefined'; var inWeex = typeof WXEnvironment !== 'undefined' && !!WXEnvironment.platform; var weexPlatform = inWeex && WXEnvironment.platform.toLowerCase(); var UA = inBrowser && window.navigator.userAgent.toLowerCase(); var isIE = UA && /msie|trident/.test(UA); var isIE9 = UA && UA.indexOf('msie 9.0') > 0; var isEdge = UA && UA.indexOf('edge/') > 0; var isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android'); var isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios'); var isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge; var isPhantomJS = UA && /phantomjs/.test(UA); var isFF = UA && UA.match(/firefox\/(\d+)/); // Firefox has a "watch" function on Object.prototype... var nativeWatch = ({}).watch; var supportsPassive = false; if (inBrowser) { try { var opts = {}; Object.defineProperty(opts, 'passive', ({ get: function get () { /* istanbul ignore next */ supportsPassive = true; } })); // https://github.com/facebook/flow/issues/285 window.addEventListener('test-passive', null, opts); } catch (e) {} } // this needs to be lazy-evaled because vue may be required before // vue-server-renderer can set VUE_ENV var _isServer; var isServerRendering = function () { if (_isServer === undefined) { /* istanbul ignore if */ if (!inBrowser && !inWeex && typeof global !== 'undefined') { // detect presence of vue-server-renderer and avoid // Webpack shimming the process _isServer = global['process'] && global['process'].env.VUE_ENV === 'server'; } else { _isServer = false; } } return _isServer }; // detect devtools var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; /* istanbul ignore next */ function isNative (Ctor) { return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) } var hasSymbol = typeof Symbol !== 'undefined' && isNative(Symbol) && typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys); var _Set; /* istanbul ignore if */ // $flow-disable-line if (typeof Set !== 'undefined' && isNative(Set)) { // use native Set when available. _Set = Set; } else { // a non-standard Set polyfill that only works with primitive keys. _Set = /*@__PURE__*/(function () { function Set () { this.set = Object.create(null); } Set.prototype.has = function has (key) { return this.set[key] === true }; Set.prototype.add = function add (key) { this.set[key] = true; }; Set.prototype.clear = function clear () { this.set = Object.create(null); }; return Set; }()); } /* */ var warn = noop; var tip = noop; var generateComponentTrace = (noop); // work around flow check var formatComponentName = (noop); { var hasConsole = typeof console !== 'undefined'; var classifyRE = /(?:^|[-_])(\w)/g; var classify = function (str) { return str .replace(classifyRE, function (c) { return c.toUpperCase(); }) .replace(/[-_]/g, ''); }; warn = function (msg, vm) { var trace = vm ? generateComponentTrace(vm) : ''; if (config.warnHandler) { config.warnHandler.call(null, msg, vm, trace); } else if (hasConsole && (!config.silent)) { console.error(("[Vue warn]: " + msg + trace)); } }; tip = function (msg, vm) { if (hasConsole && (!config.silent)) { console.warn("[Vue tip]: " + msg + ( vm ? generateComponentTrace(vm) : '' )); } }; formatComponentName = function (vm, includeFile) { if (vm.$root === vm) { return '' } var options = typeof vm === 'function' && vm.cid != null ? vm.options : vm._isVue ? vm.$options || vm.constructor.options : vm; var name = options.name || options._componentTag; var file = options.__file; if (!name && file) { var match = file.match(/([^/\\]+)\.vue$/); name = match && match[1]; } return ( (name ? ("<" + (classify(name)) + ">") : "") + (file && includeFile !== false ? (" at " + file) : '') ) }; var repeat = function (str, n) { var res = ''; while (n) { if (n % 2 === 1) { res += str; } if (n > 1) { str += str; } n >>= 1; } return res }; generateComponentTrace = function (vm) { if (vm._isVue && vm.$parent) { var tree = []; var currentRecursiveSequence = 0; while (vm) { if (tree.length > 0) { var last = tree[tree.length - 1]; if (last.constructor === vm.constructor) { currentRecursiveSequence++; vm = vm.$parent; continue } else if (currentRecursiveSequence > 0) { tree[tree.length - 1] = [last, currentRecursiveSequence]; currentRecursiveSequence = 0; } } tree.push(vm); vm = vm.$parent; } return '\n\nfound in\n\n' + tree .map(function (vm, i) { return ("" + (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm) ? ((formatComponentName(vm[0])) + "... (" + (vm[1]) + " recursive calls)") : formatComponentName(vm))); }) .join('\n') } else { return ("\n\n(found in " + (formatComponentName(vm)) + ")") } }; } /* */ var uid = 0; /** * A dep is an observable that can have multiple * directives subscribing to it. */ var Dep = function Dep () { this.id = uid++; this.subs = []; }; Dep.prototype.addSub = function addSub (sub) { this.subs.push(sub); }; Dep.prototype.removeSub = function removeSub (sub) { remove(this.subs, sub); }; Dep.prototype.depend = function depend () { if (Dep.target) { Dep.target.addDep(this); } }; Dep.prototype.notify = function notify () { // stabilize the subscriber list first var subs = this.subs.slice(); if (!config.async) { // subs aren't sorted in scheduler if not running async // we need to sort them now to make sure they fire in correct // order subs.sort(function (a, b) { return a.id - b.id; }); } for (var i = 0, l = subs.length; i < l; i++) { subs[i].update(); } }; // The current target watcher being evaluated. // This is globally unique because only one watcher // can be evaluated at a time. Dep.target = null; var targetStack = []; function pushTarget (target) { targetStack.push(target); Dep.target = target; } function popTarget () { targetStack.pop(); Dep.target = targetStack[targetStack.length - 1]; } /* */ var VNode = function VNode ( tag, data, children, text, elm, context, componentOptions, asyncFactory ) { this.tag = tag; this.data = data; this.children = children; this.text = text; this.elm = elm; this.ns = undefined; this.context = context; this.fnContext = undefined; this.fnOptions = undefined; this.fnScopeId = undefined; this.key = data && data.key; this.componentOptions = componentOptions; this.componentInstance = undefined; this.parent = undefined; this.raw = false; this.isStatic = false; this.isRootInsert = true; this.isComment = false; this.isCloned = false; this.isOnce = false; this.asyncFactory = asyncFactory; this.asyncMeta = undefined; this.isAsyncPlaceholder = false; }; var prototypeAccessors = { child: { configurable: true } }; // DEPRECATED: alias for componentInstance for backwards compat. /* istanbul ignore next */ prototypeAccessors.child.get = function () { return this.componentInstance }; Object.defineProperties( VNode.prototype, prototypeAccessors ); var createEmptyVNode = function (text) { if ( text === void 0 ) text = ''; var node = new VNode(); node.text = text; node.isComment = true; return node }; function createTextVNode (val) { return new VNode(undefined, undefined, undefined, String(val)) } // optimized shallow clone // used for static nodes and slot nodes because they may be reused across // multiple renders, cloning them avoids errors when DOM manipulations rely // on their elm reference. function cloneVNode (vnode) { var cloned = new VNode( vnode.tag, vnode.data, // #7975 // clone children array to avoid mutating original in case of cloning // a child. vnode.children && vnode.children.slice(), vnode.text, vnode.elm, vnode.context, vnode.componentOptions, vnode.asyncFactory ); cloned.ns = vnode.ns; cloned.isStatic = vnode.isStatic; cloned.key = vnode.key; cloned.isComment = vnode.isComment; cloned.fnContext = vnode.fnContext; cloned.fnOptions = vnode.fnOptions; cloned.fnScopeId = vnode.fnScopeId; cloned.asyncMeta = vnode.asyncMeta; cloned.isCloned = true; return cloned } /* * not type checking this file because flow doesn't play well with * dynamically accessing methods on Array prototype */ var arrayProto = Array.prototype; var arrayMethods = Object.create(arrayProto); var methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]; /** * Intercept mutating methods and emit events */ methodsToPatch.forEach(function (method) { // cache original method var original = arrayProto[method]; def(arrayMethods, method, function mutator () { var args = [], len = arguments.length; while ( len-- ) args[ len ] = arguments[ len ]; var result = original.apply(this, args); var ob = this.__ob__; var inserted; switch (method) { case 'push': case 'unshift': inserted = args; break case 'splice': inserted = args.slice(2); break } if (inserted) { ob.observeArray(inserted); } // notify change ob.dep.notify(); return result }); }); /* */ var arrayKeys = Object.getOwnPropertyNames(arrayMethods); /** * In some cases we may want to disable observation inside a component's * update computation. */ var shouldObserve = true; function toggleObserving (value) { shouldObserve = value; } /** * Observer class that is attached to each observed * object. Once attached, the observer converts the target * object's property keys into getter/setters that * collect dependencies and dispatch updates. */ var Observer = function Observer (value) { this.value = value; this.dep = new Dep(); this.vmCount = 0; def(value, '__ob__', this); if (Array.isArray(value)) { if (hasProto) { protoAugment(value, arrayMethods); } else { copyAugment(value, arrayMethods, arrayKeys); } this.observeArray(value); } else { this.walk(value); } }; /** * Walk through all properties and convert them into * getter/setters. This method should only be called when * value type is Object. */ Observer.prototype.walk = function walk (obj) { var keys = Object.keys(obj); for (var i = 0; i < keys.length; i++) { defineReactive$$1(obj, keys[i]); } }; /** * Observe a list of Array items. */ Observer.prototype.observeArray = function observeArray (items) { for (var i = 0, l = items.length; i < l; i++) { observe(items[i]); } }; // helpers /** * Augment a target Object or Array by intercepting * the prototype chain using __proto__ */ function protoAugment (target, src) { /* eslint-disable no-proto */ target.__proto__ = src; /* eslint-enable no-proto */ } /** * Augment a target Object or Array by defining * hidden properties. */ /* istanbul ignore next */ function copyAugment (target, src, keys) { for (var i = 0, l = keys.length; i < l; i++) { var key = keys[i]; def(target, key, src[key]); } } /** * Attempt to create an observer instance for a value, * returns the new observer if successfully observed, * or the existing observer if the value already has one. */ function observe (value, asRootData) { if (!isObject(value) || value instanceof VNode) { return } var ob; if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { ob = value.__ob__; } else if ( shouldObserve && !isServerRendering() && (Array.isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && !value._isVue ) { ob = new Observer(value); } if (asRootData && ob) { ob.vmCount++; } return ob } /** * Define a reactive property on an Object. */ function defineReactive$$1 ( obj, key, val, customSetter, shallow ) { var dep = new Dep(); var property = Object.getOwnPropertyDescriptor(obj, key); if (property && property.configurable === false) { return } // cater for pre-defined getter/setters var getter = property && property.get; var setter = property && property.set; if ((!getter || setter) && arguments.length === 2) { val = obj[key]; } var childOb = !shallow && observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { var value = getter ? getter.call(obj) : val; if (Dep.target) { dep.depend(); if (childOb) { childOb.dep.depend(); if (Array.isArray(value)) { dependArray(value); } } } return value }, set: function reactiveSetter (newVal) { var value = getter ? getter.call(obj) : val; /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return } /* eslint-enable no-self-compare */ if (customSetter) { customSetter(); } // #7981: for accessor properties without setter if (getter && !setter) { return } if (setter) { setter.call(obj, newVal); } else { val = newVal; } childOb = !shallow && observe(newVal); dep.notify(); } }); } /** * Set a property on an object. Adds the new property and * triggers change notification if the property doesn't * already exist. */ function set (target, key, val) { if (isUndef(target) || isPrimitive(target) ) { warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target)))); } if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key); target.splice(key, 1, val); return val } if (key in target && !(key in Object.prototype)) { target[key] = val; return val } var ob = (target).__ob__; if (target._isVue || (ob && ob.vmCount)) { warn( 'Avoid adding reactive properties to a Vue instance or its root $data ' + 'at runtime - declare it upfront in the data option.' ); return val } if (!ob) { target[key] = val; return val } defineReactive$$1(ob.value, key, val); ob.dep.notify(); return val } /** * Delete a property and trigger change if necessary. */ function del (target, key) { if (isUndef(target) || isPrimitive(target) ) { warn(("Cannot delete reactive property on undefined, null, or primitive value: " + ((target)))); } if (Array.isArray(target) && isValidArrayIndex(key)) { target.splice(key, 1); return } var ob = (target).__ob__; if (target._isVue || (ob && ob.vmCount)) { warn( 'Avoid deleting properties on a Vue instance or its root $data ' + '- just set it to null.' ); return } if (!hasOwn(target, key)) { return } delete target[key]; if (!ob) { return } ob.dep.notify(); } /** * Collect dependencies on array elements when the array is touched, since * we cannot intercept array element access like property getters. */ function dependArray (value) { for (var e = (void 0), i = 0, l = value.length; i < l; i++) { e = value[i]; e && e.__ob__ && e.__ob__.dep.depend(); if (Array.isArray(e)) { dependArray(e); } } } /* */ /** * Option overwriting strategies are functions that handle * how to merge a parent option value and a child option * value into the final value. */ var strats = config.optionMergeStrategies; /** * Options with restrictions */ { strats.el = strats.propsData = function (parent, child, vm, key) { if (!vm) { warn( "option \"" + key + "\" can only be used during instance " + 'creation with the `new` keyword.' ); } return defaultStrat(parent, child) }; } /** * Helper that recursively merges two data objects together. */ function mergeData (to, from) { if (!from) { return to } var key, toVal, fromVal; var keys = hasSymbol ? Reflect.ownKeys(from) : Object.keys(from); for (var i = 0; i < keys.length; i++) { key = keys[i]; // in case the object is already observed... if (key === '__ob__') { continue } toVal = to[key]; fromVal = from[key]; if (!hasOwn(to, key)) { set(to, key, fromVal); } else if ( toVal !== fromVal && isPlainObject(toVal) && isPlainObject(fromVal) ) { mergeData(toVal, fromVal); } } return to } /** * Data */ function mergeDataOrFn ( parentVal, childVal, vm ) { if (!vm) { // in a Vue.extend merge, both should be functions if (!childVal) { return parentVal } if (!parentVal) { return childVal } // when parentVal & childVal are both present, // we need to return a function that returns the // merged result of both functions... no need to // check if parentVal is a function here because // it has to be a function to pass previous merges. return function mergedDataFn () { return mergeData( typeof childVal === 'function' ? childVal.call(this, this) : childVal, typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal ) } } else { return function mergedInstanceDataFn () { // instance merge var instanceData = typeof childVal === 'function' ? childVal.call(vm, vm) : childVal; var defaultData = typeof parentVal === 'function' ? parentVal.call(vm, vm) : parentVal; if (instanceData) { return mergeData(instanceData, defaultData) } else { return defaultData } } } } strats.data = function ( parentVal, childVal, vm ) { if (!vm) { if (childVal && typeof childVal !== 'function') { warn( 'The "data" option should be a function ' + 'that returns a per-instance value in component ' + 'definitions.', vm ); return parentVal } return mergeDataOrFn(parentVal, childVal) } return mergeDataOrFn(parentVal, childVal, vm) }; /** * Hooks and props are merged as arrays. */ function mergeHook ( parentVal, childVal ) { var res = childVal ? parentVal ? parentVal.concat(childVal) : Array.isArray(childVal) ? childVal : [childVal] : parentVal; return res ? dedupeHooks(res) : res } function dedupeHooks (hooks) { var res = []; for (var i = 0; i < hooks.length; i++) { if (res.indexOf(hooks[i]) === -1) { res.push(hooks[i]); } } return res } LIFECYCLE_HOOKS.forEach(function (hook) { strats[hook] = mergeHook; }); /** * Assets * * When a vm is present (instance creation), we need to do * a three-way merge between constructor options, instance * options and parent options. */ function mergeAssets ( parentVal, childVal, vm, key ) { var res = Object.create(parentVal || null); if (childVal) { assertObjectType(key, childVal, vm); return extend(res, childVal) } else { return res } } ASSET_TYPES.forEach(function (type) { strats[type + 's'] = mergeAssets; }); /** * Watchers. * * Watchers hashes should not overwrite one * another, so we merge them as arrays. */ strats.watch = function ( parentVal, childVal, vm, key ) { // work around Firefox's Object.prototype.watch... if (parentVal === nativeWatch) { parentVal = undefined; } if (childVal === nativeWatch) { childVal = undefined; } /* istanbul ignore if */ if (!childVal) { return Object.create(parentVal || null) } { assertObjectType(key, childVal, vm); } if (!parentVal) { return childVal } var ret = {}; extend(ret, parentVal); for (var key$1 in childVal) { var parent = ret[key$1]; var child = childVal[key$1]; if (parent && !Array.isArray(parent)) { parent = [parent]; } ret[key$1] = parent ? parent.concat(child) : Array.isArray(child) ? child : [child]; } return ret }; /** * Other object hashes. */ strats.props = strats.methods = strats.inject = strats.computed = function ( parentVal, childVal, vm, key ) { if (childVal && "development" !== 'production') { assertObjectType(key, childVal, vm); } if (!parentVal) { return childVal } var ret = Object.create(null); extend(ret, parentVal); if (childVal) { extend(ret, childVal); } return ret }; strats.provide = mergeDataOrFn; /** * Default strategy. */ var defaultStrat = function (parentVal, childVal) { return childVal === undefined ? parentVal : childVal }; /** * Validate component names */ function checkComponents (options) { for (var key in options.components) { validateComponentName(key); } } function validateComponentName (name) { if (!new RegExp(("^[a-zA-Z][\\-\\.0-9_" + (unicodeRegExp.source) + "]*$")).test(name)) { warn( 'Invalid component name: "' + name + '". Component names ' + 'should conform to valid custom element name in html5 specification.' ); } if (isBuiltInTag(name) || config.isReservedTag(name)) { warn( 'Do not use built-in or reserved HTML elements as component ' + 'id: ' + name ); } } /** * Ensure all props option syntax are normalized into the * Object-based format. */ function normalizeProps (options, vm) { var props = options.props; if (!props) { return } var res = {}; var i, val, name; if (Array.isArray(props)) { i = props.length; while (i--) { val = props[i]; if (typeof val === 'string') { name = camelize(val); res[name] = { type: null }; } else { warn('props must be strings when using array syntax.'); } } } else if (isPlainObject(props)) { for (var key in props) { val = props[key]; name = camelize(key); res[name] = isPlainObject(val) ? val : { type: val }; } } else { warn( "Invalid value for option \"props\": expected an Array or an Object, " + "but got " + (toRawType(props)) + ".", vm ); } options.props = res; } /** * Normalize all injections into Object-based format */ function normalizeInject (options, vm) { var inject = options.inject; if (!inject) { return } var normalized = options.inject = {}; if (Array.isArray(inject)) { for (var i = 0; i < inject.length; i++) { normalized[inject[i]] = { from: inject[i] }; } } else if (isPlainObject(inject)) { for (var key in inject) { var val = inject[key]; normalized[key] = isPlainObject(val) ? extend({ from: key }, val) : { from: val }; } } else { warn( "Invalid value for option \"inject\": expected an Array or an Object, " + "but got " + (toRawType(inject)) + ".", vm ); } } /** * Normalize raw function directives into object format. */ function normalizeDirectives (options) { var dirs = options.directives; if (dirs) { for (var key in dirs) { var def$$1 = dirs[key]; if (typeof def$$1 === 'function') { dirs[key] = { bind: def$$1, update: def$$1 }; } } } } function assertObjectType (name, value, vm) { if (!isPlainObject(value)) { warn( "Invalid value for option \"" + name + "\": expected an Object, " + "but got " + (toRawType(value)) + ".", vm ); } } /** * Merge two option objects into a new one. * Core utility used in both instantiation and inheritance. */ function mergeOptions ( parent, child, vm ) { { checkComponents(child); } if (typeof child === 'function') { child = child.options; } normalizeProps(child, vm); normalizeInject(child, vm); normalizeDirectives(child); // Apply extends and mixins on the child options, // but only if it is a raw options object that isn't // the result of another mergeOptions call. // Only merged options has the _base property. if (!child._base) { if (child.extends) { parent = mergeOptions(parent, child.extends, vm); } if (child.mixins) { for (var i = 0, l = child.mixins.length; i < l; i++) { parent = mergeOptions(parent, child.mixins[i], vm); } } } var options = {}; var key; for (key in parent) { mergeField(key); } for (key in child) { if (!hasOwn(parent, key)) { mergeField(key); } } function mergeField (key) { var strat = strats[key] || defaultStrat; options[key] = strat(parent[key], child[key], vm, key); } return options } /** * Resolve an asset. * This function is used because child instances need access * to assets defined in its ancestor chain. */ function resolveAsset ( options, type, id, warnMissing ) { /* istanbul ignore if */ if (typeof id !== 'string') { return } var assets = options[type]; // check local registration variations first if (hasOwn(assets, id)) { return assets[id] } var camelizedId = camelize(id); if (hasOwn(assets, camelizedId)) { return assets[camelizedId] } var PascalCaseId = capitalize(camelizedId); if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] } // fallback to prototype chain var res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; if (warnMissing && !res) { warn( 'Failed to resolve ' + type.slice(0, -1) + ': ' + id, options ); } return res } /* */ function validateProp ( key, propOptions, propsData, vm ) { var prop = propOptions[key]; var absent = !hasOwn(propsData, key); var value = propsData[key]; // boolean casting var booleanIndex = getTypeIndex(Boolean, prop.type); if (booleanIndex > -1) { if (absent && !hasOwn(prop, 'default')) { value = false; } else if (value === '' || value === hyphenate(key)) { // only cast empty string / same name to boolean if // boolean has higher priority var stringIndex = getTypeIndex(String, prop.type); if (stringIndex < 0 || booleanIndex < stringIndex) { value = true; } } } // check default value if (value === undefined) { value = getPropDefaultValue(vm, prop, key); // since the default value is a fresh copy, // make sure to observe it. var prevShouldObserve = shouldObserve; toggleObserving(true); observe(value); toggleObserving(prevShouldObserve); } { assertProp(prop, key, value, vm, absent); } return value } /** * Get the default value of a prop. */ function getPropDefaultValue (vm, prop, key) { // no default, return undefined if (!hasOwn(prop, 'default')) { return undefined } var def = prop.default; // warn against non-factory defaults for Object & Array if (isObject(def)) { warn( 'Invalid default value for prop "' + key + '": ' + 'Props with type Object/Array must use a factory function ' + 'to return the default value.', vm ); } // the raw prop value was also undefined from previous render, // return previous default value to avoid unnecessary watcher trigger if (vm && vm.$options.propsData && vm.$options.propsData[key] === undefined && vm._props[key] !== undefined ) { return vm._props[key] } // call factory function for non-Function types // a value is Function if its prototype is function even across different execution context return typeof def === 'function' && getType(prop.type) !== 'Function' ? def.call(vm) : def } /** * Assert whether a prop is valid. */ function assertProp ( prop, name, value, vm, absent ) { if (prop.required && absent) { warn( 'Missing required prop: "' + name + '"', vm ); return } if (value == null && !prop.required) { return } var type = prop.type; var valid = !type || type === true; var expectedTypes = []; if (type) { if (!Array.isArray(type)) { type = [type]; } for (var i = 0; i < type.length && !valid; i++) { var assertedType = assertType(value, type[i]); expectedTypes.push(assertedType.expectedType || ''); valid = assertedType.valid; } } if (!valid) { warn( getInvalidTypeMessage(name, value, expectedTypes), vm ); return } var validator = prop.validator; if (validator) { if (!validator(value)) { warn( 'Invalid prop: custom validator check failed for prop "' + name + '".', vm ); } } } var simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/; function assertType (value, type) { var valid; var expectedType = getType(type); if (simpleCheckRE.test(expectedType)) { var t = typeof value; valid = t === expectedType.toLowerCase(); // for primitive wrapper objects if (!valid && t === 'object') { valid = value instanceof type; } } else if (expectedType === 'Object') { valid = isPlainObject(value); } else if (expectedType === 'Array') { valid = Array.isArray(value); } else { valid = value instanceof type; } return { valid: valid, expectedType: expectedType } } /** * Use function string name to check built-in types, * because a simple equality check will fail when running * across different vms / iframes. */ function getType (fn) { var match = fn && fn.toString().match(/^\s*function (\w+)/); return match ? match[1] : '' } function isSameType (a, b) { return getType(a) === getType(b) } function getTypeIndex (type, expectedTypes) { if (!Array.isArray(expectedTypes)) { return isSameType(expectedTypes, type) ? 0 : -1 } for (var i = 0, len = expectedTypes.length; i < len; i++) { if (isSameType(expectedTypes[i], type)) { return i } } return -1 } function getInvalidTypeMessage (name, value, expectedTypes) { var message = "Invalid prop: type check failed for prop \"" + name + "\"." + " Expected " + (expectedTypes.map(capitalize).join(', ')); var expectedType = expectedTypes[0]; var receivedType = toRawType(value); var expectedValue = styleValue(value, expectedType); var receivedValue = styleValue(value, receivedType); // check if we need to specify expected value if (expectedTypes.length === 1 && isExplicable(expectedType) && !isBoolean(expectedType, receivedType)) { message += " with value " + expectedValue; } message += ", got " + receivedType + " "; // check if we need to specify received value if (isExplicable(receivedType)) { message += "with value " + receivedValue + "."; } return message } function styleValue (value, type) { if (type === 'String') { return ("\"" + value + "\"") } else if (type === 'Number') { return ("" + (Number(value))) } else { return ("" + value) } } function isExplicable (value) { var explicitTypes = ['string', 'number', 'boolean']; return explicitTypes.some(function (elem) { return value.toLowerCase() === elem; }) } function isBoolean () { var args = [], len = arguments.length; while ( len-- ) args[ len ] = arguments[ len ]; return args.some(function (elem) { return elem.toLowerCase() === 'boolean'; }) } /* */ function handleError (err, vm, info) { // Deactivate deps tracking while processing error handler to avoid possible infinite rendering. // See: https://github.com/vuejs/vuex/issues/1505 pushTarget(); try { if (vm) { var cur = vm; while ((cur = cur.$parent)) { var hooks = cur.$options.errorCaptured; if (hooks) { for (var i = 0; i < hooks.length; i++) { try { var capture = hooks[i].call(cur, err, vm, info) === false; if (capture) { return } } catch (e) { globalHandleError(e, cur, 'errorCaptured hook'); } } } } } globalHandleError(err, vm, info); } finally { popTarget(); } } function invokeWithErrorHandling ( handler, context, args, vm, info ) { var res; try { res = args ? handler.apply(context, args) : handler.call(context); if (res && !res._isVue && isPromise(res)) { // issue #9511 // reassign to res to avoid catch triggering multiple times when nested calls res = res.catch(function (e) { return handleError(e, vm, info + " (Promise/async)"); }); } } catch (e) { handleError(e, vm, info); } return res } function globalHandleError (err, vm, info) { if (config.errorHandler) { try { return config.errorHandler.call(null, err, vm, info) } catch (e) { // if the user intentionally throws the original error in the handler, // do not log it twice if (e !== err) { logError(e, null, 'config.errorHandler'); } } } logError(err, vm, info); } function logError (err, vm, info) { { warn(("Error in " + info + ": \"" + (err.toString()) + "\""), vm); } /* istanbul ignore else */ if ((inBrowser || inWeex) && typeof console !== 'undefined') { console.error(err); } else { throw err } } /* */ var isUsingMicroTask = false; var callbacks = []; var pending = false; function flushCallbacks () { pending = false; var copies = callbacks.slice(0); callbacks.length = 0; for (var i = 0; i < copies.length; i++) { copies[i](); } } // Here we have async deferring wrappers using microtasks. // In 2.5 we used (macro) tasks (in combination with microtasks). // However, it has subtle problems when state is changed right before repaint // (e.g. #6813, out-in transitions). // Also, using (macro) tasks in event handler would cause some weird behaviors // that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109). // So we now use microtasks everywhere, again. // A major drawback of this tradeoff is that there are some scenarios // where microtasks have too high a priority and fire in between supposedly // sequential events (e.g. #4521, #6690, which have workarounds) // or even between bubbling of the same event (#6566). var timerFunc; // The nextTick behavior leverages the microtask queue, which can be accessed // via either native Promise.then or MutationObserver. // MutationObserver has wider support, however it is seriously bugged in // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It // completely stops working after triggering a few times... so, if native // Promise is available, we will use it: /* istanbul ignore next, $flow-disable-line */ if (typeof Promise !== 'undefined' && isNative(Promise)) { var p = Promise.resolve(); timerFunc = function () { p.then(flushCallbacks); // In problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) { setTimeout(noop); } }; isUsingMicroTask = true; } else if (!isIE && typeof MutationObserver !== 'undefined' && ( isNative(MutationObserver) || // PhantomJS and iOS 7.x MutationObserver.toString() === '[object MutationObserverConstructor]' )) { // Use MutationObserver where native Promise is not available, // e.g. PhantomJS, iOS7, Android 4.4 // (#6466 MutationObserver is unreliable in IE11) var counter = 1; var observer = new MutationObserver(flushCallbacks); var textNode = document.createTextNode(String(counter)); observer.observe(textNode, { characterData: true }); timerFunc = function () { counter = (counter + 1) % 2; textNode.data = String(counter); }; isUsingMicroTask = true; } else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { // Fallback to setImmediate. // Techinically it leverages the (macro) task queue, // but it is still a better choice than setTimeout. timerFunc = function () { setImmediate(flushCallbacks); }; } else { // Fallback to setTimeout. timerFunc = function () { setTimeout(flushCallbacks, 0); }; } function nextTick (cb, ctx) { var _resolve; callbacks.push(function () { if (cb) { try { cb.call(ctx); } catch (e) { handleError(e, ctx, 'nextTick'); } } else if (_resolve) { _resolve(ctx); } }); if (!pending) { pending = true; timerFunc(); } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(function (resolve) { _resolve = resolve; }) } } /* */ var mark; var measure; { var perf = inBrowser && window.performance; /* istanbul ignore if */ if ( perf && perf.mark && perf.measure && perf.clearMarks && perf.clearMeasures ) { mark = function (tag) { return perf.mark(tag); }; measure = function (name, startTag, endTag) { perf.measure(name, startTag, endTag); perf.clearMarks(startTag); perf.clearMarks(endTag); // perf.clearMeasures(name) }; } } /* not type checking this file because flow doesn't play well with Proxy */ var initProxy; { var allowedGlobals = makeMap( 'Infinity,undefined,NaN,isFinite,isNaN,' + 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' + 'require' // for Webpack/Browserify ); var warnNonPresent = function (target, key) { warn( "Property or method \"" + key + "\" is not defined on the instance but " + 'referenced during render. Make sure that this property is reactive, ' + 'either in the data option, or for class-based components, by ' + 'initializing the property. ' + 'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.', target ); }; var warnReservedPrefix = function (target, key) { warn( "Property \"" + key + "\" must be accessed with \"$data." + key + "\" because " + 'properties starting with "$" or "_" are not proxied in the Vue instance to ' + 'prevent conflicts with Vue internals' + 'See: https://vuejs.org/v2/api/#data', target ); }; var hasProxy = typeof Proxy !== 'undefined' && isNative(Proxy); if (hasProxy) { var isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact'); config.keyCodes = new Proxy(config.keyCodes, { set: function set (target, key, value) { if (isBuiltInModifier(key)) { warn(("Avoid overwriting built-in modifier in config.keyCodes: ." + key)); return false } else { target[key] = value; return true } } }); } var hasHandler = { has: function has (target, key) { var has = key in target; var isAllowed = allowedGlobals(key) || (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data)); if (!has && !isAllowed) { if (key in target.$data) { warnReservedPrefix(target, key); } else { warnNonPresent(target, key); } } return has || !isAllowed } }; var getHandler = { get: function get (target, key) { if (typeof key === 'string' && !(key in target)) { if (key in target.$data) { warnReservedPrefix(target, key); } else { warnNonPresent(target, key); } } return target[key] } }; initProxy = function initProxy (vm) { if (hasProxy) { // determine which proxy handler to use var options = vm.$options; var handlers = options.render && options.render._withStripped ? getHandler : hasHandler; vm._renderProxy = new Proxy(vm, handlers); } else { vm._renderProxy = vm; } }; } /* */ var seenObjects = new _Set(); /** * Recursively traverse an object to evoke all converted * getters, so that every nested property inside the object * is collected as a "deep" dependency. */ function traverse (val) { _traverse(val, seenObjects); seenObjects.clear(); } function _traverse (val, seen) { var i, keys; var isA = Array.isArray(val); if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) { return } if (val.__ob__) { var depId = val.__ob__.dep.id; if (seen.has(depId)) { return } seen.add(depId); } if (isA) { i = val.length; while (i--) { _traverse(val[i], seen); } } else { keys = Object.keys(val); i = keys.length; while (i--) { _traverse(val[keys[i]], seen); } } } /* */ var normalizeEvent = cached(function (name) { var passive = name.charAt(0) === '&'; name = passive ? name.slice(1) : name; var once$$1 = name.charAt(0) === '~'; // Prefixed last, checked first name = once$$1 ? name.slice(1) : name; var capture = name.charAt(0) === '!'; name = capture ? name.slice(1) : name; return { name: name, once: once$$1, capture: capture, passive: passive } }); function createFnInvoker (fns, vm) { function invoker () { var arguments$1 = arguments; var fns = invoker.fns; if (Array.isArray(fns)) { var cloned = fns.slice(); for (var i = 0; i < cloned.length; i++) { invokeWithErrorHandling(cloned[i], null, arguments$1, vm, "v-on handler"); } } else { // return handler return value for single handlers return invokeWithErrorHandling(fns, null, arguments, vm, "v-on handler") } } invoker.fns = fns; return invoker } function updateListeners ( on, oldOn, add, remove$$1, createOnceHandler, vm ) { var name, def$$1, cur, old, event; for (name in on) { def$$1 = cur = on[name]; old = oldOn[name]; event = normalizeEvent(name); if (isUndef(cur)) { warn( "Invalid handler for event \"" + (event.name) + "\": got " + String(cur), vm ); } else if (isUndef(old)) { if (isUndef(cur.fns)) { cur = on[name] = createFnInvoker(cur, vm); } if (isTrue(event.once)) { cur = on[name] = createOnceHandler(event.name, cur, event.capture); } add(event.name, cur, event.capture, event.passive, event.params); } else if (cur !== old) { old.fns = cur; on[name] = old; } } for (name in oldOn) { if (isUndef(on[name])) { event = normalizeEvent(name); remove$$1(event.name, oldOn[name], event.capture); } } } /* */ function mergeVNodeHook (def, hookKey, hook) { if (def instanceof VNode) { def = def.data.hook || (def.data.hook = {}); } var invoker; var oldHook = def[hookKey]; function wrappedHook () { hook.apply(this, arguments); // important: remove merged hook to ensure it's called only once // and prevent memory leak remove(invoker.fns, wrappedHook); } if (isUndef(oldHook)) { // no existing hook invoker = createFnInvoker([wrappedHook]); } else { /* istanbul ignore if */ if (isDef(oldHook.fns) && isTrue(oldHook.merged)) { // already a merged invoker invoker = oldHook; invoker.fns.push(wrappedHook); } else { // existing plain hook invoker = createFnInvoker([oldHook, wrappedHook]); } } invoker.merged = true; def[hookKey] = invoker; } /* */ function extractPropsFromVNodeData ( data, Ctor, tag ) { // we are only extracting raw values here. // validation and default values are handled in the child // component itself. var propOptions = Ctor.options.props; if (isUndef(propOptions)) { return } var res = {}; var attrs = data.attrs; var props = data.props; if (isDef(attrs) || isDef(props)) { for (var key in propOptions) { var altKey = hyphenate(key); { var keyInLowerCase = key.toLowerCase(); if ( key !== keyInLowerCase && attrs && hasOwn(attrs, keyInLowerCase) ) { tip( "Prop \"" + keyInLowerCase + "\" is passed to component " + (formatComponentName(tag || Ctor)) + ", but the declared prop name is" + " \"" + key + "\". " + "Note that HTML attributes are case-insensitive and camelCased " + "props need to use their kebab-case equivalents when using in-DOM " + "templates. You should probably use \"" + altKey + "\" instead of \"" + key + "\"." ); } } checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false); } } return res } function checkProp ( res, hash, key, altKey, preserve ) { if (isDef(hash)) { if (hasOwn(hash, key)) { res[key] = hash[key]; if (!preserve) { delete hash[key]; } return true } else if (hasOwn(hash, altKey)) { res[key] = hash[altKey]; if (!preserve) { delete hash[altKey]; } return true } } return false } /* */ // The template compiler attempts to minimize the need for normalization by // statically analyzing the template at compile time. // // For plain HTML markup, normalization can be completely skipped because the // generated render function is guaranteed to return Array. There are // two cases where extra normalization is needed: // 1. When the children contains components - because a functional component // may return an Array instead of a single root. In this case, just a simple // normalization is needed - if any child is an Array, we flatten the whole // thing with Array.prototype.concat. It is guaranteed to be only 1-level deep // because functional components already normalize their own children. function simpleNormalizeChildren (children) { for (var i = 0; i < children.length; i++) { if (Array.isArray(children[i])) { return Array.prototype.concat.apply([], children) } } return children } // 2. When the children contains constructs that always generated nested Arrays, // e.g.