├── .dockerignore ├── .github ├── socr.png └── workflows │ ├── nightly-release.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.backend.dev ├── Dockerfile.frontend.dev ├── cmd └── socr │ └── socr.go ├── db ├── db.go ├── migrations │ ├── 001_init.sql │ ├── 002_add_thumbnails.sql │ ├── 003_add_media_processed.sql │ └── 004_add_timestamp_indexes.sql └── types.go ├── directories └── directories.go ├── docker-compose.yml.example ├── go.mod ├── go.sum ├── imagery └── imagery.go ├── importer ├── importer.go └── importer_test.go ├── readme.md ├── server ├── auth │ └── auth.go ├── middleware.go ├── resp │ └── resp.go └── server.go ├── version.go ├── version.txt └── web ├── components ├── Badge.vue ├── BadgeGroup.vue ├── Home.vue ├── Importer.vue ├── LoadingModal.vue ├── LoadingSpinner.vue ├── Login.vue ├── Logo.vue ├── MediaBackground.vue ├── MediaHighlight.vue ├── MediaLines.vue ├── MediaPreview.vue ├── NavItem.vue ├── NotFound.vue ├── Public.vue ├── Search.vue ├── SearchFilter.vue ├── SearchFilterItem.vue ├── SearchNoResults.vue ├── SearchSidebar.vue ├── SearchSidebarHeader.vue ├── Settings.vue ├── SettingsAbout.vue ├── SettingsDirectories.vue ├── Toast.vue ├── ToastOverlay.vue ├── TransitionFade.vue ├── TransitionSlideX.vue ├── TransitionSlideY.vue ├── UploaderClipboard.vue └── UploaderFile.vue ├── composables ├── useInfiniteScroll.ts ├── useLoading.ts └── useStore.ts ├── dist.go ├── dist ├── assets │ └── keep └── index.html ├── index.html ├── main.css ├── main.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.ico ├── inconsolata-v31-latin-500.woff ├── inconsolata-v31-latin-500.woff2 ├── inconsolata-v31-latin-600.woff └── inconsolata-v31-latin-600.woff2 ├── request └── index.ts ├── router └── index.ts ├── shims-vue.d.ts ├── store └── index.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Dockerfile.dev 3 | README.md 4 | .gitignore 5 | .git 6 | 7 | web/dist 8 | web/node_modules 9 | 10 | db_data 11 | -------------------------------------------------------------------------------- /.github/socr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/.github/socr.png -------------------------------------------------------------------------------- /.github/workflows/nightly-release.yaml: -------------------------------------------------------------------------------- 1 | name: Nightly Release 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | workflow_dispatch: {} 6 | jobs: 7 | check-date: 8 | runs-on: ubuntu-latest 9 | name: Check latest commit 10 | outputs: 11 | should_run: ${{ steps.check.outputs.should_run }} 12 | steps: 13 | - uses: actions/checkout@v3 14 | - id: check 15 | run: | 16 | test -n "$(git rev-list --after="24 hours" ${{ github.sha }})" \ 17 | && echo "should_run=true" >>$GITHUB_OUTPUT \ 18 | || echo "should_run=false" >>$GITHUB_OUTPUT 19 | test-frontend: 20 | name: Lint and test frontend 21 | needs: check-date 22 | if: ${{ needs.check-date.outputs.should_run == 'true' }} 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | - name: Setup Node 28 | uses: actions/setup-node@v2 29 | with: 30 | node-version: 19 31 | - name: Install dependencies 32 | run: npm install 33 | working-directory: web 34 | - name: Check types 35 | run: npm run check-types 36 | working-directory: web 37 | - name: Check formatting 38 | run: npm run check-formatting 39 | working-directory: web 40 | test-backend: 41 | name: Lint and test backend 42 | needs: check-date 43 | if: ${{ needs.check-date.outputs.should_run == 'true' }} 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v2 48 | - name: Setup Go 49 | uses: actions/setup-go@v4 50 | with: 51 | go-version-file: go.mod 52 | - name: Install dependencies 53 | run: | 54 | sudo apt update -qq 55 | sudo apt install -y -qq build-essential libtesseract-dev libleptonica-dev 56 | - name: Lint 57 | uses: golangci/golangci-lint-action@v3 58 | with: 59 | version: v1.50.1 60 | - name: Test 61 | run: go test ./... 62 | build-release: 63 | name: Build and release Docker image 64 | needs: [check-date, test-frontend, test-backend] 65 | if: ${{ needs.check-date.outputs.should_run == 'true' }} 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: Checkout repository 69 | uses: actions/checkout@v2 70 | - name: Set up QEMU 71 | uses: docker/setup-qemu-action@v1 72 | with: 73 | image: tonistiigi/binfmt:latest 74 | platforms: all 75 | - name: Set up Docker Buildx 76 | id: buildx 77 | uses: docker/setup-buildx-action@v1 78 | with: 79 | install: true 80 | version: latest 81 | driver-opts: image=moby/buildkit:master 82 | - name: Login into DockerHub 83 | run: echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 84 | - name: Login into GitHub Container Registry 85 | run: echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin 86 | - name: Generate short hash 87 | run: | 88 | _short_hash=${{ github.sha }} 89 | echo "SHORT_HASH=${_short_hash:0:7}" >> $GITHUB_ENV 90 | - name: Build and Push 91 | uses: docker/build-push-action@v2 92 | with: 93 | context: . 94 | file: ./Dockerfile 95 | platforms: linux/amd64,linux/arm64,linux/arm/v7 96 | push: true 97 | tags: | 98 | ghcr.io/${{ github.repository }}:${{ env.SHORT_HASH }} 99 | ghcr.io/${{ github.repository }}:nightly 100 | ${{ github.repository }}:${{ env.SHORT_HASH }} 101 | ${{ github.repository }}:nightly 102 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | test-frontend: 8 | name: Lint and test frontend 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Setup Node 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 19 17 | - name: Install dependencies 18 | run: npm install 19 | working-directory: web 20 | - name: Check types 21 | run: npm run check-types 22 | working-directory: web 23 | - name: Check formatting 24 | run: npm run check-formatting 25 | working-directory: web 26 | test-backend: 27 | name: Lint and test backend 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v2 32 | - name: Setup Go 33 | uses: actions/setup-go@v4 34 | with: 35 | go-version-file: go.mod 36 | - name: Install dependencies 37 | run: | 38 | sudo apt update -qq 39 | sudo apt install -y -qq build-essential libtesseract-dev libleptonica-dev 40 | - name: Lint 41 | uses: golangci/golangci-lint-action@v3 42 | with: 43 | version: v1.50.1 44 | - name: Test 45 | run: go test ./... 46 | release-please: 47 | name: Run Release Please 48 | runs-on: ubuntu-latest 49 | needs: [test-frontend, test-backend] 50 | outputs: 51 | release_created: ${{ steps.release.outputs.release_created }} 52 | tag_name: ${{ steps.release.outputs.tag_name }} 53 | steps: 54 | - name: Checkout repository 55 | uses: actions/checkout@v2 56 | - name: Setup Release Please 57 | uses: google-github-actions/release-please-action@v2 58 | id: release 59 | with: 60 | token: ${{ secrets.GITHUB_TOKEN }} 61 | release-type: simple 62 | changelog-path: CHANGELOG.md 63 | package-name: socr 64 | build-release: 65 | name: Build, tag, and publish Docker image 66 | runs-on: ubuntu-latest 67 | needs: [release-please] 68 | if: ${{ needs.release-please.outputs.release_created }} 69 | steps: 70 | - name: Checkout repository 71 | uses: actions/checkout@v2 72 | - name: Set up QEMU 73 | uses: docker/setup-qemu-action@v1 74 | with: 75 | image: tonistiigi/binfmt:latest 76 | platforms: all 77 | - name: Set up Docker Buildx 78 | id: buildx 79 | uses: docker/setup-buildx-action@v1 80 | with: 81 | install: true 82 | version: latest 83 | driver-opts: image=moby/buildkit:master 84 | - name: Login into DockerHub 85 | run: echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin 86 | - name: Login into GitHub Container Registry 87 | run: echo ${{ secrets.CR_PAT }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin 88 | - name: Build and Push 89 | uses: docker/build-push-action@v2 90 | with: 91 | context: . 92 | file: ./Dockerfile 93 | platforms: linux/amd64,linux/arm64,linux/arm/v7 94 | push: true 95 | tags: | 96 | ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.tag_name }} 97 | ghcr.io/${{ github.repository }}:latest 98 | ${{ github.repository }}:${{ needs.release-please.outputs.tag_name }} 99 | ${{ github.repository }}:latest 100 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | pull_request: 7 | jobs: 8 | test-frontend: 9 | name: Lint and test frontend 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup Node 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 19 18 | - name: Install dependencies 19 | run: npm install 20 | working-directory: web 21 | - name: Check types 22 | run: npm run check-types 23 | working-directory: web 24 | - name: Check formatting 25 | run: npm run check-formatting 26 | working-directory: web 27 | test-backend: 28 | name: Lint and test backend 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v2 33 | - name: Setup Go 34 | uses: actions/setup-go@v4 35 | with: 36 | go-version-file: go.mod 37 | - name: Install dependencies 38 | run: | 39 | sudo apt update -qq 40 | sudo apt install -y -qq build-essential libtesseract-dev libleptonica-dev 41 | - name: Lint 42 | uses: golangci/golangci-lint-action@v3 43 | with: 44 | version: v1.50.1 45 | - name: Test 46 | run: go test ./... 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | web/node_modules 2 | .DS_Store 3 | web/dist 4 | web/dist-ssr 5 | !web/dist/keep 6 | *.local 7 | db_data 8 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | skip-dirs-use-default: true 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - bodyclose 8 | - deadcode 9 | - depguard 10 | - dogsled 11 | - errcheck 12 | - gochecknoglobals 13 | - gochecknoinits 14 | - goconst 15 | - gocritic 16 | - gocyclo 17 | - goprintffuncname 18 | - gosec 19 | - gosimple 20 | - govet 21 | - ineffassign 22 | - misspell 23 | - nakedret 24 | - rowserrcheck 25 | - staticcheck 26 | - structcheck 27 | - stylecheck 28 | - typecheck 29 | - unconvert 30 | - varcheck 31 | - asciicheck 32 | - cyclop 33 | - dupl 34 | - durationcheck 35 | - errorlint 36 | - exhaustive 37 | - exportloopref 38 | - forbidigo 39 | - forcetypeassert 40 | - gocognit 41 | - gofmt 42 | - goheader 43 | - goimports 44 | - gomodguard 45 | - ifshort 46 | - importas 47 | - makezero 48 | - nestif 49 | - nilerr 50 | - noctx 51 | - prealloc 52 | - revive 53 | - sqlclosecheck 54 | - testpackage 55 | - thelper 56 | - tparallel 57 | - unparam 58 | - unused 59 | - wastedassign 60 | - whitespace 61 | 62 | # TODO: fix these 63 | issues: 64 | exclude-rules: 65 | - text: 'should have comment' 66 | linters: 67 | - revive 68 | - text: 'comment on exported' 69 | linters: 70 | - revive 71 | - text: 'at least one file in a package should have a package comment' 72 | linters: 73 | - stylecheck 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.8.0](https://www.github.com/sentriz/socr/compare/v0.7.1...v0.8.0) (2023-03-30) 4 | 5 | 6 | ### Features 7 | 8 | * add opengraph image tags to media page ([13a4409](https://www.github.com/sentriz/socr/commit/13a4409e34a8b3e3a9dc284164fdde87de955672)) 9 | 10 | ### [0.7.1](https://www.github.com/sentriz/socr/compare/v0.7.0...v0.7.1) (2023-03-08) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **ui:** only get images from the clipboard ([7f53f43](https://www.github.com/sentriz/socr/commit/7f53f431584b41aa735797d94389551147b34367)) 16 | 17 | ## [0.7.0](https://www.github.com/sentriz/socr/compare/v0.6.0...v0.7.0) (2022-12-28) 18 | 19 | 20 | ### Features 21 | 22 | * **ci:** only run nightly when code has changed ([1c2be7c](https://www.github.com/sentriz/socr/commit/1c2be7cc944c52fe0d7c1592f385ed0cb3947546)) 23 | 24 | ## [0.6.0](https://www.github.com/sentriz/socr/compare/v0.5.0...v0.6.0) (2022-12-08) 25 | 26 | 27 | ### Features 28 | 29 | * **ui:** add new font ([e5b601c](https://www.github.com/sentriz/socr/commit/e5b601c4159365d62fe6547b3e37e6bc829bbbcd)) 30 | * **ui:** render directories before date in search sidebar ([fac3cff](https://www.github.com/sentriz/socr/commit/fac3cff5a58a9afd9c2e1e9a2b528d3d3e58583f)) 31 | * **ui:** update public page styling ([1e15eaf](https://www.github.com/sentriz/socr/commit/1e15eaf952270f71777f631a47735394d52fe76d)) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **server:** don't double print version ([6e11818](https://www.github.com/sentriz/socr/commit/6e1181867a52a3f85ff0201bf26652ef0a513316)) 37 | * **ui:** fix arrow keys not changing video items ([af7d43b](https://www.github.com/sentriz/socr/commit/af7d43b16795f8ec8e64e7a60558556f44843b00)) 38 | * **ui:** fix ts build ([51a56f8](https://www.github.com/sentriz/socr/commit/51a56f8ed445e406d7224538e15af063199ac581)) 39 | * **ui:** suppress router warning ([5c7cc4e](https://www.github.com/sentriz/socr/commit/5c7cc4ed111c32a0d08333792b731b4814b119e9)) 40 | 41 | ## [0.5.0](https://www.github.com/sentriz/socr/compare/v0.4.2...v0.5.0) (2022-09-17) 42 | 43 | 44 | ### Features 45 | 46 | * **ui:** update media preview border style ([be7405b](https://www.github.com/sentriz/socr/commit/be7405ba125d67a3f4f99944fca3fd0a364b04cf)) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * set video thumb max height ([96f60a1](https://www.github.com/sentriz/socr/commit/96f60a1001943f8293755497638ac883fcc329f8)) 52 | 53 | ### [0.4.2](https://www.github.com/sentriz/socr/compare/v0.4.1...v0.4.2) (2021-11-21) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * add on delete cascades ([b78f37b](https://www.github.com/sentriz/socr/commit/b78f37bb4be062eacfaa1c9e04762ebbc25bbe5e)) 59 | 60 | ### [0.4.1](https://www.github.com/sentriz/socr/compare/v0.3.0...v0.4.1) (2021-11-21) 61 | 62 | 63 | ### Features 64 | 65 | * filter by year/month and wrap search cols on different size displays ([c87d94e](https://www.github.com/sentriz/socr/commit/c87d94ee239da8a7e2a997d6aef07ec852fb4b18)) 66 | * store media processed boolean ([36f985b](https://www.github.com/sentriz/socr/commit/36f985bc8288cd35b6dcae967825b2fbd2bb43cc)) 67 | * **ui:** add arrow keys to switch between images ([74465ed](https://www.github.com/sentriz/socr/commit/74465ed2d7f0f340495f446bdd959c75145c53de)) 68 | * **ui:** clean up thumbs and mobile layout ([a7c5f0a](https://www.github.com/sentriz/socr/commit/a7c5f0a971e3d99e6f0db219848bb2714aee0070)) 69 | * **ui:** container min width ([93233f9](https://www.github.com/sentriz/socr/commit/93233f9aa503d91a268f05b70e79c76976e60027)) 70 | * **ui:** highlight selector search option ([dc18179](https://www.github.com/sentriz/socr/commit/dc18179bee3ee7818dd887b63804536ad52779eb)) 71 | * **ui:** make importer its own page ([b6e3d06](https://www.github.com/sentriz/socr/commit/b6e3d06572086e02a50e5cc5461e07104e562640)) 72 | * **ui:** round preview corners ([d3d575b](https://www.github.com/sentriz/socr/commit/d3d575b43d7d779a68bf8f5969272a6ab92d85e2)) 73 | * **ui:** scale preview thumbs to height ([59727e2](https://www.github.com/sentriz/socr/commit/59727e2c4bdf033e47293537901cfd214c576485)) 74 | * **ui:** update bg and public styles ([466a9df](https://www.github.com/sentriz/socr/commit/466a9df728aa4cfa7961cf9ebc3bcb35ad63adee)) 75 | * **ui:** update overlay hierarchy and add overlay scrollbars ([0ab4ff9](https://www.github.com/sentriz/socr/commit/0ab4ff9aea0a43b3a9cef5fefdb20c017fb4ebb9)) 76 | 77 | 78 | ### Bug Fixes 79 | 80 | * don't import empty blocks ([163fd46](https://www.github.com/sentriz/socr/commit/163fd46d204fb14e98f8ac2e57e6c778e1547217)) 81 | * **importer:** don't attempt to import a thumbnail for a media we have already found ([04529b4](https://www.github.com/sentriz/socr/commit/04529b4fe3419867c20e80e26b7f589693a3bf2c)) 82 | * **ui:** add settings header spacing ([53e0c43](https://www.github.com/sentriz/socr/commit/53e0c43388e4dfda1a192b150fdd2859a9cbc4e3)) 83 | * **ui:** don't show invalid upload date ([cca1280](https://www.github.com/sentriz/socr/commit/cca12803305cf9285dc98a5b356f2668f82ba207)) 84 | * **ui:** downgrade scrollbar ([3ea3d64](https://www.github.com/sentriz/socr/commit/3ea3d643b3d806bc250cf3677c9acd5c1baaa773)) 85 | * **ui:** hide public title on mobile ([546cf28](https://www.github.com/sentriz/socr/commit/546cf288743ccdf3004f63bf6304855c2671428a)) 86 | * **ui:** make sidebar header links bigger ([e7eb11f](https://www.github.com/sentriz/socr/commit/e7eb11f4c225614f1b4eeba873f7bda2659b4b5a)) 87 | * **ui:** overlay positions on mobile ([024028c](https://www.github.com/sentriz/socr/commit/024028cd9b37f03f5999ec0e5cced6847c559844)) 88 | * **ui:** proxy socket connection in dev mode ([a6a898f](https://www.github.com/sentriz/socr/commit/a6a898f1a5e2d5f294791c32a591bc27335a935b)) 89 | * **ui:** use intersection observer for scrolling images ([195d1ee](https://www.github.com/sentriz/socr/commit/195d1eeb702fad34d41104437ce5147417f5536b)) 90 | * use url.value in importer preview ([5d78f06](https://www.github.com/sentriz/socr/commit/5d78f0682c3043f614c8a5a9cf6edf81c203b7f0)) 91 | 92 | ### [0.4.1](https://www.github.com/sentriz/socr/compare/v0.3.0...v0.4.1) (2021-11-14) 93 | 94 | 95 | ### Features 96 | 97 | * filter by year/month and wrap search cols on different size displays ([c87d94e](https://www.github.com/sentriz/socr/commit/c87d94ee239da8a7e2a997d6aef07ec852fb4b18)) 98 | * store media processed boolean ([36f985b](https://www.github.com/sentriz/socr/commit/36f985bc8288cd35b6dcae967825b2fbd2bb43cc)) 99 | * **ui:** add arrow keys to switch between images ([74465ed](https://www.github.com/sentriz/socr/commit/74465ed2d7f0f340495f446bdd959c75145c53de)) 100 | * **ui:** clean up thumbs and mobile layout ([a7c5f0a](https://www.github.com/sentriz/socr/commit/a7c5f0a971e3d99e6f0db219848bb2714aee0070)) 101 | * **ui:** container min width ([93233f9](https://www.github.com/sentriz/socr/commit/93233f9aa503d91a268f05b70e79c76976e60027)) 102 | * **ui:** highlight selector search option ([dc18179](https://www.github.com/sentriz/socr/commit/dc18179bee3ee7818dd887b63804536ad52779eb)) 103 | * **ui:** make importer its own page ([b6e3d06](https://www.github.com/sentriz/socr/commit/b6e3d06572086e02a50e5cc5461e07104e562640)) 104 | * **ui:** round preview corners ([d3d575b](https://www.github.com/sentriz/socr/commit/d3d575b43d7d779a68bf8f5969272a6ab92d85e2)) 105 | * **ui:** scale preview thumbs to height ([59727e2](https://www.github.com/sentriz/socr/commit/59727e2c4bdf033e47293537901cfd214c576485)) 106 | * **ui:** update bg and public styles ([466a9df](https://www.github.com/sentriz/socr/commit/466a9df728aa4cfa7961cf9ebc3bcb35ad63adee)) 107 | * **ui:** update overlay hierarchy and add overlay scrollbars ([0ab4ff9](https://www.github.com/sentriz/socr/commit/0ab4ff9aea0a43b3a9cef5fefdb20c017fb4ebb9)) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * don't import empty blocks ([163fd46](https://www.github.com/sentriz/socr/commit/163fd46d204fb14e98f8ac2e57e6c778e1547217)) 113 | * **importer:** don't attempt to import a thumbnail for a media we have already found ([04529b4](https://www.github.com/sentriz/socr/commit/04529b4fe3419867c20e80e26b7f589693a3bf2c)) 114 | * **ui:** add settings header spacing ([53e0c43](https://www.github.com/sentriz/socr/commit/53e0c43388e4dfda1a192b150fdd2859a9cbc4e3)) 115 | * **ui:** don't show invalid upload date ([cca1280](https://www.github.com/sentriz/socr/commit/cca12803305cf9285dc98a5b356f2668f82ba207)) 116 | * **ui:** downgrade scrollbar ([3ea3d64](https://www.github.com/sentriz/socr/commit/3ea3d643b3d806bc250cf3677c9acd5c1baaa773)) 117 | * **ui:** hide public title on mobile ([546cf28](https://www.github.com/sentriz/socr/commit/546cf288743ccdf3004f63bf6304855c2671428a)) 118 | * **ui:** make sidebar header links bigger ([e7eb11f](https://www.github.com/sentriz/socr/commit/e7eb11f4c225614f1b4eeba873f7bda2659b4b5a)) 119 | * **ui:** overlay positions on mobile ([024028c](https://www.github.com/sentriz/socr/commit/024028cd9b37f03f5999ec0e5cced6847c559844)) 120 | * **ui:** proxy socket connection in dev mode ([a6a898f](https://www.github.com/sentriz/socr/commit/a6a898f1a5e2d5f294791c32a591bc27335a935b)) 121 | * **ui:** use intersection observer for scrolling images ([195d1ee](https://www.github.com/sentriz/socr/commit/195d1eeb702fad34d41104437ce5147417f5536b)) 122 | * use url.value in importer preview ([5d78f06](https://www.github.com/sentriz/socr/commit/5d78f0682c3043f614c8a5a9cf6edf81c203b7f0)) 123 | 124 | ## [0.3.0](https://www.github.com/sentriz/socr/compare/v0.2.2...v0.3.0) (2021-08-01) 125 | 126 | 127 | ### Features 128 | 129 | * add db migrations ([86c2db1](https://www.github.com/sentriz/socr/commit/86c2db17007a06dd0635d252c971dbb78989c061)) 130 | * add filter by media to search endpoint ([4dede23](https://www.github.com/sentriz/socr/commit/4dede234567ce07bd5fbb7bbffad030f0aad13b0)) 131 | * add image preview thumbnails ([980c09a](https://www.github.com/sentriz/socr/commit/980c09a44297ca9f89f54cd0e9950c04678f0a23)) 132 | * **ci:** check prettier ([26f1803](https://www.github.com/sentriz/socr/commit/26f18035e7a68439f6090256a340c9c02dd6c6e0)) 133 | * **ci:** lint and test frontend ([8435204](https://www.github.com/sentriz/socr/commit/84352040043152fcce4da6bd73ed0c97effd4e24)) 134 | * **readme:** add flow img ([d5757d8](https://www.github.com/sentriz/socr/commit/d5757d8e990a542dbd1f01b1fe2e29cec184f127)) 135 | * **ui:** add "check" command ([3878c6c](https://www.github.com/sentriz/socr/commit/3878c6cc3253932b4de83b161ec281308026d0ad)) 136 | * **ui:** add no results found state ([f9f1652](https://www.github.com/sentriz/socr/commit/f9f16520b82a50bd843885e2f44703ac496b5e04)) 137 | * **ui:** add option to filter by media type ([01ff10a](https://www.github.com/sentriz/socr/commit/01ff10a74a210ad92d1f80356e8d709472eab76e)) 138 | * **ui:** don't show public text for videos ([12a036e](https://www.github.com/sentriz/socr/commit/12a036e847dd137fd89490d38007a2aa81f563c3)) 139 | * **ui:** show video icon for videos ([ad03b6d](https://www.github.com/sentriz/socr/commit/ad03b6dedf8bcddd7991731b8cb1760c831fa22f)) 140 | * **ui:** use hericons ([948f459](https://www.github.com/sentriz/socr/commit/948f45918a3d857a05a23d7f58d13dfb3c3c6e53)) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * **build:** add build deps to final container ([a02943e](https://www.github.com/sentriz/socr/commit/a02943e63ef2014a195922739fc8a202307fc859)) 146 | * **lint:** remove funlen ([b102bea](https://www.github.com/sentriz/socr/commit/b102beab72d4e83f26328926d6870eeca8ea55ad)) 147 | * swap image / video icons ([75de539](https://www.github.com/sentriz/socr/commit/75de539d54bba53abc9a7b8510be4715cccc5158)) 148 | * **ui:** emit load event for video with loadstart ([ea5c2a0](https://www.github.com/sentriz/socr/commit/ea5c2a042086dc843a710108a01d218c33ef457b)) 149 | * **ui:** fix type errors ([25dfd0e](https://www.github.com/sentriz/socr/commit/25dfd0e8bdf2cd58e0e3eb779a12b2ef01e7f5d2)) 150 | * update accepted sort order dec -> desc ([cd53fe4](https://www.github.com/sentriz/socr/commit/cd53fe48922bfad041ba3e52ba35bb74f0c95467)) 151 | 152 | ### [0.2.2](https://www.github.com/sentriz/socr/compare/v0.2.1...v0.2.2) (2021-05-14) 153 | 154 | 155 | ### Bug Fixes 156 | 157 | * **ci:** install build deps for prod image ([f3528ba](https://www.github.com/sentriz/socr/commit/f3528ba688f485d68f6c494c0775d1a964e47198)) 158 | 159 | ### [0.2.1](https://www.github.com/sentriz/socr/compare/v0.1.3...v0.2.1) (2021-05-14) 160 | 161 | 162 | ### Features 163 | 164 | * add support for video ([7dfd0d8](https://www.github.com/sentriz/socr/commit/7dfd0d87eccb3dc50117425923846335160c6741)) 165 | * **ci:** pin golangci-lint version ([09be034](https://www.github.com/sentriz/socr/commit/09be03430647724ce15031ea371d4f031d804dbb)) 166 | * **ci:** test before release please, and only run extra tests on develop and pull request ([212587c](https://www.github.com/sentriz/socr/commit/212587c5348812d8f4413f4db12fcbc453c50712)) 167 | * **ci:** use GITHUB_TOKEN for release please ([57cbdd3](https://www.github.com/sentriz/socr/commit/57cbdd300c3b0f103a5481a0a337942bd65d8e04)) 168 | * **deps:** bump deps ([7614aeb](https://www.github.com/sentriz/socr/commit/7614aebee7e669000b008a1638f286a5f0fd8606)) 169 | * store mime and render video ([f8850b4](https://www.github.com/sentriz/socr/commit/f8850b45bc733fdf723755bf4b9a8e93aa3b8485)) 170 | * **ui:** reuse screenshot hightlight for public page ([988e2b1](https://www.github.com/sentriz/socr/commit/988e2b16f719264ec454a60968da2305be1c8b9f)) 171 | * **ui:** update frontend to use new terms and endpoints ([f9aa5a3](https://www.github.com/sentriz/socr/commit/f9aa5a3ba669853bae8093e62772471d21fb86f9)) 172 | 173 | 174 | ### Bug Fixes 175 | 176 | * **ci:** install ffmpeg deps ([7459cb3](https://www.github.com/sentriz/socr/commit/7459cb34b5281fe43f16c4699c1d72f75aac39bc)) 177 | * **ci:** trim short hash ([d3ade36](https://www.github.com/sentriz/socr/commit/d3ade36a62c34e00ad0f1ac610f912797eb8d7ff)) 178 | * **scanner:** try RFC3339 and add some shit tests ([a903449](https://www.github.com/sentriz/socr/commit/a903449c23ec7e918a0c0d09fb45e54280709452)) 179 | 180 | ## [0.2.0](https://www.github.com/sentriz/socr/compare/v0.1.3...v0.2.0) (2021-05-12) 181 | 182 | 183 | ### Features 184 | 185 | * **ci:** pin golangci-lint version ([09be034](https://www.github.com/sentriz/socr/commit/09be03430647724ce15031ea371d4f031d804dbb)) 186 | * **ci:** test before release please, and only run extra tests on develop and pull request ([212587c](https://www.github.com/sentriz/socr/commit/212587c5348812d8f4413f4db12fcbc453c50712)) 187 | * **ci:** use GITHUB_TOKEN for release please ([57cbdd3](https://www.github.com/sentriz/socr/commit/57cbdd300c3b0f103a5481a0a337942bd65d8e04)) 188 | * **deps:** bump deps ([7614aeb](https://www.github.com/sentriz/socr/commit/7614aebee7e669000b008a1638f286a5f0fd8606)) 189 | 190 | 191 | ### Bug Fixes 192 | 193 | * **ci:** trim short hash ([d3ade36](https://www.github.com/sentriz/socr/commit/d3ade36a62c34e00ad0f1ac610f912797eb8d7ff)) 194 | 195 | ### [0.1.3](https://www.github.com/sentriz/socr/compare/v0.1.2...v0.1.3) (2021-05-08) 196 | 197 | 198 | ### Bug Fixes 199 | 200 | * consistent release yaml ([0a8a2e9](https://www.github.com/sentriz/socr/commit/0a8a2e9e270589e3557c073c6a7e50c7854e9050)) 201 | 202 | ### [0.1.2](https://www.github.com/sentriz/socr/compare/v0.1.1...v0.1.2) (2021-05-08) 203 | 204 | 205 | ### Bug Fixes 206 | 207 | * show version on startup ([9eccd70](https://www.github.com/sentriz/socr/commit/9eccd70554aef1f3a1e5bacffdc191651d16ae5e)) 208 | 209 | ### [0.1.1](https://www.github.com/sentriz/socr/compare/v0.1.0...v0.1.1) (2021-05-08) 210 | 211 | 212 | ### Bug Fixes 213 | 214 | * **ci:** don't build arm v6 ([23835bc](https://www.github.com/sentriz/socr/commit/23835bcc9ddbedec93d63c3812d07d0142d8b903)) 215 | 216 | ## 0.1.0 (2021-05-08) 217 | 218 | 219 | ### Features 220 | 221 | * **ci:** arm builds ([1356eec](https://www.github.com/sentriz/socr/commit/1356eec1578e0ec68da954198b11261c6b8f65ce)) 222 | 223 | 224 | ### Bug Fixes 225 | 226 | * **ci:** test ([bd6fed4](https://www.github.com/sentriz/socr/commit/bd6fed43f79095695be87aaa50c65c5be07985dc)) 227 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 AS builder-frontend 2 | RUN apk add --no-cache nodejs npm 3 | WORKDIR /src 4 | COPY ./web . 5 | RUN npm install 6 | RUN PRODUCTION=true npm run-script build 7 | 8 | FROM alpine:3.18 AS builder-backend 9 | RUN apk add --no-cache build-base go tesseract-ocr tesseract-ocr-dev leptonica-dev 10 | WORKDIR /src 11 | COPY go.mod . 12 | COPY go.sum . 13 | RUN go mod download 14 | COPY . . 15 | COPY --from=builder-frontend /src/dist web/dist/ 16 | RUN GOOS=linux go build -o socr cmd/socr/socr.go 17 | 18 | FROM alpine:3.18 19 | LABEL org.opencontainers.image.source https://github.com/sentriz/socr 20 | RUN apk add --no-cache ffmpeg tesseract-ocr-data-eng 21 | COPY --from=builder-backend /src/socr / 22 | ENV SOCR_LISTEN_ADDR :80 23 | ENV SOCR_DB_DSN postgres://socr:socr@db:5432?sslmode=disable 24 | ENTRYPOINT [ "/socr" ] 25 | -------------------------------------------------------------------------------- /Dockerfile.backend.dev: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 2 | WORKDIR /src 3 | 4 | RUN apt-get update -qq 5 | RUN apt-get install -y -qq ffmpeg build-essential libtesseract-dev libleptonica-dev 6 | ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/4.00/tessdata/ 7 | RUN apt-get install -y -qq tesseract-ocr-eng 8 | -------------------------------------------------------------------------------- /Dockerfile.frontend.dev: -------------------------------------------------------------------------------- 1 | FROM node:19-buster-slim 2 | WORKDIR /src 3 | CMD [ "sh", "-c", "npm install && npm run-script dev" ] 4 | -------------------------------------------------------------------------------- /cmd/socr/socr.go: -------------------------------------------------------------------------------- 1 | //nolint:gochecknoglobals 2 | package main 3 | 4 | import ( 5 | "log" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "go.senan.xyz/socr" 15 | "go.senan.xyz/socr/db" 16 | "go.senan.xyz/socr/directories" 17 | "go.senan.xyz/socr/importer" 18 | "go.senan.xyz/socr/server" 19 | 20 | _ "image/gif" 21 | _ "image/jpeg" 22 | "image/png" 23 | ) 24 | 25 | var ( 26 | confListenAddr = mustEnv("SOCR_LISTEN_ADDR") 27 | confDBDSN = mustEnv("SOCR_DB_DSN") 28 | confHMACSecret = mustEnv("SOCR_HMAC_SECRET") 29 | confLoginUsername = mustEnv("SOCR_LOGIN_USERNAME") 30 | confLoginPassword = mustEnv("SOCR_LOGIN_PASSWORD") 31 | confAPIKey = mustEnv("SOCR_API_KEY") 32 | confDirs = envDirs("SOCR_DIR_") 33 | confUploadsAlias = envOr("SOCR_UPLOADS_DIR_ALIAS", "uploads") 34 | confThumbnailWidth = envOrInt("SOCR_THUMBNAIL_WIDTH", 315) 35 | ) 36 | 37 | func main() { 38 | if _, ok := confDirs[confUploadsAlias]; !ok { 39 | log.Fatalf("please provide an uploads directory") 40 | } 41 | for alias, path := range confDirs { 42 | log.Printf("using directory alias %q path %q", alias, path) 43 | } 44 | 45 | dbc, err := db.New(confDBDSN) 46 | if err != nil { 47 | log.Panicf("error creating database: %v", err) 48 | } 49 | defer dbc.Close() 50 | 51 | if err := dbc.Migrate(); err != nil { 52 | log.Panicf("error running migrations: %v", err) 53 | } 54 | 55 | const numImportWorkers = 1 56 | importr := importer.New(dbc, png.Encode, "image/png", confDirs, confUploadsAlias, uint(confThumbnailWidth)) 57 | for i := 0; i < numImportWorkers; i++ { 58 | log.Printf("starting import worker %d", i+1) 59 | go importr.StartWorker() 60 | } 61 | go func() { 62 | if err := importr.WatchUpdates(); err != nil { 63 | log.Printf("error starting watcher: %v", err) 64 | } 65 | }() 66 | 67 | servr := server.New(dbc, importr, confDirs, confUploadsAlias, confHMACSecret, confLoginUsername, confLoginPassword, confAPIKey) 68 | go servr.SocketNotifyScannerUpdate() 69 | go servr.SocketNotifyMedia() 70 | 71 | router := servr.Router() 72 | server := http.Server{ 73 | Addr: confListenAddr, 74 | Handler: router, 75 | ReadTimeout: 10 * time.Second, 76 | ReadHeaderTimeout: 5 * time.Second, 77 | IdleTimeout: 60 * time.Second, 78 | MaxHeaderBytes: 1024 * 64, 79 | } 80 | 81 | log.Printf("starting socr %s", socr.Version) 82 | log.Printf("listening on %q", confListenAddr) 83 | log.Printf("starting server: %v", server.ListenAndServe()) 84 | } 85 | 86 | func mustEnv(key string) string { 87 | if v, ok := os.LookupEnv(key); ok { 88 | return v 89 | } 90 | log.Fatalf("please provide a %q", key) 91 | return "" 92 | } 93 | 94 | func envOr(key string, or string) string { 95 | if v, ok := os.LookupEnv(key); ok { 96 | return v 97 | } 98 | return or 99 | } 100 | 101 | func envOrInt(key string, or int) int { 102 | if v, ok := os.LookupEnv(key); ok { 103 | if i, err := strconv.Atoi(v); err == nil { 104 | return i 105 | } 106 | } 107 | return or 108 | } 109 | 110 | func envDirs(prefix string) directories.Directories { 111 | expr := regexp.MustCompile(prefix + `(?P[\w_]+)=(?P.*)`) 112 | const ( 113 | partFull = iota 114 | partAlias 115 | partPath 116 | ) 117 | dirMap := directories.Directories{} 118 | for _, env := range os.Environ() { 119 | parts := expr.FindStringSubmatch(env) 120 | if len(parts) != 3 { 121 | continue 122 | } 123 | alias := strings.ToLower(parts[partAlias]) 124 | path := filepath.Clean(parts[partPath]) 125 | dirMap[alias] = path 126 | } 127 | return dirMap 128 | } 129 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "log" 10 | "sort" 11 | "time" 12 | 13 | sq "github.com/Masterminds/squirrel" 14 | "github.com/georgysavva/scany/pgxscan" 15 | "github.com/jackc/pgx/v4" 16 | "github.com/jackc/pgx/v4/pgxpool" 17 | ) 18 | 19 | //nolint:gochecknoglobals 20 | //go:embed migrations 21 | var migrations embed.FS 22 | 23 | type DB struct { 24 | *pgxpool.Pool 25 | sq.StatementBuilderType 26 | } 27 | 28 | func New(dsn string) (*DB, error) { 29 | pool, err := waitConnect(context.Background(), dsn, 500*time.Millisecond, 10) 30 | if err != nil { 31 | return nil, fmt.Errorf("create and connect pool: %w", err) 32 | } 33 | 34 | return &DB{ 35 | Pool: pool, 36 | StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), 37 | }, nil 38 | } 39 | 40 | func waitConnect(ctx context.Context, dsn string, interval time.Duration, times int) (*pgxpool.Pool, error) { 41 | var pool *pgxpool.Pool 42 | var err error 43 | for i := 0; i < times; i++ { 44 | if pool, err = pgxpool.Connect(ctx, dsn); err == nil { 45 | return pool, nil 46 | } 47 | time.Sleep(interval) 48 | } 49 | return nil, fmt.Errorf("failed after %d tries: %w", times, err) 50 | } 51 | 52 | func (db *DB) SchemaVersion() (int, error) { 53 | q := db. 54 | Select("version"). 55 | From("schema_version") 56 | 57 | sql, args, _ := q.ToSql() 58 | var result int 59 | return result, pgxscan.Get(context.Background(), db, &result, sql, args...) 60 | } 61 | 62 | func (db *DB) SetSchemaVersion(version int) error { 63 | q := db. 64 | Update("schema_version"). 65 | Set("version", version) 66 | 67 | sql, args, _ := q.ToSql() 68 | _, err := db.Exec(context.Background(), sql, args...) 69 | return err 70 | } 71 | 72 | func (db *DB) Migrate() error { 73 | files, err := fs.Glob(migrations, "*/*.sql") 74 | if err != nil { 75 | return fmt.Errorf("globbing migrations: %w", err) 76 | } 77 | 78 | sort.Strings(files) 79 | 80 | // select the last version from the db. an err is likely relation 81 | // schema_version doesn't exist, meaning the first migration hasn't 82 | // run yet. so we can take the zero value to be true 83 | versionCurrent, _ := db.SchemaVersion() 84 | versionLatest := len(files) 85 | 86 | for i := versionCurrent; i < versionLatest; i++ { 87 | fileName := files[i] 88 | infoName := fmt.Sprintf("%d/%d %q", i+1, versionLatest, fileName) 89 | 90 | log.Printf("running migration %s", infoName) 91 | 92 | migration, _ := migrations.Open(files[i]) 93 | migrationBytes, _ := io.ReadAll(migration) 94 | 95 | err := db.BeginFunc(context.Background(), func(tx pgx.Tx) error { 96 | _, err := tx.Exec(context.Background(), string(migrationBytes)) 97 | return err 98 | }) 99 | if err != nil { 100 | return fmt.Errorf("running %s: %w", infoName, err) 101 | } 102 | 103 | if err := db.SetSchemaVersion(i + 1); err != nil { 104 | return fmt.Errorf("updating version: %w", err) 105 | } 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func (db *DB) CreateMedia(media *Media) (*Media, error) { 112 | q := db. 113 | Insert("medias"). 114 | Columns("hash", "type", "mime", "timestamp", "dim_width", "dim_height", "dominant_colour", "blurhash"). 115 | Values(media.Hash, media.Type, media.MIME, media.Timestamp, media.DimWidth, media.DimHeight, media.DominantColour, media.Blurhash). 116 | Suffix("returning *") 117 | 118 | sql, args, _ := q.ToSql() 119 | var result Media 120 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 121 | } 122 | 123 | type SearchMediasOptions struct { 124 | Body string 125 | Directory string 126 | Media MediaType 127 | Limit int 128 | Offset int 129 | SortField string 130 | SortOrder string 131 | DateFrom time.Time 132 | DateTo time.Time 133 | } 134 | 135 | func (db *DB) GetMediaByID(id int) (*Media, error) { 136 | q := db. 137 | Select("medias.*"). 138 | From("medias"). 139 | Where(sq.Eq{"id": id}). 140 | Limit(1) 141 | 142 | sql, args, _ := q.ToSql() 143 | var result Media 144 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 145 | } 146 | 147 | func (db *DB) GetMediaByHash(hash string) (*Media, error) { 148 | q := db. 149 | Select("*"). 150 | From("medias"). 151 | Where(sq.Eq{"hash": hash}). 152 | Limit(1) 153 | 154 | sql, args, _ := q.ToSql() 155 | var result Media 156 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 157 | } 158 | 159 | func (db *DB) GetMediaByHashWithRelations(hash string) (*Media, error) { 160 | colAggBlocks := db. 161 | Select("json_agg(blocks order by index)"). 162 | From("blocks"). 163 | Where("media_id = medias.id") 164 | colAggAliases := db. 165 | Select("json_agg(distinct dir_infos.directory_alias)"). 166 | From("dir_infos"). 167 | Where("media_id = medias.id") 168 | 169 | q := db. 170 | Select("medias.*"). 171 | Column(sq.Alias(colAggBlocks, "blocks")). 172 | Column(sq.Alias(colAggAliases, "directories")). 173 | From("medias"). 174 | Where(sq.Eq{"hash": hash}). 175 | Limit(1) 176 | 177 | sql, args, _ := q.ToSql() 178 | var result Media 179 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 180 | } 181 | 182 | func (db *DB) SearchMedias(options SearchMediasOptions) ([]*Media, error) { 183 | if !isSortField(options.SortField) { 184 | return nil, fmt.Errorf("invalid sort field %q provided", options.SortField) 185 | } 186 | if !isSortOrder(options.SortOrder) { 187 | return nil, fmt.Errorf("invalid sort order %q provided", options.SortOrder) 188 | } 189 | if options.Media != "" && !isMediaType(options.Media) { 190 | return nil, fmt.Errorf("invalid media type %q provided", options.Media) 191 | } 192 | 193 | q := db. 194 | Select("medias.*"). 195 | From("medias"). 196 | Limit(uint64(options.Limit)). 197 | Offset(uint64(options.Offset)). 198 | OrderBy(fmt.Sprintf("%s %s", options.SortField, options.SortOrder)) 199 | if options.Directory != "" { 200 | q = q. 201 | Join("dir_infos on dir_infos.media_id = medias.id"). 202 | Where(sq.Eq{"dir_infos.directory_alias": options.Directory}) 203 | } 204 | if options.Body != "" { 205 | colAggBlocks := sq.Expr("json_agg(blocks order by blocks.index)") 206 | colSimilarity := sq.Expr("avg(similarity(blocks.body, ?))", options.Body) 207 | q = q. 208 | Column(sq.Alias(colAggBlocks, "highlighted_blocks")). 209 | Column(sq.Alias(colSimilarity, "similarity")). 210 | LeftJoin("blocks on blocks.media_id = medias.id"). 211 | Where("blocks.body %> ?", options.Body). 212 | GroupBy("medias.id") 213 | } 214 | if options.Media != "" { 215 | q = q.Where(sq.Eq{"medias.type": options.Media}) 216 | } 217 | if !options.DateFrom.IsZero() { 218 | q = q.Where(sq.GtOrEq{"medias.timestamp": options.DateFrom}) 219 | } 220 | if !options.DateTo.IsZero() { 221 | q = q.Where(sq.Lt{"medias.timestamp": options.DateTo}) 222 | } 223 | 224 | sql, args, _ := q.ToSql() 225 | var results []*Media 226 | return results, pgxscan.Select(context.Background(), db, &results, sql, args...) 227 | } 228 | 229 | func (db *DB) SetMediaProcessed(id MediaID) error { 230 | q := db. 231 | Update("medias"). 232 | Where(sq.Eq{"id": id}). 233 | Set("processed", true) 234 | 235 | sql, args, _ := q.ToSql() 236 | _, err := db.Exec(context.Background(), sql, args...) 237 | return err 238 | } 239 | 240 | func (db *DB) CreateBlocks(blocks []*Block) error { 241 | if len(blocks) == 0 { 242 | return nil 243 | } 244 | 245 | q := db. 246 | Insert("blocks"). 247 | Columns("media_id", "index", "min_x", "min_y", "max_x", "max_y", "body") 248 | for _, block := range blocks { 249 | q = q.Values(block.MediaID, block.Index, block.MinX, block.MinY, block.MaxX, block.MaxY, block.Body) 250 | } 251 | 252 | sql, args, _ := q.ToSql() 253 | _, err := db.Exec(context.Background(), sql, args...) 254 | return err 255 | } 256 | 257 | func (db *DB) CreateDirInfo(dirInfo *DirInfo) (*DirInfo, error) { 258 | q := db. 259 | Insert("dir_infos"). 260 | Columns("media_id", "filename", "directory_alias"). 261 | Values(dirInfo.MediaID, dirInfo.Filename, dirInfo.DirectoryAlias). 262 | Suffix("on conflict do nothing"). 263 | Suffix("returning *") 264 | 265 | sql, args, _ := q.ToSql() 266 | var result DirInfo 267 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 268 | } 269 | 270 | func (db *DB) CreateThumbnail(thumbnail *Thumbnail) (*Thumbnail, error) { 271 | q := db. 272 | Insert("thumbnails"). 273 | Columns("media_id", "mime", "dim_width", "dim_height", "timestamp", "data"). 274 | Values(thumbnail.MediaID, thumbnail.MIME, thumbnail.DimWidth, thumbnail.DimHeight, thumbnail.Timestamp, thumbnail.Data). 275 | Suffix("returning *") 276 | 277 | sql, args, _ := q.ToSql() 278 | var result Thumbnail 279 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 280 | } 281 | 282 | func (db *DB) GetDirInfo(directoryAlias string, filename string) (*DirInfo, error) { 283 | q := db. 284 | Select("*"). 285 | From("dir_infos"). 286 | Where(sq.Eq{ 287 | "directory_alias": directoryAlias, 288 | "filename": filename, 289 | }). 290 | Limit(1) 291 | 292 | sql, args, _ := q.ToSql() 293 | var result DirInfo 294 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 295 | } 296 | 297 | func (db *DB) GetDirInfoByMediaHash(hash string) (*DirInfo, error) { 298 | q := db. 299 | Select("dir_infos.*"). 300 | From("dir_infos"). 301 | Join("medias on medias.id = dir_infos.media_id"). 302 | Where(sq.Eq{"medias.hash": hash}). 303 | Limit(1) 304 | 305 | sql, args, _ := q.ToSql() 306 | var result DirInfo 307 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 308 | } 309 | 310 | func (db *DB) GetThumbnailByMediaHash(hash string) (*Thumbnail, error) { 311 | q := db. 312 | Select("thumbnails.*"). 313 | From("thumbnails"). 314 | Join("medias on medias.id = thumbnails.media_id"). 315 | Where(sq.Eq{"medias.hash": hash}). 316 | Limit(1) 317 | 318 | sql, args, _ := q.ToSql() 319 | var result Thumbnail 320 | return &result, pgxscan.Get(context.Background(), db, &result, sql, args...) 321 | } 322 | 323 | func (db *DB) CountDirectories() ([]*DirectoryCount, error) { 324 | q := db. 325 | Select( 326 | "directory_alias", 327 | "count(1) as count", 328 | ). 329 | From("dir_infos"). 330 | GroupBy("directory_alias") 331 | 332 | sql, args, _ := q.ToSql() 333 | var result []*DirectoryCount 334 | return result, pgxscan.Select(context.Background(), db, &result, sql, args...) 335 | } 336 | 337 | func isSortField(f string) bool { 338 | switch f { 339 | case "timestamp", "similarity": 340 | return true 341 | } 342 | return false 343 | } 344 | 345 | func isSortOrder(f string) bool { 346 | switch f { 347 | case "asc", "desc": 348 | return true 349 | } 350 | return false 351 | } 352 | 353 | func isMediaType(f MediaType) bool { 354 | switch f { 355 | case MediaTypeImage, MediaTypeVideo: 356 | return true 357 | } 358 | return false 359 | } 360 | -------------------------------------------------------------------------------- /db/migrations/001_init.sql: -------------------------------------------------------------------------------- 1 | create extension pg_trgm; 2 | 3 | create table schema_version ( 4 | version smallint primary key 5 | ); 6 | 7 | insert into schema_version 8 | values (0); 9 | 10 | create type media_type as enum ( 11 | 'image', 12 | 'video' 13 | ); 14 | 15 | create table medias ( 16 | id serial primary key, 17 | type media_type not null, 18 | mime text not null, 19 | hash text not null, 20 | timestamp timestamptz not null, 21 | dim_width int not null, 22 | dim_height int not null, 23 | dominant_colour text not null, 24 | blurhash text not null 25 | ); 26 | 27 | create unique index idx_medias_hash on medias (hash); 28 | 29 | create table dir_infos ( 30 | media_id int references medias (id) on delete cascade, 31 | filename text not null, 32 | directory_alias text not null, 33 | primary key (media_id, filename, directory_alias) 34 | ); 35 | 36 | create table blocks ( 37 | id serial primary key, 38 | media_id integer not null references medias (id) on delete cascade, 39 | index int not null, 40 | min_x int not null, 41 | min_y int not null, 42 | max_x int not null, 43 | max_y int not null, 44 | body text not null 45 | ); 46 | 47 | create index idx_blocks_body on blocks using gin (body gin_trgm_ops); 48 | 49 | -------------------------------------------------------------------------------- /db/migrations/002_add_thumbnails.sql: -------------------------------------------------------------------------------- 1 | create table thumbnails ( 2 | id serial primary key, 3 | media_id integer not null references medias (id) on delete cascade, 4 | mime text not null, 5 | dim_width int not null, 6 | dim_height int not null, 7 | timestamp timestamptz not null, 8 | data bytea not null 9 | ); 10 | 11 | create unique index idx_thumbnails_media_id on thumbnails (media_id); 12 | 13 | -------------------------------------------------------------------------------- /db/migrations/003_add_media_processed.sql: -------------------------------------------------------------------------------- 1 | alter table medias 2 | add column processed boolean not null default false; 3 | 4 | update 5 | medias 6 | set 7 | processed = true; 8 | 9 | -------------------------------------------------------------------------------- /db/migrations/004_add_timestamp_indexes.sql: -------------------------------------------------------------------------------- 1 | create index idx_medias_timestamp on medias (timestamp); 2 | 3 | -------------------------------------------------------------------------------- /db/types.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type BlockID int 8 | type Block struct { 9 | ID BlockID `db:"id" json:"id"` 10 | MediaID MediaID `db:"media_id" json:"media_id"` 11 | Index int `db:"index" json:"index"` 12 | MinX int `db:"min_x" json:"min_x"` 13 | MinY int `db:"min_y" json:"min_y"` 14 | MaxX int `db:"max_x" json:"max_x"` 15 | MaxY int `db:"max_y" json:"max_y"` 16 | Body string `db:"body" json:"body"` 17 | } 18 | 19 | type MediaType string 20 | 21 | const ( 22 | MediaTypeImage MediaType = "image" 23 | MediaTypeVideo MediaType = "video" 24 | ) 25 | 26 | type MediaID int 27 | type Media struct { 28 | ID MediaID `db:"id" json:"id"` 29 | Type MediaType `db:"type" json:"type"` 30 | MIME string `db:"mime" json:"mime"` 31 | Hash string `db:"hash" json:"hash"` 32 | Timestamp time.Time `db:"timestamp" json:"timestamp"` 33 | DimWidth int `db:"dim_width" json:"dim_width"` 34 | DimHeight int `db:"dim_height" json:"dim_height"` 35 | DominantColour string `db:"dominant_colour" json:"dominant_colour"` 36 | Blurhash string `db:"blurhash" json:"blurhash"` 37 | Similarity float64 `db:"similarity" json:"similarity,omitempty"` 38 | Blocks []*Block `db:"blocks" json:"blocks,omitempty"` 39 | HighlightedBlocks []*Block `db:"highlighted_blocks" json:"highlighted_blocks,omitempty"` 40 | Directories []string `db:"directories" json:"directories,omitempty"` 41 | Processed bool `db:"processed" json:"processed"` 42 | } 43 | 44 | type ThumbnailID int 45 | type Thumbnail struct { 46 | ID ThumbnailID `db:"id" json:"id"` 47 | MediaID MediaID `db:"media_id" json:"media_id"` 48 | MIME string `db:"mime" json:"mime"` 49 | DimWidth int `db:"dim_width" json:"dim_width"` 50 | DimHeight int `db:"dim_height" json:"dim_height"` 51 | Timestamp time.Time `db:"timestamp" json:"timestamp"` 52 | Data []byte `db:"data" json:"-"` 53 | } 54 | 55 | type DirInfo struct { 56 | MediaID MediaID `db:"media_id" json:"media_id"` 57 | Filename string `db:"filename" json:"filename"` 58 | DirectoryAlias string `db:"directory_alias" json:"directory_alias"` 59 | } 60 | 61 | type DirectoryCount struct { 62 | DirectoryAlias string `db:"directory_alias" json:"directory_alias"` 63 | Count int `db:"count" json:"count"` 64 | } 65 | -------------------------------------------------------------------------------- /directories/directories.go: -------------------------------------------------------------------------------- 1 | package directories 2 | 3 | // alias -> path 4 | type Directories map[string]string 5 | 6 | func (d Directories) PathByAlias(alias string) (string, bool) { 7 | alias, ok := d[alias] 8 | return alias, ok 9 | } 10 | 11 | func (d Directories) AliasByPath(path string) (string, bool) { 12 | for k, v := range d { 13 | if v == path { 14 | return k, true 15 | } 16 | } 17 | return "", false 18 | } 19 | -------------------------------------------------------------------------------- /docker-compose.yml.example: -------------------------------------------------------------------------------- 1 | version: "3" 2 | networks: 3 | internal: null 4 | reverse_proxy: 5 | external: true 6 | services: 7 | socr_db: 8 | environment: 9 | - POSTGRES_USER=socr 10 | - POSTGRES_PASSWORD=socr 11 | - TZ 12 | expose: 13 | - 5432 14 | image: postgres:13.2 15 | networks: 16 | - internal 17 | volumes: 18 | - ./db_data:/var/lib/postgresql/data 19 | socr: 20 | image: sentriz/socr:latest 21 | depends_on: 22 | - socr_db 23 | environment: 24 | - TZ 25 | - SOCR_LISTEN_ADDR=:80 26 | - SOCR_DB_DSN=postgres://socr:socr@socr_db:5432?sslmode=disable 27 | - SOCR_HMAC_SECRET=57c7e9ce3bdf663cdf15dc9ca4b7d50d # change me 28 | - SOCR_API_KEY=bcbdf7753d68d9a1fbb68e25591965fb # change me 29 | - SOCR_LOGIN_USERNAME=username # change me 30 | - SOCR_LOGIN_PASSWORD=password # change me 31 | - SOCR_DIR_EXAMPLE_A=/screenshots/example_a # change or add more of me 32 | - SOCR_DIR_EXAMPLE_B=/screenshots/example_b # change or add more of me 33 | - SOCR_DIR_UPLOADS=/screenshots/uploads 34 | expose: 35 | - 80 36 | labels: 37 | traefik.enable: "true" 38 | traefik.http.routers.socr.entrypoints: web 39 | traefik.http.routers.socr.rule: Host(`socr.example.com`) 40 | traefik.http.services.socr.loadbalancer.server.port: 80 41 | networks: 42 | - internal 43 | - reverse_proxy 44 | volumes: 45 | - ./screenshots/example_a:/screenshots/example_a:ro 46 | - ./screenshots/example_b:/screenshots/example_b:ro 47 | - ./screenshots/uploads:/screenshots/uploads 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.senan.xyz/socr 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/Masterminds/squirrel v1.5.4 7 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 8 | github.com/buckket/go-blurhash v1.1.0 9 | github.com/cenkalti/dominantcolor v1.0.0 10 | github.com/cespare/xxhash v1.1.0 11 | github.com/fsnotify/fsnotify v1.6.0 12 | github.com/georgysavva/scany v1.2.1 13 | github.com/golang-jwt/jwt/v4 v4.5.0 14 | github.com/gorilla/handlers v1.5.1 15 | github.com/gorilla/mux v1.8.0 16 | github.com/gorilla/websocket v1.5.0 17 | github.com/jackc/pgx/v4 v4.18.1 18 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 19 | github.com/otiai10/gosseract/v2 v2.4.1-0.20230623133433-7bc6b8d616e9 20 | ) 21 | 22 | require ( 23 | github.com/felixge/httpsnoop v1.0.3 // indirect 24 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect 25 | github.com/jackc/pgconn v1.14.1 // indirect 26 | github.com/jackc/pgio v1.0.0 // indirect 27 | github.com/jackc/pgpassfile v1.0.0 // indirect 28 | github.com/jackc/pgproto3/v2 v2.3.2 // indirect 29 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 30 | github.com/jackc/pgtype v1.14.0 // indirect 31 | github.com/jackc/puddle v1.3.0 // indirect 32 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 33 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 34 | golang.org/x/crypto v0.11.0 // indirect 35 | golang.org/x/sys v0.10.0 // indirect 36 | golang.org/x/text v0.11.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= 3 | github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= 4 | github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= 5 | github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= 6 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 7 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= 8 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= 9 | github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do= 10 | github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8= 11 | github.com/cenkalti/dominantcolor v1.0.0 h1:MFLKUzcxQf65GRQdCcpcMlEFYvvy4Y51+eJ4bLpe4bM= 12 | github.com/cenkalti/dominantcolor v1.0.0/go.mod h1:/fauwSWvIFhvyrHSOhqRwdnjZLETEl5ocyxCkakCI/Q= 13 | github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= 14 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 15 | github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= 16 | github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= 17 | github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= 18 | github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= 19 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 20 | github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 21 | github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 26 | github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= 27 | github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 28 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 29 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 30 | github.com/georgysavva/scany v1.2.1 h1:91PAMBpwBtDjvn46TaLQmuVhxpAG6p6sjQaU4zPHPSM= 31 | github.com/georgysavva/scany v1.2.1/go.mod h1:vGBpL5XRLOocMFFa55pj0P04DrL3I7qKVRL49K6Eu5o= 32 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 33 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 34 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 35 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 36 | github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= 37 | github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= 38 | github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 39 | github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= 40 | github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 41 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 42 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 43 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 44 | github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= 45 | github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= 46 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 47 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 48 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 49 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 50 | github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 51 | github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 52 | github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= 53 | github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 54 | github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= 55 | github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= 56 | github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= 57 | github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk= 58 | github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= 59 | github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= 60 | github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= 61 | github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= 62 | github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= 63 | github.com/jackc/pgconn v1.14.0/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E= 64 | github.com/jackc/pgconn v1.14.1 h1:smbxIaZA08n6YuxEX1sDyjV/qkbtUtkH20qLkR9MUR4= 65 | github.com/jackc/pgconn v1.14.1/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E= 66 | github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= 67 | github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= 68 | github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= 69 | github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= 70 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= 71 | github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= 72 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 73 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 74 | github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= 75 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= 76 | github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= 77 | github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 78 | github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= 79 | github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 80 | github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 81 | github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 82 | github.com/jackc/pgproto3/v2 v2.3.2 h1:7eY55bdBeCz1F2fTzSz69QC+pG46jYq9/jtSPiJ5nn0= 83 | github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= 84 | github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 85 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= 86 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 87 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 88 | github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= 89 | github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= 90 | github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= 91 | github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0= 92 | github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po= 93 | github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ= 94 | github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig= 95 | github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= 96 | github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= 97 | github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= 98 | github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= 99 | github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= 100 | github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= 101 | github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA= 102 | github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o= 103 | github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg= 104 | github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA= 105 | github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= 106 | github.com/jackc/pgx/v4 v4.18.1 h1:YP7G1KABtKpB5IHrO9vYwSrCOhs7p3uqhvhhQBptya0= 107 | github.com/jackc/pgx/v4 v4.18.1/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE= 108 | github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 109 | github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 110 | github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 111 | github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 112 | github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 113 | github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= 114 | github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= 115 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 116 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 117 | github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= 118 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 119 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 120 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 121 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 122 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 123 | github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 124 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 125 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= 126 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= 127 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= 128 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= 129 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 130 | github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 131 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 132 | github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 133 | github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 134 | github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= 135 | github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 136 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 137 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 138 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 139 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 140 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 141 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 142 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 143 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 144 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 145 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 146 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 147 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 148 | github.com/otiai10/gosseract/v2 v2.4.1-0.20230623133433-7bc6b8d616e9 h1:rhGqk4C8Juh9BbYBu8TQ/HKFFSeVFTuiaS4RdAKwQ4c= 149 | github.com/otiai10/gosseract/v2 v2.4.1-0.20230623133433-7bc6b8d616e9/go.mod h1:V8bkEo+kTxvA9CmExTAIdxRfQtmeQYJUZzgzUPslX0U= 150 | github.com/otiai10/mint v1.4.1 h1:HOVBfKP1oXIc0wWo9hZ8JLdZtyCPWqjvmFDuVZ0yv2Y= 151 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 152 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 153 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 154 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 155 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 156 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 157 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 158 | github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= 159 | github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= 160 | github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= 161 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 162 | github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= 163 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 164 | github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 165 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 166 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 167 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 168 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 169 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= 170 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 171 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 172 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 173 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 174 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 175 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 176 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 177 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 178 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 179 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 180 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 181 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 182 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 183 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 184 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 185 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 186 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 187 | github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 188 | go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 189 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 190 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 191 | go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 192 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 193 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 194 | go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 195 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 196 | go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 197 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 198 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 199 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 200 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 201 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 202 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 203 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 204 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 205 | golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 206 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 207 | golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 208 | golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 209 | golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 210 | golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 211 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 212 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 213 | golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= 214 | golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= 215 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 216 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 217 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 218 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 219 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 220 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 221 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 222 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 223 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 224 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 225 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 226 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 227 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 228 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 229 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 230 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 231 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 232 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 233 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 234 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 235 | golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 236 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 237 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 238 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 239 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 240 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 241 | golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 242 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 243 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 244 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 245 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 246 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 247 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 248 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 249 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 250 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 251 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 252 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 253 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 254 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 255 | golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 256 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 257 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 258 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 259 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 260 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 261 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 262 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 263 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 264 | golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 265 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 266 | golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 267 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 268 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 269 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 270 | golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 271 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 272 | golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 273 | golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 274 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 275 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 276 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 277 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 278 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 279 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 280 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 281 | gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= 282 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 283 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 284 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 285 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 286 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 287 | gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg= 288 | gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 289 | gorm.io/gorm v1.21.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= 290 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 291 | -------------------------------------------------------------------------------- /imagery/imagery.go: -------------------------------------------------------------------------------- 1 | //nolint:gochecknoglobals 2 | package imagery 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "image" 8 | "image/color" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/buckket/go-blurhash" 16 | "github.com/cenkalti/dominantcolor" 17 | "github.com/cespare/xxhash" 18 | "github.com/nfnt/resize" 19 | gosseract "github.com/otiai10/gosseract/v2" 20 | ) 21 | 22 | type MediaType string 23 | 24 | const ( 25 | TypeImage MediaType = "image" 26 | TypeVideo MediaType = "video" 27 | ) 28 | 29 | type Media interface { 30 | Type() MediaType 31 | MIME() string 32 | Hash() string 33 | Extension() string 34 | Thumbnail(w, h uint) image.Image 35 | Image() image.Image 36 | } 37 | 38 | func ExtractText(img []byte) ([]gosseract.BoundingBox, error) { 39 | client := gosseract.NewClient() 40 | defer client.Close() 41 | if err := client.SetImageFromBytes(img); err != nil { 42 | return nil, fmt.Errorf("set image bytes: %w", err) 43 | } 44 | 45 | if err := client.SetPageSegMode(gosseract.PSM_AUTO_OSD); err != nil { 46 | return nil, fmt.Errorf("set page setmentation mode: %w", err) 47 | } 48 | 49 | boxes, err := client.GetBoundingBoxes(gosseract.RIL_TEXTLINE) 50 | if err != nil { 51 | return nil, fmt.Errorf("get bounding boxes: %w", err) 52 | } 53 | 54 | return boxes, nil 55 | } 56 | 57 | const ( 58 | ScaleFactor = 3 59 | ) 60 | 61 | func ResizeFactor(img image.Image, factor int) image.Image { 62 | return resize.Resize( 63 | uint(img.Bounds().Max.X*factor), 0, 64 | img, resize.Lanczos3, 65 | ) 66 | } 67 | 68 | func Resize(img image.Image, width, height uint) image.Image { 69 | return resize.Resize(width, height, img, resize.Lanczos3) 70 | } 71 | 72 | func ScaleDownRect(rect image.Rectangle) image.Rectangle { 73 | return image.Rectangle{ 74 | Min: image.Point{X: rect.Min.X / ScaleFactor, Y: rect.Min.Y / ScaleFactor}, 75 | Max: image.Point{X: rect.Max.X / ScaleFactor, Y: rect.Max.Y / ScaleFactor}, 76 | } 77 | } 78 | 79 | func GreyScale(img image.Image) *image.Gray { 80 | bounds := img.Bounds() 81 | gray := image.NewGray(bounds) 82 | for x := 0; x < bounds.Max.X; x++ { 83 | for y := 0; y < bounds.Max.Y; y++ { 84 | gray.Set(x, y, img.At(x, y)) 85 | } 86 | } 87 | return gray 88 | } 89 | 90 | const ( 91 | BlurhashXC = 4 92 | BlurhashYC = 3 93 | ) 94 | 95 | func CalculateBlurhash(img image.Image) (string, error) { 96 | return blurhash.Encode(BlurhashXC, BlurhashXC, img) 97 | } 98 | 99 | func DominantColour(img image.Image) (color.Color, string) { 100 | colour := dominantcolor.Find(img) 101 | hex := dominantcolor.Hex(colour) 102 | return colour, hex 103 | } 104 | 105 | func VideoThumbnail(data []byte) (image.Image, error) { 106 | tmp, err := os.CreateTemp("", "") 107 | if err != nil { 108 | return nil, fmt.Errorf("create temp: %w", err) 109 | } 110 | if _, err := tmp.Write(data); err != nil { 111 | return nil, fmt.Errorf("write video to tmp: %w", err) 112 | } 113 | tmp.Close() 114 | defer os.Remove(tmp.Name()) 115 | 116 | cmd := exec.Command("ffmpeg", "-i", tmp.Name(), "-vframes", "1", "-f", "image2pipe", "-") //nolint:gosec 117 | 118 | var buff bytes.Buffer 119 | cmd.Stdout = &buff 120 | 121 | if err := cmd.Run(); err != nil { 122 | return nil, fmt.Errorf("run ffmpeg: %w", err) 123 | } 124 | 125 | img, _, err := image.Decode(&buff) 126 | if err != nil { 127 | return nil, fmt.Errorf("decode thumbnail: %w", err) 128 | } 129 | 130 | return img, nil 131 | } 132 | 133 | func NewMedia(raw []byte) (Media, error) { 134 | switch mime := http.DetectContentType(raw); mime { 135 | case "image/gif", "image/png", "image/jpeg": 136 | return newMediaImage(raw, mime) 137 | case "video/webm", "video/mp4", "video/mpeg": 138 | return newMediaVideo(raw, mime) 139 | default: 140 | return nil, fmt.Errorf("unknown image or video mime %q", mime) 141 | } 142 | } 143 | 144 | type mediaImage struct { 145 | image image.Image 146 | mime string 147 | hash string 148 | } 149 | 150 | func newMediaImage(raw []byte, mime string) (*mediaImage, error) { 151 | image, _, err := image.Decode(bytes.NewReader(raw)) 152 | if err != nil { 153 | return nil, fmt.Errorf("decode: %w", err) 154 | } 155 | return &mediaImage{image, mime, hashBytes(raw)}, err 156 | } 157 | 158 | func (m *mediaImage) Type() MediaType { return TypeImage } 159 | func (m *mediaImage) MIME() string { return m.mime } 160 | func (m *mediaImage) Hash() string { return m.hash } 161 | func (m *mediaImage) Extension() string { return mimeExtension(m.mime) } 162 | func (m *mediaImage) Image() image.Image { return m.image } 163 | func (m *mediaImage) Thumbnail(w, h uint) image.Image { return Resize(m.image, w, h) } 164 | 165 | type mediaVideo struct { 166 | image image.Image 167 | mime string 168 | hash string 169 | } 170 | 171 | func newMediaVideo(raw []byte, mime string) (*mediaVideo, error) { 172 | image, err := VideoThumbnail(raw) 173 | if err != nil { 174 | return nil, fmt.Errorf("get thumbnail: %w", err) 175 | } 176 | return &mediaVideo{image, mime, hashBytes(raw)}, err 177 | } 178 | 179 | func (m *mediaVideo) Type() MediaType { return TypeVideo } 180 | func (m *mediaVideo) MIME() string { return m.mime } 181 | func (m *mediaVideo) Hash() string { return m.hash } 182 | func (m *mediaVideo) Extension() string { return mimeExtension(m.mime) } 183 | func (m *mediaVideo) Image() image.Image { return m.image } 184 | func (m *mediaVideo) Thumbnail(w, h uint) image.Image { return Resize(m.image, w, h) } 185 | 186 | func hashBytes(bytes []byte) string { 187 | sum := xxhash.Sum64(bytes) 188 | hash := strconv.FormatUint(sum, 16) 189 | return hash 190 | } 191 | 192 | func mimeExtension(mime string) string { 193 | _, name, _ := strings.Cut(mime, "/") 194 | return name 195 | } 196 | 197 | var _ Media = (*mediaImage)(nil) 198 | var _ Media = (*mediaVideo)(nil) 199 | -------------------------------------------------------------------------------- /importer/importer.go: -------------------------------------------------------------------------------- 1 | package importer 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "image" 8 | "io" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/araddon/dateparse" 18 | "github.com/fsnotify/fsnotify" 19 | "github.com/jackc/pgx/v4" 20 | 21 | "go.senan.xyz/socr/db" 22 | "go.senan.xyz/socr/directories" 23 | "go.senan.xyz/socr/imagery" 24 | ) 25 | 26 | type EncodeFunc func(io.Writer, image.Image) error 27 | 28 | type NotifyMediaFunc func(hash string) 29 | type NotifyProgressFunc func() 30 | 31 | type Importer struct { 32 | db *db.DB 33 | defaultEncoder EncodeFunc 34 | defaultMIME string 35 | directories directories.Directories 36 | directoriesUploadsAlias string 37 | thumbnailWidth uint 38 | 39 | status Status 40 | jobs chan *mediaFile 41 | notifyMediaFuncs []NotifyMediaFunc 42 | notifyProgressFuncs []NotifyProgressFunc 43 | } 44 | 45 | func New( 46 | db *db.DB, defaultEncoder EncodeFunc, defaultMIME string, 47 | directories directories.Directories, directoriesUploadsAlias string, thumbnailWidth uint, 48 | ) *Importer { 49 | return &Importer{ 50 | db: db, 51 | defaultEncoder: defaultEncoder, 52 | defaultMIME: defaultMIME, 53 | directories: directories, 54 | directoriesUploadsAlias: directoriesUploadsAlias, 55 | thumbnailWidth: thumbnailWidth, 56 | 57 | status: Status{mu: &sync.RWMutex{}}, 58 | jobs: make(chan *mediaFile), 59 | } 60 | } 61 | 62 | func (i *Importer) AddNotifyMediaFunc(f NotifyMediaFunc) { 63 | i.notifyMediaFuncs = append(i.notifyMediaFuncs, f) 64 | } 65 | 66 | func (i *Importer) AddNotifyProgressFunc(f NotifyProgressFunc) { 67 | i.notifyProgressFuncs = append(i.notifyProgressFuncs, f) 68 | } 69 | 70 | func (i *Importer) ImportMedia(media imagery.Media, dirAlias string, fileName string, timestamp time.Time) error { 71 | id, isOld, err := i.insertMedia(media, timestamp) 72 | if err != nil { 73 | return fmt.Errorf("import media with props: %w", err) 74 | } 75 | if err := i.insertDirInfo(id, dirAlias, fileName); err != nil { 76 | return fmt.Errorf("import dir info: %w", err) 77 | } 78 | for _, f := range i.notifyMediaFuncs { 79 | f(media.Hash()) 80 | } 81 | 82 | if isOld { 83 | return nil 84 | } 85 | 86 | if err := i.insertThumbnail(id, media.Image()); err != nil { 87 | return fmt.Errorf("import thumbnail: %w", err) 88 | } 89 | if err := i.insertBlocks(id, media.Image()); err != nil { 90 | return fmt.Errorf("import blocks: %w", err) 91 | } 92 | if err := i.db.SetMediaProcessed(id); err != nil { 93 | return fmt.Errorf("set media processed: %w", err) 94 | } 95 | for _, f := range i.notifyMediaFuncs { 96 | f(media.Hash()) 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func (i *Importer) ImportMediaFromFile(dirAlias, dir, fileName string, modTime time.Time) (string, error) { 103 | _, err := i.db.GetDirInfo(dirAlias, fileName) 104 | if err != nil && !errors.Is(err, pgx.ErrNoRows) { 105 | return "", fmt.Errorf("getting dir info: %w", err) 106 | } 107 | if err == nil { 108 | return "", nil 109 | } 110 | 111 | log.Printf("importing new item. alias %q, filename %q", dirAlias, fileName) 112 | 113 | filePath := filepath.Join(dir, fileName) 114 | raw, err := os.ReadFile(filePath) 115 | if err != nil { 116 | return "", fmt.Errorf("open file: %w", err) 117 | } 118 | 119 | media, err := imagery.NewMedia(raw) 120 | if err != nil { 121 | return "", fmt.Errorf("decode and hash media: %w", err) 122 | } 123 | 124 | timestamp := GuessFileCreated(fileName, modTime) 125 | 126 | if err := i.ImportMedia(media, dirAlias, fileName, timestamp); err != nil { 127 | return "", fmt.Errorf("importing media: %w", err) 128 | } 129 | 130 | return media.Hash(), nil 131 | } 132 | 133 | func (i *Importer) ScanDirectories() error { 134 | if i.IsRunning() { 135 | return fmt.Errorf("already running") 136 | } 137 | 138 | i.updateStatus(func(s *Status) { 139 | s.Running = true 140 | s.CountTotal = 0 141 | s.CountProcessed = 0 142 | s.LastHash = "" 143 | s.Errors = Errors{} 144 | }) 145 | defer i.updateStatus(func(s *Status) { 146 | s.Running = false 147 | }) 148 | 149 | var mediaFiles []*mediaFile 150 | for alias, dir := range i.directories { 151 | files, err := os.ReadDir(dir) 152 | if err != nil { 153 | return fmt.Errorf("listing dir %q: %w", dir, err) 154 | } 155 | for _, file := range files { 156 | if file.IsDir() { 157 | continue 158 | } 159 | fileName := file.Name() 160 | info, err := file.Info() 161 | if err != nil { 162 | return fmt.Errorf("get file info %q: %w", fileName, err) 163 | } 164 | modTime := info.ModTime() 165 | mediaFiles = append(mediaFiles, &mediaFile{alias, dir, fileName, modTime}) 166 | } 167 | } 168 | 169 | i.updateStatus(func(s *Status) { 170 | s.CountTotal = len(mediaFiles) 171 | }) 172 | 173 | for idx, mediaFile := range mediaFiles { 174 | i.jobs <- mediaFile 175 | i.updateStatus(func(s *Status) { 176 | s.CountProcessed = idx + 1 177 | }) 178 | } 179 | return nil 180 | } 181 | 182 | func (i *Importer) Status() Status { 183 | i.status.mu.RLock() 184 | defer i.status.mu.RUnlock() 185 | return i.status 186 | } 187 | 188 | func (i *Importer) IsRunning() bool { 189 | i.status.mu.RLock() 190 | defer i.status.mu.RUnlock() 191 | return i.status.Running 192 | } 193 | 194 | func (i *Importer) StartWorker() { 195 | for j := range i.jobs { 196 | hash, err := i.ImportMediaFromFile(j.dirAlias, j.dir, j.fileName, j.modTime) 197 | i.updateStatus(func(s *Status) { 198 | s.LastHash = hash 199 | s.AddError(err) 200 | }) 201 | } 202 | } 203 | 204 | func (i *Importer) updateStatus(f func(*Status)) { 205 | i.status.mu.Lock() 206 | defer i.status.mu.Unlock() 207 | f(&i.status) 208 | for _, f := range i.notifyProgressFuncs { 209 | f() 210 | } 211 | } 212 | 213 | func (i *Importer) insertMedia(media imagery.Media, timestamp time.Time) (db.MediaID, bool, error) { 214 | old, err := i.db.GetMediaByHash(media.Hash()) 215 | if err != nil && !errors.Is(err, pgx.ErrNoRows) { 216 | return 0, false, fmt.Errorf("getting media by hash: %w", err) 217 | } 218 | if err == nil { 219 | return old.ID, true, nil 220 | } 221 | 222 | _, propDominantColour := imagery.DominantColour(media.Image()) 223 | 224 | propBlurhash, err := imagery.CalculateBlurhash(media.Image()) 225 | if err != nil { 226 | return 0, false, fmt.Errorf("calculate blurhash: %w", err) 227 | } 228 | 229 | propDimensions := media.Image().Bounds().Size() 230 | new, err := i.db.CreateMedia(&db.Media{ 231 | Hash: media.Hash(), 232 | Type: db.MediaType(media.Type()), 233 | MIME: media.MIME(), 234 | Timestamp: timestamp, 235 | DimWidth: propDimensions.X, 236 | DimHeight: propDimensions.Y, 237 | DominantColour: propDominantColour, 238 | Blurhash: propBlurhash, 239 | }) 240 | if err != nil { 241 | return 0, false, fmt.Errorf("inserting media: %w", err) 242 | } 243 | 244 | return new.ID, false, nil 245 | } 246 | 247 | func (i *Importer) insertBlocks(id db.MediaID, image image.Image) error { 248 | imageGrey := imagery.GreyScale(image) 249 | imageBig := imagery.ResizeFactor(imageGrey, imagery.ScaleFactor) 250 | imageEncoded := &bytes.Buffer{} 251 | if err := i.defaultEncoder(imageEncoded, imageBig); err != nil { 252 | return fmt.Errorf("encode scaled and greyed image: %w", err) 253 | } 254 | rawBlocks, err := imagery.ExtractText(imageEncoded.Bytes()) 255 | if err != nil { 256 | return fmt.Errorf("extract image text: %w", err) 257 | } 258 | 259 | blocks := make([]*db.Block, 0, len(rawBlocks)) 260 | for idx, rawBlock := range rawBlocks { 261 | if strings.TrimSpace(rawBlock.Word) == "" { 262 | continue 263 | } 264 | 265 | rect := imagery.ScaleDownRect(rawBlock.Box) 266 | blocks = append(blocks, &db.Block{ 267 | MediaID: id, 268 | Index: idx, 269 | MinX: rect.Min.X, 270 | MinY: rect.Min.Y, 271 | MaxX: rect.Max.X, 272 | MaxY: rect.Max.Y, 273 | Body: rawBlock.Word, 274 | }) 275 | } 276 | 277 | if err := i.db.CreateBlocks(blocks); err != nil { 278 | return fmt.Errorf("inserting blocks: %w", err) 279 | } 280 | return nil 281 | } 282 | 283 | func (i *Importer) insertThumbnail(id db.MediaID, image image.Image) error { 284 | resized := imagery.Resize(image, i.thumbnailWidth, 0) 285 | dimensions := resized.Bounds().Size() 286 | 287 | var data bytes.Buffer 288 | if err := i.defaultEncoder(&data, resized); err != nil { 289 | return fmt.Errorf("encoding thumbnail: %w", err) 290 | } 291 | 292 | thumbnail := &db.Thumbnail{ 293 | MediaID: id, 294 | MIME: i.defaultMIME, 295 | DimWidth: dimensions.X, 296 | DimHeight: dimensions.Y, 297 | Timestamp: time.Now(), 298 | Data: data.Bytes(), 299 | } 300 | if _, err := i.db.CreateThumbnail(thumbnail); err != nil { 301 | return fmt.Errorf("insert thumbnail: %w", err) 302 | } 303 | return nil 304 | } 305 | 306 | func (i *Importer) insertDirInfo(id db.MediaID, dirAlias string, fileName string) error { 307 | dirInfo := &db.DirInfo{ 308 | Filename: fileName, 309 | DirectoryAlias: dirAlias, 310 | MediaID: id, 311 | } 312 | if _, err := i.db.CreateDirInfo(dirInfo); err != nil { 313 | return fmt.Errorf("insert info dir infos: %w", err) 314 | } 315 | return nil 316 | } 317 | 318 | var fileStampExpr = regexp.MustCompile(`(?:\D|^)(?P(?:19|20|21)\d{6})\D?(?P\d{6})(?:\D|$)`) 319 | 320 | func GuessFileCreated(fileName string, modTime time.Time) time.Time { 321 | fileName = filepath.Base(fileName) 322 | fileName = strings.TrimPrefix(fileName, "IMG_") 323 | fileName = strings.TrimPrefix(fileName, "VID_") 324 | fileName = strings.TrimPrefix(fileName, "img_") 325 | fileName = strings.TrimPrefix(fileName, "vid_") 326 | fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) 327 | 328 | // first try RFC3339 329 | if guessed, err := time.Parse(time.RFC3339, fileName); err == nil { 330 | return guessed 331 | } 332 | 333 | // if that doesn't work, try the date parse library 334 | if guessed, err := dateparse.ParseLocal(fileName); err == nil { 335 | return guessed 336 | } 337 | 338 | // maybe a YYYYMMDD-HHMMSS pattern 339 | if m := fileStampExpr.FindStringSubmatch(fileName); len(m) > 0 { 340 | ymd := m[fileStampExpr.SubexpIndex("ymd")] 341 | hms := m[fileStampExpr.SubexpIndex("hms")] 342 | guessed, _ := time.Parse("20060102150405", ymd+hms) 343 | return guessed 344 | } 345 | 346 | // otherwise, fallback to the file's mod time 347 | return modTime 348 | } 349 | 350 | func (i *Importer) WatchUpdates() error { 351 | watcher, err := fsnotify.NewWatcher() 352 | if err != nil { 353 | return fmt.Errorf("create watcher: %w", err) 354 | } 355 | for alias, dir := range i.directories { 356 | if alias == i.directoriesUploadsAlias { 357 | continue 358 | } 359 | if err = watcher.Add(dir); err != nil { 360 | return fmt.Errorf("add watcher for %q: %w", alias, err) 361 | } 362 | log.Printf("starting watcher for %q", dir) 363 | } 364 | for event := range watcher.Events { 365 | if event.Op&fsnotify.Create != fsnotify.Create { 366 | continue 367 | } 368 | if strings.HasSuffix(event.Name, ".tmp") { 369 | continue 370 | } 371 | dir := filepath.Dir(event.Name) 372 | dirAlias, ok := i.directories.AliasByPath(dir) 373 | if !ok { 374 | continue 375 | } 376 | fileName := filepath.Base(event.Name) 377 | modTime := time.Now() 378 | if _, err := i.ImportMediaFromFile(dirAlias, dir, fileName, modTime); err != nil { 379 | log.Printf("error scanning directory item with event %v: %v", event, err) 380 | } 381 | } 382 | return nil 383 | } 384 | 385 | type mediaFile struct { 386 | dirAlias string 387 | dir string 388 | fileName string 389 | modTime time.Time 390 | } 391 | 392 | type Errors []StatusError 393 | type StatusError struct { 394 | Time time.Time 395 | Error error 396 | } 397 | 398 | type Status struct { 399 | mu *sync.RWMutex 400 | Running bool 401 | CountTotal int 402 | CountProcessed int 403 | LastHash string 404 | Errors Errors 405 | } 406 | 407 | func (s *Status) AddError(err error) { 408 | if err == nil { 409 | return 410 | } 411 | s.Errors = append(s.Errors, StatusError{ 412 | Time: time.Now(), 413 | Error: err, 414 | }) 415 | if len(s.Errors) > 20 { 416 | s.Errors = s.Errors[1:] 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /importer/importer_test.go: -------------------------------------------------------------------------------- 1 | package importer_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "go.senan.xyz/socr/importer" 8 | ) 9 | 10 | func TestGuessFileCreated(t *testing.T) { 11 | tcases := []struct { 12 | filename string 13 | stamp time.Time 14 | }{ 15 | {filename: "20170520_012747.png", stamp: time.Date(2017, 05, 20, 01, 27, 47, 0, time.UTC)}, 16 | {filename: "20170520_012747.mkv", stamp: time.Date(2017, 05, 20, 01, 27, 47, 0, time.UTC)}, 17 | {filename: "IMG_20190405_134142.png", stamp: time.Date(2019, 4, 5, 13, 41, 42, 0, time.UTC)}, 18 | {filename: "img_19940405_134142 (copy 1).png", stamp: time.Date(1994, 4, 5, 13, 41, 42, 0, time.UTC)}, 19 | {filename: "2021-05-18T14:14:30+01:00.png", stamp: time.Date(2021, 05, 18, 13, 14, 30, 0, time.UTC)}, 20 | {filename: "2011-05-18T14:14:30+01:00.png", stamp: time.Date(2011, 05, 18, 13, 14, 30, 0, time.UTC)}, 21 | } 22 | 23 | fallback := time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC) 24 | for _, tcase := range tcases { 25 | result := importer.GuessFileCreated(tcase.filename, fallback) 26 | if same := result.Equal(tcase.stamp); !same { 27 | t.Errorf("filename %q parsed %q expected %q", tcase.filename, result, tcase.stamp) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![](.github/socr.png?v=2) 2 | 3 | 4 | 5 | ## running 6 | 7 | please see example [docker-compose](./docker-compose.yml) and run 8 | 9 | ```shell 10 | $ # ensure db is up and has the correct database 11 | $ docker-compose up -d socr_db 12 | $ docker-compose exec socr_db psql -U socr -c "create database socr" 13 | 14 | $ # if database exists, start everything 15 | $ docker-compose up -d 16 | $ docker-compose logs --tail 20 -f main 17 | ``` 18 | 19 | ### building from source 20 | 21 | requires 22 | 23 | - node 24 | - npm 25 | - go (1.19+) 26 | - ffmpeg 27 | - libtesseract-dev 28 | - libleptonica-dev 29 | 30 | ```shell 31 | $ go generate ./web/ 32 | $ go install ./cmd/socr/socr.go 33 | $ socr 34 | ``` 35 | -------------------------------------------------------------------------------- /server/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/golang-jwt/jwt/v4" 8 | ) 9 | 10 | func TokenNew(secret string) (string, error) { 11 | return jwt. 12 | NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ 13 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)), 14 | }). 15 | SignedString([]byte(secret)) 16 | } 17 | 18 | func TokenParse(secret, tokenStr string) error { 19 | token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { 20 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 21 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 22 | } 23 | return []byte(secret), nil 24 | }) 25 | if err != nil { 26 | return fmt.Errorf("parsing token: %w", err) 27 | } 28 | 29 | if _, ok := token.Claims.(jwt.MapClaims); !(ok && token.Valid) { 30 | return fmt.Errorf("unauthorised") 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gorilla/handlers" 9 | 10 | "go.senan.xyz/socr/server/auth" 11 | "go.senan.xyz/socr/server/resp" 12 | ) 13 | 14 | func (s *Server) WithCORS() func(http.Handler) http.Handler { 15 | return handlers.CORS( 16 | handlers.AllowedOrigins([]string{"*"}), 17 | handlers.AllowedMethods([]string{"GET", "OPTIONS"}), 18 | handlers.AllowedHeaders([]string{"DNT", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Range"}), 19 | handlers.MaxAge(1728000), 20 | ) 21 | } 22 | 23 | func (s *Server) WithJWT() func(http.Handler) http.Handler { 24 | return func(next http.Handler) http.Handler { 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | if checkJWT(s.hmacSecret, r) || checkJWTParam(s.hmacSecret, r) { 27 | next.ServeHTTP(w, r) 28 | return 29 | } 30 | resp.Errorf(w, http.StatusUnauthorized, "unauthorised") 31 | }) 32 | } 33 | } 34 | 35 | func (s *Server) WithAPIKey() func(http.Handler) http.Handler { 36 | return func(next http.Handler) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | if checkAPIKey(s.apiKey, r) { 39 | next.ServeHTTP(w, r) 40 | return 41 | } 42 | resp.Errorf(w, http.StatusUnauthorized, "unauthorised") 43 | }) 44 | } 45 | } 46 | 47 | func (s *Server) WithJWTOrAPIKey() func(http.Handler) http.Handler { 48 | return func(next http.Handler) http.Handler { 49 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 | if checkAPIKey(s.apiKey, r) || checkJWT(s.hmacSecret, r) || checkJWTParam(s.hmacSecret, r) { 51 | next.ServeHTTP(w, r) 52 | return 53 | } 54 | resp.Errorf(w, http.StatusUnauthorized, "unauthorised") 55 | }) 56 | } 57 | } 58 | 59 | func (s *Server) WithLogging() func(http.Handler) http.Handler { 60 | return func(next http.Handler) http.Handler { 61 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 | log.Printf("req %q", r.URL) 63 | next.ServeHTTP(w, r) 64 | }) 65 | } 66 | } 67 | 68 | func checkAPIKey(apiKey string, r *http.Request) bool { 69 | header := r.Header.Get("x-api-key") 70 | return apiKey != "" && header == apiKey 71 | } 72 | 73 | func checkJWT(hmacSecret string, r *http.Request) bool { 74 | header := r.Header.Get("authorization") 75 | header = strings.TrimPrefix(header, "bearer ") 76 | header = strings.TrimPrefix(header, "Bearer ") 77 | return auth.TokenParse(hmacSecret, header) == nil 78 | } 79 | 80 | func checkJWTParam(hmacSecret string, r *http.Request) bool { 81 | param := r.URL.Query().Get("token") 82 | return auth.TokenParse(hmacSecret, param) == nil 83 | } 84 | -------------------------------------------------------------------------------- /server/resp/resp.go: -------------------------------------------------------------------------------- 1 | package resp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func Write(w http.ResponseWriter, body interface{}) { 10 | w.Header().Set("content-type", "application/json") 11 | w.WriteHeader(http.StatusOK) 12 | _ = json.NewEncoder(w).Encode(struct { 13 | Response interface{} `json:"result"` 14 | }{ 15 | Response: body, 16 | }) 17 | } 18 | 19 | func Errorf(w http.ResponseWriter, status int, format string, a ...interface{}) { 20 | w.Header().Set("content-type", "application/json") 21 | w.WriteHeader(status) 22 | _ = json.NewEncoder(w).Encode(struct { 23 | Error string `json:"error"` 24 | }{ 25 | Error: fmt.Sprintf(format, a...), 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/gorilla/mux" 17 | "github.com/gorilla/websocket" 18 | 19 | "go.senan.xyz/socr" 20 | "go.senan.xyz/socr/db" 21 | "go.senan.xyz/socr/directories" 22 | "go.senan.xyz/socr/imagery" 23 | "go.senan.xyz/socr/importer" 24 | "go.senan.xyz/socr/server/auth" 25 | "go.senan.xyz/socr/server/resp" 26 | "go.senan.xyz/socr/web" 27 | ) 28 | 29 | type Server struct { 30 | db *db.DB 31 | directories directories.Directories 32 | directoriesUploadsAlias string 33 | socketUpgrader websocket.Upgrader 34 | importer *importer.Importer 35 | socketClientsScanner map[*websocket.Conn]struct{} 36 | socketClientsImporter map[string]map[*websocket.Conn]struct{} 37 | hmacSecret string 38 | loginUsername string 39 | loginPassword string 40 | apiKey string 41 | socketMedias chan string 42 | socketScannerUpdates chan struct{} 43 | } 44 | 45 | func New(db *db.DB, importr *importer.Importer, directories directories.Directories, uploadsAlias string, hmacSecret, loginUsername, loginPassword, apkKey string) *Server { 46 | servr := &Server{ 47 | db: db, 48 | directories: directories, 49 | directoriesUploadsAlias: uploadsAlias, 50 | socketUpgrader: websocket.Upgrader{CheckOrigin: CheckOrigin}, 51 | importer: importr, 52 | socketClientsScanner: map[*websocket.Conn]struct{}{}, 53 | socketClientsImporter: map[string]map[*websocket.Conn]struct{}{}, 54 | hmacSecret: hmacSecret, 55 | loginUsername: loginUsername, 56 | loginPassword: loginPassword, 57 | apiKey: apkKey, 58 | socketMedias: make(chan string), 59 | socketScannerUpdates: make(chan struct{}), 60 | } 61 | importr.AddNotifyMediaFunc(func(hash string) { 62 | servr.socketMedias <- hash 63 | }) 64 | importr.AddNotifyProgressFunc(func() { 65 | servr.socketScannerUpdates <- struct{}{} 66 | }) 67 | return servr 68 | } 69 | 70 | func (s *Server) Router() *mux.Router { 71 | // begin normal routes 72 | r := mux.NewRouter() 73 | r.Use(s.WithCORS()) 74 | r.Use(s.WithLogging()) 75 | r.HandleFunc("/api/authenticate", s.serveAuthenticate) 76 | r.HandleFunc("/api/media/{hash}/raw", s.serveMediaRaw) 77 | r.HandleFunc("/api/media/{hash}/thumb", s.serveMediaThumb) 78 | r.HandleFunc("/api/media/{hash}", s.serveMedia) 79 | r.HandleFunc("/api/websocket", s.serveWebSocket) 80 | 81 | // begin authenticated routes 82 | rJWT := r.NewRoute().Subrouter() 83 | rJWT.Use(s.WithJWT()) 84 | rJWT.HandleFunc("/api/ping", s.servePing) 85 | rJWT.HandleFunc("/api/start_import", s.serveStartImport) 86 | rJWT.HandleFunc("/api/about", s.serveAbout) 87 | rJWT.HandleFunc("/api/directories", s.serveDirectories) 88 | rJWT.HandleFunc("/api/import_status", s.serveImportStatus) 89 | rJWT.HandleFunc("/api/search", s.serveSearch) 90 | 91 | // begin api key routes 92 | rAPIKey := r.NewRoute().Subrouter() 93 | rAPIKey.Use(s.WithJWTOrAPIKey()) 94 | rAPIKey.HandleFunc("/api/upload", s.serveUpload) 95 | 96 | // frontend routes 97 | dist := http.FileServer(http.FS(web.Dist)) 98 | r.PathPrefix("/assets/").Handler(dist) 99 | r.Handle("/{f}.woff", dist) 100 | r.Handle("/{f}.woff2", dist) 101 | r.Handle("/favicon.ico", dist) 102 | r.Handle("/i/{hash}", openGraphReplacer("index.html", string(web.Index), func(r *http.Request) openGraphContent { 103 | media, _ := s.db.GetMediaByHash(mux.Vars(r)["hash"]) 104 | if media == nil { 105 | return openGraphContent{} 106 | } 107 | return openGraphContent{ 108 | link: joinPath(forwardedBaseURL(r), fmt.Sprintf("/api/media/%s/raw", media.Hash)), 109 | width: media.DimWidth, 110 | height: media.DimHeight, 111 | } 112 | })) 113 | r.Handle("/", dist) 114 | r.NotFoundHandler = http.RedirectHandler("/", http.StatusSeeOther) 115 | return r 116 | } 117 | 118 | func (s *Server) SocketNotifyScannerUpdate() { 119 | for range throttleChan(s.socketScannerUpdates, 500*time.Millisecond, 2*time.Second) { 120 | for client := range s.socketClientsScanner { 121 | if err := client.WriteMessage(websocket.TextMessage, []byte(nil)); err != nil { 122 | log.Printf("error writing to socket client: %v", err) 123 | client.Close() 124 | delete(s.socketClientsScanner, client) 125 | continue 126 | } 127 | } 128 | } 129 | } 130 | 131 | func (s *Server) SocketNotifyMedia() { 132 | for hash := range s.socketMedias { 133 | for client := range s.socketClientsImporter[hash] { 134 | if err := client.WriteMessage(websocket.TextMessage, []byte(nil)); err != nil { 135 | log.Printf("error writing to socket client: %v", err) 136 | client.Close() 137 | delete(s.socketClientsImporter[hash], client) 138 | continue 139 | } 140 | } 141 | } 142 | } 143 | 144 | func (s *Server) servePing(w http.ResponseWriter, r *http.Request) { 145 | resp.Write(w, struct { 146 | Status string `json:"status"` 147 | }{ 148 | Status: "ok", 149 | }) 150 | } 151 | 152 | func (s *Server) serveUpload(w http.ResponseWriter, r *http.Request) { 153 | infile, _, err := r.FormFile("i") 154 | if err != nil { 155 | resp.Errorf(w, http.StatusBadRequest, "get form file: %v", err) 156 | return 157 | } 158 | defer infile.Close() 159 | raw, err := io.ReadAll(infile) 160 | if err != nil { 161 | resp.Errorf(w, http.StatusInternalServerError, "read form file: %v", err) 162 | return 163 | } 164 | media, err := imagery.NewMedia(raw) 165 | if err != nil { 166 | resp.Errorf(w, http.StatusInternalServerError, "decoding media: %v", err) 167 | return 168 | } 169 | 170 | timestamp := time.Now().Format(time.RFC3339) 171 | uploadsDir := s.directories[s.directoriesUploadsAlias] 172 | fileName := fmt.Sprintf("%s.%s", timestamp, media.Extension()) 173 | filePath := filepath.Join(uploadsDir, fileName) 174 | if err := os.WriteFile(filePath, raw, 0600); err != nil { 175 | resp.Errorf(w, 500, "write upload to disk: %v", err) 176 | return 177 | } 178 | 179 | go func() { 180 | timestamp := time.Now() 181 | if err := s.importer.ImportMedia(media, s.directoriesUploadsAlias, fileName, timestamp); err != nil { 182 | log.Printf("error processing media %s: %v", media.Hash(), err) 183 | return 184 | } 185 | }() 186 | 187 | resp.Write(w, struct { 188 | ID string `json:"id"` 189 | }{ 190 | ID: media.Hash(), 191 | }) 192 | } 193 | 194 | func (s *Server) serveStartImport(w http.ResponseWriter, r *http.Request) { 195 | go func() { 196 | if err := s.importer.ScanDirectories(); err != nil { 197 | log.Printf("error importing: %v", err) 198 | } 199 | }() 200 | resp.Write(w, struct{}{}) 201 | } 202 | 203 | func (s *Server) serveAbout(w http.ResponseWriter, r *http.Request) { 204 | settings := map[string]interface{}{ 205 | "version": socr.Version, 206 | "api key": s.apiKey, 207 | "socket clients": len(s.socketClientsScanner), 208 | } 209 | for alias, path := range s.directories { 210 | key := fmt.Sprintf("directory %q", alias) 211 | settings[key] = path 212 | } 213 | resp.Write(w, settings) 214 | } 215 | 216 | type DirectoryCount struct { 217 | *db.DirectoryCount 218 | IsUploads bool `json:"is_uploads,omitempty"` 219 | } 220 | 221 | func (s *Server) serveDirectories(w http.ResponseWriter, r *http.Request) { 222 | rawCounts, err := s.db.CountDirectories() 223 | if err != nil { 224 | resp.Errorf(w, 500, "counting directories by alias: %v", err) 225 | return 226 | } 227 | 228 | counts := make([]*DirectoryCount, 0, len(rawCounts)) 229 | for _, raw := range rawCounts { 230 | counts = append(counts, &DirectoryCount{ 231 | DirectoryCount: raw, 232 | IsUploads: raw.DirectoryAlias == s.directoriesUploadsAlias, 233 | }) 234 | } 235 | resp.Write(w, counts) 236 | } 237 | 238 | func (s *Server) serveMediaRaw(w http.ResponseWriter, r *http.Request) { 239 | vars := mux.Vars(r) 240 | hash := vars["hash"] 241 | if hash == "" { 242 | resp.Errorf(w, http.StatusBadRequest, "no media hash provided") 243 | return 244 | } 245 | row, err := s.db.GetDirInfoByMediaHash(hash) 246 | if err != nil { 247 | resp.Errorf(w, http.StatusBadRequest, "requested media not found: %v", err) 248 | return 249 | } 250 | directory, ok := s.directories[row.DirectoryAlias] 251 | if !ok { 252 | resp.Errorf(w, 500, "media has invalid alias %q", row.DirectoryAlias) 253 | return 254 | } 255 | http.ServeFile(w, r, filepath.Join(directory, row.Filename)) 256 | } 257 | 258 | func (s *Server) serveMediaThumb(w http.ResponseWriter, r *http.Request) { 259 | vars := mux.Vars(r) 260 | hash := vars["hash"] 261 | if hash == "" { 262 | resp.Errorf(w, http.StatusBadRequest, "no media hash provided") 263 | return 264 | } 265 | row, err := s.db.GetThumbnailByMediaHash(hash) 266 | if err != nil { 267 | resp.Errorf(w, http.StatusBadRequest, "requested media not found: %v", err) 268 | return 269 | } 270 | http.ServeContent(w, r, hash, row.Timestamp, bytes.NewReader(row.Data)) 271 | } 272 | 273 | func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) { 274 | vars := mux.Vars(r) 275 | hash := vars["hash"] 276 | if hash == "" { 277 | resp.Errorf(w, http.StatusBadRequest, "no media hash provided") 278 | return 279 | } 280 | media, err := s.db.GetMediaByHashWithRelations(hash) 281 | if err != nil { 282 | resp.Errorf(w, http.StatusBadRequest, "requested media not found: %v", err) 283 | return 284 | } 285 | resp.Write(w, media) 286 | } 287 | 288 | type ServeSearchPayload struct { 289 | Body string `json:"body"` 290 | Directory string `json:"directory"` 291 | Media string `json:"media"` 292 | Limit int `json:"limit"` 293 | Offset int `json:"offset"` 294 | Sort struct { 295 | Field string `json:"field"` 296 | Order string `json:"order"` 297 | } `json:"sort"` 298 | DateFrom time.Time `json:"date_from"` 299 | DateTo time.Time `json:"date_to"` 300 | } 301 | 302 | func (s *Server) serveSearch(w http.ResponseWriter, r *http.Request) { 303 | var payload ServeSearchPayload 304 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 305 | resp.Errorf(w, 400, "decode payload: %v", err) 306 | } 307 | defer r.Body.Close() 308 | 309 | start := time.Now() 310 | medias, err := s.db.SearchMedias(db.SearchMediasOptions{ 311 | Body: payload.Body, 312 | Offset: payload.Offset, 313 | Limit: payload.Limit, 314 | SortField: payload.Sort.Field, 315 | SortOrder: payload.Sort.Order, 316 | Directory: payload.Directory, 317 | Media: db.MediaType(payload.Media), 318 | DateFrom: payload.DateFrom, 319 | DateTo: payload.DateTo, 320 | }) 321 | if err != nil { 322 | resp.Errorf(w, 500, "searching medias: %v", err) 323 | return 324 | } 325 | 326 | resp.Write(w, struct { 327 | Medias []*db.Media `json:"medias"` 328 | Took time.Duration `json:"took"` 329 | }{ 330 | Medias: medias, 331 | Took: time.Since(start), 332 | }) 333 | } 334 | 335 | func (s *Server) serveWebSocket(w http.ResponseWriter, r *http.Request) { 336 | params := r.URL.Query() 337 | token := params.Get("token") 338 | 339 | conn, err := s.socketUpgrader.Upgrade(w, r, nil) 340 | if err != nil { 341 | log.Printf("error upgrading socket connection: %v", err) 342 | return 343 | } 344 | 345 | if w := params.Get("want_settings"); w != "" { 346 | if err := auth.TokenParse(s.hmacSecret, token); err != nil { 347 | return 348 | } 349 | s.socketClientsScanner[conn] = struct{}{} 350 | } 351 | if w := params.Get("want_media_hash"); w != "" { 352 | if _, ok := s.socketClientsImporter[w]; !ok { 353 | s.socketClientsImporter[w] = map[*websocket.Conn]struct{}{} 354 | } 355 | s.socketClientsImporter[w][conn] = struct{}{} 356 | } 357 | } 358 | 359 | func (s *Server) serveAuthenticate(w http.ResponseWriter, r *http.Request) { 360 | var payload struct { 361 | Username string `json:"username"` 362 | Password string `json:"password"` 363 | } 364 | if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 365 | resp.Errorf(w, http.StatusBadRequest, "decode payload: %v", err) 366 | } 367 | 368 | hasUsername := (payload.Username == s.loginUsername) 369 | hasPassword := (payload.Password == s.loginPassword) 370 | if !(hasUsername && hasPassword) { 371 | resp.Errorf(w, http.StatusUnauthorized, "unauthorised") 372 | return 373 | } 374 | 375 | token, err := auth.TokenNew(s.hmacSecret) 376 | if err != nil { 377 | resp.Errorf(w, 500, "generating token") 378 | return 379 | } 380 | 381 | resp.Write(w, struct { 382 | Token string `json:"token"` 383 | }{ 384 | Token: token, 385 | }) 386 | } 387 | 388 | func (s *Server) serveImportStatus(w http.ResponseWriter, r *http.Request) { 389 | type respStatusError struct { 390 | Time time.Time `json:"time"` 391 | Error string `json:"error"` 392 | } 393 | type respStatus struct { 394 | Running bool `json:"running"` 395 | Errors []*respStatusError `json:"errors"` 396 | LastHash string `json:"last_hash"` 397 | CountTotal int `json:"count_total"` 398 | CountProcessed int `json:"count_processed"` 399 | } 400 | 401 | status := s.importer.Status() 402 | statusResp := &respStatus{ 403 | Running: status.Running, 404 | CountTotal: status.CountTotal, 405 | CountProcessed: status.CountProcessed, 406 | LastHash: status.LastHash, 407 | } 408 | for _, err := range status.Errors { 409 | statusResp.Errors = append(statusResp.Errors, &respStatusError{ 410 | Time: err.Time, 411 | Error: err.Error.Error(), 412 | }) 413 | } 414 | 415 | resp.Write(w, statusResp) 416 | } 417 | 418 | // used for socket upgrader 419 | // not checking origin here because currently to become a socket client, 420 | // you must know the hash of the media, or else provide a token for sensitive info. 421 | // if there is a problem with this please let me know 422 | func CheckOrigin(r *http.Request) bool { 423 | return true 424 | } 425 | 426 | func throttleChan(c <-chan struct{}, min time.Duration, max time.Duration) chan struct{} { 427 | ticker := time.NewTicker(max) 428 | lastUpdate := time.Time{} 429 | out := make(chan struct{}) 430 | update := func() { 431 | if time.Since(lastUpdate) < min { 432 | return 433 | } 434 | out <- struct{}{} 435 | lastUpdate = time.Now() 436 | } 437 | go func() { 438 | for { 439 | select { 440 | case <-ticker.C: 441 | update() 442 | case <-c: 443 | update() 444 | } 445 | } 446 | }() 447 | return out 448 | } 449 | 450 | func forwardedBaseURL(req *http.Request) string { 451 | var url url.URL 452 | url.Scheme = first(req.Header.Get("X-Forwarded-Proto"), req.URL.Scheme) 453 | 454 | var forwardedHost string 455 | if req.Header.Get("X-Forwarded-Host") != "" { 456 | forwardedHost = fmt.Sprintf("%s:%s", req.Header.Get("X-Forwarded-Host"), first(req.Header.Get("X-Forwarded-Port"), req.URL.Port(), "443")) 457 | } 458 | 459 | url.Host = first(forwardedHost, req.URL.Host) 460 | return url.String() 461 | } 462 | 463 | func first[T comparable](items ...T) T { 464 | var z T 465 | for _, item := range items { 466 | if item != z { 467 | return item 468 | } 469 | } 470 | return z 471 | } 472 | 473 | func joinPath(base string, elem ...string) string { 474 | p, _ := url.JoinPath(base, elem...) 475 | return p 476 | } 477 | 478 | type openGraphContent struct { 479 | link string 480 | width, height int 481 | } 482 | 483 | func openGraphReplacer(name string, content string, getMeta func(*http.Request) openGraphContent) http.Handler { 484 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 485 | meta := getMeta(r) 486 | replacer := strings.NewReplacer( 487 | "[[og:image]]", meta.link, 488 | "[[og:image:width]]", fmt.Sprint(meta.width), 489 | "[[og:image:height]]", fmt.Sprint(meta.height), 490 | ) 491 | replaced := replacer.Replace(content) 492 | http.ServeContent(w, r, name, time.Time{}, strings.NewReader(replaced)) 493 | }) 494 | } 495 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | //nolint:gochecknoglobals,golint,stylecheck 2 | package socr 3 | 4 | import ( 5 | _ "embed" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | //go:embed version.txt 11 | var version string 12 | var Version = fmt.Sprintf("v%s", strings.TrimSpace(version)) 13 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.8.0 2 | -------------------------------------------------------------------------------- /web/components/Badge.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /web/components/BadgeGroup.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /web/components/Home.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 39 | -------------------------------------------------------------------------------- /web/components/Importer.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 107 | -------------------------------------------------------------------------------- /web/components/LoadingModal.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /web/components/LoadingSpinner.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /web/components/Login.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 55 | -------------------------------------------------------------------------------- /web/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /web/components/MediaBackground.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 27 | -------------------------------------------------------------------------------- /web/components/MediaHighlight.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 52 | -------------------------------------------------------------------------------- /web/components/MediaLines.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | -------------------------------------------------------------------------------- /web/components/MediaPreview.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 26 | -------------------------------------------------------------------------------- /web/components/NavItem.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /web/components/NotFound.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /web/components/Public.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 53 | -------------------------------------------------------------------------------- /web/components/Search.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 211 | -------------------------------------------------------------------------------- /web/components/SearchFilter.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 59 | -------------------------------------------------------------------------------- /web/components/SearchFilterItem.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /web/components/SearchNoResults.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /web/components/SearchSidebar.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | -------------------------------------------------------------------------------- /web/components/SearchSidebarHeader.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 56 | -------------------------------------------------------------------------------- /web/components/Settings.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 18 | -------------------------------------------------------------------------------- /web/components/SettingsAbout.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /web/components/SettingsDirectories.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /web/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /web/components/ToastOverlay.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /web/components/TransitionFade.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /web/components/TransitionSlideX.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /web/components/TransitionSlideY.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /web/components/UploaderClipboard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | -------------------------------------------------------------------------------- /web/components/UploaderFile.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 41 | -------------------------------------------------------------------------------- /web/composables/useInfiniteScroll.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { useIntersectionObserver } from '@vueuse/core' 3 | 4 | export default (onNewPage: () => Promise) => { 5 | const target = ref(null) 6 | 7 | useIntersectionObserver(target, async ([{ isIntersecting }], _) => { 8 | if (!isIntersecting) return 9 | await onNewPage() 10 | }) 11 | 12 | return target 13 | } 14 | -------------------------------------------------------------------------------- /web/composables/useLoading.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | export default , U>(fn: (...args: T) => Promise) => { 4 | const loading = ref(false) 5 | const load = async (...args: T): Promise => { 6 | loading.value = true 7 | const resp = await fn(...args) 8 | loading.value = false 9 | return resp 10 | } 11 | 12 | return { loading, load } 13 | } 14 | -------------------------------------------------------------------------------- /web/composables/useStore.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'vue' 2 | import { Store, storeSymbol } from '~/store' 3 | 4 | export default () => inject(storeSymbol, {} as Store) 5 | -------------------------------------------------------------------------------- /web/dist.go: -------------------------------------------------------------------------------- 1 | //nolint:gochecknoglobals 2 | package web 3 | 4 | import ( 5 | "embed" 6 | "io/fs" 7 | ) 8 | 9 | //go:generate npm install 10 | //go:generate npm run-script build 11 | 12 | //go:embed dist 13 | var dist embed.FS 14 | var Dist, _ = fs.Sub(dist, "dist") 15 | 16 | //go:embed dist/index.html 17 | var Index []byte 18 | -------------------------------------------------------------------------------- /web/dist/assets/keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/dist/assets/keep -------------------------------------------------------------------------------- /web/dist/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/dist/index.html -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | socr 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /web/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | hr { @apply my-6; } 7 | h1 { @apply text-gray-600 text-xl font-medium leading-none; } 8 | h2 { @apply text-gray-600 text-lg font-medium leading-none; } 9 | a { @apply leading-none; } 10 | } 11 | 12 | @layer components { 13 | .padded { @apply py-1 px-4; } 14 | .box { @apply border border-solid border-gray-100 shadow-sm; } 15 | 16 | .inp { @apply appearance-none border border-gray-300 rounded py-1 px-4 text-gray-700; } 17 | .inp:focus { @apply outline-none ring; } 18 | .inp-error { @apply border-red-500; } 19 | .inp-label { @apply block text-gray-700 text-sm font-bold mb-2; } 20 | 21 | .btn { @apply block bg-blue-500 text-white py-1 px-4 rounded; } 22 | .btn:focus { @apply outline-none ring; } 23 | .btn:hover { @apply bg-blue-600; } 24 | .btn:disabled { @apply opacity-50 cursor-not-allowed; } 25 | 26 | .code { @apply font-mono bg-gray-200 text-gray-700 px-1 rounded; } 27 | 28 | .overflow-y-thin { @apply overflow-y-auto scrollbar-thin scrollbar-thumb-blue-200 hover:scrollbar-thumb-blue-300; } 29 | .overflow-x-thin { @apply overflow-x-auto scrollbar-thin scrollbar-thumb-blue-200 hover:scrollbar-thumb-blue-300; } 30 | } 31 | 32 | @layer utilities { 33 | @screen sm { .col-resp { column-count: 1; } } 34 | @screen md { .col-resp { column-count: 2; } } 35 | @screen lg { .col-resp { column-count: 3; } } 36 | @screen xl { .col-resp { column-count: 4; } } 37 | 38 | .max-w-sm { max-width: theme('screens.sm'); } 39 | .max-w-md { max-width: theme('screens.md'); } 40 | .max-w-lg { max-width: theme('screens.lg'); } 41 | .max-w-xl { max-width: theme('screens.xl'); } 42 | } 43 | 44 | @font-face { 45 | font-family: 'Inconsolata'; 46 | font-style: normal; 47 | font-weight: 500; 48 | src: local(''), 49 | url('/inconsolata-v31-latin-500.woff2') format('woff2'), 50 | url('/inconsolata-v31-latin-500.woff') format('woff'); 51 | } 52 | 53 | @font-face { 54 | font-family: 'Inconsolata'; 55 | font-style: normal; 56 | font-weight: 600; 57 | src: local(''), 58 | url('/inconsolata-v31-latin-600.woff2') format('woff2'), 59 | url('/inconsolata-v31-latin-600.woff') format('woff'); 60 | } 61 | -------------------------------------------------------------------------------- /web/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { RouterView } from 'vue-router' 3 | 4 | import router from './router' 5 | import store, { storeSymbol } from './store' 6 | import './main.css' 7 | 8 | window.onbeforeunload = () => { 9 | window.scrollTo(0, 0) 10 | } 11 | 12 | const app = createApp(RouterView) 13 | app.use(router) 14 | app.provide(storeSymbol, store) 15 | app.mount('#main') 16 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socr", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vue-tsc --noEmit && vite build", 7 | "check-types": "vue-tsc --noEmit", 8 | "check-formatting": "prettier --check $(git ls-files | grep -E '.(ts|vue|js|json)$')" 9 | }, 10 | "dependencies": { 11 | "@heroicons/vue": "^1.0.6", 12 | "@vueuse/core": "^10.1.2", 13 | "vue": "^3.3.2", 14 | "vue-router": "^4.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^20.1.3", 18 | "@vitejs/plugin-vue": "^4.2.3", 19 | "@vue/compiler-sfc": "^3.3.2", 20 | "autoprefixer": "^10.4.14", 21 | "postcss": "^8.4.23", 22 | "prettier": "^2.8.8", 23 | "prettier-plugin-tailwindcss": "^0.2.8", 24 | "tailwind-scrollbar": "^3.0.1", 25 | "tailwindcss": "^3.3.2", 26 | "typescript": "^5.0.4", 27 | "vite": "^4.3.5", 28 | "vue-tsc": "^1.6.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 175, 3 | trailingComma: 'all', 4 | semi: false, 5 | singleQuote: true, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/inconsolata-v31-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/public/inconsolata-v31-latin-500.woff -------------------------------------------------------------------------------- /web/public/inconsolata-v31-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/public/inconsolata-v31-latin-500.woff2 -------------------------------------------------------------------------------- /web/public/inconsolata-v31-latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/public/inconsolata-v31-latin-600.woff -------------------------------------------------------------------------------- /web/public/inconsolata-v31-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sentriz/socr/c2ad39f4bb121948ade7d24c0077b61e605c5f7b/web/public/inconsolata-v31-latin-600.woff2 -------------------------------------------------------------------------------- /web/request/index.ts: -------------------------------------------------------------------------------- 1 | import router, { routes } from '~/router' 2 | 3 | export const urlMedia = '/api/media' 4 | export const urlSearch = '/api/search' 5 | export const urlStartImport = '/api/start_import' 6 | export const urlAuthenticate = '/api/authenticate' 7 | export const urlSocket = '/api/websocket' 8 | export const urlAbout = '/api/about' 9 | export const urlDirectories = '/api/directories' 10 | export const urlImportStatus = '/api/import_status' 11 | export const urlPing = '/api/ping' 12 | export const urlUpload = '/api/upload' 13 | 14 | const tokenKey = 'token' 15 | export const tokenSet = (token: string) => localStorage.setItem(tokenKey, token) 16 | export const tokenGet = () => localStorage.getItem(tokenKey) || undefined 17 | export const tokenHas = () => !!localStorage.getItem(tokenKey) 18 | 19 | export type Error = { 20 | error: string 21 | } 22 | 23 | export type Success = { 24 | result: T 25 | } 26 | 27 | export type Reponse = Promise | Error> 28 | 29 | export const isError = (r: Success | Error): r is Error => (r as Error).error !== undefined 30 | 31 | type ReqMethod = 'get' | 'post' | 'put' 32 | const req = async (method: ReqMethod, url: string, data?: P): Reponse => { 33 | const token = tokenGet() 34 | 35 | let headers: HeadersInit = {} 36 | if (token) headers.authorization = `bearer ${token}` 37 | 38 | let body: BodyInit = '' 39 | if (data instanceof FormData) body = data 40 | else body = JSON.stringify(data) 41 | 42 | const response = await fetch(url, { method, body, headers }) 43 | if (response?.status === 401) { 44 | router.push({ name: routes.LOGIN }) 45 | } 46 | 47 | try { 48 | return await response.json() 49 | } catch (e) { 50 | return { error: 'invalid JSON returned from server' } 51 | } 52 | } 53 | 54 | export enum SortOrder { 55 | Asc = 'asc', 56 | Desc = 'desc', 57 | } 58 | 59 | export type PayloadSort = { 60 | field: string 61 | order: SortOrder 62 | } 63 | 64 | export type PayloadSearch = { 65 | body: string 66 | limit: number 67 | offset: number 68 | sort: PayloadSort 69 | directory?: string 70 | media?: MediaType 71 | date_from?: Date 72 | date_to?: Date 73 | } 74 | 75 | export const reqSearch = (data: PayloadSearch) => { 76 | return req('post', urlSearch, data) 77 | } 78 | 79 | export type PayloadAuthenticate = { 80 | username: string 81 | password: string 82 | } 83 | 84 | export const reqAuthenticate = (data: PayloadAuthenticate) => { 85 | return req('put', urlAuthenticate, data) 86 | } 87 | 88 | export const reqStartImport = () => { 89 | return req<{}, StartImport>('post', urlStartImport) 90 | } 91 | 92 | export const reqMedia = (id: string) => { 93 | return req<{}, Media>('get', `${urlMedia}/${id}`) 94 | } 95 | 96 | export const reqAbout = () => { 97 | return req<{}, About>('get', urlAbout) 98 | } 99 | 100 | export const reqDirectories = () => { 101 | return req<{}, Directory[]>('get', urlDirectories) 102 | } 103 | 104 | export const reqImportStatus = () => { 105 | return req<{}, ImportStatus>('get', urlImportStatus) 106 | } 107 | 108 | export const reqPing = () => { 109 | return req<{}, {}>('get', urlPing) 110 | } 111 | 112 | export const reqUpload = (data: FormData) => { 113 | return req('post', urlUpload, data) 114 | } 115 | 116 | const socketGuesses: { [key: string]: string } = { 117 | 'https:': 'wss:', 118 | 'http:': 'ws:', 119 | } 120 | 121 | const socketProtocol = socketGuesses[window.location.protocol] 122 | const socketHost = window.location.host 123 | 124 | type SocketParams = { 125 | want_settings?: 0 | 1 126 | want_media_hash?: string 127 | token?: string 128 | } 129 | 130 | export const newSocketAuth = (params: SocketParams) => newSocket({ ...params, token: tokenGet() }) 131 | export const newSocket = (params: SocketParams) => { 132 | // @ts-ignore 133 | const paramsEnc = new URLSearchParams(params) 134 | return new WebSocket(`${socketProtocol}//${socketHost}${urlSocket}?${paramsEnc}`) 135 | } 136 | 137 | type ID = number & { __kind: U } 138 | 139 | export type BlockID = ID<'Block ID'> 140 | export type Block = { 141 | id: BlockID 142 | media_id: MediaID 143 | index: number 144 | min_x: number 145 | min_y: number 146 | max_x: number 147 | max_y: number 148 | body: string 149 | } 150 | 151 | export enum MediaType { 152 | Image = 'image', 153 | Video = 'video', 154 | } 155 | 156 | export type MediaID = ID<'Media ID'> 157 | export type Media = { 158 | id: MediaID 159 | type: MediaType 160 | mime: string 161 | hash: string 162 | timestamp: any 163 | dim_width: number 164 | dim_height: number 165 | dominant_colour: string 166 | blurhash: string 167 | blocks?: Block[] 168 | highlighted_blocks?: Block[] 169 | directories?: string[] 170 | processed: boolean 171 | } 172 | 173 | export type Similarity = { 174 | similarity: number 175 | } 176 | 177 | export type Search = { 178 | medias?: (Media & Similarity)[] 179 | took: number 180 | } 181 | 182 | export type Authenticate = { 183 | token: string 184 | } 185 | 186 | export type StartImport = {} 187 | 188 | export type About = { [key: string]: number | string } 189 | 190 | export type Directory = { 191 | directory_alias: string 192 | count: number 193 | is_uploads?: boolean 194 | } 195 | 196 | export type ImportStatus = { 197 | errors: { 198 | error: string 199 | time: string 200 | }[] 201 | running: boolean 202 | last_hash: string 203 | count_total: number 204 | count_processed: number 205 | } 206 | 207 | export type Upload = { 208 | id: MediaID 209 | } 210 | -------------------------------------------------------------------------------- /web/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, NavigationGuard, RouteRecordRaw } from 'vue-router' 2 | 3 | import Search from '~/components/Search.vue' 4 | import Settings from '~/components/Settings.vue' 5 | import Importer from '~/components/Importer.vue' 6 | import Login from '~/components/Login.vue' 7 | import Home from '~/components/Home.vue' 8 | import Public from '~/components/Public.vue' 9 | import NotFound from '~/components/NotFound.vue' 10 | 11 | import { tokenHas, tokenSet } from '~/request' 12 | 13 | export const routes = { 14 | LOGIN: Symbol(), 15 | LOGOUT: Symbol(), 16 | HOME: Symbol(), 17 | SEARCH: Symbol(), 18 | IMPORTER: Symbol(), 19 | SETTINGS: Symbol(), 20 | PUBLIC: Symbol(), 21 | NOT_FOUND: Symbol(), 22 | } as const 23 | 24 | const beforeCheckAuth: NavigationGuard = (to, _, next) => { 25 | if (tokenHas()) return next() 26 | next({ name: routes.LOGIN, query: { redirect: to.fullPath } }) 27 | } 28 | 29 | const beforeLogout: NavigationGuard = (_, __, next) => { 30 | tokenSet('') 31 | next({ name: routes.LOGIN }) 32 | } 33 | 34 | const records: RouteRecordRaw[] = [ 35 | { 36 | path: '/login', 37 | name: routes.LOGIN, 38 | component: Login, 39 | }, 40 | { 41 | path: '/logout', 42 | name: routes.LOGOUT, 43 | beforeEnter: beforeLogout, 44 | redirect: '', 45 | }, 46 | { 47 | path: '/', 48 | name: routes.HOME, 49 | component: Home, 50 | redirect: 'search', 51 | beforeEnter: beforeCheckAuth, 52 | children: [ 53 | { 54 | path: 'search/:hash?', 55 | name: routes.SEARCH, 56 | component: Search, 57 | }, 58 | { 59 | path: 'importer', 60 | name: routes.IMPORTER, 61 | component: Importer, 62 | }, 63 | { 64 | path: 'settings', 65 | name: routes.SETTINGS, 66 | component: Settings, 67 | }, 68 | { 69 | path: '', 70 | name: Symbol(), 71 | redirect: { name: routes.SEARCH }, 72 | }, 73 | ], 74 | }, 75 | { 76 | path: '/i/:hash', 77 | name: routes.PUBLIC, 78 | component: Public, 79 | }, 80 | { 81 | path: '/not-found', 82 | name: routes.NOT_FOUND, 83 | component: NotFound, 84 | }, 85 | { 86 | path: '/:catchAll(.*)', 87 | redirect: { name: routes.NOT_FOUND }, 88 | }, 89 | ] 90 | 91 | export default createRouter({ 92 | history: createWebHistory(), 93 | routes: records, 94 | }) 95 | -------------------------------------------------------------------------------- /web/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { DefineComponent } from 'vue' 3 | const component: DefineComponent<{}, {}, any> 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /web/store/index.ts: -------------------------------------------------------------------------------- 1 | import { reactive, readonly, InjectionKey } from 'vue' 2 | import { reqSearch, reqMedia, isError, Block } from '~/request' 3 | import type { Reponse, Media, Search, PayloadSearch } from '~/request' 4 | 5 | const mediasLoadState = async (state: State, resp: Media[]) => { 6 | for (const media of resp) { 7 | if (media.blocks) { 8 | state.blocks.set(media.hash, media.blocks) 9 | delete media.blocks 10 | } 11 | if (media.highlighted_blocks) { 12 | state.highlighted_blocks.set(media.hash, media.highlighted_blocks) 13 | delete media.highlighted_blocks 14 | } 15 | state.medias.set(media.hash, media) 16 | } 17 | } 18 | 19 | export type State = { 20 | medias: Map 21 | blocks: Map 22 | highlighted_blocks: Map 23 | toast: string 24 | } 25 | 26 | const createStore = () => { 27 | const state = reactive({ 28 | medias: new Map(), 29 | blocks: new Map(), 30 | highlighted_blocks: new Map(), 31 | toast: '', 32 | }) 33 | 34 | return { 35 | state: readonly(state), 36 | async loadMedias(payload: PayloadSearch): Reponse { 37 | const resp = await reqSearch(payload) 38 | if (isError(resp)) return resp 39 | if (!resp.result.medias?.length) return resp 40 | mediasLoadState(state, resp.result.medias) 41 | return resp 42 | }, 43 | async loadMedia(hash: string): Reponse { 44 | const resp = await reqMedia(hash) 45 | if (isError(resp)) return resp 46 | if (!resp.result) return resp 47 | mediasLoadState(state, [resp.result]) 48 | return resp 49 | }, 50 | getMediaByHash(hash: string) { 51 | return state.medias.get(hash) 52 | }, 53 | getBlocksByHash(hash: string) { 54 | return state.blocks.get(hash) || [] 55 | }, 56 | getHighlightedBlocksByHash(hash: string) { 57 | return state.highlighted_blocks.get(hash) || [] 58 | }, 59 | setToast(toast: string) { 60 | state.toast = toast 61 | setTimeout(() => (state.toast = ''), 1500) 62 | }, 63 | } 64 | } 65 | 66 | export default createStore() 67 | export type Store = ReturnType 68 | export const storeSymbol: InjectionKey = Symbol('store') 69 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const scrollbar = require('tailwind-scrollbar') 2 | const colors = require('tailwindcss/colors') 3 | 4 | module.exports = { 5 | content: ['index.html', './components/**/*.vue'], 6 | theme: { 7 | fontFamily: { 8 | sans: ['Inconsolata', 'sans-serif'], 9 | serif: ['serif'], 10 | mono: ['monospace'], 11 | }, 12 | screens: { 13 | sm: '640px', 14 | md: '768px', 15 | lg: '1024px', 16 | xl: '1280px', 17 | }, 18 | extend: { 19 | minHeight: { 20 | 32: '8rem', 21 | 36: '9rem', 22 | 40: '10rem', 23 | }, 24 | colors: { 25 | green: colors.emerald, 26 | yellow: colors.amber, 27 | purple: colors.violet, 28 | gray: colors.neutral, 29 | }, 30 | }, 31 | }, 32 | plugins: [scrollbar], 33 | } 34 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "lib": ["esnext", "dom"], 12 | "types": ["vite/client"], 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "skipLibCheck": true, 17 | "paths": { 18 | "~/*": ["./*"] 19 | } 20 | }, 21 | "include": ["**/*"], 22 | "exclude": ["node_modules", "public", "dist"] 23 | } 24 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | import { defineConfig } from 'vite' 3 | import path from 'path' 4 | 5 | const listenHost = process.env['VITE_DEV_SERVER_LISTEN_HOST'] || '0.0.0.0' 6 | const listenPort = parseInt(process.env['VITE_DEV_SERVER_LISTEN_PORT'] || '') || 8080 7 | 8 | export default defineConfig({ 9 | plugins: [vue()], 10 | server: { 11 | host: listenHost, 12 | port: listenPort, 13 | strictPort: true, 14 | }, 15 | resolve: { 16 | alias: { 17 | '~': path.resolve(__dirname, './'), 18 | }, 19 | }, 20 | }) 21 | --------------------------------------------------------------------------------